diff --git a/README.md b/README.md index 7b3e3fb..4c7b959 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,18 @@ recovered locomotives-page `real_packed_v1` record that lands in the explicit `blocked_unmapped_world_descriptor` bucket. The next recovered descriptor band is now partially executable too: descriptors `454..456` (`All Steam/Diesel/Electric Locos Avail.`) now lower through checked-in metadata into keyed `world_flags`, while the wider locomotive availability/cost -scalar bands remain recovered-but-parity-only until per-locomotive identity is grounded. The -runtime now carries the save-owned named locomotive availability table directly too: checked-in -save-slice documents can populate `RuntimeState.named_locomotive_availability`, and imported -runtime effects can mutate that map through the ordinary event-service path without needing full -Trainbuy or live-locomotive parity. Explicit unmapped world-condition and world-descriptor -frontier buckets still remain where current checked-in metadata stops. Shell purchase-flow and -selected-profile parity remain out of scope. Mixed supported/unsupported real rows still stay -parity-only. The PE32 hook remains useful as capture and integration tooling, but it is no longer -the main execution milestone. +scalar bands are now split more cleanly: the boolean `0/1` availability subset can import through +an overlay-backed `RuntimeState.locomotive_catalog` into +`RuntimeState.named_locomotive_availability`, while non-boolean availability payloads plus the +locomotive-cost/cargo-production/territory-access-cost families remain recovered-but-parity-only. +The runtime still carries the save-owned named locomotive availability table directly too: +checked-in save-slice documents can populate `RuntimeState.named_locomotive_availability`, and +imported runtime effects can mutate that map through the ordinary event-service path without +needing full Trainbuy or live-locomotive parity. Explicit unmapped world-condition and +world-descriptor frontier buckets still remain where current checked-in metadata stops. Shell +purchase-flow and selected-profile parity remain out of scope. Mixed supported/unsupported real +rows still stay parity-only. 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-cli/src/main.rs b/crates/rrt-cli/src/main.rs index c7f534e..f053520 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4457,6 +4457,12 @@ mod tests { let named_locomotive_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( "../../fixtures/runtime/packed-event-named-locomotive-availability-save-slice-fixture.json", ); + let missing_catalog_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice-fixture.json", + ); + let overlay_locomotive_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../fixtures/runtime/packed-event-locomotive-availability-overlay-fixture.json", + ); run_runtime_summarize_fixture(&parity_fixture) .expect("save-slice-backed parity fixture should summarize"); @@ -4476,6 +4482,11 @@ mod tests { .expect("overlay-backed mixed real-row fixture should summarize"); run_runtime_summarize_fixture(&named_locomotive_fixture) .expect("save-slice-backed named locomotive availability fixture should summarize"); + run_runtime_summarize_fixture(&missing_catalog_fixture).expect( + "save-slice-backed locomotive availability missing-catalog fixture should summarize", + ); + run_runtime_summarize_fixture(&overlay_locomotive_fixture) + .expect("overlay-backed locomotive availability fixture should summarize"); } #[test] diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index f7664ab..393995b 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -177,6 +177,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -348,6 +349,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 2039e51..cc5f764 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -76,6 +76,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub retired_train_count: Option, #[serde(default)] + pub locomotive_catalog_count: Option, + #[serde(default)] pub territory_count: Option, #[serde(default)] pub company_territory_track_count: Option, @@ -136,6 +138,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_missing_train_territory_context_count: Option, #[serde(default)] + pub packed_event_blocked_missing_locomotive_catalog_context_count: Option, + #[serde(default)] pub packed_event_blocked_confiscation_variant_count: Option, #[serde(default)] pub packed_event_blocked_retire_train_variant_count: Option, @@ -443,6 +447,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.locomotive_catalog_count { + if actual.locomotive_catalog_count != count { + mismatches.push(format!( + "locomotive_catalog_count mismatch: expected {count}, got {}", + actual.locomotive_catalog_count + )); + } + } if let Some(count) = self.territory_count { if actual.territory_count != count { mismatches.push(format!( @@ -683,6 +695,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_locomotive_catalog_context_count { + if actual.packed_event_blocked_missing_locomotive_catalog_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_locomotive_catalog_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_locomotive_catalog_context_count + )); + } + } if let Some(count) = self.packed_event_blocked_confiscation_variant_count { if actual.packed_event_blocked_confiscation_variant_count != count { mismatches.push(format!( diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index df29bbc..5b67f4f 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -117,6 +117,7 @@ struct ImportRuntimeContext { territory_name_to_id: BTreeMap, has_train_context: bool, has_train_territory_context: bool, + locomotive_catalog_names_by_id: BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -136,6 +137,7 @@ enum ImportBlocker { UnmappedWorldCondition, MissingTrainContext, MissingTrainTerritoryContext, + MissingLocomotiveCatalogContext, } impl ImportRuntimeContext { @@ -152,6 +154,7 @@ impl ImportRuntimeContext { territory_name_to_id: BTreeMap::new(), has_train_context: false, has_train_territory_context: false, + locomotive_catalog_names_by_id: BTreeMap::new(), } } @@ -199,6 +202,11 @@ impl ImportRuntimeContext { .trains .iter() .any(|train| train.territory_id.is_some()), + locomotive_catalog_names_by_id: state + .locomotive_catalog + .iter() + .map(|entry| (entry.locomotive_id, entry.name.clone())) + .collect(), } } } @@ -233,6 +241,7 @@ pub fn project_save_slice_to_runtime_state_import( players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -289,6 +298,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( players: base_state.players.clone(), selected_player_id: base_state.selected_player_id, trains: base_state.trains.clone(), + locomotive_catalog: base_state.locomotive_catalog.clone(), territories: base_state.territories.clone(), company_territory_track_piece_counts: base_state .company_territory_track_piece_counts @@ -839,6 +849,7 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp( row_shape: row.row_shape.clone(), semantic_family: row.semantic_family.clone(), semantic_preview: row.semantic_preview.clone(), + recovered_locomotive_id: row.recovered_locomotive_id, locomotive_name: row.locomotive_name.clone(), notes: row.notes.clone(), } @@ -851,10 +862,8 @@ fn smp_packed_record_to_runtime_event_record( if record.decode_status == "unsupported_framing" { return None; } - if record.payload_family == "real_packed_v1" { - if record.compact_control.is_none() || !record.executable_import_ready { - return None; - } + if record.payload_family == "real_packed_v1" && record.compact_control.is_none() { + return None; } let lowered_conditions = match lowered_record_decoded_conditions(record, company_context) { @@ -934,8 +943,14 @@ fn lowered_record_decoded_actions( let lowered_company_target = lowered_condition_true_company_target(record)?; let lowered_player_target = lowered_condition_true_player_target(record)?; - record - .decoded_actions + let base_effects = if record.payload_family != "real_packed_v1" + || record.decoded_actions.len() == record.grouped_effect_rows.len() + { + record.decoded_actions.clone() + } else { + lower_contextual_real_grouped_effects(record, company_context)? + }; + base_effects .iter() .map(|effect| { lower_condition_targets_in_effect( @@ -947,6 +962,66 @@ fn lowered_record_decoded_actions( .collect() } +fn lower_contextual_real_grouped_effects( + record: &SmpLoadedPackedEventRecordSummary, + company_context: &ImportRuntimeContext, +) -> Result, ImportBlocker> { + if record.payload_family != "real_packed_v1" || record.compact_control.is_none() { + return Err(ImportBlocker::UnmappedWorldCondition); + } + + let mut effects = Vec::with_capacity(record.grouped_effect_rows.len()); + for row in &record.grouped_effect_rows { + if let Some(effect) = lower_contextual_locomotive_availability_effect(row, company_context)? + { + effects.push(effect); + continue; + } + return Err(if real_grouped_row_is_world_state_family(row) { + ImportBlocker::UnmappedWorldCondition + } else { + ImportBlocker::UnmappedOrdinaryCondition + }); + } + + if effects.is_empty() { + return Err(ImportBlocker::UnmappedWorldCondition); + } + + Ok(effects) +} + +fn lower_contextual_locomotive_availability_effect( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, + company_context: &ImportRuntimeContext, +) -> Result, ImportBlocker> { + if row.parameter_family.as_deref() != Some("locomotive_availability_scalar") { + return Ok(None); + } + if row.row_shape != "scalar_assignment" { + return Ok(None); + } + let value = match row.raw_scalar_value { + 0 => false, + 1 => true, + _ => return Ok(None), + }; + let Some(locomotive_id) = row.recovered_locomotive_id else { + return Ok(None); + }; + let Some(name) = company_context + .locomotive_catalog_names_by_id + .get(&locomotive_id) + .cloned() + else { + return Err(ImportBlocker::MissingLocomotiveCatalogContext); + }; + Ok(Some(RuntimeEffect::SetNamedLocomotiveAvailability { + name, + value, + })) +} + fn packed_record_condition_scope_import_blocker( record: &SmpLoadedPackedEventRecordSummary, company_context: &ImportRuntimeContext, @@ -1781,6 +1856,9 @@ fn company_target_import_error_message( Some(ImportBlocker::MissingTrainTerritoryContext) => { "packed train effect requires runtime train territory context".to_string() } + Some(ImportBlocker::MissingLocomotiveCatalogContext) => { + "packed locomotive availability row requires locomotive catalog context".to_string() + } Some(ImportBlocker::MissingPlayerContext) | Some(ImportBlocker::MissingPlayerSelectionContext) | Some(ImportBlocker::MissingPlayerRoleContext) @@ -1871,6 +1949,11 @@ fn determine_packed_event_import_outcome( return "blocked_missing_compact_control".to_string(); } if !record.executable_import_ready { + if let Err(blocker) = lowered_record_decoded_actions(record, company_context) { + if blocker == ImportBlocker::MissingLocomotiveCatalogContext { + return company_target_import_outcome(blocker).to_string(); + } + } if record .grouped_effect_rows .iter() @@ -2085,6 +2168,9 @@ fn company_target_import_outcome(blocker: ImportBlocker) -> &'static str { ImportBlocker::UnmappedWorldCondition => "blocked_unmapped_world_condition", ImportBlocker::MissingTrainContext => "blocked_missing_train_context", ImportBlocker::MissingTrainTerritoryContext => "blocked_missing_train_territory_context", + ImportBlocker::MissingLocomotiveCatalogContext => { + "blocked_missing_locomotive_catalog_context" + } } } @@ -2571,6 +2657,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -2719,6 +2806,7 @@ mod tests { 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()), + recovered_locomotive_id: None, locomotive_name: Some("Mikado".to_string()), notes: vec!["grouped effect row carries locomotive-name side string".to_string()], }] @@ -2748,6 +2836,7 @@ mod tests { "Set Deactivate Company to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2772,6 +2861,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some(format!("Set Company Track Pieces Buildable to {value}")), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2801,6 +2891,7 @@ mod tests { "Set Deactivate Player to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2831,6 +2922,7 @@ mod tests { "Set Territory - Allow All to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: None, notes, } @@ -2855,6 +2947,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some(format!("Set Economic Status to {value}")), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2881,6 +2974,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some(format!("Set Limited Track Building Amount to {value}")), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2907,6 +3001,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some(format!("Set Use Wartime Cargos to {value}")), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2933,6 +3028,71 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some(format!("Set Turbo Diesel Availability to {value}")), + recovered_locomotive_id: None, + locomotive_name: None, + notes: vec![], + } + } + + fn real_locomotive_availability_row( + descriptor_id: u32, + value: i32, + ) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 0, + row_index: 0, + descriptor_id, + descriptor_label: Some("Unknown Loco Available".to_string()), + target_mask_bits: Some(0x08), + parameter_family: Some("locomotive_availability_scalar".to_string()), + opcode: 3, + raw_scalar_value: value, + value_byte_0x09: 0, + value_dword_0x0d: 0, + value_byte_0x11: 0, + value_byte_0x12: 0, + value_word_0x14: 0, + value_word_0x16: 0, + row_shape: "scalar_assignment".to_string(), + semantic_family: Some("scalar_assignment".to_string()), + semantic_preview: Some(format!("Set Unknown Loco Available to {value}")), + recovered_locomotive_id: match descriptor_id { + 241..=351 => Some(descriptor_id - 240), + 457..=474 => Some(descriptor_id - 345), + _ => None, + }, + locomotive_name: None, + notes: vec![], + } + } + + fn real_locomotive_cost_row( + descriptor_id: u32, + value: i32, + ) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 0, + row_index: 0, + descriptor_id, + descriptor_label: Some("Unknown Loco Cost".to_string()), + target_mask_bits: Some(0x08), + parameter_family: Some("locomotive_cost_scalar".to_string()), + opcode: 3, + raw_scalar_value: value, + value_byte_0x09: 0, + value_dword_0x0d: 0, + value_byte_0x11: 0, + value_byte_0x12: 0, + value_word_0x14: 0, + value_word_0x16: 0, + row_shape: "scalar_assignment".to_string(), + semantic_family: Some("scalar_assignment".to_string()), + semantic_preview: Some(format!("Set Unknown Loco Cost to {value}")), + recovered_locomotive_id: match descriptor_id { + 352..=451 => Some(descriptor_id - 351), + 475..=500 => Some(descriptor_id - 374), + _ => None, + }, locomotive_name: None, notes: vec![], } @@ -2964,6 +3124,7 @@ mod tests { "Set {label} to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -2993,6 +3154,7 @@ mod tests { "Set Confiscate All to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -3024,6 +3186,7 @@ mod tests { "Set Retire Train to {}", if enabled { "TRUE" } else { "FALSE" } )), + recovered_locomotive_id: None, locomotive_name: locomotive_name.map(ToString::to_string), notes, } @@ -3048,6 +3211,7 @@ mod tests { row_shape: "bool_toggle".to_string(), semantic_family: Some("bool_toggle".to_string()), semantic_preview: Some("Set Confiscate All to FALSE".to_string()), + recovered_locomotive_id: None, locomotive_name: None, notes: vec![], } @@ -4667,6 +4831,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some("Set Unknown Loco Available to 42".to_string()), + recovered_locomotive_id: Some(10), locomotive_name: None, notes: vec![], }], @@ -4701,6 +4866,295 @@ mod tests { ); } + #[test] + fn blocks_boolean_locomotive_availability_rows_without_catalog_context() { + 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, + named_locomotive_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: 32, + live_record_count: 1, + live_entry_ids: vec![32], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 32, + payload_offset: Some(0x7202), + payload_len: Some(96), + 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(real_compact_control()), + text_bands: vec![], + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_locomotive_availability_row(250, 1)], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec![ + "boolean locomotive availability row still needs catalog context" + .to_string(), + ], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-locomotive-availability-missing-catalog", + 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_missing_locomotive_catalog_context") + ); + } + + #[test] + fn overlays_boolean_locomotive_availability_rows_into_named_availability_effects() { + 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::new(), + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: Vec::new(), + locomotive_catalog: vec![ + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 10, + name: "Locomotive 10".to_string(), + }, + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 112, + name: "Locomotive 112".to_string(), + }, + ], + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::from([ + ("Locomotive 10".to_string(), 0), + ("Locomotive 112".to_string(), 1), + ]), + 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, + named_locomotive_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: 33, + live_record_count: 1, + live_entry_ids: vec![33], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 33, + payload_offset: Some(0x7202), + payload_len: Some(120), + 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(real_compact_control()), + text_bands: vec![], + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![2, 0, 0, 0], + grouped_effect_rows: vec![ + real_locomotive_availability_row(250, 1), + real_locomotive_availability_row(457, 0), + ], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec![ + "boolean locomotive availability rows use overlay catalog context" + .to_string(), + ], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "overlay-locomotive-availability", + 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("overlay-imported locomotive availability record should run"); + + assert_eq!( + import + .state + .named_locomotive_availability + .get("Locomotive 10"), + Some(&1) + ); + assert_eq!( + import + .state + .named_locomotive_availability + .get("Locomotive 112"), + Some(&0) + ); + } + + #[test] + fn keeps_recovered_locomotive_cost_rows_parity_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, + named_locomotive_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: 34, + live_record_count: 1, + live_entry_ids: vec![34], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 34, + payload_offset: Some(0x7202), + payload_len: Some(96), + 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(real_compact_control()), + text_bands: vec![], + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_locomotive_cost_row(352, 250000)], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec!["locomotive cost rows remain metadata-only".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-locomotive-cost-frontier", + 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_unmapped_world_descriptor") + ); + } + #[test] fn overlays_real_company_cash_descriptor_into_executable_runtime_record() { let base_state = RuntimeState { @@ -4729,6 +5183,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -4813,6 +5268,7 @@ mod tests { semantic_preview: Some( "Set Company Cash to 250 with aux [2, 3, 24, 36]".to_string(), ), + recovered_locomotive_id: None, locomotive_name: Some("Mikado".to_string()), notes: vec![ "grouped effect row carries locomotive-name side string".to_string(), @@ -6353,6 +6809,7 @@ mod tests { row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), semantic_preview: Some("Set Turbo Diesel Availability to 1".to_string()), + recovered_locomotive_id: None, locomotive_name: None, notes: vec!["checked-in whole-game grouped-effect sample".to_string()], }], @@ -7575,6 +8032,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -7753,6 +8211,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 57678e3..a9e4f89 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -38,7 +38,7 @@ pub use runtime::{ RuntimeCompany, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess, RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator, - RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeLocomotiveCatalogEntry, RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index c61ee0f..0b14e3c 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -97,6 +97,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 238d31e..76e6460 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -107,6 +107,12 @@ pub struct RuntimeTrain { pub retired: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeLocomotiveCatalogEntry { + pub locomotive_id: u32, + pub name: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeCompanyTarget { @@ -512,6 +518,8 @@ pub struct RuntimePackedEventGroupedEffectRowSummary { #[serde(default)] pub semantic_preview: Option, #[serde(default)] + pub recovered_locomotive_id: Option, + #[serde(default)] pub locomotive_name: Option, #[serde(default)] pub notes: Vec, @@ -629,6 +637,8 @@ pub struct RuntimeState { #[serde(default)] pub trains: Vec, #[serde(default)] + pub locomotive_catalog: Vec, + #[serde(default)] pub territories: Vec, #[serde(default)] pub company_territory_track_piece_counts: Vec, @@ -756,6 +766,28 @@ impl RuntimeState { )); } } + let mut seen_locomotive_ids = BTreeSet::new(); + let mut seen_locomotive_names = BTreeSet::new(); + for entry in &self.locomotive_catalog { + if !seen_locomotive_ids.insert(entry.locomotive_id) { + return Err(format!( + "duplicate locomotive_catalog.locomotive_id {}", + entry.locomotive_id + )); + } + if entry.name.trim().is_empty() { + return Err(format!( + "locomotive_catalog entry {} has an empty name", + entry.locomotive_id + )); + } + if !seen_locomotive_names.insert(entry.name.clone()) { + return Err(format!( + "duplicate locomotive_catalog.name {:?}", + entry.name + )); + } + } for entry in &self.company_territory_track_piece_counts { if !seen_company_ids.contains(&entry.company_id) { return Err(format!( @@ -1419,6 +1451,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1475,6 +1508,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1517,6 +1551,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1572,6 +1607,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1627,6 +1663,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1733,6 +1770,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1775,6 +1813,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1834,6 +1873,7 @@ mod tests { retired: false, }, ], + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1883,6 +1923,7 @@ mod tests { active: true, retired: false, }], + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1932,6 +1973,7 @@ mod tests { active: true, retired: false, }], + locomotive_catalog: Vec::new(), territories: vec![RuntimeTerritory { territory_id: 1, name: Some("Appalachia".to_string()), @@ -1985,6 +2027,7 @@ mod tests { active: true, retired: true, }], + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -2027,6 +2070,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: vec![RuntimeTerritory { territory_id: 7, name: Some("Appalachia".to_string()), @@ -2082,6 +2126,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: vec![RuntimeTerritory { territory_id: 7, name: Some("Appalachia".to_string()), @@ -2131,6 +2176,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: vec![RuntimeTerritory { territory_id: 7, name: Some("Appalachia".to_string()), diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index f6a6001..f8629c4 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -1674,6 +1674,8 @@ pub struct SmpLoadedPackedEventGroupedEffectRowSummary { #[serde(default)] pub semantic_preview: Option, #[serde(default)] + pub recovered_locomotive_id: Option, + #[serde(default)] pub locomotive_name: Option, #[serde(default)] pub notes: Vec, @@ -2709,6 +2711,7 @@ fn parse_real_grouped_effect_row_summary( value_word_0x14, value_word_0x16, )), + recovered_locomotive_id: recovered_locomotive_availability_loco_id(descriptor_id), locomotive_name, notes, }) @@ -9252,6 +9255,43 @@ mod tests { assert!(!metadata.executable_in_runtime); } + #[test] + fn looks_up_upper_band_recovered_locomotive_availability_descriptor_metadata() { + let metadata = + real_grouped_effect_descriptor_metadata(457).expect("descriptor metadata should exist"); + + assert_eq!(metadata.label, "Unknown Loco Available"); + assert_eq!(metadata.target_mask_bits, 0x08); + assert_eq!(metadata.parameter_family, "locomotive_availability_scalar"); + assert_eq!(recovered_locomotive_availability_loco_id(457), Some(112)); + } + + #[test] + fn parses_recovered_locomotive_availability_row_with_structured_locomotive_id() { + let row_bytes = build_real_grouped_effect_row(RealGroupedEffectRowSpec { + descriptor_id: 250, + raw_scalar_value: 1, + opcode: 3, + 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, + }); + + let row = parse_real_grouped_effect_row_summary(&row_bytes, 0, 0, None) + .expect("row should parse"); + + assert_eq!(row.descriptor_id, 250); + assert_eq!(row.recovered_locomotive_id, Some(10)); + assert_eq!( + row.parameter_family.as_deref(), + Some("locomotive_availability_scalar") + ); + } + #[test] fn looks_up_recovered_locomotive_policy_descriptor_metadata() { let metadata = diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 522fd78..be893b7 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -1157,6 +1157,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index c025d9b..0bb3661 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -35,6 +35,7 @@ pub struct RuntimeSummary { pub train_count: usize, pub active_train_count: usize, pub retired_train_count: usize, + pub locomotive_catalog_count: usize, pub territory_count: usize, pub company_territory_track_count: usize, pub packed_event_collection_present: bool, @@ -65,6 +66,7 @@ pub struct RuntimeSummary { pub packed_event_blocked_territory_access_scope_count: usize, pub packed_event_blocked_missing_train_context_count: usize, pub packed_event_blocked_missing_train_territory_context_count: usize, + pub packed_event_blocked_missing_locomotive_catalog_context_count: usize, pub packed_event_blocked_confiscation_variant_count: usize, pub packed_event_blocked_retire_train_variant_count: usize, pub packed_event_blocked_retire_train_scope_count: usize, @@ -165,6 +167,7 @@ impl RuntimeSummary { train_count: state.trains.len(), active_train_count: state.trains.iter().filter(|train| train.active).count(), retired_train_count: state.trains.iter().filter(|train| train.retired).count(), + locomotive_catalog_count: state.locomotive_catalog.len(), territory_count: state.territories.len(), company_territory_track_count: state.company_territory_track_piece_counts.len(), packed_event_collection_present: state.packed_event_collection.is_some(), @@ -513,6 +516,20 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_locomotive_catalog_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_locomotive_catalog_context") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_confiscation_variant_count: state .packed_event_collection .as_ref() @@ -642,6 +659,16 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: vec![ + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 10, + name: "Locomotive 10".to_string(), + }, + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 112, + name: "Locomotive 112".to_string(), + }, + ], territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -872,6 +899,16 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: vec![ + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 10, + name: "Locomotive 10".to_string(), + }, + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 112, + name: "Locomotive 112".to_string(), + }, + ], territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -906,6 +943,16 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: vec![ + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 10, + name: "Locomotive 10".to_string(), + }, + crate::RuntimeLocomotiveCatalogEntry { + locomotive_id: 112, + name: "Locomotive 112".to_string(), + }, + ], territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -922,6 +969,7 @@ mod tests { }; let summary = RuntimeSummary::from_state(&state); + assert_eq!(summary.locomotive_catalog_count, 2); assert_eq!(summary.named_locomotive_availability_count, 3); assert_eq!(summary.zero_named_locomotive_availability_count, 2); } @@ -944,6 +992,7 @@ mod tests { players: Vec::new(), selected_player_id: None, trains: Vec::new(), + locomotive_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -1029,4 +1078,78 @@ mod tests { 1 ); } + + #[test] + fn counts_missing_locomotive_catalog_context_frontier() { + 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(), + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: Vec::new(), + locomotive_catalog: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: 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: 1, + live_record_count: 1, + live_entry_ids: vec![1], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![RuntimePackedEventRecordSummary { + record_index: 0, + live_entry_id: 1, + payload_offset: Some(0x7202), + payload_len: Some(96), + 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: None, + compact_control: None, + text_bands: Vec::new(), + standalone_condition_row_count: 0, + standalone_condition_rows: Vec::new(), + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: Vec::new(), + grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), + decoded_actions: Vec::new(), + executable_import_ready: false, + import_outcome: Some("blocked_missing_locomotive_catalog_context".to_string()), + notes: Vec::new(), + }], + }), + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + let summary = RuntimeSummary::from_state(&state); + assert_eq!( + summary.packed_event_blocked_missing_locomotive_catalog_context_count, + 1 + ); + } } diff --git a/docs/README.md b/docs/README.md index ed8b35d..cc99fbe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -127,10 +127,10 @@ The highest-value next passes are now: descriptors `454..456` (`All Steam/Diesel/Electric Locos Avail.`) now lower through checked-in metadata into keyed `world_flags`, while the wider locomotive availability/cost scalar bands remain recovered-but-parity-only until per-locomotive identity is grounded -- the runtime now also carries the save-owned named locomotive availability table directly: - checked-in save-slice documents can populate `RuntimeState.named_locomotive_availability`, and - imported runtime effects can mutate that map through the ordinary event-service path without - needing full live locomotive-pool parity +- the runtime now also carries both the save-owned named locomotive availability table and an + overlay-backed locomotive catalog context: checked-in save-slice documents can populate + `RuntimeState.named_locomotive_availability`, and boolean `0/1` availability descriptors can + lower through `RuntimeState.locomotive_catalog` into the same ordinary event-service path - keep in mind that the current local `.gms` corpus still exports with no packed event collection, so real descriptor mapping needs to stay plumbing-first until better captures exist - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 237aae1..90fd199 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -86,10 +86,14 @@ Implemented today: save-slice documents can carry the persisted `[world+0x66b6]` name table into `RuntimeState.named_locomotive_availability`, and imported runtime effects can mutate that map through the ordinary event-service path without requiring Trainbuy or live locomotive-pool parity +- the boolean `0/1` subset of the recovered locomotives-page availability bands can now import + through an overlay-backed `RuntimeState.locomotive_catalog`; non-boolean availability payloads + and the adjacent locomotive-cost/cargo-production/access-cost families remain parity-only That means the next implementation work is breadth, not bootstrap. The recommended next slice is broader real grouped-descriptor and ordinary condition-id coverage beyond the current access, -whole-game toggle, train, player, numeric-threshold, and named locomotive availability batches. +whole-game toggle, train, player, numeric-threshold, named locomotive availability, and +overlay-resolved locomotive availability batches. Richer runtime ownership should still be added only where a later descriptor or condition family needs more than the current event-owned roster. diff --git a/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice-fixture.json b/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice-fixture.json new file mode 100644 index 0000000..1512457 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice-fixture.json @@ -0,0 +1,52 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-locomotive-availability-missing-catalog-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a tracked save-slice document that leaves a boolean locomotive availability row blocked until overlay-backed catalog context is supplied." + }, + "state_save_slice_path": "packed-event-locomotive-availability-missing-catalog-save-slice.json", + "commands": [ + { + "kind": "step_count", + "steps": 1 + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "calendar_projection_is_placeholder": true, + "locomotive_catalog_count": 0, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "packed_event_parity_only_record_count": 1, + "packed_event_blocked_missing_locomotive_catalog_context_count": 1, + "event_runtime_record_count": 0, + "named_locomotive_availability_count": 0 + }, + "expected_state_fragment": { + "packed_event_collection": { + "live_entry_ids": [32], + "records": [ + { + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "import_outcome": "blocked_missing_locomotive_catalog_context", + "grouped_effect_rows": [ + { + "descriptor_id": 250, + "recovered_locomotive_id": 10, + "semantic_preview": "Set Unknown Loco Available to 1" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice.json b/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice.json new file mode 100644 index 0000000..91cbfdd --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice.json @@ -0,0 +1,101 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-locomotive-availability-missing-catalog-save-slice", + "source": { + "description": "Tracked save-slice document proving boolean locomotive availability rows stay parity-only without overlay-backed locomotive catalog context.", + "original_save_filename": "captured-locomotive-availability-missing-catalog.gms", + "original_save_sha256": "locomotive-availability-missing-catalog-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "locks the explicit missing locomotive catalog frontier for boolean availability rows" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "named_locomotive_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 32, + "live_record_count": 1, + "live_entry_ids": [32], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 32, + "payload_offset": 29186, + "payload_len": 96, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 0, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 0, 0, 0], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 250, + "descriptor_label": "Unknown Loco Available", + "target_mask_bits": 8, + "parameter_family": "locomotive_availability_scalar", + "opcode": 3, + "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, + "row_shape": "scalar_assignment", + "semantic_family": "scalar_assignment", + "semantic_preview": "Set Unknown Loco Available to 1", + "recovered_locomotive_id": 10, + "locomotive_name": null, + "notes": [] + } + ], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "boolean locomotive availability row still requires overlay-backed locomotive catalog context" + ] + } + ] + }, + "notes": [ + "locomotive availability catalog blocker sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-overlay-base-snapshot.json b/fixtures/runtime/packed-event-locomotive-availability-overlay-base-snapshot.json new file mode 100644 index 0000000..6fd03ba --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-overlay-base-snapshot.json @@ -0,0 +1,44 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-locomotive-availability-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying locomotive catalog context for descriptor-driven named locomotive availability import." + }, + "state": { + "calendar": { + "year": 1835, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 4 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "preserve locomotive catalog context" + }, + "locomotive_catalog": [ + { + "locomotive_id": 10, + "name": "Locomotive 10" + }, + { + "locomotive_id": 112, + "name": "Locomotive 112" + } + ], + "named_locomotive_availability": { + "Locomotive 10": 0, + "Locomotive 112": 1 + }, + "event_runtime_records": [], + "candidate_availability": {}, + "special_conditions": {}, + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-overlay-fixture.json b/fixtures/runtime/packed-event-locomotive-availability-overlay-fixture.json new file mode 100644 index 0000000..ac34090 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-overlay-fixture.json @@ -0,0 +1,83 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-locomotive-availability-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by an overlay import document so boolean locomotive availability descriptors execute against captured catalog context." + }, + "state_import_path": "packed-event-locomotive-availability-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar": { + "year": 1835, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 4 + }, + "calendar_projection_is_placeholder": false, + "locomotive_catalog_count": 2, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "event_runtime_record_count": 1, + "named_locomotive_availability_count": 2, + "zero_named_locomotive_availability_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1 + }, + "expected_state_fragment": { + "metadata": { + "base.note": "preserve locomotive catalog context", + "save_slice.import_projection": "overlay-runtime-restore-v1" + }, + "named_locomotive_availability": { + "Locomotive 10": 1, + "Locomotive 112": 0 + }, + "packed_event_collection": { + "live_entry_ids": [33], + "records": [ + { + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "import_outcome": "imported", + "grouped_effect_rows": [ + { + "descriptor_id": 250, + "recovered_locomotive_id": 10 + }, + { + "descriptor_id": 457, + "recovered_locomotive_id": 112 + } + ] + } + ] + }, + "event_runtime_records": [ + { + "record_id": 33, + "service_count": 1, + "effects": [ + { + "kind": "set_named_locomotive_availability", + "name": "Locomotive 10", + "value": true + }, + { + "kind": "set_named_locomotive_availability", + "name": "Locomotive 112", + "value": false + } + ] + } + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-overlay-save-slice.json b/fixtures/runtime/packed-event-locomotive-availability-overlay-save-slice.json new file mode 100644 index 0000000..e0e1222 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-overlay-save-slice.json @@ -0,0 +1,123 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-locomotive-availability-overlay-save-slice", + "source": { + "description": "Tracked save-slice document proving boolean locomotive availability descriptors can import through overlay-backed catalog context.", + "original_save_filename": "captured-locomotive-availability-overlay.gms", + "original_save_sha256": "locomotive-availability-overlay-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "uses synthetic catalog names to prove descriptor-to-id lowering plus overlay-backed resolution" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "named_locomotive_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 33, + "live_record_count": 1, + "live_entry_ids": [33], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 33, + "payload_offset": 29186, + "payload_len": 120, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 0, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 0, 0, 0], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [2, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 250, + "descriptor_label": "Unknown Loco Available", + "target_mask_bits": 8, + "parameter_family": "locomotive_availability_scalar", + "opcode": 3, + "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, + "row_shape": "scalar_assignment", + "semantic_family": "scalar_assignment", + "semantic_preview": "Set Unknown Loco Available to 1", + "recovered_locomotive_id": 10, + "locomotive_name": null, + "notes": [] + }, + { + "group_index": 0, + "row_index": 1, + "descriptor_id": 457, + "descriptor_label": "Unknown Loco Available", + "target_mask_bits": 8, + "parameter_family": "locomotive_availability_scalar", + "opcode": 3, + "raw_scalar_value": 0, + "value_byte_0x09": 0, + "value_dword_0x0d": 0, + "value_byte_0x11": 0, + "value_byte_0x12": 0, + "value_word_0x14": 0, + "value_word_0x16": 0, + "row_shape": "scalar_assignment", + "semantic_family": "scalar_assignment", + "semantic_preview": "Set Unknown Loco Available to 0", + "recovered_locomotive_id": 112, + "locomotive_name": null, + "notes": [] + } + ], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "boolean locomotive availability rows use overlay-backed catalog context" + ] + } + ] + }, + "notes": [ + "overlay-backed descriptor-driven named locomotive availability sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-overlay.json b/fixtures/runtime/packed-event-locomotive-availability-overlay.json new file mode 100644 index 0000000..1ca9728 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-overlay.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "import_id": "packed-event-locomotive-availability-overlay", + "source": { + "description": "Overlay import that combines a captured base snapshot with boolean locomotive availability descriptors.", + "notes": [ + "used to upgrade descriptor-driven named locomotive availability rows through overlay-backed catalog context" + ] + }, + "base_snapshot_path": "packed-event-locomotive-availability-overlay-base-snapshot.json", + "save_slice_path": "packed-event-locomotive-availability-overlay-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index 7cee2b2..fa82915 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -57,6 +57,7 @@ "parameter_family": "locomotive_availability_scalar", "semantic_family": "scalar_assignment", "semantic_preview": "Set Unknown Loco Available to 42", + "recovered_locomotive_id": 10, "row_shape": "scalar_assignment" } ] diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json index d00ddaf..9509abd 100644 --- a/fixtures/runtime/packed-event-parity-save-slice.json +++ b/fixtures/runtime/packed-event-parity-save-slice.json @@ -5,10 +5,10 @@ "description": "Tracked save-slice document representing a parity-heavy captured packed-event collection.", "original_save_filename": "captured-parity.gms", "original_save_sha256": "parity-sample-sha256", - "notes": [ - "tracked as JSON save-slice document rather than raw .smp", - "preserves one recovered-but-unmapped locomotive policy row and one semantically decoded-but-parity-only row" - ] + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "preserves one recovered-but-unmapped locomotive availability row and one semantically decoded-but-parity-only row" + ] }, "save_slice": { "file_extension_hint": "gms", @@ -80,9 +80,10 @@ "row_shape": "scalar_assignment", "semantic_family": "scalar_assignment", "semantic_preview": "Set Unknown Loco Available to 42", + "recovered_locomotive_id": 10, "locomotive_name": null, "notes": [ - "recovered locomotive availability descriptor family remains parity-only until per-locomotive identity is grounded" + "recovered locomotive availability descriptor family remains parity-only until the scalar payload is in the grounded boolean subset" ] } ], @@ -90,7 +91,7 @@ "executable_import_ready": false, "notes": [ "decoded from grounded real 0x4e9a row framing", - "recovered locomotives-page descriptor band is now checked in, but this scalar family still has no executable runtime landing surface" + "recovered locomotives-page descriptor band is now checked in, but this scalar family still needs overlay-backed locomotive catalog context and a grounded boolean scalar payload" ] }, {