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::{
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<dyn std::error::Error>> {
} => {
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<Command, Box<dyn std::error::Error>> {
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<Command, Box<dyn std::error::Error>> {
})
}
_ => 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(),
),
}
@ -1215,13 +1245,21 @@ fn run_runtime_export_fixture_state(
}
fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
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<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(
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<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>> {
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"))

View file

@ -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<FixtureDocument, Box<dyn std::error::Error>> {
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);
}
}

View file

@ -74,6 +74,8 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub packed_event_unsupported_record_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_missing_company_context_count: Option<usize>,
#[serde(default)]
pub event_runtime_record_count: Option<usize>,
#[serde(default)]
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 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<String>,
#[serde(default)]
pub state_import_path: Option<String>,
#[serde(default)]
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,

View file

@ -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<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)]
pub struct RuntimeStateImport {
pub import_id: String,
@ -59,6 +79,24 @@ pub struct RuntimeStateImport {
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(
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<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();
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::<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,
}
});
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<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(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
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<u32>,
) -> Option<Result<RuntimeEventRecord, String>> {
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<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(
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<RuntimeSaveSliceDocument, Box<dyn std::error::Error>> {
@ -663,6 +904,14 @@ pub fn load_runtime_save_slice_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(
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<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(
path: &Path,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
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<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>> {
if let Ok(document) = serde_json::from_str::<RuntimeStateDumpDocument>(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::<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)?;
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);
}
}

View file

@ -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::{

View file

@ -133,6 +133,8 @@ pub struct RuntimePackedEventRecordSummary {
#[serde(default)]
pub executable_import_ready: bool,
#[serde(default)]
pub import_outcome: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
@ -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()],
},
],

View file

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