Decode packed event records for runtime import

This commit is contained in:
Jan Petykiewicz 2026-04-14 20:35:07 -07:00
commit 09b6514dbf
13 changed files with 1801 additions and 50 deletions

View file

@ -4152,7 +4152,38 @@ mod tests {
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 5,
"live_record_count": 3,
"live_entry_ids": [1, 3, 5]
"live_entry_ids": [1, 3, 5],
"decoded_record_count": 0,
"imported_runtime_record_count": 0,
"records": [
{
"record_index": 0,
"live_entry_id": 1,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["left fixture"]
},
{
"record_index": 1,
"live_entry_id": 3,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["left fixture"]
},
{
"record_index": 2,
"live_entry_id": 5,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["left fixture"]
}
]
},
"event_runtime_records": []
}
@ -4178,7 +4209,29 @@ mod tests {
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 5,
"live_record_count": 2,
"live_entry_ids": [1, 5]
"live_entry_ids": [1, 5],
"decoded_record_count": 0,
"imported_runtime_record_count": 0,
"records": [
{
"record_index": 0,
"live_entry_id": 1,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["right fixture"]
},
{
"record_index": 1,
"live_entry_id": 5,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["right fixture"]
}
]
},
"event_runtime_records": []
}
@ -4210,6 +4263,155 @@ mod tests {
.expect("snapshot-backed packed-event fixture should summarize");
}
#[test]
fn summarizes_snapshot_backed_fixture_with_imported_packed_event_record() {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-record-import-from-snapshot.json");
run_runtime_summarize_fixture(&fixture_path)
.expect("snapshot-backed imported packed-event fixture should summarize");
}
#[test]
fn diffs_runtime_states_with_packed_record_and_runtime_record_import_changes() {
let left = serde_json::json!({
"format_version": 1,
"snapshot_id": "left-packed-import",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {},
"companies": [],
"packed_event_collection": {
"source_kind": "packed-event-runtime-collection",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"container_profile_family": "rt3-classic-save-container-v1",
"packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 7,
"live_record_count": 1,
"live_entry_ids": [7],
"decoded_record_count": 0,
"imported_runtime_record_count": 0,
"records": [
{
"record_index": 0,
"live_entry_id": 7,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["left placeholder"]
}
]
},
"event_runtime_records": []
}
});
let right = serde_json::json!({
"format_version": 1,
"snapshot_id": "right-packed-import",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {},
"companies": [],
"packed_event_collection": {
"source_kind": "packed-event-runtime-collection",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"container_profile_family": "rt3-classic-save-container-v1",
"packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 7,
"live_record_count": 1,
"live_entry_ids": [7],
"decoded_record_count": 1,
"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": false,
"one_shot": false,
"text_bands": [
{
"label": "primary_text_band",
"packed_len": 5,
"present": true,
"preview": "Alpha"
}
],
"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
}
],
"executable_import_ready": true,
"notes": ["decoded test record"]
}
]
},
"event_runtime_records": [
{
"record_id": 7,
"trigger_kind": 7,
"active": true,
"marks_collection_dirty": false,
"one_shot": false,
"has_fired": false,
"effects": [
{
"kind": "set_world_flag",
"key": "from_packed_root",
"value": true
}
]
}
]
}
});
let left_path = write_temp_json("runtime-diff-packed-import-left", &left);
let right_path = write_temp_json("runtime-diff-packed-import-right", &right);
let left_state =
load_normalized_runtime_state(&left_path).expect("left runtime state should load");
let right_state =
load_normalized_runtime_state(&right_path).expect("right runtime state should load");
let differences = diff_json_values(&left_state, &right_state);
assert!(differences.iter().any(|entry| {
entry.path == "$.packed_event_collection.records[0].decode_status"
|| entry.path == "$.packed_event_collection.records[0].decoded_actions[0]"
}));
assert!(
differences
.iter()
.any(|entry| entry.path == "$.event_runtime_records[0]")
);
let _ = fs::remove_file(left_path);
let _ = fs::remove_file(right_path);
}
#[test]
fn diffs_classic_profile_samples_across_multiple_files() {
let sample_a = RuntimeClassicProfileSample {

View file

@ -62,6 +62,14 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub company_count: Option<usize>,
#[serde(default)]
pub packed_event_collection_present: Option<bool>,
#[serde(default)]
pub packed_event_record_count: Option<usize>,
#[serde(default)]
pub packed_event_decoded_record_count: Option<usize>,
#[serde(default)]
pub packed_event_imported_runtime_record_count: Option<usize>,
#[serde(default)]
pub event_runtime_record_count: Option<usize>,
#[serde(default)]
pub candidate_availability_count: Option<usize>,
@ -301,6 +309,38 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(present) = self.packed_event_collection_present {
if actual.packed_event_collection_present != present {
mismatches.push(format!(
"packed_event_collection_present mismatch: expected {present}, got {}",
actual.packed_event_collection_present
));
}
}
if let Some(count) = self.packed_event_record_count {
if actual.packed_event_record_count != count {
mismatches.push(format!(
"packed_event_record_count mismatch: expected {count}, got {}",
actual.packed_event_record_count
));
}
}
if let Some(count) = self.packed_event_decoded_record_count {
if actual.packed_event_decoded_record_count != count {
mismatches.push(format!(
"packed_event_decoded_record_count mismatch: expected {count}, got {}",
actual.packed_event_decoded_record_count
));
}
}
if let Some(count) = self.packed_event_imported_runtime_record_count {
if actual.packed_event_imported_runtime_record_count != count {
mismatches.push(format!(
"packed_event_imported_runtime_record_count mismatch: expected {count}, got {}",
actual.packed_event_imported_runtime_record_count
));
}
}
if let Some(count) = self.event_runtime_record_count {
if actual.event_runtime_record_count != count {
mismatches.push(format!(

View file

@ -1,11 +1,14 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::{
CalendarPoint, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedSaveSlice,
CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary,
SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice,
};
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
@ -137,7 +140,27 @@ 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(),
@ -148,6 +171,12 @@ pub fn project_save_slice_to_runtime_state_import(
live_id_bound: summary.live_id_bound,
live_record_count: summary.live_record_count,
live_entry_ids: summary.live_entry_ids.clone(),
decoded_record_count: records
.iter()
.filter(|record| record.decode_status != "unsupported_framing")
.count(),
imported_runtime_record_count: imported_event_runtime_records.len(),
records,
}
});
if let Some(summary) = &save_slice.event_runtime_collection {
@ -163,6 +192,14 @@ pub fn project_save_slice_to_runtime_state_import(
"save_slice.event_runtime_collection_record_count".to_string(),
summary.live_record_count.to_string(),
);
metadata.insert(
"save_slice.event_runtime_collection_decoded_record_count".to_string(),
summary.decoded_record_count.to_string(),
);
metadata.insert(
"save_slice.event_runtime_collection_imported_runtime_record_count".to_string(),
imported_event_runtime_records.len().to_string(),
);
}
let save_profile = if let Some(profile) = &save_slice.profile {
metadata.insert(
@ -328,7 +365,7 @@ pub fn project_save_slice_to_runtime_state_import(
metadata,
companies: Vec::new(),
packed_event_collection,
event_runtime_records: Vec::new(),
event_runtime_records: imported_event_runtime_records,
candidate_availability,
special_conditions,
service_state: RuntimeServiceState::default(),
@ -342,6 +379,193 @@ pub fn project_save_slice_to_runtime_state_import(
})
}
fn runtime_packed_event_record_summary_from_smp(
record: &SmpLoadedPackedEventRecordSummary,
) -> RuntimePackedEventRecordSummary {
RuntimePackedEventRecordSummary {
record_index: record.record_index,
live_entry_id: record.live_entry_id,
payload_offset: record.payload_offset,
payload_len: record.payload_len,
decode_status: record.decode_status.clone(),
trigger_kind: record.trigger_kind,
active: record.active,
marks_collection_dirty: record.marks_collection_dirty,
one_shot: record.one_shot,
text_bands: record
.text_bands
.iter()
.map(runtime_packed_event_text_band_summary_from_smp)
.collect(),
standalone_condition_row_count: record.standalone_condition_row_count,
grouped_effect_row_counts: record.grouped_effect_row_counts.clone(),
decoded_actions: record.decoded_actions.clone(),
executable_import_ready: record.executable_import_ready,
notes: record.notes.clone(),
}
}
fn runtime_packed_event_text_band_summary_from_smp(
band: &SmpLoadedPackedEventTextBandSummary,
) -> RuntimePackedEventTextBandSummary {
RuntimePackedEventTextBandSummary {
label: band.label.clone(),
packed_len: band.packed_len,
present: band.present,
preview: band.preview.clone(),
}
}
fn smp_packed_record_to_runtime_event_record(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
) -> Option<Result<RuntimeEventRecord, String>> {
if !record.executable_import_ready {
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())
}),
)
}
fn smp_runtime_effects_to_runtime_effects(
effects: &[RuntimeEffect],
known_company_ids: &BTreeSet<u32>,
) -> Result<Vec<RuntimeEffect>, String> {
effects
.iter()
.map(|effect| smp_runtime_effect_to_runtime_effect(effect, known_company_ids))
.collect()
}
fn smp_runtime_effect_to_runtime_effect(
effect: &RuntimeEffect,
known_company_ids: &BTreeSet<u32>,
) -> Result<RuntimeEffect, String> {
match effect {
RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag {
key: key.clone(),
value: *value,
}),
RuntimeEffect::AdjustCompanyCash { target, delta } => {
if company_target_supported_for_import(target, known_company_ids) {
Ok(RuntimeEffect::AdjustCompanyCash {
target: target.clone(),
delta: *delta,
})
} else {
Err("packed company-cash effect requires unresolved company ids".to_string())
}
}
RuntimeEffect::AdjustCompanyDebt { target, delta } => {
if company_target_supported_for_import(target, known_company_ids) {
Ok(RuntimeEffect::AdjustCompanyDebt {
target: target.clone(),
delta: *delta,
})
} else {
Err("packed company-debt effect requires unresolved company ids".to_string())
}
}
RuntimeEffect::SetCandidateAvailability { name, value } => {
Ok(RuntimeEffect::SetCandidateAvailability {
name: name.clone(),
value: *value,
})
}
RuntimeEffect::SetSpecialCondition { label, value } => {
Ok(RuntimeEffect::SetSpecialCondition {
label: label.clone(),
value: *value,
})
}
RuntimeEffect::AppendEventRecord { record } => Ok(RuntimeEffect::AppendEventRecord {
record: Box::new(smp_runtime_record_template_to_runtime(
record,
known_company_ids,
)?),
}),
RuntimeEffect::ActivateEventRecord { record_id } => {
Ok(RuntimeEffect::ActivateEventRecord {
record_id: *record_id,
})
}
RuntimeEffect::DeactivateEventRecord { record_id } => {
Ok(RuntimeEffect::DeactivateEventRecord {
record_id: *record_id,
})
}
RuntimeEffect::RemoveEventRecord { record_id } => Ok(RuntimeEffect::RemoveEventRecord {
record_id: *record_id,
}),
}
}
fn smp_runtime_record_template_to_runtime(
record: &RuntimeEventRecordTemplate,
known_company_ids: &BTreeSet<u32>,
) -> Result<RuntimeEventRecordTemplate, String> {
Ok(RuntimeEventRecordTemplate {
record_id: record.record_id,
trigger_kind: record.trigger_kind,
active: record.active,
marks_collection_dirty: record.marks_collection_dirty,
one_shot: record.one_shot,
effects: smp_runtime_effects_to_runtime_effects(&record.effects, known_company_ids)?,
})
}
fn company_target_supported_for_import(
target: &crate::RuntimeCompanyTarget,
known_company_ids: &BTreeSet<u32>,
) -> bool {
match target {
crate::RuntimeCompanyTarget::AllActive => true,
crate::RuntimeCompanyTarget::Ids { ids } => {
!ids.is_empty()
&& ids
.iter()
.all(|company_id| known_company_ids.contains(company_id))
}
}
}
pub fn validate_runtime_state_dump_document(
document: &RuntimeStateDumpDocument,
) -> Result<(), String> {
@ -397,6 +621,7 @@ pub fn load_runtime_state_import_from_str(
#[cfg(test)]
mod tests {
use super::*;
use crate::{StepCommand, execute_step_command};
fn state() -> RuntimeState {
RuntimeState {
@ -419,6 +644,47 @@ mod tests {
}
}
fn packed_text_bands() -> Vec<crate::SmpLoadedPackedEventTextBandSummary> {
vec![
crate::SmpLoadedPackedEventTextBandSummary {
label: "primary_text_band".to_string(),
packed_len: 5,
present: true,
preview: "Alpha".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_0".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_1".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_2".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_3".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_4".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
]
}
#[test]
fn loads_dump_document() {
let text = serde_json::to_string(&RuntimeStateDumpDocument {
@ -548,6 +814,61 @@ mod tests {
live_id_bound: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
decoded_record_count: 0,
imported_runtime_record_count: 0,
records: vec![
crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 1,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
crate::SmpLoadedPackedEventRecordSummary {
record_index: 1,
live_entry_id: 3,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
crate::SmpLoadedPackedEventRecordSummary {
record_index: 2,
live_entry_id: 5,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
],
}),
notes: vec!["packed profile recovered".to_string()],
};
@ -709,4 +1030,187 @@ mod tests {
);
assert!(import.state.event_runtime_records.is_empty());
}
#[test]
fn projects_executable_packed_records_into_runtime_and_services_follow_on() {
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: 7,
live_record_count: 1,
live_entry_ids: vec![7],
decoded_record_count: 1,
imported_runtime_record_count: 1,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 7,
payload_offset: Some(0x7202),
payload_len: Some(64),
decode_status: "executable".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(true),
one_shot: Some(false),
text_bands: packed_text_bands(),
standalone_condition_row_count: 1,
grouped_effect_row_counts: vec![0, 1, 0, 0],
decoded_actions: vec![
RuntimeEffect::SetWorldFlag {
key: "from_packed_root".to_string(),
value: true,
},
RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 99,
trigger_kind: 0x0a,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::SetSpecialCondition {
label: "Imported Follow-On".to_string(),
value: 1,
}],
}),
},
],
executable_import_ready: true,
notes: vec!["decoded test record".to_string()],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-exec",
Some("test packed event import".to_string()),
)
.expect("save slice should project");
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)
);
let result = execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("trigger service should succeed");
assert_eq!(result.final_summary.event_runtime_record_count, 2);
assert_eq!(result.final_summary.total_event_record_service_count, 2);
assert_eq!(result.final_summary.total_trigger_dispatch_count, 2);
assert_eq!(result.final_summary.dirty_rerun_count, 1);
assert_eq!(
import.state.world_flags.get("from_packed_root"),
Some(&true)
);
assert_eq!(
import.state.special_conditions.get("Imported Follow-On"),
Some(&1)
);
assert_eq!(import.state.event_runtime_records[0].service_count, 1);
assert_eq!(import.state.event_runtime_records[1].record_id, 99);
assert_eq!(import.state.event_runtime_records[1].service_count, 1);
}
#[test]
fn leaves_parity_only_packed_records_out_of_runtime_event_records() {
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: 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!["decoded but not importable".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-parity-only",
None,
)
.expect("save slice should project");
assert!(import.state.event_runtime_records.is_empty());
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.decoded_record_count),
Some(1)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.imported_runtime_record_count),
Some(0)
);
}
}

View file

@ -30,7 +30,8 @@ pub use pk4::{
};
pub use runtime::{
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState,
RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary,
RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
};
pub use smp::{
@ -38,7 +39,8 @@ pub use smp::{
SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock,
SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe,
SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit,
SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, SmpLoadedProfile,
SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary,
SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedProfile,
SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation,
SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe,
SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane,

View file

@ -97,6 +97,51 @@ pub struct RuntimePackedEventCollectionSummary {
pub live_id_bound: u32,
pub live_record_count: usize,
pub live_entry_ids: Vec<u32>,
#[serde(default)]
pub decoded_record_count: usize,
#[serde(default)]
pub imported_runtime_record_count: usize,
#[serde(default)]
pub records: Vec<RuntimePackedEventRecordSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimePackedEventRecordSummary {
pub record_index: usize,
pub live_entry_id: u32,
#[serde(default)]
pub payload_offset: Option<usize>,
#[serde(default)]
pub payload_len: Option<usize>,
pub decode_status: String,
#[serde(default)]
pub trigger_kind: Option<u8>,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub marks_collection_dirty: Option<bool>,
#[serde(default)]
pub one_shot: Option<bool>,
#[serde(default)]
pub text_bands: Vec<RuntimePackedEventTextBandSummary>,
#[serde(default)]
pub standalone_condition_row_count: usize,
#[serde(default)]
pub grouped_effect_row_counts: Vec<usize>,
#[serde(default)]
pub decoded_actions: Vec<RuntimeEffect>,
#[serde(default)]
pub executable_import_ready: bool,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimePackedEventTextBandSummary {
pub label: String,
pub packed_len: usize,
pub present: bool,
pub preview: String,
}
impl RuntimeEventRecordTemplate {
@ -271,9 +316,37 @@ impl RuntimeState {
.to_string(),
);
}
if summary.live_record_count != summary.records.len() {
return Err(
"packed_event_collection.live_record_count must match records length"
.to_string(),
);
}
let decoded_record_count = summary
.records
.iter()
.filter(|record| record.decode_status != "unsupported_framing")
.count();
if summary.decoded_record_count != decoded_record_count {
return Err(
"packed_event_collection.decoded_record_count must match decoded records"
.to_string(),
);
}
let executable_import_ready_count = summary
.records
.iter()
.filter(|record| record.executable_import_ready)
.count();
if summary.imported_runtime_record_count > executable_import_ready_count {
return Err(
"packed_event_collection.imported_runtime_record_count must not exceed executable-import-ready records"
.to_string(),
);
}
let mut previous_id = None;
for entry_id in &summary.live_entry_ids {
for (record_index, entry_id) in summary.live_entry_ids.iter().enumerate() {
if *entry_id == 0 {
return Err(
"packed_event_collection.live_entry_ids must not contain id 0".to_string(),
@ -292,6 +365,35 @@ impl RuntimeState {
);
}
previous_id = Some(*entry_id);
let record = &summary.records[record_index];
if record.live_entry_id != *entry_id {
return Err(format!(
"packed_event_collection.records[{record_index}].live_entry_id must match live_entry_ids"
));
}
if record.record_index != record_index {
return Err(format!(
"packed_event_collection.records[{record_index}].record_index must match position"
));
}
if record.decode_status.trim().is_empty() {
return Err(format!(
"packed_event_collection.records[{record_index}].decode_status 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"
));
}
for band in &record.text_bands {
if band.label.trim().is_empty() {
return Err(format!(
"packed_event_collection.records[{record_index}].text_bands contains an empty label"
));
}
}
}
}
@ -647,6 +749,44 @@ mod tests {
live_id_bound: 4,
live_record_count: 2,
live_entry_ids: vec![3, 3],
decoded_record_count: 0,
imported_runtime_record_count: 0,
records: vec![
RuntimePackedEventRecordSummary {
record_index: 0,
live_entry_id: 3,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
RuntimePackedEventRecordSummary {
record_index: 1,
live_entry_id: 3,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
],
}),
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),

View file

@ -4,6 +4,8 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate};
pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec;
const PREAMBLE_U32_WORD_COUNT: usize = 16;
const MIN_ASCII_RUN_LEN: usize = 8;
@ -86,6 +88,17 @@ const EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION: u32 = 0x000003e9;
const INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT: usize = 19;
const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize =
INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4;
const PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC: &[u8; 8] = b"RPEVT001";
const PACKED_EVENT_RECORD_SYNTHETIC_MAGIC: &[u8; 4] = b"RPE1";
const PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC: &[u8; 4] = b"RPT1";
const PACKED_EVENT_TEXT_BAND_LABELS: [&str; 6] = [
"primary_text_band",
"secondary_text_band_0",
"secondary_text_band_1",
"secondary_text_band_2",
"secondary_text_band_3",
"secondary_text_band_4",
];
const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_START_INDEX: usize =
(POST_SPECIAL_CONDITIONS_SCALAR_OFFSET - SPECIAL_CONDITIONS_OFFSET) / 4;
const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_DWORD_COUNT: usize =
@ -1189,6 +1202,51 @@ pub struct SmpLoadedEventRuntimeCollectionSummary {
pub live_id_bound: u32,
pub live_record_count: usize,
pub live_entry_ids: Vec<u32>,
#[serde(default)]
pub decoded_record_count: usize,
#[serde(default)]
pub imported_runtime_record_count: usize,
#[serde(default)]
pub records: Vec<SmpLoadedPackedEventRecordSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedPackedEventRecordSummary {
pub record_index: usize,
pub live_entry_id: u32,
#[serde(default)]
pub payload_offset: Option<usize>,
#[serde(default)]
pub payload_len: Option<usize>,
pub decode_status: String,
#[serde(default)]
pub trigger_kind: Option<u8>,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub marks_collection_dirty: Option<bool>,
#[serde(default)]
pub one_shot: Option<bool>,
#[serde(default)]
pub text_bands: Vec<SmpLoadedPackedEventTextBandSummary>,
#[serde(default)]
pub standalone_condition_row_count: usize,
#[serde(default)]
pub grouped_effect_row_counts: Vec<usize>,
#[serde(default)]
pub decoded_actions: Vec<RuntimeEffect>,
#[serde(default)]
pub executable_import_ready: bool,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedPackedEventTextBandSummary {
pub label: String,
pub packed_len: usize,
pub present: bool,
pub preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -1431,6 +1489,20 @@ fn parse_event_runtime_collection_summary(
if live_entry_ids.len() != live_record_count {
continue;
}
let records_payload = bytes.get(records_tag_offset + 2..close_tag_offset)?;
let records = parse_event_runtime_record_summaries(
records_payload,
records_tag_offset + 2,
&live_entry_ids,
);
let decoded_record_count = records
.iter()
.filter(|record| record.decode_status != "unsupported_framing")
.count();
let imported_runtime_record_count = records
.iter()
.filter(|record| record.executable_import_ready)
.count();
return Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
@ -1450,6 +1522,9 @@ fn parse_event_runtime_collection_summary(
live_id_bound,
live_record_count,
live_entry_ids,
decoded_record_count,
imported_runtime_record_count,
records,
});
}
@ -1491,6 +1566,313 @@ fn decode_live_entry_ids_with_mapping(
Some(live_entry_ids)
}
fn parse_event_runtime_record_summaries(
records_payload: &[u8],
records_payload_offset: usize,
live_entry_ids: &[u32],
) -> Vec<SmpLoadedPackedEventRecordSummary> {
try_parse_synthetic_event_runtime_record_summaries(
records_payload,
records_payload_offset,
live_entry_ids,
)
.unwrap_or_else(|| {
build_unsupported_event_runtime_record_summaries(
live_entry_ids,
"0x4e9a payload did not match the current packed-event record decode harness",
)
})
}
fn try_parse_synthetic_event_runtime_record_summaries(
records_payload: &[u8],
records_payload_offset: usize,
live_entry_ids: &[u32],
) -> Option<Vec<SmpLoadedPackedEventRecordSummary>> {
if !records_payload.starts_with(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC) {
return None;
}
let mut cursor = PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC.len();
let mut records = Vec::with_capacity(live_entry_ids.len());
for (record_index, live_entry_id) in live_entry_ids.iter().copied().enumerate() {
let record_len = usize::try_from(read_u32_at(records_payload, cursor)?).ok()?;
cursor += 4;
let record_body = records_payload.get(cursor..cursor + record_len)?;
records.push(parse_synthetic_event_runtime_record_summary(
record_body,
records_payload_offset + cursor,
record_index,
live_entry_id,
)?);
cursor += record_len;
}
if cursor != records_payload.len() {
return None;
}
Some(records)
}
fn parse_synthetic_event_runtime_record_summary(
record_body: &[u8],
payload_offset: usize,
record_index: usize,
live_entry_id: u32,
) -> Option<SmpLoadedPackedEventRecordSummary> {
if !record_body.starts_with(PACKED_EVENT_RECORD_SYNTHETIC_MAGIC) {
return None;
}
let mut cursor = PACKED_EVENT_RECORD_SYNTHETIC_MAGIC.len();
let trigger_kind = read_u8_at(record_body, cursor)?;
cursor += 1;
let flags = read_u8_at(record_body, cursor)?;
cursor += 1;
let standalone_condition_row_count = usize::from(read_u8_at(record_body, cursor)?);
cursor += 1;
let action_count = usize::from(read_u8_at(record_body, cursor)?);
cursor += 1;
let mut grouped_effect_row_counts = Vec::with_capacity(4);
for _ in 0..4 {
grouped_effect_row_counts.push(usize::from(read_u8_at(record_body, cursor)?));
cursor += 1;
}
let mut text_bands = Vec::with_capacity(PACKED_EVENT_TEXT_BAND_LABELS.len());
for label in PACKED_EVENT_TEXT_BAND_LABELS {
let packed_len = usize::from(read_u16_at(record_body, cursor)?);
cursor += 2;
let band_bytes = record_body.get(cursor..cursor + packed_len)?;
cursor += packed_len;
text_bands.push(SmpLoadedPackedEventTextBandSummary {
label: label.to_string(),
packed_len,
present: packed_len != 0,
preview: ascii_preview(band_bytes),
});
}
let mut decoded_actions = Vec::with_capacity(action_count);
for _ in 0..action_count {
decoded_actions.push(parse_synthetic_packed_event_action(
record_body,
&mut cursor,
)?);
}
if cursor != record_body.len() {
return None;
}
let executable_import_ready = decoded_actions
.iter()
.all(runtime_effect_supported_for_save_import);
Some(SmpLoadedPackedEventRecordSummary {
record_index,
live_entry_id,
payload_offset: Some(payload_offset),
payload_len: Some(record_body.len()),
decode_status: if executable_import_ready {
"executable".to_string()
} else {
"parity_only".to_string()
},
trigger_kind: Some(trigger_kind),
active: Some(flags & 0x01 != 0),
marks_collection_dirty: Some(flags & 0x02 != 0),
one_shot: Some(flags & 0x04 != 0),
text_bands,
standalone_condition_row_count,
grouped_effect_row_counts,
decoded_actions,
executable_import_ready,
notes: vec!["decoded from the current synthetic packed-event record harness".to_string()],
})
}
fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option<RuntimeEffect> {
let opcode = read_u8_at(bytes, *cursor)?;
*cursor += 1;
match opcode {
0x01 => {
let key = parse_len_prefixed_string(bytes, cursor)?;
let value = read_u8_at(bytes, *cursor)? != 0;
*cursor += 1;
Some(RuntimeEffect::SetWorldFlag { key, value })
}
0x02 => {
let target = parse_synthetic_company_target(bytes, cursor)?;
let delta = read_i64_at(bytes, *cursor)?;
*cursor += 8;
Some(RuntimeEffect::AdjustCompanyCash { target, delta })
}
0x03 => {
let target = parse_synthetic_company_target(bytes, cursor)?;
let delta = read_i64_at(bytes, *cursor)?;
*cursor += 8;
Some(RuntimeEffect::AdjustCompanyDebt { target, delta })
}
0x04 => {
let name = parse_len_prefixed_string(bytes, cursor)?;
let value = read_u32_at(bytes, *cursor)?;
*cursor += 4;
Some(RuntimeEffect::SetCandidateAvailability { name, value })
}
0x05 => {
let label = parse_len_prefixed_string(bytes, cursor)?;
let value = read_u32_at(bytes, *cursor)?;
*cursor += 4;
Some(RuntimeEffect::SetSpecialCondition { label, value })
}
0x06 => {
let template_len = usize::try_from(read_u32_at(bytes, *cursor)?).ok()?;
*cursor += 4;
let template_bytes = bytes.get(*cursor..*cursor + template_len)?;
let record = parse_synthetic_event_runtime_record_template(template_bytes)?;
*cursor += template_len;
Some(RuntimeEffect::AppendEventRecord {
record: Box::new(record),
})
}
0x07 => {
let record_id = read_u32_at(bytes, *cursor)?;
*cursor += 4;
Some(RuntimeEffect::ActivateEventRecord { record_id })
}
0x08 => {
let record_id = read_u32_at(bytes, *cursor)?;
*cursor += 4;
Some(RuntimeEffect::DeactivateEventRecord { record_id })
}
0x09 => {
let record_id = read_u32_at(bytes, *cursor)?;
*cursor += 4;
Some(RuntimeEffect::RemoveEventRecord { record_id })
}
_ => None,
}
}
fn parse_synthetic_event_runtime_record_template(
bytes: &[u8],
) -> Option<RuntimeEventRecordTemplate> {
if !bytes.starts_with(PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC) {
return None;
}
let mut cursor = PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC.len();
let record_id = read_u32_at(bytes, cursor)?;
cursor += 4;
let trigger_kind = read_u8_at(bytes, cursor)?;
cursor += 1;
let flags = read_u8_at(bytes, cursor)?;
cursor += 1;
let action_count = usize::from(read_u8_at(bytes, cursor)?);
cursor += 1;
cursor += 1;
let mut effects = Vec::with_capacity(action_count);
for _ in 0..action_count {
effects.push(parse_synthetic_packed_event_action(bytes, &mut cursor)?);
}
if cursor != bytes.len() {
return None;
}
Some(RuntimeEventRecordTemplate {
record_id,
trigger_kind,
active: flags & 0x01 != 0,
marks_collection_dirty: flags & 0x02 != 0,
one_shot: flags & 0x04 != 0,
effects,
})
}
fn parse_synthetic_company_target(
bytes: &[u8],
cursor: &mut usize,
) -> Option<RuntimeCompanyTarget> {
let target_kind = read_u8_at(bytes, *cursor)?;
*cursor += 1;
match target_kind {
0x00 => Some(RuntimeCompanyTarget::AllActive),
0x01 => {
let count = usize::from(read_u8_at(bytes, *cursor)?);
*cursor += 1;
let mut ids = Vec::with_capacity(count);
for _ in 0..count {
ids.push(read_u32_at(bytes, *cursor)?);
*cursor += 4;
}
Some(RuntimeCompanyTarget::Ids { ids })
}
_ => None,
}
}
fn parse_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option<String> {
let len = usize::from(read_u8_at(bytes, *cursor)?);
*cursor += 1;
let text_bytes = bytes.get(*cursor..*cursor + len)?;
*cursor += len;
Some(String::from_utf8_lossy(text_bytes).into_owned())
}
fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
match effect {
RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
| RuntimeEffect::RemoveEventRecord { .. } => true,
RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
matches!(target, RuntimeCompanyTarget::AllActive)
}
RuntimeEffect::AppendEventRecord { record } => record
.effects
.iter()
.all(runtime_effect_supported_for_save_import),
}
}
fn build_unsupported_event_runtime_record_summaries(
live_entry_ids: &[u32],
note: &str,
) -> Vec<SmpLoadedPackedEventRecordSummary> {
live_entry_ids
.iter()
.copied()
.enumerate()
.map(
|(record_index, live_entry_id)| SmpLoadedPackedEventRecordSummary {
record_index,
live_entry_id,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec![note.to_string()],
},
)
.collect()
}
fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> SmpInspectionReport {
let known_tag_hits = KNOWN_TAG_DEFINITIONS
.iter()
@ -4846,11 +5228,27 @@ fn read_u32_window(bytes: &[u8], offset: usize, count: usize) -> Vec<u32> {
words
}
fn read_u8_at(bytes: &[u8], offset: usize) -> Option<u8> {
bytes.get(offset).copied()
}
fn read_u16_at(bytes: &[u8], offset: usize) -> Option<u16> {
let chunk = bytes.get(offset..offset + 2)?;
Some(u16::from_le_bytes([chunk[0], chunk[1]]))
}
fn read_u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
let chunk = bytes.get(offset..offset + 4)?;
Some(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
}
fn read_i64_at(bytes: &[u8], offset: usize) -> Option<i64> {
let chunk = bytes.get(offset..offset + 8)?;
Some(i64::from_le_bytes([
chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
]))
}
fn probable_normal_f32_string(value: u32) -> Option<String> {
let exponent = (value >> 23) & 0xff;
if exponent == 0 || exponent == 0xff {
@ -5611,7 +6009,10 @@ mod tests {
#[test]
fn classifies_recipe_token_layouts() {
assert_eq!(classify_recipe_token_layout(0x00000000), "zero");
assert_eq!(classify_recipe_token_layout(0x72470000), "high16-ascii-stem");
assert_eq!(
classify_recipe_token_layout(0x72470000),
"high16-ascii-stem"
);
assert_eq!(classify_recipe_token_layout(0x00170000), "high16-numeric");
assert_eq!(classify_recipe_token_layout(0x000040a0), "low16-marker");
}
@ -5638,9 +6039,18 @@ mod tests {
#[test]
fn classifies_recipe_runtime_import_branches() {
assert_eq!(classify_recipe_runtime_import_branch(0), "zero-mode-skipped");
assert_eq!(classify_recipe_runtime_import_branch(1), "mode1-demand-branch");
assert_eq!(classify_recipe_runtime_import_branch(3), "mode3-dual-branch");
assert_eq!(
classify_recipe_runtime_import_branch(0),
"zero-mode-skipped"
);
assert_eq!(
classify_recipe_runtime_import_branch(1),
"mode1-demand-branch"
);
assert_eq!(
classify_recipe_runtime_import_branch(3),
"mode3-dual-branch"
);
assert_eq!(
classify_recipe_runtime_import_branch(0x00110000),
"nonzero-supply-branch"
@ -6205,6 +6615,189 @@ mod tests {
assert_eq!(summary.live_record_count, 3);
assert_eq!(summary.live_entry_ids, vec![1, 3, 5]);
assert_eq!(summary.records_tag_offset, 96);
assert_eq!(summary.decoded_record_count, 0);
assert_eq!(summary.records.len(), 3);
assert_eq!(summary.records[0].decode_status, "unsupported_framing");
}
fn encode_len_prefixed_string(text: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(1 + text.len());
bytes.push(text.len() as u8);
bytes.extend_from_slice(text.as_bytes());
bytes
}
fn encode_template(
record_id: u32,
trigger_kind: u8,
flags: u8,
actions: &[Vec<u8>],
) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(PACKED_EVENT_RECORD_TEMPLATE_SYNTHETIC_MAGIC);
bytes.extend_from_slice(&record_id.to_le_bytes());
bytes.push(trigger_kind);
bytes.push(flags);
bytes.push(actions.len() as u8);
bytes.push(0);
for action in actions {
bytes.extend_from_slice(action);
}
bytes
}
fn encode_action_set_world_flag(key: &str, value: bool) -> Vec<u8> {
let mut bytes = vec![0x01];
bytes.extend_from_slice(&encode_len_prefixed_string(key));
bytes.push(u8::from(value));
bytes
}
fn encode_action_set_special_condition(label: &str, value: u32) -> Vec<u8> {
let mut bytes = vec![0x05];
bytes.extend_from_slice(&encode_len_prefixed_string(label));
bytes.extend_from_slice(&value.to_le_bytes());
bytes
}
fn encode_action_adjust_company_cash_ids(ids: &[u32], delta: i64) -> Vec<u8> {
let mut bytes = vec![0x02, 0x01, ids.len() as u8];
for id in ids {
bytes.extend_from_slice(&id.to_le_bytes());
}
bytes.extend_from_slice(&delta.to_le_bytes());
bytes
}
fn encode_action_append_template(template: Vec<u8>) -> Vec<u8> {
let mut bytes = vec![0x06];
bytes.extend_from_slice(&(template.len() as u32).to_le_bytes());
bytes.extend_from_slice(&template);
bytes
}
fn build_synthetic_event_record(
trigger_kind: u8,
flags: u8,
standalone_count: u8,
grouped_counts: [u8; 4],
text_bands: [&[u8]; 6],
actions: &[Vec<u8>],
) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(PACKED_EVENT_RECORD_SYNTHETIC_MAGIC);
bytes.push(trigger_kind);
bytes.push(flags);
bytes.push(standalone_count);
bytes.push(actions.len() as u8);
bytes.extend_from_slice(&grouped_counts);
for band in text_bands {
bytes.extend_from_slice(&(band.len() as u16).to_le_bytes());
bytes.extend_from_slice(band);
}
for action in actions {
bytes.extend_from_slice(action);
}
bytes
}
#[test]
fn parses_synthetic_event_runtime_record_summaries_and_actions() {
let append_template = encode_template(
99,
0x0a,
0x01,
&[encode_action_set_special_condition("Imported Follow-On", 1)],
);
let record_body = build_synthetic_event_record(
7,
0x03,
1,
[0, 1, 0, 0],
[b"Alpha", b"", b"", b"", b"", b""],
&[
encode_action_set_world_flag("from_packed_root", true),
encode_action_append_template(append_template),
],
);
let mut bytes = Vec::new();
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes());
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes());
let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for word in header_words {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice(&[0x00, 0x00]);
bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes());
bytes.extend_from_slice(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC);
bytes.extend_from_slice(&(record_body.len() as u32).to_le_bytes());
bytes.extend_from_slice(&record_body);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes());
let report = inspect_smp_bytes(&bytes);
let summary = report
.event_runtime_collection_summary
.as_ref()
.expect("event runtime collection summary should parse");
assert_eq!(summary.decoded_record_count, 1);
assert_eq!(summary.imported_runtime_record_count, 1);
assert_eq!(summary.records.len(), 1);
assert_eq!(summary.records[0].decode_status, "executable");
assert_eq!(summary.records[0].text_bands[0].preview, "Alpha");
assert_eq!(summary.records[0].standalone_condition_row_count, 1);
assert_eq!(
summary.records[0].grouped_effect_row_counts,
vec![0, 1, 0, 0]
);
assert_eq!(summary.records[0].decoded_actions.len(), 2);
match &summary.records[0].decoded_actions[1] {
RuntimeEffect::AppendEventRecord { record } => {
assert_eq!(record.record_id, 99);
assert_eq!(record.trigger_kind, 0x0a);
}
other => panic!("unexpected decoded action: {other:?}"),
}
}
#[test]
fn decodes_company_targeted_synthetic_records_as_parity_only() {
let record_body = build_synthetic_event_record(
8,
0x01,
0,
[0, 0, 0, 0],
[b"", b"", b"", b"", b"", b""],
&[encode_action_adjust_company_cash_ids(&[7], 25)],
);
let mut bytes = Vec::new();
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes());
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes());
let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for word in header_words {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice(&[0x00, 0x00]);
bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes());
bytes.extend_from_slice(PACKED_EVENT_RECORDS_SYNTHETIC_MAGIC);
bytes.extend_from_slice(&(record_body.len() as u32).to_le_bytes());
bytes.extend_from_slice(&record_body);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes());
let report = inspect_smp_bytes(&bytes);
let summary = report
.event_runtime_collection_summary
.as_ref()
.expect("event runtime collection summary should parse");
assert_eq!(summary.decoded_record_count, 1);
assert_eq!(summary.imported_runtime_record_count, 0);
assert_eq!(summary.records[0].decode_status, "parity_only");
assert!(!summary.records[0].executable_import_ready);
}
#[test]
@ -6267,6 +6860,9 @@ mod tests {
live_id_bound: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
decoded_record_count: 0,
imported_runtime_record_count: 0,
records: build_unsupported_event_runtime_record_summaries(&[1, 3, 5], "test summary"),
});
let slice = load_save_slice_from_report(&report).expect("classic save slice");

View file

@ -30,6 +30,8 @@ pub struct RuntimeSummary {
pub company_count: usize,
pub packed_event_collection_present: bool,
pub packed_event_record_count: usize,
pub packed_event_decoded_record_count: usize,
pub packed_event_imported_runtime_record_count: usize,
pub event_runtime_record_count: usize,
pub candidate_availability_count: usize,
pub zero_candidate_availability_count: usize,
@ -117,6 +119,16 @@ impl RuntimeSummary {
.as_ref()
.map(|summary| summary.live_record_count)
.unwrap_or(0),
packed_event_decoded_record_count: state
.packed_event_collection
.as_ref()
.map(|summary| summary.decoded_record_count)
.unwrap_or(0),
packed_event_imported_runtime_record_count: state
.packed_event_collection
.as_ref()
.map(|summary| summary.imported_runtime_record_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

View file

@ -67,16 +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 initial persistence surfaces.
state-diff tooling, 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 the `.smp` event bridge from collection-level structural summaries toward per-record
packed-body coverage
- deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and
fixture surfaces
- 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
- 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

View file

@ -22,11 +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, and normalized state-fragment assertions
service, snapshot-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
the `.smp` event-collection structural bridge across inspection, save-slice loading, import, and
snapshot-backed fixtures.
captured-runtime depth plus wider packed-event target-family coverage, not another persistence
scaffold pass.
## Why This Boundary
@ -215,10 +216,12 @@ Current status:
- runtime snapshots and state dumps are implemented
- `.smp` save inspection and partial save-slice projection already feed normalized runtime state
- the packed event-collection summary now survives into loaded save slices and projected runtime
snapshots, but per-record packed bodies are still deferred
- the remaining gap is broader captured-runtime and round-trip fixture depth plus deeper `.smp`
event-body decoding, not the first persistence surface
- the packed event-collection bridge now carries per-record summaries into loaded save slices,
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
### Milestone 4: Domain Expansion
@ -347,8 +350,8 @@ The currently implemented normalized runtime surface is:
`runtime summarize-state`, `runtime import-state`, and `runtime diff-state`
- deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a
normalized runtime-effect vocabulary with staged event-record mutation
- save-side inspection and partial state projection for `.smp` inputs, including the structural
packed event-collection summary
- save-side inspection and partial state projection for `.smp` inputs, including per-record packed
event summaries and selective executable import
Checked-in fixture families already include:
@ -360,33 +363,35 @@ Checked-in fixture families already include:
## Next Slice
The recommended next implementation slice is deeper `.smp` event persistence, starting from the
structural bridge that already exists today.
The recommended next implementation slice is broader captured-runtime depth on top of the packed
event bridge that now exists today.
Target behavior:
- keep carrying the packed event collection across `inspect-smp`, `load-save-slice`,
`import-save-state`, snapshots, diffs, and fixtures
- deepen that bridge from collection structure into per-record packed-body summaries
- preserve the separation between parity-shaped packed state and executable normalized runtime state
until the packed layout is better decoded
- 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
- keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning
Public-model additions for that slice:
- packed per-record event summary types on the `.smp` side
- optional runtime-side parity summaries for imported packed event records
- no new executable `RuntimeEffect` variants by default in 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
- no shell queue/modal behavior in the runtime core
Fixture work for that slice:
- one or more snapshot-backed fixtures that prove imported packed event state survives normalize and
diff paths
- synthetic report/save-slice tests that lock the first per-record packed-body parse shape
- state-fragment assertions that lock imported collection ids, version, and record counts
- 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
- state-fragment assertions that lock both packed parity summaries and imported executable records
Do not mix this slice with:
- territory-access or selected-profile parity
- placed-structure batch placement parity
- shell queue/modal behavior
- direct translation of packed RT3 event rows into executable normalized effects
- broad speculative translation of packed RT3 event rows into executable normalized effects

View file

@ -23,6 +23,8 @@
"company_count": 0,
"packed_event_collection_present": true,
"packed_event_record_count": 3,
"packed_event_decoded_record_count": 0,
"packed_event_imported_runtime_record_count": 0,
"event_runtime_record_count": 0,
"total_event_record_service_count": 0,
"periodic_boundary_call_count": 0,
@ -37,10 +39,11 @@
"packed_event_collection": {
"mechanism_family": "classic-save-rehydrate-v1",
"live_record_count": 3,
"live_entry_ids": [
1,
3,
5
"live_entry_ids": [1, 3, 5],
"records": [
{
"decode_status": "unsupported_framing"
}
]
}
}

View file

@ -22,10 +22,37 @@
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 5,
"live_record_count": 3,
"live_entry_ids": [
1,
3,
5
"live_entry_ids": [1, 3, 5],
"decoded_record_count": 0,
"imported_runtime_record_count": 0,
"records": [
{
"record_index": 0,
"live_entry_id": 1,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["snapshot fixture placeholder"]
},
{
"record_index": 1,
"live_entry_id": 3,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["snapshot fixture placeholder"]
},
{
"record_index": 2,
"live_entry_id": 5,
"decode_status": "unsupported_framing",
"grouped_effect_row_counts": [0, 0, 0, 0],
"decoded_actions": [],
"executable_import_ready": false,
"notes": ["snapshot fixture placeholder"]
}
]
},
"event_runtime_records": [],

View file

@ -0,0 +1,67 @@
{
"format_version": 1,
"fixture_id": "packed-event-record-import-from-snapshot",
"source": {
"kind": "captured-runtime",
"description": "Fixture backed by a runtime snapshot that carries one decoded packed-event record and its imported executable runtime record."
},
"state_snapshot_path": "packed-event-record-import-snapshot.json",
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 7
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flag_count": 1,
"company_count": 0,
"packed_event_collection_present": true,
"packed_event_record_count": 1,
"packed_event_decoded_record_count": 1,
"packed_event_imported_runtime_record_count": 1,
"event_runtime_record_count": 2,
"special_condition_count": 1,
"enabled_special_condition_count": 1,
"total_event_record_service_count": 2,
"periodic_boundary_call_count": 0,
"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": {
"records": [
{
"decode_status": "executable",
"executable_import_ready": true
}
]
},
"event_runtime_records": [
{
"record_id": 7,
"service_count": 1
},
{
"record_id": 99,
"trigger_kind": 10,
"service_count": 1
}
],
"service_state": {
"dirty_rerun_count": 1
}
}
}

View file

@ -0,0 +1,152 @@
{
"format_version": 1,
"snapshot_id": "packed-event-record-import-snapshot",
"source": {
"description": "Snapshot fixture carrying one decoded packed-event record plus its imported executable runtime record."
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {},
"companies": [],
"packed_event_collection": {
"source_kind": "packed-event-runtime-collection",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"container_profile_family": "rt3-classic-save-container-v1",
"packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 7,
"live_record_count": 1,
"live_entry_ids": [7],
"decoded_record_count": 1,
"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"]
}
]
},
"event_runtime_records": [
{
"record_id": 7,
"trigger_kind": 7,
"active": true,
"service_count": 0,
"marks_collection_dirty": true,
"one_shot": false,
"has_fired": false,
"effects": [
{
"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
}
]
}
}
]
}
],
"candidate_availability": {},
"special_conditions": {},
"service_state": {
"periodic_boundary_calls": 0,
"trigger_dispatch_counts": {},
"total_event_record_services": 0,
"dirty_rerun_count": 0
}
}
}