Bridge packed event collection through save import

This commit is contained in:
Jan Petykiewicz 2026-04-14 20:01:43 -07:00
commit 83f55fa26e
13 changed files with 653 additions and 35 deletions

View file

@ -4129,6 +4129,87 @@ mod tests {
let _ = fs::remove_file(right_path); let _ = fs::remove_file(right_path);
} }
#[test]
fn diffs_runtime_states_with_packed_event_collection_changes() {
let left = serde_json::json!({
"format_version": 1,
"snapshot_id": "left-packed-events",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"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": 5,
"live_record_count": 3,
"live_entry_ids": [1, 3, 5]
},
"event_runtime_records": []
}
});
let right = serde_json::json!({
"format_version": 1,
"snapshot_id": "right-packed-events",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"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": 5,
"live_record_count": 2,
"live_entry_ids": [1, 5]
},
"event_runtime_records": []
}
});
let left_path = write_temp_json("runtime-diff-packed-events-left", &left);
let right_path = write_temp_json("runtime-diff-packed-events-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.live_record_count"
|| entry.path == "$.packed_event_collection.live_entry_ids[1]"
}));
let _ = fs::remove_file(left_path);
let _ = fs::remove_file(right_path);
}
#[test]
fn summarizes_snapshot_backed_fixture_with_packed_event_collection() {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-collection-from-snapshot.json");
run_runtime_summarize_fixture(&fixture_path)
.expect("snapshot-backed packed-event fixture should summarize");
}
#[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

@ -117,6 +117,7 @@ mod tests {
world_restore: RuntimeWorldRestoreState::default(), world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(), metadata: BTreeMap::new(),
companies: Vec::new(), companies: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),

View file

@ -4,8 +4,8 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
CalendarPoint, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, CalendarPoint, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState,
RuntimeWorldRestoreState, SmpLoadedSaveSlice, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, SmpLoadedSaveSlice,
}; };
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
@ -56,6 +56,10 @@ pub fn project_save_slice_to_runtime_state_import(
"save_slice.special_conditions_present".to_string(), "save_slice.special_conditions_present".to_string(),
save_slice.special_conditions_table.is_some(), save_slice.special_conditions_table.is_some(),
); );
world_flags.insert(
"save_slice.event_runtime_collection_present".to_string(),
save_slice.event_runtime_collection.is_some(),
);
world_flags.insert( world_flags.insert(
"save_slice.mechanism_confidence_grounded".to_string(), "save_slice.mechanism_confidence_grounded".to_string(),
save_slice.mechanism_confidence == "grounded", save_slice.mechanism_confidence == "grounded",
@ -133,6 +137,33 @@ 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 packed_event_collection = save_slice.event_runtime_collection.as_ref().map(|summary| {
RuntimePackedEventCollectionSummary {
source_kind: summary.source_kind.clone(),
mechanism_family: summary.mechanism_family.clone(),
mechanism_confidence: summary.mechanism_confidence.clone(),
container_profile_family: summary.container_profile_family.clone(),
packed_state_version: summary.packed_state_version,
packed_state_version_hex: summary.packed_state_version_hex.clone(),
live_id_bound: summary.live_id_bound,
live_record_count: summary.live_record_count,
live_entry_ids: summary.live_entry_ids.clone(),
}
});
if let Some(summary) = &save_slice.event_runtime_collection {
metadata.insert(
"save_slice.event_runtime_collection_source_kind".to_string(),
summary.source_kind.clone(),
);
metadata.insert(
"save_slice.event_runtime_collection_version_hex".to_string(),
summary.packed_state_version_hex.clone(),
);
metadata.insert(
"save_slice.event_runtime_collection_record_count".to_string(),
summary.live_record_count.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(
"save_slice.profile_kind".to_string(), "save_slice.profile_kind".to_string(),
@ -296,6 +327,7 @@ pub fn project_save_slice_to_runtime_state_import(
world_restore, world_restore,
metadata, metadata,
companies: Vec::new(), companies: Vec::new(),
packed_event_collection,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability, candidate_availability,
special_conditions, special_conditions,
@ -379,6 +411,7 @@ mod tests {
world_restore: RuntimeWorldRestoreState::default(), world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(), metadata: BTreeMap::new(),
companies: Vec::new(), companies: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),
@ -502,6 +535,20 @@ mod tests {
}, },
], ],
}), }),
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(),
mechanism_confidence: "mixed".to_string(),
container_profile_family: Some("rt3-105-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: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
}),
notes: vec!["packed profile recovered".to_string()], notes: vec!["packed profile recovered".to_string()],
}; };
@ -644,5 +691,22 @@ mod tests {
.get("save_slice.profile_byte_0x82_nonzero"), .get("save_slice.profile_byte_0x82_nonzero"),
Some(&true) Some(&true)
); );
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.live_record_count),
Some(3)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.live_entry_ids.clone()),
Some(vec![1, 3, 5])
);
assert!(import.state.event_runtime_records.is_empty());
} }
} }

View file

@ -30,16 +30,16 @@ pub use pk4::{
}; };
pub use runtime::{ pub use runtime::{
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimeSaveProfileState,
RuntimeWorldRestoreState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
}; };
pub use smp::{ pub use smp::{
SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAlignedRuntimeRuleBandLane, SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAlignedRuntimeRuleBandLane,
SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock, SmpAlignedRuntimeRuleBandProbe, SmpAsciiPreview, SmpClassicPackedProfileBlock,
SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe, SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe,
SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit,
SmpLoadedCandidateAvailabilityTable, SmpLoadedProfile, SmpLoadedSaveSlice, SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary, SmpLoadedProfile,
SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation, SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation,
SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe, SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe,
SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane, SmpPackedProfileWordLane, SmpPostSpecialConditionsScalarLane,
SmpPostSpecialConditionsScalarProbe, SmpPostTextFieldNeighborhoodProbe, SmpPostSpecialConditionsScalarProbe, SmpPostTextFieldNeighborhoodProbe,

View file

@ -93,6 +93,7 @@ mod tests {
world_restore: RuntimeWorldRestoreState::default(), world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(), metadata: BTreeMap::new(),
companies: Vec::new(), companies: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),

View file

@ -85,6 +85,20 @@ pub struct RuntimeEventRecord {
pub effects: Vec<RuntimeEffect>, pub effects: Vec<RuntimeEffect>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimePackedEventCollectionSummary {
pub source_kind: String,
pub mechanism_family: String,
pub mechanism_confidence: String,
#[serde(default)]
pub container_profile_family: Option<String>,
pub packed_state_version: u32,
pub packed_state_version_hex: String,
pub live_id_bound: u32,
pub live_record_count: usize,
pub live_entry_ids: Vec<u32>,
}
impl RuntimeEventRecordTemplate { impl RuntimeEventRecordTemplate {
pub fn into_runtime_record(self) -> RuntimeEventRecord { pub fn into_runtime_record(self) -> RuntimeEventRecord {
RuntimeEventRecord { RuntimeEventRecord {
@ -184,6 +198,8 @@ pub struct RuntimeState {
#[serde(default)] #[serde(default)]
pub companies: Vec<RuntimeCompany>, pub companies: Vec<RuntimeCompany>,
#[serde(default)] #[serde(default)]
pub packed_event_collection: Option<RuntimePackedEventCollectionSummary>,
#[serde(default)]
pub event_runtime_records: Vec<RuntimeEventRecord>, pub event_runtime_records: Vec<RuntimeEventRecord>,
#[serde(default)] #[serde(default)]
pub candidate_availability: BTreeMap<String, u32>, pub candidate_availability: BTreeMap<String, u32>,
@ -219,6 +235,66 @@ impl RuntimeState {
} }
} }
if let Some(summary) = &self.packed_event_collection {
if summary.source_kind.trim().is_empty() {
return Err("packed_event_collection.source_kind must not be empty".to_string());
}
if summary.mechanism_family.trim().is_empty() {
return Err(
"packed_event_collection.mechanism_family must not be empty".to_string()
);
}
if summary.mechanism_confidence.trim().is_empty() {
return Err(
"packed_event_collection.mechanism_confidence must not be empty".to_string(),
);
}
if summary
.container_profile_family
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(
"packed_event_collection.container_profile_family must not be empty"
.to_string(),
);
}
if summary.packed_state_version_hex.trim().is_empty() {
return Err(
"packed_event_collection.packed_state_version_hex must not be empty"
.to_string(),
);
}
if summary.live_record_count != summary.live_entry_ids.len() {
return Err(
"packed_event_collection.live_record_count must match live_entry_ids length"
.to_string(),
);
}
let mut previous_id = None;
for entry_id in &summary.live_entry_ids {
if *entry_id == 0 {
return Err(
"packed_event_collection.live_entry_ids must not contain id 0".to_string(),
);
}
if *entry_id > summary.live_id_bound {
return Err(format!(
"packed_event_collection.live_entry_id {} exceeds live_id_bound {}",
entry_id, summary.live_id_bound
));
}
if previous_id.is_some_and(|prior| prior >= *entry_id) {
return Err(
"packed_event_collection.live_entry_ids must be strictly ascending"
.to_string(),
);
}
previous_id = Some(*entry_id);
}
}
for key in self.world_flags.keys() { for key in self.world_flags.keys() {
if key.trim().is_empty() { if key.trim().is_empty() {
return Err("world_flags contains an empty key".to_string()); return Err("world_flags contains an empty key".to_string());
@ -402,6 +478,7 @@ mod tests {
debt: 0, debt: 0,
}, },
], ],
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),
@ -447,6 +524,7 @@ mod tests {
}, },
metadata: BTreeMap::new(), metadata: BTreeMap::new(),
companies: Vec::new(), companies: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),
@ -474,6 +552,7 @@ mod tests {
current_cash: 100, current_cash: 100,
debt: 0, debt: 0,
}], }],
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord { event_runtime_records: vec![RuntimeEventRecord {
record_id: 7, record_id: 7,
trigger_kind: 1, trigger_kind: 1,
@ -513,6 +592,7 @@ mod tests {
current_cash: 100, current_cash: 100,
debt: 0, debt: 0,
}], }],
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord { event_runtime_records: vec![RuntimeEventRecord {
record_id: 7, record_id: 7,
trigger_kind: 1, trigger_kind: 1,
@ -542,4 +622,38 @@ mod tests {
assert!(state.validate().is_err()); assert!(state.validate().is_err());
} }
#[test]
fn rejects_invalid_packed_event_collection_summary() {
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: Vec::new(),
packed_event_collection: Some(RuntimePackedEventCollectionSummary {
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()),
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 4,
live_record_count: 2,
live_entry_ids: vec![3, 3],
}),
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
} }

View file

@ -79,6 +79,13 @@ const RECIPE_BOOK_LINE_STRIDE: usize = 0x30;
const RECIPE_BOOK_LINE_AREA_LEN: usize = RECIPE_BOOK_LINE_COUNT * RECIPE_BOOK_LINE_STRIDE; const RECIPE_BOOK_LINE_AREA_LEN: usize = RECIPE_BOOK_LINE_COUNT * RECIPE_BOOK_LINE_STRIDE;
const RECIPE_BOOK_SUMMARY_END_OFFSET: usize = const RECIPE_BOOK_SUMMARY_END_OFFSET: usize =
RECIPE_BOOK_ROOT_OFFSET + RECIPE_BOOK_COUNT * RECIPE_BOOK_STRIDE; RECIPE_BOOK_ROOT_OFFSET + RECIPE_BOOK_COUNT * RECIPE_BOOK_STRIDE;
const EVENT_RUNTIME_COLLECTION_METADATA_TAG: u16 = 0x4e99;
const EVENT_RUNTIME_COLLECTION_RECORDS_TAG: u16 = 0x4e9a;
const EVENT_RUNTIME_COLLECTION_CLOSE_TAG: u16 = 0x4e9b;
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 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 =
@ -1167,6 +1174,23 @@ pub struct SmpLoadedSpecialConditionsTable {
pub entries: Vec<SmpSpecialConditionEntry>, pub entries: Vec<SmpSpecialConditionEntry>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedEventRuntimeCollectionSummary {
pub source_kind: String,
pub mechanism_family: String,
pub mechanism_confidence: String,
#[serde(default)]
pub container_profile_family: Option<String>,
pub metadata_tag_offset: usize,
pub records_tag_offset: usize,
pub close_tag_offset: usize,
pub packed_state_version: u32,
pub packed_state_version_hex: String,
pub live_id_bound: u32,
pub live_record_count: usize,
pub live_entry_ids: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedSaveSlice { pub struct SmpLoadedSaveSlice {
pub file_extension_hint: Option<String>, pub file_extension_hint: Option<String>,
@ -1178,6 +1202,7 @@ pub struct SmpLoadedSaveSlice {
pub profile: Option<SmpLoadedProfile>, pub profile: Option<SmpLoadedProfile>,
pub candidate_availability_table: Option<SmpLoadedCandidateAvailabilityTable>, pub candidate_availability_table: Option<SmpLoadedCandidateAvailabilityTable>,
pub special_conditions_table: Option<SmpLoadedSpecialConditionsTable>, pub special_conditions_table: Option<SmpLoadedSpecialConditionsTable>,
pub event_runtime_collection: Option<SmpLoadedEventRuntimeCollectionSummary>,
pub notes: Vec<String>, pub notes: Vec<String>,
} }
@ -1220,6 +1245,7 @@ pub struct SmpInspectionReport {
pub classic_rehydrate_profile_probe: Option<SmpClassicRehydrateProfileProbe>, pub classic_rehydrate_profile_probe: Option<SmpClassicRehydrateProfileProbe>,
pub rt3_105_packed_profile_probe: Option<SmpRt3105PackedProfileProbe>, pub rt3_105_packed_profile_probe: Option<SmpRt3105PackedProfileProbe>,
pub save_load_summary: Option<SmpSaveLoadSummary>, pub save_load_summary: Option<SmpSaveLoadSummary>,
pub event_runtime_collection_summary: Option<SmpLoadedEventRuntimeCollectionSummary>,
pub contains_grounded_runtime_tags: bool, pub contains_grounded_runtime_tags: bool,
pub known_tag_hits: Vec<SmpKnownTagHit>, pub known_tag_hits: Vec<SmpKnownTagHit>,
pub notes: Vec<String>, pub notes: Vec<String>,
@ -1343,10 +1369,128 @@ pub fn load_save_slice_from_report(
profile, profile,
candidate_availability_table, candidate_availability_table,
special_conditions_table, special_conditions_table,
event_runtime_collection: report.event_runtime_collection_summary.clone(),
notes: summary.notes.clone(), notes: summary.notes.clone(),
}) })
} }
fn parse_event_runtime_collection_summary(
bytes: &[u8],
container_profile: Option<&SmpContainerProfile>,
save_load_summary: Option<&SmpSaveLoadSummary>,
) -> Option<SmpLoadedEventRuntimeCollectionSummary> {
let metadata_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_METADATA_TAG);
let record_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_RECORDS_TAG);
let close_offsets = find_u16_le_offsets(bytes, EVENT_RUNTIME_COLLECTION_CLOSE_TAG);
for metadata_tag_offset in metadata_offsets {
let packed_state_version = read_u32_at(bytes, metadata_tag_offset + 2)?;
if packed_state_version != EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION {
continue;
}
let records_tag_offset = record_offsets
.iter()
.copied()
.find(|offset| *offset > metadata_tag_offset + 6)?;
let close_tag_offset = close_offsets
.iter()
.copied()
.find(|offset| *offset > records_tag_offset)?;
let metadata_payload = bytes.get(metadata_tag_offset + 6..records_tag_offset)?;
if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN {
continue;
}
let header_words = (0..INDEXED_COLLECTION_SERIALIZED_HEADER_DWORD_COUNT)
.map(|index| read_u32_at(metadata_payload, index * 4))
.collect::<Option<Vec<_>>>()?;
let direct_collection_flag = header_words[0];
let direct_record_stride = usize::try_from(header_words[1]).ok()?;
let live_id_bound = header_words[4];
let live_record_count = usize::try_from(header_words[5]).ok()?;
if direct_collection_flag == 0 || direct_record_stride == 0 {
continue;
}
let bitset_len = ((usize::try_from(live_id_bound).ok()?).saturating_add(15)) / 8;
let payload_bytes = direct_record_stride.checked_mul(live_record_count)?;
if metadata_payload.len() < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN + bitset_len {
continue;
}
if metadata_payload.len() < bitset_len + payload_bytes {
continue;
}
let bitset_offset = metadata_payload.len() - bitset_len - payload_bytes;
if bitset_offset < INDEXED_COLLECTION_SERIALIZED_HEADER_LEN {
continue;
}
let bitset = metadata_payload.get(bitset_offset..bitset_offset + bitset_len)?;
let live_entry_ids = decode_live_entry_ids_from_tombstone_bitset(bitset, live_id_bound)?;
if live_entry_ids.len() != live_record_count {
continue;
}
return Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: save_load_summary
.map(|summary| summary.mechanism_family.clone())
.unwrap_or_else(|| "unknown".to_string()),
mechanism_confidence: save_load_summary
.map(|summary| summary.mechanism_confidence.clone())
.unwrap_or_else(|| "inferred".to_string()),
container_profile_family: container_profile
.map(|profile| profile.profile_family.clone()),
metadata_tag_offset,
records_tag_offset,
close_tag_offset,
packed_state_version,
packed_state_version_hex: format!("0x{packed_state_version:08x}"),
live_id_bound,
live_record_count,
live_entry_ids,
});
}
None
}
fn decode_live_entry_ids_from_tombstone_bitset(
bitset: &[u8],
live_id_bound: u32,
) -> Option<Vec<u32>> {
let ids = decode_live_entry_ids_with_mapping(bitset, live_id_bound, false);
if ids.is_some() {
return ids;
}
decode_live_entry_ids_with_mapping(bitset, live_id_bound, true)
}
fn decode_live_entry_ids_with_mapping(
bitset: &[u8],
live_id_bound: u32,
subtract_one: bool,
) -> Option<Vec<u32>> {
let mut live_entry_ids = Vec::new();
for entry_id in 1..=live_id_bound {
let bit_index = if subtract_one {
entry_id.checked_sub(1)?
} else {
entry_id
};
let byte_index = usize::try_from(bit_index / 8).ok()?;
let bit_mask = 1u8.checked_shl(bit_index % 8).unwrap_or(0);
let tombstone_byte = *bitset.get(byte_index)?;
if tombstone_byte & bit_mask == 0 {
live_entry_ids.push(entry_id);
}
}
Some(live_entry_ids)
}
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()
@ -1475,6 +1619,11 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
rt3_105_packed_profile_probe.as_ref(), rt3_105_packed_profile_probe.as_ref(),
rt3_105_save_name_table_probe.as_ref(), rt3_105_save_name_table_probe.as_ref(),
); );
let event_runtime_collection_summary = parse_event_runtime_collection_summary(
bytes,
container_profile.as_ref(),
save_load_summary.as_ref(),
);
let mut warnings = Vec::new(); let mut warnings = Vec::new();
if bytes.is_empty() { if bytes.is_empty() {
warnings warnings
@ -1562,6 +1711,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
classic_rehydrate_profile_probe, classic_rehydrate_profile_probe,
rt3_105_packed_profile_probe, rt3_105_packed_profile_probe,
save_load_summary, save_load_summary,
event_runtime_collection_summary,
contains_grounded_runtime_tags: !known_tag_hits.is_empty(), contains_grounded_runtime_tags: !known_tag_hits.is_empty(),
known_tag_hits, known_tag_hits,
notes: vec![ notes: vec![
@ -6025,6 +6175,110 @@ mod tests {
assert!(slice.candidate_availability_table.is_none()); assert!(slice.candidate_availability_table.is_none());
} }
#[test]
fn parses_event_runtime_collection_summary_from_synthetic_chunks() {
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, 5, 3, 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(&[0x14, 0x00]);
bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
bytes.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
bytes.extend_from_slice(&[0x55, 0x66, 0x77, 0x88]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes());
bytes.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
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.packed_state_version, 0x3e9);
assert_eq!(summary.live_id_bound, 5);
assert_eq!(summary.live_record_count, 3);
assert_eq!(summary.live_entry_ids, vec![1, 3, 5]);
assert_eq!(summary.records_tag_offset, 96);
}
#[test]
fn loads_event_runtime_collection_summary_from_report() {
let mut report = inspect_smp_bytes(&[]);
let classic_probe = SmpClassicRehydrateProfileProbe {
profile_family: "rt3-classic-save-container-v1".to_string(),
progress_32dc_offset: 0x76e8,
progress_3714_offset: 0x76ec,
progress_3715_offset: 0x77f8,
packed_profile_offset: 0x76f0,
packed_profile_len: 0x108,
packed_profile_len_hex: "0x108".to_string(),
packed_profile_block: SmpClassicPackedProfileBlock {
relative_len: 0x108,
relative_len_hex: "0x108".to_string(),
leading_word_0: 3,
leading_word_0_hex: "0x00000003".to_string(),
trailing_zero_word_count_after_leading_word: 3,
map_path_offset: 0x13,
map_path: Some("British Isles.gmp".to_string()),
display_name_offset: 0x46,
display_name: Some("British Isles".to_string()),
profile_byte_0x77: 0,
profile_byte_0x77_hex: "0x00".to_string(),
profile_byte_0x82: 0,
profile_byte_0x82_hex: "0x00".to_string(),
profile_byte_0x97: 0,
profile_byte_0x97_hex: "0x00".to_string(),
profile_byte_0xc5: 0,
profile_byte_0xc5_hex: "0x00".to_string(),
stable_nonzero_words: vec![],
},
ascii_runs: vec![],
};
report.classic_rehydrate_profile_probe = Some(classic_probe.clone());
report.save_load_summary = build_save_load_summary(
Some("gms"),
Some(&SmpContainerProfile {
profile_family: "rt3-classic-save-container-v1".to_string(),
profile_evidence: vec![],
is_known_profile: true,
}),
None,
None,
Some(&classic_probe),
None,
None,
);
report.event_runtime_collection_summary = Some(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: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
});
let slice = load_save_slice_from_report(&report).expect("classic save slice");
assert_eq!(
slice
.event_runtime_collection
.as_ref()
.map(|summary| summary.live_entry_ids.clone()),
Some(vec![1, 3, 5])
);
}
#[test] #[test]
fn loads_rt3_105_save_slice_from_report() { fn loads_rt3_105_save_slice_from_report() {
let mut report = inspect_smp_bytes(&[]); let mut report = inspect_smp_bytes(&[]);

View file

@ -473,6 +473,7 @@ mod tests {
current_cash: 10, current_cash: 10,
debt: 0, debt: 0,
}], }],
packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(), special_conditions: BTreeMap::new(),

View file

@ -28,6 +28,8 @@ pub struct RuntimeSummary {
pub world_restore_absolute_counter_adjustment_context: Option<String>, pub world_restore_absolute_counter_adjustment_context: Option<String>,
pub metadata_count: usize, pub metadata_count: usize,
pub company_count: usize, pub company_count: usize,
pub packed_event_collection_present: bool,
pub packed_event_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,
@ -109,6 +111,12 @@ impl RuntimeSummary {
.clone(), .clone(),
metadata_count: state.metadata.len(), metadata_count: state.metadata.len(),
company_count: state.companies.len(), company_count: state.companies.len(),
packed_event_collection_present: state.packed_event_collection.is_some(),
packed_event_record_count: state
.packed_event_collection
.as_ref()
.map(|summary| summary.live_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

@ -66,15 +66,15 @@ 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, fixture execution, state-diff tooling, and initial trigger dispatch, normalized runtime effects, staged event-record mutation, fixture execution,
persistence surfaces. state-diff tooling, and initial persistence surfaces.
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
- broaden the normalized event-service layer through staged event-record mutation and follow-on - deepen the `.smp` event bridge from collection-level structural summaries toward per-record
record behavior packed-body coverage
- deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and - deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and
fixture surfaces fixture surfaces
- 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

View file

@ -18,14 +18,15 @@ Implemented today:
- `rrt-runtime` exists with a deterministic calendar model, step commands, runtime summaries, and - `rrt-runtime` exists with a deterministic calendar model, step commands, runtime summaries, and
normalized runtime state validation normalized runtime state validation
- periodic trigger dispatch exists, including ordered periodic maintenance, dirty rerun `0x0a`, and - periodic trigger dispatch exists, including ordered periodic maintenance, dirty rerun `0x0a`, and
a first normalized runtime-effect surface a normalized runtime-effect surface with staged event-record mutation
- 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, and normalized state-fragment assertions
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
normalized event-service breadth through staged event-record mutation and follow-on records. the `.smp` event-collection structural bridge across inspection, save-slice loading, import, and
snapshot-backed fixtures.
## Why This Boundary ## Why This Boundary
@ -189,8 +190,10 @@ Current status:
- periodic trigger ordering is implemented - periodic trigger ordering is implemented
- normalized trigger-side effects already exist for world flags, company cash/debt, candidate - normalized trigger-side effects already exist for world flags, company cash/debt, candidate
availability, and special conditions availability, and special conditions
- one-shot handling and dirty reruns are already covered by synthetic fixtures - one-shot handling, dirty reruns, and staged append/activate/deactivate/remove behavior are
- the missing breadth is event-graph mutation and richer trigger-family behavior already covered by synthetic fixtures
- the remaining breadth is richer trigger-family behavior and target resolution, not first-pass
event-graph mutation
### Milestone 3: Persistence Boundary (partially complete) ### Milestone 3: Persistence Boundary (partially complete)
@ -212,8 +215,10 @@ 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 remaining gap is broader captured-runtime and round-trip fixture depth, not the first - the packed event-collection summary now survives into loaded save slices and projected runtime
persistence surface 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
### Milestone 4: Domain Expansion ### Milestone 4: Domain Expansion
@ -313,6 +318,7 @@ Keep:
- cash, debt, and game-speed-related runtime fields when semantically relevant - cash, debt, and game-speed-related runtime fields when semantically relevant
- collection contents and semantic counts - collection contents and semantic counts
- trigger-side effects - trigger-side effects
- packed event-collection structural summaries when present
## Risks ## Risks
@ -340,47 +346,47 @@ The currently implemented normalized runtime surface is:
- `runtime validate-fixture`, `runtime summarize-fixture`, `runtime export-fixture-state`, - `runtime validate-fixture`, `runtime summarize-fixture`, `runtime export-fixture-state`,
`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
first normalized runtime-effect vocabulary normalized runtime-effect vocabulary with staged event-record mutation
- save-side inspection and partial state projection for `.smp` inputs - save-side inspection and partial state projection for `.smp` inputs, including the structural
packed event-collection summary
Checked-in fixture families already include: Checked-in fixture families already include:
- deterministic minimal-world stepping - deterministic minimal-world stepping
- periodic boundary service - periodic boundary service
- direct trigger-service mutation - direct trigger-service mutation
- staged event-record lifecycle coverage
- snapshot-backed fixture execution - snapshot-backed fixture execution
## Next Slice ## Next Slice
The recommended next implementation slice is normalized event-service breadth through staged The recommended next implementation slice is deeper `.smp` event persistence, starting from the
event-record mutation. structural bridge that already exists today.
Target behavior: Target behavior:
- allow one serviced record to append a follow-on runtime record - keep carrying the packed event collection across `inspect-smp`, `load-save-slice`,
- allow one serviced record to activate, deactivate, or remove another runtime record `import-save-state`, snapshots, diffs, and fixtures
- stage those graph mutations during the pass and commit them only after the pass finishes - deepen that bridge from collection structure into per-record packed-body summaries
- commit staged mutations in exact emission order - preserve the separation between parity-shaped packed state and executable normalized runtime state
- allow newly appended `0x0a` records to run in the dirty rerun after commit, but never in the until the packed layout is better decoded
original pass snapshot
Public-model additions for that slice: Public-model additions for that slice:
- `RuntimeEventRecordTemplate` - packed per-record event summary types on the `.smp` side
- `RuntimeEffect::AppendEventRecord` - optional runtime-side parity summaries for imported packed event records
- `RuntimeEffect::ActivateEventRecord` - no new executable `RuntimeEffect` variants by default in that slice
- `RuntimeEffect::DeactivateEventRecord`
- `RuntimeEffect::RemoveEventRecord`
Fixture work for that slice: Fixture work for that slice:
- one synthetic fixture for append plus dirty rerun behavior - one or more snapshot-backed fixtures that prove imported packed event state survives normalize and
- one synthetic fixture for cross-pass activate/deactivate/remove semantics diff paths
- state-fragment assertions that lock final collection contents and per-record counters - 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
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
- packed RT3 event-row import/export parity - direct translation of packed RT3 event rows into executable normalized effects

View file

@ -0,0 +1,47 @@
{
"format_version": 1,
"fixture_id": "packed-event-collection-from-snapshot",
"source": {
"kind": "captured-runtime",
"description": "Fixture backed by a runtime snapshot that carries the packed event collection summary."
},
"state_snapshot_path": "packed-event-collection-snapshot.json",
"commands": [
{
"kind": "step_count",
"steps": 1
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"world_flag_count": 0,
"company_count": 0,
"packed_event_collection_present": true,
"packed_event_record_count": 3,
"event_runtime_record_count": 0,
"total_event_record_service_count": 0,
"periodic_boundary_call_count": 0,
"total_trigger_dispatch_count": 0,
"dirty_rerun_count": 0,
"total_company_cash": 0
},
"expected_state_fragment": {
"calendar": {
"tick_slot": 1
},
"packed_event_collection": {
"mechanism_family": "classic-save-rehydrate-v1",
"live_record_count": 3,
"live_entry_ids": [
1,
3,
5
]
}
}
}

View file

@ -0,0 +1,41 @@
{
"format_version": 1,
"snapshot_id": "packed-event-collection-snapshot",
"source": {
"description": "Snapshot fixture carrying a projected packed event collection summary."
},
"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": 5,
"live_record_count": 3,
"live_entry_ids": [
1,
3,
5
]
},
"event_runtime_records": [],
"candidate_availability": {},
"special_conditions": {},
"service_state": {
"periodic_boundary_calls": 0,
"trigger_dispatch_counts": {},
"total_event_record_services": 0,
"dirty_rerun_count": 0
}
}
}