diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 8c95a5c..de0d536 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4129,6 +4129,87 @@ mod tests { let _ = fs::remove_file(right_path); } + #[test] + fn diffs_runtime_states_with_packed_event_collection_changes() { + let left = serde_json::json!({ + "format_version": 1, + "snapshot_id": "left-packed-events", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flags": {}, + "companies": [], + "packed_event_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 5, + "live_record_count": 3, + "live_entry_ids": [1, 3, 5] + }, + "event_runtime_records": [] + } + }); + let right = serde_json::json!({ + "format_version": 1, + "snapshot_id": "right-packed-events", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flags": {}, + "companies": [], + "packed_event_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 5, + "live_record_count": 2, + "live_entry_ids": [1, 5] + }, + "event_runtime_records": [] + } + }); + let left_path = write_temp_json("runtime-diff-packed-events-left", &left); + let right_path = write_temp_json("runtime-diff-packed-events-right", &right); + + let left_state = + load_normalized_runtime_state(&left_path).expect("left runtime state should load"); + let right_state = + load_normalized_runtime_state(&right_path).expect("right runtime state should load"); + let differences = diff_json_values(&left_state, &right_state); + + assert!(differences.iter().any(|entry| { + entry.path == "$.packed_event_collection.live_record_count" + || entry.path == "$.packed_event_collection.live_entry_ids[1]" + })); + + let _ = fs::remove_file(left_path); + let _ = fs::remove_file(right_path); + } + + #[test] + fn summarizes_snapshot_backed_fixture_with_packed_event_collection() { + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-collection-from-snapshot.json"); + + run_runtime_summarize_fixture(&fixture_path) + .expect("snapshot-backed packed-event fixture should summarize"); + } + #[test] fn diffs_classic_profile_samples_across_multiple_files() { let sample_a = RuntimeClassicProfileSample { diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index b9c335e..8093021 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -117,6 +117,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 01ba454..19b1d26 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -4,8 +4,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::{ - CalendarPoint, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, - RuntimeWorldRestoreState, SmpLoadedSaveSlice, + CalendarPoint, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedSaveSlice, }; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; @@ -56,6 +56,10 @@ pub fn project_save_slice_to_runtime_state_import( "save_slice.special_conditions_present".to_string(), save_slice.special_conditions_table.is_some(), ); + world_flags.insert( + "save_slice.event_runtime_collection_present".to_string(), + save_slice.event_runtime_collection.is_some(), + ); world_flags.insert( "save_slice.mechanism_confidence_grounded".to_string(), save_slice.mechanism_confidence == "grounded", @@ -133,6 +137,33 @@ pub fn project_save_slice_to_runtime_state_import( if let Some(family) = &save_slice.bridge_family { metadata.insert("save_slice.bridge_family".to_string(), family.clone()); } + let packed_event_collection = save_slice.event_runtime_collection.as_ref().map(|summary| { + RuntimePackedEventCollectionSummary { + source_kind: summary.source_kind.clone(), + mechanism_family: summary.mechanism_family.clone(), + mechanism_confidence: summary.mechanism_confidence.clone(), + container_profile_family: summary.container_profile_family.clone(), + packed_state_version: summary.packed_state_version, + packed_state_version_hex: summary.packed_state_version_hex.clone(), + live_id_bound: summary.live_id_bound, + live_record_count: summary.live_record_count, + live_entry_ids: summary.live_entry_ids.clone(), + } + }); + if let Some(summary) = &save_slice.event_runtime_collection { + metadata.insert( + "save_slice.event_runtime_collection_source_kind".to_string(), + summary.source_kind.clone(), + ); + metadata.insert( + "save_slice.event_runtime_collection_version_hex".to_string(), + summary.packed_state_version_hex.clone(), + ); + metadata.insert( + "save_slice.event_runtime_collection_record_count".to_string(), + summary.live_record_count.to_string(), + ); + } let save_profile = if let Some(profile) = &save_slice.profile { metadata.insert( "save_slice.profile_kind".to_string(), @@ -296,6 +327,7 @@ pub fn project_save_slice_to_runtime_state_import( world_restore, metadata, companies: Vec::new(), + packed_event_collection, event_runtime_records: Vec::new(), candidate_availability, special_conditions, @@ -379,6 +411,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), @@ -502,6 +535,20 @@ mod tests { }, ], }), + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(), + mechanism_confidence: "mixed".to_string(), + container_profile_family: Some("rt3-105-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: 5, + live_record_count: 3, + live_entry_ids: vec![1, 3, 5], + }), notes: vec!["packed profile recovered".to_string()], }; @@ -644,5 +691,22 @@ mod tests { .get("save_slice.profile_byte_0x82_nonzero"), Some(&true) ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.live_record_count), + Some(3) + ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.live_entry_ids.clone()), + Some(vec![1, 3, 5]) + ); + assert!(import.state.event_runtime_records.is_empty()); } } diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index a4a349e..34e1f33 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -30,16 +30,16 @@ pub use pk4::{ }; pub use runtime::{ RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, - RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, - RuntimeWorldRestoreState, + RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, }; pub use smp::{ SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAlignedRuntimeRuleBandLane, SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock, SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe, SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, - SmpLoadedCandidateAvailabilityTable, SmpLoadedProfile, SmpLoadedSaveSlice, - SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, + SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, SmpLoadedProfile, + SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe, SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane, SmpPostSpecialConditionsScalarProbe, SmpPostTextFieldNeighborhoodProbe, diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index 8760784..585e34d 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -93,6 +93,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 1d3cdf9..82bf747 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -85,6 +85,20 @@ pub struct RuntimeEventRecord { pub effects: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePackedEventCollectionSummary { + pub source_kind: String, + pub mechanism_family: String, + pub mechanism_confidence: String, + #[serde(default)] + pub container_profile_family: Option, + pub packed_state_version: u32, + pub packed_state_version_hex: String, + pub live_id_bound: u32, + pub live_record_count: usize, + pub live_entry_ids: Vec, +} + impl RuntimeEventRecordTemplate { pub fn into_runtime_record(self) -> RuntimeEventRecord { RuntimeEventRecord { @@ -184,6 +198,8 @@ pub struct RuntimeState { #[serde(default)] pub companies: Vec, #[serde(default)] + pub packed_event_collection: Option, + #[serde(default)] pub event_runtime_records: Vec, #[serde(default)] pub candidate_availability: BTreeMap, @@ -219,6 +235,66 @@ impl RuntimeState { } } + if let Some(summary) = &self.packed_event_collection { + if summary.source_kind.trim().is_empty() { + return Err("packed_event_collection.source_kind must not be empty".to_string()); + } + if summary.mechanism_family.trim().is_empty() { + return Err( + "packed_event_collection.mechanism_family must not be empty".to_string() + ); + } + if summary.mechanism_confidence.trim().is_empty() { + return Err( + "packed_event_collection.mechanism_confidence must not be empty".to_string(), + ); + } + if summary + .container_profile_family + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err( + "packed_event_collection.container_profile_family must not be empty" + .to_string(), + ); + } + if summary.packed_state_version_hex.trim().is_empty() { + return Err( + "packed_event_collection.packed_state_version_hex must not be empty" + .to_string(), + ); + } + if summary.live_record_count != summary.live_entry_ids.len() { + return Err( + "packed_event_collection.live_record_count must match live_entry_ids length" + .to_string(), + ); + } + + let mut previous_id = None; + for entry_id in &summary.live_entry_ids { + if *entry_id == 0 { + return Err( + "packed_event_collection.live_entry_ids must not contain id 0".to_string(), + ); + } + if *entry_id > summary.live_id_bound { + return Err(format!( + "packed_event_collection.live_entry_id {} exceeds live_id_bound {}", + entry_id, summary.live_id_bound + )); + } + if previous_id.is_some_and(|prior| prior >= *entry_id) { + return Err( + "packed_event_collection.live_entry_ids must be strictly ascending" + .to_string(), + ); + } + previous_id = Some(*entry_id); + } + } + for key in self.world_flags.keys() { if key.trim().is_empty() { return Err("world_flags contains an empty key".to_string()); @@ -402,6 +478,7 @@ mod tests { debt: 0, }, ], + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), @@ -447,6 +524,7 @@ mod tests { }, metadata: BTreeMap::new(), companies: Vec::new(), + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), @@ -474,6 +552,7 @@ mod tests { current_cash: 100, debt: 0, }], + packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, trigger_kind: 1, @@ -513,6 +592,7 @@ mod tests { current_cash: 100, debt: 0, }], + packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, trigger_kind: 1, @@ -542,4 +622,38 @@ mod tests { assert!(state.validate().is_err()); } + + #[test] + fn rejects_invalid_packed_event_collection_summary() { + 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: 4, + live_record_count: 2, + live_entry_ids: vec![3, 3], + }), + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } } diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 42bff84..0fa26a6 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -79,6 +79,13 @@ const RECIPE_BOOK_LINE_STRIDE: usize = 0x30; const RECIPE_BOOK_LINE_AREA_LEN: usize = RECIPE_BOOK_LINE_COUNT * RECIPE_BOOK_LINE_STRIDE; const RECIPE_BOOK_SUMMARY_END_OFFSET: usize = RECIPE_BOOK_ROOT_OFFSET + RECIPE_BOOK_COUNT * RECIPE_BOOK_STRIDE; +const EVENT_RUNTIME_COLLECTION_METADATA_TAG: u16 = 0x4e99; +const EVENT_RUNTIME_COLLECTION_RECORDS_TAG: u16 = 0x4e9a; +const EVENT_RUNTIME_COLLECTION_CLOSE_TAG: u16 = 0x4e9b; +const EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION: u32 = 0x000003e9; +const INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT: usize = 19; +const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize = + INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4; const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_START_INDEX: usize = (POST_SPECIAL_CONDITIONS_SCALAR_OFFSET - SPECIAL_CONDITIONS_OFFSET) / 4; const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_DWORD_COUNT: usize = @@ -1167,6 +1174,23 @@ pub struct SmpLoadedSpecialConditionsTable { pub entries: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedEventRuntimeCollectionSummary { + pub source_kind: String, + pub mechanism_family: String, + pub mechanism_confidence: String, + #[serde(default)] + pub container_profile_family: Option, + pub metadata_tag_offset: usize, + pub records_tag_offset: usize, + pub close_tag_offset: usize, + pub packed_state_version: u32, + pub packed_state_version_hex: String, + pub live_id_bound: u32, + pub live_record_count: usize, + pub live_entry_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpLoadedSaveSlice { pub file_extension_hint: Option, @@ -1178,6 +1202,7 @@ pub struct SmpLoadedSaveSlice { pub profile: Option, pub candidate_availability_table: Option, pub special_conditions_table: Option, + pub event_runtime_collection: Option, pub notes: Vec, } @@ -1220,6 +1245,7 @@ pub struct SmpInspectionReport { pub classic_rehydrate_profile_probe: Option, pub rt3_105_packed_profile_probe: Option, pub save_load_summary: Option, + pub event_runtime_collection_summary: Option, pub contains_grounded_runtime_tags: bool, pub known_tag_hits: Vec, pub notes: Vec, @@ -1343,10 +1369,128 @@ pub fn load_save_slice_from_report( profile, candidate_availability_table, special_conditions_table, + event_runtime_collection: report.event_runtime_collection_summary.clone(), notes: summary.notes.clone(), }) } +fn parse_event_runtime_collection_summary( + bytes: &[u8], + container_profile: Option<&SmpContainerProfile>, + save_load_summary: Option<&SmpSaveLoadSummary>, +) -> Option { + let metadata_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_METADATA_TAG); + let record_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_RECORDS_TAG); + let close_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_CLOSE_TAG); + + for metadata_tag_offset in metadata_offsets { + let packed_state_version = read_u32_at(bytes, metadata_tag_offset + 2)?; + if packed_state_version != EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION { + continue; + } + + let records_tag_offset = record_offsets + .iter() + .copied() + .find(|offset| *offset > metadata_tag_offset + 6)?; + let close_tag_offset = close_offsets + .iter() + .copied() + .find(|offset| *offset > records_tag_offset)?; + let metadata_payload = bytes.get(metadata_tag_offset + 6..records_tag_offset)?; + if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN { + continue; + } + + let header_words = (0..INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT) + .map(|index| read_u32_at(metadata_payload, index * 4)) + .collect::>>()?; + let direct_collection_flag = header_words[0]; + let direct_record_stride = usize::try_from(header_words[1]).ok()?; + let live_id_bound = header_words[4]; + let live_record_count = usize::try_from(header_words[5]).ok()?; + if direct_collection_flag == 0 || direct_record_stride == 0 { + continue; + } + + let bitset_len = ((usize::try_from(live_id_bound).ok()?).saturating_add(15)) / 8; + let payload_bytes = direct_record_stride.checked_mul(live_record_count)?; + if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN + bitset_len { + continue; + } + if metadata_payload.len() < bitset_len + payload_bytes { + continue; + } + + let bitset_offset = metadata_payload.len() - bitset_len - payload_bytes; + if bitset_offset < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN { + continue; + } + let bitset = metadata_payload.get(bitset_offset..bitset_offset + bitset_len)?; + let live_entry_ids = decode_live_entry_ids_from_tombstone_bitset(bitset, live_id_bound)?; + if live_entry_ids.len() != live_record_count { + continue; + } + + return Some(SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: save_load_summary + .map(|summary| summary.mechanism_family.clone()) + .unwrap_or_else(|| "unknown".to_string()), + mechanism_confidence: save_load_summary + .map(|summary| summary.mechanism_confidence.clone()) + .unwrap_or_else(|| "inferred".to_string()), + container_profile_family: container_profile + .map(|profile| profile.profile_family.clone()), + metadata_tag_offset, + records_tag_offset, + close_tag_offset, + packed_state_version, + packed_state_version_hex: format!("0x{packed_state_version:08x}"), + live_id_bound, + live_record_count, + live_entry_ids, + }); + } + + None +} + +fn decode_live_entry_ids_from_tombstone_bitset( + bitset: &[u8], + live_id_bound: u32, +) -> Option> { + let ids = decode_live_entry_ids_with_mapping(bitset, live_id_bound, false); + if ids.is_some() { + return ids; + } + decode_live_entry_ids_with_mapping(bitset, live_id_bound, true) +} + +fn decode_live_entry_ids_with_mapping( + bitset: &[u8], + live_id_bound: u32, + subtract_one: bool, +) -> Option> { + let mut live_entry_ids = Vec::new(); + + for entry_id in 1..=live_id_bound { + let bit_index = if subtract_one { + entry_id.checked_sub(1)? + } else { + entry_id + }; + let byte_index = usize::try_from(bit_index / 8).ok()?; + let bit_mask = 1u8.checked_shl(bit_index % 8).unwrap_or(0); + let tombstone_byte = *bitset.get(byte_index)?; + if tombstone_byte & bit_mask == 0 { + live_entry_ids.push(entry_id); + } + } + + Some(live_entry_ids) +} + fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> SmpInspectionReport { let known_tag_hits = KNOWN_TAG_DEFINITIONS .iter() @@ -1475,6 +1619,11 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm rt3_105_packed_profile_probe.as_ref(), rt3_105_save_name_table_probe.as_ref(), ); + let event_runtime_collection_summary = parse_event_runtime_collection_summary( + bytes, + container_profile.as_ref(), + save_load_summary.as_ref(), + ); let mut warnings = Vec::new(); if bytes.is_empty() { warnings @@ -1562,6 +1711,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm classic_rehydrate_profile_probe, rt3_105_packed_profile_probe, save_load_summary, + event_runtime_collection_summary, contains_grounded_runtime_tags: !known_tag_hits.is_empty(), known_tag_hits, notes: vec![ @@ -6025,6 +6175,110 @@ mod tests { assert!(slice.candidate_availability_table.is_none()); } + #[test] + fn parses_event_runtime_collection_summary_from_synthetic_chunks() { + 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, 5, 3, 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(&[0x14, 0x00]); + bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]); + bytes.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]); + bytes.extend_from_slice(&[0x55, 0x66, 0x77, 0x88]); + bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes()); + bytes.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + 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.packed_state_version, 0x3e9); + assert_eq!(summary.live_id_bound, 5); + assert_eq!(summary.live_record_count, 3); + assert_eq!(summary.live_entry_ids, vec![1, 3, 5]); + assert_eq!(summary.records_tag_offset, 96); + } + + #[test] + fn loads_event_runtime_collection_summary_from_report() { + let mut report = inspect_smp_bytes(&[]); + let classic_probe = SmpClassicRehydrateProfileProbe { + profile_family: "rt3-classic-save-container-v1".to_string(), + progress_32dc_offset: 0x76e8, + progress_3714_offset: 0x76ec, + progress_3715_offset: 0x77f8, + packed_profile_offset: 0x76f0, + packed_profile_len: 0x108, + packed_profile_len_hex: "0x108".to_string(), + packed_profile_block: SmpClassicPackedProfileBlock { + relative_len: 0x108, + relative_len_hex: "0x108".to_string(), + leading_word_0: 3, + leading_word_0_hex: "0x00000003".to_string(), + trailing_zero_word_count_after_leading_word: 3, + map_path_offset: 0x13, + map_path: Some("British Isles.gmp".to_string()), + display_name_offset: 0x46, + display_name: Some("British Isles".to_string()), + profile_byte_0x77: 0, + profile_byte_0x77_hex: "0x00".to_string(), + profile_byte_0x82: 0, + profile_byte_0x82_hex: "0x00".to_string(), + profile_byte_0x97: 0, + profile_byte_0x97_hex: "0x00".to_string(), + profile_byte_0xc5: 0, + profile_byte_0xc5_hex: "0x00".to_string(), + stable_nonzero_words: vec![], + }, + ascii_runs: vec![], + }; + report.classic_rehydrate_profile_probe = Some(classic_probe.clone()); + report.save_load_summary = build_save_load_summary( + Some("gms"), + Some(&SmpContainerProfile { + profile_family: "rt3-classic-save-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + None, + None, + Some(&classic_probe), + None, + None, + ); + report.event_runtime_collection_summary = Some(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: 5, + live_record_count: 3, + live_entry_ids: vec![1, 3, 5], + }); + + let slice = load_save_slice_from_report(&report).expect("classic save slice"); + assert_eq!( + slice + .event_runtime_collection + .as_ref() + .map(|summary| summary.live_entry_ids.clone()), + Some(vec![1, 3, 5]) + ); + } + #[test] fn loads_rt3_105_save_slice_from_report() { let mut report = inspect_smp_bytes(&[]); diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index f5d4cfc..ea97e72 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -473,6 +473,7 @@ mod tests { current_cash: 10, debt: 0, }], + packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index fdcdbf3..f07fe84 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -28,6 +28,8 @@ pub struct RuntimeSummary { pub world_restore_absolute_counter_adjustment_context: Option, pub metadata_count: usize, pub company_count: usize, + pub packed_event_collection_present: bool, + pub packed_event_record_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, pub zero_candidate_availability_count: usize, @@ -109,6 +111,12 @@ impl RuntimeSummary { .clone(), metadata_count: state.metadata.len(), company_count: state.companies.len(), + packed_event_collection_present: state.packed_event_collection.is_some(), + packed_event_record_count: state + .packed_event_collection + .as_ref() + .map(|summary| summary.live_record_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 diff --git a/docs/README.md b/docs/README.md index 851b66c..d715162 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,15 +66,15 @@ Current local tool status: The atlas milestone is broad enough that the next implementation focus has already shifted downward into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic -trigger dispatch, normalized runtime effects, fixture execution, state-diff tooling, and initial -persistence surfaces. +trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution, +state-diff tooling, and initial persistence surfaces. 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 -- broaden the normalized event-service layer through staged event-record mutation and follow-on - record behavior +- deepen the `.smp` event bridge from collection-level structural summaries toward per-record + packed-body coverage - deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and fixture surfaces - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index b05a00c..2c3006a 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -18,14 +18,15 @@ Implemented today: - `rrt-runtime` exists with a deterministic calendar model, step commands, runtime summaries, and normalized runtime state validation - periodic trigger dispatch exists, including ordered periodic maintenance, dirty rerun `0x0a`, and - a first normalized runtime-effect surface + a normalized runtime-effect surface with staged event-record mutation - snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the CLI and fixture layers - checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger service, snapshot-backed inputs, and normalized state-fragment assertions That means the next implementation work is breadth, not bootstrap. The recommended next slice is -normalized event-service breadth through staged event-record mutation and follow-on records. +the `.smp` event-collection structural bridge across inspection, save-slice loading, import, and +snapshot-backed fixtures. ## Why This Boundary @@ -189,8 +190,10 @@ Current status: - periodic trigger ordering is implemented - normalized trigger-side effects already exist for world flags, company cash/debt, candidate availability, and special conditions -- one-shot handling and dirty reruns are already covered by synthetic fixtures -- the missing breadth is event-graph mutation and richer trigger-family behavior +- one-shot handling, dirty reruns, and staged append/activate/deactivate/remove behavior are + already covered by synthetic fixtures +- the remaining breadth is richer trigger-family behavior and target resolution, not first-pass + event-graph mutation ### Milestone 3: Persistence Boundary (partially complete) @@ -212,8 +215,10 @@ Current status: - runtime snapshots and state dumps are implemented - `.smp` save inspection and partial save-slice projection already feed normalized runtime state -- the remaining gap is broader captured-runtime and round-trip fixture depth, not the first - persistence surface +- the packed event-collection summary now survives into loaded save slices and projected runtime + snapshots, but per-record packed bodies are still deferred +- the remaining gap is broader captured-runtime and round-trip fixture depth plus deeper `.smp` + event-body decoding, not the first persistence surface ### Milestone 4: Domain Expansion @@ -313,6 +318,7 @@ Keep: - cash, debt, and game-speed-related runtime fields when semantically relevant - collection contents and semantic counts - trigger-side effects +- packed event-collection structural summaries when present ## Risks @@ -340,47 +346,47 @@ The currently implemented normalized runtime surface is: - `runtime validate-fixture`, `runtime summarize-fixture`, `runtime export-fixture-state`, `runtime summarize-state`, `runtime import-state`, and `runtime diff-state` - deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a - first normalized runtime-effect vocabulary -- save-side inspection and partial state projection for `.smp` inputs + normalized runtime-effect vocabulary with staged event-record mutation +- save-side inspection and partial state projection for `.smp` inputs, including the structural + packed event-collection summary Checked-in fixture families already include: - deterministic minimal-world stepping - periodic boundary service - direct trigger-service mutation +- staged event-record lifecycle coverage - snapshot-backed fixture execution ## Next Slice -The recommended next implementation slice is normalized event-service breadth through staged -event-record mutation. +The recommended next implementation slice is deeper `.smp` event persistence, starting from the +structural bridge that already exists today. Target behavior: -- allow one serviced record to append a follow-on runtime record -- allow one serviced record to activate, deactivate, or remove another runtime record -- stage those graph mutations during the pass and commit them only after the pass finishes -- commit staged mutations in exact emission order -- allow newly appended `0x0a` records to run in the dirty rerun after commit, but never in the - original pass snapshot +- keep carrying the packed event collection across `inspect-smp`, `load-save-slice`, + `import-save-state`, snapshots, diffs, and fixtures +- deepen that bridge from collection structure into per-record packed-body summaries +- preserve the separation between parity-shaped packed state and executable normalized runtime state + until the packed layout is better decoded Public-model additions for that slice: -- `RuntimeEventRecordTemplate` -- `RuntimeEffect::AppendEventRecord` -- `RuntimeEffect::ActivateEventRecord` -- `RuntimeEffect::DeactivateEventRecord` -- `RuntimeEffect::RemoveEventRecord` +- packed per-record event summary types on the `.smp` side +- optional runtime-side parity summaries for imported packed event records +- no new executable `RuntimeEffect` variants by default in that slice Fixture work for that slice: -- one synthetic fixture for append plus dirty rerun behavior -- one synthetic fixture for cross-pass activate/deactivate/remove semantics -- state-fragment assertions that lock final collection contents and per-record counters +- one or more snapshot-backed fixtures that prove imported packed event state survives normalize and + diff paths +- synthetic report/save-slice tests that lock the first per-record packed-body parse shape +- state-fragment assertions that lock imported collection ids, version, and record counts Do not mix this slice with: - territory-access or selected-profile parity - placed-structure batch placement parity - shell queue/modal behavior -- packed RT3 event-row import/export parity +- direct translation of packed RT3 event rows into executable normalized effects diff --git a/fixtures/runtime/packed-event-collection-from-snapshot.json b/fixtures/runtime/packed-event-collection-from-snapshot.json new file mode 100644 index 0000000..9616100 --- /dev/null +++ b/fixtures/runtime/packed-event-collection-from-snapshot.json @@ -0,0 +1,47 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-collection-from-snapshot", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a runtime snapshot that carries the packed event collection summary." + }, + "state_snapshot_path": "packed-event-collection-snapshot.json", + "commands": [ + { + "kind": "step_count", + "steps": 1 + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flag_count": 0, + "company_count": 0, + "packed_event_collection_present": true, + "packed_event_record_count": 3, + "event_runtime_record_count": 0, + "total_event_record_service_count": 0, + "periodic_boundary_call_count": 0, + "total_trigger_dispatch_count": 0, + "dirty_rerun_count": 0, + "total_company_cash": 0 + }, + "expected_state_fragment": { + "calendar": { + "tick_slot": 1 + }, + "packed_event_collection": { + "mechanism_family": "classic-save-rehydrate-v1", + "live_record_count": 3, + "live_entry_ids": [ + 1, + 3, + 5 + ] + } + } +} diff --git a/fixtures/runtime/packed-event-collection-snapshot.json b/fixtures/runtime/packed-event-collection-snapshot.json new file mode 100644 index 0000000..fcdd6fc --- /dev/null +++ b/fixtures/runtime/packed-event-collection-snapshot.json @@ -0,0 +1,41 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-collection-snapshot", + "source": { + "description": "Snapshot fixture carrying a projected packed event collection summary." + }, + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 0 + }, + "world_flags": {}, + "companies": [], + "packed_event_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 5, + "live_record_count": 3, + "live_entry_ids": [ + 1, + 3, + 5 + ] + }, + "event_runtime_records": [], + "candidate_availability": {}, + "special_conditions": {}, + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } +}