diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 6cc8f4f..b1082da 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -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> { } => { 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> { 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> { }) } _ => Err( - "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" + "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime export-save-slice | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" .into(), ), } @@ -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> { + 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> { + 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> { 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 { diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index 8093021..ed44cb7 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -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> { - 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); + } } diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 8d57983..21fded9 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -70,6 +70,10 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_imported_runtime_record_count: Option, #[serde(default)] + pub packed_event_parity_only_record_count: Option, + #[serde(default)] + pub packed_event_unsupported_record_count: Option, + #[serde(default)] pub event_runtime_record_count: Option, #[serde(default)] pub candidate_availability_count: Option, @@ -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, #[serde(default)] + pub state_save_slice_path: Option, + #[serde(default)] pub commands: Vec, #[serde(default)] pub expected_summary: ExpectedRuntimeSummary, diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 5a9ae91..89dcc7f 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -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, + #[serde(default)] + pub original_save_filename: Option, + #[serde(default)] + pub original_save_sha256: Option, + #[serde(default)] + pub notes: Vec, +} + +#[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> { + 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> { + 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> { @@ -607,6 +703,28 @@ pub fn load_runtime_state_import_from_str( }); } + if let Ok(document) = serde_json::from_str::(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 { diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 1864425..be5bcb2 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -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, diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index 104aedf..996a470 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -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 diff --git a/docs/README.md b/docs/README.md index ad002cb..1f9ef08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,17 +67,17 @@ Current local tool status: The atlas milestone is broad enough that the next implementation focus has already shifted downward into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution, -state-diff tooling, and a packed-event persistence bridge that now reaches per-record summaries and -selective executable import. +state-diff tooling, tracked save-slice documents for captured-runtime inputs, and a packed-event +persistence bridge that now reaches per-record summaries and selective executable import. The highest-value next passes are now: - preserve the atlas and function map as the source of subsystem boundaries while continuing to avoid shell-first implementation bets -- deepen captured-runtime and round-trip fixture coverage on top of the packed-event bridge that now - exists - widen packed-event target-family coverage only where static evidence is strong enough to support deterministic executable import +- 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 environment - keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index aec2a4d..4ce786a 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -22,12 +22,12 @@ Implemented today: - snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the CLI and fixture layers - checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger - service, snapshot-backed inputs, normalized state-fragment assertions, and imported packed-event - execution + service, snapshot-backed inputs, save-slice-backed inputs, normalized state-fragment assertions, + and imported packed-event execution That means the next implementation work is breadth, not bootstrap. The recommended next slice is -captured-runtime depth plus wider packed-event target-family coverage, not another persistence -scaffold pass. +wider packed-event target-family coverage plus company-collection import depth, not another +persistence scaffold pass. ## Why This Boundary @@ -220,8 +220,10 @@ Current status: projected runtime snapshots, normalized diffs, and fixtures - the first decoded packed-event subset can now import into executable runtime records when the decoded actions fit the current normalized runtime-effect model -- the remaining gap is broader captured-runtime and round-trip fixture depth plus wider packed - target-family coverage, not first-pass packed-event decode +- tracked save-slice documents now provide a repo-friendly captured-runtime path without checking in + raw `.smp` binaries +- the remaining gap is wider packed target-family coverage plus company-import depth, not + first-pass captured-runtime plumbing ### Milestone 4: Domain Expansion @@ -352,6 +354,7 @@ The currently implemented normalized runtime surface is: normalized runtime-effect vocabulary with staged event-record mutation - save-side inspection and partial state projection for `.smp` inputs, including per-record packed event summaries and selective executable import +- tracked save-slice documents plus save-slice-backed fixture loading for captured-runtime coverage Checked-in fixture families already include: @@ -363,30 +366,30 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is broader captured-runtime depth on top of the packed -event bridge that now exists today. +The recommended next implementation slice is wider packed-event target-family coverage on top of the +captured save-slice workflow that now exists today. Target behavior: -- keep the packed event bridge grounded against real captured save inputs rather than only synthetic - parser tests and snapshot fixtures - expand the executable import subset beyond the current direct-state and follow-on lanes only when target resolution and field semantics are statically grounded enough to preserve headless determinism +- add the next imported object families needed to make currently parity-only packed rows executable, + starting with company-targeted effects - keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning Public-model additions for that slice: -- additional captured-save fixture material for packed event collections - wider target-family summaries only where imported execution can be justified by current static evidence +- imported company/runtime context needed by the next packed-event target families - no shell queue/modal behavior in the runtime core Fixture work for that slice: -- captured `.smp` or save-slice-backed fixtures that prove real packed event records survive import - and diff paths -- regression fixtures that lock the current selective executable import boundary +- save-slice-backed fixtures that prove real packed event records survive import and diff paths +- regression fixtures that lock the current selective executable import boundary and the + unsupported/parity-only counts - state-fragment assertions that lock both packed parity summaries and imported executable records Do not mix this slice with: diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json new file mode 100644 index 0000000..48f3573 --- /dev/null +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -0,0 +1,51 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-parity-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a tracked save-slice document with parity-heavy packed-event records." + }, + "state_save_slice_path": "packed-event-parity-save-slice.json", + "commands": [ + { + "kind": "step_count", + "steps": 1 + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "calendar_projection_is_placeholder": true, + "packed_event_collection_present": true, + "packed_event_record_count": 2, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "packed_event_parity_only_record_count": 1, + "packed_event_unsupported_record_count": 1, + "event_runtime_record_count": 0, + "total_company_cash": 0 + }, + "expected_state_fragment": { + "calendar": { + "tick_slot": 1 + }, + "metadata": { + "save_slice.import_projection": "partial-runtime-restore-v1" + }, + "packed_event_collection": { + "live_entry_ids": [3, 5], + "records": [ + { + "decode_status": "unsupported_framing" + }, + { + "decode_status": "parity_only" + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json new file mode 100644 index 0000000..1297b60 --- /dev/null +++ b/fixtures/runtime/packed-event-parity-save-slice.json @@ -0,0 +1,123 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-parity-save-slice", + "source": { + "description": "Tracked save-slice document representing a parity-heavy captured packed-event collection.", + "original_save_filename": "captured-parity.gms", + "original_save_sha256": "parity-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "preserves one unsupported row and one decoded-but-parity-only row" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 5, + "live_record_count": 2, + "live_entry_ids": [3, 5], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 3, + "payload_offset": 29186, + "payload_len": 96, + "decode_status": "unsupported_framing", + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "real payload framing not yet decoded" + ] + }, + { + "record_index": 1, + "live_entry_id": 5, + "payload_offset": 29290, + "payload_len": 72, + "decode_status": "parity_only", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [ + { + "label": "primary_text_band", + "packed_len": 7, + "present": true, + "preview": "Parity!" + }, + { + "label": "secondary_text_band_0", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_1", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_2", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_3", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_4", + "packed_len": 0, + "present": false, + "preview": "" + } + ], + "standalone_condition_row_count": 0, + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [ + { + "kind": "adjust_company_cash", + "target": { + "kind": "ids", + "ids": [42] + }, + "delta": 75 + } + ], + "executable_import_ready": false, + "notes": [ + "decoded action requires explicit imported company ids before execution" + ] + } + ] + }, + "notes": [ + "parity-heavy packed-event sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json b/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json new file mode 100644 index 0000000..947dc1c --- /dev/null +++ b/fixtures/runtime/packed-event-selective-import-save-slice-fixture.json @@ -0,0 +1,65 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-selective-import-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a tracked save-slice document with one imported packed-event record and one parity-only record." + }, + "state_save_slice_path": "packed-event-selective-import-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar_projection_is_placeholder": true, + "packed_event_collection_present": true, + "packed_event_record_count": 2, + "packed_event_decoded_record_count": 2, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "packed_event_unsupported_record_count": 0, + "event_runtime_record_count": 2, + "special_condition_count": 1, + "enabled_special_condition_count": 1, + "total_event_record_service_count": 2, + "total_trigger_dispatch_count": 2, + "dirty_rerun_count": 1, + "total_company_cash": 0 + }, + "expected_state_fragment": { + "world_flags": { + "from_packed_root": true + }, + "special_conditions": { + "Imported Follow-On": 1 + }, + "packed_event_collection": { + "live_entry_ids": [7, 9], + "records": [ + { + "decode_status": "executable", + "executable_import_ready": true + }, + { + "decode_status": "parity_only", + "executable_import_ready": false + } + ] + }, + "event_runtime_records": [ + { + "record_id": 7, + "service_count": 1 + }, + { + "record_id": 99, + "service_count": 1 + } + ], + "service_state": { + "dirty_rerun_count": 1 + } + } +} diff --git a/fixtures/runtime/packed-event-selective-import-save-slice.json b/fixtures/runtime/packed-event-selective-import-save-slice.json new file mode 100644 index 0000000..385ef21 --- /dev/null +++ b/fixtures/runtime/packed-event-selective-import-save-slice.json @@ -0,0 +1,189 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-selective-import-save-slice", + "source": { + "description": "Tracked save-slice document representing one executable packed-event record plus one parity-only record.", + "original_save_filename": "captured-selective-import.gms", + "original_save_sha256": "selective-import-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "locks the current selective import boundary" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29952, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 9, + "live_record_count": 2, + "live_entry_ids": [7, 9], + "decoded_record_count": 2, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 7, + "payload_offset": 29186, + "payload_len": 64, + "decode_status": "executable", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": true, + "one_shot": false, + "text_bands": [ + { + "label": "primary_text_band", + "packed_len": 5, + "present": true, + "preview": "Alpha" + }, + { + "label": "secondary_text_band_0", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_1", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_2", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_3", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_4", + "packed_len": 0, + "present": false, + "preview": "" + } + ], + "standalone_condition_row_count": 1, + "grouped_effect_row_counts": [0, 1, 0, 0], + "decoded_actions": [ + { + "kind": "set_world_flag", + "key": "from_packed_root", + "value": true + }, + { + "kind": "append_event_record", + "record": { + "record_id": 99, + "trigger_kind": 10, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "effects": [ + { + "kind": "set_special_condition", + "label": "Imported Follow-On", + "value": 1 + } + ] + } + } + ], + "executable_import_ready": true, + "notes": [ + "fixture packed-event record" + ] + }, + { + "record_index": 1, + "live_entry_id": 9, + "payload_offset": 29260, + "payload_len": 72, + "decode_status": "parity_only", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [ + { + "label": "primary_text_band", + "packed_len": 4, + "present": true, + "preview": "Beta" + }, + { + "label": "secondary_text_band_0", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_1", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_2", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_3", + "packed_len": 0, + "present": false, + "preview": "" + }, + { + "label": "secondary_text_band_4", + "packed_len": 0, + "present": false, + "preview": "" + } + ], + "standalone_condition_row_count": 0, + "grouped_effect_row_counts": [0, 0, 0, 0], + "decoded_actions": [ + { + "kind": "adjust_company_cash", + "target": { + "kind": "ids", + "ids": [42] + }, + "delta": 50 + } + ], + "executable_import_ready": false, + "notes": [ + "decoded action still requires company import depth" + ] + } + ] + }, + "notes": [ + "mixed packed-event sample" + ] + } +}