Decode embedded save-side region profile collections

This commit is contained in:
Jan Petykiewicz 2026-04-18 08:47:09 -07:00
commit e01039379b
2 changed files with 205 additions and 10 deletions

View file

@ -1648,6 +1648,26 @@ pub struct SmpSaveTrainCollectionDirectoryProbe {
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SmpSaveRegionProfileEntryProbe {
pub entry_index: usize,
pub row_relative_offset: usize,
pub name: String,
pub trailing_weight_f32: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SmpSaveRegionProfileCollectionProbe {
pub direct_collection_flag: u32,
pub entry_stride: u32,
pub live_id_bound: u32,
pub live_record_count: u32,
pub entry_start_relative_offset: usize,
pub trailing_padding_len: usize,
#[serde(default)]
pub entries: Vec<SmpSaveRegionProfileEntryProbe>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SmpSaveRegionRecordTripletEntryProbe {
pub record_index: usize,
@ -1664,6 +1684,8 @@ pub struct SmpSaveRegionRecordTripletEntryProbe {
pub policy_reserved_dwords: Vec<u32>,
pub policy_trailing_word: u16,
pub policy_trailing_word_hex: String,
#[serde(default)]
pub profile_collection: Option<SmpSaveRegionProfileCollectionProbe>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -3182,7 +3204,7 @@ pub fn load_save_slice_from_report(
}
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={:?}, first policy lanes=({:.3}, {:.3}, {:.3}), trailing_word={}.",
"Raw save tagged region records also expose {} repeated 0x55f1/0x55f2/0x55f3 triplets in the records span; first name={:?}, first policy lanes=({:.3}, {:.3}, {:.3}), trailing_word={}, first profile collection count={:?}.",
probe.record_count,
probe.entries.first().map(|entry| entry.name.as_str()),
probe.entries
@ -3200,7 +3222,10 @@ pub fn load_save_slice_from_report(
probe.entries
.first()
.map(|entry| entry.policy_trailing_word_hex.as_str())
.unwrap_or("0x0000")
.unwrap_or("0x0000"),
probe.entries.first().and_then(|entry| {
entry.profile_collection.as_ref().map(|collection| collection.live_record_count)
})
));
}
if let Some(probe) = &report.save_placed_structure_collection_header_probe {
@ -3588,7 +3613,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
}
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={:?}, first policy lanes=({:.3}, {:.3}, {:.3}).",
"Region analysis now also exports {} tagged 0x55f1/0x55f2/0x55f3 record triplets; first serialized region name={:?}, first policy lanes=({:.3}, {:.3}, {:.3}), first profile collection count={:?}.",
triplets.record_count,
triplets.entries.first().map(|entry| entry.name.as_str()),
triplets
@ -3605,7 +3630,10 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
.entries
.first()
.map(|entry| entry.policy_leading_f32_2)
.unwrap_or_default()
.unwrap_or_default(),
triplets.entries.first().and_then(|entry| {
entry.profile_collection.as_ref().map(|collection| collection.live_record_count)
})
));
}
if let Some(header) = report
@ -9992,6 +10020,9 @@ fn parse_save_region_record_triplet_probe(
let policy_trailing_word = read_u16_at(policy_payload, 24)?;
let profile_chunk_len =
next_record_relative_offset.checked_sub(profile_tag_relative_offset + 4)?;
let profile_payload =
records_payload.get(profile_tag_relative_offset + 4..next_record_relative_offset)?;
let profile_collection = parse_save_region_profile_collection_probe(profile_payload);
entries.push(SmpSaveRegionRecordTripletEntryProbe {
record_index: index,
name,
@ -10006,6 +10037,7 @@ fn parse_save_region_record_triplet_probe(
policy_reserved_dwords,
policy_trailing_word,
policy_trailing_word_hex: format!("0x{policy_trailing_word:04x}"),
profile_collection,
});
}
Some(SmpSaveRegionRecordTripletProbe {
@ -10023,6 +10055,7 @@ fn parse_save_region_record_triplet_probe(
record_count
),
"each fixed 0x55f2 policy chunk currently decodes as three leading f32 lanes, three reserved dwords, and one trailing u16 word".to_string(),
"the trailing 0x55f3 payload also carries an embedded direct profile collection with fixed 0x22-byte rows on grounded saves".to_string(),
],
})
}
@ -10177,6 +10210,94 @@ fn parse_save_len_prefixed_ascii_name(bytes: &[u8]) -> Option<String> {
Some(text.to_string())
}
fn parse_save_fixed_ascii_name(bytes: &[u8]) -> Option<String> {
let nul_index = bytes
.iter()
.position(|byte| *byte == 0)
.unwrap_or(bytes.len());
let text = std::str::from_utf8(bytes.get(..nul_index)?).ok()?;
if text.is_empty()
|| !text
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b' ' | b'-' | b'&' | b'/'))
{
return None;
}
Some(text.to_string())
}
fn parse_save_region_profile_collection_probe(
profile_payload: &[u8],
) -> Option<SmpSaveRegionProfileCollectionProbe> {
let direct_collection_flag = read_u32_at(profile_payload, 0)?;
let entry_stride = read_u32_at(profile_payload, 4)?;
let header_word_2 = read_u32_at(profile_payload, 8)?;
let header_word_3 = read_u32_at(profile_payload, 12)?;
let live_id_bound = read_u32_at(profile_payload, 16)?;
let live_record_count = read_u32_at(profile_payload, 20)?;
let header_word_6 = read_u32_at(profile_payload, 24)?;
let header_word_7 = read_u32_at(profile_payload, 28)?;
if !(direct_collection_flag == 1
&& entry_stride == 0x22
&& header_word_2 == 2
&& header_word_3 == 2
&& live_record_count > 0
&& live_record_count < live_id_bound
&& header_word_6 == 0
&& header_word_7 == 1)
{
return None;
}
let entry_stride = entry_stride as usize;
let live_record_count_usize = live_record_count as usize;
let rows_byte_len = live_record_count_usize.checked_mul(entry_stride)?;
let mut matched_probe = None;
for entry_start_relative_offset in 0x20..=0x80 {
if entry_start_relative_offset + rows_byte_len > profile_payload.len() {
break;
}
let mut entries = Vec::with_capacity(live_record_count_usize);
let mut matched = true;
for entry_index in 0..live_record_count_usize {
let row_relative_offset = entry_start_relative_offset + entry_index * entry_stride;
let row =
profile_payload.get(row_relative_offset..row_relative_offset + entry_stride)?;
let name = match parse_save_fixed_ascii_name(row.get(..12)?) {
Some(name) => name,
None => {
matched = false;
break;
}
};
let trailing_weight_f32 = f32::from_bits(read_u32_at(row, entry_stride - 4)?);
if !trailing_weight_f32.is_finite() || trailing_weight_f32 < 0.0 {
matched = false;
break;
}
entries.push(SmpSaveRegionProfileEntryProbe {
entry_index,
row_relative_offset,
name,
trailing_weight_f32,
});
}
if matched {
matched_probe = Some(SmpSaveRegionProfileCollectionProbe {
direct_collection_flag,
entry_stride: entry_stride as u32,
live_id_bound,
live_record_count,
entry_start_relative_offset,
trailing_padding_len: profile_payload.len()
- (entry_start_relative_offset + rows_byte_len),
entries,
});
break;
}
}
matched_probe
}
fn parse_rt3_105_save_name_table_probe(
bytes: &[u8],
file_extension_hint: Option<&str>,
@ -17015,6 +17136,28 @@ mod tests {
policy_reserved_dwords: vec![0, 0, 0],
policy_trailing_word: 1,
policy_trailing_word_hex: "0x0001".to_string(),
profile_collection: Some(SmpSaveRegionProfileCollectionProbe {
direct_collection_flag: 1,
entry_stride: 0x22,
live_id_bound: 18,
live_record_count: 17,
entry_start_relative_offset: 0x4d,
trailing_padding_len: 2,
entries: vec![
SmpSaveRegionProfileEntryProbe {
entry_index: 0,
row_relative_offset: 0x4d,
name: "House".to_string(),
trailing_weight_f32: 0.2,
},
SmpSaveRegionProfileEntryProbe {
entry_index: 1,
row_relative_offset: 0x6f,
name: "Farm Corn".to_string(),
trailing_weight_f32: 0.2,
},
],
}),
},
SmpSaveRegionRecordTripletEntryProbe {
record_index: 1,
@ -17030,6 +17173,20 @@ mod tests {
policy_reserved_dwords: vec![0, 0, 0],
policy_trailing_word: 1,
policy_trailing_word_hex: "0x0001".to_string(),
profile_collection: Some(SmpSaveRegionProfileCollectionProbe {
direct_collection_flag: 1,
entry_stride: 0x22,
live_id_bound: 26,
live_record_count: 24,
entry_start_relative_offset: 0x50,
trailing_padding_len: 0,
entries: vec![SmpSaveRegionProfileEntryProbe {
entry_index: 0,
row_relative_offset: 0x50,
name: "Farm Corn".to_string(),
trailing_weight_f32: 0.2,
}],
}),
},
],
evidence: vec![],
@ -17434,6 +17591,41 @@ mod tests {
assert_eq!(triplet_probe.entries[1].policy_leading_f32_2, 276.0);
}
#[test]
fn parses_region_profile_collection_probe_from_fixed_name_rows() {
let mut payload = vec![0u8; 0x80];
let header_words = [1u32, 0x22, 2, 2, 3, 2, 0, 1];
for (index, word) in header_words.into_iter().enumerate() {
let offset = index * 4;
payload[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
}
let first_row_offset = 0x20usize;
let first_name = b"House";
payload[first_row_offset..first_row_offset + first_name.len()].copy_from_slice(first_name);
payload[first_row_offset + 0x1e..first_row_offset + 0x22]
.copy_from_slice(&0.2f32.to_bits().to_le_bytes());
let second_row_offset = first_row_offset + 0x22;
let second_name = b"Farm Corn";
payload[second_row_offset..second_row_offset + second_name.len()]
.copy_from_slice(second_name);
payload[second_row_offset + 0x1e..second_row_offset + 0x22]
.copy_from_slice(&0.45f32.to_bits().to_le_bytes());
let profile_probe = parse_save_region_profile_collection_probe(&payload)
.expect("profile collection probe should parse");
assert_eq!(profile_probe.direct_collection_flag, 1);
assert_eq!(profile_probe.entry_stride, 0x22);
assert_eq!(profile_probe.live_id_bound, 3);
assert_eq!(profile_probe.live_record_count, 2);
assert_eq!(profile_probe.entry_start_relative_offset, 0x20);
assert_eq!(profile_probe.entries.len(), 2);
assert_eq!(profile_probe.entries[0].name, "House");
assert_eq!(profile_probe.entries[0].trailing_weight_f32, 0.2);
assert_eq!(profile_probe.entries[1].name, "Farm Corn");
assert_eq!(profile_probe.entries[1].trailing_weight_f32, 0.45);
}
#[test]
fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() {
let mut bytes = vec![0u8; 0x400];

View file

@ -11,12 +11,11 @@ Working rule:
- 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) and its now
grounded repeated `0x55f1/0x55f2/0x55f3` record-triplet envelope, especially the large `0x55f3`
profile payload that should carry the pending bonus lane `[region+0x276]`, 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, now that the fixed `0x55f2` policy row already exposes its three leading f32 lanes,
reserved dwords, and trailing word structurally.
grounded repeated `0x55f1/0x55f2/0x55f3` record-triplet envelope, especially the unresolved
fields that remain above the now-grounded embedded profile collection in the large `0x55f3`
payload: the pending bonus lane `[region+0x276]`, 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
`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.
@ -66,6 +65,10 @@ Working rule:
`f32` lanes, three reserved `u32` lanes, and a trailing `u16` word, so the next save-region
slice can focus on the larger `0x55f3` payload where the pending/completion/one-shot latches are
most likely to live.
- The larger `0x55f3` payload now also exposes an embedded direct profile collection with grounded
live-id/count headers, fixed `0x22`-byte rows, profile names, and trailing weight scalars, so
the remaining region work is on the unresolved payload fields above that collection rather than
on the profile subcollection itself.
- 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.
- Automatic year-rollover calendar stepping now invokes periodic-boundary service.