Add overlay runtime import for packed events

This commit is contained in:
Jan Petykiewicz 2026-04-14 21:19:08 -07:00
commit fa63cefb70
13 changed files with 1248 additions and 153 deletions

View file

@ -16,15 +16,16 @@ use rrt_model::{
}; };
use rrt_runtime::{ use rrt_runtime::{
CAMPAIGN_SCENARIO_COUNT, CampaignExeInspectionReport, OBSERVED_CAMPAIGN_SCENARIO_NAMES, 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, RuntimeSaveSliceDocumentSource, RuntimeSnapshotDocument, RuntimeSnapshotSource, RuntimeSummary,
SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, SmpClassicPackedProfileBlock, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, SmpClassicPackedProfileBlock,
SmpInspectionReport, SmpLoadedSaveSlice, SmpRt3105PackedProfileBlock, SmpSaveLoadSummary, SmpInspectionReport, SmpLoadedSaveSlice, SmpRt3105PackedProfileBlock, SmpSaveLoadSummary,
WinInspectionReport, execute_step_command, extract_pk4_entry_file, inspect_campaign_exe_file, 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, 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, load_runtime_state_import, load_save_slice_file, project_save_slice_to_runtime_state_import,
save_runtime_save_slice_document, save_runtime_snapshot_document, save_runtime_overlay_import_document, save_runtime_save_slice_document,
validate_runtime_snapshot_document, save_runtime_snapshot_document, validate_runtime_snapshot_document,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
@ -128,6 +129,11 @@ enum Command {
smp_path: PathBuf, smp_path: PathBuf,
output_path: PathBuf, output_path: PathBuf,
}, },
RuntimeExportOverlayImport {
snapshot_path: PathBuf,
save_slice_path: PathBuf,
output_path: PathBuf,
},
RuntimeInspectPk4 { RuntimeInspectPk4 {
pk4_path: PathBuf, pk4_path: PathBuf,
}, },
@ -250,6 +256,14 @@ struct RuntimeSaveSliceExportOutput {
save_slice_id: String, 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)] #[derive(Debug, Serialize)]
struct RuntimePk4InspectionOutput { struct RuntimePk4InspectionOutput {
path: String, path: String,
@ -789,6 +803,13 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
} => { } => {
run_runtime_export_save_slice(&smp_path, &output_path)?; 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 } => { Command::RuntimeInspectPk4 { pk4_path } => {
run_runtime_inspect_pk4(&pk4_path)?; run_runtime_inspect_pk4(&pk4_path)?;
} }
@ -956,6 +977,15 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
output_path: PathBuf::from(output_path), 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" => { [command, subcommand, path] if command == "runtime" && subcommand == "inspect-pk4" => {
Ok(Command::RuntimeInspectPk4 { Ok(Command::RuntimeInspectPk4 {
pk4_path: PathBuf::from(path), pk4_path: PathBuf::from(path),
@ -1096,7 +1126,7 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
}) })
} }
_ => Err( _ => Err(
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime diff-state <left.json> <right.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime export-save-slice <file.smp> <save-slice.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]" "usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime diff-state <left.json> <right.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime export-save-slice <file.smp> <save-slice.json> | runtime export-overlay-import <snapshot.json> <save-slice.json> <overlay-import.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]"
.into(), .into(),
), ),
} }
@ -1215,13 +1245,21 @@ fn run_runtime_export_fixture_state(
} }
fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box<dyn std::error::Error>> { fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let snapshot = load_runtime_snapshot_document(snapshot_path)?; if let Ok(snapshot) = load_runtime_snapshot_document(snapshot_path) {
validate_runtime_snapshot_document(&snapshot) validate_runtime_snapshot_document(&snapshot)
.map_err(|err| format!("invalid runtime snapshot: {err}"))?; .map_err(|err| format!("invalid runtime snapshot: {err}"))?;
let summary = snapshot.summary(); 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 { let report = RuntimeStateSummaryReport {
snapshot_id: snapshot.snapshot_id, snapshot_id: import.import_id,
summary, summary: RuntimeSummary::from_state(&import.state),
}; };
println!("{}", serde_json::to_string_pretty(&report)?); println!("{}", serde_json::to_string_pretty(&report)?);
Ok(()) Ok(())
@ -1363,6 +1401,17 @@ fn run_runtime_export_save_slice(
Ok(()) Ok(())
} }
fn run_runtime_export_overlay_import(
snapshot_path: &Path,
save_slice_path: &Path,
output_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
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( fn export_runtime_save_slice_document(
smp_path: &Path, smp_path: &Path,
output_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<RuntimeOverlayImportExportOutput, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> { fn run_runtime_inspect_pk4(pk4_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let report = RuntimePk4InspectionOutput { let report = RuntimePk4InspectionOutput {
path: pk4_path.display().to_string(), path: pk4_path.display().to_string(),
@ -4349,11 +4431,15 @@ mod tests {
.join("../../fixtures/runtime/packed-event-parity-save-slice-fixture.json"); .join("../../fixtures/runtime/packed-event-parity-save-slice-fixture.json");
let selective_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) let selective_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-selective-import-save-slice-fixture.json"); .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) run_runtime_summarize_fixture(&parity_fixture)
.expect("save-slice-backed parity fixture should summarize"); .expect("save-slice-backed parity fixture should summarize");
run_runtime_summarize_fixture(&selective_fixture) run_runtime_summarize_fixture(&selective_fixture)
.expect("save-slice-backed selective-import fixture should summarize"); .expect("save-slice-backed selective-import fixture should summarize");
run_runtime_summarize_fixture(&overlay_fixture)
.expect("overlay-backed selective-import fixture should summarize");
} }
#[test] #[test]
@ -4396,6 +4482,35 @@ mod tests {
let _ = fs::remove_file(output_path); 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] #[test]
fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() { fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() {
let left = serde_json::json!({ let left = serde_json::json!({
@ -4536,6 +4651,27 @@ mod tests {
let _ = fs::remove_file(right_path); 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] #[test]
fn diffs_save_slice_backed_states_across_packed_event_boundaries() { fn diffs_save_slice_backed_states_across_packed_event_boundaries() {
let left_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) let left_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))

View file

@ -1,7 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use rrt_runtime::{ 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, project_save_slice_to_runtime_state_import, validate_runtime_save_slice_document,
validate_runtime_snapshot_document, validate_runtime_snapshot_document,
}; };
@ -34,10 +34,11 @@ fn resolve_raw_fixture_document(
) -> Result<FixtureDocument, Box<dyn std::error::Error>> { ) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
let specified_state_inputs = usize::from(raw.state.is_some()) let specified_state_inputs = usize::from(raw.state.is_some())
+ usize::from(raw.state_snapshot_path.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 { if specified_state_inputs != 1 {
return Err( 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(), .into(),
); );
} }
@ -46,9 +47,10 @@ fn resolve_raw_fixture_document(
&raw.state, &raw.state,
&raw.state_snapshot_path, &raw.state_snapshot_path,
&raw.state_save_slice_path, &raw.state_save_slice_path,
&raw.state_import_path,
) { ) {
(Some(state), None, None) => state.clone(), (Some(state), None, None, None) => state.clone(),
(None, Some(snapshot_path), None) => { (None, Some(snapshot_path), None, None) => {
let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path); let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path);
let snapshot = load_runtime_snapshot_document(&snapshot_path)?; let snapshot = load_runtime_snapshot_document(&snapshot_path)?;
validate_runtime_snapshot_document(&snapshot).map_err(|err| { validate_runtime_snapshot_document(&snapshot).map_err(|err| {
@ -59,7 +61,7 @@ fn resolve_raw_fixture_document(
})?; })?;
snapshot.state 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 save_slice_path = resolve_snapshot_path(base_dir, save_slice_path);
let document = load_runtime_save_slice_document(&save_slice_path)?; let document = load_runtime_save_slice_document(&save_slice_path)?;
validate_runtime_save_slice_document(&document).map_err(|err| { validate_runtime_save_slice_document(&document).map_err(|err| {
@ -81,12 +83,28 @@ fn resolve_raw_fixture_document(
})? })?
.state .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"), _ => unreachable!("state input exclusivity checked above"),
}; };
let state_origin = match (raw.state_snapshot_path, raw.state_save_slice_path) { let state_origin = match (
(Some(snapshot_path), None) => FixtureStateOrigin::SnapshotPath(snapshot_path), raw.state_snapshot_path,
(None, Some(save_slice_path)) => FixtureStateOrigin::SaveSlicePath(save_slice_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, _ => FixtureStateOrigin::Inline,
}; };
@ -116,11 +134,13 @@ mod tests {
use super::*; use super::*;
use crate::FixtureStateOrigin; use crate::FixtureStateOrigin;
use rrt_runtime::{ use rrt_runtime::{
CalendarPoint, RuntimeSaveProfileState, RuntimeSaveSliceDocument, CalendarPoint, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument,
RuntimeOverlayImportDocumentSource, RuntimeSaveProfileState, RuntimeSaveSliceDocument,
RuntimeSaveSliceDocumentSource, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSaveSliceDocumentSource, RuntimeServiceState, RuntimeSnapshotDocument,
RuntimeSnapshotSource, RuntimeState, RuntimeWorldRestoreState, RuntimeSnapshotSource, RuntimeState, RuntimeWorldRestoreState,
SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, 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; use std::collections::BTreeMap;
@ -275,4 +295,150 @@ mod tests {
let _ = std::fs::remove_file(save_slice_path); let _ = std::fs::remove_file(save_slice_path);
let _ = std::fs::remove_dir(fixture_dir); 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);
}
} }

View file

@ -74,6 +74,8 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)] #[serde(default)]
pub packed_event_unsupported_record_count: Option<usize>, pub packed_event_unsupported_record_count: Option<usize>,
#[serde(default)] #[serde(default)]
pub packed_event_blocked_missing_company_context_count: Option<usize>,
#[serde(default)]
pub event_runtime_record_count: Option<usize>, pub event_runtime_record_count: Option<usize>,
#[serde(default)] #[serde(default)]
pub candidate_availability_count: Option<usize>, pub candidate_availability_count: Option<usize>,
@ -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 let Some(count) = self.event_runtime_record_count {
if actual.event_runtime_record_count != count { if actual.event_runtime_record_count != count {
mismatches.push(format!( mismatches.push(format!(
@ -531,6 +541,7 @@ pub enum FixtureStateOrigin {
Inline, Inline,
SnapshotPath(String), SnapshotPath(String),
SaveSlicePath(String), SaveSlicePath(String),
ImportPath(String),
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -546,6 +557,8 @@ pub struct RawFixtureDocument {
#[serde(default)] #[serde(default)]
pub state_save_slice_path: Option<String>, pub state_save_slice_path: Option<String>,
#[serde(default)] #[serde(default)]
pub state_import_path: Option<String>,
#[serde(default)]
pub commands: Vec<StepCommand>, pub commands: Vec<StepCommand>,
#[serde(default)] #[serde(default)]
pub expected_summary: ExpectedRuntimeSummary, pub expected_summary: ExpectedRuntimeSummary,

View file

@ -1,8 +1,9 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::path::Path; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use crate::{ use crate::{
CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
@ -13,6 +14,7 @@ use crate::{
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
pub const SAVE_SLICE_DOCUMENT_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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeStateDumpSource { pub struct RuntimeStateDumpSource {
@ -52,6 +54,24 @@ pub struct RuntimeSaveSliceDocument {
pub save_slice: SmpLoadedSaveSlice, pub save_slice: SmpLoadedSaveSlice,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeOverlayImportDocumentSource {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeStateImport { pub struct RuntimeStateImport {
pub import_id: String, pub import_id: String,
@ -59,6 +79,24 @@ pub struct RuntimeStateImport {
pub state: RuntimeState, pub state: RuntimeState,
} }
#[derive(Debug)]
struct SaveSliceProjection {
world_flags: BTreeMap<String, bool>,
save_profile: RuntimeSaveProfileState,
world_restore: RuntimeWorldRestoreState,
metadata: BTreeMap<String, String>,
packed_event_collection: Option<RuntimePackedEventCollectionSummary>,
event_runtime_records: Vec<RuntimeEventRecord>,
candidate_availability: BTreeMap<String, u32>,
special_conditions: BTreeMap<String, u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SaveSliceProjectionMode {
Standalone,
Overlay,
}
pub fn project_save_slice_to_runtime_state_import( pub fn project_save_slice_to_runtime_state_import(
save_slice: &SmpLoadedSaveSlice, save_slice: &SmpLoadedSaveSlice,
import_id: &str, import_id: &str,
@ -67,7 +105,96 @@ pub fn project_save_slice_to_runtime_state_import(
if import_id.trim().is_empty() { if import_id.trim().is_empty() {
return Err("import_id must not be empty".to_string()); 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<String>,
) -> Result<RuntimeStateImport, String> {
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::<BTreeSet<_>>();
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<u32>,
mode: SaveSliceProjectionMode,
) -> Result<SaveSliceProjection, String> {
let mut world_flags = BTreeMap::new(); let mut world_flags = BTreeMap::new();
world_flags.insert( world_flags.insert(
"save_slice.profile_present".to_string(), "save_slice.profile_present".to_string(),
@ -107,11 +234,19 @@ pub fn project_save_slice_to_runtime_state_import(
let mut metadata = BTreeMap::new(); let mut metadata = BTreeMap::new();
metadata.insert( metadata.insert(
"save_slice.import_projection".to_string(), "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( metadata.insert(
"save_slice.calendar_source".to_string(), "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( metadata.insert(
"save_slice.selected_year_seed_tuple_source".to_string(), "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 { if let Some(family) = &save_slice.bridge_family {
metadata.insert("save_slice.bridge_family".to_string(), family.clone()); metadata.insert("save_slice.bridge_family".to_string(), family.clone());
} }
let known_company_ids = BTreeSet::new();
let imported_event_runtime_records = save_slice let (packed_event_collection, event_runtime_records) =
.event_runtime_collection project_packed_event_collection(save_slice, known_company_ids)?;
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter_map(|record| {
smp_packed_record_to_runtime_event_record(record, &known_company_ids)
})
.collect::<Result<Vec<_>, _>>()
})
.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::<Vec<_>>();
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,
}
});
if let Some(summary) = &save_slice.event_runtime_collection { if let Some(summary) = &save_slice.event_runtime_collection {
metadata.insert( metadata.insert(
"save_slice.event_runtime_collection_source_kind".to_string(), "save_slice.event_runtime_collection_source_kind".to_string(),
@ -220,9 +319,10 @@ pub fn project_save_slice_to_runtime_state_import(
); );
metadata.insert( metadata.insert(
"save_slice.event_runtime_collection_imported_runtime_record_count".to_string(), "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 { let save_profile = if let Some(profile) = &save_slice.profile {
metadata.insert( metadata.insert(
"save_slice.profile_kind".to_string(), "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); candidate_availability.insert(entry.text.clone(), entry.availability_dword);
} }
} }
let mut special_conditions = BTreeMap::new(); let mut special_conditions = BTreeMap::new();
if let Some(table) = &save_slice.special_conditions_table { if let Some(table) = &save_slice.special_conditions_table {
metadata.insert( metadata.insert(
@ -374,35 +475,82 @@ pub fn project_save_slice_to_runtime_state_import(
metadata.insert(format!("save_slice.note.{index}"), note.clone()); metadata.insert(format!("save_slice.note.{index}"), note.clone());
} }
let state = RuntimeState { Ok(SaveSliceProjection {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags, world_flags,
save_profile, save_profile,
world_restore, world_restore,
metadata, metadata,
companies: Vec::new(),
packed_event_collection, packed_event_collection,
event_runtime_records: imported_event_runtime_records, event_runtime_records,
candidate_availability, candidate_availability,
special_conditions, 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<u32>,
) -> Result<
(
Option<RuntimePackedEventCollectionSummary>,
Vec<RuntimeEventRecord>,
),
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::<Vec<_>>();
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( fn runtime_packed_event_record_summary_from_smp(
record: &SmpLoadedPackedEventRecordSummary, record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
imported: bool,
) -> RuntimePackedEventRecordSummary { ) -> RuntimePackedEventRecordSummary {
RuntimePackedEventRecordSummary { RuntimePackedEventRecordSummary {
record_index: record.record_index, 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(), grouped_effect_row_counts: record.grouped_effect_row_counts.clone(),
decoded_actions: record.decoded_actions.clone(), decoded_actions: record.decoded_actions.clone(),
executable_import_ready: record.executable_import_ready, executable_import_ready: record.executable_import_ready,
import_outcome: Some(determine_packed_event_import_outcome(
record,
known_company_ids,
imported,
)),
notes: record.notes.clone(), notes: record.notes.clone(),
} }
} }
@ -442,48 +595,52 @@ fn smp_packed_record_to_runtime_event_record(
record: &SmpLoadedPackedEventRecordSummary, record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>, known_company_ids: &BTreeSet<u32>,
) -> Option<Result<RuntimeEventRecord, String>> { ) -> Option<Result<RuntimeEventRecord, String>> {
if !record.executable_import_ready { if record.decode_status == "unsupported_framing" {
return None; return None;
} }
Some( let effects =
smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) {
.and_then(|effects| { Ok(effects) => effects,
let trigger_kind = record.trigger_kind.ok_or_else(|| { Err(err) if err.contains("unresolved company ids") => return None,
format!( Err(_) => return None,
"packed event record {} is missing trigger_kind", };
record.live_entry_id
) Some((|| {
})?; let trigger_kind = record.trigger_kind.ok_or_else(|| {
let active = record.active.ok_or_else(|| { format!(
format!( "packed event record {} is missing trigger_kind",
"packed event record {} is missing active flag", record.live_entry_id
record.live_entry_id )
) })?;
})?; let active = record.active.ok_or_else(|| {
let marks_collection_dirty = record.marks_collection_dirty.ok_or_else(|| { format!(
format!( "packed event record {} is missing active flag",
"packed event record {} is missing dirty flag", record.live_entry_id
record.live_entry_id )
) })?;
})?; let marks_collection_dirty = record.marks_collection_dirty.ok_or_else(|| {
let one_shot = record.one_shot.ok_or_else(|| { format!(
format!( "packed event record {} is missing dirty flag",
"packed event record {} is missing one_shot flag", record.live_entry_id
record.live_entry_id )
) })?;
})?; let one_shot = record.one_shot.ok_or_else(|| {
Ok(RuntimeEventRecordTemplate { format!(
record_id: record.live_entry_id, "packed event record {} is missing one_shot flag",
trigger_kind, record.live_entry_id
active, )
marks_collection_dirty, })?;
one_shot, Ok(RuntimeEventRecordTemplate {
effects, record_id: record.live_entry_id,
} trigger_kind,
.into_runtime_record()) active,
}), marks_collection_dirty,
) one_shot,
effects,
}
.into_runtime_record())
})())
} }
fn smp_runtime_effects_to_runtime_effects( 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<u32>,
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<u32>,
) -> 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<u32>,
) -> 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( pub fn validate_runtime_state_dump_document(
document: &RuntimeStateDumpDocument, document: &RuntimeStateDumpDocument,
) -> Result<(), String> { ) -> Result<(), String> {
@ -655,6 +860,42 @@ pub fn validate_runtime_save_slice_document(
Ok(()) 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( pub fn load_runtime_save_slice_document(
path: &Path, path: &Path,
) -> Result<RuntimeSaveSliceDocument, Box<dyn std::error::Error>> { ) -> Result<RuntimeSaveSliceDocument, Box<dyn std::error::Error>> {
@ -663,6 +904,14 @@ pub fn load_runtime_save_slice_document(
Ok(document) Ok(document)
} }
pub fn load_runtime_overlay_import_document(
path: &Path,
) -> Result<RuntimeOverlayImportDocument, Box<dyn std::error::Error>> {
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( pub fn save_runtime_save_slice_document(
path: &Path, path: &Path,
document: &RuntimeSaveSliceDocument, document: &RuntimeSaveSliceDocument,
@ -677,21 +926,44 @@ pub fn save_runtime_save_slice_document(
Ok(()) Ok(())
} }
pub fn save_runtime_overlay_import_document(
path: &Path,
document: &RuntimeOverlayImportDocument,
) -> Result<(), Box<dyn std::error::Error>> {
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( pub fn load_runtime_state_import(
path: &Path, path: &Path,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> { ) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?; let text = std::fs::read_to_string(path)?;
load_runtime_state_import_from_str( load_runtime_state_import_from_str_with_base(
&text, &text,
path.file_stem() path.file_stem()
.and_then(|stem| stem.to_str()) .and_then(|stem| stem.to_str())
.unwrap_or("runtime-state"), .unwrap_or("runtime-state"),
path.parent().unwrap_or_else(|| Path::new(".")),
) )
} }
pub fn load_runtime_state_import_from_str( pub fn load_runtime_state_import_from_str(
text: &str, text: &str,
fallback_id: &str, fallback_id: &str,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
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<RuntimeStateImport, Box<dyn std::error::Error>> { ) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
if let Ok(document) = serde_json::from_str::<RuntimeStateDumpDocument>(text) { if let Ok(document) = serde_json::from_str::<RuntimeStateDumpDocument>(text) {
validate_runtime_state_dump_document(&document) validate_runtime_state_dump_document(&document)
@ -725,6 +997,51 @@ pub fn load_runtime_state_import_from_str(
return Ok(import); return Ok(import);
} }
if let Ok(document) = serde_json::from_str::<RuntimeOverlayImportDocument>(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)?; let state: RuntimeState = serde_json::from_str(text)?;
state state
.validate() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1408,5 +1734,278 @@ mod tests {
.map(|summary| summary.imported_runtime_record_count), .map(|summary| summary.imported_runtime_record_count),
Some(0) 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);
} }
} }

View file

@ -15,10 +15,14 @@ pub use campaign_exe::{
OBSERVED_CAMPAIGN_SCENARIO_NAMES, inspect_campaign_exe_bytes, inspect_campaign_exe_file, OBSERVED_CAMPAIGN_SCENARIO_NAMES, inspect_campaign_exe_bytes, inspect_campaign_exe_file,
}; };
pub use import::{ pub use import::{
RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeStateDumpDocument, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument,
RuntimeStateDumpSource, RuntimeStateImport, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocumentSource, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource,
STATE_DUMP_FORMAT_VERSION, load_runtime_save_slice_document, load_runtime_state_import, RuntimeStateDumpDocument, RuntimeStateDumpSource, RuntimeStateImport,
project_save_slice_to_runtime_state_import, save_runtime_save_slice_document, 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, validate_runtime_save_slice_document, validate_runtime_state_dump_document,
}; };
pub use persistence::{ pub use persistence::{

View file

@ -133,6 +133,8 @@ pub struct RuntimePackedEventRecordSummary {
#[serde(default)] #[serde(default)]
pub executable_import_ready: bool, pub executable_import_ready: bool,
#[serde(default)] #[serde(default)]
pub import_outcome: Option<String>,
#[serde(default)]
pub notes: Vec<String>, pub notes: Vec<String>,
} }
@ -333,14 +335,17 @@ impl RuntimeState {
.to_string(), .to_string(),
); );
} }
let executable_import_ready_count = summary let importable_or_imported_count = summary
.records .records
.iter() .iter()
.filter(|record| record.executable_import_ready) .filter(|record| {
record.executable_import_ready
|| record.import_outcome.as_deref() == Some("imported")
})
.count(); .count();
if summary.imported_runtime_record_count > executable_import_ready_count { if summary.imported_runtime_record_count > importable_or_imported_count {
return Err( 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(), .to_string(),
); );
} }
@ -382,6 +387,15 @@ impl RuntimeState {
"packed_event_collection.records[{record_index}].decode_status must not be empty" "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 { if record.grouped_effect_row_counts.len() != 4 {
return Err(format!( return Err(format!(
"packed_event_collection.records[{record_index}].grouped_effect_row_counts must contain exactly 4 entries" "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], grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(), decoded_actions: Vec::new(),
executable_import_ready: false, executable_import_ready: false,
import_outcome: None,
notes: vec!["test".to_string()], notes: vec!["test".to_string()],
}, },
RuntimePackedEventRecordSummary { RuntimePackedEventRecordSummary {
@ -784,6 +799,7 @@ mod tests {
grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(), decoded_actions: Vec::new(),
executable_import_ready: false, executable_import_ready: false,
import_outcome: None,
notes: vec!["test".to_string()], notes: vec!["test".to_string()],
}, },
], ],

View file

@ -34,6 +34,7 @@ pub struct RuntimeSummary {
pub packed_event_imported_runtime_record_count: usize, pub packed_event_imported_runtime_record_count: usize,
pub packed_event_parity_only_record_count: usize, pub packed_event_parity_only_record_count: usize,
pub packed_event_unsupported_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 event_runtime_record_count: usize,
pub candidate_availability_count: usize, pub candidate_availability_count: usize,
pub zero_candidate_availability_count: usize, pub zero_candidate_availability_count: usize,
@ -153,6 +154,20 @@ impl RuntimeSummary {
.count() .count()
}) })
.unwrap_or(0), .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(), event_runtime_record_count: state.event_runtime_records.len(),
candidate_availability_count: state.candidate_availability.len(), candidate_availability_count: state.candidate_availability.len(),
zero_candidate_availability_count: state zero_candidate_availability_count: state

View file

@ -67,17 +67,18 @@ Current local tool status:
The atlas milestone is broad enough that the next implementation focus has already shifted downward 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 into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic
trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution, 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 state-diff tooling, tracked save-slice documents for captured-runtime inputs, overlay import
persistence bridge that now reaches per-record summaries and selective executable 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: The highest-value next passes are now:
- preserve the atlas and function map as the source of subsystem boundaries while continuing to - preserve the atlas and function map as the source of subsystem boundaries while continuing to
avoid shell-first implementation bets 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 - widen packed-event target-family coverage only where static evidence is strong enough to support
deterministic executable import deterministic executable import after the necessary runtime context is present
- add the next imported object/context families needed to turn current parity-only packed rows into
executable runtime records
- use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution
environment environment
- keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice - keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice

View file

@ -22,12 +22,12 @@ Implemented today:
- snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the - snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the
CLI and fixture layers CLI and fixture layers
- checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger - checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger
service, snapshot-backed inputs, save-slice-backed inputs, normalized state-fragment assertions, service, snapshot-backed inputs, save-slice-backed inputs, overlay-import-backed inputs,
and imported packed-event execution normalized state-fragment assertions, and imported packed-event execution
That means the next implementation work is breadth, not bootstrap. The recommended next slice is 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 captured-context overlay import for company-targeted packed events, not another persistence
persistence scaffold pass. scaffold pass.
## Why This Boundary ## Why This Boundary
@ -366,30 +366,32 @@ Checked-in fixture families already include:
## Next Slice ## Next Slice
The recommended next implementation slice is wider packed-event target-family coverage on top of the The recommended next implementation slice is captured-context overlay import on top of the
captured save-slice workflow that now exists today. save-slice and snapshot workflows that already exist today.
Target behavior: Target behavior:
- expand the executable import subset beyond the current direct-state and follow-on lanes only when - preserve save slices as partial state rather than pretending they reconstruct full live company
target resolution and field semantics are statically grounded enough to preserve headless state
determinism - overlay save-derived packed-event state onto a captured runtime snapshot that already has the
- add the next imported object families needed to make currently parity-only packed rows executable, needed company roster and other live context
starting with company-targeted effects - 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 - keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning
Public-model additions for that slice: Public-model additions for that slice:
- wider target-family summaries only where imported execution can be justified by current static - tracked overlay import documents that reference one base snapshot plus one save-slice document
evidence - runtime-side import outcome labels for packed records so blocked-missing-context and
- imported company/runtime context needed by the next packed-event target families blocked-unsupported cases stay explicit
- no shell queue/modal behavior in the runtime core - fixture support for generic runtime-import documents, not just snapshots or save slices
Fixture work for that slice: Fixture work for that slice:
- save-slice-backed fixtures that prove real packed event records survive import and diff paths - overlay-backed fixtures that prove company-targeted packed rows execute deterministically against
- regression fixtures that lock the current selective executable import boundary and the captured company context
unsupported/parity-only counts - 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 - state-fragment assertions that lock both packed parity summaries and imported executable records
Do not mix this slice with: Do not mix this slice with:

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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"
}

View file

@ -20,6 +20,7 @@
"packed_event_imported_runtime_record_count": 1, "packed_event_imported_runtime_record_count": 1,
"packed_event_parity_only_record_count": 1, "packed_event_parity_only_record_count": 1,
"packed_event_unsupported_record_count": 0, "packed_event_unsupported_record_count": 0,
"packed_event_blocked_missing_company_context_count": 1,
"event_runtime_record_count": 2, "event_runtime_record_count": 2,
"special_condition_count": 1, "special_condition_count": 1,
"enabled_special_condition_count": 1, "enabled_special_condition_count": 1,
@ -40,11 +41,13 @@
"records": [ "records": [
{ {
"decode_status": "executable", "decode_status": "executable",
"executable_import_ready": true "executable_import_ready": true,
"import_outcome": "imported"
}, },
{ {
"decode_status": "parity_only", "decode_status": "parity_only",
"executable_import_ready": false "executable_import_ready": false,
"import_outcome": "blocked_missing_company_context"
} }
] ]
}, },