Census locomotive tail blockers across local saves

This commit is contained in:
Jan Petykiewicz 2026-04-21 22:15:17 -07:00
commit cbfe0a8df9
16 changed files with 2022 additions and 319 deletions

View file

@ -14,6 +14,11 @@ pub(super) fn parse_scan_command(
root_path: root_path.into(),
})
}
[subcommand, root_path] if subcommand == "scan-locomotive-catalog-tail" => {
Ok(ScanCommand::ScanLocomotiveCatalogTail {
root_path: root_path.into(),
})
}
[subcommand, root_path] if subcommand == "scan-special-conditions" => {
Ok(ScanCommand::ScanSpecialConditions {
root_path: root_path.into(),

View file

@ -1,8 +1,8 @@
use crate::app::command::ScanCommand;
use crate::app::runtime_scan::{
scan_aligned_runtime_rule_band, scan_candidate_table_headers, scan_candidate_table_named_runs,
scan_post_special_conditions_scalars, scan_post_special_conditions_tail,
scan_recipe_book_lines, scan_special_conditions,
scan_locomotive_catalog_tail, scan_post_special_conditions_scalars,
scan_post_special_conditions_tail, scan_recipe_book_lines, scan_special_conditions,
};
pub(super) fn dispatch_scan(command: ScanCommand) -> Result<(), Box<dyn std::error::Error>> {
@ -13,6 +13,9 @@ pub(super) fn dispatch_scan(command: ScanCommand) -> Result<(), Box<dyn std::err
ScanCommand::ScanCandidateTableNamedRuns { root_path } => {
scan_candidate_table_named_runs(&root_path)
}
ScanCommand::ScanLocomotiveCatalogTail { root_path } => {
scan_locomotive_catalog_tail(&root_path)
}
ScanCommand::ScanSpecialConditions { root_path } => scan_special_conditions(&root_path),
ScanCommand::ScanAlignedRuntimeRuleBand { root_path } => {
scan_aligned_runtime_rule_band(&root_path)

View file

@ -447,7 +447,8 @@ fn build_named_run_aggregates(
find_named_run_by_names(&sample.port_runs, "Port00", "Port00", 1),
find_named_run_by_names(&sample.warehouse_runs, "Warehouse00", "Warehouse00", 1),
) {
let row_pair_key = format!("{}/{}", port00_run.start_index, warehouse00_run.start_index);
let row_pair_key =
format!("{}/{}", port00_run.start_index, warehouse00_run.start_index);
*aggregates
.port00_warehouse00_row_pair_map_counts
.entry(row_pair_key.clone())

View file

@ -0,0 +1,569 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use rrt_runtime::inspect::smp::save_load::{
SmpLoadedLocomotiveCatalogEntry, SmpLoadedSaveSlice, load_save_slice_file,
};
const EXTRA_SHIPPED_ENGINE_TYPE_NAMES: [&str; 5] =
["242 A1", "Class 460", "Class A1", "Class P8", "Class QJ"];
#[derive(Debug, Clone)]
struct RuntimeLocomotiveCatalogTailScanSample {
path: String,
container_profile_family: Option<String>,
mechanism_family: String,
map_path: Option<String>,
display_name: Option<String>,
named_locomotive_table_entry_count: Option<usize>,
locomotive_catalog_entries: Vec<SmpLoadedLocomotiveCatalogEntry>,
descriptor_rows: Vec<RuntimeLocomotiveDescriptorRowHit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RuntimeLocomotiveDescriptorRowHit {
descriptor_id: u32,
descriptor_label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub(crate) struct RuntimeObservedLocomotiveCatalogEntry {
pub(crate) locomotive_id: u32,
pub(crate) name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct RuntimeLocomotiveCatalogTailScanSampleReport {
pub(crate) path: String,
pub(crate) container_profile_family: Option<String>,
pub(crate) mechanism_family: String,
pub(crate) map_path: Option<String>,
pub(crate) display_name: Option<String>,
pub(crate) named_locomotive_table_entry_count: Option<usize>,
pub(crate) locomotive_catalog_entry_count: usize,
pub(crate) tail_entries: Vec<RuntimeObservedLocomotiveCatalogEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct RuntimeLocomotiveCatalogTailCluster {
pub(crate) entry_count: usize,
pub(crate) tail_entries: Vec<RuntimeObservedLocomotiveCatalogEntry>,
pub(crate) file_count: usize,
pub(crate) sample_paths: Vec<String>,
pub(crate) map_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct RuntimeLocomotiveExtraNameCoverageSummary {
pub(crate) name: String,
pub(crate) observed_in_catalog_file_count: usize,
pub(crate) observed_ordinals: Vec<u32>,
pub(crate) sample_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct RuntimeLocomotiveDescriptorBandHitSummary {
pub(crate) row_count: usize,
pub(crate) file_count: usize,
pub(crate) descriptor_ids_present: Vec<u32>,
pub(crate) descriptor_labels_present: Vec<String>,
pub(crate) carrier_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct RuntimeLocomotiveCatalogTailCensusReport {
pub(crate) root_path: String,
pub(crate) file_count: usize,
pub(crate) files_with_named_locomotive_table_count: usize,
pub(crate) files_with_locomotive_catalog_count: usize,
pub(crate) files_with_packed_event_collection_count: usize,
pub(crate) stable_prefix_length: usize,
pub(crate) stable_prefix_last_name: Option<String>,
pub(crate) tail_cluster_count: usize,
pub(crate) tail_clusters: Vec<RuntimeLocomotiveCatalogTailCluster>,
pub(crate) extra_shipped_name_coverage: Vec<RuntimeLocomotiveExtraNameCoverageSummary>,
pub(crate) descriptor_452_hits: RuntimeLocomotiveDescriptorBandHitSummary,
pub(crate) upper_availability_band_hits: RuntimeLocomotiveDescriptorBandHitSummary,
pub(crate) upper_cost_band_hits: RuntimeLocomotiveDescriptorBandHitSummary,
pub(crate) skipped_file_count: usize,
pub(crate) samples: Vec<RuntimeLocomotiveCatalogTailScanSampleReport>,
}
pub(crate) fn scan_locomotive_catalog_tail(
root_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let mut candidate_paths = Vec::new();
collect_locomotive_tail_input_paths(root_path, &mut candidate_paths)?;
let file_count = candidate_paths.len();
let mut samples = Vec::new();
let mut skipped_file_count = 0usize;
for path in candidate_paths {
match load_locomotive_catalog_tail_scan_sample(&path) {
Ok(sample) => samples.push(sample),
Err(_) => skipped_file_count += 1,
}
}
let files_with_named_locomotive_table_count = samples
.iter()
.filter(|sample| sample.named_locomotive_table_entry_count.is_some())
.count();
let files_with_locomotive_catalog_count = samples
.iter()
.filter(|sample| !sample.locomotive_catalog_entries.is_empty())
.count();
let files_with_packed_event_collection_count = samples
.iter()
.filter(|sample| !sample.descriptor_rows.is_empty())
.count();
let stable_prefix_length = stable_catalog_prefix_length(&samples);
let stable_prefix_last_name = stable_prefix_last_name(&samples, stable_prefix_length);
let tail_clusters = build_tail_clusters(&samples, stable_prefix_length);
let extra_shipped_name_coverage = build_extra_shipped_name_coverage(&samples);
let descriptor_452_hits = build_descriptor_band_hit_summary(&samples, 452..=452);
let upper_availability_band_hits = build_descriptor_band_hit_summary(&samples, 457..=474);
let upper_cost_band_hits = build_descriptor_band_hit_summary(&samples, 475..=502);
let sample_reports = build_sample_reports(&samples, stable_prefix_length);
let report = RuntimeLocomotiveCatalogTailCensusReport {
root_path: root_path.display().to_string(),
file_count,
files_with_named_locomotive_table_count,
files_with_locomotive_catalog_count,
files_with_packed_event_collection_count,
stable_prefix_length,
stable_prefix_last_name,
tail_cluster_count: tail_clusters.len(),
tail_clusters,
extra_shipped_name_coverage,
descriptor_452_hits,
upper_availability_band_hits,
upper_cost_band_hits,
skipped_file_count,
samples: sample_reports,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn build_sample_reports(
samples: &[RuntimeLocomotiveCatalogTailScanSample],
stable_prefix_length: usize,
) -> Vec<RuntimeLocomotiveCatalogTailScanSampleReport> {
let mut reports = samples
.iter()
.map(|sample| RuntimeLocomotiveCatalogTailScanSampleReport {
path: sample.path.clone(),
container_profile_family: sample.container_profile_family.clone(),
mechanism_family: sample.mechanism_family.clone(),
map_path: sample.map_path.clone(),
display_name: sample.display_name.clone(),
named_locomotive_table_entry_count: sample.named_locomotive_table_entry_count,
locomotive_catalog_entry_count: sample.locomotive_catalog_entries.len(),
tail_entries: tail_entries(sample, stable_prefix_length),
})
.collect::<Vec<_>>();
reports.sort_by(|left, right| left.path.cmp(&right.path));
reports
}
fn stable_prefix_last_name(
samples: &[RuntimeLocomotiveCatalogTailScanSample],
stable_prefix_length: usize,
) -> Option<String> {
(stable_prefix_length != 0).then(|| {
samples
.iter()
.find_map(|sample| {
sample
.locomotive_catalog_entries
.get(stable_prefix_length - 1)
})
.map(|entry| entry.name.clone())
.expect("stable prefix entry should exist")
})
}
fn stable_catalog_prefix_length(samples: &[RuntimeLocomotiveCatalogTailScanSample]) -> usize {
let catalog_samples = samples
.iter()
.filter(|sample| !sample.locomotive_catalog_entries.is_empty())
.collect::<Vec<_>>();
if catalog_samples.is_empty() {
return 0;
}
let min_count = catalog_samples
.iter()
.map(|sample| sample.locomotive_catalog_entries.len())
.min()
.unwrap_or(0);
let mut stable_prefix_length = 0usize;
for index in 0..min_count {
let first_name = &catalog_samples[0].locomotive_catalog_entries[index].name;
if catalog_samples
.iter()
.all(|sample| sample.locomotive_catalog_entries[index].name == *first_name)
{
stable_prefix_length += 1;
} else {
break;
}
}
stable_prefix_length
}
fn build_tail_clusters(
samples: &[RuntimeLocomotiveCatalogTailScanSample],
stable_prefix_length: usize,
) -> Vec<RuntimeLocomotiveCatalogTailCluster> {
let mut grouped = BTreeMap::<
Vec<RuntimeObservedLocomotiveCatalogEntry>,
Vec<&RuntimeLocomotiveCatalogTailScanSample>,
>::new();
for sample in samples
.iter()
.filter(|sample| !sample.locomotive_catalog_entries.is_empty())
{
grouped
.entry(tail_entries(sample, stable_prefix_length))
.or_default()
.push(sample);
}
let mut clusters = grouped
.into_iter()
.map(|(tail_entries, samples)| {
let mut sample_paths = samples
.iter()
.map(|sample| sample.path.clone())
.collect::<Vec<_>>();
let mut map_paths = samples
.iter()
.filter_map(|sample| sample.map_path.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
sample_paths.sort();
map_paths.sort();
RuntimeLocomotiveCatalogTailCluster {
entry_count: stable_prefix_length + tail_entries.len(),
tail_entries,
file_count: samples.len(),
sample_paths,
map_paths,
}
})
.collect::<Vec<_>>();
clusters.sort_by(|left, right| {
left.tail_entries
.cmp(&right.tail_entries)
.then(left.entry_count.cmp(&right.entry_count))
});
clusters
}
fn tail_entries(
sample: &RuntimeLocomotiveCatalogTailScanSample,
stable_prefix_length: usize,
) -> Vec<RuntimeObservedLocomotiveCatalogEntry> {
sample
.locomotive_catalog_entries
.iter()
.skip(stable_prefix_length)
.map(|entry| RuntimeObservedLocomotiveCatalogEntry {
locomotive_id: entry.locomotive_id,
name: entry.name.clone(),
})
.collect()
}
fn build_extra_shipped_name_coverage(
samples: &[RuntimeLocomotiveCatalogTailScanSample],
) -> Vec<RuntimeLocomotiveExtraNameCoverageSummary> {
EXTRA_SHIPPED_ENGINE_TYPE_NAMES
.into_iter()
.map(|name| {
let mut observed_ordinals = BTreeSet::new();
let mut sample_paths = Vec::new();
for sample in samples {
let matching_ordinals = sample
.locomotive_catalog_entries
.iter()
.filter(|entry| entry.name == name)
.map(|entry| entry.locomotive_id)
.collect::<Vec<_>>();
if matching_ordinals.is_empty() {
continue;
}
observed_ordinals.extend(matching_ordinals);
sample_paths.push(sample.path.clone());
}
sample_paths.sort();
RuntimeLocomotiveExtraNameCoverageSummary {
name: name.to_string(),
observed_in_catalog_file_count: sample_paths.len(),
observed_ordinals: observed_ordinals.into_iter().collect(),
sample_paths,
}
})
.collect()
}
fn build_descriptor_band_hit_summary(
samples: &[RuntimeLocomotiveCatalogTailScanSample],
descriptor_ids: std::ops::RangeInclusive<u32>,
) -> RuntimeLocomotiveDescriptorBandHitSummary {
let mut row_count = 0usize;
let mut carrier_paths = Vec::new();
let mut descriptor_ids_present = BTreeSet::new();
let mut descriptor_labels_present = BTreeSet::new();
for sample in samples {
let matching_rows = sample
.descriptor_rows
.iter()
.filter(|row| descriptor_ids.contains(&row.descriptor_id))
.collect::<Vec<_>>();
if matching_rows.is_empty() {
continue;
}
row_count += matching_rows.len();
carrier_paths.push(sample.path.clone());
for row in matching_rows {
descriptor_ids_present.insert(row.descriptor_id);
if let Some(label) = &row.descriptor_label {
descriptor_labels_present.insert(label.clone());
}
}
}
carrier_paths.sort();
RuntimeLocomotiveDescriptorBandHitSummary {
row_count,
file_count: carrier_paths.len(),
descriptor_ids_present: descriptor_ids_present.into_iter().collect(),
descriptor_labels_present: descriptor_labels_present.into_iter().collect(),
carrier_paths,
}
}
fn load_locomotive_catalog_tail_scan_sample(
smp_path: &Path,
) -> Result<RuntimeLocomotiveCatalogTailScanSample, Box<dyn std::error::Error>> {
let save_slice = load_save_slice_file(smp_path)?;
Ok(build_locomotive_catalog_tail_scan_sample(
smp_path,
&save_slice,
))
}
fn build_locomotive_catalog_tail_scan_sample(
smp_path: &Path,
save_slice: &SmpLoadedSaveSlice,
) -> RuntimeLocomotiveCatalogTailScanSample {
RuntimeLocomotiveCatalogTailScanSample {
path: smp_path.display().to_string(),
container_profile_family: save_slice.container_profile_family.clone(),
mechanism_family: save_slice.mechanism_family.clone(),
map_path: save_slice
.profile
.as_ref()
.and_then(|profile| profile.map_path.clone()),
display_name: save_slice
.profile
.as_ref()
.and_then(|profile| profile.display_name.clone()),
named_locomotive_table_entry_count: save_slice
.named_locomotive_availability_table
.as_ref()
.map(|table| table.observed_entry_count),
locomotive_catalog_entries: save_slice
.locomotive_catalog
.as_ref()
.map(|catalog| catalog.entries.clone())
.unwrap_or_default(),
descriptor_rows: save_slice
.event_runtime_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.flat_map(|record| {
record.grouped_effect_rows.iter().map(|row| {
RuntimeLocomotiveDescriptorRowHit {
descriptor_id: row.descriptor_id,
descriptor_label: row.descriptor_label.clone(),
}
})
})
.collect()
})
.unwrap_or_default(),
}
}
fn collect_locomotive_tail_input_paths(
root_path: &Path,
out: &mut Vec<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let metadata = match fs::symlink_metadata(root_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
Err(err) => return Err(err.into()),
};
if metadata.file_type().is_symlink() {
return Ok(());
}
if root_path.is_file() {
if path_is_locomotive_tail_input(root_path) {
out.push(root_path.to_path_buf());
}
return Ok(());
}
let entries = match fs::read_dir(root_path) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_locomotive_tail_input_paths(&path, out)?;
continue;
}
if path_is_locomotive_tail_input(&path) {
out.push(path);
}
}
Ok(())
}
fn path_is_locomotive_tail_input(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "gms" | "gmx" | "smp"))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(
path: &str,
names: &[&str],
descriptor_ids: &[u32],
) -> RuntimeLocomotiveCatalogTailScanSample {
RuntimeLocomotiveCatalogTailScanSample {
path: path.to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
map_path: Some(format!("{path}.gmp")),
display_name: Some(path.to_string()),
named_locomotive_table_entry_count: Some(names.len()),
locomotive_catalog_entries: names
.iter()
.enumerate()
.map(|(index, name)| SmpLoadedLocomotiveCatalogEntry {
locomotive_id: (index + 1) as u32,
name: (*name).to_string(),
})
.collect(),
descriptor_rows: descriptor_ids
.iter()
.map(|descriptor_id| RuntimeLocomotiveDescriptorRowHit {
descriptor_id: *descriptor_id,
descriptor_label: Some(format!("Descriptor {descriptor_id}")),
})
.collect(),
}
}
#[test]
fn computes_stable_prefix_length_across_catalog_samples() {
let samples = vec![
sample("a", &["Big Boy", "VL80T", "242 A1"], &[]),
sample("b", &["Big Boy", "VL80T", "GP 35"], &[]),
];
assert_eq!(stable_catalog_prefix_length(&samples), 2);
assert_eq!(
stable_prefix_last_name(&samples, 2),
Some("VL80T".to_string())
);
}
#[test]
fn groups_tail_clusters_by_exact_tail_sequence() {
let samples = vec![
sample("a", &["Big Boy", "VL80T", "242 A1", "Class 460"], &[]),
sample("b", &["Big Boy", "VL80T", "242 A1", "Class 460"], &[]),
sample("c", &["Big Boy", "VL80T", "GP 35", "U1"], &[]),
];
let clusters = build_tail_clusters(&samples, 2);
assert_eq!(clusters.len(), 2);
let counts = clusters
.iter()
.map(|cluster| cluster.file_count)
.collect::<Vec<_>>();
assert_eq!(counts, vec![2, 1]);
}
#[test]
fn summarizes_extra_shipped_name_coverage() {
let samples = vec![
sample("a", &["Big Boy", "242 A1", "Class 460"], &[]),
sample("b", &["Big Boy", "Class QJ"], &[]),
];
let coverage = build_extra_shipped_name_coverage(&samples);
assert_eq!(coverage[0].name, "242 A1");
assert_eq!(coverage[0].observed_in_catalog_file_count, 1);
assert_eq!(coverage[0].observed_ordinals, vec![2]);
assert_eq!(coverage[4].name, "Class QJ");
assert_eq!(coverage[4].observed_in_catalog_file_count, 1);
assert_eq!(coverage[4].observed_ordinals, vec![2]);
}
#[test]
fn summarizes_descriptor_band_hits() {
let samples = vec![
sample("a", &["Big Boy"], &[452, 457, 475]),
sample("b", &["Big Boy"], &[457, 458]),
];
let descriptor_452_hits = build_descriptor_band_hit_summary(&samples, 452..=452);
let upper_availability_hits = build_descriptor_band_hit_summary(&samples, 457..=474);
assert_eq!(descriptor_452_hits.row_count, 1);
assert_eq!(descriptor_452_hits.file_count, 1);
assert_eq!(upper_availability_hits.row_count, 3);
assert_eq!(upper_availability_hits.file_count, 2);
assert_eq!(
upper_availability_hits.descriptor_ids_present,
vec![457, 458]
);
}
#[test]
fn accepts_gmx_inputs_in_locomotive_tail_scan() {
assert!(path_is_locomotive_tail_input(Path::new("save.gms")));
assert!(path_is_locomotive_tail_input(Path::new("sandbox.gmx")));
assert!(path_is_locomotive_tail_input(Path::new("fixture.smp")));
assert!(!path_is_locomotive_tail_input(Path::new("map.gmp")));
}
}

View file

@ -3,11 +3,13 @@ pub(crate) mod post_special;
mod aligned_band;
mod candidate_table;
mod locomotive_tail;
mod recipe_book;
mod special_conditions;
pub(super) use aligned_band::scan_aligned_runtime_rule_band;
pub(super) use candidate_table::{scan_candidate_table_headers, scan_candidate_table_named_runs};
pub(super) use locomotive_tail::scan_locomotive_catalog_tail;
pub(super) use post_special::{
scan_post_special_conditions_scalars, scan_post_special_conditions_tail,
};

View file

@ -1571,8 +1571,10 @@ fn blocks_scalar_locomotive_availability_rows_without_catalog_context() {
grouped_effect_rows: vec![SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 250,
descriptor_label: Some("Big Boy 4-8-8-4 Availability".to_string()),
descriptor_id: 302,
descriptor_label: Some(
"Lower-Band Locomotive Availability Slot 62".to_string(),
),
target_mask_bits: Some(0x08),
parameter_family: Some("locomotive_availability_scalar".to_string()),
grouped_target_subject: None,
@ -1587,11 +1589,13 @@ fn blocks_scalar_locomotive_availability_rows_without_catalog_context() {
value_word_0x16: 0,
row_shape: "scalar_assignment".to_string(),
semantic_family: Some("scalar_assignment".to_string()),
semantic_preview: Some("Set Big Boy 4-8-8-4 Availability to 42".to_string()),
semantic_preview: Some(
"Set Lower-Band Locomotive Availability Slot 62 to 42".to_string(),
),
recovered_cargo_slot: None,
recovered_cargo_class: None,
recovered_cargo_label: None,
recovered_locomotive_id: Some(10),
recovered_locomotive_id: Some(62),
locomotive_name: None,
notes: vec![],
}],
@ -1599,8 +1603,9 @@ fn blocks_scalar_locomotive_availability_rows_without_catalog_context() {
decoded_actions: vec![],
executable_import_ready: false,
notes: vec![
"decoded from grounded real 0x4e9a row framing".to_string(),
"scalar locomotive availability rows still need catalog context".to_string(),
"decoded from lower-tail real 0x4e9a row framing".to_string(),
"scalar lower-tail locomotive availability row still needs catalog context"
.to_string(),
],
}],
}),
@ -1609,7 +1614,7 @@ fn blocks_scalar_locomotive_availability_rows_without_catalog_context() {
let input = build_runtime_state_input_from_save_slice(
&save_slice,
"packed-events-recovered-locomotive-availability-frontier",
"packed-events-recovered-locomotive-availability-lower-tail-frontier",
None,
)
.expect("save slice should project");
@ -1737,7 +1742,7 @@ fn imports_scalar_locomotive_availability_rows_with_save_derived_catalog_context
bridge_family: None,
profile: None,
candidate_availability_table: None,
named_locomotive_availability_table: Some(save_named_locomotive_table(61)),
named_locomotive_availability_table: Some(save_named_locomotive_table(62)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
@ -1799,7 +1804,7 @@ fn imports_scalar_locomotive_availability_rows_with_save_derived_catalog_context
grouped_effect_row_counts: vec![2, 0, 0, 0],
grouped_effect_rows: vec![
real_locomotive_availability_row(250, 42),
real_locomotive_availability_row(301, 7),
real_locomotive_availability_row(302, 7),
],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
@ -1820,7 +1825,7 @@ fn imports_scalar_locomotive_availability_rows_with_save_derived_catalog_context
)
.expect("save slice should project");
assert_eq!(input.state.locomotive_catalog.len(), 61);
assert_eq!(input.state.locomotive_catalog.len(), 62);
assert_eq!(input.state.event_runtime_records.len(), 1);
assert_eq!(
input
@ -1845,11 +1850,228 @@ fn imports_scalar_locomotive_availability_rows_with_save_derived_catalog_context
Some(&42)
);
assert_eq!(
input.state.named_locomotive_availability.get("Zephyr"),
input
.state
.named_locomotive_availability
.get("Locomotive 62"),
Some(&7)
);
}
#[test]
fn imports_scalar_locomotive_availability_rows_with_dynamic_tail_catalog_context() {
let mut names = (0..58)
.map(default_save_named_locomotive_name)
.collect::<Vec<_>>();
names.extend([
"242 A1".to_string(),
"Class 460".to_string(),
"Class A1".to_string(),
"Class P8".to_string(),
"U1".to_string(),
]);
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_with_names(&names)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
world_economic_tuning_state: None,
world_finance_neighborhood_state: None,
world_locomotive_policy_state: None,
company_roster: None,
chairman_profile_table: None,
region_collection: None,
region_fixed_row_run_summary: None,
placed_structure_collection: None,
placed_structure_dynamic_side_buffer_summary: None,
special_conditions_table: None,
event_runtime_collection: Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 34,
live_record_count: 1,
live_entry_ids: vec![34],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records_with_trigger_kind: 0,
records_missing_trigger_kind: 0,
nondirect_compact_record_count: 0,
nondirect_compact_records_missing_trigger_kind: 0,
trigger_kinds_present: vec![],
control_lane_notes: vec![],
add_building_dispatch_strip_record_indexes: vec![],
add_building_dispatch_strip_descriptor_labels: vec![],
add_building_dispatch_strip_records_with_trigger_kind: 0,
add_building_dispatch_strip_records_missing_trigger_kind: 0,
add_building_dispatch_strip_row_shape_families: vec![],
add_building_dispatch_strip_signature_families: vec![],
add_building_dispatch_strip_condition_tuple_families: vec![],
add_building_dispatch_strip_signature_condition_clusters: vec![],
records: vec![SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 34,
payload_offset: Some(0x7202),
payload_len: Some(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(real_compact_control()),
text_bands: vec![],
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_locomotive_availability_row(299, 7)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec![
"save-derived locomotive availability row uses scenario-dependent tail names"
.to_string(),
],
}],
}),
notes: vec![],
};
let mut input = build_runtime_state_input_from_save_slice(
&save_slice,
"save-derived-dynamic-tail-locomotive-availability",
None,
)
.expect("save slice should project");
execute_step_command(
&mut input.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("save-derived dynamic-tail locomotive availability record should run");
assert_eq!(
input.state.named_locomotive_availability.get("242 A1"),
Some(&7)
);
}
#[test]
fn keeps_upper_band_locomotive_availability_rows_on_descriptor_parity() {
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(140)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
world_economic_tuning_state: None,
world_finance_neighborhood_state: None,
world_locomotive_policy_state: None,
company_roster: None,
chairman_profile_table: None,
region_collection: None,
region_fixed_row_run_summary: None,
placed_structure_collection: None,
placed_structure_dynamic_side_buffer_summary: None,
special_conditions_table: None,
event_runtime_collection: Some(SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 34,
live_record_count: 1,
live_entry_ids: vec![34],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records_with_trigger_kind: 0,
records_missing_trigger_kind: 0,
nondirect_compact_record_count: 0,
nondirect_compact_records_missing_trigger_kind: 0,
trigger_kinds_present: vec![],
control_lane_notes: vec![],
add_building_dispatch_strip_record_indexes: vec![],
add_building_dispatch_strip_descriptor_labels: vec![],
add_building_dispatch_strip_records_with_trigger_kind: 0,
add_building_dispatch_strip_records_missing_trigger_kind: 0,
add_building_dispatch_strip_row_shape_families: vec![],
add_building_dispatch_strip_signature_families: vec![],
add_building_dispatch_strip_condition_tuple_families: vec![],
add_building_dispatch_strip_signature_condition_clusters: vec![],
records: vec![SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 34,
payload_offset: Some(0x7202),
payload_len: Some(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(real_compact_control()),
text_bands: vec![],
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_locomotive_availability_row(457, 1)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec![
"upper-band locomotive availability row remains descriptor parity".to_string(),
],
}],
}),
notes: vec![],
};
let input = build_runtime_state_input_from_save_slice(
&save_slice,
"packed-events-upper-band-locomotive-availability",
None,
)
.expect("save slice should project");
assert!(input.state.event_runtime_records.is_empty());
assert_eq!(
input
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_evidence_blocked_descriptor")
);
}
#[test]
fn overlays_scalar_locomotive_availability_rows_into_named_availability_effects() {
let base_state = RuntimeState {
@ -2099,11 +2321,13 @@ fn blocks_recovered_locomotive_cost_rows_without_catalog_context_lower_band() {
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)],
grouped_effect_rows: vec![real_locomotive_cost_row(413, 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![
"scalar lower-tail locomotive cost row still needs catalog context".to_string(),
],
}],
}),
notes: vec![],
@ -2237,7 +2461,7 @@ fn imports_scalar_locomotive_cost_rows_with_save_derived_catalog_context() {
bridge_family: None,
profile: None,
candidate_availability_table: None,
named_locomotive_availability_table: Some(save_named_locomotive_table(61)),
named_locomotive_availability_table: Some(save_named_locomotive_table(62)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
@ -2299,7 +2523,7 @@ fn imports_scalar_locomotive_cost_rows_with_save_derived_catalog_context() {
grouped_effect_row_counts: vec![2, 0, 0, 0],
grouped_effect_rows: vec![
real_locomotive_cost_row(352, 250000),
real_locomotive_cost_row(412, 325000),
real_locomotive_cost_row(413, 325000),
],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
@ -2319,7 +2543,7 @@ fn imports_scalar_locomotive_cost_rows_with_save_derived_catalog_context() {
)
.expect("save slice should project");
assert_eq!(input.state.locomotive_catalog.len(), 61);
assert_eq!(input.state.locomotive_catalog.len(), 62);
assert_eq!(input.state.event_runtime_records.len(), 1);
assert_eq!(
input
@ -2341,11 +2565,110 @@ fn imports_scalar_locomotive_cost_rows_with_save_derived_catalog_context() {
Some(&250000)
);
assert_eq!(
input.state.named_locomotive_cost.get("Zephyr"),
input.state.named_locomotive_cost.get("Locomotive 62"),
Some(&325000)
);
}
#[test]
fn keeps_upper_band_locomotive_cost_rows_on_descriptor_parity() {
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(140)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
world_economic_tuning_state: None,
world_finance_neighborhood_state: None,
world_locomotive_policy_state: None,
company_roster: None,
chairman_profile_table: None,
region_collection: None,
region_fixed_row_run_summary: None,
placed_structure_collection: None,
placed_structure_dynamic_side_buffer_summary: None,
special_conditions_table: None,
event_runtime_collection: Some(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: 42,
live_record_count: 1,
live_entry_ids: vec![42],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records_with_trigger_kind: 0,
records_missing_trigger_kind: 0,
nondirect_compact_record_count: 0,
nondirect_compact_records_missing_trigger_kind: 0,
trigger_kinds_present: vec![],
control_lane_notes: vec![],
add_building_dispatch_strip_record_indexes: vec![],
add_building_dispatch_strip_descriptor_labels: vec![],
add_building_dispatch_strip_records_with_trigger_kind: 0,
add_building_dispatch_strip_records_missing_trigger_kind: 0,
add_building_dispatch_strip_row_shape_families: vec![],
add_building_dispatch_strip_signature_families: vec![],
add_building_dispatch_strip_condition_tuple_families: vec![],
add_building_dispatch_strip_signature_condition_clusters: vec![],
records: vec![SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 42,
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(475, 250000)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["upper-band locomotive cost row remains descriptor parity".to_string()],
}],
}),
notes: vec![],
};
let input = build_runtime_state_input_from_save_slice(
&save_slice,
"packed-events-upper-band-locomotive-cost",
None,
)
.expect("save slice should project");
assert!(input.state.event_runtime_records.is_empty());
assert_eq!(
input
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_evidence_blocked_descriptor")
);
}
#[test]
fn overlays_scalar_locomotive_cost_rows_into_named_cost_effects() {
let base_state = RuntimeState {
@ -2519,6 +2842,119 @@ fn overlays_scalar_locomotive_cost_rows_into_named_cost_effects() {
);
}
#[test]
fn imports_scalar_locomotive_cost_rows_with_dynamic_tail_catalog_context() {
let mut names = (0..58)
.map(default_save_named_locomotive_name)
.collect::<Vec<_>>();
names.extend([
"242 A1".to_string(),
"Class 460".to_string(),
"Class A1".to_string(),
"Class P8".to_string(),
"U1".to_string(),
]);
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_with_names(&names)),
locomotive_catalog: None,
cargo_catalog: None,
world_issue_37_state: None,
world_economic_tuning_state: None,
world_finance_neighborhood_state: None,
world_locomotive_policy_state: None,
company_roster: None,
chairman_profile_table: None,
region_collection: None,
region_fixed_row_run_summary: None,
placed_structure_collection: None,
placed_structure_dynamic_side_buffer_summary: None,
special_conditions_table: None,
event_runtime_collection: Some(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_with_trigger_kind: 0,
records_missing_trigger_kind: 0,
nondirect_compact_record_count: 0,
nondirect_compact_records_missing_trigger_kind: 0,
trigger_kinds_present: vec![],
control_lane_notes: vec![],
add_building_dispatch_strip_record_indexes: vec![],
add_building_dispatch_strip_descriptor_labels: vec![],
add_building_dispatch_strip_records_with_trigger_kind: 0,
add_building_dispatch_strip_records_missing_trigger_kind: 0,
add_building_dispatch_strip_row_shape_families: vec![],
add_building_dispatch_strip_signature_families: vec![],
add_building_dispatch_strip_condition_tuple_families: vec![],
add_building_dispatch_strip_signature_condition_clusters: vec![],
records: vec![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(410, 325000)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec![
"save-derived locomotive cost row uses scenario-dependent tail names"
.to_string(),
],
}],
}),
notes: vec![],
};
let mut input = build_runtime_state_input_from_save_slice(
&save_slice,
"save-derived-dynamic-tail-locomotive-cost",
None,
)
.expect("save slice should project");
execute_step_command(
&mut input.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("save-derived dynamic-tail locomotive cost record should run");
assert_eq!(
input.state.named_locomotive_cost.get("242 A1"),
Some(&325000)
);
}
#[test]
fn keeps_negative_locomotive_cost_rows_parity_only() {
let save_slice = SmpLoadedSaveSlice {

View file

@ -590,9 +590,6 @@ pub(super) fn real_locomotive_availability_row(
56 => Some("Trans-Euro"),
57 => Some("V200"),
58 => Some("VL80T"),
59 => Some("GP 35"),
60 => Some("U1"),
61 => Some("Zephyr"),
_ => None,
}
}
@ -709,9 +706,6 @@ pub(super) fn real_locomotive_cost_row(
56 => Some("Trans-Euro"),
57 => Some("V200"),
58 => Some("VL80T"),
59 => Some("GP 35"),
60 => Some("U1"),
61 => Some("Zephyr"),
_ => None,
}
}
@ -759,77 +753,78 @@ pub(super) fn real_locomotive_cost_row(
}
}
pub(super) fn save_named_locomotive_table(
count: usize,
) -> SmpLoadedNamedLocomotiveAvailabilityTable {
fn grounded_locomotive_name(index: usize) -> String {
match index {
0 => "2-D-2",
1 => "E-88",
2 => "Adler 2-2-2",
3 => "USA 103",
4 => "American 4-4-0",
5 => "Atlantic 4-4-2",
6 => "Baldwin 0-6-0",
7 => "Be 5/7",
8 => "Beuth 2-2-2",
9 => "Big Boy 4-8-8-4",
10 => "C55 Deltic",
11 => "Camelback 0-6-0",
12 => "Challenger 4-6-6-4",
13 => "Class 01 4-6-2",
14 => "Class 103",
15 => "Class 132",
16 => "Class 500 4-6-0",
17 => "Class 9100",
18 => "Class EF 66",
19 => "Class 6E",
20 => "Consolidation 2-8-0",
21 => "Crampton 4-2-0",
22 => "DD 080-X",
23 => "DD40AX",
24 => "Duke Class 4-4-0",
25 => "E18",
26 => "E428",
27 => "Brenner E412",
28 => "E60CP",
29 => "Eight Wheeler 4-4-0",
30 => "EP-2 Bipolar",
31 => "ET22",
32 => "F3",
33 => "Fairlie 0-6-6-0",
34 => "Firefly 2-2-2",
35 => "FP45",
36 => "Ge 6/6 Crocodile",
37 => "GG1",
38 => "GP7",
39 => "H10 2-8-2",
40 => "HST 125",
41 => "Kriegslok 2-10-0",
42 => "Mallard 4-6-2",
43 => "Norris 4-2-0",
44 => "Northern 4-8-4",
45 => "Orca NX462",
46 => "Pacific 4-6-2",
47 => "Planet 2-2-0",
48 => "Re 6/6",
49 => "Red Devil 4-8-4",
50 => "S3 4-4-0",
51 => "NA-90D",
52 => "Shay (2-Truck)",
53 => "Shinkansen Series 0",
54 => "Stirling 4-2-2",
55 => "Trans-Euro",
56 => "V200",
57 => "VL80T",
58 => "GP 35",
59 => "U1",
60 => "Zephyr",
_ => return format!("Locomotive {}", index + 1),
}
.to_string()
pub(super) fn default_save_named_locomotive_name(index: usize) -> String {
match index {
0 => "2-D-2",
1 => "E-88",
2 => "Adler 2-2-2",
3 => "USA 103",
4 => "American 4-4-0",
5 => "Atlantic 4-4-2",
6 => "Baldwin 0-6-0",
7 => "Be 5/7",
8 => "Beuth 2-2-2",
9 => "Big Boy 4-8-8-4",
10 => "C55 Deltic",
11 => "Camelback 0-6-0",
12 => "Challenger 4-6-6-4",
13 => "Class 01 4-6-2",
14 => "Class 103",
15 => "Class 132",
16 => "Class 500 4-6-0",
17 => "Class 9100",
18 => "Class EF 66",
19 => "Class 6E",
20 => "Consolidation 2-8-0",
21 => "Crampton 4-2-0",
22 => "DD 080-X",
23 => "DD40AX",
24 => "Duke Class 4-4-0",
25 => "E18",
26 => "E428",
27 => "Brenner E412",
28 => "E60CP",
29 => "Eight Wheeler 4-4-0",
30 => "EP-2 Bipolar",
31 => "ET22",
32 => "F3",
33 => "Fairlie 0-6-6-0",
34 => "Firefly 2-2-2",
35 => "FP45",
36 => "Ge 6/6 Crocodile",
37 => "GG1",
38 => "GP7",
39 => "H10 2-8-2",
40 => "HST 125",
41 => "Kriegslok 2-10-0",
42 => "Mallard 4-6-2",
43 => "Norris 4-2-0",
44 => "Northern 4-8-4",
45 => "Orca NX462",
46 => "Pacific 4-6-2",
47 => "Planet 2-2-0",
48 => "Re 6/6",
49 => "Red Devil 4-8-4",
50 => "S3 4-4-0",
51 => "NA-90D",
52 => "Shay (2-Truck)",
53 => "Shinkansen Series 0",
54 => "Stirling 4-2-2",
55 => "Trans-Euro",
56 => "V200",
57 => "VL80T",
58 => "GP 35",
59 => "U1",
60 => "Zephyr",
_ => return format!("Locomotive {}", index + 1),
}
.to_string()
}
pub(super) fn save_named_locomotive_table_with_names(
names: &[String],
) -> SmpLoadedNamedLocomotiveAvailabilityTable {
let count = names.len();
SmpLoadedNamedLocomotiveAvailabilityTable {
source_kind: "runtime-save-direct-serializer".to_string(),
semantic_family: "scenario-named-locomotive-availability-table".to_string(),
@ -839,11 +834,13 @@ pub(super) fn save_named_locomotive_table(
observed_entry_count: count,
zero_availability_count: 0,
zero_availability_names: vec![],
entries: (0..count)
.map(|index| SmpRt3105SaveNameTableEntry {
entries: names
.iter()
.enumerate()
.map(|(index, name)| SmpRt3105SaveNameTableEntry {
index,
offset: 0x7c78 + index * 0x41,
text: grounded_locomotive_name(index),
text: name.clone(),
availability_dword: 1,
availability_dword_hex: "0x00000001".to_string(),
trailer_word: 1,
@ -853,6 +850,16 @@ pub(super) fn save_named_locomotive_table(
}
}
pub(super) fn save_named_locomotive_table(
count: usize,
) -> SmpLoadedNamedLocomotiveAvailabilityTable {
let names = (0..count)
.map(default_save_named_locomotive_name)
.collect::<Vec<_>>();
save_named_locomotive_table_with_names(&names)
}
pub(super) fn save_cargo_catalog(
entries: &[(u32, crate::event::targets::RuntimeCargoClass)],
) -> SmpLoadedCargoCatalog {

View file

@ -2,24 +2,21 @@ use super::super::super::*;
use std::collections::BTreeMap;
use std::sync::OnceLock;
const STATIC_GROUNDED_LOCOMOTIVE_NAME_MAX_ID: u32 = 58;
pub(in crate::inspect::smp) fn recovered_locomotive_availability_descriptor_metadata(
descriptor_id: u32,
) -> Option<RealGroupedEffectDescriptorMetadata> {
if let Some(loco_id) = recovered_locomotive_availability_loco_id(descriptor_id) {
let label = recovered_locomotive_availability_label(loco_id);
let executable_in_runtime = (loco_id as usize) <= GROUNDED_LOCOMOTIVE_PREFIX.len();
return Some(RealGroupedEffectDescriptorMetadata {
descriptor_id,
label,
target_mask_bits: 0x08,
parameter_family: "locomotive_availability_scalar",
runtime_key: None,
runtime_status: if executable_in_runtime {
RealGroupedEffectRuntimeStatus::Executable
} else {
RealGroupedEffectRuntimeStatus::EvidenceBlocked
},
executable_in_runtime,
runtime_status: RealGroupedEffectRuntimeStatus::Executable,
executable_in_runtime: true,
});
}
(457..=474)
@ -45,6 +42,9 @@ pub(in crate::inspect::smp) fn recovered_locomotive_availability_loco_id(
}
pub(in crate::inspect::smp) fn grounded_locomotive_name(loco_id: u32) -> Option<&'static str> {
if loco_id > STATIC_GROUNDED_LOCOMOTIVE_NAME_MAX_ID {
return None;
}
let index = loco_id.checked_sub(1)? as usize;
GROUNDED_LOCOMOTIVE_PREFIX.get(index).copied()
}
@ -153,8 +153,7 @@ pub(in crate::inspect::smp) fn recovered_locomotive_cost_descriptor_metadata(
descriptor_id: u32,
) -> Option<RealGroupedEffectDescriptorMetadata> {
recovered_locomotive_cost_label(descriptor_id).map(|label| {
let executable_in_runtime = recovered_locomotive_cost_loco_id(descriptor_id)
.is_some_and(|loco_id| (loco_id as usize) <= GROUNDED_LOCOMOTIVE_PREFIX.len());
let executable_in_runtime = recovered_locomotive_cost_loco_id(descriptor_id).is_some();
RealGroupedEffectDescriptorMetadata {
descriptor_id,
label,

View file

@ -223,11 +223,33 @@ fn looks_up_upper_band_recovered_locomotive_availability_descriptor_metadata() {
#[test]
fn looks_up_extended_lower_band_locomotive_availability_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(301).expect("descriptor metadata should exist");
real_grouped_effect_descriptor_metadata(298).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Zephyr Availability");
assert_eq!(metadata.label, "VL80T Availability");
assert_eq!(metadata.parameter_family, "locomotive_availability_scalar");
assert_eq!(recovered_locomotive_availability_loco_id(301), Some(61));
assert_eq!(recovered_locomotive_availability_loco_id(298), Some(58));
assert!(metadata.executable_in_runtime);
}
#[test]
fn looks_up_first_unstable_lower_band_locomotive_availability_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(299).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Lower-Band Locomotive Availability Slot 59");
assert_eq!(metadata.parameter_family, "locomotive_availability_scalar");
assert_eq!(recovered_locomotive_availability_loco_id(299), Some(59));
assert!(metadata.executable_in_runtime);
}
#[test]
fn looks_up_lower_tail_locomotive_availability_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(302).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Lower-Band Locomotive Availability Slot 62");
assert_eq!(metadata.parameter_family, "locomotive_availability_scalar");
assert_eq!(recovered_locomotive_availability_loco_id(302), Some(62));
assert!(metadata.executable_in_runtime);
}
@ -359,11 +381,33 @@ fn looks_up_recovered_upper_band_locomotive_cost_descriptor_metadata() {
#[test]
fn looks_up_extended_lower_band_locomotive_cost_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(412).expect("descriptor metadata should exist");
real_grouped_effect_descriptor_metadata(409).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Zephyr Cost");
assert_eq!(metadata.label, "VL80T Cost");
assert_eq!(metadata.parameter_family, "locomotive_cost_scalar");
assert_eq!(recovered_locomotive_cost_loco_id(412), Some(61));
assert_eq!(recovered_locomotive_cost_loco_id(409), Some(58));
assert!(metadata.executable_in_runtime);
}
#[test]
fn looks_up_first_unstable_lower_band_locomotive_cost_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(410).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Lower-Band Locomotive Cost Slot 59");
assert_eq!(metadata.parameter_family, "locomotive_cost_scalar");
assert_eq!(recovered_locomotive_cost_loco_id(410), Some(59));
assert!(metadata.executable_in_runtime);
}
#[test]
fn looks_up_lower_tail_locomotive_cost_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(413).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Lower-Band Locomotive Cost Slot 62");
assert_eq!(metadata.parameter_family, "locomotive_cost_scalar");
assert_eq!(recovered_locomotive_cost_loco_id(413), Some(62));
assert!(metadata.executable_in_runtime);
}