diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index b1082da..8bbe965 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -16,15 +16,16 @@ use rrt_model::{ }; use rrt_runtime::{ CAMPAIGN_SCENARIO_COUNT, CampaignExeInspectionReport, OBSERVED_CAMPAIGN_SCENARIO_NAMES, - Pk4ExtractionReport, Pk4InspectionReport, RuntimeSaveSliceDocument, + OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, Pk4ExtractionReport, Pk4InspectionReport, + RuntimeOverlayImportDocument, RuntimeOverlayImportDocumentSource, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeSnapshotDocument, RuntimeSnapshotSource, RuntimeSummary, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, SmpClassicPackedProfileBlock, SmpInspectionReport, SmpLoadedSaveSlice, SmpRt3105PackedProfileBlock, SmpSaveLoadSummary, WinInspectionReport, execute_step_command, extract_pk4_entry_file, inspect_campaign_exe_file, inspect_pk4_file, inspect_smp_file, inspect_win_file, load_runtime_snapshot_document, load_runtime_state_import, load_save_slice_file, project_save_slice_to_runtime_state_import, - save_runtime_save_slice_document, save_runtime_snapshot_document, - validate_runtime_snapshot_document, + save_runtime_overlay_import_document, save_runtime_save_slice_document, + save_runtime_snapshot_document, validate_runtime_snapshot_document, }; use serde::Serialize; use serde_json::Value; @@ -128,6 +129,11 @@ enum Command { smp_path: PathBuf, output_path: PathBuf, }, + RuntimeExportOverlayImport { + snapshot_path: PathBuf, + save_slice_path: PathBuf, + output_path: PathBuf, + }, RuntimeInspectPk4 { pk4_path: PathBuf, }, @@ -250,6 +256,14 @@ struct RuntimeSaveSliceExportOutput { save_slice_id: String, } +#[derive(Debug, Serialize)] +struct RuntimeOverlayImportExportOutput { + output_path: String, + import_id: String, + base_snapshot_path: String, + save_slice_path: String, +} + #[derive(Debug, Serialize)] struct RuntimePk4InspectionOutput { path: String, @@ -789,6 +803,13 @@ fn real_main() -> Result<(), Box> { } => { run_runtime_export_save_slice(&smp_path, &output_path)?; } + Command::RuntimeExportOverlayImport { + snapshot_path, + save_slice_path, + output_path, + } => { + run_runtime_export_overlay_import(&snapshot_path, &save_slice_path, &output_path)?; + } Command::RuntimeInspectPk4 { pk4_path } => { run_runtime_inspect_pk4(&pk4_path)?; } @@ -956,6 +977,15 @@ fn parse_command() -> Result> { output_path: PathBuf::from(output_path), }) } + [command, subcommand, snapshot_path, save_slice_path, output_path] + if command == "runtime" && subcommand == "export-overlay-import" => + { + Ok(Command::RuntimeExportOverlayImport { + snapshot_path: PathBuf::from(snapshot_path), + save_slice_path: PathBuf::from(save_slice_path), + output_path: PathBuf::from(output_path), + }) + } [command, subcommand, path] if command == "runtime" && subcommand == "inspect-pk4" => { Ok(Command::RuntimeInspectPk4 { pk4_path: PathBuf::from(path), @@ -1096,7 +1126,7 @@ fn parse_command() -> Result> { }) } _ => Err( - "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime export-save-slice | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" + "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime export-save-slice | runtime export-overlay-import | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" .into(), ), } @@ -1215,13 +1245,21 @@ fn run_runtime_export_fixture_state( } fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box> { - let snapshot = load_runtime_snapshot_document(snapshot_path)?; - validate_runtime_snapshot_document(&snapshot) - .map_err(|err| format!("invalid runtime snapshot: {err}"))?; - let summary = snapshot.summary(); + if let Ok(snapshot) = load_runtime_snapshot_document(snapshot_path) { + validate_runtime_snapshot_document(&snapshot) + .map_err(|err| format!("invalid runtime snapshot: {err}"))?; + let report = RuntimeStateSummaryReport { + snapshot_id: snapshot.snapshot_id.clone(), + summary: snapshot.summary(), + }; + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + let import = load_runtime_state_import(snapshot_path)?; let report = RuntimeStateSummaryReport { - snapshot_id: snapshot.snapshot_id, - summary, + snapshot_id: import.import_id, + summary: RuntimeSummary::from_state(&import.state), }; println!("{}", serde_json::to_string_pretty(&report)?); Ok(()) @@ -1363,6 +1401,17 @@ fn run_runtime_export_save_slice( Ok(()) } +fn run_runtime_export_overlay_import( + snapshot_path: &Path, + save_slice_path: &Path, + output_path: &Path, +) -> Result<(), Box> { + let report = + export_runtime_overlay_import_document(snapshot_path, save_slice_path, output_path)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + fn export_runtime_save_slice_document( smp_path: &Path, output_path: &Path, @@ -1397,6 +1446,39 @@ fn export_runtime_save_slice_document( }) } +fn export_runtime_overlay_import_document( + snapshot_path: &Path, + save_slice_path: &Path, + output_path: &Path, +) -> Result> { + let import_id = output_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("overlay-import") + .to_string(); + let document = RuntimeOverlayImportDocument { + format_version: OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, + import_id: import_id.clone(), + source: RuntimeOverlayImportDocumentSource { + description: Some(format!( + "Overlay import referencing {} and {}", + snapshot_path.display(), + save_slice_path.display() + )), + notes: vec![], + }, + base_snapshot_path: snapshot_path.display().to_string(), + save_slice_path: save_slice_path.display().to_string(), + }; + save_runtime_overlay_import_document(output_path, &document)?; + Ok(RuntimeOverlayImportExportOutput { + output_path: output_path.display().to_string(), + import_id, + base_snapshot_path: document.base_snapshot_path, + save_slice_path: document.save_slice_path, + }) +} + fn run_runtime_inspect_pk4(pk4_path: &Path) -> Result<(), Box> { let report = RuntimePk4InspectionOutput { path: pk4_path.display().to_string(), @@ -4349,11 +4431,15 @@ mod tests { .join("../../fixtures/runtime/packed-event-parity-save-slice-fixture.json"); let selective_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/runtime/packed-event-selective-import-save-slice-fixture.json"); + let overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-selective-import-overlay-fixture.json"); run_runtime_summarize_fixture(&parity_fixture) .expect("save-slice-backed parity fixture should summarize"); run_runtime_summarize_fixture(&selective_fixture) .expect("save-slice-backed selective-import fixture should summarize"); + run_runtime_summarize_fixture(&overlay_fixture) + .expect("overlay-backed selective-import fixture should summarize"); } #[test] @@ -4396,6 +4482,35 @@ mod tests { let _ = fs::remove_file(output_path); } + #[test] + fn exports_runtime_overlay_import_document() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let output_path = + std::env::temp_dir().join(format!("rrt-export-overlay-import-test-{nonce}.json")); + let snapshot_path = PathBuf::from("base-snapshot.json"); + let save_slice_path = PathBuf::from("captured-save-slice.json"); + + let report = + export_runtime_overlay_import_document(&snapshot_path, &save_slice_path, &output_path) + .expect("overlay import export should succeed"); + + let expected_import_id = output_path + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("output path should have a stem") + .to_string(); + assert_eq!(report.import_id, expected_import_id); + let document = rrt_runtime::load_runtime_overlay_import_document(&output_path) + .expect("exported overlay import document should load"); + assert_eq!(document.import_id, expected_import_id); + assert_eq!(document.base_snapshot_path, "base-snapshot.json"); + assert_eq!(document.save_slice_path, "captured-save-slice.json"); + let _ = fs::remove_file(output_path); + } + #[test] fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() { let left = serde_json::json!({ @@ -4536,6 +4651,27 @@ mod tests { let _ = fs::remove_file(right_path); } + #[test] + fn diffs_runtime_states_between_save_slice_and_overlay_import() { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-selective-import-save-slice.json"); + let overlay = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-selective-import-overlay.json"); + + let left_state = + load_normalized_runtime_state(&base).expect("save-slice-backed state should load"); + let right_state = + load_normalized_runtime_state(&overlay).expect("overlay-backed state should load"); + let differences = diff_json_values(&left_state, &right_state); + + assert!(differences.iter().any(|entry| { + entry.path == "$.companies[0].company_id" + || entry.path == "$.packed_event_collection.imported_runtime_record_count" + || entry.path == "$.packed_event_collection.records[1].import_outcome" + || entry.path == "$.event_runtime_records[1].record_id" + })); + } + #[test] fn diffs_save_slice_backed_states_across_packed_event_boundaries() { let left_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index ed44cb7..9f3de2c 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use rrt_runtime::{ - load_runtime_save_slice_document, load_runtime_snapshot_document, + load_runtime_save_slice_document, load_runtime_snapshot_document, load_runtime_state_import, project_save_slice_to_runtime_state_import, validate_runtime_save_slice_document, validate_runtime_snapshot_document, }; @@ -34,10 +34,11 @@ fn resolve_raw_fixture_document( ) -> Result> { let specified_state_inputs = usize::from(raw.state.is_some()) + usize::from(raw.state_snapshot_path.is_some()) - + usize::from(raw.state_save_slice_path.is_some()); + + usize::from(raw.state_save_slice_path.is_some()) + + usize::from(raw.state_import_path.is_some()); if specified_state_inputs != 1 { return Err( - "fixture must specify exactly one of inline state, state_snapshot_path, or state_save_slice_path" + "fixture must specify exactly one of inline state, state_snapshot_path, state_save_slice_path, or state_import_path" .into(), ); } @@ -46,9 +47,10 @@ fn resolve_raw_fixture_document( &raw.state, &raw.state_snapshot_path, &raw.state_save_slice_path, + &raw.state_import_path, ) { - (Some(state), None, None) => state.clone(), - (None, Some(snapshot_path), None) => { + (Some(state), None, None, None) => state.clone(), + (None, Some(snapshot_path), None, None) => { let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path); let snapshot = load_runtime_snapshot_document(&snapshot_path)?; validate_runtime_snapshot_document(&snapshot).map_err(|err| { @@ -59,7 +61,7 @@ fn resolve_raw_fixture_document( })?; snapshot.state } - (None, None, Some(save_slice_path)) => { + (None, None, Some(save_slice_path), None) => { let save_slice_path = resolve_snapshot_path(base_dir, save_slice_path); let document = load_runtime_save_slice_document(&save_slice_path)?; validate_runtime_save_slice_document(&document).map_err(|err| { @@ -81,12 +83,28 @@ fn resolve_raw_fixture_document( })? .state } + (None, None, None, Some(import_path)) => { + let import_path = resolve_snapshot_path(base_dir, import_path); + load_runtime_state_import(&import_path) + .map_err(|err| { + format!( + "failed to load runtime import {}: {err}", + import_path.display() + ) + })? + .state + } _ => unreachable!("state input exclusivity checked above"), }; - let state_origin = match (raw.state_snapshot_path, raw.state_save_slice_path) { - (Some(snapshot_path), None) => FixtureStateOrigin::SnapshotPath(snapshot_path), - (None, Some(save_slice_path)) => FixtureStateOrigin::SaveSlicePath(save_slice_path), + let state_origin = match ( + raw.state_snapshot_path, + raw.state_save_slice_path, + raw.state_import_path, + ) { + (Some(snapshot_path), None, None) => FixtureStateOrigin::SnapshotPath(snapshot_path), + (None, Some(save_slice_path), None) => FixtureStateOrigin::SaveSlicePath(save_slice_path), + (None, None, Some(import_path)) => FixtureStateOrigin::ImportPath(import_path), _ => FixtureStateOrigin::Inline, }; @@ -116,11 +134,13 @@ mod tests { use super::*; use crate::FixtureStateOrigin; use rrt_runtime::{ - CalendarPoint, RuntimeSaveProfileState, RuntimeSaveSliceDocument, + CalendarPoint, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument, + RuntimeOverlayImportDocumentSource, RuntimeSaveProfileState, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSnapshotSource, RuntimeState, RuntimeWorldRestoreState, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, - save_runtime_save_slice_document, save_runtime_snapshot_document, + save_runtime_overlay_import_document, save_runtime_save_slice_document, + save_runtime_snapshot_document, }; use std::collections::BTreeMap; @@ -275,4 +295,150 @@ mod tests { let _ = std::fs::remove_file(save_slice_path); let _ = std::fs::remove_dir(fixture_dir); } + + #[test] + fn loads_fixture_from_relative_import_path() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let fixture_dir = std::env::temp_dir().join(format!("rrt-fixture-import-{nonce}")); + std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created"); + + let snapshot_path = fixture_dir.join("base.json"); + let save_slice_path = fixture_dir.join("slice.json"); + let import_path = fixture_dir.join("overlay.json"); + + let snapshot = RuntimeSnapshotDocument { + format_version: SNAPSHOT_FORMAT_VERSION, + snapshot_id: "base".to_string(), + source: RuntimeSnapshotSource::default(), + state: RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 5, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![rrt_runtime::RuntimeCompany { + company_id: 42, + current_cash: 100, + debt: 0, + }], + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }, + }; + save_runtime_snapshot_document(&snapshot_path, &snapshot).expect("snapshot should save"); + + let save_slice = RuntimeSaveSliceDocument { + format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION, + save_slice_id: "slice".to_string(), + source: RuntimeSaveSliceDocumentSource::default(), + save_slice: rrt_runtime::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( + rrt_runtime::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![rrt_runtime::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: vec![], + standalone_condition_row_count: 0, + grouped_effect_row_counts: vec![0, 0, 0, 0], + decoded_actions: vec![rrt_runtime::RuntimeEffect::AdjustCompanyCash { + target: rrt_runtime::RuntimeCompanyTarget::Ids { ids: vec![42] }, + delta: 25, + }], + executable_import_ready: false, + notes: vec![], + }], + }, + ), + notes: vec![], + }, + }; + save_runtime_save_slice_document(&save_slice_path, &save_slice) + .expect("save slice should save"); + + let overlay = RuntimeOverlayImportDocument { + format_version: OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, + import_id: "overlay-backed-fixture-state".to_string(), + source: RuntimeOverlayImportDocumentSource::default(), + base_snapshot_path: "base.json".to_string(), + save_slice_path: "slice.json".to_string(), + }; + save_runtime_overlay_import_document(&import_path, &overlay) + .expect("overlay import should save"); + + let fixture_json = r#" +{ + "format_version": 1, + "fixture_id": "overlay-backed-fixture", + "source": { + "kind": "captured-runtime" + }, + "state_import_path": "overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "company_count": 1, + "packed_event_imported_runtime_record_count": 1 + } +} +"#; + + let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir) + .expect("overlay-backed fixture should load"); + + assert_eq!( + fixture.state_origin, + FixtureStateOrigin::ImportPath("overlay.json".to_string()) + ); + assert_eq!(fixture.state.event_runtime_records.len(), 1); + + let _ = std::fs::remove_file(snapshot_path); + let _ = std::fs::remove_file(save_slice_path); + let _ = std::fs::remove_file(import_path); + let _ = std::fs::remove_dir(fixture_dir); + } } diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 21fded9..a1d7ae2 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -74,6 +74,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_unsupported_record_count: Option, #[serde(default)] + pub packed_event_blocked_missing_company_context_count: Option, + #[serde(default)] pub event_runtime_record_count: Option, #[serde(default)] pub candidate_availability_count: Option, @@ -361,6 +363,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_company_context_count { + if actual.packed_event_blocked_missing_company_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_company_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_company_context_count + )); + } + } if let Some(count) = self.event_runtime_record_count { if actual.event_runtime_record_count != count { mismatches.push(format!( @@ -531,6 +541,7 @@ pub enum FixtureStateOrigin { Inline, SnapshotPath(String), SaveSlicePath(String), + ImportPath(String), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -546,6 +557,8 @@ pub struct RawFixtureDocument { #[serde(default)] pub state_save_slice_path: Option, #[serde(default)] + pub state_import_path: Option, + #[serde(default)] pub commands: Vec, #[serde(default)] pub expected_summary: ExpectedRuntimeSummary, diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 89dcc7f..a463a59 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document}; use crate::{ CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, @@ -13,6 +14,7 @@ use crate::{ pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; pub const SAVE_SLICE_DOCUMENT_FORMAT_VERSION: u32 = 1; +pub const OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RuntimeStateDumpSource { @@ -52,6 +54,24 @@ pub struct RuntimeSaveSliceDocument { pub save_slice: SmpLoadedSaveSlice, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RuntimeOverlayImportDocumentSource { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeOverlayImportDocument { + pub format_version: u32, + pub import_id: String, + #[serde(default)] + pub source: RuntimeOverlayImportDocumentSource, + pub base_snapshot_path: String, + pub save_slice_path: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeStateImport { pub import_id: String, @@ -59,6 +79,24 @@ pub struct RuntimeStateImport { pub state: RuntimeState, } +#[derive(Debug)] +struct SaveSliceProjection { + world_flags: BTreeMap, + save_profile: RuntimeSaveProfileState, + world_restore: RuntimeWorldRestoreState, + metadata: BTreeMap, + packed_event_collection: Option, + event_runtime_records: Vec, + candidate_availability: BTreeMap, + special_conditions: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SaveSliceProjectionMode { + Standalone, + Overlay, +} + pub fn project_save_slice_to_runtime_state_import( save_slice: &SmpLoadedSaveSlice, import_id: &str, @@ -67,7 +105,96 @@ pub fn project_save_slice_to_runtime_state_import( if import_id.trim().is_empty() { return Err("import_id must not be empty".to_string()); } + let projection = project_save_slice_components( + save_slice, + &BTreeSet::new(), + SaveSliceProjectionMode::Standalone, + )?; + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: projection.world_flags, + save_profile: projection.save_profile, + world_restore: projection.world_restore, + metadata: projection.metadata, + companies: Vec::new(), + packed_event_collection: projection.packed_event_collection, + event_runtime_records: projection.event_runtime_records, + candidate_availability: projection.candidate_availability, + special_conditions: projection.special_conditions, + service_state: RuntimeServiceState::default(), + }; + state.validate()?; + + Ok(RuntimeStateImport { + import_id: import_id.to_string(), + description, + state, + }) +} + +pub fn project_save_slice_overlay_to_runtime_state_import( + base_state: &RuntimeState, + save_slice: &SmpLoadedSaveSlice, + import_id: &str, + description: Option, +) -> Result { + if import_id.trim().is_empty() { + return Err("import_id must not be empty".to_string()); + } + base_state.validate()?; + + let known_company_ids = base_state + .companies + .iter() + .map(|company| company.company_id) + .collect::>(); + let projection = project_save_slice_components( + save_slice, + &known_company_ids, + SaveSliceProjectionMode::Overlay, + )?; + + let mut world_flags = base_state.world_flags.clone(); + world_flags.retain(|key, _| !key.starts_with("save_slice.")); + world_flags.extend(projection.world_flags); + + let mut metadata = base_state.metadata.clone(); + metadata.retain(|key, _| !key.starts_with("save_slice.")); + metadata.extend(projection.metadata); + + let state = RuntimeState { + calendar: base_state.calendar, + world_flags, + save_profile: projection.save_profile, + world_restore: projection.world_restore, + metadata, + companies: base_state.companies.clone(), + packed_event_collection: projection.packed_event_collection, + event_runtime_records: projection.event_runtime_records, + candidate_availability: projection.candidate_availability, + special_conditions: projection.special_conditions, + service_state: base_state.service_state.clone(), + }; + state.validate()?; + + Ok(RuntimeStateImport { + import_id: import_id.to_string(), + description, + state, + }) +} + +fn project_save_slice_components( + save_slice: &SmpLoadedSaveSlice, + known_company_ids: &BTreeSet, + mode: SaveSliceProjectionMode, +) -> Result { let mut world_flags = BTreeMap::new(); world_flags.insert( "save_slice.profile_present".to_string(), @@ -107,11 +234,19 @@ pub fn project_save_slice_to_runtime_state_import( let mut metadata = BTreeMap::new(); metadata.insert( "save_slice.import_projection".to_string(), - "partial-runtime-restore-v1".to_string(), + match mode { + SaveSliceProjectionMode::Standalone => "partial-runtime-restore-v1", + SaveSliceProjectionMode::Overlay => "overlay-runtime-restore-v1", + } + .to_string(), ); metadata.insert( "save_slice.calendar_source".to_string(), - "default-1830-placeholder".to_string(), + match mode { + SaveSliceProjectionMode::Standalone => "default-1830-placeholder", + SaveSliceProjectionMode::Overlay => "base-snapshot-preserved", + } + .to_string(), ); metadata.insert( "save_slice.selected_year_seed_tuple_source".to_string(), @@ -162,45 +297,9 @@ 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(), - 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(), - decoded_record_count: records - .iter() - .filter(|record| record.decode_status != "unsupported_framing") - .count(), - imported_runtime_record_count: imported_event_runtime_records.len(), - records, - } - }); + + let (packed_event_collection, event_runtime_records) = + project_packed_event_collection(save_slice, known_company_ids)?; if let Some(summary) = &save_slice.event_runtime_collection { metadata.insert( "save_slice.event_runtime_collection_source_kind".to_string(), @@ -220,9 +319,10 @@ pub fn project_save_slice_to_runtime_state_import( ); metadata.insert( "save_slice.event_runtime_collection_imported_runtime_record_count".to_string(), - imported_event_runtime_records.len().to_string(), + event_runtime_records.len().to_string(), ); } + let save_profile = if let Some(profile) = &save_slice.profile { metadata.insert( "save_slice.profile_kind".to_string(), @@ -349,6 +449,7 @@ pub fn project_save_slice_to_runtime_state_import( candidate_availability.insert(entry.text.clone(), entry.availability_dword); } } + let mut special_conditions = BTreeMap::new(); if let Some(table) = &save_slice.special_conditions_table { metadata.insert( @@ -374,35 +475,82 @@ pub fn project_save_slice_to_runtime_state_import( metadata.insert(format!("save_slice.note.{index}"), note.clone()); } - let state = RuntimeState { - calendar: CalendarPoint { - year: 1830, - month_slot: 0, - phase_slot: 0, - tick_slot: 0, - }, + Ok(SaveSliceProjection { world_flags, save_profile, world_restore, metadata, - companies: Vec::new(), packed_event_collection, - event_runtime_records: imported_event_runtime_records, + event_runtime_records, candidate_availability, special_conditions, - service_state: RuntimeServiceState::default(), - }; - state.validate()?; - - Ok(RuntimeStateImport { - import_id: import_id.to_string(), - description, - state, }) } +fn project_packed_event_collection( + save_slice: &SmpLoadedSaveSlice, + known_company_ids: &BTreeSet, +) -> Result< + ( + Option, + Vec, + ), + String, +> { + let Some(summary) = save_slice.event_runtime_collection.as_ref() else { + return Ok((None, Vec::new())); + }; + + let mut imported_runtime_records = Vec::new(); + let mut imported_record_ids = BTreeSet::new(); + for record in &summary.records { + if let Some(import_result) = + smp_packed_record_to_runtime_event_record(record, known_company_ids) + { + let runtime_record = import_result?; + imported_record_ids.insert(record.live_entry_id); + imported_runtime_records.push(runtime_record); + } + } + + let records = summary + .records + .iter() + .map(|record| { + runtime_packed_event_record_summary_from_smp( + record, + known_company_ids, + imported_record_ids.contains(&record.live_entry_id), + ) + }) + .collect::>(); + + Ok(( + Some(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(), + decoded_record_count: records + .iter() + .filter(|record| record.decode_status != "unsupported_framing") + .count(), + imported_runtime_record_count: imported_runtime_records.len(), + records, + }), + imported_runtime_records, + )) +} + fn runtime_packed_event_record_summary_from_smp( record: &SmpLoadedPackedEventRecordSummary, + known_company_ids: &BTreeSet, + imported: bool, ) -> RuntimePackedEventRecordSummary { RuntimePackedEventRecordSummary { record_index: record.record_index, @@ -423,6 +571,11 @@ fn runtime_packed_event_record_summary_from_smp( grouped_effect_row_counts: record.grouped_effect_row_counts.clone(), decoded_actions: record.decoded_actions.clone(), executable_import_ready: record.executable_import_ready, + import_outcome: Some(determine_packed_event_import_outcome( + record, + known_company_ids, + imported, + )), notes: record.notes.clone(), } } @@ -442,48 +595,52 @@ fn smp_packed_record_to_runtime_event_record( record: &SmpLoadedPackedEventRecordSummary, known_company_ids: &BTreeSet, ) -> Option> { - if !record.executable_import_ready { + if record.decode_status == "unsupported_framing" { 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()) - }), - ) + let effects = + match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) { + Ok(effects) => effects, + Err(err) if err.contains("unresolved company ids") => return None, + Err(_) => return None, + }; + + Some((|| { + 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( @@ -588,6 +745,54 @@ fn company_target_supported_for_import( } } +fn determine_packed_event_import_outcome( + record: &SmpLoadedPackedEventRecordSummary, + known_company_ids: &BTreeSet, + imported: bool, +) -> String { + if imported { + return "imported".to_string(); + } + if record.decode_status == "unsupported_framing" { + return "blocked_unsupported_decode".to_string(); + } + if packed_record_requires_missing_company_context(record, known_company_ids) { + return "blocked_missing_company_context".to_string(); + } + "blocked_unsupported_decode".to_string() +} + +fn packed_record_requires_missing_company_context( + record: &SmpLoadedPackedEventRecordSummary, + known_company_ids: &BTreeSet, +) -> bool { + record + .decoded_actions + .iter() + .any(|effect| runtime_effect_requires_missing_company_context(effect, known_company_ids)) +} + +fn runtime_effect_requires_missing_company_context( + effect: &RuntimeEffect, + known_company_ids: &BTreeSet, +) -> bool { + match effect { + RuntimeEffect::AdjustCompanyCash { target, .. } + | RuntimeEffect::AdjustCompanyDebt { target, .. } => { + !company_target_supported_for_import(target, known_company_ids) + } + RuntimeEffect::AppendEventRecord { record } => record.effects.iter().any(|nested| { + runtime_effect_requires_missing_company_context(nested, known_company_ids) + }), + RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetCandidateAvailability { .. } + | RuntimeEffect::SetSpecialCondition { .. } + | RuntimeEffect::ActivateEventRecord { .. } + | RuntimeEffect::DeactivateEventRecord { .. } + | RuntimeEffect::RemoveEventRecord { .. } => false, + } +} + pub fn validate_runtime_state_dump_document( document: &RuntimeStateDumpDocument, ) -> Result<(), String> { @@ -655,6 +860,42 @@ pub fn validate_runtime_save_slice_document( Ok(()) } +pub fn validate_runtime_overlay_import_document( + document: &RuntimeOverlayImportDocument, +) -> Result<(), String> { + if document.format_version != OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION { + return Err(format!( + "unsupported overlay import document format_version {} (expected {})", + document.format_version, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION + )); + } + if document.import_id.trim().is_empty() { + return Err("import_id must not be empty".to_string()); + } + if document + .source + .description + .as_deref() + .is_some_and(|text| text.trim().is_empty()) + { + return Err("overlay import source.description must not be empty".to_string()); + } + for (index, note) in document.source.notes.iter().enumerate() { + if note.trim().is_empty() { + return Err(format!( + "overlay import source.notes[{index}] must not be empty" + )); + } + } + if document.base_snapshot_path.trim().is_empty() { + return Err("base_snapshot_path must not be empty".to_string()); + } + if document.save_slice_path.trim().is_empty() { + return Err("save_slice_path must not be empty".to_string()); + } + Ok(()) +} + pub fn load_runtime_save_slice_document( path: &Path, ) -> Result> { @@ -663,6 +904,14 @@ pub fn load_runtime_save_slice_document( Ok(document) } +pub fn load_runtime_overlay_import_document( + path: &Path, +) -> Result> { + let text = std::fs::read_to_string(path)?; + let document: RuntimeOverlayImportDocument = serde_json::from_str(&text)?; + Ok(document) +} + pub fn save_runtime_save_slice_document( path: &Path, document: &RuntimeSaveSliceDocument, @@ -677,21 +926,44 @@ pub fn save_runtime_save_slice_document( Ok(()) } +pub fn save_runtime_overlay_import_document( + path: &Path, + document: &RuntimeOverlayImportDocument, +) -> Result<(), Box> { + validate_runtime_overlay_import_document(document) + .map_err(|err| format!("invalid runtime overlay import document: {err}"))?; + let bytes = serde_json::to_vec_pretty(document)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, bytes)?; + Ok(()) +} + pub fn load_runtime_state_import( path: &Path, ) -> Result> { let text = std::fs::read_to_string(path)?; - load_runtime_state_import_from_str( + load_runtime_state_import_from_str_with_base( &text, path.file_stem() .and_then(|stem| stem.to_str()) .unwrap_or("runtime-state"), + path.parent().unwrap_or_else(|| Path::new(".")), ) } pub fn load_runtime_state_import_from_str( text: &str, fallback_id: &str, +) -> Result> { + load_runtime_state_import_from_str_with_base(text, fallback_id, Path::new(".")) +} + +fn load_runtime_state_import_from_str_with_base( + text: &str, + fallback_id: &str, + base_dir: &Path, ) -> Result> { if let Ok(document) = serde_json::from_str::(text) { validate_runtime_state_dump_document(&document) @@ -725,6 +997,51 @@ pub fn load_runtime_state_import_from_str( return Ok(import); } + if let Ok(document) = serde_json::from_str::(text) { + validate_runtime_overlay_import_document(&document) + .map_err(|err| format!("invalid runtime overlay import document: {err}"))?; + let base_snapshot_path = resolve_document_path(base_dir, &document.base_snapshot_path); + let save_slice_path = resolve_document_path(base_dir, &document.save_slice_path); + + let snapshot = load_runtime_snapshot_document(&base_snapshot_path)?; + validate_runtime_snapshot_document(&snapshot).map_err(|err| { + format!( + "invalid runtime snapshot {}: {err}", + base_snapshot_path.display() + ) + })?; + let save_slice_document = load_runtime_save_slice_document(&save_slice_path)?; + validate_runtime_save_slice_document(&save_slice_document).map_err(|err| { + format!( + "invalid runtime save slice document {}: {err}", + save_slice_path.display() + ) + })?; + + let mut description_parts = Vec::new(); + if let Some(description) = document.source.description { + description_parts.push(description); + } + if let Some(description) = snapshot.source.description { + description_parts.push(format!("base snapshot {description}")); + } + if let Some(description) = save_slice_document.source.description { + description_parts.push(format!("save slice {description}")); + } + + return project_save_slice_overlay_to_runtime_state_import( + &snapshot.state, + &save_slice_document.save_slice, + &document.import_id, + if description_parts.is_empty() { + None + } else { + Some(description_parts.join(" | ")) + }, + ) + .map_err(Into::into); + } + let state: RuntimeState = serde_json::from_str(text)?; state .validate() @@ -736,6 +1053,15 @@ pub fn load_runtime_state_import_from_str( }) } +fn resolve_document_path(base_dir: &Path, path: &str) -> PathBuf { + let candidate = PathBuf::from(path); + if candidate.is_absolute() { + candidate + } else { + base_dir.join(candidate) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1408,5 +1734,278 @@ mod tests { .map(|summary| summary.imported_runtime_record_count), Some(0) ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_missing_company_context") + ); + } + + #[test] + fn overlays_save_slice_events_onto_base_company_context() { + let base_state = RuntimeState { + calendar: CalendarPoint { + year: 1845, + month_slot: 2, + phase_slot: 1, + tick_slot: 3, + }, + world_flags: BTreeMap::from([("base.only".to_string(), true)]), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::from([("base.note".to_string(), "kept".to_string())]), + companies: vec![crate::RuntimeCompany { + company_id: 42, + current_cash: 500, + debt: 20, + }], + packed_event_collection: None, + event_runtime_records: vec![RuntimeEventRecord { + record_id: 1, + trigger_kind: 1, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![], + }], + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState { + periodic_boundary_calls: 9, + trigger_dispatch_counts: BTreeMap::new(), + total_event_record_services: 4, + dirty_rerun_count: 2, + }, + }; + 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: 42, + 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!["needs company context".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "overlay-smoke", + Some("overlay test".to_string()), + ) + .expect("overlay import should project"); + + assert_eq!(import.state.calendar, base_state.calendar); + assert_eq!(import.state.companies, base_state.companies); + assert_eq!(import.state.service_state, base_state.service_state); + 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) + ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("imported") + ); + assert_eq!( + import + .state + .metadata + .get("save_slice.import_projection") + .map(String::as_str), + Some("overlay-runtime-restore-v1") + ); + assert_eq!( + import.state.metadata.get("base.note").map(String::as_str), + Some("kept") + ); + assert_eq!(import.state.world_flags.get("base.only"), Some(&true)); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("overlay-imported company-targeted record should run"); + + assert_eq!(import.state.companies[0].current_cash, 550); + } + + #[test] + fn loads_overlay_import_document_with_relative_paths() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let fixture_dir = std::env::temp_dir().join(format!("rrt-overlay-import-{nonce}")); + std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created"); + + let snapshot_path = fixture_dir.join("base.json"); + let save_slice_path = fixture_dir.join("slice.json"); + let overlay_path = fixture_dir.join("overlay.json"); + + let snapshot = crate::RuntimeSnapshotDocument { + format_version: crate::SNAPSHOT_FORMAT_VERSION, + snapshot_id: "base".to_string(), + source: crate::RuntimeSnapshotSource { + source_fixture_id: None, + description: Some("base snapshot".to_string()), + }, + state: RuntimeState { + calendar: CalendarPoint { + year: 1835, + month_slot: 1, + phase_slot: 2, + tick_slot: 4, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![crate::RuntimeCompany { + company_id: 42, + current_cash: 100, + debt: 0, + }], + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }, + }; + crate::save_runtime_snapshot_document(&snapshot_path, &snapshot) + .expect("snapshot should save"); + + let save_slice_document = RuntimeSaveSliceDocument { + format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION, + save_slice_id: "slice".to_string(), + source: RuntimeSaveSliceDocumentSource::default(), + 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!["needs company context".to_string()], + }], + }), + notes: vec![], + }, + }; + save_runtime_save_slice_document(&save_slice_path, &save_slice_document) + .expect("save slice should save"); + + let overlay = RuntimeOverlayImportDocument { + format_version: OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, + import_id: "overlay-relative".to_string(), + source: RuntimeOverlayImportDocumentSource { + description: Some("relative overlay".to_string()), + notes: vec![], + }, + base_snapshot_path: "base.json".to_string(), + save_slice_path: "slice.json".to_string(), + }; + save_runtime_overlay_import_document(&overlay_path, &overlay) + .expect("overlay document should save"); + + let import = + load_runtime_state_import(&overlay_path).expect("overlay runtime import should load"); + assert_eq!(import.import_id, "overlay-relative"); + assert_eq!(import.state.event_runtime_records.len(), 1); + assert_eq!(import.state.companies[0].company_id, 42); + + let _ = std::fs::remove_file(snapshot_path); + let _ = std::fs::remove_file(save_slice_path); + let _ = std::fs::remove_file(overlay_path); + let _ = std::fs::remove_dir(fixture_dir); } } diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index be5bcb2..3b76982 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -15,10 +15,14 @@ pub use campaign_exe::{ OBSERVED_CAMPAIGN_SCENARIO_NAMES, inspect_campaign_exe_bytes, inspect_campaign_exe_file, }; pub use import::{ - RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeStateDumpDocument, - RuntimeStateDumpSource, RuntimeStateImport, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, - STATE_DUMP_FORMAT_VERSION, load_runtime_save_slice_document, load_runtime_state_import, - project_save_slice_to_runtime_state_import, save_runtime_save_slice_document, + OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument, + RuntimeOverlayImportDocumentSource, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, + RuntimeStateDumpDocument, RuntimeStateDumpSource, RuntimeStateImport, + SAVE_SLICE_DOCUMENT_FORMAT_VERSION, STATE_DUMP_FORMAT_VERSION, + load_runtime_overlay_import_document, load_runtime_save_slice_document, + load_runtime_state_import, project_save_slice_overlay_to_runtime_state_import, + project_save_slice_to_runtime_state_import, save_runtime_overlay_import_document, + save_runtime_save_slice_document, validate_runtime_overlay_import_document, validate_runtime_save_slice_document, validate_runtime_state_dump_document, }; pub use persistence::{ diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index b6210d8..28441fb 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -133,6 +133,8 @@ pub struct RuntimePackedEventRecordSummary { #[serde(default)] pub executable_import_ready: bool, #[serde(default)] + pub import_outcome: Option, + #[serde(default)] pub notes: Vec, } @@ -333,14 +335,17 @@ impl RuntimeState { .to_string(), ); } - let executable_import_ready_count = summary + let importable_or_imported_count = summary .records .iter() - .filter(|record| record.executable_import_ready) + .filter(|record| { + record.executable_import_ready + || record.import_outcome.as_deref() == Some("imported") + }) .count(); - if summary.imported_runtime_record_count > executable_import_ready_count { + if summary.imported_runtime_record_count > importable_or_imported_count { return Err( - "packed_event_collection.imported_runtime_record_count must not exceed executable-import-ready records" + "packed_event_collection.imported_runtime_record_count must not exceed importable or imported records" .to_string(), ); } @@ -382,6 +387,15 @@ impl RuntimeState { "packed_event_collection.records[{record_index}].decode_status must not be empty" )); } + if record + .import_outcome + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].import_outcome 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" @@ -767,6 +781,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], decoded_actions: Vec::new(), executable_import_ready: false, + import_outcome: None, notes: vec!["test".to_string()], }, RuntimePackedEventRecordSummary { @@ -784,6 +799,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], decoded_actions: Vec::new(), executable_import_ready: false, + import_outcome: None, notes: vec!["test".to_string()], }, ], diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index 996a470..daba4f9 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -34,6 +34,7 @@ pub struct RuntimeSummary { pub packed_event_imported_runtime_record_count: usize, pub packed_event_parity_only_record_count: usize, pub packed_event_unsupported_record_count: usize, + pub packed_event_blocked_missing_company_context_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, pub zero_candidate_availability_count: usize, @@ -153,6 +154,20 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_company_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_company_context") + }) + .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 1f9ef08..0288579 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,17 +67,18 @@ 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, tracked save-slice documents for captured-runtime inputs, and a packed-event -persistence bridge that now reaches per-record summaries and selective executable import. +state-diff tooling, tracked save-slice documents for captured-runtime inputs, overlay import +documents that combine captured snapshots with save-derived state, 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 +- use captured-context overlay imports whenever save-derived packed rows need live runtime context + that the save slice does not actually persist - widen packed-event target-family coverage only where static evidence is strong enough to support - deterministic executable import -- add the next imported object/context families needed to turn current parity-only packed rows into - executable runtime records + deterministic executable import after the necessary runtime context is present - 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 4ce786a..592bca1 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -22,12 +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, save-slice-backed inputs, normalized state-fragment assertions, - and imported packed-event execution + service, snapshot-backed inputs, save-slice-backed inputs, overlay-import-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 -wider packed-event target-family coverage plus company-collection import depth, not another -persistence scaffold pass. +captured-context overlay import for company-targeted packed events, not another persistence +scaffold pass. ## Why This Boundary @@ -366,30 +366,32 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is wider packed-event target-family coverage on top of the -captured save-slice workflow that now exists today. +The recommended next implementation slice is captured-context overlay import on top of the +save-slice and snapshot workflows that already exist today. Target behavior: -- 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 -- add the next imported object families needed to make currently parity-only packed rows executable, - starting with company-targeted effects +- preserve save slices as partial state rather than pretending they reconstruct full live company + state +- overlay save-derived packed-event state onto a captured runtime snapshot that already has the + needed company roster and other live context +- upgrade currently blocked company-targeted packed rows when the overlaid base snapshot provides + every referenced company id - keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning Public-model additions for that slice: -- wider target-family summaries only where imported execution can be justified by current static - evidence -- imported company/runtime context needed by the next packed-event target families -- no shell queue/modal behavior in the runtime core +- tracked overlay import documents that reference one base snapshot plus one save-slice document +- runtime-side import outcome labels for packed records so blocked-missing-context and + blocked-unsupported cases stay explicit +- fixture support for generic runtime-import documents, not just snapshots or save slices Fixture work for that slice: -- 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 and the - unsupported/parity-only counts +- overlay-backed fixtures that prove company-targeted packed rows execute deterministically against + captured company context +- regression fixtures that lock the before/after boundary between save-slice-only imports and + overlay-backed imports - state-fragment assertions that lock both packed parity summaries and imported executable records Do not mix this slice with: diff --git a/fixtures/runtime/packed-event-selective-import-overlay-base-snapshot.json b/fixtures/runtime/packed-event-selective-import-overlay-base-snapshot.json new file mode 100644 index 0000000..e9e137d --- /dev/null +++ b/fixtures/runtime/packed-event-selective-import-overlay-base-snapshot.json @@ -0,0 +1,37 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-selective-import-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying company context for selective packed-event overlay import." + }, + "state": { + "calendar": { + "year": 1835, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 4 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "preserve base runtime context" + }, + "companies": [ + { + "company_id": 42, + "current_cash": 100, + "debt": 10 + } + ], + "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 + } + } +} diff --git a/fixtures/runtime/packed-event-selective-import-overlay-fixture.json b/fixtures/runtime/packed-event-selective-import-overlay-fixture.json new file mode 100644 index 0000000..a95f769 --- /dev/null +++ b/fixtures/runtime/packed-event-selective-import-overlay-fixture.json @@ -0,0 +1,91 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-selective-import-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by an overlay import document so save-derived packed events execute against captured company context." + }, + "state_import_path": "packed-event-selective-import-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar": { + "year": 1835, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 4 + }, + "calendar_projection_source": "base-snapshot-preserved", + "calendar_projection_is_placeholder": false, + "world_flag_count": 7, + "company_count": 1, + "packed_event_collection_present": true, + "packed_event_record_count": 2, + "packed_event_decoded_record_count": 2, + "packed_event_imported_runtime_record_count": 2, + "packed_event_parity_only_record_count": 1, + "packed_event_unsupported_record_count": 0, + "packed_event_blocked_missing_company_context_count": 0, + "event_runtime_record_count": 3, + "special_condition_count": 1, + "enabled_special_condition_count": 1, + "total_event_record_service_count": 3, + "total_trigger_dispatch_count": 2, + "dirty_rerun_count": 1, + "total_company_cash": 150 + }, + "expected_state_fragment": { + "world_flags": { + "base.only": true, + "from_packed_root": true + }, + "metadata": { + "base.note": "preserve base runtime context", + "save_slice.import_projection": "overlay-runtime-restore-v1" + }, + "companies": [ + { + "company_id": 42, + "current_cash": 150, + "debt": 10 + } + ], + "special_conditions": { + "Imported Follow-On": 1 + }, + "packed_event_collection": { + "live_entry_ids": [7, 9], + "records": [ + { + "decode_status": "executable", + "import_outcome": "imported" + }, + { + "decode_status": "parity_only", + "import_outcome": "imported" + } + ] + }, + "event_runtime_records": [ + { + "record_id": 7, + "service_count": 1 + }, + { + "record_id": 9, + "service_count": 1 + }, + { + "record_id": 99, + "service_count": 1 + } + ], + "service_state": { + "dirty_rerun_count": 1 + } + } +} diff --git a/fixtures/runtime/packed-event-selective-import-overlay.json b/fixtures/runtime/packed-event-selective-import-overlay.json new file mode 100644 index 0000000..89019c3 --- /dev/null +++ b/fixtures/runtime/packed-event-selective-import-overlay.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "import_id": "packed-event-selective-import-overlay", + "source": { + "description": "Overlay import that combines a captured base snapshot with the selective packed-event save slice.", + "notes": [ + "used to upgrade explicit company-targeted packed rows from blocked parity-only state into executable runtime records" + ] + }, + "base_snapshot_path": "packed-event-selective-import-overlay-base-snapshot.json", + "save_slice_path": "packed-event-selective-import-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json b/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json index 947dc1c..8f4d080 100644 --- a/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json @@ -20,6 +20,7 @@ "packed_event_imported_runtime_record_count": 1, "packed_event_parity_only_record_count": 1, "packed_event_unsupported_record_count": 0, + "packed_event_blocked_missing_company_context_count": 1, "event_runtime_record_count": 2, "special_condition_count": 1, "enabled_special_condition_count": 1, @@ -40,11 +41,13 @@ "records": [ { "decode_status": "executable", - "executable_import_ready": true + "executable_import_ready": true, + "import_outcome": "imported" }, { "decode_status": "parity_only", - "executable_import_ready": false + "executable_import_ready": false, + "import_outcome": "blocked_missing_company_context" } ] },