Probe raw save company and chairman header counts

This commit is contained in:
Jan Petykiewicz 2026-04-17 14:00:32 -07:00
commit 0fbe03e470
5 changed files with 412 additions and 19 deletions

View file

@ -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

View file

@ -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;

View file

@ -1479,6 +1479,27 @@ pub struct SmpSaveWorldSelectionContextProbe {
pub evidence: Vec<String>,
}
#[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<u32>,
pub header_hex_words: Vec<String>,
pub evidence: Vec<String>,
}
#[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<SmpRt3105PostSpanBridgeProbe>,
pub rt3_105_save_bridge_payload_probe: Option<SmpRt3105SaveBridgePayloadProbe>,
pub save_world_selection_context_probe: Option<SmpSaveWorldSelectionContextProbe>,
pub save_company_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
pub save_chairman_profile_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
pub rt3_105_save_name_table_probe: Option<SmpRt3105SaveNameTableProbe>,
pub rt3_105_save_named_locomotive_availability_probe:
Option<SmpRt3105SaveNamedLocomotiveAvailabilityProbe>,
@ -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<SmpLoadedCompanyRoster> {
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<SmpLoadedChairmanProfileTable> {
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<String>) -> 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<String>) -> 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<SmpSaveTaggedCollectionHeaderProbe> {
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<SmpSaveTaggedCollectionHeaderProbe> {
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<String>,
) -> Option<SmpSaveTaggedCollectionHeaderProbe> {
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::<Option<Vec<_>>>()?;
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]

View file

@ -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`

View file

@ -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`