Link engine types to packaged side views

This commit is contained in:
Jan Petykiewicz 2026-04-21 23:14:50 -07:00
commit 8387008728
3 changed files with 144 additions and 6 deletions

View file

@ -1,9 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::pk4::inspect_pk4_file;
const CAR_PRIMARY_DISPLAY_NAME_OFFSET: usize = 0x0c;
const CAR_CONTENT_NAME_OFFSET: usize = 0x48;
const CAR_INTERNAL_STEM_OFFSET: usize = 0x84;
@ -124,8 +126,11 @@ pub struct EngineTypeFamilyEntry {
pub internal_stem: Option<String>,
pub auxiliary_stem: Option<String>,
pub side_view_resource: Option<String>,
pub side_view_resource_found_in_pk4: Option<bool>,
pub companion_stem: Option<String>,
pub body_type_label: Option<String>,
pub internal_ne_profile_name: Option<String>,
pub internal_ne_profile_found_in_pk4: Option<bool>,
pub cct_identifier: Option<String>,
pub cct_value: Option<i64>,
pub has_matched_locomotive_pair: bool,
@ -134,6 +139,7 @@ pub struct EngineTypeFamilyEntry {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineTypesInspectionReport {
pub source_root: String,
pub side_view_imb_pk4_path: Option<String>,
pub family_count: usize,
pub car_file_count: usize,
pub lco_file_count: usize,
@ -145,8 +151,14 @@ pub struct EngineTypesInspectionReport {
pub unmatched_cgo_file_count: usize,
pub unmatched_cct_file_count: usize,
pub car_side_view_resource_counts: BTreeMap<String, usize>,
pub car_side_view_resource_pk4_match_count: usize,
pub car_side_view_resource_pk4_missing_count: usize,
pub car_auxiliary_stem_counts: BTreeMap<String, usize>,
pub car_auxiliary_stem_relation_counts: BTreeMap<String, usize>,
pub internal_ne_profile_pk4_match_count: usize,
pub internal_ne_profile_pk4_missing_count: usize,
pub locomotive_pair_internal_ne_profile_pk4_match_count: usize,
pub locomotive_pair_internal_ne_profile_pk4_missing_count: usize,
pub lco_companion_stem_counts: BTreeMap<String, usize>,
pub lco_body_type_label_counts: BTreeMap<String, usize>,
pub lco_low_cardinality_lane_counts: BTreeMap<String, BTreeMap<String, usize>>,
@ -297,6 +309,7 @@ pub fn inspect_engine_types_dir(
let mut lco_reports = BTreeMap::<String, EngineTypeLcoInspectionReport>::new();
let mut cgo_reports = BTreeMap::<String, EngineTypeCgoInspectionReport>::new();
let mut cct_reports = BTreeMap::<String, EngineTypeCctInspectionReport>::new();
let (side_view_imb_pk4_path, side_view_imb_entry_names) = load_side_view_imb_pk4_lookup(path)?;
for entry in fs::read_dir(path)? {
let entry = entry?;
@ -343,7 +356,15 @@ pub fn inspect_engine_types_dir(
let family_entries = families
.values()
.map(|family| build_family_entry(family, &car_reports, &lco_reports, &cct_reports))
.map(|family| {
build_family_entry(
family,
&car_reports,
&lco_reports,
&cct_reports,
side_view_imb_entry_names.as_ref(),
)
})
.collect::<Vec<_>>();
let matched_locomotive_pair_count = family_entries
.iter()
@ -354,6 +375,14 @@ pub fn inspect_engine_types_dir(
.iter()
.filter_map(|family| family.side_view_resource.as_deref()),
);
let car_side_view_resource_pk4_match_count = family_entries
.iter()
.filter(|family| family.side_view_resource_found_in_pk4 == Some(true))
.count();
let car_side_view_resource_pk4_missing_count = family_entries
.iter()
.filter(|family| family.side_view_resource_found_in_pk4 == Some(false))
.count();
let car_auxiliary_stem_counts = count_named_values(
family_entries
.iter()
@ -369,6 +398,28 @@ pub fn inspect_engine_types_dir(
.iter()
.filter_map(|family| family.companion_stem.as_deref()),
);
let internal_ne_profile_pk4_match_count = family_entries
.iter()
.filter(|family| family.internal_ne_profile_found_in_pk4 == Some(true))
.count();
let internal_ne_profile_pk4_missing_count = family_entries
.iter()
.filter(|family| family.internal_ne_profile_found_in_pk4 == Some(false))
.count();
let locomotive_pair_internal_ne_profile_pk4_match_count = family_entries
.iter()
.filter(|family| {
family.has_matched_locomotive_pair
&& family.internal_ne_profile_found_in_pk4 == Some(true)
})
.count();
let locomotive_pair_internal_ne_profile_pk4_missing_count = family_entries
.iter()
.filter(|family| {
family.has_matched_locomotive_pair
&& family.internal_ne_profile_found_in_pk4 == Some(false)
})
.count();
let lco_body_type_label_counts = count_named_values(
family_entries
.iter()
@ -400,6 +451,7 @@ pub fn inspect_engine_types_dir(
Ok(EngineTypesInspectionReport {
source_root: path.display().to_string(),
side_view_imb_pk4_path,
family_count: family_entries.len(),
car_file_count: family_entries
.iter()
@ -439,8 +491,14 @@ pub fn inspect_engine_types_dir(
})
.count(),
car_side_view_resource_counts,
car_side_view_resource_pk4_match_count,
car_side_view_resource_pk4_missing_count,
car_auxiliary_stem_counts,
car_auxiliary_stem_relation_counts,
internal_ne_profile_pk4_match_count,
internal_ne_profile_pk4_missing_count,
locomotive_pair_internal_ne_profile_pk4_match_count,
locomotive_pair_internal_ne_profile_pk4_missing_count,
lco_companion_stem_counts,
lco_body_type_label_counts,
lco_low_cardinality_lane_counts,
@ -468,6 +526,7 @@ fn build_family_entry(
car_reports: &BTreeMap<String, EngineTypeCarInspectionReport>,
lco_reports: &BTreeMap<String, EngineTypeLcoInspectionReport>,
cct_reports: &BTreeMap<String, EngineTypeCctInspectionReport>,
side_view_imb_entry_names: Option<&BTreeSet<String>>,
) -> EngineTypeFamilyEntry {
let car_report = family
.car_file
@ -481,6 +540,11 @@ fn build_family_entry(
.cct_file
.as_ref()
.and_then(|file_name| cct_reports.get(file_name));
let side_view_resource = car_report.and_then(|report| report.side_view_resource.clone());
let internal_stem = car_report.and_then(|report| report.internal_stem.clone());
let internal_ne_profile_name = internal_stem
.as_ref()
.map(|internal_stem| format!("{internal_stem}_NE.imb"));
EngineTypeFamilyEntry {
canonical_stem: family.canonical_stem.clone(),
car_file: family.car_file.clone(),
@ -489,17 +553,59 @@ fn build_family_entry(
cct_file: family.cct_file.clone(),
primary_display_name: car_report.and_then(|report| report.primary_display_name.clone()),
content_name: car_report.and_then(|report| report.content_name.clone()),
internal_stem: car_report.and_then(|report| report.internal_stem.clone()),
internal_stem,
auxiliary_stem: car_report.and_then(|report| report.auxiliary_stem.clone()),
side_view_resource: car_report.and_then(|report| report.side_view_resource.clone()),
side_view_resource: side_view_resource.clone(),
side_view_resource_found_in_pk4: side_view_resource.as_ref().and_then(|resource| {
side_view_imb_entry_names.map(|entries| entries.contains(resource))
}),
companion_stem: lco_report.and_then(|report| report.companion_stem.clone()),
body_type_label: lco_report.and_then(|report| report.body_type_label.clone()),
internal_ne_profile_name: internal_ne_profile_name.clone(),
internal_ne_profile_found_in_pk4: internal_ne_profile_name.as_ref().and_then(
|entry_name| side_view_imb_entry_names.map(|entries| entries.contains(entry_name)),
),
cct_identifier: cct_report.and_then(|report| report.identifier.clone()),
cct_value: cct_report.and_then(|report| report.value),
has_matched_locomotive_pair: family.car_file.is_some() && family.lco_file.is_some(),
}
}
fn load_side_view_imb_pk4_lookup(
engine_types_dir: &Path,
) -> Result<(Option<String>, Option<BTreeSet<String>>), Box<dyn std::error::Error>> {
let Some(data_dir) = engine_types_dir.parent() else {
return Ok((None, None));
};
let pk4_path = find_case_insensitive_file(&data_dir.join("2D"), "rt3_2imb.pk4");
let Some(pk4_path) = pk4_path else {
return Ok((None, None));
};
let inspection = inspect_pk4_file(&pk4_path)?;
let entry_names = inspection
.entries
.into_iter()
.map(|entry| entry.name)
.collect::<BTreeSet<_>>();
Ok((Some(pk4_path.display().to_string()), Some(entry_names)))
}
fn find_case_insensitive_file(dir: &Path, expected_name: &str) -> Option<PathBuf> {
let expected_lower = expected_name.to_ascii_lowercase();
fs::read_dir(dir)
.ok()?
.filter_map(Result::ok)
.find(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.to_ascii_lowercase() == expected_lower)
.unwrap_or(false)
})
.map(|entry| entry.path())
}
fn build_locomotive_display_census(
path: &Path,
families: &[EngineTypeFamilyEntry],
@ -894,14 +1000,28 @@ mod tests {
},
)]);
let entry = build_family_entry(&family, &car_reports, &lco_reports, &cct_reports);
let pk4_entry_names =
BTreeSet::from(["CarSideView_1.imb".to_string(), "GP7L_NE.imb".to_string()]);
let entry = build_family_entry(
&family,
&car_reports,
&lco_reports,
&cct_reports,
Some(&pk4_entry_names),
);
assert_eq!(entry.auxiliary_stem.as_deref(), Some("GP7L"));
assert_eq!(
entry.side_view_resource.as_deref(),
Some("CarSideView_1.imb")
);
assert_eq!(entry.side_view_resource_found_in_pk4, Some(true));
assert_eq!(entry.companion_stem.as_deref(), Some("VL80T"));
assert_eq!(entry.body_type_label.as_deref(), Some("Loco"));
assert_eq!(
entry.internal_ne_profile_name.as_deref(),
Some("GP7L_NE.imb")
);
assert_eq!(entry.internal_ne_profile_found_in_pk4, Some(true));
assert_eq!(entry.cct_identifier.as_deref(), Some("GP7"));
}
@ -963,8 +1083,11 @@ mod tests {
internal_stem: Some("GP7L".to_string()),
auxiliary_stem: Some("GP7L".to_string()),
side_view_resource: None,
side_view_resource_found_in_pk4: None,
companion_stem: None,
body_type_label: None,
internal_ne_profile_name: None,
internal_ne_profile_found_in_pk4: None,
cct_identifier: None,
cct_value: None,
has_matched_locomotive_pair: false,
@ -1129,8 +1252,11 @@ mod tests {
internal_stem: Some("2D2L".to_string()),
auxiliary_stem: Some("2D2L".to_string()),
side_view_resource: Some("CarSideView_2.imb".to_string()),
side_view_resource_found_in_pk4: Some(true),
companion_stem: None,
body_type_label: None,
internal_ne_profile_name: Some("2D2L_NE.imb".to_string()),
internal_ne_profile_found_in_pk4: Some(true),
cct_identifier: None,
cct_value: None,
has_matched_locomotive_pair: true,

View file

@ -15,6 +15,7 @@ This file is the short active queue for the current runtime and reverse-engineer
The checked 1.05 corpus now also splits `.car` auxiliary stems into `126` direct matches, `14` role-neutral roots, and only `5` truly distinct cases, while `.cgo` collapses into five stable scalar ladders instead of arbitrary floats.
The early `.lco` lane block is now partially partitioned too: only offsets `0x20`, `0x34`, `0x38`, `0x3c`, `0x44`, `0x48`, and `0x54` behave like low-cardinality buckets, while the other early lanes still look high-variance.
The side-view resource path is now grounded into `Data/2D/rt3_2IMB.PK4`, and the `.imb` parser now decodes shipped comment-suffixed numeric rows plus `_NE` profile fields such as `HorizontalScaleModifier` and `ImageWHScaled`.
The checked PK4 linkage split is now explicit too: `132 / 145` side-view resource names resolve directly, but the remaining `13` are the missing `CarSideView_3.imb` cohort, while `43 / 145` derived `{internal_stem}_NE.imb` names resolve and all of those hits belong to matched locomotive pairs.
The next honest static work is to keep promoting those fixed lanes into stable parser fields, explain the five remaining distinct auxiliary-stem cases, and decide how far the `.cgo` ladders plus the low-cardinality `.lco` lanes can be grounded without overclaiming semantics.
Preserved checked parser detail now lives in [EngineTypes parser semantics](rehost-queue/engine-types-parser-semantics-2026-04-21.md).
Preserved checked format inventory detail now lives in [RT3 format inventory](rehost-queue/format-inventory-2026-04-21.md).

View file

@ -57,6 +57,13 @@ first `.car` / `.lco` / `.cgo` / `.cct` inspector pass landed.
- the `.imb` parser now preserves comment-suffixed numeric rows in those shipped files, so
`MaxPercentOfInterfaceVRAM`, `HorizontalScaleModifier`, `ImageWH`, and `ImageWHScaled` can
all surface as typed fields instead of falling back to raw text
- The checked 1.05 PK4 census now gives the first grounded linkage split between `EngineTypes`
and the packaged `.imb` resources:
- `132 / 145` `.car` side-view resource names resolve directly inside `rt3_2IMB.PK4`
- the `13` missing side-view resource names are exactly the `CarSideView_3.imb` cohort
- `43 / 145` derived `{internal_stem}_NE.imb` names resolve directly inside `rt3_2IMB.PK4`
- every one of those `43` `_NE` hits belongs to a matched locomotive `.car/.lco` pair
- no freight-car or tender family currently resolves to a packaged `{internal_stem}_NE.imb`
## What The Current Parser Now Owns
@ -67,6 +74,8 @@ first `.car` / `.lco` / `.cgo` / `.cct` inspector pass landed.
- auxiliary stem slot
- side-view resource name
- auxiliary-stem relation counts across the shipped corpus
- PK4-backed side-view resource resolution status
- derived `{internal_stem}_NE.imb` resolution status
- `.lco`
- full internal stem
- conditional companion stem slot
@ -91,6 +100,8 @@ first `.car` / `.lco` / `.cgo` / `.cct` inspector pass landed.
alternate content root, paired tender/loco image root, or a narrower foreign-display alias
- whether the trailing side-view resource can be tied cleanly to the PK4-backed `CarSideView_*`
metadata and the engine-specific `_NE.imb` profiles without inventing frontend semantics
- whether the `CarSideView_3.imb` missing cohort is a classic-only asset dependency, an omitted
1.05 package, or just a dead resource reference in the shipped `EngineTypes` data
- `.lco`
- whether the guarded companion-stem slot is a tender/fallback display family, a foreign reuse
key, or only a subset authoring convenience