diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index de0d536..6cc8f4f 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4152,7 +4152,38 @@ mod tests { "packed_state_version_hex": "0x000003e9", "live_id_bound": 5, "live_record_count": 3, - "live_entry_ids": [1, 3, 5] + "live_entry_ids": [1, 3, 5], + "decoded_record_count": 0, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 1, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["left fixture"] + }, + { + "record_index": 1, + "live_entry_id": 3, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["left fixture"] + }, + { + "record_index": 2, + "live_entry_id": 5, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["left fixture"] + } + ] }, "event_runtime_records": [] } @@ -4178,7 +4209,29 @@ mod tests { "packed_state_version_hex": "0x000003e9", "live_id_bound": 5, "live_record_count": 2, - "live_entry_ids": [1, 5] + "live_entry_ids": [1, 5], + "decoded_record_count": 0, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 1, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["right fixture"] + }, + { + "record_index": 1, + "live_entry_id": 5, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["right fixture"] + } + ] }, "event_runtime_records": [] } @@ -4210,6 +4263,155 @@ mod tests { .expect("snapshot-backed packed-event fixture should summarize"); } + #[test] + fn summarizes_snapshot_backed_fixture_with_imported_packed_event_record() { + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-record-import-from-snapshot.json"); + + run_runtime_summarize_fixture(&fixture_path) + .expect("snapshot-backed imported packed-event fixture should summarize"); + } + + #[test] + fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() { + let left = serde_json::json!({ + "format_version": 1, + "snapshot_id": "left-packed-import", + "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": 7, + "live_record_count": 1, + "live_entry_ids": [7], + "decoded_record_count": 0, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 7, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["left placeholder"] + } + ] + }, + "event_runtime_records": [] + } + }); + let right = serde_json::json!({ + "format_version": 1, + "snapshot_id": "right-packed-import", + "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": 7, + "live_record_count": 1, + "live_entry_ids": [7], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 7, + "payload_offset": 29186, + "payload_len": 64, + "decode_status": "executable", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [ + { + "label": "primary_text_band", + "packed_len": 5, + "present": true, + "preview": "Alpha" + } + ], + "standalone_condition_row_count": 1, + "grouped_effect_row_counts": [0, 1, 0, 0], + "decoded_actions": [ + { + "kind": "set_world_flag", + "key": "from_packed_root", + "value": true + } + ], + "executable_import_ready": true, + "notes": ["decoded test record"] + } + ] + }, + "event_runtime_records": [ + { + "record_id": 7, + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "has_fired": false, + "effects": [ + { + "kind": "set_world_flag", + "key": "from_packed_root", + "value": true + } + ] + } + ] + } + }); + let left_path = write_temp_json("runtime-diff-packed-import-left", &left); + let right_path = write_temp_json("runtime-diff-packed-import-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.records[0].decode_status" + || entry.path == "$.packed_event_collection.records[0].decoded_actions[0]" + })); + assert!( + differences + .iter() + .any(|entry| entry.path == "$.event_runtime_records[0]") + ); + + let _ = fs::remove_file(left_path); + let _ = fs::remove_file(right_path); + } + #[test] fn diffs_classic_profile_samples_across_multiple_files() { let sample_a = RuntimeClassicProfileSample { diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 45f7efd..8d57983 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -62,6 +62,14 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub company_count: Option, #[serde(default)] + pub packed_event_collection_present: Option, + #[serde(default)] + pub packed_event_record_count: Option, + #[serde(default)] + pub packed_event_decoded_record_count: Option, + #[serde(default)] + pub packed_event_imported_runtime_record_count: Option, + #[serde(default)] pub event_runtime_record_count: Option, #[serde(default)] pub candidate_availability_count: Option, @@ -301,6 +309,38 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(present) = self.packed_event_collection_present { + if actual.packed_event_collection_present != present { + mismatches.push(format!( + "packed_event_collection_present mismatch: expected {present}, got {}", + actual.packed_event_collection_present + )); + } + } + if let Some(count) = self.packed_event_record_count { + if actual.packed_event_record_count != count { + mismatches.push(format!( + "packed_event_record_count mismatch: expected {count}, got {}", + actual.packed_event_record_count + )); + } + } + if let Some(count) = self.packed_event_decoded_record_count { + if actual.packed_event_decoded_record_count != count { + mismatches.push(format!( + "packed_event_decoded_record_count mismatch: expected {count}, got {}", + actual.packed_event_decoded_record_count + )); + } + } + if let Some(count) = self.packed_event_imported_runtime_record_count { + if actual.packed_event_imported_runtime_record_count != count { + mismatches.push(format!( + "packed_event_imported_runtime_record_count mismatch: expected {count}, got {}", + actual.packed_event_imported_runtime_record_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 19b1d26..5a9ae91 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -1,11 +1,14 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use serde::{Deserialize, Serialize}; use crate::{ - CalendarPoint, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedSaveSlice, + CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, + RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, + RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary, + SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, }; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; @@ -137,7 +140,27 @@ 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 known_company_ids = BTreeSet::new(); + let imported_event_runtime_records = save_slice + .event_runtime_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter_map(|record| { + smp_packed_record_to_runtime_event_record(record, &known_company_ids) + }) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); let packed_event_collection = save_slice.event_runtime_collection.as_ref().map(|summary| { + let records = summary + .records + .iter() + .map(runtime_packed_event_record_summary_from_smp) + .collect::>(); RuntimePackedEventCollectionSummary { source_kind: summary.source_kind.clone(), mechanism_family: summary.mechanism_family.clone(), @@ -148,6 +171,12 @@ pub fn project_save_slice_to_runtime_state_import( live_id_bound: summary.live_id_bound, live_record_count: summary.live_record_count, live_entry_ids: summary.live_entry_ids.clone(), + decoded_record_count: records + .iter() + .filter(|record| record.decode_status != "unsupported_framing") + .count(), + imported_runtime_record_count: imported_event_runtime_records.len(), + records, } }); if let Some(summary) = &save_slice.event_runtime_collection { @@ -163,6 +192,14 @@ pub fn project_save_slice_to_runtime_state_import( "save_slice.event_runtime_collection_record_count".to_string(), summary.live_record_count.to_string(), ); + metadata.insert( + "save_slice.event_runtime_collection_decoded_record_count".to_string(), + summary.decoded_record_count.to_string(), + ); + metadata.insert( + "save_slice.event_runtime_collection_imported_runtime_record_count".to_string(), + imported_event_runtime_records.len().to_string(), + ); } let save_profile = if let Some(profile) = &save_slice.profile { metadata.insert( @@ -328,7 +365,7 @@ pub fn project_save_slice_to_runtime_state_import( metadata, companies: Vec::new(), packed_event_collection, - event_runtime_records: Vec::new(), + event_runtime_records: imported_event_runtime_records, candidate_availability, special_conditions, service_state: RuntimeServiceState::default(), @@ -342,6 +379,193 @@ pub fn project_save_slice_to_runtime_state_import( }) } +fn runtime_packed_event_record_summary_from_smp( + record: &SmpLoadedPackedEventRecordSummary, +) -> RuntimePackedEventRecordSummary { + RuntimePackedEventRecordSummary { + record_index: record.record_index, + live_entry_id: record.live_entry_id, + payload_offset: record.payload_offset, + payload_len: record.payload_len, + decode_status: record.decode_status.clone(), + trigger_kind: record.trigger_kind, + active: record.active, + marks_collection_dirty: record.marks_collection_dirty, + one_shot: record.one_shot, + text_bands: record + .text_bands + .iter() + .map(runtime_packed_event_text_band_summary_from_smp) + .collect(), + standalone_condition_row_count: record.standalone_condition_row_count, + grouped_effect_row_counts: record.grouped_effect_row_counts.clone(), + decoded_actions: record.decoded_actions.clone(), + executable_import_ready: record.executable_import_ready, + notes: record.notes.clone(), + } +} + +fn runtime_packed_event_text_band_summary_from_smp( + band: &SmpLoadedPackedEventTextBandSummary, +) -> RuntimePackedEventTextBandSummary { + RuntimePackedEventTextBandSummary { + label: band.label.clone(), + packed_len: band.packed_len, + present: band.present, + preview: band.preview.clone(), + } +} + +fn smp_packed_record_to_runtime_event_record( + record: &SmpLoadedPackedEventRecordSummary, + known_company_ids: &BTreeSet, +) -> Option> { + if !record.executable_import_ready { + return None; + } + + Some( + smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) + .and_then(|effects| { + let trigger_kind = record.trigger_kind.ok_or_else(|| { + format!( + "packed event record {} is missing trigger_kind", + record.live_entry_id + ) + })?; + let active = record.active.ok_or_else(|| { + format!( + "packed event record {} is missing active flag", + record.live_entry_id + ) + })?; + let marks_collection_dirty = record.marks_collection_dirty.ok_or_else(|| { + format!( + "packed event record {} is missing dirty flag", + record.live_entry_id + ) + })?; + let one_shot = record.one_shot.ok_or_else(|| { + format!( + "packed event record {} is missing one_shot flag", + record.live_entry_id + ) + })?; + Ok(RuntimeEventRecordTemplate { + record_id: record.live_entry_id, + trigger_kind, + active, + marks_collection_dirty, + one_shot, + effects, + } + .into_runtime_record()) + }), + ) +} + +fn smp_runtime_effects_to_runtime_effects( + effects: &[RuntimeEffect], + known_company_ids: &BTreeSet, +) -> Result, String> { + effects + .iter() + .map(|effect| smp_runtime_effect_to_runtime_effect(effect, known_company_ids)) + .collect() +} + +fn smp_runtime_effect_to_runtime_effect( + effect: &RuntimeEffect, + known_company_ids: &BTreeSet, +) -> Result { + match effect { + RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag { + key: key.clone(), + value: *value, + }), + RuntimeEffect::AdjustCompanyCash { target, delta } => { + if company_target_supported_for_import(target, known_company_ids) { + Ok(RuntimeEffect::AdjustCompanyCash { + target: target.clone(), + delta: *delta, + }) + } else { + Err("packed company-cash effect requires unresolved company ids".to_string()) + } + } + RuntimeEffect::AdjustCompanyDebt { target, delta } => { + if company_target_supported_for_import(target, known_company_ids) { + Ok(RuntimeEffect::AdjustCompanyDebt { + target: target.clone(), + delta: *delta, + }) + } else { + Err("packed company-debt effect requires unresolved company ids".to_string()) + } + } + RuntimeEffect::SetCandidateAvailability { name, value } => { + Ok(RuntimeEffect::SetCandidateAvailability { + name: name.clone(), + value: *value, + }) + } + RuntimeEffect::SetSpecialCondition { label, value } => { + Ok(RuntimeEffect::SetSpecialCondition { + label: label.clone(), + value: *value, + }) + } + RuntimeEffect::AppendEventRecord { record } => Ok(RuntimeEffect::AppendEventRecord { + record: Box::new(smp_runtime_record_template_to_runtime( + record, + known_company_ids, + )?), + }), + RuntimeEffect::ActivateEventRecord { record_id } => { + Ok(RuntimeEffect::ActivateEventRecord { + record_id: *record_id, + }) + } + RuntimeEffect::DeactivateEventRecord { record_id } => { + Ok(RuntimeEffect::DeactivateEventRecord { + record_id: *record_id, + }) + } + RuntimeEffect::RemoveEventRecord { record_id } => Ok(RuntimeEffect::RemoveEventRecord { + record_id: *record_id, + }), + } +} + +fn smp_runtime_record_template_to_runtime( + record: &RuntimeEventRecordTemplate, + known_company_ids: &BTreeSet, +) -> Result { + Ok(RuntimeEventRecordTemplate { + record_id: record.record_id, + trigger_kind: record.trigger_kind, + active: record.active, + marks_collection_dirty: record.marks_collection_dirty, + one_shot: record.one_shot, + effects: smp_runtime_effects_to_runtime_effects(&record.effects, known_company_ids)?, + }) +} + +fn company_target_supported_for_import( + target: &crate::RuntimeCompanyTarget, + known_company_ids: &BTreeSet, +) -> bool { + match target { + crate::RuntimeCompanyTarget::AllActive => true, + crate::RuntimeCompanyTarget::Ids { ids } => { + !ids.is_empty() + && ids + .iter() + .all(|company_id| known_company_ids.contains(company_id)) + } + } +} + pub fn validate_runtime_state_dump_document( document: &RuntimeStateDumpDocument, ) -> Result<(), String> { @@ -397,6 +621,7 @@ pub fn load_runtime_state_import_from_str( #[cfg(test)] mod tests { use super::*; + use crate::{StepCommand, execute_step_command}; fn state() -> RuntimeState { RuntimeState { @@ -419,6 +644,47 @@ mod tests { } } + fn packed_text_bands() -> Vec { + vec![ + crate::SmpLoadedPackedEventTextBandSummary { + label: "primary_text_band".to_string(), + packed_len: 5, + present: true, + preview: "Alpha".to_string(), + }, + crate::SmpLoadedPackedEventTextBandSummary { + label: "secondary_text_band_0".to_string(), + packed_len: 0, + present: false, + preview: "".to_string(), + }, + crate::SmpLoadedPackedEventTextBandSummary { + label: "secondary_text_band_1".to_string(), + packed_len: 0, + present: false, + preview: "".to_string(), + }, + crate::SmpLoadedPackedEventTextBandSummary { + label: "secondary_text_band_2".to_string(), + packed_len: 0, + present: false, + preview: "".to_string(), + }, + crate::SmpLoadedPackedEventTextBandSummary { + label: "secondary_text_band_3".to_string(), + packed_len: 0, + present: false, + preview: "".to_string(), + }, + crate::SmpLoadedPackedEventTextBandSummary { + label: "secondary_text_band_4".to_string(), + packed_len: 0, + present: false, + preview: "".to_string(), + }, + ] + } + #[test] fn loads_dump_document() { let text = serde_json::to_string(&RuntimeStateDumpDocument { @@ -548,6 +814,61 @@ mod tests { live_id_bound: 5, live_record_count: 3, live_entry_ids: vec![1, 3, 5], + decoded_record_count: 0, + imported_runtime_record_count: 0, + records: vec![ + crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 1, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["test".to_string()], + }, + crate::SmpLoadedPackedEventRecordSummary { + record_index: 1, + live_entry_id: 3, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["test".to_string()], + }, + crate::SmpLoadedPackedEventRecordSummary { + record_index: 2, + live_entry_id: 5, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["test".to_string()], + }, + ], }), notes: vec!["packed profile recovered".to_string()], }; @@ -709,4 +1030,187 @@ mod tests { ); assert!(import.state.event_runtime_records.is_empty()); } + + #[test] + fn projects_executable_packed_records_into_runtime_and_services_follow_on() { + 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: 1, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 7, + payload_offset: Some(0x7202), + payload_len: Some(64), + decode_status: "executable".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, + grouped_effect_row_counts: vec![0, 1, 0, 0], + decoded_actions: vec![ + RuntimeEffect::SetWorldFlag { + key: "from_packed_root".to_string(), + value: true, + }, + RuntimeEffect::AppendEventRecord { + record: Box::new(RuntimeEventRecordTemplate { + record_id: 99, + trigger_kind: 0x0a, + active: true, + marks_collection_dirty: false, + one_shot: false, + effects: vec![RuntimeEffect::SetSpecialCondition { + label: "Imported Follow-On".to_string(), + value: 1, + }], + }), + }, + ], + executable_import_ready: true, + notes: vec!["decoded test record".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-exec", + Some("test packed event import".to_string()), + ) + .expect("save slice should project"); + + assert_eq!(import.state.event_runtime_records.len(), 1); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.imported_runtime_record_count), + Some(1) + ); + + let result = execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("trigger service should succeed"); + + assert_eq!(result.final_summary.event_runtime_record_count, 2); + assert_eq!(result.final_summary.total_event_record_service_count, 2); + assert_eq!(result.final_summary.total_trigger_dispatch_count, 2); + assert_eq!(result.final_summary.dirty_rerun_count, 1); + assert_eq!( + import.state.world_flags.get("from_packed_root"), + Some(&true) + ); + assert_eq!( + import.state.special_conditions.get("Imported Follow-On"), + Some(&1) + ); + assert_eq!(import.state.event_runtime_records[0].service_count, 1); + assert_eq!(import.state.event_runtime_records[1].record_id, 99); + assert_eq!(import.state.event_runtime_records[1].service_count, 1); + } + + #[test] + fn leaves_parity_only_packed_records_out_of_runtime_event_records() { + 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(48), + decode_status: "parity_only".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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { + target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, + delta: 50, + }], + executable_import_ready: false, + notes: vec!["decoded but not importable".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-parity-only", + None, + ) + .expect("save slice should project"); + + assert!(import.state.event_runtime_records.is_empty()); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.decoded_record_count), + Some(1) + ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .map(|summary| summary.imported_runtime_record_count), + Some(0) + ); + } } diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 34e1f33..1864425 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -30,7 +30,8 @@ pub use pk4::{ }; pub use runtime::{ RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, - RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, + RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, }; pub use smp::{ @@ -38,7 +39,8 @@ pub use smp::{ SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock, SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe, SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, - SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, SmpLoadedProfile, + SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, + SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedProfile, SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe, SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane, diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 82bf747..b6210d8 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -97,6 +97,51 @@ pub struct RuntimePackedEventCollectionSummary { pub live_id_bound: u32, pub live_record_count: usize, pub live_entry_ids: Vec, + #[serde(default)] + pub decoded_record_count: usize, + #[serde(default)] + pub imported_runtime_record_count: usize, + #[serde(default)] + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePackedEventRecordSummary { + pub record_index: usize, + pub live_entry_id: u32, + #[serde(default)] + pub payload_offset: Option, + #[serde(default)] + pub payload_len: Option, + pub decode_status: String, + #[serde(default)] + pub trigger_kind: Option, + #[serde(default)] + pub active: Option, + #[serde(default)] + pub marks_collection_dirty: Option, + #[serde(default)] + pub one_shot: Option, + #[serde(default)] + pub text_bands: Vec, + #[serde(default)] + pub standalone_condition_row_count: usize, + #[serde(default)] + pub grouped_effect_row_counts: Vec, + #[serde(default)] + pub decoded_actions: Vec, + #[serde(default)] + pub executable_import_ready: bool, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePackedEventTextBandSummary { + pub label: String, + pub packed_len: usize, + pub present: bool, + pub preview: String, } impl RuntimeEventRecordTemplate { @@ -271,9 +316,37 @@ impl RuntimeState { .to_string(), ); } + if summary.live_record_count != summary.records.len() { + return Err( + "packed_event_collection.live_record_count must match records length" + .to_string(), + ); + } + let decoded_record_count = summary + .records + .iter() + .filter(|record| record.decode_status != "unsupported_framing") + .count(); + if summary.decoded_record_count != decoded_record_count { + return Err( + "packed_event_collection.decoded_record_count must match decoded records" + .to_string(), + ); + } + let executable_import_ready_count = summary + .records + .iter() + .filter(|record| record.executable_import_ready) + .count(); + if summary.imported_runtime_record_count > executable_import_ready_count { + return Err( + "packed_event_collection.imported_runtime_record_count must not exceed executable-import-ready records" + .to_string(), + ); + } let mut previous_id = None; - for entry_id in &summary.live_entry_ids { + for (record_index, entry_id) in summary.live_entry_ids.iter().enumerate() { if *entry_id == 0 { return Err( "packed_event_collection.live_entry_ids must not contain id 0".to_string(), @@ -292,6 +365,35 @@ impl RuntimeState { ); } previous_id = Some(*entry_id); + + let record = &summary.records[record_index]; + if record.live_entry_id != *entry_id { + return Err(format!( + "packed_event_collection.records[{record_index}].live_entry_id must match live_entry_ids" + )); + } + if record.record_index != record_index { + return Err(format!( + "packed_event_collection.records[{record_index}].record_index must match position" + )); + } + if record.decode_status.trim().is_empty() { + return Err(format!( + "packed_event_collection.records[{record_index}].decode_status must not be empty" + )); + } + if record.grouped_effect_row_counts.len() != 4 { + return Err(format!( + "packed_event_collection.records[{record_index}].grouped_effect_row_counts must contain exactly 4 entries" + )); + } + for band in &record.text_bands { + if band.label.trim().is_empty() { + return Err(format!( + "packed_event_collection.records[{record_index}].text_bands contains an empty label" + )); + } + } } } @@ -647,6 +749,44 @@ mod tests { live_id_bound: 4, live_record_count: 2, live_entry_ids: vec![3, 3], + decoded_record_count: 0, + imported_runtime_record_count: 0, + records: vec![ + RuntimePackedEventRecordSummary { + record_index: 0, + live_entry_id: 3, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["test".to_string()], + }, + RuntimePackedEventRecordSummary { + record_index: 1, + live_entry_id: 3, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec!["test".to_string()], + }, + ], }), event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 0fa26a6..e83ca10 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -4,6 +4,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::{RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate}; + pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; const PREAMBLE_U32_WORD_COUNT: usize = 16; const MIN_ASCII_RUN_LEN: usize = 8; @@ -86,6 +88,17 @@ 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 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_TEXT_BAND_LABELS: [&str; 6] = [ + "primary_text_band", + "secondary_text_band_0", + "secondary_text_band_1", + "secondary_text_band_2", + "secondary_text_band_3", + "secondary_text_band_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 = @@ -1189,6 +1202,51 @@ pub struct SmpLoadedEventRuntimeCollectionSummary { pub live_id_bound: u32, pub live_record_count: usize, pub live_entry_ids: Vec, + #[serde(default)] + pub decoded_record_count: usize, + #[serde(default)] + pub imported_runtime_record_count: usize, + #[serde(default)] + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedPackedEventRecordSummary { + pub record_index: usize, + pub live_entry_id: u32, + #[serde(default)] + pub payload_offset: Option, + #[serde(default)] + pub payload_len: Option, + pub decode_status: String, + #[serde(default)] + pub trigger_kind: Option, + #[serde(default)] + pub active: Option, + #[serde(default)] + pub marks_collection_dirty: Option, + #[serde(default)] + pub one_shot: Option, + #[serde(default)] + pub text_bands: Vec, + #[serde(default)] + pub standalone_condition_row_count: usize, + #[serde(default)] + pub grouped_effect_row_counts: Vec, + #[serde(default)] + pub decoded_actions: Vec, + #[serde(default)] + pub executable_import_ready: bool, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedPackedEventTextBandSummary { + pub label: String, + pub packed_len: usize, + pub present: bool, + pub preview: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1431,6 +1489,20 @@ fn parse_event_runtime_collection_summary( if live_entry_ids.len() != live_record_count { continue; } + let records_payload = bytes.get(records_tag_offset + 2..close_tag_offset)?; + let records = parse_event_runtime_record_summaries( + records_payload, + records_tag_offset + 2, + &live_entry_ids, + ); + let decoded_record_count = records + .iter() + .filter(|record| record.decode_status != "unsupported_framing") + .count(); + let imported_runtime_record_count = records + .iter() + .filter(|record| record.executable_import_ready) + .count(); return Some(SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -1450,6 +1522,9 @@ fn parse_event_runtime_collection_summary( live_id_bound, live_record_count, live_entry_ids, + decoded_record_count, + imported_runtime_record_count, + records, }); } @@ -1491,6 +1566,313 @@ fn decode_live_entry_ids_with_mapping( Some(live_entry_ids) } +fn parse_event_runtime_record_summaries( + records_payload: &[u8], + records_payload_offset: usize, + live_entry_ids: &[u32], +) -> Vec { + try_parse_synthetic_event_runtime_record_summaries( + records_payload, + records_payload_offset, + live_entry_ids, + ) + .unwrap_or_else(|| { + build_unsupported_event_runtime_record_summaries( + live_entry_ids, + "0x4e9a payload did not match the current packed-event record decode harness", + ) + }) +} + +fn try_parse_synthetic_event_runtime_record_summaries( + records_payload: &[u8], + records_payload_offset: usize, + live_entry_ids: &[u32], +) -> Option> { + if !records_payload.starts_with(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC) { + return None; + } + + let mut cursor = PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC.len(); + 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_len = usize::try_from(read_u32_at(records_payload, cursor)?).ok()?; + cursor += 4; + let record_body = records_payload.get(cursor..cursor + record_len)?; + records.push(parse_synthetic_event_runtime_record_summary( + record_body, + records_payload_offset + cursor, + record_index, + live_entry_id, + )?); + cursor += record_len; + } + + if cursor != records_payload.len() { + return None; + } + + Some(records) +} + +fn parse_synthetic_event_runtime_record_summary( + record_body: &[u8], + payload_offset: usize, + record_index: usize, + live_entry_id: u32, +) -> Option { + if !record_body.starts_with(PACKED_EVENT_RECORD_SYNTHETIC_MAGIC) { + return None; + } + + let mut cursor = PACKED_EVENT_RECORD_SYNTHETIC_MAGIC.len(); + let trigger_kind = read_u8_at(record_body, cursor)?; + cursor += 1; + let flags = read_u8_at(record_body, cursor)?; + cursor += 1; + let standalone_condition_row_count = usize::from(read_u8_at(record_body, cursor)?); + cursor += 1; + let action_count = usize::from(read_u8_at(record_body, cursor)?); + cursor += 1; + + let mut grouped_effect_row_counts = Vec::with_capacity(4); + for _ in 0..4 { + grouped_effect_row_counts.push(usize::from(read_u8_at(record_body, cursor)?)); + cursor += 1; + } + + 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), + }); + } + + let mut decoded_actions = Vec::with_capacity(action_count); + for _ in 0..action_count { + decoded_actions.push(parse_synthetic_packed_event_action( + record_body, + &mut cursor, + )?); + } + + if cursor != record_body.len() { + return None; + } + + let executable_import_ready = decoded_actions + .iter() + .all(runtime_effect_supported_for_save_import); + + Some(SmpLoadedPackedEventRecordSummary { + record_index, + live_entry_id, + payload_offset: Some(payload_offset), + payload_len: Some(record_body.len()), + decode_status: if executable_import_ready { + "executable".to_string() + } else { + "parity_only".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, + grouped_effect_row_counts, + decoded_actions, + executable_import_ready, + notes: vec!["decoded from the current synthetic packed-event record harness".to_string()], + }) +} + +fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option { + let opcode = read_u8_at(bytes, *cursor)?; + *cursor += 1; + match opcode { + 0x01 => { + let key = parse_len_prefixed_string(bytes, cursor)?; + let value = read_u8_at(bytes, *cursor)? != 0; + *cursor += 1; + Some(RuntimeEffect::SetWorldFlag { key, value }) + } + 0x02 => { + let target = parse_synthetic_company_target(bytes, cursor)?; + let delta = read_i64_at(bytes, *cursor)?; + *cursor += 8; + Some(RuntimeEffect::AdjustCompanyCash { target, delta }) + } + 0x03 => { + let target = parse_synthetic_company_target(bytes, cursor)?; + let delta = read_i64_at(bytes, *cursor)?; + *cursor += 8; + Some(RuntimeEffect::AdjustCompanyDebt { target, delta }) + } + 0x04 => { + let name = parse_len_prefixed_string(bytes, cursor)?; + let value = read_u32_at(bytes, *cursor)?; + *cursor += 4; + Some(RuntimeEffect::SetCandidateAvailability { name, value }) + } + 0x05 => { + let label = parse_len_prefixed_string(bytes, cursor)?; + let value = read_u32_at(bytes, *cursor)?; + *cursor += 4; + Some(RuntimeEffect::SetSpecialCondition { label, value }) + } + 0x06 => { + let template_len = usize::try_from(read_u32_at(bytes, *cursor)?).ok()?; + *cursor += 4; + let template_bytes = bytes.get(*cursor..*cursor + template_len)?; + let record = parse_synthetic_event_runtime_record_template(template_bytes)?; + *cursor += template_len; + Some(RuntimeEffect::AppendEventRecord { + record: Box::new(record), + }) + } + 0x07 => { + let record_id = read_u32_at(bytes, *cursor)?; + *cursor += 4; + Some(RuntimeEffect::ActivateEventRecord { record_id }) + } + 0x08 => { + let record_id = read_u32_at(bytes, *cursor)?; + *cursor += 4; + Some(RuntimeEffect::DeactivateEventRecord { record_id }) + } + 0x09 => { + let record_id = read_u32_at(bytes, *cursor)?; + *cursor += 4; + Some(RuntimeEffect::RemoveEventRecord { record_id }) + } + _ => None, + } +} + +fn parse_synthetic_event_runtime_record_template( + bytes: &[u8], +) -> Option { + if !bytes.starts_with(PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC) { + return None; + } + + let mut cursor = PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC.len(); + let record_id = read_u32_at(bytes, cursor)?; + cursor += 4; + let trigger_kind = read_u8_at(bytes, cursor)?; + cursor += 1; + let flags = read_u8_at(bytes, cursor)?; + cursor += 1; + let action_count = usize::from(read_u8_at(bytes, cursor)?); + cursor += 1; + cursor += 1; + + let mut effects = Vec::with_capacity(action_count); + for _ in 0..action_count { + effects.push(parse_synthetic_packed_event_action(bytes, &mut cursor)?); + } + + if cursor != bytes.len() { + return None; + } + + Some(RuntimeEventRecordTemplate { + record_id, + trigger_kind, + active: flags & 0x01 != 0, + marks_collection_dirty: flags & 0x02 != 0, + one_shot: flags & 0x04 != 0, + effects, + }) +} + +fn parse_synthetic_company_target( + bytes: &[u8], + cursor: &mut usize, +) -> Option { + let target_kind = read_u8_at(bytes, *cursor)?; + *cursor += 1; + match target_kind { + 0x00 => Some(RuntimeCompanyTarget::AllActive), + 0x01 => { + let count = usize::from(read_u8_at(bytes, *cursor)?); + *cursor += 1; + let mut ids = Vec::with_capacity(count); + for _ in 0..count { + ids.push(read_u32_at(bytes, *cursor)?); + *cursor += 4; + } + Some(RuntimeCompanyTarget::Ids { ids }) + } + _ => None, + } +} + +fn parse_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option { + let len = usize::from(read_u8_at(bytes, *cursor)?); + *cursor += 1; + let text_bytes = bytes.get(*cursor..*cursor + len)?; + *cursor += len; + Some(String::from_utf8_lossy(text_bytes).into_owned()) +} + +fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { + match effect { + RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetCandidateAvailability { .. } + | RuntimeEffect::SetSpecialCondition { .. } + | RuntimeEffect::ActivateEventRecord { .. } + | RuntimeEffect::DeactivateEventRecord { .. } + | RuntimeEffect::RemoveEventRecord { .. } => true, + RuntimeEffect::AdjustCompanyCash { target, .. } + | RuntimeEffect::AdjustCompanyDebt { target, .. } => { + matches!(target, RuntimeCompanyTarget::AllActive) + } + RuntimeEffect::AppendEventRecord { record } => record + .effects + .iter() + .all(runtime_effect_supported_for_save_import), + } +} + +fn build_unsupported_event_runtime_record_summaries( + live_entry_ids: &[u32], + note: &str, +) -> Vec { + live_entry_ids + .iter() + .copied() + .enumerate() + .map( + |(record_index, live_entry_id)| SmpLoadedPackedEventRecordSummary { + record_index, + live_entry_id, + payload_offset: None, + payload_len: None, + decode_status: "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, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: Vec::new(), + executable_import_ready: false, + notes: vec![note.to_string()], + }, + ) + .collect() +} + fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> SmpInspectionReport { let known_tag_hits = KNOWN_TAG_DEFINITIONS .iter() @@ -4846,11 +5228,27 @@ fn read_u32_window(bytes: &[u8], offset: usize, count: usize) -> Vec { words } +fn read_u8_at(bytes: &[u8], offset: usize) -> Option { + bytes.get(offset).copied() +} + +fn read_u16_at(bytes: &[u8], offset: usize) -> Option { + let chunk = bytes.get(offset..offset + 2)?; + Some(u16::from_le_bytes([chunk[0], chunk[1]])) +} + fn read_u32_at(bytes: &[u8], offset: usize) -> Option { let chunk = bytes.get(offset..offset + 4)?; Some(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) } +fn read_i64_at(bytes: &[u8], offset: usize) -> Option { + let chunk = bytes.get(offset..offset + 8)?; + Some(i64::from_le_bytes([ + chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7], + ])) +} + fn probable_normal_f32_string(value: u32) -> Option { let exponent = (value >> 23) & 0xff; if exponent == 0 || exponent == 0xff { @@ -5611,7 +6009,10 @@ mod tests { #[test] fn classifies_recipe_token_layouts() { assert_eq!(classify_recipe_token_layout(0x00000000), "zero"); - assert_eq!(classify_recipe_token_layout(0x72470000), "high16-ascii-stem"); + assert_eq!( + classify_recipe_token_layout(0x72470000), + "high16-ascii-stem" + ); assert_eq!(classify_recipe_token_layout(0x00170000), "high16-numeric"); assert_eq!(classify_recipe_token_layout(0x000040a0), "low16-marker"); } @@ -5638,9 +6039,18 @@ mod tests { #[test] fn classifies_recipe_runtime_import_branches() { - assert_eq!(classify_recipe_runtime_import_branch(0), "zero-mode-skipped"); - assert_eq!(classify_recipe_runtime_import_branch(1), "mode1-demand-branch"); - assert_eq!(classify_recipe_runtime_import_branch(3), "mode3-dual-branch"); + assert_eq!( + classify_recipe_runtime_import_branch(0), + "zero-mode-skipped" + ); + assert_eq!( + classify_recipe_runtime_import_branch(1), + "mode1-demand-branch" + ); + assert_eq!( + classify_recipe_runtime_import_branch(3), + "mode3-dual-branch" + ); assert_eq!( classify_recipe_runtime_import_branch(0x00110000), "nonzero-supply-branch" @@ -6205,6 +6615,189 @@ mod tests { assert_eq!(summary.live_record_count, 3); assert_eq!(summary.live_entry_ids, vec![1, 3, 5]); assert_eq!(summary.records_tag_offset, 96); + assert_eq!(summary.decoded_record_count, 0); + assert_eq!(summary.records.len(), 3); + assert_eq!(summary.records[0].decode_status, "unsupported_framing"); + } + + fn encode_len_prefixed_string(text: &str) -> Vec { + let mut bytes = Vec::with_capacity(1 + text.len()); + bytes.push(text.len() as u8); + bytes.extend_from_slice(text.as_bytes()); + bytes + } + + fn encode_template( + record_id: u32, + trigger_kind: u8, + flags: u8, + actions: &[Vec], + ) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC); + bytes.extend_from_slice(&record_id.to_le_bytes()); + bytes.push(trigger_kind); + bytes.push(flags); + bytes.push(actions.len() as u8); + bytes.push(0); + for action in actions { + bytes.extend_from_slice(action); + } + bytes + } + + fn encode_action_set_world_flag(key: &str, value: bool) -> Vec { + let mut bytes = vec![0x01]; + bytes.extend_from_slice(&encode_len_prefixed_string(key)); + bytes.push(u8::from(value)); + bytes + } + + fn encode_action_set_special_condition(label: &str, value: u32) -> Vec { + let mut bytes = vec![0x05]; + bytes.extend_from_slice(&encode_len_prefixed_string(label)); + bytes.extend_from_slice(&value.to_le_bytes()); + bytes + } + + fn encode_action_adjust_company_cash_ids(ids: &[u32], delta: i64) -> Vec { + let mut bytes = vec![0x02, 0x01, ids.len() as u8]; + for id in ids { + bytes.extend_from_slice(&id.to_le_bytes()); + } + bytes.extend_from_slice(&delta.to_le_bytes()); + bytes + } + + fn encode_action_append_template(template: Vec) -> Vec { + let mut bytes = vec![0x06]; + bytes.extend_from_slice(&(template.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&template); + bytes + } + + fn build_synthetic_event_record( + trigger_kind: u8, + flags: u8, + standalone_count: u8, + grouped_counts: [u8; 4], + text_bands: [&[u8]; 6], + actions: &[Vec], + ) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(PACKED_EVENT_RECORD_SYNTHETIC_MAGIC); + bytes.push(trigger_kind); + bytes.push(flags); + bytes.push(standalone_count); + bytes.push(actions.len() as u8); + bytes.extend_from_slice(&grouped_counts); + for band in text_bands { + bytes.extend_from_slice(&(band.len() as u16).to_le_bytes()); + bytes.extend_from_slice(band); + } + for action in actions { + bytes.extend_from_slice(action); + } + bytes + } + + #[test] + fn parses_synthetic_event_runtime_record_summaries_and_actions() { + let append_template = encode_template( + 99, + 0x0a, + 0x01, + &[encode_action_set_special_condition("Imported Follow-On", 1)], + ); + let record_body = build_synthetic_event_record( + 7, + 0x03, + 1, + [0, 1, 0, 0], + [b"Alpha", b"", b"", b"", b"", b""], + &[ + encode_action_set_world_flag("from_packed_root", true), + encode_action_append_template(append_template), + ], + ); + + 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(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC); + bytes.extend_from_slice(&(record_body.len() as u32).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, 1); + assert_eq!(summary.records.len(), 1); + assert_eq!(summary.records[0].decode_status, "executable"); + assert_eq!(summary.records[0].text_bands[0].preview, "Alpha"); + assert_eq!(summary.records[0].standalone_condition_row_count, 1); + assert_eq!( + summary.records[0].grouped_effect_row_counts, + vec![0, 1, 0, 0] + ); + assert_eq!(summary.records[0].decoded_actions.len(), 2); + match &summary.records[0].decoded_actions[1] { + RuntimeEffect::AppendEventRecord { record } => { + assert_eq!(record.record_id, 99); + assert_eq!(record.trigger_kind, 0x0a); + } + other => panic!("unexpected decoded action: {other:?}"), + } + } + + #[test] + fn decodes_company_targeted_synthetic_records_as_parity_only() { + let record_body = build_synthetic_event_record( + 8, + 0x01, + 0, + [0, 0, 0, 0], + [b"", b"", b"", b"", b"", b""], + &[encode_action_adjust_company_cash_ids(&[7], 25)], + ); + + 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(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC); + bytes.extend_from_slice(&(record_body.len() as u32).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!(!summary.records[0].executable_import_ready); } #[test] @@ -6267,6 +6860,9 @@ mod tests { live_id_bound: 5, live_record_count: 3, live_entry_ids: vec![1, 3, 5], + decoded_record_count: 0, + imported_runtime_record_count: 0, + records: build_unsupported_event_runtime_record_summaries(&[1, 3, 5], "test summary"), }); let slice = load_save_slice_from_report(&report).expect("classic save slice"); diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index f07fe84..104aedf 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -30,6 +30,8 @@ pub struct RuntimeSummary { pub company_count: usize, pub packed_event_collection_present: bool, pub packed_event_record_count: usize, + pub packed_event_decoded_record_count: usize, + pub packed_event_imported_runtime_record_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, pub zero_candidate_availability_count: usize, @@ -117,6 +119,16 @@ impl RuntimeSummary { .as_ref() .map(|summary| summary.live_record_count) .unwrap_or(0), + packed_event_decoded_record_count: state + .packed_event_collection + .as_ref() + .map(|summary| summary.decoded_record_count) + .unwrap_or(0), + packed_event_imported_runtime_record_count: state + .packed_event_collection + .as_ref() + .map(|summary| summary.imported_runtime_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 d715162..ad002cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,16 +67,17 @@ 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, staged event-record mutation, fixture execution, -state-diff tooling, and initial persistence surfaces. +state-diff tooling, and a packed-event persistence bridge that now reaches per-record summaries and +selective executable import. 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 -- 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 +- deepen captured-runtime and round-trip fixture coverage on top of the packed-event bridge that now + exists +- widen packed-event target-family coverage only where static evidence is strong enough to support + deterministic executable import - 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 2c3006a..aec2a4d 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -22,11 +22,12 @@ Implemented today: - 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 + service, snapshot-backed inputs, normalized state-fragment assertions, and imported packed-event + execution That means the next implementation work is breadth, not bootstrap. The recommended next slice is -the `.smp` event-collection structural bridge across inspection, save-slice loading, import, and -snapshot-backed fixtures. +captured-runtime depth plus wider packed-event target-family coverage, not another persistence +scaffold pass. ## Why This Boundary @@ -215,10 +216,12 @@ Current status: - runtime snapshots and state dumps are implemented - `.smp` save inspection and partial save-slice projection already feed normalized runtime state -- 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 +- the packed event-collection bridge now carries per-record summaries into loaded save slices, + projected runtime snapshots, normalized diffs, and fixtures +- the first decoded packed-event subset can now import into executable runtime records when the + decoded actions fit the current normalized runtime-effect model +- the remaining gap is broader captured-runtime and round-trip fixture depth plus wider packed + target-family coverage, not first-pass packed-event decode ### Milestone 4: Domain Expansion @@ -347,8 +350,8 @@ The currently implemented normalized runtime surface is: `runtime summarize-state`, `runtime import-state`, and `runtime diff-state` - deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a 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 +- save-side inspection and partial state projection for `.smp` inputs, including per-record packed + event summaries and selective executable import Checked-in fixture families already include: @@ -360,33 +363,35 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is deeper `.smp` event persistence, starting from the -structural bridge that already exists today. +The recommended next implementation slice is broader captured-runtime depth on top of the packed +event bridge that now exists today. Target behavior: -- 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 +- keep the packed event bridge grounded against real captured save inputs rather than only synthetic + parser tests and snapshot fixtures +- expand the executable import subset beyond the current direct-state and follow-on lanes only when + target resolution and field semantics are statically grounded enough to preserve headless + determinism +- keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning Public-model additions for that slice: -- 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 +- additional captured-save fixture material for packed event collections +- wider target-family summaries only where imported execution can be justified by current static + evidence +- no shell queue/modal behavior in the runtime core Fixture work for that slice: -- 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 +- captured `.smp` or save-slice-backed fixtures that prove real packed event records survive import + and diff paths +- regression fixtures that lock the current selective executable import boundary +- state-fragment assertions that lock both packed parity summaries and imported executable records Do not mix this slice with: - territory-access or selected-profile parity - placed-structure batch placement parity - shell queue/modal behavior -- direct translation of packed RT3 event rows into executable normalized effects +- broad speculative 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 index 9616100..20f3fd2 100644 --- a/fixtures/runtime/packed-event-collection-from-snapshot.json +++ b/fixtures/runtime/packed-event-collection-from-snapshot.json @@ -23,6 +23,8 @@ "company_count": 0, "packed_event_collection_present": true, "packed_event_record_count": 3, + "packed_event_decoded_record_count": 0, + "packed_event_imported_runtime_record_count": 0, "event_runtime_record_count": 0, "total_event_record_service_count": 0, "periodic_boundary_call_count": 0, @@ -37,10 +39,11 @@ "packed_event_collection": { "mechanism_family": "classic-save-rehydrate-v1", "live_record_count": 3, - "live_entry_ids": [ - 1, - 3, - 5 + "live_entry_ids": [1, 3, 5], + "records": [ + { + "decode_status": "unsupported_framing" + } ] } } diff --git a/fixtures/runtime/packed-event-collection-snapshot.json b/fixtures/runtime/packed-event-collection-snapshot.json index fcdd6fc..ae88d4c 100644 --- a/fixtures/runtime/packed-event-collection-snapshot.json +++ b/fixtures/runtime/packed-event-collection-snapshot.json @@ -22,10 +22,37 @@ "packed_state_version_hex": "0x000003e9", "live_id_bound": 5, "live_record_count": 3, - "live_entry_ids": [ - 1, - 3, - 5 + "live_entry_ids": [1, 3, 5], + "decoded_record_count": 0, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 1, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["snapshot fixture placeholder"] + }, + { + "record_index": 1, + "live_entry_id": 3, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["snapshot fixture placeholder"] + }, + { + "record_index": 2, + "live_entry_id": 5, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": ["snapshot fixture placeholder"] + } ] }, "event_runtime_records": [], diff --git a/fixtures/runtime/packed-event-record-import-from-snapshot.json b/fixtures/runtime/packed-event-record-import-from-snapshot.json new file mode 100644 index 0000000..85ff29c --- /dev/null +++ b/fixtures/runtime/packed-event-record-import-from-snapshot.json @@ -0,0 +1,67 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-record-import-from-snapshot", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a runtime snapshot that carries one decoded packed-event record and its imported executable runtime record." + }, + "state_snapshot_path": "packed-event-record-import-snapshot.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 0 + }, + "world_flag_count": 1, + "company_count": 0, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "event_runtime_record_count": 2, + "special_condition_count": 1, + "enabled_special_condition_count": 1, + "total_event_record_service_count": 2, + "periodic_boundary_call_count": 0, + "total_trigger_dispatch_count": 2, + "dirty_rerun_count": 1, + "total_company_cash": 0 + }, + "expected_state_fragment": { + "world_flags": { + "from_packed_root": true + }, + "special_conditions": { + "Imported Follow-On": 1 + }, + "packed_event_collection": { + "records": [ + { + "decode_status": "executable", + "executable_import_ready": true + } + ] + }, + "event_runtime_records": [ + { + "record_id": 7, + "service_count": 1 + }, + { + "record_id": 99, + "trigger_kind": 10, + "service_count": 1 + } + ], + "service_state": { + "dirty_rerun_count": 1 + } + } +} diff --git a/fixtures/runtime/packed-event-record-import-snapshot.json b/fixtures/runtime/packed-event-record-import-snapshot.json new file mode 100644 index 0000000..268288b --- /dev/null +++ b/fixtures/runtime/packed-event-record-import-snapshot.json @@ -0,0 +1,152 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-record-import-snapshot", + "source": { + "description": "Snapshot fixture carrying one decoded packed-event record plus its imported executable runtime record." + }, + "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": 7, + "live_record_count": 1, + "live_entry_ids": [7], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 7, + "payload_offset": 29186, + "payload_len": 64, + "decode_status": "executable", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": true, + "one_shot": false, + "text_bands": [ + { + "label": "primary_text_band", + "packed_len": 5, + "present": true, + "preview": "Alpha" + }, + { + "label": "secondary_text_band_0", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_1", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_2", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_3", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_4", + "packed_len": 0, + "present": false, + "preview": "" + } + ], + "standalone_condition_row_count": 1, + "grouped_effect_row_counts": [0, 1, 0, 0], + "decoded_actions": [ + { + "kind": "set_world_flag", + "key": "from_packed_root", + "value": true + }, + { + "kind": "append_event_record", + "record": { + "record_id": 99, + "trigger_kind": 10, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "effects": [ + { + "kind": "set_special_condition", + "label": "Imported Follow-On", + "value": 1 + } + ] + } + } + ], + "executable_import_ready": true, + "notes": ["fixture packed-event record"] + } + ] + }, + "event_runtime_records": [ + { + "record_id": 7, + "trigger_kind": 7, + "active": true, + "service_count": 0, + "marks_collection_dirty": true, + "one_shot": false, + "has_fired": false, + "effects": [ + { + "kind": "set_world_flag", + "key": "from_packed_root", + "value": true + }, + { + "kind": "append_event_record", + "record": { + "record_id": 99, + "trigger_kind": 10, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "effects": [ + { + "kind": "set_special_condition", + "label": "Imported Follow-On", + "value": 1 + } + ] + } + } + ] + } + ], + "candidate_availability": {}, + "special_conditions": {}, + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } +}