Files
benilla-octo/crates/benilla-formats/src/chain.rs
T
Sam SungandClaude Opus 5.5 8da634afa9 Comments in benilla-formats: one line of fact each, no history, no record numbers (#130)
Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264.

Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load.
  - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw.
  - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node.
  - `SpellRec` is the row as stored, column × 4, not a translated copy.
  - A global sequence's clock is anchored at the instance's attach time (`0x714352`).
  - Particle twinkle is a gated size multiplier (`0x7b2adc`).
  - The blob shadow sizes from the Stand box.
  - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`.
  - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data.

String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:38:53 +07:00

238 lines
8.7 KiB
Rust

//! The vanilla patch chain: a priority-ordered set of MPQ archives, read through `benilla-mpq`. A
//! read resolves a name to the highest-priority archive holding it, so a patch wins; base archives
//! carry no `(listfile)`, so resolution is by name hash.
use std::collections::HashSet;
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use benilla_mpq::Archive;
use crate::VANILLA_BASE_ORDER;
/// One entry from a chain listing: an internal path and its uncompressed size.
pub struct ChainEntry {
pub name: String,
pub size: u64,
}
/// A priority-ordered patch chain of MPQ archives (`Send + Sync`; reads are `&self` and lock-free).
pub struct Chain {
/// Ascending priority: later archives win.
archives: Vec<Archive>,
}
/// `patch-?.MPQ` as the reference's `FindFirstFileW` glob matches it (template `0x82edbc`, wrapper
/// `0x42ad10`): `?` is exactly one character, any case, so `patch-10.MPQ` never mounts.
fn is_patch_glob_match(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
let Some(mid) = lower
.strip_prefix("patch-")
.and_then(|rest| rest.strip_suffix(".mpq"))
else {
return false;
};
mid.chars().count() == 1
}
/// The reference's mount order over a `Data` listing, ascending priority (`0x403740`): the ten
/// [`VANILLA_BASE_ORDER`] archives, `patch.MPQ`, every `patch-?.MPQ` by case-folded name (the
/// reference sorts descending with `strnicmp` and walks backwards), then `speech2.MPQ`. Names
/// match case-insensitively and come back as found on disk.
fn mount_order(dir_names: &[String]) -> Vec<String> {
let find = |want: &str| {
dir_names
.iter()
.find(|n| n.eq_ignore_ascii_case(want))
.cloned()
};
let mut order: Vec<String> = VANILLA_BASE_ORDER.iter().filter_map(|b| find(b)).collect();
order.extend(find("patch.MPQ"));
let mut patches: Vec<String> = dir_names
.iter()
.filter(|n| is_patch_glob_match(n))
.cloned()
.collect();
patches.sort_by_key(|n| n.to_ascii_lowercase());
order.extend(patches);
order.extend(find("speech2.MPQ"));
order
}
impl Chain {
/// Open a `Data` directory's archives in [`mount_order`], or a single `.MPQ` file.
///
/// Deviation: an archive that fails to open is an error, where the reference logs
/// `"Failed to open archive"` and goes on, because a skipped corrupt archive surfaces only as
/// missing files far downstream.
pub fn open(path: &Path) -> Result<Self> {
let mut archives = Vec::new();
if path.is_dir() {
let mut names: Vec<String> = std::fs::read_dir(path)
.with_context(|| format!("listing {}", path.display()))?
.filter_map(|entry| {
let entry = entry.ok()?;
// `path().is_file()` follows symlinks (`read_dir`'s file_type doesn't).
entry.path().is_file().then(|| entry.file_name())
})
.filter_map(|name| name.into_string().ok())
.collect();
// read_dir order is arbitrary; sort so case-variant ties resolve deterministically.
names.sort();
for name in mount_order(&names) {
let mpq = path.join(&name);
archives.push(
Archive::open(&mpq).with_context(|| format!("opening {}", mpq.display()))?,
);
}
if archives.is_empty() {
bail!("no known vanilla MPQs found in {}", path.display());
}
} else {
archives.push(
Archive::open(path).with_context(|| format!("opening MPQ {}", path.display()))?,
);
}
Ok(Self { archives })
}
/// The highest-priority archive with an entry for `name`, a delete marker included, as a
/// tombstone shadows every lower copy: check [`Archive::is_delete_marker`] for a readable file.
fn resolve(&self, name: &str) -> Option<&Archive> {
self.archives.iter().rev().find(|a| a.contains(name))
}
/// Whether `name` (`/` or `\`, any case) is a readable file, not a delete marker.
pub fn contains(&self, name: &str) -> bool {
self.resolve(name)
.is_some_and(|a| !a.is_delete_marker(name))
}
/// The path of the archive `name` resolves to, for debugging and extraction.
pub fn find_file_archive(&self, name: &str) -> Option<&Path> {
self.resolve(name).map(|a| a.path())
}
/// Read a file by internal path (`/` or `\`) from its winning archive.
pub fn read(&self, name: &str) -> Result<Vec<u8>> {
let archive = self
.resolve(name)
.ok_or_else(|| anyhow!("file not in patch chain: {name}"))?;
// A tombstone deletes the path from the composite: not found, never a stale lower copy.
if archive.is_delete_marker(name) {
bail!(
"file deleted from patch chain: {name} (tombstoned by {})",
archive.path().display()
);
}
archive
.read_file(name)
.with_context(|| format!("reading {name} from {}", archive.path().display()))
}
/// `&mut` alias of [`Chain::read`] for call sites that thread a `&mut Chain`.
pub fn read_file(&mut self, name: &str) -> Result<Vec<u8>> {
self.read(name)
}
/// The chain's named files with sizes, for development and extraction; a file in no listfile
/// (most of `texture.MPQ`) is readable by name but not listed. Unions every archive's
/// `(listfile)`, as each names only its own files; sizes come from the winning archive.
pub fn list(&self) -> Result<Vec<ChainEntry>> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for archive in &self.archives {
let Ok(listfile) = archive.read_file("(listfile)") else {
continue;
};
for raw in String::from_utf8_lossy(&listfile).split([';', '\r', '\n']) {
let name = raw.trim();
// Dedupe the way MPQ hashing compares names: any case, `/` and `\` alike.
if name.is_empty() || !seen.insert(name.replace('/', "\\").to_ascii_lowercase()) {
continue;
}
if let Some(a) = self.resolve(name) {
// A tombstoned path is not a file in the composite.
if a.is_delete_marker(name) {
continue;
}
out.push(ChainEntry {
name: name.to_string(),
size: a.file_size(name).unwrap_or(0) as u64,
});
}
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn patch_glob_matches_exactly_one_character_case_insensitively() {
assert!(is_patch_glob_match("patch-2.MPQ"));
assert!(is_patch_glob_match("patch-3.MPQ"));
assert!(is_patch_glob_match("PATCH-A.mpq"));
assert!(!is_patch_glob_match("patch-.MPQ"));
assert!(!is_patch_glob_match("patch-10.MPQ"));
assert!(!is_patch_glob_match("patch-33.MPQ"));
assert!(!is_patch_glob_match("patch.MPQ"));
assert!(!is_patch_glob_match("patch-2.MPQ.bak"));
assert!(!is_patch_glob_match("mypatch-2.MPQ"));
}
#[test]
fn mount_order_is_the_carved_law() {
// base.MPQ is telemetry-only in the reference and never mounts.
let dir = owned(&[
"patch-2.MPQ",
"backup.MPQ",
"model.MPQ",
"base.MPQ",
"dbc.MPQ",
"patch.MPQ",
"eula.html",
"patch-3.MPQ",
"speech2.MPQ",
"texture.MPQ",
]);
assert_eq!(
mount_order(&dir),
owned(&[
"dbc.MPQ",
"texture.MPQ",
"model.MPQ",
"patch.MPQ",
"patch-2.MPQ",
"patch-3.MPQ",
"speech2.MPQ",
])
);
}
#[test]
fn patch_sort_is_ascending_and_case_folded() {
let dir = owned(&["patch-B.MPQ", "patch-3.MPQ", "patch-a.MPQ", "patch-2.MPQ"]);
assert_eq!(
mount_order(&dir),
owned(&["patch-2.MPQ", "patch-3.MPQ", "patch-a.MPQ", "patch-B.MPQ"])
);
}
#[test]
fn base_archives_are_found_case_insensitively() {
let dir = owned(&["DBC.mpq", "Model.MPQ", "PATCH.mpq"]);
assert_eq!(
mount_order(&dir),
owned(&["DBC.mpq", "Model.MPQ", "PATCH.mpq"])
);
}
}