Add save-slice-backed runtime fixtures

This commit is contained in:
Jan Petykiewicz 2026-04-14 20:51:27 -07:00
commit 8ca65cbbfb
12 changed files with 974 additions and 47 deletions

View file

@ -16,13 +16,15 @@ use rrt_model::{
};
use rrt_runtime::{
CAMPAIGN_SCENARIO_COUNT, CampaignExeInspectionReport, OBSERVED_CAMPAIGN_SCENARIO_NAMES,
Pk4ExtractionReport, Pk4InspectionReport, RuntimeSnapshotDocument, RuntimeSnapshotSource,
RuntimeSummary, 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_snapshot_document, validate_runtime_snapshot_document,
Pk4ExtractionReport, Pk4InspectionReport, 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,
};
use serde::Serialize;
use serde_json::Value;
@ -122,6 +124,10 @@ enum Command {
smp_path: PathBuf,
output_path: PathBuf,
},
RuntimeExportSaveSlice {
smp_path: PathBuf,
output_path: PathBuf,
},
RuntimeInspectPk4 {
pk4_path: PathBuf,
},
@ -237,6 +243,13 @@ struct RuntimeLoadedSaveSliceOutput {
save_slice: SmpLoadedSaveSlice,
}
#[derive(Debug, Serialize)]
struct RuntimeSaveSliceExportOutput {
path: String,
output_path: String,
save_slice_id: String,
}
#[derive(Debug, Serialize)]
struct RuntimePk4InspectionOutput {
path: String,
@ -770,6 +783,12 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
} => {
run_runtime_import_save_state(&smp_path, &output_path)?;
}
Command::RuntimeExportSaveSlice {
smp_path,
output_path,
} => {
run_runtime_export_save_slice(&smp_path, &output_path)?;
}
Command::RuntimeInspectPk4 { pk4_path } => {
run_runtime_inspect_pk4(&pk4_path)?;
}
@ -929,6 +948,14 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
output_path: PathBuf::from(output_path),
})
}
[command, subcommand, smp_path, output_path]
if command == "runtime" && subcommand == "export-save-slice" =>
{
Ok(Command::RuntimeExportSaveSlice {
smp_path: PathBuf::from(smp_path),
output_path: PathBuf::from(output_path),
})
}
[command, subcommand, path] if command == "runtime" && subcommand == "inspect-pk4" => {
Ok(Command::RuntimeInspectPk4 {
pk4_path: PathBuf::from(path),
@ -1069,7 +1096,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 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 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(),
),
}
@ -1326,6 +1353,50 @@ fn run_runtime_import_save_state(
Ok(())
}
fn run_runtime_export_save_slice(
smp_path: &Path,
output_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let save_slice = load_save_slice_file(smp_path)?;
let report = export_runtime_save_slice_document(smp_path, output_path, save_slice)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn export_runtime_save_slice_document(
smp_path: &Path,
output_path: &Path,
save_slice: SmpLoadedSaveSlice,
) -> Result<RuntimeSaveSliceExportOutput, Box<dyn std::error::Error>> {
let document = RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: smp_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("save-slice")
.to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some(format!(
"Exported loaded save slice from {}",
smp_path.display()
)),
original_save_filename: smp_path
.file_name()
.and_then(|name| name.to_str())
.map(ToString::to_string),
original_save_sha256: None,
notes: vec![],
},
save_slice,
};
save_runtime_save_slice_document(output_path, &document)?;
Ok(RuntimeSaveSliceExportOutput {
path: smp_path.display().to_string(),
output_path: output_path.display().to_string(),
save_slice_id: document.save_slice_id,
})
}
fn run_runtime_inspect_pk4(pk4_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let report = RuntimePk4InspectionOutput {
path: pk4_path.display().to_string(),
@ -4272,6 +4343,59 @@ mod tests {
.expect("snapshot-backed imported packed-event fixture should summarize");
}
#[test]
fn summarizes_save_slice_backed_fixtures() {
let parity_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.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");
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");
}
#[test]
fn exports_runtime_save_slice_document_from_loaded_slice() {
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-save-slice-test-{nonce}.json"));
let smp_path = PathBuf::from("captured-test.gms");
let report = export_runtime_save_slice_document(
&smp_path,
&output_path,
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: None,
notes: vec!["exported for test".to_string()],
},
)
.expect("save slice export should succeed");
assert_eq!(report.save_slice_id, "captured-test");
let document = rrt_runtime::load_runtime_save_slice_document(&output_path)
.expect("exported save slice document should load");
assert_eq!(document.save_slice_id, "captured-test");
assert_eq!(
document.source.original_save_filename.as_deref(),
Some("captured-test.gms")
);
let _ = fs::remove_file(output_path);
}
#[test]
fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() {
let left = serde_json::json!({
@ -4412,6 +4536,25 @@ mod tests {
let _ = fs::remove_file(right_path);
}
#[test]
fn diffs_save_slice_backed_states_across_packed_event_boundaries() {
let left_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-parity-save-slice.json");
let right_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-selective-import-save-slice.json");
let left_state = load_normalized_runtime_state(&left_path)
.expect("left save-slice-backed state should load");
let right_state = load_normalized_runtime_state(&right_path)
.expect("right save-slice-backed state should load");
let differences = diff_json_values(&left_state, &right_state);
assert!(differences.iter().any(|entry| {
entry.path == "$.packed_event_collection.imported_runtime_record_count"
|| entry.path == "$.packed_event_collection.records[0].decode_status"
}));
}
#[test]
fn diffs_classic_profile_samples_across_multiple_files() {
let sample_a = RuntimeClassicProfileSample {

View file

@ -1,6 +1,10 @@
use std::path::{Path, PathBuf};
use rrt_runtime::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use rrt_runtime::{
load_runtime_save_slice_document, load_runtime_snapshot_document,
project_save_slice_to_runtime_state_import, validate_runtime_save_slice_document,
validate_runtime_snapshot_document,
};
use crate::{FixtureDocument, FixtureStateOrigin, RawFixtureDocument};
@ -28,17 +32,23 @@ fn resolve_raw_fixture_document(
raw: RawFixtureDocument,
base_dir: &Path,
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
let state = match (&raw.state, &raw.state_snapshot_path) {
(Some(_), Some(_)) => {
return Err(
"fixture must not specify both inline state and state_snapshot_path".into(),
);
}
(None, None) => {
return Err("fixture must specify either inline state or state_snapshot_path".into());
}
(Some(state), None) => state.clone(),
(None, Some(snapshot_path)) => {
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());
if specified_state_inputs != 1 {
return Err(
"fixture must specify exactly one of inline state, state_snapshot_path, or state_save_slice_path"
.into(),
);
}
let state = match (
&raw.state,
&raw.state_snapshot_path,
&raw.state_save_slice_path,
) {
(Some(state), None, None) => state.clone(),
(None, Some(snapshot_path), 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| {
@ -49,11 +59,35 @@ fn resolve_raw_fixture_document(
})?;
snapshot.state
}
(None, None, Some(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)?;
validate_runtime_save_slice_document(&document).map_err(|err| {
format!(
"invalid runtime save slice document {}: {err}",
save_slice_path.display()
)
})?;
project_save_slice_to_runtime_state_import(
&document.save_slice,
&document.save_slice_id,
document.source.description.clone(),
)
.map_err(|err| {
format!(
"failed to project runtime save slice document {}: {err}",
save_slice_path.display()
)
})?
.state
}
_ => unreachable!("state input exclusivity checked above"),
};
let state_origin = match raw.state_snapshot_path {
Some(snapshot_path) => FixtureStateOrigin::SnapshotPath(snapshot_path),
None => FixtureStateOrigin::Inline,
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),
_ => FixtureStateOrigin::Inline,
};
Ok(FixtureDocument {
@ -82,9 +116,11 @@ mod tests {
use super::*;
use crate::FixtureStateOrigin;
use rrt_runtime::{
CalendarPoint, RuntimeSaveProfileState, RuntimeServiceState, RuntimeSnapshotDocument,
RuntimeSnapshotSource, RuntimeState, RuntimeWorldRestoreState, SNAPSHOT_FORMAT_VERSION,
save_runtime_snapshot_document,
CalendarPoint, 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,
};
use std::collections::BTreeMap;
@ -167,4 +203,76 @@ mod tests {
let _ = std::fs::remove_file(snapshot_path);
let _ = std::fs::remove_dir(fixture_dir);
}
#[test]
fn loads_fixture_from_relative_save_slice_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-save-slice-{nonce}"));
std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created");
let save_slice_path = fixture_dir.join("state-save-slice.json");
let save_slice = RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "save-slice-backed-fixture-state".to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some("test save slice".to_string()),
original_save_filename: Some("fixture.gms".to_string()),
original_save_sha256: None,
notes: vec![],
},
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: None,
notes: vec![],
},
};
save_runtime_save_slice_document(&save_slice_path, &save_slice)
.expect("save slice should save");
let fixture_json = r#"
{
"format_version": 1,
"fixture_id": "save-slice-backed-fixture",
"source": {
"kind": "captured-runtime"
},
"state_save_slice_path": "state-save-slice.json",
"commands": [],
"expected_summary": {
"calendar_projection_is_placeholder": true,
"packed_event_collection_present": false
}
}
"#;
let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir)
.expect("save-slice-backed fixture should load");
assert_eq!(
fixture.state_origin,
FixtureStateOrigin::SaveSlicePath("state-save-slice.json".to_string())
);
assert_eq!(
fixture
.state
.metadata
.get("save_slice.import_projection")
.map(String::as_str),
Some("partial-runtime-restore-v1")
);
let _ = std::fs::remove_file(save_slice_path);
let _ = std::fs::remove_dir(fixture_dir);
}
}

View file

@ -70,6 +70,10 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub packed_event_imported_runtime_record_count: Option<usize>,
#[serde(default)]
pub packed_event_parity_only_record_count: Option<usize>,
#[serde(default)]
pub packed_event_unsupported_record_count: Option<usize>,
#[serde(default)]
pub event_runtime_record_count: Option<usize>,
#[serde(default)]
pub candidate_availability_count: Option<usize>,
@ -341,6 +345,22 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(count) = self.packed_event_parity_only_record_count {
if actual.packed_event_parity_only_record_count != count {
mismatches.push(format!(
"packed_event_parity_only_record_count mismatch: expected {count}, got {}",
actual.packed_event_parity_only_record_count
));
}
}
if let Some(count) = self.packed_event_unsupported_record_count {
if actual.packed_event_unsupported_record_count != count {
mismatches.push(format!(
"packed_event_unsupported_record_count mismatch: expected {count}, got {}",
actual.packed_event_unsupported_record_count
));
}
}
if let Some(count) = self.event_runtime_record_count {
if actual.event_runtime_record_count != count {
mismatches.push(format!(
@ -510,6 +530,7 @@ pub struct FixtureDocument {
pub enum FixtureStateOrigin {
Inline,
SnapshotPath(String),
SaveSlicePath(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -523,6 +544,8 @@ pub struct RawFixtureDocument {
#[serde(default)]
pub state_snapshot_path: Option<String>,
#[serde(default)]
pub state_save_slice_path: Option<String>,
#[serde(default)]
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,

View file

@ -12,6 +12,7 @@ use crate::{
};
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
pub const SAVE_SLICE_DOCUMENT_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeStateDumpSource {
@ -30,6 +31,27 @@ pub struct RuntimeStateDumpDocument {
pub state: RuntimeState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeSaveSliceDocumentSource {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub original_save_filename: Option<String>,
#[serde(default)]
pub original_save_sha256: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeSaveSliceDocument {
pub format_version: u32,
pub save_slice_id: String,
#[serde(default)]
pub source: RuntimeSaveSliceDocumentSource,
pub save_slice: SmpLoadedSaveSlice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeStateImport {
pub import_id: String,
@ -581,6 +603,80 @@ pub fn validate_runtime_state_dump_document(
document.state.validate()
}
pub fn validate_runtime_save_slice_document(
document: &RuntimeSaveSliceDocument,
) -> Result<(), String> {
if document.format_version != SAVE_SLICE_DOCUMENT_FORMAT_VERSION {
return Err(format!(
"unsupported save slice document format_version {} (expected {})",
document.format_version, SAVE_SLICE_DOCUMENT_FORMAT_VERSION
));
}
if document.save_slice_id.trim().is_empty() {
return Err("save_slice_id must not be empty".to_string());
}
if document
.source
.description
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.description must not be empty".to_string());
}
if document
.source
.original_save_filename
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.original_save_filename must not be empty".to_string());
}
if document
.source
.original_save_sha256
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.original_save_sha256 must not be empty".to_string());
}
for (index, note) in document.source.notes.iter().enumerate() {
if note.trim().is_empty() {
return Err(format!(
"save slice source.notes[{index}] must not be empty"
));
}
}
if document.save_slice.mechanism_family.trim().is_empty() {
return Err("save_slice.mechanism_family must not be empty".to_string());
}
if document.save_slice.mechanism_confidence.trim().is_empty() {
return Err("save_slice.mechanism_confidence must not be empty".to_string());
}
Ok(())
}
pub fn load_runtime_save_slice_document(
path: &Path,
) -> Result<RuntimeSaveSliceDocument, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let document: RuntimeSaveSliceDocument = serde_json::from_str(&text)?;
Ok(document)
}
pub fn save_runtime_save_slice_document(
path: &Path,
document: &RuntimeSaveSliceDocument,
) -> Result<(), Box<dyn std::error::Error>> {
validate_runtime_save_slice_document(document)
.map_err(|err| format!("invalid runtime save slice 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>> {
@ -607,6 +703,28 @@ pub fn load_runtime_state_import_from_str(
});
}
if let Ok(document) = serde_json::from_str::<RuntimeSaveSliceDocument>(text) {
validate_runtime_save_slice_document(&document)
.map_err(|err| format!("invalid runtime save slice document: {err}"))?;
let mut description_parts = Vec::new();
if let Some(description) = document.source.description {
description_parts.push(description);
}
if let Some(filename) = document.source.original_save_filename {
description_parts.push(format!("source save {filename}"));
}
let import = project_save_slice_to_runtime_state_import(
&document.save_slice,
&document.save_slice_id,
if description_parts.is_empty() {
None
} else {
Some(description_parts.join(" | "))
},
)?;
return Ok(import);
}
let state: RuntimeState = serde_json::from_str(text)?;
state
.validate()
@ -713,6 +831,84 @@ mod tests {
assert!(import.description.is_none());
}
#[test]
fn validates_and_roundtrips_save_slice_document() {
let document = RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "save-slice-smoke".to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some("test save slice".to_string()),
original_save_filename: Some("smoke.gms".to_string()),
original_save_sha256: Some("deadbeef".to_string()),
notes: vec!["captured fixture".to_string()],
},
save_slice: crate::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: None,
notes: vec![],
},
};
assert!(validate_runtime_save_slice_document(&document).is_ok());
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("rrt-save-slice-doc-{nonce}.json"));
save_runtime_save_slice_document(&path, &document).expect("save slice doc should save");
let loaded = load_runtime_save_slice_document(&path).expect("save slice doc should load");
assert_eq!(document, loaded);
let _ = std::fs::remove_file(path);
}
#[test]
fn loads_save_slice_document_as_runtime_state_import() {
let text = serde_json::to_string(&RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "save-slice-import".to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some("test save slice import".to_string()),
original_save_filename: Some("import.gms".to_string()),
original_save_sha256: None,
notes: vec![],
},
save_slice: crate::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: None,
notes: vec![],
},
})
.expect("save slice doc should serialize");
let import = load_runtime_state_import_from_str(&text, "fallback")
.expect("save slice document should load as runtime import");
assert_eq!(import.import_id, "save-slice-import");
assert_eq!(
import
.state
.metadata
.get("save_slice.import_projection")
.map(String::as_str),
Some("partial-runtime-restore-v1")
);
}
#[test]
fn projects_save_slice_into_runtime_state_import() {
let save_slice = SmpLoadedSaveSlice {

View file

@ -15,9 +15,11 @@ pub use campaign_exe::{
OBSERVED_CAMPAIGN_SCENARIO_NAMES, inspect_campaign_exe_bytes, inspect_campaign_exe_file,
};
pub use import::{
RuntimeStateDumpDocument, RuntimeStateDumpSource, RuntimeStateImport,
STATE_DUMP_FORMAT_VERSION, load_runtime_state_import,
project_save_slice_to_runtime_state_import, validate_runtime_state_dump_document,
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,
validate_runtime_save_slice_document, validate_runtime_state_dump_document,
};
pub use persistence::{
RuntimeSnapshotDocument, RuntimeSnapshotSource, SNAPSHOT_FORMAT_VERSION,

View file

@ -32,6 +32,8 @@ pub struct RuntimeSummary {
pub packed_event_record_count: usize,
pub packed_event_decoded_record_count: usize,
pub packed_event_imported_runtime_record_count: usize,
pub packed_event_parity_only_record_count: usize,
pub packed_event_unsupported_record_count: usize,
pub event_runtime_record_count: usize,
pub candidate_availability_count: usize,
pub zero_candidate_availability_count: usize,
@ -129,6 +131,28 @@ impl RuntimeSummary {
.as_ref()
.map(|summary| summary.imported_runtime_record_count)
.unwrap_or(0),
packed_event_parity_only_record_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| record.decode_status == "parity_only")
.count()
})
.unwrap_or(0),
packed_event_unsupported_record_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| record.decode_status == "unsupported_framing")
.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