diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 235859d..42a25a2 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -1648,6 +1648,26 @@ pub struct SmpSaveTrainCollectionDirectoryProbe { pub evidence: Vec, } +#[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, +} + #[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, pub policy_trailing_word: u16, pub policy_trailing_word_hex: String, + #[serde(default)] + pub profile_collection: Option, } #[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 { Some(text.to_string()) } +fn parse_save_fixed_ascii_name(bytes: &[u8]) -> Option { + 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 { + 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]; diff --git a/docs/rehost-queue.md b/docs/rehost-queue.md index b1e9cf9..b1a294a 100644 --- a/docs/rehost-queue.md +++ b/docs/rehost-queue.md @@ -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.