- the guild lines are message ids: `ui_guild/lines.rs` carried 34 English constants and two hand-rolled fill helpers and pushed composed text onto the chat log — 2045's largest ratchet row and the one file its rule had no route for, because `UiError`'s doc claimed the 1.12 formats use at most one `%s` and one `%d` and the type was built to match, while `ERR_GUILD_PROMOTE_SSS` is "%s has promoted %s to %s." — a wrong sentence in a doc had become a wrong type, and the wrong type had made a file's worth of drift structurally necessary; `CGGameUI::DisplayError` (`0x496720`) is cdecl varargs — `add esp,4` bare, `add esp,8` with one string, `SMSG_GUILD_INVITE` pushing two with the stack check exact at `0x30 + 0x60 = 0x90`, and `SMSG_GUILD_EVENT`'s emitter tail `0x5e745f` passing one, two or three off `strCount` — so `UiError` carries an ordered heterogeneous `Vec<FillArg>` (`S(String) | D(i64)`, since `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer) behind four constructors naming the reference's call shapes (`key`, `s`, `strings`, `args`), twenty sites migrated; what the catalog knew and composed text could not carry: `ERR_GUILD_NAME_INVALID`/`ERR_GUILD_NAME_EXISTS_S` are kind 2 — the red line — and went to chat, `ERR_GUILD_CREATE_S`/`ERR_INVITED_TO_GUILD_SS` carry the LEVELUP cue and `ERR_GUILD_PERMISSIONS` (type_tag `0x3e`) is one the reference speaks (1815), all three silent; the default arm was missing outright — `0x5e745a` pushes `0x69` = `ERR_GUILD_INTERNAL` for `0x09` and every id past `0x0d`, catalog row 105 the whole time, so `TABARD_CHANGE` had been silent under a comment calling the key unsettled; a short `strCount` is passed SHORT (0 and ≥ 4 pass zero strings) so a starved specifier reads "…promoted Furor to %s.", not "…to ."; `/ginfo` deliberately stays off the queue — `GUILD_NAME_TEMPLATE`/`GUILD_INFO_TEMPLATE` are not catalog rows, `0x5e6fb0` resolves each token through the VM and emits chat itself, and a non-row key through `Shown::keyed` would turn a chat line red — so it waits on the guild feed and goes out unkeyed, the enUS month-day-year swap with it; the `_S` arity is asserted from the key names, 22 of 22; the ratchet row is deleted, 208 over 31 files remain (2054) - a unit's blob shadow asks the UNIT for its alpha: the lane gathered live `RenderFade`s off a unit's part entities and read "no part is fading" as 1.0 — true of a settled unit and exactly backwards for arrival, where a streamed unit holds `PendingAppearFade` with no `RenderFade` anywhere until the world is shown, so its parts were deliberately invisible while the shadow drew fully opaque; measured on a probe login: alpha 1.000 from t 0.61 to the arm at t 3.51, then 0, then the ramp — 2.9 s of dark oval under no creature, the symptom `model_render_alpha`'s own "pending ⇒ 0" law (0827) already named for emitters and ribbons; `model_fade::UnitRenderAlpha` is the one door, composed from the ROOT where `UnitAppearFade` tells pending from settled, published as one question rather than its three inputs so a caller cannot reassemble the mistake, and `DespawnFade::armed()` retires the `-1.0` sentinel from every caller; the world API wall goes 177 → 178; and wow-re settled what the shadow's alpha IS, four questions VERIFIED: it reads `CM2Model+0x180` through `0x710ca0`, not `+0x19c`; that slot carries the appear and despawn ramps, the self fade AND the CharProc-14 aura — `0x60d180` drives the same `StartAlphaFade` the appear fade does, so a stealthed unit's shadow now dims with its body — and not the per-batch M2 colour/weight tracks; and the reference draws nothing under a not-yet-shown object either (`+0x180 = 1.0 · 0.0`, written 30 bytes before the read), so this is fidelity, not taste; three module-doc claims corrected against it: the dead `0x683ea5` gate, the registration handoff (GameObjects and DynamicObjects never cast; units, players and corpses always do), and 1277's `InheritedVisibility` gate, which the reference lacks and stays ours on purpose (2055) - the mail stationery list was empty for the whole session, and every send with it: the five `dropping command — not connected` lines at login, once named, were `ItemQuery` for all five `Stationery.dbc` rows — `feed_mail` carried no run condition and asked from the login screen before the socket existed, the asks evaporated in the io thread, and `Items::template` had already latched all five in its ask-once `pending` set, cleared on disconnect and never on connect, so they were never re-asked and the stock `SendMailFrame_Reset`'s row-1 selection left every send silently unsent; the list now waits for a self player, and `release_ask_once_latches_on_enter` clears the pending sets at world enter, the counterpart the disconnect teardown never had — a redundant re-ask costs one packet, a wrong latch costs a dead feature nobody can see is dead; and three more log lines answer for themselves: a control character is answered before the shape — a newline is a line break the markup parser consumes, not a missing glyph — so the warn stays meaningful for a glyph a face genuinely lacks; the blend-check's one-frame run is the specialization tick catching up with a material swap, a real login showing four entries clear 26 ms later, so the warn waits for a run to SURVIVE the tick and a one-frame run is recorded at debug; and the audio starve line carries the wall span it covered beside the time it counted and the position it saw advance, because a 1.0 s window still closed 0.756 s of wall after the stream started on `Time<Real>` — if they disagree the caller's clock is still wrong, if they agree the starvation is real Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
280 lines
12 KiB
Rust
280 lines
12 KiB
Rust
//! The 1.12 glue string table, read off the MPQ chain at startup — the localized text the glue
|
|
//! screens quote: faction/race/class description paragraphs (`FACTION_INFO_*`, `RACE_INFO_*`,
|
|
//! `ABILITY_INFO_*`, `CLASS_*`), the per-race customization dial labels (`HAIR_<tok>_STYLE`,
|
|
//! `FACIAL_HAIR_<tok>`), the login refusals, and the button captions.
|
|
//!
|
|
//! **It is two files, in the reference's own order** (decision 2052). `GlueXML.toc` sources
|
|
//! `GlueStrings.lua` at line 1 and `GlueLocalization.xml` at line 3, and that XML exists only to
|
|
//! load its script and call `Localize()` — a function of nothing but assignments, which *overwrite*
|
|
//! the base table. In 1.12 enGB it rewrites 32 keys, among them every long login refusal
|
|
//! (`AUTH_BANNED`, `LOGIN_UNKNOWN_ACCOUNT`, …) and the realm-type suffixes. Reading only the base
|
|
//! file showed the player sentences their own client never shows. The in-game side already loads
|
|
//! its exact twin — `Interface\FrameXML\Localization.xml`, same two-part shape, sourced from
|
|
//! `benilla.toc` — so the glue side was the half that had been left behind.
|
|
//!
|
|
//! Runtime-read, never embedded: the paragraphs are Blizzard content, so they load from the
|
|
//! player's own client data like every other asset (the repo's never-commit rule) — which is also
|
|
//! what makes the locale *theirs* rather than ours to guess. Both files are plain `KEY = "value";`
|
|
//! Lua assignments, one per line — a full Lua VM would be theater here; [`parse_glue_strings`]
|
|
//! handles exactly that shape (and skips everything else).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_assets::{LockRecover, WorldAssets};
|
|
|
|
const GLUE_STRINGS: &str = "Interface\\GlueXML\\GlueStrings.lua";
|
|
const GLUE_LOCALIZATION: &str = "Interface\\GlueXML\\GlueLocalization.lua";
|
|
|
|
/// The glue string table. Present but possibly empty (missing client data — the graceful-absence
|
|
/// posture; callers fall back to their built-in captions).
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct GlueStrings(HashMap<String, String>);
|
|
|
|
impl GlueStrings {
|
|
/// Build a table straight from parsed pairs — the test seam for code that has to resolve
|
|
/// against the *real* shipped file rather than a stub.
|
|
#[cfg(test)]
|
|
pub(crate) fn from_map(map: HashMap<String, String>) -> Self {
|
|
Self(map)
|
|
}
|
|
|
|
/// The pairs back out, for a test that has to alter one key and re-resolve.
|
|
#[cfg(test)]
|
|
pub(crate) fn into_map(self) -> HashMap<String, String> {
|
|
self.0
|
|
}
|
|
|
|
/// The string for a key, or `None` (unknown key / no data).
|
|
pub(crate) fn get(&self, key: &str) -> Option<&str> {
|
|
self.0.get(key).map(String::as_str)
|
|
}
|
|
|
|
/// The string for a key, falling back to a built-in caption.
|
|
pub(crate) fn text<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
|
|
self.get(key).unwrap_or(fallback)
|
|
}
|
|
}
|
|
|
|
/// Startup: read + parse the glue string table off the chain (after the chain exists).
|
|
///
|
|
/// Two reads, and the **order is the whole point**: the base file, then the locale patch over it,
|
|
/// exactly as `GlueXML.toc` sources them. A chain missing either one degrades on its own — no
|
|
/// base means built-in captions, no patch means the base text.
|
|
pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
|
let mut table = GlueStrings::default();
|
|
if let Some(assets) = assets {
|
|
let mut chain = assets.chain.lock_recover();
|
|
match chain.read_file(GLUE_STRINGS) {
|
|
Ok(bytes) => {
|
|
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
|
info!("glue strings: {} entries", table.0.len());
|
|
}
|
|
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
|
|
}
|
|
match chain.read_file(GLUE_LOCALIZATION) {
|
|
Ok(bytes) => {
|
|
let patch = parse_localize_overrides(&String::from_utf8_lossy(&bytes));
|
|
info!("glue strings: {} locale overrides", patch.len());
|
|
table.0.extend(patch);
|
|
}
|
|
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
|
|
}
|
|
}
|
|
commands.insert_resource(table);
|
|
}
|
|
|
|
/// Parse the `KEY = "value";` assignments (one per line; `\n`/`\t`/`\"`/`\\` escapes unfolded).
|
|
/// Anything else — comments, code, multi-line constructs — is skipped.
|
|
fn parse_glue_strings(src: &str) -> HashMap<String, String> {
|
|
let mut out = HashMap::new();
|
|
for line in src.lines() {
|
|
let Some((key, rest)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
let key = key.trim();
|
|
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
continue;
|
|
}
|
|
let rest = rest.trim_start();
|
|
let Some(rest) = rest.strip_prefix('"') else {
|
|
continue;
|
|
};
|
|
// Unescape up to the closing quote; a line whose quote never closes is skipped.
|
|
let mut value = String::new();
|
|
let mut chars = rest.chars();
|
|
let mut closed = false;
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'"' => {
|
|
closed = true;
|
|
break;
|
|
}
|
|
'\\' => match chars.next() {
|
|
Some('n') => value.push('\n'),
|
|
Some('t') => value.push('\t'),
|
|
Some('r') => {}
|
|
Some(other) => value.push(other),
|
|
None => break,
|
|
},
|
|
other => value.push(other),
|
|
}
|
|
}
|
|
if closed {
|
|
out.insert(key.to_string(), value);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The assignments inside `GlueLocalization.lua`'s **`Localize()`** — the locale patch the
|
|
/// reference lays over `GlueStrings.lua`.
|
|
///
|
|
/// Scoped to that one function deliberately, because the file defines two and only one of them is
|
|
/// a string table:
|
|
///
|
|
/// - **`Localize()`** is run by `GlueLocalization.xml`'s own inline `<Script>` (l.6) the moment the
|
|
/// file loads, which in `GlueXML.toc` is line 3 — after `GlueStrings.lua` at line 1. Its body is
|
|
/// nothing but `KEY = "value";`, and it is what this reads.
|
|
/// - **`LocalizeFrames()`** is called much later, from `GlueParent_OnEvent`'s `FRAMES_LOADED` arm
|
|
/// (`GlueParent.lua` l.95), and is frame surgery rather than text: in 1.12 enGB, a texture swap
|
|
/// and reposition of the login screen's `WorldOfWarcraftRating` logo — a frame benilla's native
|
|
/// glue does not have at all. Nothing there to apply, so nothing here reads it.
|
|
///
|
|
/// The body runs from `function Localize()` to the first **unindented** `end`, which is Blizzard's
|
|
/// own formatting: nested blocks are indented, a function's own terminator is not. A file whose
|
|
/// shape doesn't match yields no overrides rather than the wrong ones — the loader logs the count,
|
|
/// so a zero is visible rather than silent.
|
|
fn parse_localize_overrides(src: &str) -> HashMap<String, String> {
|
|
let mut body = String::new();
|
|
let mut inside = false;
|
|
for line in src.lines() {
|
|
if !inside {
|
|
inside = line.trim_start().starts_with("function Localize()");
|
|
continue;
|
|
}
|
|
if line.trim_end() == "end" {
|
|
return parse_glue_strings(&body);
|
|
}
|
|
body.push_str(line);
|
|
body.push('\n');
|
|
}
|
|
// No terminator (or no `Localize()` at all): an unrecognised file patches nothing.
|
|
HashMap::new()
|
|
}
|
|
|
|
/// Build the table the way [`load_glue_strings`] does — base file, then the locale patch over it.
|
|
/// Every test that resolves against the *real* chain goes through here, so no test can assert a
|
|
/// sentence the running client would not show.
|
|
#[cfg(test)]
|
|
pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueStrings {
|
|
let base = chain.read_file(GLUE_STRINGS).expect("GlueStrings.lua");
|
|
let mut map = parse_glue_strings(&String::from_utf8_lossy(&base));
|
|
let patch = chain
|
|
.read_file(GLUE_LOCALIZATION)
|
|
.expect("GlueLocalization.lua");
|
|
map.extend(parse_localize_overrides(&String::from_utf8_lossy(&patch)));
|
|
GlueStrings::from_map(map)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_assignments_and_skips_the_rest() {
|
|
let src = r#"
|
|
MALE = "Male";
|
|
FACTION_INFO_HORDE = "Four races\ncomprise the Horde";
|
|
QUOTED = "a \"b\" c";
|
|
-- a comment = "not a string";
|
|
CODE = getglobal("nope");
|
|
BROKEN = "no close
|
|
"#;
|
|
let t = parse_glue_strings(src);
|
|
assert_eq!(t.get("MALE").map(String::as_str), Some("Male"));
|
|
assert_eq!(
|
|
t.get("FACTION_INFO_HORDE").map(String::as_str),
|
|
Some("Four races\ncomprise the Horde")
|
|
);
|
|
assert_eq!(t.get("QUOTED").map(String::as_str), Some(r#"a "b" c"#));
|
|
assert!(!t.contains_key("CODE"));
|
|
assert!(!t.contains_key("BROKEN"));
|
|
assert_eq!(t.len(), 3);
|
|
}
|
|
|
|
/// `Localize()`'s body, and **only** its body: the second function in the file is frame
|
|
/// surgery that the reference calls from somewhere else entirely, so an assignment there must
|
|
/// not leak into the string table.
|
|
#[test]
|
|
fn the_locale_patch_reads_localize_and_stops_at_its_end() {
|
|
let src = r#"function Localize()
|
|
-- Put all locale specific string adjustments here
|
|
AUTH_REJECT = "Login unavailable - contact support";
|
|
PVP_PARENTHESES = "PVP";
|
|
--SHOW_CONTEST_AGREEMENT = 1;
|
|
end
|
|
|
|
function LocalizeFrames()
|
|
NOT_A_STRING_TABLE = "must not leak";
|
|
WorldOfWarcraftRating:SetTexture("Interface\\Glues\\Login\\Glues-FrenchRating");
|
|
end
|
|
"#;
|
|
let t = parse_localize_overrides(src);
|
|
assert_eq!(
|
|
t.get("AUTH_REJECT").map(String::as_str),
|
|
Some("Login unavailable - contact support")
|
|
);
|
|
assert_eq!(t.get("PVP_PARENTHESES").map(String::as_str), Some("PVP"));
|
|
assert!(
|
|
!t.contains_key("NOT_A_STRING_TABLE"),
|
|
"LocalizeFrames leaked"
|
|
);
|
|
assert!(!t.contains_key("SHOW_CONTEST_AGREEMENT"), "comment parsed");
|
|
assert_eq!(t.len(), 2);
|
|
}
|
|
|
|
/// A file that is not the shape we understand patches **nothing** — never a partial or a
|
|
/// guessed table. The two ways it can fail: no `Localize()` at all, and a body with no
|
|
/// terminator to stop at.
|
|
#[test]
|
|
fn an_unrecognised_file_patches_nothing() {
|
|
assert!(parse_localize_overrides("KEY = \"value\";\n").is_empty());
|
|
assert!(parse_localize_overrides("function Localize()\n\tK = \"v\";\n").is_empty());
|
|
}
|
|
|
|
/// **The real chain's patch, applied in the reference's own order.** Reading only the base
|
|
/// file is what decision 2052 fixed, and this is the regression: three keys whose base text
|
|
/// and patched text differ, asserted from both ends so neither a lost patch nor a lost base
|
|
/// can pass. Skips without client data.
|
|
#[test]
|
|
fn the_real_chain_patches_the_login_refusals_and_the_realm_suffixes() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
|
|
let base = chain.read_file(GLUE_STRINGS).expect("GlueStrings.lua");
|
|
let base = parse_glue_strings(&String::from_utf8_lossy(&base));
|
|
let table = table_from_chain(&mut chain);
|
|
|
|
for key in ["AUTH_BANNED", "LOGIN_UNKNOWN_ACCOUNT", "PVP_PARENTHESES"] {
|
|
let before = base.get(key).expect("key in the base file").as_str();
|
|
let after = table.get(key).expect("key in the patched table");
|
|
assert_ne!(before, after, "{key} was not patched by Localize()");
|
|
}
|
|
|
|
// The suffix is the one a player can read at a glance: the base file parenthesises it,
|
|
// the enGB patch does not — `CharSelectRealmName` says "Realm PVP", not "Realm (PVP)".
|
|
assert_eq!(
|
|
base.get("PVP_PARENTHESES").map(String::as_str),
|
|
Some("(PVP)")
|
|
);
|
|
assert_eq!(table.get("PVP_PARENTHESES"), Some("PVP"));
|
|
|
|
// Every key the base file never had still resolves, and every key it had is still there:
|
|
// the patch overwrites, it does not replace the table.
|
|
assert!(table.get("FACTION_INFO_HORDE").is_some(), "base lost");
|
|
assert!(base.len() > 100 && table.0.len() >= base.len());
|
|
}
|
|
}
|