From 73bc9a9e8eb98098d7c0ffc2bbe8afecadbdcb47 Mon Sep 17 00:00:00 2001 From: grom Date: Fri, 23 Oct 2026 16:52:40 -0600 Subject: [PATCH] app+formats: name the realm files a shadowing archive holds stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive found holding an old copy of the realm's own `FrameXML.toc` is holding an old snapshot of the realm's interface, not one old file. `Chain::copies` answers which archives hold a name and at what size off the hash and block tables alone, decompressing nothing, so the boot log can now name every row of the realm's toc whose winning copy comes from that archive while the realm's own has a different one. On grom's install that is ten, and it is the better half of the finding: `GlobalStrings.lua`, five `OctoLocale` files, `AIO.lua` — the client and server's whole transport — the calendar, the challenge tips and the radio all load as an August `patch-A.mpq` has them, because a letter patch outranks the realm's `patch-5.mpq`. `Spell.dbc` is shadowed the same way, 20 KB behind. Reported and not repaired, deliberately: shadowing a file with different content is what a patch archive is *for*, so outside the toc — where "holds every row the winner does, plus more" is a decidable relation — this client cannot tell a stale copy from an intended override. The real 1.12 client reads the same copies, so this is a fix on disk, and the line is what makes it findable instead of invisible. `richest_toc` splits the superset rule out of `realm_toc_rows` so it is testable without client data, which is where it ships from: the winner stands unless a shadowed copy drops nothing, a longer copy that drops a row is passed over, and the caller is told which archive it read so it can say so. The staleness report is checked against the real chain and every row it names must really differ in size between the two archives, so it cannot be noise. The 269 data-gated failures remain identical to the baseline at HEAD, name for name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../benilla-app/src/ui_script/reference_ui.rs | 190 ++++++++++++++++-- crates/benilla-formats/src/chain.rs | 25 +++ 2 files changed, 195 insertions(+), 20 deletions(-) diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index a02476d3..482046c3 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -73,7 +73,47 @@ fn unnamed_chain_rows(chain_toc: &[String], manifest: &[String]) -> Vec .collect() } -/// The realm's interface manifest as its rows, or `None` without client data. +/// An archive found holding a stale copy of the realm's own `FrameXML.toc` is holding a stale +/// snapshot of the realm's interface, not one stale file, so this says which of its rows it also +/// shadows — by size off the hash tables, decompressing nothing. +/// +/// Reported and not repaired because that is the honest limit: shadowing a file with different +/// content is what a patch archive is *for*, so outside the toc, where "holds every row the winner +/// does, plus more" is a decidable relation, this client cannot tell a stale copy from an intended +/// override. On grom's install it names ten — `GlobalStrings.lua`, five `OctoLocale` files, `AIO.lua` +/// (the client/server transport), the calendar, the challenge tips and the radio — against an +/// August snapshot in a `patch-A.mpq` that outranks the realm's own `patch-5.mpq` by the mount +/// order. The real 1.12 client reads the same copies, so the fix is on disk and this line is what +/// makes it findable. +fn rows_the_winner_holds_stale( + rows: &[String], + stale_at: &std::path::Path, + fresh_at: &std::path::Path, +) -> Vec { + let Some(chain) = chain() else { + return Vec::new(); + }; + let mut shadowed: Vec = Vec::new(); + for row in rows { + let name = format!("{FRAMEXML_PREFIX}{}", row.replace('/', "\\")); + let copies = chain.copies(&name); + let Some((at, winning_size)) = copies.last() else { + continue; + }; + if *at != stale_at { + continue; + } + if copies + .iter() + .any(|(from, size)| *from == fresh_at && size != winning_size) + { + shadowed.push(row.clone()); + } + } + shadowed +} + +/// The realm's interface manifest as its rows, or `None` without client data./// The realm's interface manifest as its rows, or `None` without client data. /// /// Deviation, and the only interesting thing in this file: where a **shadowed** copy of /// `FrameXML.toc` strictly extends the winning one, the shadowed copy is read instead. @@ -93,8 +133,46 @@ fn unnamed_chain_rows(chain_toc: &[String], manifest: &[String]) -> Vec /// arbitrate, and then the winner stands and the log says what was passed over. fn realm_toc_rows() -> Option> { let copies = chain()?.read_all(CHAIN_TOC); + let (rows, from) = richest_toc(&copies)?; + let (winner_at, _) = copies.last()?; + if let Some(at) = from { + let shadowed = rows_the_winner_holds_stale(&rows, winner_at, at); + if !shadowed.is_empty() { + warn!( + "framexml: {} also wins {} of the realm's own interface file(s) over a different \ + copy in {}, so those load as that archive has them, not as the realm ships them \ + \u{2014} every feature they carry is the older one: {}", + winner_at.display(), + shadowed.len(), + at.display(), + shadowed.join(", ") + ); + } + warn!( + "framexml: reading the {}-row FrameXML.toc in {} rather than the one in {}, which \ + mounts above it: the shadowed copy holds every row the winner does and more, so it is \ + the realm's later manifest with an older copy on top of it. The clean fix is on disk \ + \u{2014} that archive should not carry the realm's interface", + rows.len(), + at.display(), + winner_at.display() + ); + } + Some(rows) +} + +/// The rows to read out of `copies` (ascending priority, as [`Chain::read_all`] gives them), and +/// the archive they came from when that is **not** the winning one. +/// +/// Split from [`realm_toc_rows`] so the rule itself is testable: the winner stands unless a +/// shadowed copy holds every row it has and more, in which case the richest such copy wins. A copy +/// that is merely *longer* but drops rows the winner has is passed over with a line saying so — +/// that is a different list, not a later edition of the same one, and nothing here can arbitrate +/// between them. +fn richest_toc<'a>( + copies: &'a [(&'a std::path::Path, Vec)], +) -> Option<(Vec, Option<&'a std::path::Path>)> { let rows = |bytes: &[u8]| Toc::parse(&benilla_ui::source::decode(bytes)).files; - // Ascending priority, so the last copy is the one a read answers with. let (winner_at, winner_bytes) = copies.last()?; let winner = rows(winner_bytes); let key = |s: &str| s.replace('/', "\\").to_ascii_lowercase(); @@ -104,7 +182,6 @@ fn realm_toc_rows() -> Option> { let theirs = rows(bytes); let keys: std::collections::HashSet = theirs.iter().map(|r| key(r)).collect(); if !held.is_subset(&keys) { - // Not an extension of the winner: it drops rows the winner has. if theirs.len() > winner.len() { warn!( "framexml: {} carries a {}-row FrameXML.toc against the {} rows of {}, which \ @@ -122,23 +199,10 @@ fn realm_toc_rows() -> Option> { best = Some((at, theirs)); } } - match best { - Some((at, rows)) => { - warn!( - "framexml: reading the {}-row FrameXML.toc in {} rather than the {} rows of {}, \ - which mounts above it: the shadowed copy holds every row the winner does and {} \ - more, so it is the realm's later manifest with an older copy on top of it. The \ - clean fix is on disk \u{2014} that archive should not carry the realm's interface", - rows.len(), - at.display(), - winner.len(), - winner_at.display(), - rows.len() - winner.len() - ); - Some(rows) - } - None => Some(winner), - } + Some(match best { + Some((at, rows)) => (rows, Some(at)), + None => (winner, None), + }) } /// Every row the realm's `FrameXML.toc` carries that the manifest does not name, prefixed into @@ -293,6 +357,42 @@ mod tests { ); } + /// The superset rule, without client data: the winner stands unless a shadowed copy holds + /// every row it has and more, and a longer copy that *drops* a row is passed over rather than + /// trusted. This is the deviation from the reference's one-file-one-winner read, so it is the + /// one thing here that most wants a test that runs on the machine it ships from. + #[test] + fn the_richest_toc_wins_only_when_it_drops_nothing() { + let toc = |lines: &[&str]| lines.join("\n").into_bytes(); + let low = std::path::Path::new("patch-5.mpq"); + let high = std::path::Path::new("patch-A.mpq"); + + // The shadowed copy adds a row and removes none: the later edition, so it is read. + let copies = vec![ + (low, toc(&["A.lua", "B.lua", "New.lua"])), + (high, toc(&["A.lua", "B.lua"])), + ]; + assert_eq!( + super::richest_toc(&copies), + Some((rows(&["A.lua", "B.lua", "New.lua"]), Some(low))), + "and the caller is told which archive it came from, for the line it owes" + ); + + // It adds one and drops one: a different list, not a later edition. The winner stands. + let copies = vec![ + (low, toc(&["A.lua", "New.lua", "Another.lua"])), + (high, toc(&["A.lua", "B.lua"])), + ]; + assert_eq!( + super::richest_toc(&copies), + Some((rows(&["A.lua", "B.lua"]), None)) + ); + + // Nothing shadowed: the winner stands and no line is owed. + let copies = vec![(high, toc(&["A.lua"]))]; + assert_eq!(super::richest_toc(&copies), Some((rows(&["A.lua"]), None))); + } + /// Order is the chain's, not the manifest's and not alphabetical, because a pair like /// `Turtle_GroupUI.xml` and the fix that patches it only works one way round. #[test] @@ -334,6 +434,56 @@ mod tests { ); } + /// What the boot WARN says, as data: which copy of the realm's toc wins, which one is read + /// instead, and which of its interface files the winning archive also shadows. + /// + /// Printed rather than pinned to a list, because the answer is a property of whatever archives + /// are on this disk and asserting one install's would fail on every other. Pinned instead: a + /// row it names must really differ between the two archives, so the check cannot be noise. + #[test] + fn a_stale_archive_shadowing_the_toc_is_reported_with_the_rows_it_also_shadows() { + let _data = benilla_formats::wow_data_or_skip!(); + let Some(chain) = super::chain() else { return }; + let copies = chain.read_all(super::CHAIN_TOC); + let (rows, from) = super::richest_toc(&copies).expect("a copy of the realm's toc"); + let (winner_at, _) = copies.last().expect("a copy"); + eprintln!( + "{} copies of the realm's toc; {} rows read from {}", + copies.len(), + rows.len(), + from.unwrap_or(winner_at).display() + ); + let Some(fresh_at) = from else { + eprintln!("the winning copy is already the richest, so nothing is shadowed"); + return; + }; + let stale = super::rows_the_winner_holds_stale(&rows, winner_at, fresh_at); + eprintln!( + "{} also wins {} of the realm's interface file(s) over {}: {}", + winner_at.display(), + stale.len(), + fresh_at.display(), + if stale.is_empty() { + "none".to_string() + } else { + stale.join(", ") + } + ); + for row in &stale { + let name = format!("{}{}", super::FRAMEXML_PREFIX, row.replace('/', "\\")); + let sizes: Vec = chain + .copies(&name) + .into_iter() + .filter(|(at, _)| *at == *winner_at || *at == fresh_at) + .map(|(_, size)| size) + .collect(); + assert!( + sizes.len() == 2 && sizes[0] != sizes[1], + "{row} was named as shadowed but the two archives' copies are {sizes:?}" + ); + } + } + /// A [`super::DECLINED`] row must still be a row the realm ships, or it is a note about a file /// nobody has any more — and the next reader would take it for a live decision. #[test] diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs index 103aed1d..7efb175d 100644 --- a/crates/benilla-formats/src/chain.rs +++ b/crates/benilla-formats/src/chain.rs @@ -161,6 +161,31 @@ impl Chain { out } + /// Every archive holding `name` with that copy's uncompressed size, **ascending priority**, as + /// [`Self::read_all`] orders its bytes — but from the hash and block tables alone, so it costs + /// a lookup per archive and decompresses nothing. + /// + /// For asking *which* copies of a file exist and whether they differ, over a list of names, + /// where reading each one would mean megabytes to answer a question about bytes nobody wants. + /// Equal sizes are not proof of equal content; differing sizes are proof of differing content, + /// which is the direction a staleness check needs. + pub fn copies(&self, name: &str) -> Vec<(&Path, u32)> { + let mut out = Vec::new(); + for archive in self.archives.iter().rev() { + if !archive.contains(name) { + continue; + } + if archive.is_delete_marker(name) { + break; + } + if let Some(size) = archive.file_size(name) { + out.push((archive.path(), size)); + } + } + out.reverse(); + out + } + /// 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.