From 0fbe03e470342ed22cdc450a738eb1b1b9b70073 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 17 Apr 2026 14:00:32 -0700 Subject: [PATCH] Probe raw save company and chairman header counts --- README.md | 5 +- crates/rrt-runtime/src/lib.rs | 6 +- crates/rrt-runtime/src/smp.rs | 402 +++++++++++++++++++++++++++++++++- docs/README.md | 7 +- docs/runtime-rehost-plan.md | 11 +- 5 files changed, 412 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bed75fa..ee57a7e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,10 @@ Those raw selected ids can flow through save-slice export/import and override ov selection even while the full raw rosters remain absent, and a tracked overlay fixture now pins that selection-only override path explicitly. The same fixed block now also exports the grounded campaign override byte plus the raw chairman slot selector and role-gate bytes as analysis-only -save fields. A checked-in +save fields. Raw `.gms` inspection now also lifts the save-side tagged header counts for the +company and chairman/profile collections into `observed_entry_count`, so save-slice exports carry +header-level roster counts alongside the selected ids even while per-entry payload remains +unreconstructed. A checked-in `EventEffects` export now exists too in `artifacts/exports/rt3-1.06/event-effects-table.json`, and a checked-in semantic closure layer now exists beside it in `artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json`. Recovered diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index d60c8d9..28f64b2 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -82,9 +82,9 @@ pub use smp::{ SmpRt3105SaveBridgePayloadProbe, SmpRt3105SaveNameTableEntry, SmpRt3105SaveNameTableProbe, SmpRuntimeAnchorCycleBlock, SmpRuntimePostSpanHeaderCandidate, SmpRuntimePostSpanProbe, SmpRuntimeTrailerBlock, SmpSaveAnchorRunBlock, SmpSaveBootstrapBlock, - SmpSaveLoadCandidateTableSummary, SmpSaveLoadSummary, SmpSecondaryVariantProbe, - SmpSharedHeader, SmpSpecialConditionEntry, SmpSpecialConditionsProbe, inspect_smp_bytes, - inspect_smp_file, load_save_slice_file, load_save_slice_from_report, + SmpSaveLoadCandidateTableSummary, SmpSaveLoadSummary, SmpSaveTaggedCollectionHeaderProbe, + SmpSecondaryVariantProbe, SmpSharedHeader, SmpSpecialConditionEntry, SmpSpecialConditionsProbe, + inspect_smp_bytes, inspect_smp_file, load_save_slice_file, load_save_slice_from_report, }; pub use step::{BoundaryEvent, ServiceEvent, StepCommand, StepResult, execute_step_command}; pub use summary::RuntimeSummary; diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index e4f8afb..7c2b148 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -1479,6 +1479,27 @@ pub struct SmpSaveWorldSelectionContextProbe { pub evidence: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpSaveTaggedCollectionHeaderProbe { + pub profile_family: String, + pub source_kind: String, + pub semantic_family: String, + pub metadata_tag_offset: usize, + pub records_tag_offset: usize, + pub close_tag_offset: usize, + pub direct_collection_flag: u32, + pub direct_collection_flag_hex: String, + pub direct_record_stride: u32, + pub direct_record_stride_hex: String, + pub live_id_bound: u32, + pub live_id_bound_hex: String, + pub live_record_count: u32, + pub live_record_count_hex: String, + pub header_words: Vec, + pub header_hex_words: Vec, + pub evidence: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpRt3105SaveNameTableProbe { pub profile_family: String, @@ -2398,6 +2419,8 @@ pub struct SmpInspectionReport { pub rt3_105_post_span_bridge_probe: Option, pub rt3_105_save_bridge_payload_probe: Option, pub save_world_selection_context_probe: Option, + pub save_company_collection_header_probe: Option, + pub save_chairman_profile_collection_header_probe: Option, pub rt3_105_save_name_table_probe: Option, pub rt3_105_save_named_locomotive_availability_probe: Option, @@ -2536,11 +2559,24 @@ pub fn load_save_slice_from_report( let company_roster = report .save_world_selection_context_probe .as_ref() - .and_then(derive_selection_only_company_roster_from_save_world_probe); - let chairman_profile_table = report - .save_world_selection_context_probe - .as_ref() - .and_then(derive_selection_only_chairman_profile_table_from_save_world_probe); + .and_then(|probe| { + derive_selection_only_company_roster_from_save_world_probe( + probe, + report.save_company_collection_header_probe.as_ref(), + ) + }); + let chairman_profile_table = + report + .save_world_selection_context_probe + .as_ref() + .and_then(|probe| { + derive_selection_only_chairman_profile_table_from_save_world_probe( + probe, + report + .save_chairman_profile_collection_header_probe + .as_ref(), + ) + }); let special_conditions_table = report .special_conditions_probe @@ -2575,6 +2611,26 @@ pub fn load_save_slice_from_report( .to_string(), ); } + if let Some(probe) = &report.save_company_collection_header_probe { + notes.push(format!( + "Raw save tagged company header reports live_record_count={} and live_id_bound={} at file offsets 0x{:x}/0x{:x}/0x{:x}.", + probe.live_record_count, + probe.live_id_bound, + probe.metadata_tag_offset, + probe.records_tag_offset, + probe.close_tag_offset + )); + } + if let Some(probe) = &report.save_chairman_profile_collection_header_probe { + notes.push(format!( + "Raw save tagged chairman/profile header reports live_record_count={} and live_id_bound={} at file offsets 0x{:x}/0x{:x}/0x{:x}.", + probe.live_record_count, + probe.live_id_bound, + probe.metadata_tag_offset, + probe.records_tag_offset, + probe.close_tag_offset + )); + } Ok(SmpLoadedSaveSlice { file_extension_hint: summary.file_extension_hint.clone(), @@ -2677,11 +2733,14 @@ fn derive_cargo_catalog_from_recipe_book_probe( fn derive_selection_only_company_roster_from_save_world_probe( probe: &SmpSaveWorldSelectionContextProbe, + header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>, ) -> Option { Some(SmpLoadedCompanyRoster { source_kind: format!("{}-company-selection-only", probe.source_kind), semantic_family: "scenario-selected-company-context".to_string(), - observed_entry_count: 0, + observed_entry_count: header_probe + .map(|probe| probe.live_record_count as usize) + .unwrap_or(0), selected_company_id: Some(probe.selected_company_id), entries: Vec::new(), }) @@ -2689,11 +2748,14 @@ fn derive_selection_only_company_roster_from_save_world_probe( fn derive_selection_only_chairman_profile_table_from_save_world_probe( probe: &SmpSaveWorldSelectionContextProbe, + header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>, ) -> Option { Some(SmpLoadedChairmanProfileTable { source_kind: format!("{}-chairman-selection-only", probe.source_kind), semantic_family: "scenario-selected-chairman-context".to_string(), - observed_entry_count: 0, + observed_entry_count: header_probe + .map(|probe| probe.live_record_count as usize) + .unwrap_or(0), selected_chairman_profile_id: Some(probe.selected_chairman_profile_id), entries: Vec::new(), }) @@ -5420,6 +5482,17 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm file_extension_hint.as_deref(), container_profile.as_ref(), ); + let save_company_collection_header_probe = parse_save_company_collection_header_probe( + bytes, + file_extension_hint.as_deref(), + container_profile.as_ref(), + ); + let save_chairman_profile_collection_header_probe = + parse_save_chairman_profile_collection_header_probe( + bytes, + file_extension_hint.as_deref(), + container_profile.as_ref(), + ); let rt3_105_save_name_table_probe = parse_rt3_105_save_name_table_probe( bytes, file_extension_hint.as_deref(), @@ -5567,6 +5640,8 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm rt3_105_post_span_bridge_probe, rt3_105_save_bridge_payload_probe, save_world_selection_context_probe, + save_company_collection_header_probe, + save_chairman_profile_collection_header_probe, rt3_105_save_name_table_probe, rt3_105_save_named_locomotive_availability_probe, special_conditions_probe, @@ -7094,6 +7169,177 @@ fn parse_save_world_selection_context_probe( None } +fn parse_save_company_collection_header_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + container_profile: Option<&SmpContainerProfile>, +) -> Option { + parse_save_tagged_collection_header_probe( + bytes, + file_extension_hint, + container_profile, + 0x000061a9, + 0x000061aa, + 0x000061ab, + "save-company-tagged-header-counts", + "scenario-save-company-header-counts", + |header| { + header.direct_collection_flag == 1 + && header.live_id_bound >= 1 + && header.live_id_bound <= 0x20 + && header.live_record_count <= header.live_id_bound + && header.direct_record_stride >= 0x1000 + }, + vec![ + "save-side company collection uses tagged header family 0x61a9/0x61aa/0x61ab".to_string(), + "package-save per-company callback is currently grounded as a no-op stub, so this probe only claims header-level collection counts, not per-company payload".to_string(), + ], + ) +} + +fn parse_save_chairman_profile_collection_header_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + container_profile: Option<&SmpContainerProfile>, +) -> Option { + parse_save_tagged_collection_header_probe( + bytes, + file_extension_hint, + container_profile, + 0x00005209, + 0x0000520a, + 0x0000520b, + "save-chairman-profile-tagged-header-counts", + "scenario-save-chairman-profile-header-counts", + |header| { + header.direct_collection_flag == 1 + && header.live_id_bound >= 1 + && header.live_id_bound <= 0x80 + && header.live_record_count <= header.live_id_bound + && header.direct_record_stride >= 0x100 + && header.direct_record_stride <= 0x400 + }, + vec![ + "save-side chairman/profile collection uses tagged header family 0x5209/0x520a/0x520b".to_string(), + "current grounded claim is header-only: active/live record counts are save-native, but per-profile payload is not yet reconstructed from raw save".to_string(), + ], + ) +} + +#[derive(Clone, Copy)] +struct IndexedCollectionHeaderSummary { + metadata_tag_offset: usize, + records_tag_offset: usize, + close_tag_offset: usize, + direct_collection_flag: u32, + direct_record_stride: u32, + live_id_bound: u32, + live_record_count: u32, + header_words: [u32; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT], +} + +fn parse_save_tagged_collection_header_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + container_profile: Option<&SmpContainerProfile>, + metadata_tag: u32, + records_tag: u32, + close_tag: u32, + source_kind: &str, + semantic_family: &str, + predicate: impl Fn(IndexedCollectionHeaderSummary) -> bool, + mut evidence: Vec, +) -> Option { + if file_extension_hint != Some("gms") { + return None; + } + let profile = container_profile?; + if !matches!( + profile.profile_family.as_str(), + "rt3-classic-save-container-v1" + | "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + ) { + return None; + } + + let metadata_offsets = find_u32_le_offsets(bytes, metadata_tag); + let records_offsets = find_u32_le_offsets(bytes, records_tag); + let close_offsets = find_u32_le_offsets(bytes, close_tag); + + let summary = metadata_offsets + .into_iter() + .filter_map(|metadata_tag_offset| { + let records_tag_offset = records_offsets + .iter() + .copied() + .find(|offset| *offset > metadata_tag_offset)?; + let close_tag_offset = close_offsets + .iter() + .copied() + .find(|offset| *offset > records_tag_offset)?; + let payload = bytes.get(metadata_tag_offset + 4..records_tag_offset)?; + if payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN { + return None; + } + + let header_words = (0..INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT) + .map(|index| read_u32_at(payload, index * 4)) + .collect::>>()?; + let header_words: [u32; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT] = + header_words.try_into().ok()?; + let summary = IndexedCollectionHeaderSummary { + metadata_tag_offset, + records_tag_offset, + close_tag_offset, + direct_collection_flag: header_words[0], + direct_record_stride: header_words[1], + live_id_bound: header_words[4], + live_record_count: header_words[5], + header_words, + }; + predicate(summary).then_some(summary) + }) + .next()?; + + evidence.push(format!( + "exact little-endian u32 tag family 0x{metadata_tag:04x}/0x{records_tag:04x}/0x{close_tag:04x} appears at file offsets 0x{:x}/0x{:x}/0x{:x}", + summary.metadata_tag_offset, summary.records_tag_offset, summary.close_tag_offset + )); + evidence.push(format!( + "header words report direct_collection_flag={}, direct_record_stride=0x{:x}, live_id_bound={}, live_record_count={}", + summary.direct_collection_flag, + summary.direct_record_stride, + summary.live_id_bound, + summary.live_record_count + )); + + Some(SmpSaveTaggedCollectionHeaderProbe { + profile_family: profile.profile_family.clone(), + source_kind: source_kind.to_string(), + semantic_family: semantic_family.to_string(), + metadata_tag_offset: summary.metadata_tag_offset, + records_tag_offset: summary.records_tag_offset, + close_tag_offset: summary.close_tag_offset, + direct_collection_flag: summary.direct_collection_flag, + direct_collection_flag_hex: format!("0x{:08x}", summary.direct_collection_flag), + direct_record_stride: summary.direct_record_stride, + direct_record_stride_hex: format!("0x{:08x}", summary.direct_record_stride), + live_id_bound: summary.live_id_bound, + live_id_bound_hex: format!("0x{:08x}", summary.live_id_bound), + live_record_count: summary.live_record_count, + live_record_count_hex: format!("0x{:08x}", summary.live_record_count), + header_words: summary.header_words.to_vec(), + header_hex_words: summary + .header_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + evidence, + }) +} + fn parse_rt3_105_save_name_table_probe( bytes: &[u8], file_extension_hint: Option<&str>, @@ -13453,18 +13699,71 @@ mod tests { chairman_role_gate_bytes: vec![2, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], evidence: vec![], }); + report.save_company_collection_header_probe = Some(SmpSaveTaggedCollectionHeaderProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + source_kind: "save-company-tagged-header-counts".to_string(), + semantic_family: "scenario-save-company-header-counts".to_string(), + metadata_tag_offset: 0x1000, + records_tag_offset: 0x1100, + close_tag_offset: 0x1200, + direct_collection_flag: 1, + direct_collection_flag_hex: "0x00000001".to_string(), + direct_record_stride: 0x7684, + direct_record_stride_hex: "0x00007684".to_string(), + live_id_bound: 5, + live_id_bound_hex: "0x00000005".to_string(), + live_record_count: 1, + live_record_count_hex: "0x00000001".to_string(), + header_words: vec![1, 0x7684, 5, 5, 5, 1], + header_hex_words: vec![ + "0x00000001".to_string(), + "0x00007684".to_string(), + "0x00000005".to_string(), + "0x00000005".to_string(), + "0x00000005".to_string(), + "0x00000001".to_string(), + ], + evidence: vec![], + }); + report.save_chairman_profile_collection_header_probe = + Some(SmpSaveTaggedCollectionHeaderProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + source_kind: "save-chairman-profile-tagged-header-counts".to_string(), + semantic_family: "scenario-save-chairman-profile-header-counts".to_string(), + metadata_tag_offset: 0x2000, + records_tag_offset: 0x2100, + close_tag_offset: 0x2200, + direct_collection_flag: 1, + direct_collection_flag_hex: "0x00000001".to_string(), + direct_record_stride: 0x1d5, + direct_record_stride_hex: "0x000001d5".to_string(), + live_id_bound: 0x32, + live_id_bound_hex: "0x00000032".to_string(), + live_record_count: 2, + live_record_count_hex: "0x00000002".to_string(), + header_words: vec![1, 0x1d5, 0x32, 0x14, 0x32, 2], + header_hex_words: vec![ + "0x00000001".to_string(), + "0x000001d5".to_string(), + "0x00000032".to_string(), + "0x00000014".to_string(), + "0x00000032".to_string(), + "0x00000002".to_string(), + ], + evidence: vec![], + }); let slice = load_save_slice_from_report(&report).expect("save slice"); let company_roster = slice.company_roster.expect("selection-only company roster"); - assert_eq!(company_roster.observed_entry_count, 0); + assert_eq!(company_roster.observed_entry_count, 1); assert_eq!(company_roster.selected_company_id, Some(1)); assert!(company_roster.entries.is_empty()); let chairman_table = slice .chairman_profile_table .expect("selection-only chairman table"); - assert_eq!(chairman_table.observed_entry_count, 0); + assert_eq!(chairman_table.observed_entry_count, 2); assert_eq!(chairman_table.selected_chairman_profile_id, Some(9)); assert!(chairman_table.entries.is_empty()); @@ -13486,6 +13785,91 @@ mod tests { .iter() .any(|note| note.contains("campaign_override_flag=1")) ); + assert!( + slice + .notes + .iter() + .any(|note| note.contains("tagged company header reports live_record_count=1")) + ); + assert!(slice.notes.iter().any(|note| { + note.contains("tagged chairman/profile header reports live_record_count=2") + })); + } + + #[test] + fn parses_company_tagged_collection_header_probe_from_exact_u32_tags() { + let mut bytes = vec![0u8; 0x400]; + let metadata_tag_offset = 0x40usize; + let records_tag_offset = 0x140usize; + let close_tag_offset = 0x180usize; + bytes[metadata_tag_offset..metadata_tag_offset + 4] + .copy_from_slice(&0x000061a9u32.to_le_bytes()); + bytes[records_tag_offset..records_tag_offset + 4] + .copy_from_slice(&0x000061aau32.to_le_bytes()); + bytes[close_tag_offset..close_tag_offset + 4].copy_from_slice(&0x000061abu32.to_le_bytes()); + let header_words = [ + 1u32, 0x7684, 5, 5, 5, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + ]; + for (index, word) in header_words.into_iter().enumerate() { + let offset = metadata_tag_offset + 4 + index * 4; + bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes()); + } + + let probe = parse_save_company_collection_header_probe( + &bytes, + Some("gms"), + Some(&SmpContainerProfile { + profile_family: "rt3-105-save-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + ) + .expect("company header probe should parse"); + + assert_eq!(probe.metadata_tag_offset, metadata_tag_offset); + assert_eq!(probe.records_tag_offset, records_tag_offset); + assert_eq!(probe.close_tag_offset, close_tag_offset); + assert_eq!(probe.direct_record_stride, 0x7684); + assert_eq!(probe.live_id_bound, 5); + assert_eq!(probe.live_record_count, 1); + } + + #[test] + fn parses_chairman_profile_tagged_collection_header_probe_from_exact_u32_tags() { + let mut bytes = vec![0u8; 0x400]; + let metadata_tag_offset = 0x40usize; + let records_tag_offset = 0x140usize; + let close_tag_offset = 0x180usize; + bytes[metadata_tag_offset..metadata_tag_offset + 4] + .copy_from_slice(&0x00005209u32.to_le_bytes()); + bytes[records_tag_offset..records_tag_offset + 4] + .copy_from_slice(&0x0000520au32.to_le_bytes()); + bytes[close_tag_offset..close_tag_offset + 4].copy_from_slice(&0x0000520bu32.to_le_bytes()); + let header_words = [ + 1u32, 0x1d5, 0x32, 0x14, 0x32, 2, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + ]; + for (index, word) in header_words.into_iter().enumerate() { + let offset = metadata_tag_offset + 4 + index * 4; + bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes()); + } + + let probe = parse_save_chairman_profile_collection_header_probe( + &bytes, + Some("gms"), + Some(&SmpContainerProfile { + profile_family: "rt3-105-save-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + ) + .expect("chairman profile header probe should parse"); + + assert_eq!(probe.metadata_tag_offset, metadata_tag_offset); + assert_eq!(probe.records_tag_offset, records_tag_offset); + assert_eq!(probe.close_tag_offset, close_tag_offset); + assert_eq!(probe.direct_record_stride, 0x1d5); + assert_eq!(probe.live_id_bound, 0x32); + assert_eq!(probe.live_record_count, 2); } #[test] diff --git a/docs/README.md b/docs/README.md index c5a2cce..4424689 100644 --- a/docs/README.md +++ b/docs/README.md @@ -100,8 +100,11 @@ The highest-value next passes are now: but it now does reconstruct selection-only company/chairman context from the fixed save-side `0x32c8` world block, so overlay imports can reuse base rosters while honoring raw save-native selected company/chairman ids, and a tracked overlay fixture now pins that selection-only - override path explicitly; the same fixed block now also exports the grounded campaign override - byte plus the raw chairman slot selector and role-gate bytes as analysis-only save fields + override path explicitly; raw `.gms` inspection now also lifts the save-side tagged collection + header counts for the company and chairman/profile families into `observed_entry_count`, so + save-slice exports carry header-level roster counts even while per-entry payload remains absent; + the same fixed block now also exports the grounded campaign override byte plus the raw chairman + slot selector and role-gate bytes as analysis-only save fields - a checked-in `EventEffects` export now exists at `artifacts/exports/rt3-1.06/event-effects-table.json`, and a checked-in semantic closure layer now exists at `artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json` diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 1f984f2..14d1b1d 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -61,10 +61,13 @@ Implemented today: without overlay snapshots when the checked-in documents include that context, while raw `.gms` inspection/export still leaves full company/chairman rosters absent; the grounded raw-save tranche now covers only selection-only company/chairman context from the fixed `0x32c8` world - block, which overlay import can use to replace selected ids while preserving base rosters; that - same fixed block now also exports the grounded campaign override byte plus the raw chairman slot - selector and role-gate bytes as analysis-only fields, and a tracked overlay fixture now pins the - selection-only override path explicitly + block, which overlay import can use to replace selected ids while preserving base rosters; raw + save inspection now also lifts the save-side tagged company and chairman/profile collection + header counts into `observed_entry_count`, so save-slice exports carry header-level roster + counts even though per-entry payload still remains absent; that same fixed block now also + exports the grounded campaign override byte plus the raw chairman slot selector and role-gate + bytes as analysis-only fields, and a tracked overlay fixture now pins the selection-only + override path explicitly - a checked-in `EventEffects` export now exists too at `artifacts/exports/rt3-1.06/event-effects-table.json`, and a checked-in semantic closure layer now exists at `artifacts/exports/rt3-1.06/event-effects-semantic-catalog.json`