From eb6c4833af5855058aa9d54b36df0fc98dec6678 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 15 Apr 2026 09:50:58 -0700 Subject: [PATCH] Recover real packed event descriptor semantics --- README.md | 12 +- crates/rrt-runtime/src/import.rs | 263 ++++++++--- crates/rrt-runtime/src/runtime.rs | 20 +- crates/rrt-runtime/src/smp.rs | 424 +++++++++++++++++- crates/rrt-runtime/src/step.rs | 17 +- docs/README.md | 10 +- docs/runtime-rehost-plan.md | 59 +-- ...acked-event-parity-save-slice-fixture.json | 14 + .../packed-event-parity-save-slice.json | 20 +- 9 files changed, 719 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index f602bfa..c3454e2 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,14 @@ The long-term direction is still a DLL we can inject into the original executabl individual functions as we build them out. The active implementation milestone is now a headless runtime rehost layer that can execute deterministic world work, compare normalized state, and grow subsystem breadth without depending on the shell or presentation path. The current packed-event -frontier is real grouped-descriptor semantic mapping on top of the existing save-slice, snapshot, +frontier is broader real grouped-descriptor coverage on top of the existing save-slice, snapshot, overlay-import, compact-control, and symbolic company-target workflows. The runtime already carries -selected-company and controller-role context through overlay imports so synthetic packed records can -execute `selected_company`, `human_companies`, and `ai_companies` scopes without a parallel packed -executor, while condition-relative company scopes remain explicitly blocked. The PE32 hook remains -useful as capture and integration tooling, but it is no longer the main execution milestone. +selected-company and controller-role context through overlay imports, real descriptor `2` +`Company Cash` now parses and executes through the ordinary runtime path, and synthetic packed +records still exercise the same service engine without a parallel packed executor. Condition- +relative company scopes remain explicitly blocked until condition evaluation is grounded. The PE32 +hook remains useful as capture and integration tooling, but it is no longer the main execution +milestone. ## Project Docs diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index aa7fcc1..196d787 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -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> { - 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 { 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 { diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index a4cbd7b..3589f2c 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -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, + #[serde(default)] + pub target_mask_bits: Option, + #[serde(default)] + pub parameter_family: Option, 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, + #[serde(default)] + pub semantic_preview: Option, + #[serde(default)] pub locomotive_name: Option, #[serde(default)] pub notes: Vec, @@ -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)?; } diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 97e66c2..4286a4b 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -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, + #[serde(default)] + pub target_mask_bits: Option, + #[serde(default)] + pub parameter_family: Option, 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, + #[serde(default)] + pub semantic_preview: Option, + #[serde(default)] pub locomotive_name: Option, #[serde(default)] pub notes: Vec, @@ -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 { + 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 { + 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 { + 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 { @@ -2183,7 +2398,10 @@ fn parse_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option Some(String::from_utf8_lossy(text_bytes).into_owned()) } -fn parse_optional_u16_len_prefixed_string(bytes: &[u8], cursor: &mut usize) -> Option> { +fn parse_optional_u16_len_prefixed_string( + bytes: &[u8], + cursor: &mut usize, +) -> Option> { 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::>(); + 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] diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 30d7cc8..752b312 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -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 { diff --git a/docs/README.md b/docs/README.md index b9e29ea..9bd78ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,11 +77,11 @@ The highest-value next passes are now: avoid shell-first implementation bets - keep using overlay imports as the context bridge when selectively executable packed rows still need live company state that save slices do not persist -- treat normalized symbolic company targets as the active packed-event frontier now that - `selected_company`, `human_companies`, and `ai_companies` import and execute through the runtime - service path -- widen real packed-event executable coverage only after the compact-control, symbolic target, and - descriptor frontier is stable, not just after row framing is parsed +- treat broader real grouped-descriptor recovery as the active packed-event frontier now that + descriptor `2` `Company Cash` already parses, summarizes, and executes through the ordinary + runtime path when overlay context resolves its symbolic company scope +- widen real packed-event executable coverage descriptor by descriptor after identity, target mask, + and normalized effect semantics are all grounded, not just after row framing is parsed - leave condition-relative company scopes explicit and blocked until condition evaluation has grounded runtime semantics - keep in mind that the current local `.gms` corpus still exports with no packed event collection, diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 675c1ce..52d1ec8 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -28,10 +28,12 @@ Implemented today: company-target model can execute `selected_company`, `human_companies`, and `ai_companies` symbolic scopes through the ordinary runtime service path while keeping condition-relative company scopes explicitly blocked +- real `0x4e9a` grouped rows now carry checked-in descriptor metadata, semantic family/preview + summaries, and one recovered executable family: descriptor `2` = `Company Cash` That means the next implementation work is breadth, not bootstrap. The recommended next slice is -real grouped-descriptor semantic mapping on top of the now-stable compact-control and symbolic -target frontier, not another persistence scaffold pass. +broader real grouped-descriptor coverage beyond `Company Cash`, plus condition-relative execution +for the still-blocked symbolic scopes, not another persistence scaffold pass. ## Why This Boundary @@ -372,45 +374,44 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is real `0x4e9a` compact-control decode on top of the -existing real-row structural parse. +The recommended next implementation slice is broader real grouped-descriptor coverage on top of the +now-stable compact-control, symbolic-target, and first recovered real-family path. Target behavior: -- decode the compact control block that sits above the real standalone-condition and grouped-effect - row families, carrying through raw grounded lanes such as mode byte `0x7ef`, primary selector - `0x7f0`, grouped mode `0x7f4`, one-shot header `0x7f5`, modifier bytes `0x7f9/0x7fa`, grouped - target-scope ordinals `0x7fb`, grouped scope checkboxes `0x7ff`, summary toggle `0x800`, and - grouped territory selectors `0x80f` -- keep real rows parity-only in runtime import, but replace the coarse `blocked_structural_only` - frontier with narrower outcomes such as `blocked_missing_compact_control` and - `blocked_unmapped_real_descriptor` -- keep the existing synthetic harness and overlay-backed executable import path working unchanged -- reserve the first real descriptor-to-effect mapping for a later slice once captured evidence is - tighter +- keep descriptor `2` `Company Cash` as the proof that real grouped rows can cross the whole path: + parse, semantic summary, overlay-backed import, and ordinary trigger execution +- recover more real descriptor identities from the checked-in effect table and expose their target + masks and conservative semantic previews without guessing unsupported behavior +- widen executable real import only when both descriptor identity and runtime effect semantics are + grounded enough to map into the normalized runtime path honestly +- keep condition-relative company scopes explicit until a real condition evaluator exists, instead + of silently degrading or inventing target resolution -Public-model additions for that slice: +Public-model expectations for that slice: -- compact-control summaries on packed-event records in both the save-side and runtime-side models -- runtime summary counts for compact-control-missing and unmapped-real-descriptor blockers -- trigger-kind and one-shot derivation only where the compact-control mapping is already grounded +- additional checked-in grouped-descriptor metadata entries keyed by recovered descriptor id +- more parity summaries with real descriptor labels, target masks, parameter families, and semantic + previews +- more selective real-row `decoded_actions` only where the descriptor-to-runtime mapping is + supported end to end Fixture work for that slice: -- update the parity-heavy tracked sample so the real row includes compact-control state -- regression fixtures that keep synthetic executable import and overlay-backed company-context - upgrade behavior green -- state-fragment assertions that lock the new compact-control summary and narrower import blockers +- preserve the parity-heavy tracked sample as the condition-relative blocked frontier while it now + carries recovered `Company Cash` semantics +- add overlay-backed captured fixtures whenever a new real descriptor family becomes executable +- keep synthetic harness, save-slice, and overlay paths green as the real descriptor surface widens Current local constraint: -- the local checked-in and on-disk `.gms` corpus currently exports with - `packed_event_collection_present = false`, so this slice must not depend on a newly captured real - packed-event-bearing save for acceptance +- the local checked-in and on-disk `.gms` corpus still does not provide a richer captured packed + event save set, so descriptor recovery must continue to rely on the grounded static tables and + tracked JSON artifacts until new captures exist Do not mix this slice with: -- territory-access or selected-profile parity -- placed-structure batch placement parity - shell queue/modal behavior -- broad speculative translation of real packed RT3 event rows into executable normalized effects +- territory-access or selected-profile parity +- broad condition evaluation without grounded runtime ownership +- speculative executable import for real rows whose descriptor meaning is still weak diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index b841fe7..be43779 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -70,9 +70,23 @@ "candidate_name": "AutoPlant" } ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 7 + } + ], "grouped_effect_rows": [ { + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 7 with aux [2, 3, 24, 36]", "locomotive_name": "Mikado" } ] diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json index a85a557..054efdd 100644 --- a/fixtures/runtime/packed-event-parity-save-slice.json +++ b/fixtures/runtime/packed-event-parity-save-slice.json @@ -7,7 +7,7 @@ "original_save_sha256": "parity-sample-sha256", "notes": [ "tracked as JSON save-slice document rather than raw .smp", - "preserves one unsupported row and one decoded-but-parity-only row" + "preserves one unsupported row and one semantically decoded-but-parity-only row" ] }, "save_slice": { @@ -133,6 +133,9 @@ "group_index": 0, "row_index": 0, "descriptor_id": 2, + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", "opcode": 8, "raw_scalar_value": 7, "value_byte_0x09": 1, @@ -142,16 +145,27 @@ "value_word_0x14": 24, "value_word_0x16": 36, "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 7 with aux [2, 3, 24, 36]", "locomotive_name": "Mikado", "notes": [ "grouped effect row carries locomotive-name side string" ] } ], - "decoded_actions": [], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 7 + } + ], "executable_import_ready": false, "notes": [ - "decoded from grounded real 0x4e9a row framing with compact control" + "decoded from grounded real 0x4e9a row framing", + "grouped descriptor labels and target masks come from the checked-in effect table recovered around 0x006103a0" ] } ]