Decode real packed event record structure

This commit is contained in:
Jan Petykiewicz 2026-04-14 22:09:09 -07:00
commit 45f258cf5d
16 changed files with 1011 additions and 44 deletions

View file

@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use crate::{
CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary,
@ -558,6 +559,7 @@ fn runtime_packed_event_record_summary_from_smp(
payload_offset: record.payload_offset,
payload_len: record.payload_len,
decode_status: record.decode_status.clone(),
payload_family: record.payload_family.clone(),
trigger_kind: record.trigger_kind,
active: record.active,
marks_collection_dirty: record.marks_collection_dirty,
@ -568,7 +570,17 @@ fn runtime_packed_event_record_summary_from_smp(
.map(runtime_packed_event_text_band_summary_from_smp)
.collect(),
standalone_condition_row_count: record.standalone_condition_row_count,
standalone_condition_rows: record
.standalone_condition_rows
.iter()
.map(runtime_packed_event_condition_row_summary_from_smp)
.collect(),
grouped_effect_row_counts: record.grouped_effect_row_counts.clone(),
grouped_effect_rows: record
.grouped_effect_rows
.iter()
.map(runtime_packed_event_grouped_effect_row_summary_from_smp)
.collect(),
decoded_actions: record.decoded_actions.clone(),
executable_import_ready: record.executable_import_ready,
import_outcome: Some(determine_packed_event_import_outcome(
@ -591,11 +603,45 @@ fn runtime_packed_event_text_band_summary_from_smp(
}
}
fn runtime_packed_event_condition_row_summary_from_smp(
row: &crate::SmpLoadedPackedEventConditionRowSummary,
) -> RuntimePackedEventConditionRowSummary {
RuntimePackedEventConditionRowSummary {
row_index: row.row_index,
raw_condition_id: row.raw_condition_id,
subtype: row.subtype,
flag_bytes: row.flag_bytes.clone(),
candidate_name: row.candidate_name.clone(),
notes: row.notes.clone(),
}
}
fn runtime_packed_event_grouped_effect_row_summary_from_smp(
row: &crate::SmpLoadedPackedEventGroupedEffectRowSummary,
) -> RuntimePackedEventGroupedEffectRowSummary {
RuntimePackedEventGroupedEffectRowSummary {
group_index: row.group_index,
row_index: row.row_index,
descriptor_id: row.descriptor_id,
opcode: row.opcode,
raw_scalar_value: row.raw_scalar_value,
value_byte_0x09: row.value_byte_0x09,
value_dword_0x0d: row.value_dword_0x0d,
value_byte_0x11: row.value_byte_0x11,
value_byte_0x12: row.value_byte_0x12,
value_word_0x14: row.value_word_0x14,
value_word_0x16: row.value_word_0x16,
row_shape: row.row_shape.clone(),
locomotive_name: row.locomotive_name.clone(),
notes: row.notes.clone(),
}
}
fn smp_packed_record_to_runtime_event_record(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
) -> Option<Result<RuntimeEventRecord, String>> {
if record.decode_status == "unsupported_framing" {
if record.decode_status == "unsupported_framing" || record.payload_family == "real_packed_v1" {
return None;
}
@ -756,6 +802,9 @@ fn determine_packed_event_import_outcome(
if record.decode_status == "unsupported_framing" {
return "blocked_unsupported_decode".to_string();
}
if record.payload_family == "real_packed_v1" {
return "blocked_structural_only".to_string();
}
if packed_record_requires_missing_company_context(record, known_company_ids) {
return "blocked_missing_company_context".to_string();
}
@ -1129,6 +1178,36 @@ mod tests {
]
}
fn real_condition_rows() -> Vec<crate::SmpLoadedPackedEventConditionRowSummary> {
vec![crate::SmpLoadedPackedEventConditionRowSummary {
row_index: 0,
raw_condition_id: -1,
subtype: 4,
flag_bytes: vec![0x30; 25],
candidate_name: Some("AutoPlant".to_string()),
notes: vec!["negative sentinel-style condition row id".to_string()],
}]
}
fn real_grouped_rows() -> Vec<crate::SmpLoadedPackedEventGroupedEffectRowSummary> {
vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 2,
opcode: 8,
raw_scalar_value: 7,
value_byte_0x09: 1,
value_dword_0x0d: 12,
value_byte_0x11: 2,
value_byte_0x12: 3,
value_word_0x14: 24,
value_word_0x16: 36,
row_shape: "multivalue_scalar".to_string(),
locomotive_name: Some("Mikado".to_string()),
notes: vec!["grouped effect row carries locomotive-name side string".to_string()],
}]
}
#[test]
fn loads_dump_document() {
let text = serde_json::to_string(&RuntimeStateDumpDocument {
@ -1345,13 +1424,16 @@ mod tests {
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
@ -1362,13 +1444,16 @@ mod tests {
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
@ -1379,13 +1464,16 @@ mod tests {
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
@ -1586,13 +1674,16 @@ mod tests {
payload_offset: Some(0x7202),
payload_len: Some(64),
decode_status: "executable".to_string(),
payload_family: "synthetic_harness".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,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![0, 1, 0, 0],
grouped_effect_rows: vec![],
decoded_actions: vec![
RuntimeEffect::SetWorldFlag {
key: "from_packed_root".to_string(),
@ -1692,13 +1783,16 @@ mod tests {
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
payload_family: "synthetic_harness".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,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: vec![],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,
@ -1744,6 +1838,91 @@ mod tests {
);
}
#[test]
fn leaves_real_structural_records_blocked_structural_only() {
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(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: packed_text_bands(),
standalone_condition_row_count: 1,
standalone_condition_rows: real_condition_rows(),
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: real_grouped_rows(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-structural-only",
None,
)
.expect("save slice should project");
assert!(import.state.event_runtime_records.is_empty());
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_structural_only")
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.records[0].standalone_condition_rows.len()),
Some(1)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.records[0].grouped_effect_rows.len()),
Some(1)
);
}
#[test]
fn overlays_save_slice_events_onto_base_company_context() {
let base_state = RuntimeState {
@ -1813,13 +1992,16 @@ mod tests {
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
payload_family: "synthetic_harness".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,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: vec![],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,
@ -1963,13 +2145,16 @@ mod tests {
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
payload_family: "synthetic_harness".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,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: vec![],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,

View file

@ -37,6 +37,7 @@ pub use pk4::{
pub use runtime::{
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary,
RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary,
RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
};
@ -46,6 +47,7 @@ pub use smp::{
SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe,
SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit,
SmpLoadedCandidateAvailabilityTable, SmpLoadedEventRuntimeCollectionSummary,
SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventGroupedEffectRowSummary,
SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedProfile,
SmpLoadedSaveSlice, SmpLoadedSpecialConditionsTable, SmpLocomotivePolicyFieldObservation,
SmpLocomotivePolicyFloatAlignmentCandidate, SmpLocomotivePolicyNeighborhoodProbe,

View file

@ -115,6 +115,8 @@ pub struct RuntimePackedEventRecordSummary {
pub payload_len: Option<usize>,
pub decode_status: String,
#[serde(default)]
pub payload_family: String,
#[serde(default)]
pub trigger_kind: Option<u8>,
#[serde(default)]
pub active: Option<bool>,
@ -127,8 +129,12 @@ pub struct RuntimePackedEventRecordSummary {
#[serde(default)]
pub standalone_condition_row_count: usize,
#[serde(default)]
pub standalone_condition_rows: Vec<RuntimePackedEventConditionRowSummary>,
#[serde(default)]
pub grouped_effect_row_counts: Vec<usize>,
#[serde(default)]
pub grouped_effect_rows: Vec<RuntimePackedEventGroupedEffectRowSummary>,
#[serde(default)]
pub decoded_actions: Vec<RuntimeEffect>,
#[serde(default)]
pub executable_import_ready: bool,
@ -146,6 +152,39 @@ pub struct RuntimePackedEventTextBandSummary {
pub preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimePackedEventConditionRowSummary {
pub row_index: usize,
pub raw_condition_id: i32,
pub subtype: u8,
#[serde(default)]
pub flag_bytes: Vec<u8>,
#[serde(default)]
pub candidate_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimePackedEventGroupedEffectRowSummary {
pub group_index: usize,
pub row_index: usize,
pub descriptor_id: u32,
pub opcode: u8,
pub raw_scalar_value: i32,
pub value_byte_0x09: u8,
pub value_dword_0x0d: u32,
pub value_byte_0x11: u8,
pub value_byte_0x12: u8,
pub value_word_0x14: u16,
pub value_word_0x16: u16,
pub row_shape: String,
#[serde(default)]
pub locomotive_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
impl RuntimeEventRecordTemplate {
pub fn into_runtime_record(self) -> RuntimeEventRecord {
RuntimeEventRecord {
@ -387,6 +426,11 @@ impl RuntimeState {
"packed_event_collection.records[{record_index}].decode_status must not be empty"
));
}
if record.payload_family.trim().is_empty() {
return Err(format!(
"packed_event_collection.records[{record_index}].payload_family must not be empty"
));
}
if record
.import_outcome
.as_deref()
@ -401,6 +445,21 @@ impl RuntimeState {
"packed_event_collection.records[{record_index}].grouped_effect_row_counts must contain exactly 4 entries"
));
}
if record.payload_family == "real_packed_v1"
&& record.standalone_condition_rows.len() != record.standalone_condition_row_count
{
return Err(format!(
"packed_event_collection.records[{record_index}].standalone_condition_rows must match standalone_condition_row_count"
));
}
if record.payload_family == "real_packed_v1"
&& record.grouped_effect_rows.len()
!= record.grouped_effect_row_counts.iter().sum::<usize>()
{
return Err(format!(
"packed_event_collection.records[{record_index}].grouped_effect_rows must match grouped_effect_row_counts"
));
}
for band in &record.text_bands {
if band.label.trim().is_empty() {
return Err(format!(
@ -408,6 +467,33 @@ impl RuntimeState {
));
}
}
for row in &record.standalone_condition_rows {
if row
.candidate_name
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(format!(
"packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty candidate_name"
));
}
}
for row in &record.grouped_effect_rows {
if row.row_shape.trim().is_empty() {
return Err(format!(
"packed_event_collection.records[{record_index}].grouped_effect_rows contains an empty row_shape"
));
}
if row
.locomotive_name
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(format!(
"packed_event_collection.records[{record_index}].grouped_effect_rows contains an empty locomotive_name"
));
}
}
}
}
@ -772,13 +858,16 @@ mod tests {
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: None,
@ -790,13 +879,16 @@ mod tests {
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: None,

View file

@ -91,6 +91,10 @@ const INDEXED_COLLECTION_SERIALIZED_HEADER_LEN: usize =
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_REAL_CONDITION_MARKER: u16 = 0x526f;
const PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER: u16 = 0x4eb8;
const PACKED_EVENT_REAL_CONDITION_ROW_LEN: usize = 0x1e;
const PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN: usize = 0x28;
const PACKED_EVENT_TEXT_BAND_LABELS: [&str; 6] = [
"primary_text_band",
"secondary_text_band_0",
@ -1220,6 +1224,8 @@ pub struct SmpLoadedPackedEventRecordSummary {
pub payload_len: Option<usize>,
pub decode_status: String,
#[serde(default)]
pub payload_family: String,
#[serde(default)]
pub trigger_kind: Option<u8>,
#[serde(default)]
pub active: Option<bool>,
@ -1232,8 +1238,12 @@ pub struct SmpLoadedPackedEventRecordSummary {
#[serde(default)]
pub standalone_condition_row_count: usize,
#[serde(default)]
pub standalone_condition_rows: Vec<SmpLoadedPackedEventConditionRowSummary>,
#[serde(default)]
pub grouped_effect_row_counts: Vec<usize>,
#[serde(default)]
pub grouped_effect_rows: Vec<SmpLoadedPackedEventGroupedEffectRowSummary>,
#[serde(default)]
pub decoded_actions: Vec<RuntimeEffect>,
#[serde(default)]
pub executable_import_ready: bool,
@ -1249,6 +1259,39 @@ pub struct SmpLoadedPackedEventTextBandSummary {
pub preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedPackedEventConditionRowSummary {
pub row_index: usize,
pub raw_condition_id: i32,
pub subtype: u8,
#[serde(default)]
pub flag_bytes: Vec<u8>,
#[serde(default)]
pub candidate_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedPackedEventGroupedEffectRowSummary {
pub group_index: usize,
pub row_index: usize,
pub descriptor_id: u32,
pub opcode: u8,
pub raw_scalar_value: i32,
pub value_byte_0x09: u8,
pub value_dword_0x0d: u32,
pub value_byte_0x11: u8,
pub value_byte_0x12: u8,
pub value_word_0x14: u16,
pub value_word_0x16: u16,
pub row_shape: String,
#[serde(default)]
pub locomotive_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmpLoadedSaveSlice {
pub file_extension_hint: Option<String>,
@ -1576,6 +1619,13 @@ fn parse_event_runtime_record_summaries(
records_payload_offset,
live_entry_ids,
)
.or_else(|| {
try_parse_real_event_runtime_record_summaries(
records_payload,
records_payload_offset,
live_entry_ids,
)
})
.unwrap_or_else(|| {
build_unsupported_event_runtime_record_summaries(
live_entry_ids,
@ -1681,19 +1731,236 @@ fn parse_synthetic_event_runtime_record_summary(
} else {
"parity_only".to_string()
},
payload_family: "synthetic_harness".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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts,
grouped_effect_rows: Vec::new(),
decoded_actions,
executable_import_ready,
notes: vec!["decoded from the current synthetic packed-event record harness".to_string()],
})
}
fn try_parse_real_event_runtime_record_summaries(
records_payload: &[u8],
records_payload_offset: usize,
live_entry_ids: &[u32],
) -> Option<Vec<SmpLoadedPackedEventRecordSummary>> {
let mut cursor = 0usize;
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, consumed_len) = parse_real_event_runtime_record_summary(
records_payload.get(cursor..)?,
records_payload_offset + cursor,
record_index,
live_entry_id,
)?;
records.push(record);
cursor += consumed_len;
}
if cursor != records_payload.len() {
return None;
}
Some(records)
}
fn parse_real_event_runtime_record_summary(
record_body: &[u8],
payload_offset: usize,
record_index: usize,
live_entry_id: u32,
) -> Option<(SmpLoadedPackedEventRecordSummary, usize)> {
let mut cursor = 0usize;
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),
});
}
if read_u16_at(record_body, cursor)? != PACKED_EVENT_REAL_CONDITION_MARKER {
return None;
}
cursor += 2;
let standalone_condition_row_count = usize::from(read_u16_at(record_body, cursor)?);
cursor += 2;
let mut standalone_condition_rows = Vec::with_capacity(standalone_condition_row_count);
for row_index in 0..standalone_condition_row_count {
let row_bytes = record_body.get(cursor..cursor + PACKED_EVENT_REAL_CONDITION_ROW_LEN)?;
cursor += PACKED_EVENT_REAL_CONDITION_ROW_LEN;
let candidate_name = parse_optional_u16_len_prefixed_string(record_body, &mut cursor)?;
standalone_condition_rows.push(parse_real_condition_row_summary(
row_bytes,
row_index,
candidate_name,
)?);
}
if read_u16_at(record_body, cursor)? != PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER {
return None;
}
cursor += 2;
let mut grouped_effect_row_counts = Vec::with_capacity(4);
for _ in 0..4 {
grouped_effect_row_counts.push(usize::from(read_u16_at(record_body, cursor)?));
cursor += 2;
}
let mut grouped_effect_rows =
Vec::with_capacity(grouped_effect_row_counts.iter().sum::<usize>());
for (group_index, row_count) in grouped_effect_row_counts.iter().copied().enumerate() {
for row_index in 0..row_count {
let row_bytes =
record_body.get(cursor..cursor + PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN)?;
cursor += PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN;
let locomotive_name =
parse_optional_u16_len_prefixed_string(record_body, &mut cursor)?;
grouped_effect_rows.push(parse_real_grouped_effect_row_summary(
row_bytes,
group_index,
row_index,
locomotive_name,
)?);
}
}
let consumed_len = cursor;
Some((
SmpLoadedPackedEventRecordSummary {
record_index,
live_entry_id,
payload_offset: Some(payload_offset),
payload_len: Some(consumed_len),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands,
standalone_condition_row_count,
standalone_condition_rows,
grouped_effect_row_counts,
grouped_effect_rows,
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
},
consumed_len,
))
}
fn parse_real_condition_row_summary(
row_bytes: &[u8],
row_index: usize,
candidate_name: Option<String>,
) -> Option<SmpLoadedPackedEventConditionRowSummary> {
let raw_condition_id = read_u32_at(row_bytes, 0)? as i32;
let subtype = read_u8_at(row_bytes, 4)?;
let mut notes = Vec::new();
if raw_condition_id < 0 {
notes.push("negative sentinel-style condition row id".to_string());
}
if candidate_name.is_some() {
notes.push("condition row carries candidate-name side string".to_string());
}
Some(SmpLoadedPackedEventConditionRowSummary {
row_index,
raw_condition_id,
subtype,
flag_bytes: row_bytes.get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)?.to_vec(),
candidate_name,
notes,
})
}
fn parse_real_grouped_effect_row_summary(
row_bytes: &[u8],
group_index: usize,
row_index: usize,
locomotive_name: Option<String>,
) -> Option<SmpLoadedPackedEventGroupedEffectRowSummary> {
let descriptor_id = read_u32_at(row_bytes, 0)?;
let raw_scalar_value = read_u32_at(row_bytes, 4)? as i32;
let opcode = read_u8_at(row_bytes, 8)?;
let value_byte_0x09 = read_u8_at(row_bytes, 9)?;
let value_dword_0x0d = read_u32_at(row_bytes, 0x0d)?;
let value_byte_0x11 = read_u8_at(row_bytes, 0x11)?;
let value_byte_0x12 = read_u8_at(row_bytes, 0x12)?;
let value_word_0x14 = read_u16_at(row_bytes, 0x14)?;
let value_word_0x16 = read_u16_at(row_bytes, 0x16)?;
let row_shape = classify_real_grouped_effect_row_shape(
opcode,
raw_scalar_value,
value_byte_0x11,
value_byte_0x12,
value_word_0x14,
value_word_0x16,
)
.to_string();
let mut notes = Vec::new();
if locomotive_name.is_some() {
notes.push("grouped effect row carries locomotive-name side string".to_string());
}
Some(SmpLoadedPackedEventGroupedEffectRowSummary {
group_index,
row_index,
descriptor_id,
opcode,
raw_scalar_value,
value_byte_0x09,
value_dword_0x0d,
value_byte_0x11,
value_byte_0x12,
value_word_0x14,
value_word_0x16,
row_shape,
locomotive_name,
notes,
})
}
fn classify_real_grouped_effect_row_shape(
opcode: u8,
raw_scalar_value: i32,
value_byte_0x11: u8,
value_byte_0x12: u8,
value_word_0x14: u16,
value_word_0x16: u16,
) -> &'static str {
if opcode == 8 {
return "multivalue_scalar";
}
if value_byte_0x11 != 0 || value_byte_0x12 != 0 || value_word_0x14 != 0 || value_word_0x16 != 0
{
return "timed_duration";
}
if raw_scalar_value == 0 || raw_scalar_value == 1 {
return "bool_toggle";
}
"raw_other"
}
fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option<RuntimeEffect> {
let opcode = read_u8_at(bytes, *cursor)?;
*cursor += 1;
@ -1824,6 +2091,17 @@ fn parse_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option<String>
Some(String::from_utf8_lossy(text_bytes).into_owned())
}
fn parse_optional_u16_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option<Option<String>> {
let len = usize::from(read_u16_at(bytes, *cursor)?);
*cursor += 2;
if len == 0 {
return Some(None);
}
let text_bytes = bytes.get(*cursor..*cursor + len)?;
*cursor += len;
Some(Some(String::from_utf8_lossy(text_bytes).into_owned()))
}
fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
match effect {
RuntimeEffect::SetWorldFlag { .. }
@ -1858,13 +2136,16 @@ fn build_unsupported_event_runtime_record_summaries(
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
payload_family: "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,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec![note.to_string()],
@ -6701,6 +6982,90 @@ mod tests {
bytes
}
fn encode_real_optional_string(text: &str) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(text.len() as u16).to_le_bytes());
bytes.extend_from_slice(text.as_bytes());
bytes
}
fn build_real_condition_row(
raw_condition_id: i32,
subtype: u8,
flag_seed: u8,
candidate_name: Option<&str>,
) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(raw_condition_id as u32).to_le_bytes());
bytes.push(subtype);
while bytes.len() < PACKED_EVENT_REAL_CONDITION_ROW_LEN {
bytes.push(flag_seed.wrapping_add(bytes.len() as u8));
}
match candidate_name {
Some(text) => bytes.extend_from_slice(&encode_real_optional_string(text)),
None => bytes.extend_from_slice(&0u16.to_le_bytes()),
}
bytes
}
struct RealGroupedEffectRowSpec<'a> {
descriptor_id: u32,
opcode: u8,
raw_scalar_value: i32,
value_byte_0x09: u8,
value_dword_0x0d: u32,
value_byte_0x11: u8,
value_byte_0x12: u8,
value_word_0x14: u16,
value_word_0x16: u16,
locomotive_name: Option<&'a str>,
}
fn build_real_grouped_effect_row(spec: RealGroupedEffectRowSpec<'_>) -> Vec<u8> {
let mut bytes = vec![0; PACKED_EVENT_REAL_GROUPED_EFFECT_ROW_LEN];
bytes[0..4].copy_from_slice(&spec.descriptor_id.to_le_bytes());
bytes[4..8].copy_from_slice(&(spec.raw_scalar_value as u32).to_le_bytes());
bytes[8] = spec.opcode;
bytes[9] = spec.value_byte_0x09;
bytes[0x0d..0x11].copy_from_slice(&spec.value_dword_0x0d.to_le_bytes());
bytes[0x11] = spec.value_byte_0x11;
bytes[0x12] = spec.value_byte_0x12;
bytes[0x14..0x16].copy_from_slice(&spec.value_word_0x14.to_le_bytes());
bytes[0x16..0x18].copy_from_slice(&spec.value_word_0x16.to_le_bytes());
match spec.locomotive_name {
Some(text) => bytes.extend_from_slice(&encode_real_optional_string(text)),
None => bytes.extend_from_slice(&0u16.to_le_bytes()),
}
bytes
}
fn build_real_event_record(
text_bands: [&[u8]; 6],
condition_rows: &[Vec<u8>],
grouped_rows: [&[Vec<u8>]; 4],
) -> Vec<u8> {
let mut bytes = Vec::new();
for band in text_bands {
bytes.extend_from_slice(&(band.len() as u16).to_le_bytes());
bytes.extend_from_slice(band);
}
bytes.extend_from_slice(&PACKED_EVENT_REAL_CONDITION_MARKER.to_le_bytes());
bytes.extend_from_slice(&(condition_rows.len() as u16).to_le_bytes());
for row in condition_rows {
bytes.extend_from_slice(row);
}
bytes.extend_from_slice(&PACKED_EVENT_REAL_GROUPED_EFFECT_MARKER.to_le_bytes());
for rows in grouped_rows {
bytes.extend_from_slice(&(rows.len() as u16).to_le_bytes());
}
for rows in grouped_rows {
for row in rows {
bytes.extend_from_slice(row);
}
}
bytes
}
#[test]
fn parses_synthetic_event_runtime_record_summaries_and_actions() {
let append_template = encode_template(
@ -6746,6 +7111,7 @@ mod tests {
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].payload_family, "synthetic_harness");
assert_eq!(summary.records[0].text_bands[0].preview, "Alpha");
assert_eq!(summary.records[0].standalone_condition_row_count, 1);
assert_eq!(
@ -6797,9 +7163,143 @@ mod tests {
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_eq!(summary.records[0].payload_family, "synthetic_harness");
assert!(!summary.records[0].executable_import_ready);
}
#[test]
fn parses_real_style_event_runtime_record_with_zero_rows() {
let record_body = build_real_event_record(
[b"Alpha", b"", b"", b"", b"", b""],
&[],
[&[], &[], &[], &[]],
);
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(&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_eq!(summary.records[0].payload_family, "real_packed_v1");
assert_eq!(summary.records[0].text_bands[0].preview, "Alpha");
assert_eq!(summary.records[0].standalone_condition_row_count, 0);
assert_eq!(summary.records[0].standalone_condition_rows.len(), 0);
assert_eq!(summary.records[0].grouped_effect_row_counts, vec![0, 0, 0, 0]);
assert_eq!(summary.records[0].grouped_effect_rows.len(), 0);
}
#[test]
fn parses_real_style_rows_and_side_strings() {
let condition_row = build_real_condition_row(-1, 4, 0x30, Some("AutoPlant"));
let grouped_row = build_real_grouped_effect_row(RealGroupedEffectRowSpec {
descriptor_id: 2,
opcode: 8,
raw_scalar_value: 7,
value_byte_0x09: 1,
value_dword_0x0d: 12,
value_byte_0x11: 2,
value_byte_0x12: 3,
value_word_0x14: 24,
value_word_0x16: 36,
locomotive_name: Some("Mikado"),
});
let group0_rows = vec![grouped_row];
let record_body = build_real_event_record(
[b"Gamma", b"", b"", b"", b"", b""],
&[condition_row],
[&group0_rows, &[], &[], &[]],
);
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(&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.records[0].standalone_condition_rows.len(), 1);
assert_eq!(summary.records[0].standalone_condition_rows[0].raw_condition_id, -1);
assert_eq!(
summary.records[0].standalone_condition_rows[0]
.candidate_name
.as_deref(),
Some("AutoPlant")
);
assert_eq!(summary.records[0].grouped_effect_rows.len(), 1);
assert_eq!(summary.records[0].grouped_effect_rows[0].opcode, 8);
assert_eq!(
summary.records[0].grouped_effect_rows[0].row_shape,
"multivalue_scalar"
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.locomotive_name
.as_deref(),
Some("Mikado")
);
}
#[test]
fn rejects_truncated_real_style_event_runtime_record() {
let mut record_body = build_real_event_record(
[b"Oops", b"", b"", b"", b"", b""],
&[],
[&[], &[], &[], &[]],
);
record_body.pop();
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(&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.records[0].decode_status, "unsupported_framing");
assert_eq!(summary.records[0].payload_family, "unsupported_framing");
}
#[test]
fn loads_event_runtime_collection_summary_from_report() {
let mut report = inspect_smp_bytes(&[]);

View file

@ -35,6 +35,7 @@ pub struct RuntimeSummary {
pub packed_event_parity_only_record_count: usize,
pub packed_event_unsupported_record_count: usize,
pub packed_event_blocked_missing_company_context_count: usize,
pub packed_event_blocked_structural_only_count: usize,
pub event_runtime_record_count: usize,
pub candidate_availability_count: usize,
pub zero_candidate_availability_count: usize,
@ -168,6 +169,19 @@ impl RuntimeSummary {
.count()
})
.unwrap_or(0),
packed_event_blocked_structural_only_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| {
record.import_outcome.as_deref() == Some("blocked_structural_only")
})
.count()
})
.unwrap_or(0),
event_runtime_record_count: state.event_runtime_records.len(),
candidate_availability_count: state.candidate_availability.len(),
zero_candidate_availability_count: state
@ -207,3 +221,97 @@ impl RuntimeSummary {
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crate::{
CalendarPoint, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
};
use super::RuntimeSummary;
#[test]
fn counts_structural_only_and_missing_context_frontiers() {
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: 7,
live_record_count: 2,
live_entry_ids: vec![3, 7],
decoded_record_count: 2,
imported_runtime_record_count: 0,
records: vec![
RuntimePackedEventRecordSummary {
record_index: 0,
live_entry_id: 3,
payload_offset: Some(0x7202),
payload_len: Some(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: Some("blocked_structural_only".to_string()),
notes: Vec::new(),
},
RuntimePackedEventRecordSummary {
record_index: 1,
live_entry_id: 7,
payload_offset: Some(0x7262),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
payload_family: "synthetic_harness".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(false),
one_shot: Some(false),
text_bands: Vec::new(),
standalone_condition_row_count: 0,
standalone_condition_rows: Vec::new(),
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: Some("blocked_missing_company_context".to_string()),
notes: Vec::new(),
},
],
}),
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
let summary = RuntimeSummary::from_state(&state);
assert_eq!(summary.packed_event_blocked_structural_only_count, 1);
assert_eq!(summary.packed_event_blocked_missing_company_context_count, 1);
}
}