diff --git a/README.md b/README.md index 4c289f0..ef955c5 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ and stand up Rust tooling that can validate artifacts and later host replacement The long-term direction is still a DLL we can inject into the original executable, patching in individual functions as we build them out. The active implementation milestone is now a headless runtime rehost layer that can execute deterministic world work, compare normalized state, and grow -subsystem breadth without depending on the shell or presentation path. The PE32 hook remains useful -as capture and integration tooling, but it is no longer the main execution milestone. +subsystem breadth without depending on the shell or presentation path. The current packed-event +frontier is real `0x4e9a` structural decode on top of the existing save-slice, snapshot, and +overlay-import workflows. The PE32 hook remains useful as capture and integration tooling, but it +is no longer the main execution milestone. ## Project Docs diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 8bbe965..9c63004 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; @@ -4313,6 +4315,7 @@ mod tests { "record_index": 0, "live_entry_id": 1, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4322,6 +4325,7 @@ mod tests { "record_index": 1, "live_entry_id": 3, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4331,6 +4335,7 @@ mod tests { "record_index": 2, "live_entry_id": 5, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4370,6 +4375,7 @@ mod tests { "record_index": 0, "live_entry_id": 1, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4379,6 +4385,7 @@ mod tests { "record_index": 1, "live_entry_id": 5, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4542,6 +4549,7 @@ mod tests { "record_index": 0, "live_entry_id": 7, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -4583,6 +4591,7 @@ mod tests { "payload_offset": 29186, "payload_len": 64, "decode_status": "executable", + "payload_family": "synthetic_harness", "trigger_kind": 7, "active": true, "marks_collection_dirty": false, @@ -4596,7 +4605,9 @@ mod tests { } ], "standalone_condition_row_count": 1, + "standalone_condition_rows": [], "grouped_effect_row_counts": [0, 1, 0, 0], + "grouped_effect_rows": [], "decoded_actions": [ { "kind": "set_world_flag", diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index 9f3de2c..3f0ff6a 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -374,13 +374,16 @@ mod tests { payload_offset: Some(0x7202), payload_len: Some(48), decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(false), one_shot: Some(false), text_bands: vec![], standalone_condition_row_count: 0, + standalone_condition_rows: vec![], grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: vec![], decoded_actions: vec![rrt_runtime::RuntimeEffect::AdjustCompanyCash { target: rrt_runtime::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 25, diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index a1d7ae2..6830278 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -76,6 +76,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_missing_company_context_count: Option, #[serde(default)] + pub packed_event_blocked_structural_only_count: Option, + #[serde(default)] pub event_runtime_record_count: Option, #[serde(default)] pub candidate_availability_count: Option, @@ -371,6 +373,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_structural_only_count { + if actual.packed_event_blocked_structural_only_count != count { + mismatches.push(format!( + "packed_event_blocked_structural_only_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_structural_only_count + )); + } + } if let Some(count) = self.event_runtime_record_count { if actual.event_runtime_record_count != count { mismatches.push(format!( diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index a463a59..7979872 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document}; use crate::{ CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary, @@ -558,6 +559,7 @@ fn runtime_packed_event_record_summary_from_smp( payload_offset: record.payload_offset, payload_len: record.payload_len, decode_status: record.decode_status.clone(), + payload_family: record.payload_family.clone(), trigger_kind: record.trigger_kind, active: record.active, marks_collection_dirty: record.marks_collection_dirty, @@ -568,7 +570,17 @@ fn runtime_packed_event_record_summary_from_smp( .map(runtime_packed_event_text_band_summary_from_smp) .collect(), standalone_condition_row_count: record.standalone_condition_row_count, + standalone_condition_rows: record + .standalone_condition_rows + .iter() + .map(runtime_packed_event_condition_row_summary_from_smp) + .collect(), grouped_effect_row_counts: record.grouped_effect_row_counts.clone(), + grouped_effect_rows: record + .grouped_effect_rows + .iter() + .map(runtime_packed_event_grouped_effect_row_summary_from_smp) + .collect(), decoded_actions: record.decoded_actions.clone(), executable_import_ready: record.executable_import_ready, import_outcome: Some(determine_packed_event_import_outcome( @@ -591,11 +603,45 @@ fn runtime_packed_event_text_band_summary_from_smp( } } +fn runtime_packed_event_condition_row_summary_from_smp( + row: &crate::SmpLoadedPackedEventConditionRowSummary, +) -> RuntimePackedEventConditionRowSummary { + RuntimePackedEventConditionRowSummary { + row_index: row.row_index, + raw_condition_id: row.raw_condition_id, + subtype: row.subtype, + flag_bytes: row.flag_bytes.clone(), + candidate_name: row.candidate_name.clone(), + notes: row.notes.clone(), + } +} + +fn runtime_packed_event_grouped_effect_row_summary_from_smp( + row: &crate::SmpLoadedPackedEventGroupedEffectRowSummary, +) -> RuntimePackedEventGroupedEffectRowSummary { + RuntimePackedEventGroupedEffectRowSummary { + group_index: row.group_index, + row_index: row.row_index, + descriptor_id: row.descriptor_id, + opcode: row.opcode, + raw_scalar_value: row.raw_scalar_value, + value_byte_0x09: row.value_byte_0x09, + value_dword_0x0d: row.value_dword_0x0d, + value_byte_0x11: row.value_byte_0x11, + value_byte_0x12: row.value_byte_0x12, + value_word_0x14: row.value_word_0x14, + value_word_0x16: row.value_word_0x16, + row_shape: row.row_shape.clone(), + locomotive_name: row.locomotive_name.clone(), + notes: row.notes.clone(), + } +} + fn smp_packed_record_to_runtime_event_record( record: &SmpLoadedPackedEventRecordSummary, known_company_ids: &BTreeSet, ) -> Option> { - if record.decode_status == "unsupported_framing" { + if record.decode_status == "unsupported_framing" || record.payload_family == "real_packed_v1" { return None; } @@ -756,6 +802,9 @@ fn determine_packed_event_import_outcome( if record.decode_status == "unsupported_framing" { return "blocked_unsupported_decode".to_string(); } + if record.payload_family == "real_packed_v1" { + return "blocked_structural_only".to_string(); + } if packed_record_requires_missing_company_context(record, known_company_ids) { return "blocked_missing_company_context".to_string(); } @@ -1129,6 +1178,36 @@ mod tests { ] } + fn real_condition_rows() -> Vec { + vec![crate::SmpLoadedPackedEventConditionRowSummary { + row_index: 0, + raw_condition_id: -1, + subtype: 4, + flag_bytes: vec![0x30; 25], + candidate_name: Some("AutoPlant".to_string()), + notes: vec!["negative sentinel-style condition row id".to_string()], + }] + } + + fn real_grouped_rows() -> Vec { + vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 0, + row_index: 0, + descriptor_id: 2, + opcode: 8, + raw_scalar_value: 7, + value_byte_0x09: 1, + value_dword_0x0d: 12, + value_byte_0x11: 2, + value_byte_0x12: 3, + value_word_0x14: 24, + value_word_0x16: 36, + row_shape: "multivalue_scalar".to_string(), + locomotive_name: Some("Mikado".to_string()), + notes: vec!["grouped effect row carries locomotive-name side string".to_string()], + }] + } + #[test] fn loads_dump_document() { let text = serde_json::to_string(&RuntimeStateDumpDocument { @@ -1345,13 +1424,16 @@ mod tests { payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -1362,13 +1444,16 @@ mod tests { payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -1379,13 +1464,16 @@ mod tests { payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -1586,13 +1674,16 @@ mod tests { payload_offset: Some(0x7202), payload_len: Some(64), decode_status: "executable".to_string(), + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(true), one_shot: Some(false), text_bands: packed_text_bands(), standalone_condition_row_count: 1, + standalone_condition_rows: vec![], grouped_effect_row_counts: vec![0, 1, 0, 0], + grouped_effect_rows: vec![], decoded_actions: vec![ RuntimeEffect::SetWorldFlag { key: "from_packed_root".to_string(), @@ -1692,13 +1783,16 @@ mod tests { payload_offset: Some(0x7202), payload_len: Some(48), decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(false), one_shot: Some(false), text_bands: packed_text_bands(), standalone_condition_row_count: 0, + standalone_condition_rows: vec![], grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: vec![], decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, @@ -1744,6 +1838,91 @@ mod tests { ); } + #[test] + fn leaves_real_structural_records_blocked_structural_only() { + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 7, + live_record_count: 1, + live_entry_ids: vec![7], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 7, + payload_offset: Some(0x7202), + payload_len: Some(96), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: None, + active: None, + marks_collection_dirty: None, + one_shot: None, + text_bands: packed_text_bands(), + standalone_condition_row_count: 1, + standalone_condition_rows: real_condition_rows(), + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: real_grouped_rows(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-structural-only", + None, + ) + .expect("save slice should project"); + + assert!(import.state.event_runtime_records.is_empty()); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_structural_only") + ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.records[0].standalone_condition_rows.len()), + Some(1) + ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.records[0].grouped_effect_rows.len()), + Some(1) + ); + } + #[test] fn overlays_save_slice_events_onto_base_company_context() { let base_state = RuntimeState { @@ -1813,13 +1992,16 @@ mod tests { payload_offset: Some(0x7202), payload_len: Some(48), decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(false), one_shot: Some(false), text_bands: packed_text_bands(), standalone_condition_row_count: 0, + standalone_condition_rows: vec![], grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: vec![], decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, @@ -1963,13 +2145,16 @@ mod tests { payload_offset: Some(0x7202), payload_len: Some(48), decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(false), one_shot: Some(false), text_bands: packed_text_bands(), standalone_condition_row_count: 0, + standalone_condition_rows: vec![], grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: vec![], decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 3b76982..53e35c0 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -37,6 +37,7 @@ pub use pk4::{ pub use runtime::{ RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, + RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, }; @@ -46,6 +47,7 @@ pub use smp::{ SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe, SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, + SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventGroupedEffectRowSummary, SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedProfile, SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe, diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 28441fb..2a09dc1 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -115,6 +115,8 @@ pub struct RuntimePackedEventRecordSummary { pub payload_len: Option, pub decode_status: String, #[serde(default)] + pub payload_family: String, + #[serde(default)] pub trigger_kind: Option, #[serde(default)] pub active: Option, @@ -127,8 +129,12 @@ pub struct RuntimePackedEventRecordSummary { #[serde(default)] pub standalone_condition_row_count: usize, #[serde(default)] + pub standalone_condition_rows: Vec, + #[serde(default)] pub grouped_effect_row_counts: Vec, #[serde(default)] + pub grouped_effect_rows: Vec, + #[serde(default)] pub decoded_actions: Vec, #[serde(default)] pub executable_import_ready: bool, @@ -146,6 +152,39 @@ pub struct RuntimePackedEventTextBandSummary { pub preview: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePackedEventConditionRowSummary { + pub row_index: usize, + pub raw_condition_id: i32, + pub subtype: u8, + #[serde(default)] + pub flag_bytes: Vec, + #[serde(default)] + pub candidate_name: Option, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePackedEventGroupedEffectRowSummary { + pub group_index: usize, + pub row_index: usize, + pub descriptor_id: u32, + pub opcode: u8, + pub raw_scalar_value: i32, + pub value_byte_0x09: u8, + pub value_dword_0x0d: u32, + pub value_byte_0x11: u8, + pub value_byte_0x12: u8, + pub value_word_0x14: u16, + pub value_word_0x16: u16, + pub row_shape: String, + #[serde(default)] + pub locomotive_name: Option, + #[serde(default)] + pub notes: Vec, +} + impl RuntimeEventRecordTemplate { pub fn into_runtime_record(self) -> RuntimeEventRecord { RuntimeEventRecord { @@ -387,6 +426,11 @@ impl RuntimeState { "packed_event_collection.records[{record_index}].decode_status must not be empty" )); } + if record.payload_family.trim().is_empty() { + return Err(format!( + "packed_event_collection.records[{record_index}].payload_family must not be empty" + )); + } if record .import_outcome .as_deref() @@ -401,6 +445,21 @@ impl RuntimeState { "packed_event_collection.records[{record_index}].grouped_effect_row_counts must contain exactly 4 entries" )); } + if record.payload_family == "real_packed_v1" + && record.standalone_condition_rows.len() != record.standalone_condition_row_count + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows must match standalone_condition_row_count" + )); + } + if record.payload_family == "real_packed_v1" + && record.grouped_effect_rows.len() + != record.grouped_effect_row_counts.iter().sum::() + { + return Err(format!( + "packed_event_collection.records[{record_index}].grouped_effect_rows must match grouped_effect_row_counts" + )); + } for band in &record.text_bands { if band.label.trim().is_empty() { return Err(format!( @@ -408,6 +467,33 @@ impl RuntimeState { )); } } + for row in &record.standalone_condition_rows { + if row + .candidate_name + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty candidate_name" + )); + } + } + for row in &record.grouped_effect_rows { + if row.row_shape.trim().is_empty() { + return Err(format!( + "packed_event_collection.records[{record_index}].grouped_effect_rows contains an empty row_shape" + )); + } + if row + .locomotive_name + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].grouped_effect_rows contains an empty locomotive_name" + )); + } + } } } @@ -772,13 +858,16 @@ mod tests { payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, @@ -790,13 +879,16 @@ mod tests { payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index e83ca10..97c8c59 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -91,6 +91,10 @@ const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize = const PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC: &[u8; 8] = b"RPEVT001"; const PACKED_EVENT_RECORD_SYNTHETIC_MAGIC: &[u8; 4] = b"RPE1"; const PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC: &[u8; 4] = b"RPT1"; +const PACKED_EVENT_REAL_CONDITION_MARKER: u16 = 0x526f; +const PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER: u16 = 0x4eb8; +const PACKED_EVENT_REAL_CONDITION_ROW_LEN: usize = 0x1e; +const PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN: usize = 0x28; const PACKED_EVENT_TEXT_BAND_LABELS: [&str; 6] = [ "primary_text_band", "secondary_text_band_0", @@ -1220,6 +1224,8 @@ pub struct SmpLoadedPackedEventRecordSummary { pub payload_len: Option, pub decode_status: String, #[serde(default)] + pub payload_family: String, + #[serde(default)] pub trigger_kind: Option, #[serde(default)] pub active: Option, @@ -1232,8 +1238,12 @@ pub struct SmpLoadedPackedEventRecordSummary { #[serde(default)] pub standalone_condition_row_count: usize, #[serde(default)] + pub standalone_condition_rows: Vec, + #[serde(default)] pub grouped_effect_row_counts: Vec, #[serde(default)] + pub grouped_effect_rows: Vec, + #[serde(default)] pub decoded_actions: Vec, #[serde(default)] pub executable_import_ready: bool, @@ -1249,6 +1259,39 @@ pub struct SmpLoadedPackedEventTextBandSummary { pub preview: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedPackedEventConditionRowSummary { + pub row_index: usize, + pub raw_condition_id: i32, + pub subtype: u8, + #[serde(default)] + pub flag_bytes: Vec, + #[serde(default)] + pub candidate_name: Option, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedPackedEventGroupedEffectRowSummary { + pub group_index: usize, + pub row_index: usize, + pub descriptor_id: u32, + pub opcode: u8, + pub raw_scalar_value: i32, + pub value_byte_0x09: u8, + pub value_dword_0x0d: u32, + pub value_byte_0x11: u8, + pub value_byte_0x12: u8, + pub value_word_0x14: u16, + pub value_word_0x16: u16, + pub row_shape: String, + #[serde(default)] + pub locomotive_name: Option, + #[serde(default)] + pub notes: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpLoadedSaveSlice { pub file_extension_hint: Option, @@ -1576,6 +1619,13 @@ fn parse_event_runtime_record_summaries( records_payload_offset, live_entry_ids, ) + .or_else(|| { + try_parse_real_event_runtime_record_summaries( + records_payload, + records_payload_offset, + live_entry_ids, + ) + }) .unwrap_or_else(|| { build_unsupported_event_runtime_record_summaries( live_entry_ids, @@ -1681,19 +1731,236 @@ fn parse_synthetic_event_runtime_record_summary( } else { "parity_only".to_string() }, + payload_family: "synthetic_harness".to_string(), trigger_kind: Some(trigger_kind), active: Some(flags & 0x01 != 0), marks_collection_dirty: Some(flags & 0x02 != 0), one_shot: Some(flags & 0x04 != 0), text_bands, standalone_condition_row_count, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts, + grouped_effect_rows: Vec::new(), decoded_actions, executable_import_ready, notes: vec!["decoded from the current synthetic packed-event record harness".to_string()], }) } +fn try_parse_real_event_runtime_record_summaries( + records_payload: &[u8], + records_payload_offset: usize, + live_entry_ids: &[u32], +) -> Option> { + let mut cursor = 0usize; + let mut records = Vec::with_capacity(live_entry_ids.len()); + + for (record_index, live_entry_id) in live_entry_ids.iter().copied().enumerate() { + let (record, consumed_len) = parse_real_event_runtime_record_summary( + records_payload.get(cursor..)?, + records_payload_offset + cursor, + record_index, + live_entry_id, + )?; + records.push(record); + cursor += consumed_len; + } + + if cursor != records_payload.len() { + return None; + } + + Some(records) +} + +fn parse_real_event_runtime_record_summary( + record_body: &[u8], + payload_offset: usize, + record_index: usize, + live_entry_id: u32, +) -> Option<(SmpLoadedPackedEventRecordSummary, usize)> { + let mut cursor = 0usize; + let mut text_bands = Vec::with_capacity(PACKED_EVENT_TEXT_BAND_LABELS.len()); + for label in PACKED_EVENT_TEXT_BAND_LABELS { + let packed_len = usize::from(read_u16_at(record_body, cursor)?); + cursor += 2; + let band_bytes = record_body.get(cursor..cursor + packed_len)?; + cursor += packed_len; + text_bands.push(SmpLoadedPackedEventTextBandSummary { + label: label.to_string(), + packed_len, + present: packed_len != 0, + preview: ascii_preview(band_bytes), + }); + } + + if read_u16_at(record_body, cursor)? != PACKED_EVENT_REAL_CONDITION_MARKER { + return None; + } + cursor += 2; + let standalone_condition_row_count = usize::from(read_u16_at(record_body, cursor)?); + cursor += 2; + + let mut standalone_condition_rows = Vec::with_capacity(standalone_condition_row_count); + for row_index in 0..standalone_condition_row_count { + let row_bytes = record_body.get(cursor..cursor + PACKED_EVENT_REAL_CONDITION_ROW_LEN)?; + cursor += PACKED_EVENT_REAL_CONDITION_ROW_LEN; + let candidate_name = parse_optional_u16_len_prefixed_string(record_body, &mut cursor)?; + standalone_condition_rows.push(parse_real_condition_row_summary( + row_bytes, + row_index, + candidate_name, + )?); + } + + if read_u16_at(record_body, cursor)? != PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER { + return None; + } + cursor += 2; + + let mut grouped_effect_row_counts = Vec::with_capacity(4); + for _ in 0..4 { + grouped_effect_row_counts.push(usize::from(read_u16_at(record_body, cursor)?)); + cursor += 2; + } + + let mut grouped_effect_rows = + Vec::with_capacity(grouped_effect_row_counts.iter().sum::()); + for (group_index, row_count) in grouped_effect_row_counts.iter().copied().enumerate() { + for row_index in 0..row_count { + let row_bytes = + record_body.get(cursor..cursor + PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN)?; + cursor += PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN; + let locomotive_name = + parse_optional_u16_len_prefixed_string(record_body, &mut cursor)?; + grouped_effect_rows.push(parse_real_grouped_effect_row_summary( + row_bytes, + group_index, + row_index, + locomotive_name, + )?); + } + } + + let consumed_len = cursor; + Some(( + SmpLoadedPackedEventRecordSummary { + record_index, + live_entry_id, + payload_offset: Some(payload_offset), + payload_len: Some(consumed_len), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: None, + active: None, + marks_collection_dirty: None, + one_shot: None, + text_bands, + standalone_condition_row_count, + standalone_condition_rows, + grouped_effect_row_counts, + grouped_effect_rows, + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }, + consumed_len, + )) +} + +fn parse_real_condition_row_summary( + row_bytes: &[u8], + row_index: usize, + candidate_name: Option, +) -> Option { + let raw_condition_id = read_u32_at(row_bytes, 0)? as i32; + let subtype = read_u8_at(row_bytes, 4)?; + let mut notes = Vec::new(); + if raw_condition_id < 0 { + notes.push("negative sentinel-style condition row id".to_string()); + } + if candidate_name.is_some() { + notes.push("condition row carries candidate-name side string".to_string()); + } + Some(SmpLoadedPackedEventConditionRowSummary { + row_index, + raw_condition_id, + subtype, + flag_bytes: row_bytes.get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)?.to_vec(), + candidate_name, + notes, + }) +} + +fn parse_real_grouped_effect_row_summary( + row_bytes: &[u8], + group_index: usize, + row_index: usize, + locomotive_name: Option, +) -> Option { + let descriptor_id = read_u32_at(row_bytes, 0)?; + let raw_scalar_value = read_u32_at(row_bytes, 4)? as i32; + let opcode = read_u8_at(row_bytes, 8)?; + let value_byte_0x09 = read_u8_at(row_bytes, 9)?; + let value_dword_0x0d = read_u32_at(row_bytes, 0x0d)?; + let value_byte_0x11 = read_u8_at(row_bytes, 0x11)?; + let value_byte_0x12 = read_u8_at(row_bytes, 0x12)?; + let value_word_0x14 = read_u16_at(row_bytes, 0x14)?; + let value_word_0x16 = read_u16_at(row_bytes, 0x16)?; + let row_shape = classify_real_grouped_effect_row_shape( + opcode, + raw_scalar_value, + value_byte_0x11, + value_byte_0x12, + value_word_0x14, + value_word_0x16, + ) + .to_string(); + + let mut notes = Vec::new(); + if locomotive_name.is_some() { + notes.push("grouped effect row carries locomotive-name side string".to_string()); + } + + Some(SmpLoadedPackedEventGroupedEffectRowSummary { + group_index, + row_index, + descriptor_id, + opcode, + raw_scalar_value, + value_byte_0x09, + value_dword_0x0d, + value_byte_0x11, + value_byte_0x12, + value_word_0x14, + value_word_0x16, + row_shape, + locomotive_name, + notes, + }) +} + +fn classify_real_grouped_effect_row_shape( + opcode: u8, + raw_scalar_value: i32, + value_byte_0x11: u8, + value_byte_0x12: u8, + value_word_0x14: u16, + value_word_0x16: u16, +) -> &'static str { + if opcode == 8 { + return "multivalue_scalar"; + } + if value_byte_0x11 != 0 || value_byte_0x12 != 0 || value_word_0x14 != 0 || value_word_0x16 != 0 + { + return "timed_duration"; + } + if raw_scalar_value == 0 || raw_scalar_value == 1 { + return "bool_toggle"; + } + "raw_other" +} + fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option { let opcode = read_u8_at(bytes, *cursor)?; *cursor += 1; @@ -1824,6 +2091,17 @@ fn parse_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option Some(String::from_utf8_lossy(text_bytes).into_owned()) } +fn parse_optional_u16_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option> { + let len = usize::from(read_u16_at(bytes, *cursor)?); + *cursor += 2; + if len == 0 { + return Some(None); + } + let text_bytes = bytes.get(*cursor..*cursor + len)?; + *cursor += len; + Some(Some(String::from_utf8_lossy(text_bytes).into_owned())) +} + fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { match effect { RuntimeEffect::SetWorldFlag { .. } @@ -1858,13 +2136,16 @@ fn build_unsupported_event_runtime_record_summaries( payload_offset: None, payload_len: None, decode_status: "unsupported_framing".to_string(), + payload_family: "unsupported_framing".to_string(), trigger_kind: None, active: None, marks_collection_dirty: None, one_shot: None, text_bands: Vec::new(), standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec![note.to_string()], @@ -6701,6 +6982,90 @@ mod tests { bytes } + fn encode_real_optional_string(text: &str) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&(text.len() as u16).to_le_bytes()); + bytes.extend_from_slice(text.as_bytes()); + bytes + } + + fn build_real_condition_row( + raw_condition_id: i32, + subtype: u8, + flag_seed: u8, + candidate_name: Option<&str>, + ) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&(raw_condition_id as u32).to_le_bytes()); + bytes.push(subtype); + while bytes.len() < PACKED_EVENT_REAL_CONDITION_ROW_LEN { + bytes.push(flag_seed.wrapping_add(bytes.len() as u8)); + } + match candidate_name { + Some(text) => bytes.extend_from_slice(&encode_real_optional_string(text)), + None => bytes.extend_from_slice(&0u16.to_le_bytes()), + } + bytes + } + + struct RealGroupedEffectRowSpec<'a> { + descriptor_id: u32, + opcode: u8, + raw_scalar_value: i32, + value_byte_0x09: u8, + value_dword_0x0d: u32, + value_byte_0x11: u8, + value_byte_0x12: u8, + value_word_0x14: u16, + value_word_0x16: u16, + locomotive_name: Option<&'a str>, + } + + fn build_real_grouped_effect_row(spec: RealGroupedEffectRowSpec<'_>) -> Vec { + let mut bytes = vec![0; PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN]; + bytes[0..4].copy_from_slice(&spec.descriptor_id.to_le_bytes()); + bytes[4..8].copy_from_slice(&(spec.raw_scalar_value as u32).to_le_bytes()); + bytes[8] = spec.opcode; + bytes[9] = spec.value_byte_0x09; + bytes[0x0d..0x11].copy_from_slice(&spec.value_dword_0x0d.to_le_bytes()); + bytes[0x11] = spec.value_byte_0x11; + bytes[0x12] = spec.value_byte_0x12; + bytes[0x14..0x16].copy_from_slice(&spec.value_word_0x14.to_le_bytes()); + bytes[0x16..0x18].copy_from_slice(&spec.value_word_0x16.to_le_bytes()); + match spec.locomotive_name { + Some(text) => bytes.extend_from_slice(&encode_real_optional_string(text)), + None => bytes.extend_from_slice(&0u16.to_le_bytes()), + } + bytes + } + + fn build_real_event_record( + text_bands: [&[u8]; 6], + condition_rows: &[Vec], + grouped_rows: [&[Vec]; 4], + ) -> Vec { + let mut bytes = Vec::new(); + for band in text_bands { + bytes.extend_from_slice(&(band.len() as u16).to_le_bytes()); + bytes.extend_from_slice(band); + } + bytes.extend_from_slice(&PACKED_EVENT_REAL_CONDITION_MARKER.to_le_bytes()); + bytes.extend_from_slice(&(condition_rows.len() as u16).to_le_bytes()); + for row in condition_rows { + bytes.extend_from_slice(row); + } + bytes.extend_from_slice(&PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER.to_le_bytes()); + for rows in grouped_rows { + bytes.extend_from_slice(&(rows.len() as u16).to_le_bytes()); + } + for rows in grouped_rows { + for row in rows { + bytes.extend_from_slice(row); + } + } + bytes + } + #[test] fn parses_synthetic_event_runtime_record_summaries_and_actions() { let append_template = encode_template( @@ -6746,6 +7111,7 @@ mod tests { assert_eq!(summary.imported_runtime_record_count, 1); assert_eq!(summary.records.len(), 1); assert_eq!(summary.records[0].decode_status, "executable"); + assert_eq!(summary.records[0].payload_family, "synthetic_harness"); assert_eq!(summary.records[0].text_bands[0].preview, "Alpha"); assert_eq!(summary.records[0].standalone_condition_row_count, 1); assert_eq!( @@ -6797,9 +7163,143 @@ mod tests { assert_eq!(summary.decoded_record_count, 1); assert_eq!(summary.imported_runtime_record_count, 0); assert_eq!(summary.records[0].decode_status, "parity_only"); + assert_eq!(summary.records[0].payload_family, "synthetic_harness"); assert!(!summary.records[0].executable_import_ready); } + #[test] + fn parses_real_style_event_runtime_record_with_zero_rows() { + let record_body = build_real_event_record( + [b"Alpha", b"", b"", b"", b"", b""], + &[], + [&[], &[], &[], &[]], + ); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes()); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes()); + let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for word in header_words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&[0x00, 0x00]); + bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes()); + bytes.extend_from_slice(&record_body); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes()); + + let report = inspect_smp_bytes(&bytes); + let summary = report + .event_runtime_collection_summary + .as_ref() + .expect("event runtime collection summary should parse"); + + assert_eq!(summary.decoded_record_count, 1); + assert_eq!(summary.imported_runtime_record_count, 0); + assert_eq!(summary.records[0].decode_status, "parity_only"); + assert_eq!(summary.records[0].payload_family, "real_packed_v1"); + assert_eq!(summary.records[0].text_bands[0].preview, "Alpha"); + assert_eq!(summary.records[0].standalone_condition_row_count, 0); + assert_eq!(summary.records[0].standalone_condition_rows.len(), 0); + assert_eq!(summary.records[0].grouped_effect_row_counts, vec![0, 0, 0, 0]); + assert_eq!(summary.records[0].grouped_effect_rows.len(), 0); + } + + #[test] + fn parses_real_style_rows_and_side_strings() { + let condition_row = build_real_condition_row(-1, 4, 0x30, Some("AutoPlant")); + let grouped_row = build_real_grouped_effect_row(RealGroupedEffectRowSpec { + descriptor_id: 2, + opcode: 8, + raw_scalar_value: 7, + value_byte_0x09: 1, + value_dword_0x0d: 12, + value_byte_0x11: 2, + value_byte_0x12: 3, + value_word_0x14: 24, + value_word_0x16: 36, + locomotive_name: Some("Mikado"), + }); + let group0_rows = vec![grouped_row]; + let record_body = build_real_event_record( + [b"Gamma", b"", b"", b"", b"", b""], + &[condition_row], + [&group0_rows, &[], &[], &[]], + ); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes()); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes()); + let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for word in header_words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&[0x00, 0x00]); + bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes()); + bytes.extend_from_slice(&record_body); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes()); + + let report = inspect_smp_bytes(&bytes); + let summary = report + .event_runtime_collection_summary + .as_ref() + .expect("event runtime collection summary should parse"); + + assert_eq!(summary.records[0].standalone_condition_rows.len(), 1); + assert_eq!(summary.records[0].standalone_condition_rows[0].raw_condition_id, -1); + assert_eq!( + summary.records[0].standalone_condition_rows[0] + .candidate_name + .as_deref(), + Some("AutoPlant") + ); + assert_eq!(summary.records[0].grouped_effect_rows.len(), 1); + assert_eq!(summary.records[0].grouped_effect_rows[0].opcode, 8); + assert_eq!( + summary.records[0].grouped_effect_rows[0].row_shape, + "multivalue_scalar" + ); + assert_eq!( + summary.records[0].grouped_effect_rows[0] + .locomotive_name + .as_deref(), + Some("Mikado") + ); + } + + #[test] + fn rejects_truncated_real_style_event_runtime_record() { + let mut record_body = build_real_event_record( + [b"Oops", b"", b"", b"", b"", b""], + &[], + [&[], &[], &[], &[]], + ); + record_body.pop(); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes()); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes()); + let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for word in header_words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&[0x00, 0x00]); + bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes()); + bytes.extend_from_slice(&record_body); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes()); + + let report = inspect_smp_bytes(&bytes); + let summary = report + .event_runtime_collection_summary + .as_ref() + .expect("event runtime collection summary should parse"); + + assert_eq!(summary.records[0].decode_status, "unsupported_framing"); + assert_eq!(summary.records[0].payload_family, "unsupported_framing"); + } + #[test] fn loads_event_runtime_collection_summary_from_report() { let mut report = inspect_smp_bytes(&[]); diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index daba4f9..e7f09a6 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -35,6 +35,7 @@ pub struct RuntimeSummary { pub packed_event_parity_only_record_count: usize, pub packed_event_unsupported_record_count: usize, pub packed_event_blocked_missing_company_context_count: usize, + pub packed_event_blocked_structural_only_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, pub zero_candidate_availability_count: usize, @@ -168,6 +169,19 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_structural_only_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() == Some("blocked_structural_only") + }) + .count() + }) + .unwrap_or(0), event_runtime_record_count: state.event_runtime_records.len(), candidate_availability_count: state.candidate_availability.len(), zero_candidate_availability_count: state @@ -207,3 +221,97 @@ impl RuntimeSummary { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + CalendarPoint, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, + RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, + }; + + use super::RuntimeSummary; + + #[test] + fn counts_structural_only_and_missing_context_frontiers() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: Vec::new(), + packed_event_collection: Some(RuntimePackedEventCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 7, + live_record_count: 2, + live_entry_ids: vec![3, 7], + decoded_record_count: 2, + imported_runtime_record_count: 0, + records: vec![ + RuntimePackedEventRecordSummary { + record_index: 0, + live_entry_id: 3, + payload_offset: Some(0x7202), + payload_len: Some(96), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: None, + active: None, + marks_collection_dirty: None, + one_shot: None, + text_bands: Vec::new(), + standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), + grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), + decoded_actions: Vec::new(), + executable_import_ready: false, + import_outcome: Some("blocked_structural_only".to_string()), + notes: Vec::new(), + }, + RuntimePackedEventRecordSummary { + record_index: 1, + live_entry_id: 7, + payload_offset: Some(0x7262), + payload_len: Some(48), + decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), + trigger_kind: Some(7), + active: Some(true), + marks_collection_dirty: Some(false), + one_shot: Some(false), + text_bands: Vec::new(), + standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), + grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: Vec::new(), + decoded_actions: Vec::new(), + executable_import_ready: false, + import_outcome: Some("blocked_missing_company_context".to_string()), + notes: Vec::new(), + }, + ], + }), + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + let summary = RuntimeSummary::from_state(&state); + assert_eq!(summary.packed_event_blocked_structural_only_count, 1); + assert_eq!(summary.packed_event_blocked_missing_company_context_count, 1); + } +} diff --git a/docs/README.md b/docs/README.md index 0288579..ec05bc8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,10 +75,12 @@ The highest-value next passes are now: - preserve the atlas and function map as the source of subsystem boundaries while continuing to avoid shell-first implementation bets -- use captured-context overlay imports whenever save-derived packed rows need live runtime context - that the save slice does not actually persist -- widen packed-event target-family coverage only where static evidence is strong enough to support - deterministic executable import after the necessary runtime context is present +- move the packed-event parser from the synthetic harness onto real `0x4e9a` structural decode so + real rows stop collapsing to generic unsupported framing +- use overlay imports as the context bridge when selectively executable packed rows still need live + company state that save slices do not persist +- widen real packed-event executable coverage only after the structural decode frontier and row + summaries are stable - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution environment - keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 592bca1..0e2f07b 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -26,8 +26,7 @@ Implemented today: normalized state-fragment assertions, and imported packed-event execution That means the next implementation work is breadth, not bootstrap. The recommended next slice is -captured-context overlay import for company-targeted packed events, not another persistence -scaffold pass. +real `0x4e9a` packed-event structural decode, not another persistence scaffold pass. ## Why This Boundary @@ -366,37 +365,37 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is captured-context overlay import on top of the -save-slice and snapshot workflows that already exist today. +The recommended next implementation slice is real `0x4e9a` packed-event structural decode on top +of the save-slice, snapshot, and overlay workflows that already exist today. Target behavior: -- preserve save slices as partial state rather than pretending they reconstruct full live company - state -- overlay save-derived packed-event state onto a captured runtime snapshot that already has the - needed company roster and other live context -- upgrade currently blocked company-targeted packed rows when the overlaid base snapshot provides - every referenced company id -- keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning +- parse real packed-event record bodies structurally instead of dropping them straight to + `unsupported_framing` +- preserve the first real decode pass as parity-only, exposing text-band and row-family structure + without guessing executable semantics +- keep the existing synthetic harness and overlay-backed executable import path working unchanged +- reserve selective executable import from real rows for a later pass once row semantics are tighter Public-model additions for that slice: -- tracked overlay import documents that reference one base snapshot plus one save-slice document -- runtime-side import outcome labels for packed records so blocked-missing-context and - blocked-unsupported cases stay explicit -- fixture support for generic runtime-import documents, not just snapshots or save slices +- payload-family labeling that distinguishes synthetic harness records from real packed rows and + unsupported framing +- structural row summaries for real standalone condition rows and grouped effect rows +- runtime-side import outcome labels that distinguish `blocked_structural_only` from + `blocked_missing_company_context` and `blocked_unsupported_decode` Fixture work for that slice: -- overlay-backed fixtures that prove company-targeted packed rows execute deterministically against - captured company context -- regression fixtures that lock the before/after boundary between save-slice-only imports and - overlay-backed imports -- state-fragment assertions that lock both packed parity summaries and imported executable records +- one parity-heavy tracked sample that now exposes a real structurally decoded row family +- regression fixtures that keep synthetic executable import and overlay-backed company-context + upgrade behavior green +- state-fragment assertions that lock the new structural row summaries and + `blocked_structural_only` frontier Do not mix this slice with: - territory-access or selected-profile parity - placed-structure batch placement parity - shell queue/modal behavior -- broad speculative translation of packed RT3 event rows into executable normalized effects +- broad speculative translation of real packed RT3 event rows into executable normalized effects diff --git a/fixtures/runtime/packed-event-collection-snapshot.json b/fixtures/runtime/packed-event-collection-snapshot.json index ae88d4c..cb6409f 100644 --- a/fixtures/runtime/packed-event-collection-snapshot.json +++ b/fixtures/runtime/packed-event-collection-snapshot.json @@ -30,6 +30,7 @@ "record_index": 0, "live_entry_id": 1, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -39,6 +40,7 @@ "record_index": 1, "live_entry_id": 3, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -48,6 +50,7 @@ "record_index": 2, "live_entry_id": 5, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index 48f3573..e6fe58b 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -26,6 +26,7 @@ "packed_event_imported_runtime_record_count": 0, "packed_event_parity_only_record_count": 1, "packed_event_unsupported_record_count": 1, + "packed_event_blocked_structural_only_count": 1, "event_runtime_record_count": 0, "total_company_cash": 0 }, @@ -40,10 +41,24 @@ "live_entry_ids": [3, 5], "records": [ { - "decode_status": "unsupported_framing" + "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing" }, { - "decode_status": "parity_only" + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "import_outcome": "blocked_structural_only", + "standalone_condition_rows": [ + { + "candidate_name": "AutoPlant" + } + ], + "grouped_effect_rows": [ + { + "row_shape": "multivalue_scalar", + "locomotive_name": "Mikado" + } + ] } ] } diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json index 1297b60..d14a243 100644 --- a/fixtures/runtime/packed-event-parity-save-slice.json +++ b/fixtures/runtime/packed-event-parity-save-slice.json @@ -42,6 +42,7 @@ "payload_offset": 29186, "payload_len": 96, "decode_status": "unsupported_framing", + "payload_family": "unsupported_framing", "grouped_effect_row_counts": [0, 0, 0, 0], "decoded_actions": [], "executable_import_ready": false, @@ -55,10 +56,7 @@ "payload_offset": 29290, "payload_len": 72, "decode_status": "parity_only", - "trigger_kind": 7, - "active": true, - "marks_collection_dirty": false, - "one_shot": false, + "payload_family": "real_packed_v1", "text_bands": [ { "label": "primary_text_band", @@ -97,21 +95,49 @@ "preview": "" } ], - "standalone_condition_row_count": 0, - "grouped_effect_row_counts": [0, 0, 0, 0], - "decoded_actions": [ + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ { - "kind": "adjust_company_cash", - "target": { - "kind": "ids", - "ids": [42] - }, - "delta": 75 + "row_index": 0, + "raw_condition_id": -1, + "subtype": 4, + "flag_bytes": [ + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, + 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, + 68, 69, 70, 71, 72 + ], + "candidate_name": "AutoPlant", + "notes": [ + "negative sentinel-style condition row id", + "condition row carries candidate-name side string" + ] } ], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 2, + "opcode": 8, + "raw_scalar_value": 7, + "value_byte_0x09": 1, + "value_dword_0x0d": 12, + "value_byte_0x11": 2, + "value_byte_0x12": 3, + "value_word_0x14": 24, + "value_word_0x16": 36, + "row_shape": "multivalue_scalar", + "locomotive_name": "Mikado", + "notes": [ + "grouped effect row carries locomotive-name side string" + ] + } + ], + "decoded_actions": [], "executable_import_ready": false, "notes": [ - "decoded action requires explicit imported company ids before execution" + "decoded from grounded real 0x4e9a row framing" ] } ] diff --git a/fixtures/runtime/packed-event-record-import-snapshot.json b/fixtures/runtime/packed-event-record-import-snapshot.json index 268288b..b116854 100644 --- a/fixtures/runtime/packed-event-record-import-snapshot.json +++ b/fixtures/runtime/packed-event-record-import-snapshot.json @@ -32,6 +32,7 @@ "payload_offset": 29186, "payload_len": 64, "decode_status": "executable", + "payload_family": "synthetic_harness", "trigger_kind": 7, "active": true, "marks_collection_dirty": true, @@ -75,7 +76,9 @@ } ], "standalone_condition_row_count": 1, + "standalone_condition_rows": [], "grouped_effect_row_counts": [0, 1, 0, 0], + "grouped_effect_rows": [], "decoded_actions": [ { "kind": "set_world_flag", diff --git a/fixtures/runtime/packed-event-selective-import-save-slice.json b/fixtures/runtime/packed-event-selective-import-save-slice.json index 385ef21..952819f 100644 --- a/fixtures/runtime/packed-event-selective-import-save-slice.json +++ b/fixtures/runtime/packed-event-selective-import-save-slice.json @@ -42,6 +42,7 @@ "payload_offset": 29186, "payload_len": 64, "decode_status": "executable", + "payload_family": "synthetic_harness", "trigger_kind": 7, "active": true, "marks_collection_dirty": true, @@ -85,7 +86,9 @@ } ], "standalone_condition_row_count": 1, + "standalone_condition_rows": [], "grouped_effect_row_counts": [0, 1, 0, 0], + "grouped_effect_rows": [], "decoded_actions": [ { "kind": "set_world_flag", @@ -121,6 +124,7 @@ "payload_offset": 29260, "payload_len": 72, "decode_status": "parity_only", + "payload_family": "synthetic_harness", "trigger_kind": 7, "active": true, "marks_collection_dirty": false, @@ -164,7 +168,9 @@ } ], "standalone_condition_row_count": 0, + "standalone_condition_rows": [], "grouped_effect_row_counts": [0, 0, 0, 0], + "grouped_effect_rows": [], "decoded_actions": [ { "kind": "adjust_company_cash",