diff --git a/README.md b/README.md index 755e0d4..81479af 100644 --- a/README.md +++ b/README.md @@ -51,25 +51,20 @@ 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 are now split more cleanly: the recovered locomotive availability bands can import as -full scalar overrides through an overlay-backed `RuntimeState.locomotive_catalog` into -`RuntimeState.named_locomotive_availability`, while save-slice-only imports of those rows still -block explicitly when catalog context is missing. 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. A parallel event-owned named locomotive cost map -now exists too: recovered locomotive-cost descriptors from bands `352..451` and `475..500` can -import through the same overlay-backed locomotive catalog into -`RuntimeState.named_locomotive_cost`, and the remaining recovered scalar world families now execute -too: cargo-production slots `230..240` lower into `cargo_production_overrides`, and descriptor -`453` lowers into `world_restore.territory_access_cost`. Explicit unmapped world-condition and -world-descriptor frontier buckets still remain where current checked-in metadata stops, with the -main scalar residue now being missing-catalog locomotive rows rather than unknown world-side -families. Shell purchase-flow, Trainbuy refresh, cached locomotive-rating recomputation, 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 save-native too. Raw `.smp` inspection/export reconstructs the persisted +`[world+0x66b6]` locomotive name table and derives a minimal `RuntimeState.locomotive_catalog`, so +standalone save-slice imports can now lower recovered locomotive availability and locomotive-cost +rows directly into `RuntimeState.named_locomotive_availability` and +`RuntimeState.named_locomotive_cost` without needing overlay snapshots when the save carries enough +catalog context. The remaining recovered scalar world families execute too: cargo-production slots +`230..240` lower into `cargo_production_overrides`, and descriptor `453` lowers into +`world_restore.territory_access_cost`. Explicit unmapped world-condition and world-descriptor +frontier buckets still remain where current checked-in metadata stops, and +`blocked_missing_locomotive_catalog_context` is now reserved for intentionally incomplete save-side +catalog context instead of the normal save-slice path. Shell purchase-flow, Trainbuy refresh, +cached locomotive-rating recomputation, 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 e91f81e..0fb14b6 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4460,9 +4460,14 @@ mod tests { let missing_catalog_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( "../../fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice-fixture.json", ); + let save_locomotive_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../fixtures/runtime/packed-event-locomotive-availability-save-slice-fixture.json", + ); let overlay_locomotive_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( "../../fixtures/runtime/packed-event-locomotive-availability-overlay-fixture.json", ); + let save_locomotive_cost_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-locomotive-cost-save-slice-fixture.json"); let overlay_locomotive_cost_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/runtime/packed-event-locomotive-cost-overlay-fixture.json"); let scalar_band_parity_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( @@ -4493,8 +4498,13 @@ mod tests { run_runtime_summarize_fixture(&missing_catalog_fixture).expect( "save-slice-backed locomotive availability missing-catalog fixture should summarize", ); + run_runtime_summarize_fixture(&save_locomotive_fixture).expect( + "save-slice-backed locomotive availability descriptor fixture should summarize", + ); run_runtime_summarize_fixture(&overlay_locomotive_fixture) .expect("overlay-backed locomotive availability fixture should summarize"); + run_runtime_summarize_fixture(&save_locomotive_cost_fixture) + .expect("save-slice-backed locomotive cost fixture should summarize"); run_runtime_summarize_fixture(&overlay_locomotive_cost_fixture) .expect("overlay-backed locomotive cost fixture should summarize"); run_runtime_summarize_fixture(&scalar_band_parity_fixture) @@ -4526,6 +4536,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: None, notes: vec!["exported for test".to_string()], diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index ad4020f..53f13e1 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -264,6 +264,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: None, notes: vec![], @@ -381,6 +382,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some( rrt_runtime::SmpLoadedEventRuntimeCollectionSummary { diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 1f5385f..a1708a1 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -7,7 +7,7 @@ use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapsh use crate::{ CalendarPoint, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeCondition, RuntimeEffect, RuntimeEventRecord, - RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, + RuntimeEventRecordTemplate, RuntimeLocomotiveCatalogEntry, RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, @@ -95,6 +95,7 @@ struct SaveSliceProjection { event_runtime_records: Vec, candidate_availability: BTreeMap, named_locomotive_availability: BTreeMap, + locomotive_catalog: Option>, named_locomotive_cost: BTreeMap, cargo_production_overrides: BTreeMap, special_conditions: BTreeMap, @@ -243,7 +244,7 @@ pub fn project_save_slice_to_runtime_state_import( players: Vec::new(), selected_player_id: None, trains: Vec::new(), - locomotive_catalog: Vec::new(), + locomotive_catalog: projection.locomotive_catalog.unwrap_or_default(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), @@ -305,7 +306,9 @@ 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(), + locomotive_catalog: projection + .locomotive_catalog + .unwrap_or_else(|| base_state.locomotive_catalog.clone()), territories: base_state.territories.clone(), company_territory_track_piece_counts: base_state .company_territory_track_piece_counts @@ -351,6 +354,11 @@ fn project_save_slice_components( "save_slice.named_locomotive_availability_present".to_string(), save_slice.named_locomotive_availability_table.is_some(), ); + world_flags.insert( + "save_slice.locomotive_catalog_present".to_string(), + save_slice.locomotive_catalog.is_some() + || save_slice.named_locomotive_availability_table.is_some(), + ); world_flags.insert( "save_slice.event_runtime_collection_present".to_string(), save_slice.event_runtime_collection.is_some(), @@ -441,31 +449,6 @@ fn project_save_slice_components( metadata.insert("save_slice.bridge_family".to_string(), family.clone()); } - let (packed_event_collection, event_runtime_records) = - project_packed_event_collection(save_slice, company_context)?; - if let Some(summary) = &save_slice.event_runtime_collection { - metadata.insert( - "save_slice.event_runtime_collection_source_kind".to_string(), - summary.source_kind.clone(), - ); - metadata.insert( - "save_slice.event_runtime_collection_version_hex".to_string(), - summary.packed_state_version_hex.clone(), - ); - metadata.insert( - "save_slice.event_runtime_collection_record_count".to_string(), - summary.live_record_count.to_string(), - ); - metadata.insert( - "save_slice.event_runtime_collection_decoded_record_count".to_string(), - summary.decoded_record_count.to_string(), - ); - metadata.insert( - "save_slice.event_runtime_collection_imported_runtime_record_count".to_string(), - event_runtime_records.len().to_string(), - ); - } - let save_profile = if let Some(profile) = &save_slice.profile { metadata.insert( "save_slice.profile_kind".to_string(), @@ -645,10 +628,105 @@ fn project_save_slice_components( named_locomotive_availability.insert(entry.text.clone(), entry.availability_dword); } } + let locomotive_catalog = if let Some(catalog) = &save_slice.locomotive_catalog { + metadata.insert( + "save_slice.locomotive_catalog_source_kind".to_string(), + catalog.source_kind.clone(), + ); + metadata.insert( + "save_slice.locomotive_catalog_semantic_family".to_string(), + catalog.semantic_family.clone(), + ); + metadata.insert( + "save_slice.locomotive_catalog_entry_count".to_string(), + catalog.observed_entry_count.to_string(), + ); + if let Some(entries_offset) = catalog.entries_offset { + metadata.insert( + "save_slice.locomotive_catalog_entries_offset".to_string(), + entries_offset.to_string(), + ); + } + Some( + catalog + .entries + .iter() + .map(|entry| RuntimeLocomotiveCatalogEntry { + locomotive_id: entry.locomotive_id, + name: entry.name.clone(), + }) + .collect::>(), + ) + } else if let Some(table) = &save_slice.named_locomotive_availability_table { + metadata.insert( + "save_slice.locomotive_catalog_source_kind".to_string(), + "derived-from-named-locomotive-availability-table".to_string(), + ); + metadata.insert( + "save_slice.locomotive_catalog_semantic_family".to_string(), + "scenario-save-derived-locomotive-catalog".to_string(), + ); + metadata.insert( + "save_slice.locomotive_catalog_entry_count".to_string(), + table.observed_entry_count.to_string(), + ); + if let Some(entries_offset) = table.entries_offset { + metadata.insert( + "save_slice.locomotive_catalog_entries_offset".to_string(), + entries_offset.to_string(), + ); + } + Some( + table + .entries + .iter() + .enumerate() + .map(|(index, entry)| RuntimeLocomotiveCatalogEntry { + locomotive_id: (index + 1) as u32, + name: entry.text.clone(), + }) + .collect::>(), + ) + } else { + None + }; let named_locomotive_cost = BTreeMap::new(); let cargo_production_overrides = BTreeMap::new(); + let mut packed_event_context = company_context.clone(); + if let Some(catalog) = &locomotive_catalog { + packed_event_context.locomotive_catalog_names_by_id = catalog + .iter() + .map(|entry| (entry.locomotive_id, entry.name.clone())) + .collect(); + } + + let (packed_event_collection, event_runtime_records) = + project_packed_event_collection(save_slice, &packed_event_context)?; + if let Some(summary) = &save_slice.event_runtime_collection { + metadata.insert( + "save_slice.event_runtime_collection_source_kind".to_string(), + summary.source_kind.clone(), + ); + metadata.insert( + "save_slice.event_runtime_collection_version_hex".to_string(), + summary.packed_state_version_hex.clone(), + ); + metadata.insert( + "save_slice.event_runtime_collection_record_count".to_string(), + summary.live_record_count.to_string(), + ); + metadata.insert( + "save_slice.event_runtime_collection_decoded_record_count".to_string(), + summary.decoded_record_count.to_string(), + ); + metadata.insert( + "save_slice.event_runtime_collection_imported_runtime_record_count".to_string(), + event_runtime_records.len().to_string(), + ); + } + for (index, note) in save_slice.notes.iter().enumerate() { metadata.insert(format!("save_slice.note.{index}"), note.clone()); } @@ -662,6 +740,7 @@ fn project_save_slice_components( event_runtime_records, candidate_availability, named_locomotive_availability, + locomotive_catalog, named_locomotive_cost, cargo_production_overrides, special_conditions, @@ -3242,6 +3321,32 @@ mod tests { } } + fn save_named_locomotive_table( + count: usize, + ) -> crate::SmpLoadedNamedLocomotiveAvailabilityTable { + crate::SmpLoadedNamedLocomotiveAvailabilityTable { + source_kind: "runtime-save-direct-serializer".to_string(), + semantic_family: "scenario-named-locomotive-availability-table".to_string(), + header_offset: None, + entries_offset: Some(0x7c78), + entries_end_offset: Some(0x7c78 + count * 0x41), + observed_entry_count: count, + zero_availability_count: 0, + zero_availability_names: vec![], + entries: (0..count) + .map(|index| crate::SmpRt3105SaveNameTableEntry { + index, + offset: 0x7c78 + index * 0x41, + text: format!("Locomotive {}", index + 1), + availability_dword: 1, + availability_dword_hex: "0x00000001".to_string(), + trailer_word: 1, + trailer_word_hex: "0x00000001".to_string(), + }) + .collect(), + } + } + fn real_cargo_production_row( descriptor_id: u32, value: i32, @@ -3498,6 +3603,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: None, notes: vec![], @@ -3537,6 +3643,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: None, notes: vec![], @@ -3650,6 +3757,7 @@ mod tests { ], }, ), + locomotive_catalog: None, special_conditions_table: Some(crate::SmpLoadedSpecialConditionsTable { source_kind: "save-fixed-special-conditions-range".to_string(), table_offset: 0x0d64, @@ -3903,6 +4011,11 @@ mod tests { import.state.named_locomotive_availability.get("GP7"), Some(&1) ); + assert_eq!(import.state.locomotive_catalog.len(), 2); + assert_eq!(import.state.locomotive_catalog[0].locomotive_id, 1); + assert_eq!(import.state.locomotive_catalog[0].name, "Big Boy"); + assert_eq!(import.state.locomotive_catalog[1].locomotive_id, 2); + assert_eq!(import.state.locomotive_catalog[1].name, "GP7"); assert_eq!( import.state.special_conditions.get("Disable Cargo Economy"), Some(&0) @@ -3923,6 +4036,14 @@ mod tests { .map(String::as_str), Some("2") ); + assert_eq!( + import + .state + .metadata + .get("save_slice.locomotive_catalog_source_kind") + .map(String::as_str), + Some("derived-from-named-locomotive-availability-table") + ); assert_eq!( import .state @@ -3961,6 +4082,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4075,6 +4197,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4167,6 +4290,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4277,6 +4401,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4360,6 +4485,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4493,6 +4619,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4734,6 +4861,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4810,6 +4938,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4908,6 +5037,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -4981,6 +5111,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5079,6 +5210,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5143,6 +5275,108 @@ mod tests { ); } + #[test] + fn imports_scalar_locomotive_availability_rows_with_save_derived_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: Some(save_named_locomotive_table(112)), + locomotive_catalog: 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, 42), + real_locomotive_availability_row(457, 7), + ], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec![ + "scalar locomotive availability rows use save-derived catalog context" + .to_string(), + ], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_to_runtime_state_import( + &save_slice, + "save-derived-locomotive-availability", + None, + ) + .expect("save slice should project"); + + assert_eq!(import.state.locomotive_catalog.len(), 112); + 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("save-derived locomotive availability record should run"); + + assert_eq!( + import + .state + .named_locomotive_availability + .get("Locomotive 10"), + Some(&42) + ); + assert_eq!( + import + .state + .named_locomotive_availability + .get("Locomotive 112"), + Some(&7) + ); + } + #[test] fn overlays_scalar_locomotive_availability_rows_into_named_availability_effects() { let base_state = RuntimeState { @@ -5196,6 +5430,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5297,6 +5532,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5372,6 +5608,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5435,6 +5672,101 @@ mod tests { ); } + #[test] + fn imports_scalar_locomotive_cost_rows_with_save_derived_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: Some(save_named_locomotive_table(112)), + locomotive_catalog: 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: 41, + live_record_count: 1, + live_entry_ids: vec![41], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 41, + 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_cost_row(352, 250000), + real_locomotive_cost_row(475, 325000), + ], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec![ + "scalar locomotive cost rows use save-derived catalog context".to_string(), + ], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_to_runtime_state_import( + &save_slice, + "save-derived-locomotive-cost", + None, + ) + .expect("save slice should project"); + + assert_eq!(import.state.locomotive_catalog.len(), 112); + 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("save-derived locomotive cost record should run"); + + assert_eq!( + import.state.named_locomotive_cost.get("Locomotive 1"), + Some(&250000) + ); + assert_eq!( + import.state.named_locomotive_cost.get("Locomotive 101"), + Some(&325000) + ); + } + #[test] fn overlays_scalar_locomotive_cost_rows_into_named_cost_effects() { let base_state = RuntimeState { @@ -5488,6 +5820,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5582,6 +5915,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5655,6 +5989,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5738,6 +6073,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -5863,6 +6199,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6007,6 +6344,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6110,6 +6448,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6194,6 +6533,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6299,6 +6639,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6404,6 +6745,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6499,6 +6841,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6590,6 +6933,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6689,6 +7033,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6779,6 +7124,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6851,6 +7197,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -6928,6 +7275,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7010,6 +7358,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7092,6 +7441,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7190,6 +7540,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7279,6 +7630,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7394,6 +7746,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7519,6 +7872,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7628,6 +7982,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7800,6 +8155,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7892,6 +8248,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -7980,6 +8337,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8133,6 +8491,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8275,6 +8634,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8364,6 +8724,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8480,6 +8841,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8584,6 +8946,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8729,6 +9092,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), @@ -8903,6 +9267,7 @@ mod tests { profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, + locomotive_catalog: None, special_conditions_table: None, event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index e088e01..1081e47 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -1008,6 +1008,23 @@ pub struct SmpRt3105SaveNameTableProbe { pub evidence: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105SaveNamedLocomotiveAvailabilityProbe { + pub profile_family: String, + pub source_kind: String, + pub semantic_family: String, + pub semantic_alignment: Vec, + pub entries_offset: usize, + pub entry_stride: usize, + pub entry_stride_hex: String, + pub observed_entry_count: usize, + pub zero_availability_count: usize, + pub zero_availability_names: Vec, + pub entries_end_offset: usize, + pub entries: Vec, + pub evidence: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpRt3105SaveNameTableEntry { pub index: usize, @@ -1521,6 +1538,22 @@ pub struct SmpLoadedNamedLocomotiveAvailabilityTable { pub entries: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedLocomotiveCatalogEntry { + pub locomotive_id: u32, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpLoadedLocomotiveCatalog { + pub source_kind: String, + pub semantic_family: String, + #[serde(default)] + pub entries_offset: Option, + pub observed_entry_count: usize, + pub entries: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpLoadedSpecialConditionsTable { pub source_kind: String, @@ -1694,6 +1727,8 @@ pub struct SmpLoadedSaveSlice { pub profile: Option, pub candidate_availability_table: Option, pub named_locomotive_availability_table: Option, + #[serde(default)] + pub locomotive_catalog: Option, pub special_conditions_table: Option, pub event_runtime_collection: Option, pub notes: Vec, @@ -1728,6 +1763,8 @@ pub struct SmpInspectionReport { pub rt3_105_post_span_bridge_probe: Option, pub rt3_105_save_bridge_payload_probe: Option, pub rt3_105_save_name_table_probe: Option, + pub rt3_105_save_named_locomotive_availability_probe: + Option, pub special_conditions_probe: Option, pub smp_aligned_runtime_rule_band_probe: Option, pub post_special_conditions_scalar_probe: Option, @@ -1839,6 +1876,23 @@ pub fn load_save_slice_from_report( entries: probe.entries.clone(), } }); + let named_locomotive_availability_table = report + .rt3_105_save_named_locomotive_availability_probe + .as_ref() + .map(|probe| SmpLoadedNamedLocomotiveAvailabilityTable { + source_kind: probe.source_kind.clone(), + semantic_family: probe.semantic_family.clone(), + header_offset: None, + entries_offset: Some(probe.entries_offset), + entries_end_offset: Some(probe.entries_end_offset), + observed_entry_count: probe.observed_entry_count, + zero_availability_count: probe.zero_availability_count, + zero_availability_names: probe.zero_availability_names.clone(), + entries: probe.entries.clone(), + }); + let locomotive_catalog = named_locomotive_availability_table + .as_ref() + .and_then(derive_locomotive_catalog_from_named_availability_table); let special_conditions_table = report .special_conditions_probe @@ -1861,13 +1915,40 @@ pub fn load_save_slice_from_report( bridge_family: summary.bridge_family.clone(), profile, candidate_availability_table, - named_locomotive_availability_table: None, + named_locomotive_availability_table, + locomotive_catalog, special_conditions_table, event_runtime_collection: report.event_runtime_collection_summary.clone(), notes: summary.notes.clone(), }) } +fn derive_locomotive_catalog_from_named_availability_table( + table: &SmpLoadedNamedLocomotiveAvailabilityTable, +) -> Option { + if table.entries.is_empty() { + return None; + } + + let entries = table + .entries + .iter() + .enumerate() + .map(|(index, entry)| SmpLoadedLocomotiveCatalogEntry { + locomotive_id: (index + 1) as u32, + name: entry.text.clone(), + }) + .collect::>(); + + Some(SmpLoadedLocomotiveCatalog { + source_kind: format!("{}-ordinal-catalog", table.source_kind), + semantic_family: "scenario-save-derived-locomotive-catalog".to_string(), + entries_offset: table.entries_offset, + observed_entry_count: entries.len(), + entries, + }) +} + fn parse_event_runtime_collection_summary( bytes: &[u8], container_profile: Option<&SmpContainerProfile>, @@ -3691,6 +3772,13 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm container_profile.as_ref(), rt3_105_save_bridge_payload_probe.as_ref(), ); + let rt3_105_save_named_locomotive_availability_probe = + parse_rt3_105_save_named_locomotive_availability_probe( + bytes, + file_extension_hint.as_deref(), + container_profile.as_ref(), + rt3_105_packed_profile_probe.as_ref(), + ); let special_conditions_probe = parse_special_conditions_probe( bytes, file_extension_hint.as_deref(), @@ -3825,6 +3913,7 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm rt3_105_post_span_bridge_probe, rt3_105_save_bridge_payload_probe, rt3_105_save_name_table_probe, + rt3_105_save_named_locomotive_availability_probe, special_conditions_probe, smp_aligned_runtime_rule_band_probe, post_special_conditions_scalar_probe, @@ -3870,6 +3959,8 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> Sm .to_string(), "The RT3 1.05 candidate-availability table probe decodes the fixed-width trailing name table from either the common-save bridge payload or the fixed 0x6a70..0x73c0 source range when that header validates." .to_string(), + "The RT3 1.05 save-side named locomotive availability probe scans the post-profile save region for the grounded fixed-width locomotive-name-plus-dword row family when that run is present." + .to_string(), "The post-special-conditions scalar probe captures the fixed 0x0df4..0x0f30 dword window immediately after the hidden sentinel slot, splits it into the aligned-band overlap prefix and the later tail, and records the live-object offset alignment of that tail without claiming a byte-for-byte mirror." .to_string(), "The classic rehydrate-profile probe recognizes the grounded 0x32dc -> 0x3714 -> 0x3715 progress-id sequence and captures the exact 0x108-byte block between the latter two ids when that pattern appears." @@ -5414,6 +5505,148 @@ fn parse_rt3_105_save_name_table_probe( }) } +const RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE: usize = 0x41; +const RT3_105_SAVE_NAMED_LOCOMOTIVE_MIN_ENTRY_COUNT: usize = 8; +const RT3_105_SAVE_NAMED_LOCOMOTIVE_MAX_SEARCH_SPAN: usize = 0x4000; + +fn parse_rt3_105_save_named_locomotive_availability_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + container_profile: Option<&SmpContainerProfile>, + packed_profile_probe: Option<&SmpRt3105PackedProfileProbe>, +) -> Option { + let packed_profile_probe = packed_profile_probe?; + let extension = file_extension_hint.unwrap_or(""); + let profile_family = container_profile + .map(|profile| profile.profile_family.clone()) + .unwrap_or_else(|| packed_profile_probe.profile_family.clone()); + if !matches!(extension, "gms" | "gmx") || !profile_family.contains("save-container") { + return None; + } + + let search_start = packed_profile_probe + .packed_profile_offset + .checked_add(packed_profile_probe.packed_profile_len)?; + let search_end = search_start + .checked_add(RT3_105_SAVE_NAMED_LOCOMOTIVE_MAX_SEARCH_SPAN) + .map(|end| end.min(bytes.len())) + .unwrap_or(bytes.len()); + if search_end <= search_start + RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE { + return None; + } + + let mut best_start = None; + let mut best_entries = Vec::new(); + for candidate_start in search_start..search_end { + let entries = parse_direct_named_locomotive_entries(bytes, candidate_start, search_end); + if entries.len() > best_entries.len() { + best_entries = entries; + best_start = Some(candidate_start); + } + } + + if best_entries.len() < RT3_105_SAVE_NAMED_LOCOMOTIVE_MIN_ENTRY_COUNT { + return None; + } + + let entries_offset = best_start?; + let entries_end_offset = entries_offset + .checked_add(best_entries.len() * RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE)?; + let zero_availability_names = best_entries + .iter() + .filter(|entry| entry.availability_dword == 0) + .map(|entry| entry.text.clone()) + .collect::>(); + let zero_availability_count = zero_availability_names.len(); + let source_kind = match extension { + "gms" => "save-direct-locomotive-row-run", + "gmx" => "sandbox-direct-locomotive-row-run", + _ => "direct-locomotive-row-run", + } + .to_string(); + + let observed_entry_count = best_entries.len(); + + Some(SmpRt3105SaveNamedLocomotiveAvailabilityProbe { + profile_family, + source_kind, + semantic_family: "scenario-named-locomotive-availability-table".to_string(), + semantic_alignment: vec![ + "Matches the grounded `.smp` save-side locomotive-name-plus-dword row family restored into scenario state [world+0x66b6].".to_string(), + "Entry layout is one availability dword at +0x00 followed by one fixed-width locomotive name buffer at +0x04..+0x40.".to_string(), + "The recovered row order is treated conservatively as the live locomotive ordinal order later used by locomotives-page descriptor lowering.".to_string(), + ], + entries_offset, + entry_stride: RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE, + entry_stride_hex: format!("0x{:x}", RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE), + observed_entry_count, + zero_availability_count, + zero_availability_names, + entries_end_offset, + entries: best_entries, + evidence: vec![ + format!("search span 0x{search_start:08x}..0x{search_end:08x}"), + format!("entries offset 0x{entries_offset:08x}"), + format!( + "entry stride 0x{:x}", + RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE + ), + format!("observed entry count {observed_entry_count}"), + ], + }) +} + +fn parse_direct_named_locomotive_entries( + bytes: &[u8], + start_offset: usize, + search_end: usize, +) -> Vec { + let mut entries = Vec::new(); + let mut offset = start_offset; + while offset + RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE <= bytes.len() && offset < search_end + { + let record = &bytes[offset..offset + RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE]; + let Some(nul_index) = record[4..].iter().position(|byte| *byte == 0) else { + break; + }; + let name_bytes = &record[4..4 + nul_index]; + if name_bytes.is_empty() { + break; + } + let Ok(text) = std::str::from_utf8(name_bytes) else { + break; + }; + if !is_probable_named_locomotive_label(text) { + break; + } + if record[4 + nul_index + 1..].iter().any(|byte| *byte != 0) { + break; + } + + let availability_dword = u32::from_le_bytes([record[0], record[1], record[2], record[3]]); + entries.push(SmpRt3105SaveNameTableEntry { + index: entries.len(), + offset, + text: text.to_string(), + availability_dword, + availability_dword_hex: format!("0x{availability_dword:08x}"), + trailer_word: availability_dword, + trailer_word_hex: format!("0x{availability_dword:08x}"), + }); + offset += RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE; + } + entries +} + +fn is_probable_named_locomotive_label(text: &str) -> bool { + if text.is_empty() || text.len() > 40 { + return false; + } + text.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b' ' | b'-' | b'/' | b'(' | b')' | b'.') + }) +} + fn parse_special_conditions_probe( bytes: &[u8], file_extension_hint: Option<&str>, @@ -10296,6 +10529,40 @@ mod tests { ], evidence: vec![], }; + let named_locomotive_table = SmpRt3105SaveNamedLocomotiveAvailabilityProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + source_kind: "save-direct-locomotive-row-run".to_string(), + semantic_family: "scenario-named-locomotive-availability-table".to_string(), + semantic_alignment: vec![], + entries_offset: 0x7c78, + entry_stride: RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE, + entry_stride_hex: format!("0x{:x}", RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE), + observed_entry_count: 2, + zero_availability_count: 1, + zero_availability_names: vec!["Big Boy".to_string()], + entries_end_offset: 0x7c78 + 2 * RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE, + entries: vec![ + SmpRt3105SaveNameTableEntry { + index: 0, + offset: 0x7c78, + text: "Big Boy".to_string(), + availability_dword: 0, + availability_dword_hex: "0x00000000".to_string(), + trailer_word: 0, + trailer_word_hex: "0x00000000".to_string(), + }, + SmpRt3105SaveNameTableEntry { + index: 1, + offset: 0x7cb9, + text: "GP7".to_string(), + availability_dword: 1, + availability_dword_hex: "0x00000001".to_string(), + trailer_word: 1, + trailer_word_hex: "0x00000001".to_string(), + }, + ], + evidence: vec![], + }; let bridge = SmpRt3105PostSpanBridgeProbe { profile_family: "rt3-105-save-container-v1".to_string(), bridge_family: "rt3-105-save-post-span-bridge-v1".to_string(), @@ -10315,6 +10582,8 @@ mod tests { }; report.rt3_105_packed_profile_probe = Some(packed_profile.clone()); report.rt3_105_save_name_table_probe = Some(name_table.clone()); + report.rt3_105_save_named_locomotive_availability_probe = + Some(named_locomotive_table.clone()); report.save_load_summary = build_save_load_summary( Some("gms"), Some(&SmpContainerProfile { @@ -10347,6 +10616,33 @@ mod tests { .text, "Uranium Mine" ); + assert_eq!( + slice + .named_locomotive_availability_table + .as_ref() + .expect("named locomotive availability table") + .entries[1] + .text, + "GP7" + ); + assert_eq!( + slice + .locomotive_catalog + .as_ref() + .expect("derived locomotive catalog") + .entries[0] + .name, + "Big Boy" + ); + assert_eq!( + slice + .locomotive_catalog + .as_ref() + .expect("derived locomotive catalog") + .entries[1] + .locomotive_id, + 2 + ); } #[test] @@ -10797,6 +11093,87 @@ mod tests { assert_eq!(probe.footer_progress_word_1, 0x3714); } + #[test] + fn parses_rt3_105_save_named_locomotive_availability_probe() { + let mut bytes = vec![0u8; 0x9000]; + let packed_profile_offset = 0x73c0usize; + let packed_profile_len = 0x108usize; + let entries_offset = 0x7c78usize; + let names = [ + ("Eight Wheeler 4-4-0", 1u32), + ("EP-2 Bipolar", 1u32), + ("ET22", 1u32), + ("F3", 0u32), + ("Fairlie 0-6-6-0", 1u32), + ("Firefly 2-2-2", 0u32), + ("FP45", 0u32), + ("Ge 6/6 Crocodile", 1u32), + ("GG1", 0u32), + ("GP7", 1u32), + ]; + + for (index, (name, value)) in names.iter().enumerate() { + let offset = entries_offset + index * RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE; + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + bytes[offset + 4..offset + 4 + name.len()].copy_from_slice(name.as_bytes()); + } + + let probe = parse_rt3_105_save_named_locomotive_availability_probe( + &bytes, + Some("gms"), + Some(&SmpContainerProfile { + profile_family: "rt3-105-save-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + Some(&SmpRt3105PackedProfileProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + packed_profile_offset, + packed_profile_len, + packed_profile_len_hex: "0x108".to_string(), + packed_profile_block: SmpRt3105PackedProfileBlock { + relative_len: packed_profile_len, + relative_len_hex: "0x108".to_string(), + leading_word_0: 3, + leading_word_0_hex: "0x00000003".to_string(), + trailing_zero_word_count_after_leading_word: 2, + header_flag_word_3: 1, + header_flag_word_3_hex: "0x00000001".to_string(), + map_path_offset: 0x10, + map_path: Some("Alternate USA.gmp".to_string()), + display_name_offset: 0x43, + display_name: Some("Alternate USA".to_string()), + profile_byte_0x77: 0x07, + profile_byte_0x77_hex: "0x07".to_string(), + profile_byte_0x82: 0x4d, + profile_byte_0x82_hex: "0x4d".to_string(), + profile_byte_0x97: 0, + profile_byte_0x97_hex: "0x00".to_string(), + profile_byte_0xc5: 0, + profile_byte_0xc5_hex: "0x00".to_string(), + stable_nonzero_words: vec![], + }, + ascii_runs: vec![], + }), + ) + .expect("save-side locomotive table probe should parse"); + + assert_eq!(probe.source_kind, "save-direct-locomotive-row-run"); + assert_eq!( + probe.semantic_family, + "scenario-named-locomotive-availability-table" + ); + assert_eq!(probe.entries_offset, entries_offset); + assert_eq!( + probe.entry_stride, + RT3_105_SAVE_NAMED_LOCOMOTIVE_ENTRY_STRIDE + ); + assert_eq!(probe.observed_entry_count, names.len()); + assert_eq!(probe.zero_availability_count, 4); + assert_eq!(probe.entries[0].text, "Eight Wheeler 4-4-0"); + assert_eq!(probe.entries[9].text, "GP7"); + } + #[test] fn classifies_rt3_105_alt_save_container_profile() { let shared_header = SmpSharedHeader { diff --git a/docs/README.md b/docs/README.md index 01530fe..62764ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -128,13 +128,13 @@ The highest-value next passes are now: metadata into keyed `world_flags`, while the wider locomotive availability/cost scalar bands now split cleanly between executable scalar availability/cost rows and the remaining world-side scalar families -- 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 recovered scalar availability descriptors can - lower through `RuntimeState.locomotive_catalog` into the same ordinary event-service path -- that same overlay-backed locomotive catalog now unlocks the recovered locomotive-cost bands too: - nonnegative scalar rows from descriptors `352..451` and `475..500` can lower into the new - event-owned `RuntimeState.named_locomotive_cost` map through the ordinary runtime path +- raw `.smp` inspection/export now reconstructs the persisted save-side named locomotive table and + derives a minimal locomotive catalog from its row order, so save-slice documents can carry both + `RuntimeState.named_locomotive_availability` and the catalog context needed for descriptor + lowering +- recovered scalar locomotive availability and locomotive-cost descriptors now import through that + save-native or embedded `RuntimeState.locomotive_catalog` context into the ordinary + `named_locomotive_availability` and `named_locomotive_cost` runtime maps - cargo-production `230..240` and territory-access-cost `453` now execute too through minimal world-side scalar landing surfaces: slot-indexed `cargo_production_overrides` and `world_restore.territory_access_cost` diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 712abe4..9093341 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -87,13 +87,13 @@ Implemented today: `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 recovered locomotives-page availability bands can now import as full scalar overrides - through an overlay-backed `RuntimeState.locomotive_catalog` into - `RuntimeState.named_locomotive_availability`; save-slice-only imports of those rows now fail on - the explicit `blocked_missing_locomotive_catalog_context` frontier rather than a generic - unmapped-world bucket + through `RuntimeState.locomotive_catalog` into `RuntimeState.named_locomotive_availability`; + raw `.smp` inspection/export now reconstructs the save-side locomotive row family and derives the + catalog directly into save-slice documents, so standalone save-slice imports can execute those + rows whenever the save carries enough catalog entries - the adjacent locomotive-cost bands `352..451` and `475..500` now import too through the same - overlay-backed catalog into the event-owned `RuntimeState.named_locomotive_cost` map when their - scalar payloads are nonnegative + save-native or embedded catalog into the event-owned `RuntimeState.named_locomotive_cost` map + when their scalar payloads are nonnegative - the remaining recovered scalar world families now execute as well: cargo-production `230..240` rows lower into slot-indexed `cargo_production_overrides`, and territory-access-cost descriptor `453` lowers into `world_restore.territory_access_cost` 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 index 1c95445..2664e69 100644 --- 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 @@ -3,7 +3,7 @@ "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 scalar locomotive availability row blocked until overlay-backed catalog context is supplied." + "description": "Fixture backed by a tracked save-slice document that leaves a scalar locomotive availability row blocked until save-derived or embedded catalog context is supplied." }, "state_save_slice_path": "packed-event-locomotive-availability-missing-catalog-save-slice.json", "commands": [ 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 index 313dd55..7b8006c 100644 --- a/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice.json +++ b/fixtures/runtime/packed-event-locomotive-availability-missing-catalog-save-slice.json @@ -2,7 +2,7 @@ "format_version": 1, "save_slice_id": "packed-event-locomotive-availability-missing-catalog-save-slice", "source": { - "description": "Tracked save-slice document proving scalar locomotive availability rows stay parity-only without overlay-backed locomotive catalog context.", + "description": "Tracked save-slice document proving scalar locomotive availability rows stay parity-only when the save slice lacks enough locomotive catalog context.", "original_save_filename": "captured-locomotive-availability-missing-catalog.gms", "original_save_sha256": "locomotive-availability-missing-catalog-sample-sha256", "notes": [ @@ -89,7 +89,7 @@ "decoded_actions": [], "executable_import_ready": false, "notes": [ - "scalar locomotive availability row still requires overlay-backed locomotive catalog context" + "scalar locomotive availability row still requires locomotive catalog context that this save slice does not carry" ] } ] diff --git a/fixtures/runtime/packed-event-locomotive-availability-save-slice-fixture.json b/fixtures/runtime/packed-event-locomotive-availability-save-slice-fixture.json new file mode 100644 index 0000000..397fb08 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-save-slice-fixture.json @@ -0,0 +1,53 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-locomotive-availability-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a tracked save-slice document that imports recovered scalar locomotive availability descriptors through embedded locomotive catalog context." + }, + "state_save_slice_path": "packed-event-locomotive-availability-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar_projection_is_placeholder": true, + "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, + "event_runtime_record_count": 1, + "named_locomotive_availability_count": 2, + "zero_named_locomotive_availability_count": 0, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "dirty_rerun_count": 0 + }, + "expected_state_fragment": { + "metadata": { + "save_slice.locomotive_catalog_source_kind": "save-direct-locomotive-row-run-ordinal-catalog" + }, + "named_locomotive_availability": { + "Locomotive 10": 42, + "Locomotive 112": 7 + }, + "packed_event_collection": { + "live_entry_ids": [33], + "records": [ + { + "decode_status": "parity_only", + "import_outcome": "imported" + } + ] + }, + "event_runtime_records": [ + { + "record_id": 33, + "service_count": 1 + } + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-availability-save-slice.json b/fixtures/runtime/packed-event-locomotive-availability-save-slice.json new file mode 100644 index 0000000..c4d8f44 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-availability-save-slice.json @@ -0,0 +1,133 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-locomotive-availability-save-slice", + "source": { + "description": "Tracked save-slice document proving recovered scalar locomotive availability descriptors import through embedded save-native locomotive catalog context.", + "original_save_filename": "captured-locomotive-availability-save-slice.gms", + "original_save_sha256": "locomotive-availability-save-slice-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "uses embedded save-native locomotive catalog entries to prove standalone save-slice execution for lower-band and upper-band locomotive availability descriptors" + ] + }, + "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, + "locomotive_catalog": { + "source_kind": "save-direct-locomotive-row-run-ordinal-catalog", + "semantic_family": "scenario-save-derived-locomotive-catalog", + "entries_offset": 31864, + "observed_entry_count": 2, + "entries": [ + { "locomotive_id": 10, "name": "Locomotive 10" }, + { "locomotive_id": 112, "name": "Locomotive 112" } + ] + }, + "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": 1, + "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": 42, + "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 42", + "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": 7, + "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 7", + "recovered_locomotive_id": 112, + "locomotive_name": null, + "notes": [] + } + ], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "scalar locomotive availability rows use save-native catalog context" + ] + } + ] + }, + "notes": [ + "save-slice-backed locomotive availability effect sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-cost-save-slice-fixture.json b/fixtures/runtime/packed-event-locomotive-cost-save-slice-fixture.json new file mode 100644 index 0000000..22f6e87 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-cost-save-slice-fixture.json @@ -0,0 +1,52 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-locomotive-cost-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by a tracked save-slice document that imports recovered scalar locomotive cost descriptors through embedded locomotive catalog context." + }, + "state_save_slice_path": "packed-event-locomotive-cost-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar_projection_is_placeholder": true, + "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, + "event_runtime_record_count": 1, + "named_locomotive_cost_count": 2, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "dirty_rerun_count": 0 + }, + "expected_state_fragment": { + "metadata": { + "save_slice.locomotive_catalog_source_kind": "save-direct-locomotive-row-run-ordinal-catalog" + }, + "named_locomotive_cost": { + "Locomotive 1": 250000, + "Locomotive 101": 325000 + }, + "packed_event_collection": { + "live_entry_ids": [41], + "records": [ + { + "decode_status": "parity_only", + "import_outcome": "imported" + } + ] + }, + "event_runtime_records": [ + { + "record_id": 41, + "service_count": 1 + } + ] + } +} diff --git a/fixtures/runtime/packed-event-locomotive-cost-save-slice.json b/fixtures/runtime/packed-event-locomotive-cost-save-slice.json new file mode 100644 index 0000000..6697410 --- /dev/null +++ b/fixtures/runtime/packed-event-locomotive-cost-save-slice.json @@ -0,0 +1,150 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-locomotive-cost-save-slice", + "source": { + "description": "Tracked save-slice document proving recovered scalar locomotive cost descriptors import through embedded save-native locomotive catalog context.", + "original_save_filename": "captured-locomotive-cost-save-slice.gms", + "original_save_sha256": "locomotive-cost-save-slice-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "uses embedded save-native locomotive catalog entries to prove standalone save-slice execution for lower-band and upper-band locomotive cost descriptors" + ] + }, + "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, + "locomotive_catalog": { + "source_kind": "save-direct-locomotive-row-run-ordinal-catalog", + "semantic_family": "scenario-save-derived-locomotive-catalog", + "entries_offset": 31864, + "observed_entry_count": 2, + "entries": [ + { "locomotive_id": 1, "name": "Locomotive 1" }, + { "locomotive_id": 101, "name": "Locomotive 101" } + ] + }, + "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": 29952, + "records_tag_offset": 30208, + "close_tag_offset": 30976, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 41, + "live_record_count": 1, + "live_entry_ids": [41], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 41, + "payload_offset": 30240, + "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": [], + "negative_sentinel_scope": null, + "grouped_effect_row_counts": [2, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 352, + "descriptor_label": "Locomotive 1 Cost", + "target_mask_bits": 8, + "parameter_family": "locomotive_cost_scalar", + "opcode": 3, + "raw_scalar_value": 250000, + "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 Locomotive 1 Cost to 250000", + "recovered_locomotive_id": 1, + "locomotive_name": null, + "notes": [ + "locomotive cost descriptor maps to live locomotive id 1" + ] + }, + { + "group_index": 0, + "row_index": 1, + "descriptor_id": 475, + "descriptor_label": "Locomotive 101 Cost", + "target_mask_bits": 8, + "parameter_family": "locomotive_cost_scalar", + "opcode": 3, + "raw_scalar_value": 325000, + "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 Locomotive 101 Cost to 325000", + "recovered_locomotive_id": 101, + "locomotive_name": null, + "notes": [ + "locomotive cost descriptor maps to live locomotive id 101" + ] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "set_named_locomotive_cost", + "name": "Locomotive 1", + "value": 250000 + }, + { + "kind": "set_named_locomotive_cost", + "name": "Locomotive 101", + "value": 325000 + } + ], + "executable_import_ready": false, + "notes": [ + "scalar locomotive cost rows use save-native catalog context" + ] + } + ] + }, + "notes": [ + "save-slice-backed locomotive cost effect sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index d4b684a..2bfd280 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -20,20 +20,21 @@ "tick_slot": 1 }, "calendar_projection_is_placeholder": true, + "locomotive_catalog_count": 10, "packed_event_collection_present": true, "packed_event_record_count": 2, "packed_event_decoded_record_count": 2, - "packed_event_imported_runtime_record_count": 1, + "packed_event_imported_runtime_record_count": 2, "packed_event_parity_only_record_count": 2, "packed_event_unsupported_record_count": 0, - "packed_event_blocked_missing_locomotive_catalog_context_count": 1, + "packed_event_blocked_missing_locomotive_catalog_context_count": 0, "packed_event_blocked_missing_condition_context_count": 0, "packed_event_blocked_territory_condition_scope_count": 0, "packed_event_blocked_missing_compact_control_count": 0, "packed_event_blocked_unmapped_real_descriptor_count": 0, "packed_event_blocked_unmapped_world_descriptor_count": 0, "packed_event_blocked_structural_only_count": 0, - "event_runtime_record_count": 1, + "event_runtime_record_count": 2, "total_company_cash": 0 }, "expected_state_fragment": { @@ -49,7 +50,7 @@ { "decode_status": "parity_only", "payload_family": "real_packed_v1", - "import_outcome": "blocked_missing_locomotive_catalog_context", + "import_outcome": "imported", "grouped_effect_rows": [ { "descriptor_id": 250, diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json index 80c0413..b462d31 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 recovered scalar locomotive-availability row that still needs overlay-backed catalog context and one semantically decoded imported row" + "preserves one recovered scalar locomotive-availability row that now imports through save-native locomotive catalog context and one semantically decoded imported row" ] }, "save_slice": { @@ -19,6 +19,24 @@ "bridge_family": null, "profile": null, "candidate_availability_table": null, + "locomotive_catalog": { + "source_kind": "save-direct-locomotive-row-run-ordinal-catalog", + "semantic_family": "scenario-save-derived-locomotive-catalog", + "entries_offset": 31864, + "observed_entry_count": 10, + "entries": [ + { "locomotive_id": 1, "name": "Locomotive 1" }, + { "locomotive_id": 2, "name": "Locomotive 2" }, + { "locomotive_id": 3, "name": "Locomotive 3" }, + { "locomotive_id": 4, "name": "Locomotive 4" }, + { "locomotive_id": 5, "name": "Locomotive 5" }, + { "locomotive_id": 6, "name": "Locomotive 6" }, + { "locomotive_id": 7, "name": "Locomotive 7" }, + { "locomotive_id": 8, "name": "Locomotive 8" }, + { "locomotive_id": 9, "name": "Locomotive 9" }, + { "locomotive_id": 10, "name": "Locomotive 10" } + ] + }, "special_conditions_table": null, "event_runtime_collection": { "source_kind": "packed-event-runtime-collection", @@ -34,7 +52,7 @@ "live_record_count": 2, "live_entry_ids": [3, 5], "decoded_record_count": 2, - "imported_runtime_record_count": 1, + "imported_runtime_record_count": 2, "records": [ { "record_index": 0, @@ -83,7 +101,7 @@ "recovered_locomotive_id": 10, "locomotive_name": null, "notes": [ - "recovered locomotive availability descriptor family now supports scalar payloads, but standalone save-slice import still needs overlay-backed locomotive catalog context" + "recovered locomotive availability descriptor family now imports through save-native locomotive catalog context" ] } ], @@ -91,7 +109,7 @@ "executable_import_ready": false, "notes": [ "decoded from grounded real 0x4e9a row framing", - "recovered locomotives-page descriptor band is now checked in, and this scalar family can import through named locomotive availability overrides once overlay-backed locomotive catalog context is present" + "recovered locomotives-page descriptor band is now checked in, and this scalar family now imports through named locomotive availability overrides when the save slice carries locomotive catalog context" ] }, { diff --git a/fixtures/runtime/packed-event-selective-import-overlay-fixture.json b/fixtures/runtime/packed-event-selective-import-overlay-fixture.json index 1e869d1..dbdcbe7 100644 --- a/fixtures/runtime/packed-event-selective-import-overlay-fixture.json +++ b/fixtures/runtime/packed-event-selective-import-overlay-fixture.json @@ -21,7 +21,7 @@ }, "calendar_projection_source": "base-snapshot-preserved", "calendar_projection_is_placeholder": false, - "world_flag_count": 8, + "world_flag_count": 9, "company_count": 1, "packed_event_collection_present": true, "packed_event_record_count": 2, diff --git a/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice-fixture.json index 5c03e47..7092571 100644 --- a/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice-fixture.json @@ -3,7 +3,7 @@ "fixture_id": "packed-event-world-scalar-band-parity-save-slice-fixture", "source": { "kind": "captured-runtime", - "description": "Fixture backed by a tracked save-slice document that mixes executable scalar world descriptors with one remaining missing-catalog frontier." + "description": "Fixture backed by a tracked save-slice document that mixes executable scalar world descriptors with one remaining intentionally unsupported scalar frontier." }, "state_save_slice_path": "packed-event-world-scalar-band-parity-save-slice.json", "commands": [ @@ -26,12 +26,12 @@ "packed_event_imported_runtime_record_count": 2, "packed_event_parity_only_record_count": 3, "packed_event_unsupported_record_count": 0, - "packed_event_blocked_missing_locomotive_catalog_context_count": 1, + "packed_event_blocked_missing_locomotive_catalog_context_count": 0, "packed_event_blocked_missing_condition_context_count": 0, "packed_event_blocked_territory_condition_scope_count": 0, "packed_event_blocked_missing_compact_control_count": 0, "packed_event_blocked_unmapped_real_descriptor_count": 0, - "packed_event_blocked_unmapped_world_descriptor_count": 0, + "packed_event_blocked_unmapped_world_descriptor_count": 1, "packed_event_blocked_structural_only_count": 0, "event_runtime_record_count": 2, "cargo_production_override_count": 0, @@ -63,7 +63,7 @@ { "decode_status": "parity_only", "payload_family": "real_packed_v1", - "import_outcome": "blocked_missing_locomotive_catalog_context", + "import_outcome": "blocked_unmapped_world_descriptor", "grouped_effect_rows": [ { "descriptor_id": 352, @@ -71,7 +71,7 @@ "target_mask_bits": 8, "parameter_family": "locomotive_cost_scalar", "semantic_family": "scalar_assignment", - "semantic_preview": "Set Locomotive 1 Cost to 250000", + "semantic_preview": "Set Locomotive 1 Cost to -250000", "recovered_locomotive_id": 1, "row_shape": "scalar_assignment" } diff --git a/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice.json b/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice.json index bb7b615..391e34b 100644 --- a/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice.json +++ b/fixtures/runtime/packed-event-world-scalar-band-parity-save-slice.json @@ -7,7 +7,7 @@ "original_save_sha256": "world-scalar-band-parity-sample-sha256", "notes": [ "tracked as JSON save-slice document rather than raw .smp", - "covers recovered cargo production, locomotive cost, and territory access cost families with one remaining missing-catalog frontier" + "covers recovered cargo production, locomotive cost, and territory access cost families with one remaining intentionally unsupported scalar frontier" ] }, "save_slice": { @@ -35,7 +35,7 @@ "live_record_count": 3, "live_entry_ids": [11, 12, 13], "decoded_record_count": 3, - "imported_runtime_record_count": 0, + "imported_runtime_record_count": 2, "records": [ { "record_index": 0, @@ -126,7 +126,7 @@ "target_mask_bits": 8, "parameter_family": "locomotive_cost_scalar", "opcode": 3, - "raw_scalar_value": 250000, + "raw_scalar_value": -250000, "value_byte_0x09": 0, "value_dword_0x0d": 0, "value_byte_0x11": 0, @@ -135,7 +135,7 @@ "value_word_0x16": 0, "row_shape": "scalar_assignment", "semantic_family": "scalar_assignment", - "semantic_preview": "Set Locomotive 1 Cost to 250000", + "semantic_preview": "Set Locomotive 1 Cost to -250000", "recovered_locomotive_id": 1, "locomotive_name": null, "notes": [ @@ -146,7 +146,7 @@ "decoded_actions": [], "executable_import_ready": false, "notes": [ - "recovered locomotive cost metadata is now checked in, but scalar rows still need overlay-backed locomotive catalog context to import" + "negative locomotive cost payload remains intentionally unsupported even though the descriptor family is recovered" ] }, {