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", "packed_state_version_hex": "0x000003e9",
"live_id_bound": 5, "live_id_bound": 5,
"live_record_count": 3, "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": [] "event_runtime_records": []
} }
@ -4178,7 +4209,29 @@ mod tests {
"packed_state_version_hex": "0x000003e9", "packed_state_version_hex": "0x000003e9",
"live_id_bound": 5, "live_id_bound": 5,
"live_record_count": 2, "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": [] "event_runtime_records": []
} }
@ -4210,6 +4263,155 @@ mod tests {
.expect("snapshot-backed packed-event fixture should summarize"); .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] #[test]
fn diffs_classic_profile_samples_across_multiple_files() { fn diffs_classic_profile_samples_across_multiple_files() {
let sample_a = RuntimeClassicProfileSample { let sample_a = RuntimeClassicProfileSample {

View file

@ -62,6 +62,14 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)] #[serde(default)]
pub company_count: Option<usize>, pub company_count: Option<usize>,
#[serde(default)] #[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>, pub event_runtime_record_count: Option<usize>,
#[serde(default)] #[serde(default)]
pub candidate_availability_count: Option<usize>, 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 let Some(count) = self.event_runtime_record_count {
if actual.event_runtime_record_count != count { if actual.event_runtime_record_count != count {
mismatches.push(format!( mismatches.push(format!(

View file

@ -1,11 +1,14 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::path::Path; use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
CalendarPoint, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedSaveSlice, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary,
SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice,
}; };
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; 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 { if let Some(family) = &save_slice.bridge_family {
metadata.insert("save_slice.bridge_family".to_string(), family.clone()); metadata.insert("save_slice.bridge_family".to_string(), family.clone());
} }
let known_company_ids = BTreeSet::new();
let imported_event_runtime_records = save_slice
.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 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 { RuntimePackedEventCollectionSummary {
source_kind: summary.source_kind.clone(), source_kind: summary.source_kind.clone(),
mechanism_family: summary.mechanism_family.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_id_bound: summary.live_id_bound,
live_record_count: summary.live_record_count, live_record_count: summary.live_record_count,
live_entry_ids: summary.live_entry_ids.clone(), live_entry_ids: summary.live_entry_ids.clone(),
decoded_record_count: records
.iter()
.filter(|record| record.decode_status != "unsupported_framing")
.count(),
imported_runtime_record_count: imported_event_runtime_records.len(),
records,
} }
}); });
if let Some(summary) = &save_slice.event_runtime_collection { if let Some(summary) = &save_slice.event_runtime_collection {
@ -163,6 +192,14 @@ pub fn project_save_slice_to_runtime_state_import(
"save_slice.event_runtime_collection_record_count".to_string(), "save_slice.event_runtime_collection_record_count".to_string(),
summary.live_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 { let save_profile = if let Some(profile) = &save_slice.profile {
metadata.insert( metadata.insert(
@ -328,7 +365,7 @@ pub fn project_save_slice_to_runtime_state_import(
metadata, metadata,
companies: Vec::new(), companies: Vec::new(),
packed_event_collection, packed_event_collection,
event_runtime_records: Vec::new(), event_runtime_records: imported_event_runtime_records,
candidate_availability, candidate_availability,
special_conditions, special_conditions,
service_state: RuntimeServiceState::default(), 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( pub fn validate_runtime_state_dump_document(
document: &RuntimeStateDumpDocument, document: &RuntimeStateDumpDocument,
) -> Result<(), String> { ) -> Result<(), String> {
@ -397,6 +621,7 @@ pub fn load_runtime_state_import_from_str(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{StepCommand, execute_step_command};
fn state() -> RuntimeState { fn state() -> RuntimeState {
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] #[test]
fn loads_dump_document() { fn loads_dump_document() {
let text = serde_json::to_string(&RuntimeStateDumpDocument { let text = serde_json::to_string(&RuntimeStateDumpDocument {
@ -548,6 +814,61 @@ mod tests {
live_id_bound: 5, live_id_bound: 5,
live_record_count: 3, live_record_count: 3,
live_entry_ids: vec![1, 3, 5], 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()], notes: vec!["packed profile recovered".to_string()],
}; };
@ -709,4 +1030,187 @@ mod tests {
); );
assert!(import.state.event_runtime_records.is_empty()); 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::{ pub use runtime::{
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary,
RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
}; };
pub use smp::{ pub use smp::{
@ -38,7 +39,8 @@ pub use smp::{
SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock, SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock,
SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe, SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe,
SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit,
SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, SmpLoadedProfile, SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary,
SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedProfile,
SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation,
SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe, SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe,
SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane, SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane,

View file

@ -97,6 +97,51 @@ pub struct RuntimePackedEventCollectionSummary {
pub live_id_bound: u32, pub live_id_bound: u32,
pub live_record_count: usize, pub live_record_count: usize,
pub live_entry_ids: Vec<u32>, 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 { impl RuntimeEventRecordTemplate {
@ -271,9 +316,37 @@ impl RuntimeState {
.to_string(), .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; 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 { if *entry_id == 0 {
return Err( return Err(
"packed_event_collection.live_entry_ids must not contain id 0".to_string(), "packed_event_collection.live_entry_ids must not contain id 0".to_string(),
@ -292,6 +365,35 @@ impl RuntimeState {
); );
} }
previous_id = Some(*entry_id); 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_id_bound: 4,
live_record_count: 2, live_record_count: 2,
live_entry_ids: vec![3, 3], 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(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),

View file

@ -4,6 +4,8 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::{RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate};
pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec;
const PREAMBLE_U32_WORD_COUNT: usize = 16; const PREAMBLE_U32_WORD_COUNT: usize = 16;
const MIN_ASCII_RUN_LEN: usize = 8; 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_DWORD_COUNT: usize = 19;
const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize = const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize =
INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT * 4; 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 = const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_START_INDEX: usize =
(POST_SPECIAL_CONDITIONS_SCALAR_OFFSET - SPECIAL_CONDITIONS_OFFSET) / 4; (POST_SPECIAL_CONDITIONS_SCALAR_OFFSET - SPECIAL_CONDITIONS_OFFSET) / 4;
const SMP_ALIGNED_RUNTIME_RULE_POST_WINDOW_OVERLAP_DWORD_COUNT: usize = 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_id_bound: u32,
pub live_record_count: usize, pub live_record_count: usize,
pub live_entry_ids: Vec<u32>, 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)] #[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 { if live_entry_ids.len() != live_record_count {
continue; 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 { return Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(), source_kind: "packed-event-runtime-collection".to_string(),
@ -1450,6 +1522,9 @@ fn parse_event_runtime_collection_summary(
live_id_bound, live_id_bound,
live_record_count, live_record_count,
live_entry_ids, 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) 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 { fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> SmpInspectionReport {
let known_tag_hits = KNOWN_TAG_DEFINITIONS let known_tag_hits = KNOWN_TAG_DEFINITIONS
.iter() .iter()
@ -4846,11 +5228,27 @@ fn read_u32_window(bytes: &[u8], offset: usize, count: usize) -> Vec<u32> {
words 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> { fn read_u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
let chunk = bytes.get(offset..offset + 4)?; let chunk = bytes.get(offset..offset + 4)?;
Some(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) 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> { fn probable_normal_f32_string(value: u32) -> Option<String> {
let exponent = (value >> 23) & 0xff; let exponent = (value >> 23) & 0xff;
if exponent == 0 || exponent == 0xff { if exponent == 0 || exponent == 0xff {
@ -5611,7 +6009,10 @@ mod tests {
#[test] #[test]
fn classifies_recipe_token_layouts() { fn classifies_recipe_token_layouts() {
assert_eq!(classify_recipe_token_layout(0x00000000), "zero"); 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(0x00170000), "high16-numeric");
assert_eq!(classify_recipe_token_layout(0x000040a0), "low16-marker"); assert_eq!(classify_recipe_token_layout(0x000040a0), "low16-marker");
} }
@ -5638,9 +6039,18 @@ mod tests {
#[test] #[test]
fn classifies_recipe_runtime_import_branches() { fn classifies_recipe_runtime_import_branches() {
assert_eq!(classify_recipe_runtime_import_branch(0), "zero-mode-skipped"); assert_eq!(
assert_eq!(classify_recipe_runtime_import_branch(1), "mode1-demand-branch"); classify_recipe_runtime_import_branch(0),
assert_eq!(classify_recipe_runtime_import_branch(3), "mode3-dual-branch"); "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!( assert_eq!(
classify_recipe_runtime_import_branch(0x00110000), classify_recipe_runtime_import_branch(0x00110000),
"nonzero-supply-branch" "nonzero-supply-branch"
@ -6205,6 +6615,189 @@ mod tests {
assert_eq!(summary.live_record_count, 3); assert_eq!(summary.live_record_count, 3);
assert_eq!(summary.live_entry_ids, vec![1, 3, 5]); assert_eq!(summary.live_entry_ids, vec![1, 3, 5]);
assert_eq!(summary.records_tag_offset, 96); 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] #[test]
@ -6267,6 +6860,9 @@ mod tests {
live_id_bound: 5, live_id_bound: 5,
live_record_count: 3, live_record_count: 3,
live_entry_ids: vec![1, 3, 5], 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"); 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 company_count: usize,
pub packed_event_collection_present: bool, pub packed_event_collection_present: bool,
pub packed_event_record_count: usize, 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 event_runtime_record_count: usize,
pub candidate_availability_count: usize, pub candidate_availability_count: usize,
pub zero_candidate_availability_count: usize, pub zero_candidate_availability_count: usize,
@ -117,6 +119,16 @@ impl RuntimeSummary {
.as_ref() .as_ref()
.map(|summary| summary.live_record_count) .map(|summary| summary.live_record_count)
.unwrap_or(0), .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(), event_runtime_record_count: state.event_runtime_records.len(),
candidate_availability_count: state.candidate_availability.len(), candidate_availability_count: state.candidate_availability.len(),
zero_candidate_availability_count: state zero_candidate_availability_count: state

View file

@ -67,16 +67,17 @@ Current local tool status:
The atlas milestone is broad enough that the next implementation focus has already shifted downward The atlas milestone is broad enough that the next implementation focus has already shifted downward
into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic
trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution, trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution,
state-diff tooling, 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: The highest-value next passes are now:
- preserve the atlas and function map as the source of subsystem boundaries while continuing to - preserve the atlas and function map as the source of subsystem boundaries while continuing to
avoid shell-first implementation bets avoid shell-first implementation bets
- deepen the `.smp` event bridge from collection-level structural summaries toward per-record - deepen captured-runtime and round-trip fixture coverage on top of the packed-event bridge that now
packed-body coverage exists
- deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and - widen packed-event target-family coverage only where static evidence is strong enough to support
fixture surfaces deterministic executable import
- use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution
environment environment
- keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice - keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice

View file

@ -22,11 +22,12 @@ Implemented today:
- snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the - snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the
CLI and fixture layers CLI and fixture layers
- checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger - checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger
service, snapshot-backed inputs, 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 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 captured-runtime depth plus wider packed-event target-family coverage, not another persistence
snapshot-backed fixtures. scaffold pass.
## Why This Boundary ## Why This Boundary
@ -215,10 +216,12 @@ Current status:
- runtime snapshots and state dumps are implemented - runtime snapshots and state dumps are implemented
- `.smp` save inspection and partial save-slice projection already feed normalized runtime state - `.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 - the packed event-collection bridge now carries per-record summaries into loaded save slices,
snapshots, but per-record packed bodies are still deferred projected runtime snapshots, normalized diffs, and fixtures
- the remaining gap is broader captured-runtime and round-trip fixture depth plus deeper `.smp` - the first decoded packed-event subset can now import into executable runtime records when the
event-body decoding, not the first persistence surface 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 ### 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` `runtime summarize-state`, `runtime import-state`, and `runtime diff-state`
- deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a - deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a
normalized runtime-effect vocabulary with staged event-record mutation normalized runtime-effect vocabulary with staged event-record mutation
- save-side inspection and partial state projection for `.smp` inputs, including the structural - save-side inspection and partial state projection for `.smp` inputs, including per-record packed
packed event-collection summary event summaries and selective executable import
Checked-in fixture families already include: Checked-in fixture families already include:
@ -360,33 +363,35 @@ Checked-in fixture families already include:
## Next Slice ## Next Slice
The recommended next implementation slice is deeper `.smp` event persistence, starting from the The recommended next implementation slice is broader captured-runtime depth on top of the packed
structural bridge that already exists today. event bridge that now exists today.
Target behavior: Target behavior:
- keep carrying the packed event collection across `inspect-smp`, `load-save-slice`, - keep the packed event bridge grounded against real captured save inputs rather than only synthetic
`import-save-state`, snapshots, diffs, and fixtures parser tests and snapshot fixtures
- deepen that bridge from collection structure into per-record packed-body summaries - expand the executable import subset beyond the current direct-state and follow-on lanes only when
- preserve the separation between parity-shaped packed state and executable normalized runtime state target resolution and field semantics are statically grounded enough to preserve headless
until the packed layout is better decoded determinism
- keep preserving unsupported packed rows as parity summaries instead of guessing executable meaning
Public-model additions for that slice: Public-model additions for that slice:
- packed per-record event summary types on the `.smp` side - additional captured-save fixture material for packed event collections
- optional runtime-side parity summaries for imported packed event records - wider target-family summaries only where imported execution can be justified by current static
- no new executable `RuntimeEffect` variants by default in that slice evidence
- no shell queue/modal behavior in the runtime core
Fixture work for that slice: Fixture work for that slice:
- one or more snapshot-backed fixtures that prove imported packed event state survives normalize and - captured `.smp` or save-slice-backed fixtures that prove real packed event records survive import
diff paths and diff paths
- synthetic report/save-slice tests that lock the first per-record packed-body parse shape - regression fixtures that lock the current selective executable import boundary
- state-fragment assertions that lock imported collection ids, version, and record counts - state-fragment assertions that lock both packed parity summaries and imported executable records
Do not mix this slice with: Do not mix this slice with:
- territory-access or selected-profile parity - territory-access or selected-profile parity
- placed-structure batch placement parity - placed-structure batch placement parity
- shell queue/modal behavior - 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, "company_count": 0,
"packed_event_collection_present": true, "packed_event_collection_present": true,
"packed_event_record_count": 3, "packed_event_record_count": 3,
"packed_event_decoded_record_count": 0,
"packed_event_imported_runtime_record_count": 0,
"event_runtime_record_count": 0, "event_runtime_record_count": 0,
"total_event_record_service_count": 0, "total_event_record_service_count": 0,
"periodic_boundary_call_count": 0, "periodic_boundary_call_count": 0,
@ -37,10 +39,11 @@
"packed_event_collection": { "packed_event_collection": {
"mechanism_family": "classic-save-rehydrate-v1", "mechanism_family": "classic-save-rehydrate-v1",
"live_record_count": 3, "live_record_count": 3,
"live_entry_ids": [ "live_entry_ids": [1, 3, 5],
1, "records": [
3, {
5 "decode_status": "unsupported_framing"
}
] ]
} }
} }

View file

@ -22,10 +22,37 @@
"packed_state_version_hex": "0x000003e9", "packed_state_version_hex": "0x000003e9",
"live_id_bound": 5, "live_id_bound": 5,
"live_record_count": 3, "live_record_count": 3,
"live_entry_ids": [ "live_entry_ids": [1, 3, 5],
1, "decoded_record_count": 0,
3, "imported_runtime_record_count": 0,
5 "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": [], "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
}
}
}