Decode embedded save-side region profile collections
This commit is contained in:
parent
91651f3a9a
commit
e01039379b
2 changed files with 205 additions and 10 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue