rrt/crates/rrt-runtime/src/economy.rs

417 lines
15 KiB
Rust
Raw Normal View History

2026-04-17 11:49:20 -07:00
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::pk4::inspect_pk4_bytes;
pub const NAMED_CARGO_PRICE_DESCRIPTOR_ROW_COUNT: usize = 71;
pub const NAMED_CARGO_PRODUCTION_DESCRIPTOR_ROW_COUNT: usize = 50;
pub const CARGO_TYPE_MAGIC: u32 = 0x0000_03ea;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoNameToken {
pub raw_name: String,
pub visible_name: String,
#[serde(default)]
pub localized_string_id: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoTypeEntry {
pub file_name: String,
pub file_size: usize,
pub file_size_hex: String,
pub header_magic: u32,
pub header_magic_hex: String,
pub name: CargoNameToken,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoTypeInspectionReport {
pub directory_path: String,
pub entry_count: usize,
pub unique_visible_name_count: usize,
pub notes: Vec<String>,
pub entries: Vec<CargoTypeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoSkinDescriptorEntry {
pub pk4_entry_name: String,
pub payload_len: usize,
pub payload_len_hex: String,
pub descriptor_kind: String,
pub name: CargoNameToken,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoSkinInspectionReport {
pub pk4_path: String,
pub entry_count: usize,
pub unique_visible_name_count: usize,
pub notes: Vec<String>,
pub entries: Vec<CargoSkinDescriptorEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoEconomySourceReport {
pub cargo_types_dir: String,
pub cargo_skin_pk4_path: String,
pub named_cargo_price_row_count: usize,
pub named_cargo_production_row_count: usize,
pub cargo_type_count: usize,
pub cargo_skin_count: usize,
pub shared_visible_name_count: usize,
pub visible_name_union_count: usize,
pub cargo_type_only_visible_names: Vec<String>,
pub cargo_skin_only_visible_names: Vec<String>,
pub notes: Vec<String>,
pub cargo_type_entries: Vec<CargoTypeEntry>,
pub cargo_skin_entries: Vec<CargoSkinDescriptorEntry>,
}
pub fn inspect_cargo_types_dir(
path: &Path,
) -> Result<CargoTypeInspectionReport, Box<dyn std::error::Error>> {
let mut entries = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy().into_owned();
if Path::new(&file_name)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.eq_ignore_ascii_case("cty"))
!= Some(true)
{
continue;
}
let bytes = fs::read(entry.path())?;
entries.push(parse_cargo_type_entry(&file_name, &bytes)?);
}
entries.sort_by(|left, right| left.name.visible_name.cmp(&right.name.visible_name));
let mut notes = Vec::new();
notes.push(
"CargoTypes entries carry a 0x03ea header and an inline NUL-terminated cargo token string."
.to_string(),
);
notes.push(
"A leading `~####` token is preserved as raw_name and normalized into visible_name by stripping the localized string id prefix."
.to_string(),
);
let unique_visible_name_count = entries
.iter()
.map(|entry| entry.name.visible_name.as_str())
.collect::<BTreeSet<_>>()
.len();
Ok(CargoTypeInspectionReport {
directory_path: path.display().to_string(),
entry_count: entries.len(),
unique_visible_name_count,
notes,
entries,
})
}
pub fn inspect_cargo_skin_pk4(
path: &Path,
) -> Result<CargoSkinInspectionReport, Box<dyn std::error::Error>> {
let bytes = fs::read(path)?;
let inspection = inspect_pk4_bytes(&bytes)?;
let mut entries = Vec::new();
for entry in &inspection.entries {
if entry.extension.as_deref() != Some("dsc") {
continue;
}
let payload = bytes
.get(entry.payload_absolute_offset..entry.payload_end_offset)
.ok_or_else(|| format!("pk4 payload range out of bounds for {}", entry.name))?;
let parsed = parse_cargo_skin_descriptor_entry(&entry.name, payload)?;
entries.push(parsed);
}
entries.sort_by(|left, right| left.name.visible_name.cmp(&right.name.visible_name));
let mut notes = Vec::new();
notes.push(
"cargoSkin descriptors are parsed from .dsc payloads whose first non-empty line names the descriptor kind and whose second non-empty line carries the cargo token."
.to_string(),
);
notes.push(
"A leading `~####` token is preserved as raw_name and normalized into visible_name by stripping the localized string id prefix."
.to_string(),
);
let unique_visible_name_count = entries
.iter()
.map(|entry| entry.name.visible_name.as_str())
.collect::<BTreeSet<_>>()
.len();
Ok(CargoSkinInspectionReport {
pk4_path: path.display().to_string(),
entry_count: entries.len(),
unique_visible_name_count,
notes,
entries,
})
}
pub fn inspect_cargo_economy_sources(
cargo_types_dir: &Path,
cargo_skin_pk4_path: &Path,
) -> Result<CargoEconomySourceReport, Box<dyn std::error::Error>> {
let cargo_types = inspect_cargo_types_dir(cargo_types_dir)?;
let cargo_skins = inspect_cargo_skin_pk4(cargo_skin_pk4_path)?;
Ok(build_cargo_economy_source_report(cargo_types, cargo_skins))
}
fn build_cargo_economy_source_report(
cargo_types: CargoTypeInspectionReport,
cargo_skins: CargoSkinInspectionReport,
) -> CargoEconomySourceReport {
let cargo_type_visible_names = cargo_types
.entries
.iter()
.map(|entry| entry.name.visible_name.clone())
.collect::<BTreeSet<_>>();
let cargo_skin_visible_names = cargo_skins
.entries
.iter()
.map(|entry| entry.name.visible_name.clone())
.collect::<BTreeSet<_>>();
let shared_visible_name_count = cargo_type_visible_names
.intersection(&cargo_skin_visible_names)
.count();
let visible_name_union_count = cargo_type_visible_names
.union(&cargo_skin_visible_names)
.count();
let cargo_type_only_visible_names = cargo_type_visible_names
.difference(&cargo_skin_visible_names)
.cloned()
.collect::<Vec<_>>();
let cargo_skin_only_visible_names = cargo_skin_visible_names
.difference(&cargo_type_visible_names)
.cloned()
.collect::<Vec<_>>();
let mut notes = Vec::new();
notes.push(format!(
"Named cargo-price descriptors 106..176 span {} rows, while named cargo-production descriptors 180..229 span {} rows.",
NAMED_CARGO_PRICE_DESCRIPTOR_ROW_COUNT, NAMED_CARGO_PRODUCTION_DESCRIPTOR_ROW_COUNT
));
notes.push(format!(
"The inspected CargoTypes corpus exposes {} visible names, the inspected cargoSkin corpus exposes {} visible names, and their union exposes {} visible names.",
cargo_types.unique_visible_name_count, cargo_skins.unique_visible_name_count, visible_name_union_count
));
if visible_name_union_count != NAMED_CARGO_PRICE_DESCRIPTOR_ROW_COUNT {
notes.push(
"That visible-name union still does not match the 71-row named cargo-price strip, so this offline source reconstruction is groundwork rather than a complete price-selector binding."
.to_string(),
);
} else {
notes.push(
"The visible-name union matches the 71-row named cargo-price strip; a later pass can decide whether ordering evidence is now strong enough for descriptor bindings."
.to_string(),
);
}
if cargo_types.unique_visible_name_count == NAMED_CARGO_PRODUCTION_DESCRIPTOR_ROW_COUNT {
notes.push(
"The CargoTypes corpus still matches the 50-row named cargo-production strip cardinality that grounds the current production bindings."
.to_string(),
);
}
CargoEconomySourceReport {
cargo_types_dir: cargo_types.directory_path,
cargo_skin_pk4_path: cargo_skins.pk4_path,
named_cargo_price_row_count: NAMED_CARGO_PRICE_DESCRIPTOR_ROW_COUNT,
named_cargo_production_row_count: NAMED_CARGO_PRODUCTION_DESCRIPTOR_ROW_COUNT,
cargo_type_count: cargo_types.entry_count,
cargo_skin_count: cargo_skins.entry_count,
shared_visible_name_count,
visible_name_union_count,
cargo_type_only_visible_names,
cargo_skin_only_visible_names,
notes,
cargo_type_entries: cargo_types.entries,
cargo_skin_entries: cargo_skins.entries,
}
}
fn parse_cargo_type_entry(
file_name: &str,
bytes: &[u8],
) -> Result<CargoTypeEntry, Box<dyn std::error::Error>> {
if bytes.len() < 5 {
return Err(format!("cargo type entry {file_name} is too short").into());
}
let header_magic = u32::from_le_bytes(bytes[0..4].try_into().expect("length checked"));
let raw_name = parse_nul_terminated_utf8(bytes, 4)
.ok_or_else(|| format!("cargo type entry {file_name} is missing a NUL-terminated name"))?;
Ok(CargoTypeEntry {
file_name: file_name.to_string(),
file_size: bytes.len(),
file_size_hex: format!("0x{:x}", bytes.len()),
header_magic,
header_magic_hex: format!("0x{header_magic:08x}"),
name: parse_cargo_name_token(&raw_name),
})
}
fn parse_cargo_skin_descriptor_entry(
entry_name: &str,
bytes: &[u8],
) -> Result<CargoSkinDescriptorEntry, Box<dyn std::error::Error>> {
let text = std::str::from_utf8(bytes)?;
let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
let descriptor_kind = lines
.next()
.ok_or_else(|| format!("cargo skin descriptor {entry_name} is missing the kind line"))?;
let raw_name = lines
.next()
.ok_or_else(|| format!("cargo skin descriptor {entry_name} is missing the name line"))?;
Ok(CargoSkinDescriptorEntry {
pk4_entry_name: entry_name.to_string(),
payload_len: bytes.len(),
payload_len_hex: format!("0x{:x}", bytes.len()),
descriptor_kind: descriptor_kind.to_string(),
name: parse_cargo_name_token(raw_name),
})
}
fn parse_nul_terminated_utf8(bytes: &[u8], offset: usize) -> Option<String> {
let tail = bytes.get(offset..)?;
let end = tail.iter().position(|byte| *byte == 0)?;
String::from_utf8(tail[..end].to_vec()).ok()
}
fn parse_cargo_name_token(raw_name: &str) -> CargoNameToken {
let mut visible_name = raw_name.to_string();
let mut localized_string_id = None;
if let Some(rest) = raw_name.strip_prefix('~') {
let digits = rest
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
if !digits.is_empty() {
localized_string_id = digits.parse::<u32>().ok();
visible_name = rest[digits.len()..].to_string();
}
}
CargoNameToken {
raw_name: raw_name.to_string(),
visible_name,
localized_string_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_cargo_type_entry() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&CARGO_TYPE_MAGIC.to_le_bytes());
bytes.extend_from_slice(b"Alcohol\0");
let entry = parse_cargo_type_entry("Alcohol.cty", &bytes).expect("entry should parse");
assert_eq!(entry.header_magic, CARGO_TYPE_MAGIC);
assert_eq!(entry.name.raw_name, "Alcohol");
assert_eq!(entry.name.visible_name, "Alcohol");
assert_eq!(entry.name.localized_string_id, None);
}
#[test]
fn parses_localized_cargo_type_entry() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&CARGO_TYPE_MAGIC.to_le_bytes());
bytes.extend_from_slice(b"~4465Ceramics\0");
let entry =
parse_cargo_type_entry("~4465Ceramics.cty", &bytes).expect("entry should parse");
assert_eq!(entry.name.raw_name, "~4465Ceramics");
assert_eq!(entry.name.visible_name, "Ceramics");
assert_eq!(entry.name.localized_string_id, Some(4465));
}
#[test]
fn parses_cargo_skin_descriptor_entry() {
let entry = parse_cargo_skin_descriptor_entry("Alcohol.dsc", b"cargoSkin\r\nAlcohol\r\n")
.expect("descriptor should parse");
assert_eq!(entry.descriptor_kind, "cargoSkin");
assert_eq!(entry.name.visible_name, "Alcohol");
}
#[test]
fn builds_cargo_source_report_union_counts() {
let cargo_types = CargoTypeInspectionReport {
directory_path: "CargoTypes".to_string(),
entry_count: 2,
unique_visible_name_count: 2,
notes: Vec::new(),
entries: vec![
CargoTypeEntry {
file_name: "Alcohol.cty".to_string(),
file_size: 16,
file_size_hex: "0x10".to_string(),
header_magic: CARGO_TYPE_MAGIC,
header_magic_hex: format!("0x{CARGO_TYPE_MAGIC:08x}"),
name: parse_cargo_name_token("Alcohol"),
},
CargoTypeEntry {
file_name: "Coal.cty".to_string(),
file_size: 16,
file_size_hex: "0x10".to_string(),
header_magic: CARGO_TYPE_MAGIC,
header_magic_hex: format!("0x{CARGO_TYPE_MAGIC:08x}"),
name: parse_cargo_name_token("Coal"),
},
],
};
let cargo_skins = CargoSkinInspectionReport {
pk4_path: "Cargo106.PK4".to_string(),
entry_count: 2,
unique_visible_name_count: 2,
notes: Vec::new(),
entries: vec![
CargoSkinDescriptorEntry {
pk4_entry_name: "Alcohol.dsc".to_string(),
payload_len: 20,
payload_len_hex: "0x14".to_string(),
descriptor_kind: "cargoSkin".to_string(),
name: parse_cargo_name_token("Alcohol"),
},
CargoSkinDescriptorEntry {
pk4_entry_name: "Beer.dsc".to_string(),
payload_len: 16,
payload_len_hex: "0x10".to_string(),
descriptor_kind: "cargoSkin".to_string(),
name: parse_cargo_name_token("Beer"),
},
],
};
let report = build_cargo_economy_source_report(cargo_types, cargo_skins);
assert_eq!(report.shared_visible_name_count, 1);
assert_eq!(report.visible_name_union_count, 3);
assert_eq!(
report.cargo_type_only_visible_names,
vec!["Coal".to_string()]
);
assert_eq!(
report.cargo_skin_only_visible_names,
vec!["Beer".to_string()]
);
}
}