Execute locomotive cost packed event descriptors

This commit is contained in:
Jan Petykiewicz 2026-04-16 11:19:53 -07:00
commit 09039d24e4
18 changed files with 785 additions and 21 deletions

View file

@ -53,16 +53,19 @@ executable too: descriptors `454..456` (`All Steam/Diesel/Electric Locos Avail.`
through checked-in metadata into keyed `world_flags`, while the wider locomotive availability/cost
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 now surface as recovered,
metadata-rich parity rows with checked-in slot labels and locomotive ids where grounded, but they
still remain non-executable.
The runtime still carries the save-owned named locomotive availability table directly too:
`RuntimeState.named_locomotive_availability`, while non-boolean availability payloads still remain
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
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`, while cargo-production and territory-access-cost rows remain
metadata-rich parity-only families until a later slice grounds an honest landing surface. Explicit
unmapped world-condition and world-descriptor frontier buckets still remain where current
checked-in metadata stops. 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.

View file

@ -4463,6 +4463,8 @@ mod tests {
let overlay_locomotive_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"../../fixtures/runtime/packed-event-locomotive-availability-overlay-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(
"../../fixtures/runtime/packed-event-world-scalar-band-parity-save-slice-fixture.json",
);
@ -4490,6 +4492,8 @@ mod tests {
);
run_runtime_summarize_fixture(&overlay_locomotive_fixture)
.expect("overlay-backed locomotive availability 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)
.expect("save-slice-backed recovered scalar-band parity fixture should summarize");
}

View file

@ -185,6 +185,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
},
@ -357,6 +358,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
},

View file

@ -158,6 +158,8 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub zero_named_locomotive_availability_count: Option<usize>,
#[serde(default)]
pub named_locomotive_cost_count: Option<usize>,
#[serde(default)]
pub special_condition_count: Option<usize>,
#[serde(default)]
pub enabled_special_condition_count: Option<usize>,
@ -775,6 +777,14 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(count) = self.named_locomotive_cost_count {
if actual.named_locomotive_cost_count != count {
mismatches.push(format!(
"named_locomotive_cost_count mismatch: expected {count}, got {}",
actual.named_locomotive_cost_count
));
}
}
if let Some(count) = self.special_condition_count {
if actual.special_condition_count != count {
mismatches.push(format!(

View file

@ -95,6 +95,7 @@ struct SaveSliceProjection {
event_runtime_records: Vec<RuntimeEventRecord>,
candidate_availability: BTreeMap<String, u32>,
named_locomotive_availability: BTreeMap<String, u32>,
named_locomotive_cost: BTreeMap<String, u32>,
special_conditions: BTreeMap<String, u32>,
}
@ -249,6 +250,7 @@ pub fn project_save_slice_to_runtime_state_import(
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
named_locomotive_availability: projection.named_locomotive_availability,
named_locomotive_cost: projection.named_locomotive_cost,
special_conditions: projection.special_conditions,
service_state: RuntimeServiceState::default(),
};
@ -308,6 +310,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import(
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
named_locomotive_availability: projection.named_locomotive_availability,
named_locomotive_cost: base_state.named_locomotive_cost.clone(),
special_conditions: projection.special_conditions,
service_state: base_state.service_state.clone(),
};
@ -636,6 +639,8 @@ fn project_save_slice_components(
}
}
let named_locomotive_cost = BTreeMap::new();
for (index, note) in save_slice.notes.iter().enumerate() {
metadata.insert(format!("save_slice.note.{index}"), note.clone());
}
@ -649,6 +654,7 @@ fn project_save_slice_components(
event_runtime_records,
candidate_availability,
named_locomotive_availability,
named_locomotive_cost,
special_conditions,
})
}
@ -972,6 +978,10 @@ fn lower_contextual_real_grouped_effects(
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_cost_effect(row, company_context)? {
effects.push(effect);
continue;
}
if let Some(effect) = lower_contextual_locomotive_availability_effect(row, company_context)?
{
effects.push(effect);
@ -1022,6 +1032,33 @@ fn lower_contextual_locomotive_availability_effect(
}))
}
fn lower_contextual_locomotive_cost_effect(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
company_context: &ImportRuntimeContext,
) -> Result<Option<RuntimeEffect>, ImportBlocker> {
if row.parameter_family.as_deref() != Some("locomotive_cost_scalar") {
return Ok(None);
}
if row.row_shape != "scalar_assignment" {
return Ok(None);
}
let value = u32::try_from(row.raw_scalar_value).ok();
let Some(value) = value else {
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::SetNamedLocomotiveCost { name, value }))
}
fn packed_record_condition_scope_import_blocker(
record: &SmpLoadedPackedEventRecordSummary,
company_context: &ImportRuntimeContext,
@ -1251,6 +1288,12 @@ fn lower_condition_targets_in_effect(
value: *value,
}
}
RuntimeEffect::SetNamedLocomotiveCost { name, value } => {
RuntimeEffect::SetNamedLocomotiveCost {
name: name.clone(),
value: *value,
}
}
RuntimeEffect::SetSpecialCondition { label, value } => RuntimeEffect::SetSpecialCondition {
label: label.clone(),
value: *value,
@ -1686,6 +1729,12 @@ fn smp_runtime_effect_to_runtime_effect(
value: *value,
})
}
RuntimeEffect::SetNamedLocomotiveCost { name, value } => {
Ok(RuntimeEffect::SetNamedLocomotiveCost {
name: name.clone(),
value: *value,
})
}
RuntimeEffect::SetSpecialCondition { label, value } => {
Ok(RuntimeEffect::SetSpecialCondition {
label: label.clone(),
@ -2247,6 +2296,7 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool {
| RuntimeEffect::DeactivatePlayer { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveCost { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
@ -2325,6 +2375,7 @@ fn runtime_effect_company_target_import_blocker(
| RuntimeEffect::SetEconomicStatusCode { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveCost { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
@ -2665,6 +2716,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
}
@ -5041,6 +5093,7 @@ mod tests {
("Locomotive 10".to_string(), 0),
("Locomotive 112".to_string(), 1),
]),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -5144,7 +5197,7 @@ mod tests {
}
#[test]
fn keeps_recovered_locomotive_cost_rows_parity_only() {
fn blocks_recovered_locomotive_cost_rows_without_catalog_context_lower_band() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
@ -5192,7 +5245,9 @@ mod tests {
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["locomotive cost rows remain metadata-only".to_string()],
notes: vec![
"scalar locomotive cost row still needs catalog context".to_string(),
],
}],
}),
notes: vec![],
@ -5200,7 +5255,289 @@ mod tests {
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-locomotive-cost-frontier",
"packed-events-locomotive-cost-frontier-lower-band",
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 blocks_recovered_locomotive_cost_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: 35,
live_record_count: 1,
live_entry_ids: vec![35],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 35,
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![
"scalar locomotive cost row still needs catalog context".to_string(),
],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-locomotive-cost-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_scalar_locomotive_cost_rows_into_named_cost_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: 1,
name: "Locomotive 1".to_string(),
},
crate::RuntimeLocomotiveCatalogEntry {
locomotive_id: 101,
name: "Locomotive 101".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::new(),
named_locomotive_cost: BTreeMap::from([
("Locomotive 1".to_string(), 100000),
("Locomotive 101".to_string(), 200000),
]),
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: 36,
live_record_count: 1,
live_entry_ids: vec![36],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 36,
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 overlay catalog context".to_string(),
],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_overlay_to_runtime_state_import(
&base_state,
&save_slice,
"overlay-locomotive-cost",
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 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 keeps_negative_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: 37,
live_record_count: 1,
live_entry_ids: vec![37],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 37,
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, -1)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["negative locomotive cost rows remain parity-only".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-negative-locomotive-cost-frontier",
None,
)
.expect("save slice should project");
@ -5398,6 +5735,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -8257,6 +8595,7 @@ mod tests {
}],
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState {
periodic_boundary_calls: 9,
@ -8426,6 +8765,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
},

View file

@ -105,6 +105,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
},

View file

@ -317,6 +317,10 @@ pub enum RuntimeEffect {
name: String,
value: bool,
},
SetNamedLocomotiveCost {
name: String,
value: u32,
},
SetSpecialCondition {
label: String,
value: u32,
@ -653,6 +657,8 @@ pub struct RuntimeState {
#[serde(default)]
pub named_locomotive_availability: BTreeMap<String, u32>,
#[serde(default)]
pub named_locomotive_cost: BTreeMap<String, u32>,
#[serde(default)]
pub special_conditions: BTreeMap<String, u32>,
#[serde(default)]
pub service_state: RuntimeServiceState,
@ -1162,6 +1168,11 @@ impl RuntimeState {
return Err("named_locomotive_availability contains an empty key".to_string());
}
}
for key in self.named_locomotive_cost.keys() {
if key.trim().is_empty() {
return Err("named_locomotive_cost contains an empty key".to_string());
}
}
for key in self.special_conditions.keys() {
if key.trim().is_empty() {
return Err("special_conditions contains an empty key".to_string());
@ -1238,6 +1249,11 @@ fn validate_runtime_effect(
return Err("name must not be empty".to_string());
}
}
RuntimeEffect::SetNamedLocomotiveCost { name, .. } => {
if name.trim().is_empty() {
return Err("name must not be empty".to_string());
}
}
RuntimeEffect::SetSpecialCondition { label, .. } => {
if label.trim().is_empty() {
return Err("label must not be empty".to_string());
@ -1459,6 +1475,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1516,6 +1533,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1572,6 +1590,7 @@ mod tests {
}],
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1638,6 +1657,7 @@ mod tests {
}],
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1735,6 +1755,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1778,6 +1799,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1821,6 +1843,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1881,6 +1904,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1931,6 +1955,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1985,6 +2010,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -2035,6 +2061,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -2091,6 +2118,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -2141,6 +2169,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -2191,6 +2220,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};

View file

@ -3489,6 +3489,7 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimeEffect::SetEconomicStatusCode { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveAvailability { .. }
| RuntimeEffect::SetNamedLocomotiveCost { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ConfiscateCompanyAssets { .. }
| RuntimeEffect::DeactivateCompany { .. }

View file

@ -495,6 +495,9 @@ fn apply_runtime_effects(
.named_locomotive_availability
.insert(name.clone(), u32::from(*value));
}
RuntimeEffect::SetNamedLocomotiveCost { name, value } => {
state.named_locomotive_cost.insert(name.clone(), *value);
}
RuntimeEffect::SetSpecialCondition { label, value } => {
state.special_conditions.insert(label.clone(), *value);
}
@ -1165,6 +1168,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
}
@ -1416,6 +1420,43 @@ mod tests {
assert_eq!(result.service_events[0].applied_effect_count, 2);
}
#[test]
fn applies_named_locomotive_cost_effects() {
let mut state = RuntimeState {
event_runtime_records: vec![RuntimeEventRecord {
record_id: 11,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: Vec::new(),
effects: vec![
RuntimeEffect::SetNamedLocomotiveCost {
name: "Big Boy".to_string(),
value: 250000,
},
RuntimeEffect::SetNamedLocomotiveCost {
name: "GP7".to_string(),
value: 175000,
},
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("named locomotive cost effects should succeed");
assert_eq!(state.named_locomotive_cost.get("Big Boy"), Some(&250000));
assert_eq!(state.named_locomotive_cost.get("GP7"), Some(&175000));
assert_eq!(result.service_events[0].applied_effect_count, 2);
}
#[test]
fn resolves_symbolic_company_targets() {
let mut state = RuntimeState {

View file

@ -76,6 +76,7 @@ pub struct RuntimeSummary {
pub zero_candidate_availability_count: usize,
pub named_locomotive_availability_count: usize,
pub zero_named_locomotive_availability_count: usize,
pub named_locomotive_cost_count: usize,
pub special_condition_count: usize,
pub enabled_special_condition_count: usize,
pub save_profile_kind: Option<String>,
@ -595,6 +596,7 @@ impl RuntimeSummary {
.values()
.filter(|value| **value == 0)
.count(),
named_locomotive_cost_count: state.named_locomotive_cost.len(),
special_condition_count: state.special_conditions.len(),
enabled_special_condition_count: state
.special_conditions
@ -817,6 +819,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -916,6 +919,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -964,6 +968,7 @@ mod tests {
("GP7".to_string(), 1),
("Mikado".to_string(), 0),
]),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -972,6 +977,46 @@ mod tests {
assert_eq!(summary.locomotive_catalog_count, 2);
assert_eq!(summary.named_locomotive_availability_count, 3);
assert_eq!(summary.zero_named_locomotive_availability_count, 2);
assert_eq!(summary.named_locomotive_cost_count, 0);
}
#[test]
fn counts_named_locomotive_cost_entries() {
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: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::from([
("Big Boy".to_string(), 250000),
("GP7".to_string(), 175000),
]),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
let summary = RuntimeSummary::from_state(&state);
assert_eq!(summary.named_locomotive_cost_count, 2);
}
#[test]
@ -1064,6 +1109,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
@ -1142,6 +1188,7 @@ mod tests {
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};

View file

@ -132,6 +132,10 @@ The highest-value next passes are now:
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
- 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, while
cargo-production and territory-access-cost rows remain metadata-rich parity-only families
- 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

View file

@ -88,15 +88,19 @@ Implemented today:
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 now surface as recovered
metadata-rich parity rows with checked-in slot labels and locomotive ids where grounded, but
execution for those scalar families remains deferred
still remain parity-only, but 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
- cargo-production `230..240` and territory-access-cost `453` rows now remain as the primary
recovered scalar-band parity families: their labels, target masks, and slot identities are
checked in, but execution for those families is still deferred until a grounded landing surface
exists
That means the next implementation work is breadth, not bootstrap. The recommended next slice is
honest landing surfaces for one or more of those recovered scalar families, plus broader real
grouped-descriptor and ordinary condition-id coverage beyond the current access, whole-game
toggle, train, player, numeric-threshold, named locomotive availability, and overlay-resolved
locomotive availability batches.
honest landing surfaces for one or more of the remaining recovered scalar families, plus broader
real grouped-descriptor and ordinary condition-id coverage beyond the current access, whole-game
toggle, train, player, numeric-threshold, named locomotive availability, and named locomotive cost
batches.
Richer runtime ownership should still be added only where a later descriptor or condition family
needs more than the current event-owned roster.

View file

@ -0,0 +1,45 @@
{
"format_version": 1,
"snapshot_id": "packed-event-locomotive-cost-overlay-base-snapshot",
"source": {
"description": "Base runtime snapshot supplying locomotive catalog context for descriptor-driven named locomotive cost import."
},
"state": {
"calendar": {
"year": 1835,
"month_slot": 1,
"phase_slot": 2,
"tick_slot": 4
},
"world_flags": {
"base.only": true
},
"metadata": {
"base.note": "preserve locomotive cost catalog context"
},
"locomotive_catalog": [
{
"locomotive_id": 1,
"name": "Locomotive 1"
},
{
"locomotive_id": 101,
"name": "Locomotive 101"
}
],
"named_locomotive_cost": {
"Locomotive 1": 100000,
"Locomotive 101": 200000
},
"event_runtime_records": [],
"candidate_availability": {},
"named_locomotive_availability": {},
"special_conditions": {},
"service_state": {
"periodic_boundary_calls": 0,
"trigger_dispatch_counts": {},
"total_event_record_services": 0,
"dirty_rerun_count": 0
}
}
}

View file

@ -0,0 +1,82 @@
{
"format_version": 1,
"fixture_id": "packed-event-locomotive-cost-overlay-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture backed by an overlay import document so recovered scalar locomotive cost descriptors execute against captured catalog context."
},
"state_import_path": "packed-event-locomotive-cost-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_cost_count": 2,
"total_event_record_service_count": 1,
"total_trigger_dispatch_count": 1
},
"expected_state_fragment": {
"metadata": {
"base.note": "preserve locomotive cost catalog context",
"save_slice.import_projection": "overlay-runtime-restore-v1"
},
"named_locomotive_cost": {
"Locomotive 1": 250000,
"Locomotive 101": 325000
},
"packed_event_collection": {
"live_entry_ids": [41],
"records": [
{
"decode_status": "parity_only",
"payload_family": "real_packed_v1",
"import_outcome": "imported",
"grouped_effect_rows": [
{
"descriptor_id": 352,
"recovered_locomotive_id": 1
},
{
"descriptor_id": 475,
"recovered_locomotive_id": 101
}
]
}
]
},
"event_runtime_records": [
{
"record_id": 41,
"service_count": 1,
"effects": [
{
"kind": "set_named_locomotive_cost",
"name": "Locomotive 1",
"value": 250000
},
{
"kind": "set_named_locomotive_cost",
"name": "Locomotive 101",
"value": 325000
}
]
}
]
}
}

View file

@ -0,0 +1,140 @@
{
"format_version": 1,
"save_slice_id": "packed-event-locomotive-cost-overlay-save-slice",
"source": {
"description": "Tracked save-slice document proving recovered scalar locomotive cost descriptors import through overlay-backed catalog context.",
"original_save_filename": "captured-locomotive-cost-overlay.gms",
"original_save_sha256": "locomotive-cost-overlay-sample-sha256",
"notes": [
"tracked as JSON save-slice document rather than raw .smp",
"recovered locomotive cost descriptors 352 and 475 import through the ordinary runtime path when overlay catalog context resolves their ids"
]
},
"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": 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 overlay catalog context"
]
}
]
},
"notes": [
"overlay-backed locomotive cost effect sample"
]
}
}

View file

@ -0,0 +1,9 @@
{
"format_version": 1,
"import_id": "packed-event-locomotive-cost-overlay",
"source": {
"description": "Overlay import document combining a tracked base snapshot with a tracked recovered locomotive-cost save slice."
},
"base_snapshot_path": "packed-event-locomotive-cost-overlay-base-snapshot.json",
"save_slice_path": "packed-event-locomotive-cost-overlay-save-slice.json"
}

View file

@ -26,11 +26,12 @@
"packed_event_imported_runtime_record_count": 0,
"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_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": 3,
"packed_event_blocked_unmapped_world_descriptor_count": 2,
"packed_event_blocked_structural_only_count": 0,
"event_runtime_record_count": 0,
"total_company_cash": 0
@ -61,7 +62,7 @@
{
"decode_status": "parity_only",
"payload_family": "real_packed_v1",
"import_outcome": "blocked_unmapped_world_descriptor",
"import_outcome": "blocked_missing_locomotive_catalog_context",
"grouped_effect_rows": [
{
"descriptor_id": 352,

View file

@ -146,7 +146,7 @@
"decoded_actions": [],
"executable_import_ready": false,
"notes": [
"recovered locomotive cost metadata is now checked in, but execution is still deferred"
"recovered locomotive cost metadata is now checked in, but scalar rows still need overlay-backed locomotive catalog context to import"
]
},
{