Recover real packed event descriptor semantics

This commit is contained in:
Jan Petykiewicz 2026-04-15 09:50:58 -07:00
commit eb6c4833af
9 changed files with 719 additions and 120 deletions

View file

@ -6,10 +6,9 @@ use serde::{Deserialize, Serialize};
use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use crate::{
CalendarPoint, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect,
RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCompactControlSummary,
RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary,
RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary,
RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary,
SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice,
@ -133,10 +132,9 @@ impl ImportCompanyContext {
.collect(),
selected_company_id: state.selected_company_id,
has_complete_controller_context: !state.companies.is_empty()
&& state
.companies
.iter()
.all(|company| company.controller_kind != RuntimeCompanyControllerKind::Unknown),
&& state.companies.iter().all(|company| {
company.controller_kind != RuntimeCompanyControllerKind::Unknown
}),
}
}
}
@ -546,7 +544,9 @@ fn project_packed_event_collection(
let mut imported_runtime_records = Vec::new();
let mut imported_record_ids = BTreeSet::new();
for record in &summary.records {
if let Some(import_result) = smp_packed_record_to_runtime_event_record(record, company_context) {
if let Some(import_result) =
smp_packed_record_to_runtime_event_record(record, company_context)
{
let runtime_record = import_result?;
imported_record_ids.insert(record.live_entry_id);
imported_runtime_records.push(runtime_record);
@ -684,6 +684,9 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp(
group_index: row.group_index,
row_index: row.row_index,
descriptor_id: row.descriptor_id,
descriptor_label: row.descriptor_label.clone(),
target_mask_bits: row.target_mask_bits,
parameter_family: row.parameter_family.clone(),
opcode: row.opcode,
raw_scalar_value: row.raw_scalar_value,
value_byte_0x09: row.value_byte_0x09,
@ -693,6 +696,8 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp(
value_word_0x14: row.value_word_0x14,
value_word_0x16: row.value_word_0x16,
row_shape: row.row_shape.clone(),
semantic_family: row.semantic_family.clone(),
semantic_preview: row.semantic_preview.clone(),
locomotive_name: row.locomotive_name.clone(),
notes: row.notes.clone(),
}
@ -702,14 +707,18 @@ fn smp_packed_record_to_runtime_event_record(
record: &SmpLoadedPackedEventRecordSummary,
company_context: &ImportCompanyContext,
) -> Option<Result<RuntimeEventRecord, String>> {
if record.decode_status == "unsupported_framing" || record.payload_family == "real_packed_v1" {
if record.decode_status == "unsupported_framing" {
return None;
}
if record.payload_family == "real_packed_v1" && record.decoded_actions.is_empty() {
return None;
}
let effects = match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, company_context) {
Ok(effects) => effects,
Err(_) => return None,
};
let effects =
match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, company_context) {
Ok(effects) => effects,
Err(_) => return None,
};
Some((|| {
let trigger_kind = record.trigger_kind.ok_or_else(|| {
@ -718,24 +727,9 @@ fn smp_packed_record_to_runtime_event_record(
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
)
})?;
let active = record.active.unwrap_or(true);
let marks_collection_dirty = record.marks_collection_dirty.unwrap_or(false);
let one_shot = record.one_shot.unwrap_or(false);
Ok(RuntimeEventRecordTemplate {
record_id: record.live_entry_id,
trigger_kind,
@ -767,6 +761,16 @@ fn smp_runtime_effect_to_runtime_effect(
key: key.clone(),
value: *value,
}),
RuntimeEffect::SetCompanyCash { target, value } => {
if company_target_import_blocker(target, company_context).is_none() {
Ok(RuntimeEffect::SetCompanyCash {
target: target.clone(),
value: *value,
})
} else {
Err(company_target_import_error_message(target, company_context))
}
}
RuntimeEffect::AdjustCompanyCash { target, delta } => {
if company_target_import_blocker(target, company_context).is_none() {
Ok(RuntimeEffect::AdjustCompanyCash {
@ -908,6 +912,13 @@ fn determine_packed_event_import_outcome(
if record.compact_control.is_none() {
return "blocked_missing_compact_control".to_string();
}
if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context)
{
return company_target_import_outcome(blocker).to_string();
}
if !record.decoded_actions.is_empty() {
return "blocked_unsupported_decode".to_string();
}
if let Some(blocker) = real_record_company_target_import_blocker(record, company_context) {
return company_target_import_outcome(blocker).to_string();
}
@ -934,14 +945,14 @@ fn runtime_effect_company_target_import_blocker(
company_context: &ImportCompanyContext,
) -> Option<CompanyTargetImportBlocker> {
match effect {
RuntimeEffect::AdjustCompanyCash { target, .. }
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
company_target_import_blocker(target, company_context)
}
RuntimeEffect::AppendEventRecord { record } => record
.effects
.iter()
.find_map(|nested| runtime_effect_company_target_import_blocker(nested, company_context)),
RuntimeEffect::AppendEventRecord { record } => record.effects.iter().find_map(|nested| {
runtime_effect_company_target_import_blocker(nested, company_context)
}),
RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. }
@ -998,15 +1009,11 @@ fn real_record_company_target_import_blocker(
fn company_target_import_outcome(blocker: CompanyTargetImportBlocker) -> &'static str {
match blocker {
CompanyTargetImportBlocker::MissingCompanyContext => "blocked_missing_company_context",
CompanyTargetImportBlocker::MissingSelectionContext => {
"blocked_missing_selection_context"
}
CompanyTargetImportBlocker::MissingSelectionContext => "blocked_missing_selection_context",
CompanyTargetImportBlocker::MissingCompanyRoleContext => {
"blocked_missing_company_role_context"
}
CompanyTargetImportBlocker::MissingConditionContext => {
"blocked_missing_condition_context"
}
CompanyTargetImportBlocker::MissingConditionContext => "blocked_missing_condition_context",
}
}
@ -1391,6 +1398,9 @@ mod tests {
group_index: 0,
row_index: 0,
descriptor_id: 2,
descriptor_label: Some("Company Cash".to_string()),
target_mask_bits: Some(0x01),
parameter_family: Some("company_finance_scalar".to_string()),
opcode: 8,
raw_scalar_value: 7,
value_byte_0x09: 1,
@ -1400,6 +1410,8 @@ mod tests {
value_word_0x14: 24,
value_word_0x16: 36,
row_shape: "multivalue_scalar".to_string(),
semantic_family: Some("multivalue_scalar".to_string()),
semantic_preview: Some("Set Company Cash to 7 with aux [2, 3, 24, 36]".to_string()),
locomotive_name: Some("Mikado".to_string()),
notes: vec!["grouped effect row carries locomotive-name side string".to_string()],
}]
@ -1420,8 +1432,8 @@ mod tests {
}
}
fn real_compact_control_without_symbolic_company_scope(
) -> crate::SmpLoadedPackedEventCompactControlSummary {
fn real_compact_control_without_symbolic_company_scope()
-> crate::SmpLoadedPackedEventCompactControlSummary {
crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 6,
primary_selector_0x7f0: 0x63,
@ -2128,12 +2140,9 @@ mod tests {
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"symbolic-blockers",
None,
)
.expect("standalone projection should succeed");
let import =
project_save_slice_to_runtime_state_import(&save_slice, "symbolic-blockers", None)
.expect("standalone projection should succeed");
assert!(import.state.event_runtime_records.is_empty());
let outcomes = import
@ -2299,7 +2308,10 @@ mod tests {
standalone_condition_rows: real_condition_rows(),
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: real_grouped_rows(),
decoded_actions: vec![],
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
target: RuntimeCompanyTarget::ConditionTrueCompany,
value: 7,
}],
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
@ -2342,7 +2354,8 @@ mod tests {
}
#[test]
fn leaves_real_records_with_condition_relative_company_scope_blocked_missing_condition_context() {
fn leaves_real_records_with_condition_relative_company_scope_blocked_missing_condition_context()
{
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
@ -2490,6 +2503,150 @@ mod tests {
);
}
#[test]
fn overlays_real_company_cash_descriptor_into_executable_runtime_record() {
let base_state = RuntimeState {
calendar: CalendarPoint {
year: 1845,
month_slot: 2,
phase_slot: 1,
tick_slot: 3,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: vec![crate::RuntimeCompany {
company_id: 42,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 500,
debt: 20,
}],
selected_company_id: Some(42),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
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: 9,
live_record_count: 1,
live_entry_ids: vec![9],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 9,
payload_offset: Some(0x7202),
payload_len: Some(133),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 1,
modifier_flag_0x7fa: 0,
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
summary_toggle_0x800: 1,
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 2,
descriptor_label: Some("Company Cash".to_string()),
target_mask_bits: Some(0x01),
parameter_family: Some("company_finance_scalar".to_string()),
opcode: 8,
raw_scalar_value: 250,
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(),
semantic_family: Some("multivalue_scalar".to_string()),
semantic_preview: Some(
"Set Company Cash to 250 with aux [2, 3, 24, 36]".to_string(),
),
locomotive_name: Some("Mikado".to_string()),
notes: vec![
"grouped effect row carries locomotive-name side string".to_string(),
],
}],
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
target: RuntimeCompanyTarget::SelectedCompany,
value: 250,
}],
executable_import_ready: false,
notes: vec![
"decoded from grounded real 0x4e9a row framing".to_string(),
"grouped descriptor labels and target masks come from the checked-in effect table recovered around 0x006103a0".to_string(),
],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_overlay_to_runtime_state_import(
&base_state,
&save_slice,
"real-company-cash-overlay",
None,
)
.expect("overlay import should project");
assert_eq!(import.state.event_runtime_records.len(), 1);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("imported")
);
execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("real company-cash descriptor should execute through the normal trigger path");
assert_eq!(import.state.companies[0].current_cash, 250);
}
#[test]
fn overlays_save_slice_events_onto_base_company_context() {
let base_state = RuntimeState {

View file

@ -40,6 +40,10 @@ pub enum RuntimeEffect {
key: String,
value: bool,
},
SetCompanyCash {
target: RuntimeCompanyTarget,
value: i64,
},
AdjustCompanyCash {
target: RuntimeCompanyTarget,
delta: i64,
@ -206,6 +210,12 @@ pub struct RuntimePackedEventGroupedEffectRowSummary {
pub group_index: usize,
pub row_index: usize,
pub descriptor_id: u32,
#[serde(default)]
pub descriptor_label: Option<String>,
#[serde(default)]
pub target_mask_bits: Option<u8>,
#[serde(default)]
pub parameter_family: Option<String>,
pub opcode: u8,
pub raw_scalar_value: i32,
pub value_byte_0x09: u8,
@ -216,6 +226,10 @@ pub struct RuntimePackedEventGroupedEffectRowSummary {
pub value_word_0x16: u16,
pub row_shape: String,
#[serde(default)]
pub semantic_family: Option<String>,
#[serde(default)]
pub semantic_preview: Option<String>,
#[serde(default)]
pub locomotive_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
@ -492,7 +506,8 @@ impl RuntimeState {
));
}
if record.payload_family == "real_packed_v1"
&& record.standalone_condition_rows.len() != record.standalone_condition_row_count
&& 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"
@ -653,7 +668,8 @@ fn validate_runtime_effect(
return Err("key must not be empty".to_string());
}
}
RuntimeEffect::AdjustCompanyCash { target, .. }
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
validate_company_target(target, valid_company_ids)?;
}

View file

@ -113,6 +113,74 @@ const SHARED_SIGNATURE_WORDS_1_TO_7: [u32; 7] = [
0x00002ee0, 0x00040001, 0x00028000, 0x00010000, 0x00000771, 0x00000771, 0x00000771,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RealGroupedEffectDescriptorMetadata {
descriptor_id: u32,
label: &'static str,
target_mask_bits: u8,
parameter_family: &'static str,
executable_in_runtime: bool,
}
const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetadata; 8] = [
RealGroupedEffectDescriptorMetadata {
descriptor_id: 1,
label: "Player Cash",
target_mask_bits: 0x02,
parameter_family: "player_finance_scalar",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 2,
label: "Company Cash",
target_mask_bits: 0x01,
parameter_family: "company_finance_scalar",
executable_in_runtime: true,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 3,
label: "Territory - Allow All",
target_mask_bits: 0x05,
parameter_family: "territory_access_toggle",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 8,
label: "Economic Status",
target_mask_bits: 0x08,
parameter_family: "whole_game_state_enum",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 9,
label: "Confiscate All",
target_mask_bits: 0x01,
parameter_family: "company_confiscation_variant",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 13,
label: "Deactivate Company",
target_mask_bits: 0x01,
parameter_family: "company_lifecycle_toggle",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 15,
label: "Retire Train",
target_mask_bits: 0x0d,
parameter_family: "company_or_territory_asset_toggle",
executable_in_runtime: false,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 16,
label: "Company Track Pieces Buildable",
target_mask_bits: 0x01,
parameter_family: "company_build_limit_scalar",
executable_in_runtime: false,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct KnownSpecialConditionDefinition {
slot_index: u8,
@ -1295,6 +1363,12 @@ pub struct SmpLoadedPackedEventGroupedEffectRowSummary {
pub group_index: usize,
pub row_index: usize,
pub descriptor_id: u32,
#[serde(default)]
pub descriptor_label: Option<String>,
#[serde(default)]
pub target_mask_bits: Option<u8>,
#[serde(default)]
pub parameter_family: Option<String>,
pub opcode: u8,
pub raw_scalar_value: i32,
pub value_byte_0x09: u8,
@ -1305,6 +1379,10 @@ pub struct SmpLoadedPackedEventGroupedEffectRowSummary {
pub value_word_0x16: u16,
pub row_shape: String,
#[serde(default)]
pub semantic_family: Option<String>,
#[serde(default)]
pub semantic_preview: Option<String>,
#[serde(default)]
pub locomotive_name: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
@ -1852,8 +1930,7 @@ fn parse_real_event_runtime_record_summary(
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)?;
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,
@ -1863,6 +1940,14 @@ fn parse_real_event_runtime_record_summary(
}
}
let decoded_actions = compact_control
.as_ref()
.map(|control| decode_real_grouped_effect_actions(&grouped_effect_rows, control))
.unwrap_or_default();
let executable_import_ready = !decoded_actions.is_empty()
&& decoded_actions
.iter()
.all(runtime_effect_supported_for_save_import);
let consumed_len = cursor;
Some((
SmpLoadedPackedEventRecordSummary {
@ -1884,9 +1969,12 @@ fn parse_real_event_runtime_record_summary(
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()],
decoded_actions,
executable_import_ready,
notes: vec![
"decoded from grounded real 0x4e9a row framing".to_string(),
"grouped descriptor labels and target masks come from the checked-in effect table recovered around 0x006103a0".to_string(),
],
},
consumed_len,
))
@ -1931,8 +2019,7 @@ fn parse_optional_real_compact_control_summary(
let summary_toggle_0x800 = read_u8_at(bytes, local)?;
local += 1;
let mut grouped_territory_selectors_0x80f =
Vec::with_capacity(PACKED_EVENT_REAL_GROUP_COUNT);
let mut grouped_territory_selectors_0x80f = Vec::with_capacity(PACKED_EVENT_REAL_GROUP_COUNT);
for _ in 0..PACKED_EVENT_REAL_GROUP_COUNT {
grouped_territory_selectors_0x80f.push(read_i32_at(bytes, local)?);
local += 4;
@ -1978,7 +2065,9 @@ fn parse_real_condition_row_summary(
row_index,
raw_condition_id,
subtype,
flag_bytes: row_bytes.get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)?.to_vec(),
flag_bytes: row_bytes
.get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)?
.to_vec(),
candidate_name,
notes,
})
@ -2008,16 +2097,32 @@ fn parse_real_grouped_effect_row_summary(
value_word_0x16,
)
.to_string();
let descriptor_metadata = real_grouped_effect_descriptor_metadata(descriptor_id);
let semantic_family = classify_real_grouped_effect_semantic_family(
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());
}
if descriptor_metadata.is_none() {
notes.push("descriptor id not yet recovered in the checked-in effect table".to_string());
}
Some(SmpLoadedPackedEventGroupedEffectRowSummary {
group_index,
row_index,
descriptor_id,
descriptor_label: descriptor_metadata.map(|metadata| metadata.label.to_string()),
target_mask_bits: descriptor_metadata.map(|metadata| metadata.target_mask_bits),
parameter_family: descriptor_metadata.map(|metadata| metadata.parameter_family.to_string()),
opcode,
raw_scalar_value,
value_byte_0x09,
@ -2027,11 +2132,51 @@ fn parse_real_grouped_effect_row_summary(
value_word_0x14,
value_word_0x16,
row_shape,
semantic_family: Some(semantic_family.clone()),
semantic_preview: Some(build_real_grouped_effect_semantic_preview(
descriptor_metadata.map(|metadata| metadata.label),
&semantic_family,
raw_scalar_value,
value_byte_0x11,
value_byte_0x12,
value_word_0x14,
value_word_0x16,
)),
locomotive_name,
notes,
})
}
fn real_grouped_effect_descriptor_metadata(
descriptor_id: u32,
) -> Option<RealGroupedEffectDescriptorMetadata> {
REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA
.iter()
.copied()
.find(|metadata| metadata.descriptor_id == descriptor_id)
}
fn classify_real_grouped_effect_semantic_family(
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";
}
"scalar_assignment"
}
fn classify_real_grouped_effect_row_shape(
opcode: u8,
raw_scalar_value: i32,
@ -2050,7 +2195,77 @@ fn classify_real_grouped_effect_row_shape(
if raw_scalar_value == 0 || raw_scalar_value == 1 {
return "bool_toggle";
}
"raw_other"
"scalar_assignment"
}
fn build_real_grouped_effect_semantic_preview(
descriptor_label: Option<&str>,
semantic_family: &str,
raw_scalar_value: i32,
value_byte_0x11: u8,
value_byte_0x12: u8,
value_word_0x14: u16,
value_word_0x16: u16,
) -> String {
let label = descriptor_label.unwrap_or("descriptor");
match semantic_family {
"bool_toggle" => {
let state = if raw_scalar_value == 0 {
"FALSE"
} else {
"TRUE"
};
format!("Set {label} to {state}")
}
"timed_duration" => format!(
"Set {label} to {raw_scalar_value} for {value_word_0x14} years {value_word_0x16} months"
),
"multivalue_scalar" => format!(
"Set {label} to {raw_scalar_value} with aux [{value_byte_0x11}, {value_byte_0x12}, {value_word_0x14}, {value_word_0x16}]"
),
_ => format!("Set {label} to {raw_scalar_value}"),
}
}
fn decode_real_grouped_effect_actions(
grouped_effect_rows: &[SmpLoadedPackedEventGroupedEffectRowSummary],
compact_control: &SmpLoadedPackedEventCompactControlSummary,
) -> Vec<RuntimeEffect> {
grouped_effect_rows
.iter()
.filter_map(|row| decode_real_grouped_effect_action(row, compact_control))
.collect()
}
fn decode_real_grouped_effect_action(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
compact_control: &SmpLoadedPackedEventCompactControlSummary,
) -> Option<RuntimeEffect> {
let descriptor_metadata = real_grouped_effect_descriptor_metadata(row.descriptor_id)?;
let target_scope_ordinal = compact_control
.grouped_target_scope_ordinals_0x7fb
.get(row.group_index)
.copied()?;
let target = match target_scope_ordinal {
0 => RuntimeCompanyTarget::ConditionTrueCompany,
1 => RuntimeCompanyTarget::SelectedCompany,
2 => RuntimeCompanyTarget::HumanCompanies,
3 => RuntimeCompanyTarget::AiCompanies,
_ => return None,
};
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 2
&& row.opcode == 8
&& row.row_shape == "multivalue_scalar"
{
return Some(RuntimeEffect::SetCompanyCash {
target,
value: i64::from(row.raw_scalar_value),
});
}
None
}
fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option<RuntimeEffect> {
@ -2183,7 +2398,10 @@ 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>> {
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 {
@ -2202,7 +2420,8 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
| RuntimeEffect::RemoveEventRecord { .. } => true,
RuntimeEffect::AdjustCompanyCash { target, .. }
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
matches!(target, RuntimeCompanyTarget::AllActive)
}
@ -2228,14 +2447,14 @@ 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,
compact_control: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
payload_family: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
compact_control: 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(),
@ -7356,7 +7575,10 @@ mod tests {
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_row_counts,
vec![0, 0, 0, 0]
);
assert_eq!(summary.records[0].grouped_effect_rows.len(), 0);
}
@ -7422,7 +7644,10 @@ mod tests {
.grouped_target_scope_ordinals_0x7fb,
vec![1, 4, 7, 8]
);
assert_eq!(summary.records[0].standalone_condition_rows[0].raw_condition_id, -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
@ -7431,16 +7656,173 @@ mod tests {
);
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]
.descriptor_label
.as_deref(),
Some("Company Cash")
);
assert_eq!(
summary.records[0].grouped_effect_rows[0].target_mask_bits,
Some(0x01)
);
assert_eq!(
summary.records[0].grouped_effect_rows[0].row_shape,
"multivalue_scalar"
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.semantic_family
.as_deref(),
Some("multivalue_scalar")
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.semantic_preview
.as_deref(),
Some("Set Company Cash to 7 with aux [2, 3, 24, 36]")
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.locomotive_name
.as_deref(),
Some("Mikado")
);
assert_eq!(
summary.records[0].decoded_actions,
vec![RuntimeEffect::SetCompanyCash {
target: RuntimeCompanyTarget::SelectedCompany,
value: 7,
}]
);
}
#[test]
fn classifies_real_grouped_row_semantic_families() {
let grouped_rows = vec![
build_real_grouped_effect_row(RealGroupedEffectRowSpec {
descriptor_id: 2,
opcode: 1,
raw_scalar_value: 1,
value_byte_0x09: 0,
value_dword_0x0d: 0,
value_byte_0x11: 0,
value_byte_0x12: 0,
value_word_0x14: 0,
value_word_0x16: 0,
locomotive_name: None,
}),
build_real_grouped_effect_row(RealGroupedEffectRowSpec {
descriptor_id: 2,
opcode: 4,
raw_scalar_value: 25,
value_byte_0x09: 0,
value_dword_0x0d: 0,
value_byte_0x11: 0,
value_byte_0x12: 0,
value_word_0x14: 2,
value_word_0x16: 6,
locomotive_name: None,
}),
build_real_grouped_effect_row(RealGroupedEffectRowSpec {
descriptor_id: 2,
opcode: 3,
raw_scalar_value: 250,
value_byte_0x09: 0,
value_dword_0x0d: 0,
value_byte_0x11: 0,
value_byte_0x12: 0,
value_word_0x14: 0,
value_word_0x16: 0,
locomotive_name: None,
}),
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 record_body = build_real_event_record(
[b"Semantic", b"", b"", b"", b"", b""],
Some(RealCompactControlSpec {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 1,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 0,
modifier_flag_0x7fa: 0,
grouped_target_scope_ordinals_0x7fb: [1, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: [0, 0, 0, 0],
summary_toggle_0x800: 0,
grouped_territory_selectors_0x80f: [-1, -1, -1, -1],
}),
&[],
[&grouped_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");
let families = summary.records[0]
.grouped_effect_rows
.iter()
.map(|row| row.semantic_family.as_deref().unwrap_or(""))
.collect::<Vec<_>>();
assert_eq!(
families,
vec![
"bool_toggle",
"timed_duration",
"scalar_assignment",
"multivalue_scalar",
]
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.semantic_preview
.as_deref(),
Some("Set Company Cash to TRUE")
);
assert_eq!(
summary.records[0].grouped_effect_rows[1]
.semantic_preview
.as_deref(),
Some("Set Company Cash to 25 for 2 years 6 months")
);
assert_eq!(
summary.records[0].grouped_effect_rows[2]
.semantic_preview
.as_deref(),
Some("Set Company Cash to 250")
);
assert_eq!(
summary.records[0].grouped_effect_rows[3]
.semantic_preview
.as_deref(),
Some("Set Company Cash to 7 with aux [2, 3, 24, 36]")
);
}
#[test]

View file

@ -4,8 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{
RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate,
RuntimeState, RuntimeSummary,
calendar::BoundaryEventKind,
RuntimeState, RuntimeSummary, calendar::BoundaryEventKind,
};
const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
@ -286,6 +285,20 @@ fn apply_runtime_effects(
RuntimeEffect::SetWorldFlag { key, value } => {
state.world_flags.insert(key.clone(), *value);
}
RuntimeEffect::SetCompanyCash { target, value } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
let company = state
.companies
.iter_mut()
.find(|company| company.company_id == company_id)
.ok_or_else(|| {
format!("missing company_id {company_id} while applying cash effect")
})?;
company.current_cash = *value;
mutated_company_ids.insert(company_id);
}
}
RuntimeEffect::AdjustCompanyCash { target, delta } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {