Expose save-side region record triplets

This commit is contained in:
Jan Petykiewicz 2026-04-18 08:35:37 -07:00
commit 8861074c1b
2 changed files with 222 additions and 4 deletions

View file

@ -152,6 +152,9 @@ const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize =
INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4; INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4;
const SAVE_REGION_COLLECTION_DIRECTORY_ROOT_DWORD_INDEX: usize = 16; const SAVE_REGION_COLLECTION_DIRECTORY_ROOT_DWORD_INDEX: usize = 16;
const SAVE_REGION_COLLECTION_DIRECTORY_ENTRY_DWORD_COUNT: usize = 3; const SAVE_REGION_COLLECTION_DIRECTORY_ENTRY_DWORD_COUNT: usize = 3;
const SAVE_REGION_RECORD_NAME_TAG: u16 = 0x55f1;
const SAVE_REGION_RECORD_POLICY_TAG: u16 = 0x55f2;
const SAVE_REGION_RECORD_PROFILE_TAG: u16 = 0x55f3;
const PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC: &[u8; 8] = b"RPEVT001"; const PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC: &[u8; 8] = b"RPEVT001";
const PACKED_EVENT_RECORD_SYNTHETIC_MAGIC: &[u8; 4] = b"RPE1"; const PACKED_EVENT_RECORD_SYNTHETIC_MAGIC: &[u8; 4] = b"RPE1";
const PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC: &[u8; 4] = b"RPT1"; const PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC: &[u8; 4] = b"RPT1";
@ -1645,6 +1648,30 @@ pub struct SmpSaveTrainCollectionDirectoryProbe {
pub evidence: Vec<String>, pub evidence: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpSaveRegionRecordTripletEntryProbe {
pub record_index: usize,
pub name: String,
pub name_tag_relative_offset: usize,
pub policy_tag_relative_offset: usize,
pub profile_tag_relative_offset: usize,
pub policy_chunk_len: usize,
pub profile_chunk_len: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpSaveRegionRecordTripletProbe {
pub profile_family: String,
pub source_kind: String,
pub semantic_family: String,
pub records_tag_offset: usize,
pub close_tag_offset: usize,
pub record_count: usize,
#[serde(default)]
pub entries: Vec<SmpSaveRegionRecordTripletEntryProbe>,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpRt3105SaveNameTableProbe { pub struct SmpRt3105SaveNameTableProbe {
pub profile_family: String, pub profile_family: String,
@ -2585,6 +2612,8 @@ pub struct SmpSaveCompanyChairmanAnalysisReport {
#[serde(default)] #[serde(default)]
pub region_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>, pub region_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
#[serde(default)] #[serde(default)]
pub region_record_triplets: Option<SmpSaveRegionRecordTripletProbe>,
#[serde(default)]
pub placed_structure_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>, pub placed_structure_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
#[serde(default)] #[serde(default)]
pub company_entries: Vec<SmpSaveCompanyRecordAnalysisEntry>, pub company_entries: Vec<SmpSaveCompanyRecordAnalysisEntry>,
@ -2848,6 +2877,7 @@ pub struct SmpInspectionReport {
pub save_train_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>, pub save_train_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
pub save_train_collection_directory_probe: Option<SmpSaveTrainCollectionDirectoryProbe>, pub save_train_collection_directory_probe: Option<SmpSaveTrainCollectionDirectoryProbe>,
pub save_region_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>, pub save_region_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
pub save_region_record_triplet_probe: Option<SmpSaveRegionRecordTripletProbe>,
pub save_placed_structure_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>, pub save_placed_structure_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
#[serde(default)] #[serde(default)]
pub save_company_roster_probe: Option<SmpLoadedCompanyRoster>, pub save_company_roster_probe: Option<SmpLoadedCompanyRoster>,
@ -3143,6 +3173,13 @@ pub fn load_save_slice_from_report(
probe.close_tag_offset probe.close_tag_offset
)); ));
} }
if let Some(probe) = &report.save_region_record_triplet_probe {
notes.push(format!(
"Raw save tagged region records also expose {} repeated 0x55f1/0x55f2/0x55f3 triplets in the records span; first name={:?}.",
probe.record_count,
probe.entries.first().map(|entry| entry.name.as_str())
));
}
if let Some(probe) = &report.save_placed_structure_collection_header_probe { if let Some(probe) = &report.save_placed_structure_collection_header_probe {
notes.push(format!( notes.push(format!(
"Raw save tagged placed-structure header reports live_record_count={} and live_id_bound={} with serialized stride hint 0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.", "Raw save tagged placed-structure header reports live_record_count={} and live_id_bound={} with serialized stride hint 0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
@ -3215,6 +3252,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
let world_economic_tuning = report.save_world_economic_tuning_probe.clone(); let world_economic_tuning = report.save_world_economic_tuning_probe.clone();
let world_finance_neighborhood = report.save_world_finance_neighborhood_probe.clone(); let world_finance_neighborhood = report.save_world_finance_neighborhood_probe.clone();
let train_collection_directory = report.save_train_collection_directory_probe.clone(); let train_collection_directory = report.save_train_collection_directory_probe.clone();
let region_record_triplets = report.save_region_record_triplet_probe.clone();
let company_header_probe = report.save_company_collection_header_probe.as_ref(); let company_header_probe = report.save_company_collection_header_probe.as_ref();
let chairman_header_probe = report let chairman_header_probe = report
.save_chairman_profile_collection_header_probe .save_chairman_profile_collection_header_probe
@ -3525,6 +3563,13 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
header.live_record_count, header.live_id_bound, header.direct_record_stride header.live_record_count, header.live_id_bound, header.direct_record_stride
)); ));
} }
if let Some(triplets) = region_record_triplets.as_ref() {
notes.push(format!(
"Region analysis now also exports {} tagged 0x55f1/0x55f2/0x55f3 record triplets; first serialized region name={:?}.",
triplets.record_count,
triplets.entries.first().map(|entry| entry.name.as_str())
));
}
if let Some(header) = report if let Some(header) = report
.save_placed_structure_collection_header_probe .save_placed_structure_collection_header_probe
.as_ref() .as_ref()
@ -3578,6 +3623,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
train_collection_header: report.save_train_collection_header_probe.clone(), train_collection_header: report.save_train_collection_header_probe.clone(),
train_collection_directory, train_collection_directory,
region_collection_header: report.save_region_collection_header_probe.clone(), region_collection_header: report.save_region_collection_header_probe.clone(),
region_record_triplets,
placed_structure_collection_header: report placed_structure_collection_header: report
.save_placed_structure_collection_header_probe .save_placed_structure_collection_header_probe
.clone(), .clone(),
@ -7593,6 +7639,8 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
file_extension_hint.as_deref(), file_extension_hint.as_deref(),
container_profile.as_ref(), container_profile.as_ref(),
); );
let save_region_record_triplet_probe =
parse_save_region_record_triplet_probe(bytes, save_region_collection_header_probe.as_ref());
let save_placed_structure_collection_header_probe = let save_placed_structure_collection_header_probe =
parse_save_placed_structure_collection_header_probe( parse_save_placed_structure_collection_header_probe(
bytes, bytes,
@ -7765,6 +7813,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
save_train_collection_header_probe, save_train_collection_header_probe,
save_train_collection_directory_probe, save_train_collection_directory_probe,
save_region_collection_header_probe, save_region_collection_header_probe,
save_region_record_triplet_probe,
save_placed_structure_collection_header_probe, save_placed_structure_collection_header_probe,
save_company_roster_probe, save_company_roster_probe,
save_chairman_profile_table_probe, save_chairman_profile_table_probe,
@ -9850,6 +9899,76 @@ fn parse_save_region_collection_header_probe(
.then_some(probe) .then_some(probe)
} }
fn parse_save_region_record_triplet_probe(
bytes: &[u8],
header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>,
) -> Option<SmpSaveRegionRecordTripletProbe> {
let header_probe = header_probe?;
if header_probe.source_kind != "save-region-tagged-header-counts" {
return None;
}
let records_payload =
bytes.get(header_probe.records_tag_offset + 4..header_probe.close_tag_offset)?;
let name_offsets = find_u16_le_offsets(records_payload, SAVE_REGION_RECORD_NAME_TAG);
let policy_offsets = find_u16_le_offsets(records_payload, SAVE_REGION_RECORD_POLICY_TAG);
let profile_offsets = find_u16_le_offsets(records_payload, SAVE_REGION_RECORD_PROFILE_TAG);
let record_count = header_probe.live_record_count as usize;
if name_offsets.len() != record_count
|| policy_offsets.len() != record_count
|| profile_offsets.len() != record_count
{
return None;
}
let mut entries = Vec::with_capacity(record_count);
for index in 0..record_count {
let name_tag_relative_offset = name_offsets[index];
let policy_tag_relative_offset = policy_offsets[index];
let profile_tag_relative_offset = profile_offsets[index];
let next_record_relative_offset = name_offsets
.get(index + 1)
.copied()
.unwrap_or(records_payload.len());
if !(name_tag_relative_offset < policy_tag_relative_offset
&& policy_tag_relative_offset < profile_tag_relative_offset
&& profile_tag_relative_offset < next_record_relative_offset)
{
return None;
}
let name_payload =
records_payload.get(name_tag_relative_offset + 4..policy_tag_relative_offset)?;
let name = parse_save_len_prefixed_ascii_name(name_payload)?;
let policy_chunk_len =
profile_tag_relative_offset.checked_sub(policy_tag_relative_offset + 4)?;
let profile_chunk_len =
next_record_relative_offset.checked_sub(profile_tag_relative_offset + 4)?;
entries.push(SmpSaveRegionRecordTripletEntryProbe {
record_index: index,
name,
name_tag_relative_offset,
policy_tag_relative_offset,
profile_tag_relative_offset,
policy_chunk_len,
profile_chunk_len,
});
}
Some(SmpSaveRegionRecordTripletProbe {
profile_family: header_probe.profile_family.clone(),
source_kind: "save-region-record-triplets".to_string(),
semantic_family: "scenario-save-region-record-triplets".to_string(),
records_tag_offset: header_probe.records_tag_offset,
close_tag_offset: header_probe.close_tag_offset,
record_count,
entries,
evidence: vec![
"save-side region records in the non-direct Marker09 family are serialized as repeated 0x55f1/0x55f2/0x55f3 triplets inside the records span".to_string(),
format!(
"decoded {} region record triplets with one len-prefixed name chunk, one fixed policy chunk, and one trailing profile payload chunk per record",
record_count
),
],
})
}
fn parse_save_placed_structure_collection_header_probe( fn parse_save_placed_structure_collection_header_probe(
bytes: &[u8], bytes: &[u8],
file_extension_hint: Option<&str>, file_extension_hint: Option<&str>,
@ -9993,6 +10112,13 @@ fn parse_save_tagged_collection_header_probe(
}) })
} }
fn parse_save_len_prefixed_ascii_name(bytes: &[u8]) -> Option<String> {
let len = *bytes.first()? as usize;
let text_bytes = bytes.get(1..1 + len)?;
let text = std::str::from_utf8(text_bytes).ok()?.trim_end_matches('\0');
Some(text.to_string())
}
fn parse_rt3_105_save_name_table_probe( fn parse_rt3_105_save_name_table_probe(
bytes: &[u8], bytes: &[u8],
file_extension_hint: Option<&str>, file_extension_hint: Option<&str>,
@ -16809,6 +16935,35 @@ mod tests {
], ],
evidence: vec![], evidence: vec![],
}); });
report.save_region_record_triplet_probe = Some(SmpSaveRegionRecordTripletProbe {
profile_family: "rt3-105-save-container-v1".to_string(),
source_kind: "save-region-record-triplets".to_string(),
semantic_family: "scenario-save-region-record-triplets".to_string(),
records_tag_offset: 0x5100,
close_tag_offset: 0x5200,
record_count: 2,
entries: vec![
SmpSaveRegionRecordTripletEntryProbe {
record_index: 0,
name: "Marker09".to_string(),
name_tag_relative_offset: 0,
policy_tag_relative_offset: 0x10,
profile_tag_relative_offset: 0x2e,
policy_chunk_len: 0x1a,
profile_chunk_len: 0x40,
},
SmpSaveRegionRecordTripletEntryProbe {
record_index: 1,
name: "Marker10".to_string(),
name_tag_relative_offset: 0x6e,
policy_tag_relative_offset: 0x7e,
profile_tag_relative_offset: 0x9c,
policy_chunk_len: 0x1a,
profile_chunk_len: 0x20,
},
],
evidence: vec![],
});
report.save_placed_structure_collection_header_probe = report.save_placed_structure_collection_header_probe =
Some(SmpSaveTaggedCollectionHeaderProbe { Some(SmpSaveTaggedCollectionHeaderProbe {
profile_family: "rt3-105-save-container-v1".to_string(), profile_family: "rt3-105-save-container-v1".to_string(),
@ -16916,6 +17071,11 @@ mod tests {
note.contains("tagged region header reports live_record_count=145") note.contains("tagged region header reports live_record_count=145")
}) })
); );
assert!(slice.notes.iter().any(|note| {
note.contains(
"tagged region records also expose 2 repeated 0x55f1/0x55f2/0x55f3 triplets",
)
}));
assert!(slice.notes.iter().any(|note| { assert!(slice.notes.iter().any(|note| {
note.contains("tagged placed-structure header reports live_record_count=2026") note.contains("tagged placed-structure header reports live_record_count=2026")
})); }));
@ -17133,6 +17293,59 @@ mod tests {
assert_eq!(directory_probe.entries[2].next_live_entry_id, 0); assert_eq!(directory_probe.entries[2].next_live_entry_id, 0);
} }
#[test]
fn parses_region_record_triplet_probe_from_marker09_records() {
let mut bytes = vec![0u8; 0x400];
let metadata_tag_offset = 0x40usize;
let records_tag_offset = 0x140usize;
let close_tag_offset = 0x260usize;
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 mut cursor = records_tag_offset + 4;
for name in ["Marker09", "Marker10"] {
bytes[cursor..cursor + 2].copy_from_slice(&SAVE_REGION_RECORD_NAME_TAG.to_le_bytes());
bytes[cursor + 4] = name.len() as u8;
bytes[cursor + 5..cursor + 5 + name.len()].copy_from_slice(name.as_bytes());
cursor += 0x10;
bytes[cursor..cursor + 2].copy_from_slice(&SAVE_REGION_RECORD_POLICY_TAG.to_le_bytes());
cursor += 0x1e;
bytes[cursor..cursor + 2]
.copy_from_slice(&SAVE_REGION_RECORD_PROFILE_TAG.to_le_bytes());
cursor += 0x40;
}
let header_probe = SmpSaveTaggedCollectionHeaderProbe {
profile_family: "rt3-105-save-container-v1".to_string(),
source_kind: "save-region-tagged-header-counts".to_string(),
semantic_family: "scenario-save-region-header-counts".to_string(),
metadata_tag_offset,
records_tag_offset,
close_tag_offset,
direct_collection_flag: 0,
direct_collection_flag_hex: "0x00000000".to_string(),
direct_record_stride: 0x06,
direct_record_stride_hex: "0x00000006".to_string(),
live_id_bound: 0x96,
live_id_bound_hex: "0x00000096".to_string(),
live_record_count: 2,
live_record_count_hex: "0x00000002".to_string(),
header_words: vec![0, 6, 0x0a, 0x14, 0x96, 0x02],
header_hex_words: vec![],
evidence: vec![],
};
let triplet_probe = parse_save_region_record_triplet_probe(&bytes, Some(&header_probe))
.expect("region triplet probe should parse");
assert_eq!(triplet_probe.record_count, 2);
assert_eq!(triplet_probe.entries[0].name, "Marker09");
assert_eq!(triplet_probe.entries[0].policy_tag_relative_offset, 0x10);
assert_eq!(triplet_probe.entries[0].profile_tag_relative_offset, 0x2e);
assert_eq!(triplet_probe.entries[1].name, "Marker10");
}
#[test] #[test]
fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() { fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() {
let mut bytes = vec![0u8; 0x400]; let mut bytes = vec![0u8; 0x400];

View file

@ -10,10 +10,12 @@ Working rule:
## Next ## Next
- Reconstruct the save-side region record body on top of the newly corrected non-direct tagged - Reconstruct the save-side region record body on top of the newly corrected non-direct tagged
region seam (`0x5209/0x520a/0x520b`, stride hint `0x06`, `Marker09` record stems), especially region seam (`0x5209/0x520a/0x520b`, stride hint `0x06`, `Marker09` record stems) and its now
the pending bonus lane `[region+0x276]`, completion latch `[region+0x302]`, one-shot notice grounded repeated `0x55f1/0x55f2/0x55f3` record-triplet envelope, especially the fixed `0x55f2`
latch `[region+0x316]`, severity/source lane `[region+0x25e]`, and any stable region-id or policy row behind `[region+0x272/+0x25a/+0x25e]`, the pending bonus lane `[region+0x276]`,
class discriminator that can drive shellless city-connection service. completion latch `[region+0x302]`, one-shot notice latch `[region+0x316]`, severity/source lane
`[region+0x25e]`, and any stable region-id or class discriminator that can drive shellless
city-connection service.
- Reconstruct the save-side placed-structure collection body on top of the newly grounded - Reconstruct the save-side placed-structure collection body on top of the newly grounded
`0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can `0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can
stop depending on atlas-only placed-structure and local-runtime refresh notes. stop depending on atlas-only placed-structure and local-runtime refresh notes.
@ -56,6 +58,9 @@ Working rule:
at metadata dword `16`, while the actual region family is the larger non-direct `Marker09` at metadata dword `16`, while the actual region family is the larger non-direct `Marker09`
collection with live_id/count `0x96/0x91`; the tagged placed-structure header collection with live_id/count `0x96/0x91`; the tagged placed-structure header
(`0x36b1/0x36b2/0x36b3`) remains grounded alongside them. (`0x36b1/0x36b2/0x36b3`) remains grounded alongside them.
- That same corrected region seam now also exposes repeated `0x55f1/0x55f2/0x55f3` serialized
record triplets with len-prefixed names plus fixed policy/profile chunk lengths, so the next
city-connection pass can target the real record envelope instead of another blind scan.
- Stepped calendar progression now also refreshes save-world owner time fields, including packed - Stepped calendar progression now also refreshes save-world owner time fields, including packed
year, packed tuple words, absolute counter, and the derived selected-year gap scalar. year, packed tuple words, absolute counter, and the derived selected-year gap scalar.
- Automatic year-rollover calendar stepping now invokes periodic-boundary service. - Automatic year-rollover calendar stepping now invokes periodic-boundary service.