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>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SmpSaveRegionRecordTripletEntryProbe {
|
pub struct SmpSaveRegionRecordTripletEntryProbe {
|
||||||
pub record_index: usize,
|
pub record_index: usize,
|
||||||
|
|
@ -1664,6 +1684,8 @@ pub struct SmpSaveRegionRecordTripletEntryProbe {
|
||||||
pub policy_reserved_dwords: Vec<u32>,
|
pub policy_reserved_dwords: Vec<u32>,
|
||||||
pub policy_trailing_word: u16,
|
pub policy_trailing_word: u16,
|
||||||
pub policy_trailing_word_hex: String,
|
pub policy_trailing_word_hex: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_collection: Option<SmpSaveRegionProfileCollectionProbe>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[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 {
|
if let Some(probe) = &report.save_region_record_triplet_probe {
|
||||||
notes.push(format!(
|
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.record_count,
|
||||||
probe.entries.first().map(|entry| entry.name.as_str()),
|
probe.entries.first().map(|entry| entry.name.as_str()),
|
||||||
probe.entries
|
probe.entries
|
||||||
|
|
@ -3200,7 +3222,10 @@ pub fn load_save_slice_from_report(
|
||||||
probe.entries
|
probe.entries
|
||||||
.first()
|
.first()
|
||||||
.map(|entry| entry.policy_trailing_word_hex.as_str())
|
.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 {
|
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() {
|
if let Some(triplets) = region_record_triplets.as_ref() {
|
||||||
notes.push(format!(
|
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.record_count,
|
||||||
triplets.entries.first().map(|entry| entry.name.as_str()),
|
triplets.entries.first().map(|entry| entry.name.as_str()),
|
||||||
triplets
|
triplets
|
||||||
|
|
@ -3605,7 +3630,10 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
|
||||||
.entries
|
.entries
|
||||||
.first()
|
.first()
|
||||||
.map(|entry| entry.policy_leading_f32_2)
|
.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
|
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 policy_trailing_word = read_u16_at(policy_payload, 24)?;
|
||||||
let profile_chunk_len =
|
let profile_chunk_len =
|
||||||
next_record_relative_offset.checked_sub(profile_tag_relative_offset + 4)?;
|
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 {
|
entries.push(SmpSaveRegionRecordTripletEntryProbe {
|
||||||
record_index: index,
|
record_index: index,
|
||||||
name,
|
name,
|
||||||
|
|
@ -10006,6 +10037,7 @@ fn parse_save_region_record_triplet_probe(
|
||||||
policy_reserved_dwords,
|
policy_reserved_dwords,
|
||||||
policy_trailing_word,
|
policy_trailing_word,
|
||||||
policy_trailing_word_hex: format!("0x{policy_trailing_word:04x}"),
|
policy_trailing_word_hex: format!("0x{policy_trailing_word:04x}"),
|
||||||
|
profile_collection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(SmpSaveRegionRecordTripletProbe {
|
Some(SmpSaveRegionRecordTripletProbe {
|
||||||
|
|
@ -10023,6 +10055,7 @@ fn parse_save_region_record_triplet_probe(
|
||||||
record_count
|
record_count
|
||||||
),
|
),
|
||||||
"each fixed 0x55f2 policy chunk currently decodes as three leading f32 lanes, three reserved dwords, and one trailing u16 word".to_string(),
|
"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())
|
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(
|
fn parse_rt3_105_save_name_table_probe(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
file_extension_hint: Option<&str>,
|
file_extension_hint: Option<&str>,
|
||||||
|
|
@ -17015,6 +17136,28 @@ mod tests {
|
||||||
policy_reserved_dwords: vec![0, 0, 0],
|
policy_reserved_dwords: vec![0, 0, 0],
|
||||||
policy_trailing_word: 1,
|
policy_trailing_word: 1,
|
||||||
policy_trailing_word_hex: "0x0001".to_string(),
|
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 {
|
SmpSaveRegionRecordTripletEntryProbe {
|
||||||
record_index: 1,
|
record_index: 1,
|
||||||
|
|
@ -17030,6 +17173,20 @@ mod tests {
|
||||||
policy_reserved_dwords: vec![0, 0, 0],
|
policy_reserved_dwords: vec![0, 0, 0],
|
||||||
policy_trailing_word: 1,
|
policy_trailing_word: 1,
|
||||||
policy_trailing_word_hex: "0x0001".to_string(),
|
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![],
|
evidence: vec![],
|
||||||
|
|
@ -17434,6 +17591,41 @@ mod tests {
|
||||||
assert_eq!(triplet_probe.entries[1].policy_leading_f32_2, 276.0);
|
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]
|
#[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];
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,11 @@ Working rule:
|
||||||
|
|
||||||
- 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) and its now
|
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`
|
grounded repeated `0x55f1/0x55f2/0x55f3` record-triplet envelope, especially the unresolved
|
||||||
profile payload that should carry the pending bonus lane `[region+0x276]`, completion latch
|
fields that remain above the now-grounded embedded profile collection in the large `0x55f3`
|
||||||
`[region+0x302]`, one-shot notice latch `[region+0x316]`, severity/source lane `[region+0x25e]`,
|
payload: the pending bonus lane `[region+0x276]`, completion latch `[region+0x302]`, one-shot
|
||||||
and any stable region-id or class discriminator that can drive shellless city-connection
|
notice latch `[region+0x316]`, severity/source lane `[region+0x25e]`, and any stable region-id
|
||||||
service, now that the fixed `0x55f2` policy row already exposes its three leading f32 lanes,
|
or class discriminator that can drive shellless city-connection service.
|
||||||
reserved dwords, and trailing word structurally.
|
|
||||||
- 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.
|
||||||
|
|
@ -66,6 +65,10 @@ Working rule:
|
||||||
`f32` lanes, three reserved `u32` lanes, and a trailing `u16` word, so the next save-region
|
`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
|
slice can focus on the larger `0x55f3` payload where the pending/completion/one-shot latches are
|
||||||
most likely to live.
|
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
|
- 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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue