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.