Files
benilla-octo/crates/benilla-app/src/glue_strings.rs
T
SamandClaude Fable 5.1 7fe4b37c2f guild lines are message ids, mail sends again, and the shadow fades in
- 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>
2026-09-06 19:41:57 +07:00

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());
}
}