diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 42a25a2..12c09b9 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -1701,6 +1701,39 @@ pub struct SmpSaveRegionRecordTripletProbe { pub evidence: Vec, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SmpSavePlacedStructureRecordTripletEntryProbe { + pub record_index: usize, + pub primary_name: String, + pub secondary_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, + pub policy_f32_lane_0: f32, + pub policy_f32_lane_1: f32, + pub policy_f32_lane_2: f32, + pub policy_f32_lane_3: f32, + pub policy_f32_lane_4: f32, + pub policy_reserved_dword: u32, + pub policy_trailing_word: u16, + pub policy_trailing_word_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SmpSavePlacedStructureRecordTripletProbe { + 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, + pub evidence: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpRt3105SaveNameTableProbe { pub profile_family: String, @@ -2645,6 +2678,8 @@ pub struct SmpSaveCompanyChairmanAnalysisReport { #[serde(default)] pub placed_structure_collection_header: Option, #[serde(default)] + pub placed_structure_record_triplets: Option, + #[serde(default)] pub company_entries: Vec, #[serde(default)] pub chairman_entries: Vec, @@ -2908,6 +2943,8 @@ pub struct SmpInspectionReport { pub save_region_collection_header_probe: Option, pub save_region_record_triplet_probe: Option, pub save_placed_structure_collection_header_probe: Option, + pub save_placed_structure_record_triplet_probe: + Option, #[serde(default)] pub save_company_roster_probe: Option, #[serde(default)] @@ -3239,6 +3276,19 @@ pub fn load_save_slice_from_report( probe.close_tag_offset )); } + if let Some(probe) = &report.save_placed_structure_record_triplet_probe { + notes.push(format!( + "Raw save tagged placed-structure records also expose {} repeated 0x55f1/0x55f2/0x55f3 triplets; first stems={:?}/{:?}, first policy lanes=({:.3}, {:.3}, {:.3}, {:.3}, {:.3}).", + probe.record_count, + probe.entries.first().map(|entry| entry.primary_name.as_str()), + probe.entries.first().map(|entry| entry.secondary_name.as_str()), + probe.entries.first().map(|entry| entry.policy_f32_lane_0).unwrap_or_default(), + probe.entries.first().map(|entry| entry.policy_f32_lane_1).unwrap_or_default(), + probe.entries.first().map(|entry| entry.policy_f32_lane_2).unwrap_or_default(), + probe.entries.first().map(|entry| entry.policy_f32_lane_3).unwrap_or_default(), + probe.entries.first().map(|entry| entry.policy_f32_lane_4).unwrap_or_default() + )); + } if let Some(roster) = &report.save_company_roster_probe { notes.push(format!( "Raw save inspection reconstructed {} company direct records from the tagged company collection.", @@ -3301,6 +3351,8 @@ pub fn inspect_save_company_and_chairman_analysis_bytes( let world_finance_neighborhood = report.save_world_finance_neighborhood_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 placed_structure_record_triplets = + report.save_placed_structure_record_triplet_probe.clone(); let company_header_probe = report.save_company_collection_header_probe.as_ref(); let chairman_header_probe = report .save_chairman_profile_collection_header_probe @@ -3645,6 +3697,14 @@ pub fn inspect_save_company_and_chairman_analysis_bytes( header.live_record_count, header.live_id_bound, header.direct_record_stride )); } + if let Some(triplets) = placed_structure_record_triplets.as_ref() { + notes.push(format!( + "Placed-structure analysis now also exports {} tagged 0x55f1/0x55f2/0x55f3 record triplets; first stems={:?}/{:?}.", + triplets.record_count, + triplets.entries.first().map(|entry| entry.primary_name.as_str()), + triplets.entries.first().map(|entry| entry.secondary_name.as_str()) + )); + } if !company_entries.is_empty() { notes.push( "Company debt is derived from the grounded bond table at [company+0x5b/+0x5f] by summing live principal slots.".to_string(), @@ -3693,6 +3753,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes( placed_structure_collection_header: report .save_placed_structure_collection_header_probe .clone(), + placed_structure_record_triplets, company_entries, chairman_entries, notes, @@ -7713,6 +7774,11 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm file_extension_hint.as_deref(), container_profile.as_ref(), ); + let save_placed_structure_record_triplet_probe = + parse_save_placed_structure_record_triplet_probe( + bytes, + save_placed_structure_collection_header_probe.as_ref(), + ); let save_company_roster_probe = parse_save_company_roster_probe( bytes, save_company_collection_header_probe.as_ref(), @@ -7881,6 +7947,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm save_region_collection_header_probe, save_region_record_triplet_probe, save_placed_structure_collection_header_probe, + save_placed_structure_record_triplet_probe, save_company_roster_probe, save_chairman_profile_table_probe, rt3_105_save_name_table_probe, @@ -10060,6 +10127,95 @@ fn parse_save_region_record_triplet_probe( }) } +fn parse_save_placed_structure_record_triplet_probe( + bytes: &[u8], + header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>, +) -> Option { + let header_probe = header_probe?; + if header_probe.source_kind != "save-placed-structure-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 (primary_name, secondary_name) = parse_save_len_prefixed_ascii_name_pair(name_payload)?; + let policy_chunk_len = + profile_tag_relative_offset.checked_sub(policy_tag_relative_offset + 4)?; + if policy_chunk_len != 0x1a { + return None; + } + let policy_payload = + records_payload.get(policy_tag_relative_offset + 4..profile_tag_relative_offset)?; + let policy_f32_lane_0 = f32::from_bits(read_u32_at(policy_payload, 0)?); + let policy_f32_lane_1 = f32::from_bits(read_u32_at(policy_payload, 4)?); + let policy_f32_lane_2 = f32::from_bits(read_u32_at(policy_payload, 8)?); + let policy_f32_lane_3 = f32::from_bits(read_u32_at(policy_payload, 12)?); + let policy_f32_lane_4 = f32::from_bits(read_u32_at(policy_payload, 16)?); + let policy_reserved_dword = read_u32_at(policy_payload, 20)?; + 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)?; + entries.push(SmpSavePlacedStructureRecordTripletEntryProbe { + record_index: index, + primary_name, + secondary_name, + name_tag_relative_offset, + policy_tag_relative_offset, + profile_tag_relative_offset, + policy_chunk_len, + profile_chunk_len, + policy_f32_lane_0, + policy_f32_lane_1, + policy_f32_lane_2, + policy_f32_lane_3, + policy_f32_lane_4, + policy_reserved_dword, + policy_trailing_word, + policy_trailing_word_hex: format!("0x{policy_trailing_word:04x}"), + }); + } + Some(SmpSavePlacedStructureRecordTripletProbe { + profile_family: header_probe.profile_family.clone(), + source_kind: "save-placed-structure-record-triplets".to_string(), + semantic_family: "scenario-save-placed-structure-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 placed-structure records are serialized as repeated 0x55f1/0x55f2/0x55f3 triplets inside the tagged records span".to_string(), + "the 0x55f1 chunk currently exposes two len-prefixed structure-name stems before the fixed 0x55f2 policy row".to_string(), + "each fixed placed-structure 0x55f2 policy chunk currently decodes as five f32-like lanes, one reserved dword, and one trailing u16 word".to_string(), + ], + }) +} + fn parse_save_placed_structure_collection_header_probe( bytes: &[u8], file_extension_hint: Option<&str>, @@ -10210,6 +10366,29 @@ fn parse_save_len_prefixed_ascii_name(bytes: &[u8]) -> Option { Some(text.to_string()) } +fn parse_save_len_prefixed_ascii_name_pair(bytes: &[u8]) -> Option<(String, String)> { + let first_len = *bytes.first()? as usize; + let first_end = 1 + first_len; + let first = std::str::from_utf8(bytes.get(1..first_end)?) + .ok()? + .trim_end_matches('\0') + .to_string(); + let mut second_len_offset = first_end; + while matches!(bytes.get(second_len_offset), Some(0)) { + second_len_offset += 1; + } + let second_len = *bytes.get(second_len_offset)? as usize; + let second_start = second_len_offset + 1; + let second = std::str::from_utf8(bytes.get(second_start..second_start + second_len)?) + .ok()? + .trim_end_matches('\0') + .to_string(); + if first.is_empty() || second.is_empty() { + return None; + } + Some((first, second)) +} + fn parse_save_fixed_ascii_name(bytes: &[u8]) -> Option { let nul_index = bytes .iter() @@ -17626,6 +17805,93 @@ mod tests { assert_eq!(profile_probe.entries[1].trailing_weight_f32, 0.45); } + #[test] + fn parses_placed_structure_record_triplet_probe_from_dual_name_rows() { + 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(&0x000036b1u32.to_le_bytes()); + bytes[records_tag_offset..records_tag_offset + 4] + .copy_from_slice(&0x000036b2u32.to_le_bytes()); + bytes[close_tag_offset..close_tag_offset + 4].copy_from_slice(&0x000036b3u32.to_le_bytes()); + let mut cursor = records_tag_offset + 4; + for (primary, secondary, lane0, lane1, lane2, lane3, lane4) in [ + ( + "StationA", + "StationSetA", + 43111.92f32, + 1385.5f32, + 34581.95f32, + 0.0f32, + 5.9760494f32, + ), + ( + "StationB", + "StationSetB", + 44000.0f32, + 1200.0f32, + 33000.0f32, + 0.0f32, + 4.5f32, + ), + ] { + bytes[cursor..cursor + 2].copy_from_slice(&SAVE_REGION_RECORD_NAME_TAG.to_le_bytes()); + bytes[cursor + 4] = primary.len() as u8; + bytes[cursor + 5..cursor + 5 + primary.len()].copy_from_slice(primary.as_bytes()); + let second_len_offset = cursor + 5 + primary.len(); + bytes[second_len_offset] = secondary.len() as u8; + bytes[second_len_offset + 1..second_len_offset + 1 + secondary.len()] + .copy_from_slice(secondary.as_bytes()); + cursor += 0x19; + bytes[cursor..cursor + 2].copy_from_slice(&SAVE_REGION_RECORD_POLICY_TAG.to_le_bytes()); + bytes[cursor + 4..cursor + 8].copy_from_slice(&lane0.to_bits().to_le_bytes()); + bytes[cursor + 8..cursor + 12].copy_from_slice(&lane1.to_bits().to_le_bytes()); + bytes[cursor + 12..cursor + 16].copy_from_slice(&lane2.to_bits().to_le_bytes()); + bytes[cursor + 16..cursor + 20].copy_from_slice(&lane3.to_bits().to_le_bytes()); + bytes[cursor + 20..cursor + 24].copy_from_slice(&lane4.to_bits().to_le_bytes()); + bytes[cursor + 28..cursor + 30].copy_from_slice(&0x0101u16.to_le_bytes()); + cursor += 0x1e; + bytes[cursor..cursor + 2] + .copy_from_slice(&SAVE_REGION_RECORD_PROFILE_TAG.to_le_bytes()); + cursor += 0x10; + } + + let header_probe = SmpSaveTaggedCollectionHeaderProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + source_kind: "save-placed-structure-tagged-header-counts".to_string(), + semantic_family: "scenario-save-placed-structure-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: 3, + live_id_bound_hex: "0x00000003".to_string(), + live_record_count: 2, + live_record_count_hex: "0x00000002".to_string(), + header_words: vec![0, 6, 0x0a, 0x14, 3, 2], + header_hex_words: vec![], + evidence: vec![], + }; + let triplet_probe = + parse_save_placed_structure_record_triplet_probe(&bytes, Some(&header_probe)) + .expect("placed-structure triplet probe should parse"); + + assert_eq!(triplet_probe.record_count, 2); + assert_eq!(triplet_probe.entries[0].primary_name, "StationA"); + assert_eq!(triplet_probe.entries[0].secondary_name, "StationSetA"); + assert_eq!(triplet_probe.entries[0].policy_chunk_len, 0x1a); + assert_eq!(triplet_probe.entries[0].policy_f32_lane_4, 5.9760494); + assert_eq!(triplet_probe.entries[0].policy_trailing_word, 0x0101); + assert_eq!(triplet_probe.entries[1].primary_name, "StationB"); + assert_eq!(triplet_probe.entries[1].secondary_name, "StationSetB"); + assert_eq!(triplet_probe.entries[1].policy_f32_lane_1, 1200.0); + } + #[test] fn parses_placed_structure_tagged_collection_header_probe_from_exact_u32_tags() { let mut bytes = vec![0u8; 0x400];