Files
benilla-octo/crates/benilla-app/tests/reference_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

418 lines
17 KiB
Rust

//! **No user-facing sentence is written in Rust when the reference ships one.**
//!
//! The real client never composes display text: every sentence is a key into `GlobalStrings.lua`
//! (in-game) or `GlueStrings.lua` (the login/character screens) — each with a `Localize()` patch
//! file laid over it, which is where a good many of the sentences the player actually reads come
//! from (decision 2052) — resolved at runtime from the player's own install. Writing the English in
//! Rust throws away three things at once —
//! localization, and, for anything that goes through the message catalog, the *surface* the
//! message is shown on and the *voice line* it speaks with (decisions 1770, 1815, 2035).
//!
//! **Why a tripwire and not a review rule.** Decision 2035 found a loot-refusal table that had
//! composed its own eight sentences, six of which said something 1.12 never says, under a comment
//! asserting they were quoted from the reference. The comment was the only check there had ever
//! been, and a comment cannot fail. What makes that class *findable* is that a re-typed string is
//! byte-identical to the shipped one — so a walk that normalises both sides and compares them finds
//! every instance mechanically, which is what this does.
//!
//! **What it cannot find, stated plainly.** Text that matches nothing is invisible here: an
//! *invented* sentence (2035's "Those pockets are already empty.", for a code 1.12 has no string
//! for) has no shipped counterpart to match against. This tripwire catches the re-typing class,
//! which is the large one; the invention class needs a reader who checks the reference.
//!
//! **A literal beside a key lookup is correct and is not flagged** — a fallback for an install
//! whose chain lacks the key is the reference's own `UNKNOWNOBJECT` shape. The walk only counts a
//! literal whose enclosing function resolves nothing.
//!
//! [`ALLOWED`] is a **ratchet**: it records what was already drifted when the tripwire was built,
//! per file, and the counts may only ever go **down**. Converting a file means lowering its number
//! (or deleting the row); a file not listed may carry none at all. Skips without client data.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The drift still standing, per file: **208 literals over 31 files**, each a sentence the
/// reference ships and we re-typed.
///
/// **These numbers may only decrease.** They are not a budget to spend; every one is a string that
/// will not translate, and some carry a surface and a voice line with them. Lower the count when
/// you convert a file, and delete the row at zero — a row left stale fails this test too, so a
/// conversion cannot quietly re-permit the drift it retired. `char_create` (2045) and `ui_trade`
/// (2042, a neighbouring session) are both converted and have no row: that is what retiring one
/// looks like, and the second was caught by this test rather than by remembering.
///
/// **Regenerate these against a clean tree, never mid-edit.** The walk is deterministic on the
/// sources plus the shipped tables (two consecutive runs agree exactly), but a baseline captured
/// while other files are half-converted records counts that no later tree will reproduce — which
/// is exactly how the first cut of this list carried a row the gates then failed on.
const ALLOWED: &[(&str, usize)] = &[
("benilla-app/src/ui_chat/frames.rs", 48),
("benilla-app/src/ui_tooltip/mod.rs", 28),
("benilla-app/src/ui_party/mod.rs", 12),
("benilla-app/src/ui_social/mod.rs", 18),
("benilla-app/src/ui_petition/lines.rs", 15),
("benilla-app/src/ui_chat/feed.rs", 13),
("benilla-ui/src/script/tooltip_item/render.rs", 9),
("benilla-app/src/ui_unit.rs", 6),
("benilla-formats/src/spells/tokens.rs", 6),
("benilla-app/src/ui_duel.rs", 5),
("benilla-app/src/ui_social/feed.rs", 4),
("benilla-app/src/ui_talent.rs", 4),
("benilla-ui/src/script/tooltip_unit.rs", 4),
("benilla-app/src/ui_quest.rs", 3),
("benilla-app/src/ui_quest_log.rs", 3),
("benilla-app/src/ui_script/resolve_bench.rs", 3),
("benilla-ui/src/script/tooltip_item/names.rs", 3),
("benilla-ui/src/script/tradeskill/view.rs", 3),
("benilla-app/src/net/apply/chat.rs", 2),
("benilla-app/src/ui_binder.rs", 2),
("benilla-app/src/ui_char.rs", 2),
("benilla-app/src/ui_trainer/law.rs", 2),
("benilla-app/src/ui_trainer/mod.rs", 2),
("benilla-ui/src/script/tooltip_item/mod.rs", 2),
("benilla-ui/src/script/tooltip_spell.rs", 2),
("benilla-ui/src/script/unit/mod.rs", 2),
("benilla-app/src/death.rs", 1),
("benilla-app/src/net/apply/quests.rs", 1),
("benilla-app/src/ui_chat/input/mod.rs", 1),
("benilla-app/src/ui_items/feed.rs", 1),
("benilla-ui/src/script/talent.rs", 1),
];
/// Paths that are never player-facing: dev instruments, probes, capture harnesses and benches.
/// A `.learn <spell>` GM command is not a UI string even when it collides with one.
fn is_instrument(rel: &str) -> bool {
[
"/capture/",
"/bin/",
"/probes/",
"debug_panel",
"shape_gate",
"framexml_diff",
]
.iter()
.any(|p| rel.contains(p))
}
/// Collapse a shipped value and a Rust literal onto the same shape: every placeholder — the
/// reference's `%s`/`%d`/`%1$s` and Rust's `{}`/`{name}` alike — becomes one marker, `\32` is the
/// escaped space it stands for, runs of whitespace collapse, and case is dropped.
fn normalize(s: &str) -> String {
let s = s.replace("\\32", " ");
let mut out = String::with_capacity(s.len());
let b: Vec<char> = s.chars().collect();
let mut i = 0;
while i < b.len() {
match b[i] {
'{' => {
// a Rust format hole
while i < b.len() && b[i] != '}' {
i += 1;
}
i += 1;
out.push('\u{1}');
}
'%' => {
let mut j = i + 1;
while j < b.len()
&& (b[j].is_ascii_digit() || b[j] == '$' || b[j] == '.' || b[j] == '-')
{
j += 1;
}
if j < b.len() && matches!(b[j], 's' | 'd' | 'f' | 'c' | 'g') {
out.push('\u{1}');
i = j + 1;
} else {
out.push('%');
i += 1;
}
}
c => {
out.push(c.to_ascii_lowercase());
i += 1;
}
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Parse `KEY = "value";` out of a Lua string table.
fn lua_table(src: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for line in src.lines() {
let Some((k, rest)) = line.split_once('=') else {
continue;
};
let k = k.trim();
if k.is_empty()
|| !k
.bytes()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_')
{
continue;
}
let rest = rest.trim();
let Some(rest) = rest.strip_prefix('"') else {
continue;
};
if let Some(end) = rest.rfind("\";") {
out.insert(k.to_string(), rest[..end].to_string());
}
}
out
}
fn walk(dir: &Path, into: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if !matches!(name, "target" | ".git" | "tests" | "benches" | "examples") {
walk(&path, into);
}
} else if name.ends_with(".rs") && !name.contains("test") {
into.push(path);
}
}
}
/// Does this function resolve a key at all? If so, a literal inside it is a fallback.
const RESOLVERS: &[&str] = &[
"strings.get(",
".text(",
"keyed_line",
"globals().get::<String>",
"GlueStrings",
"by_key(",
"UiError::key",
"glue_strings",
];
#[test]
fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
let data = match benilla_formats::wow_data() {
Some(d) => d,
None => return,
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let mut shipped = HashMap::new();
// The base tables AND the locale patches laid over them (decision 2052): where `Localize()`
// redefines a key, its wording is the one the player actually reads, so a set that stopped at
// the base files would be grading against text this install never shows.
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.lua",
"Interface\\GlueXML\\GlueStrings.lua",
"Interface\\GlueXML\\GlueLocalization.lua",
] {
let src = chain
.read_file(file)
.unwrap_or_else(|e| panic!("{file}: {e}"));
let mut taken = 0usize;
for (k, v) in lua_table(&String::from_utf8_lossy(&src)) {
let n = normalize(&v);
// A one-word or punctuation-only value ("Locked", "%s") is too weak a signal: it
// collides with ordinary program text. Sentences are what this is after.
if n.split(' ').count() >= 2 && n.chars().any(|c| c.is_ascii_lowercase()) {
shipped.entry(n).or_insert(k);
taken += 1;
}
}
// A file that reads fine but parses to nothing would make this whole test pass vacuously
// — and the two `Localize()` files wrap their assignments in a function, a shape the base
// tables never have. Every source has to contribute or the walk is grading against less
// than it claims.
assert!(taken > 0, "{file} contributed no sentences");
}
let mut sources = Vec::new();
walk(Path::new("src"), &mut sources);
walk(Path::new("../benilla-ui/src"), &mut sources);
walk(Path::new("../benilla-formats/src"), &mut sources);
let allowed: HashMap<&str, usize> = ALLOWED.iter().copied().collect();
let mut found: HashMap<String, Vec<String>> = HashMap::new();
for path in &sources {
// The walk starts at this crate's `src` and at the two sibling crates by relative path;
// name every hit `<crate>/src/...` so the ratchet rows read the same wherever it ran.
let raw = path.to_string_lossy().to_string();
let rel = match raw.strip_prefix("../") {
Some(sibling) => sibling.to_string(),
None => format!("benilla-app/{raw}"),
};
if is_instrument(&rel) {
continue;
}
let src = strip_test_modules(&std::fs::read_to_string(path).expect("read source"));
for (fname, body) in split_fns(&src) {
if RESOLVERS.iter().any(|r| body.contains(r)) {
continue; // a literal here is a fallback beside a real lookup
}
for lit in string_literals(body) {
let n = normalize(&lit);
if n.split(' ').count() < 2 {
continue;
}
if let Some(key) = shipped.get(&n) {
found
.entry(rel.clone())
.or_default()
.push(format!("{key} — {fname}: {lit:?}"));
}
}
}
}
let mut over = Vec::new();
for (file, hits) in &found {
let budget = allowed.get(file.as_str()).copied().unwrap_or(0);
if hits.len() > budget {
over.push(format!(
"\n {file}: {} literals, ratchet allows {budget}\n{}",
hits.len(),
hits.iter()
.map(|h| format!(" {h}\n"))
.collect::<String>()
));
}
}
// The ratchet only bites downward if a *stale* row is an error too — otherwise a converted
// file's row lingers and silently re-permits the drift it was meant to retire.
for (file, budget) in ALLOWED {
let actual = found.get(*file).map_or(0, |h| h.len());
assert!(
actual >= *budget,
"\nRATCHET STALE: {file} now has {actual} re-typed literals but its row still allows \
{budget}.\nLower it to {actual} (or delete the row at 0) — the count may only go down."
);
}
assert!(
over.is_empty(),
"\nA user-facing sentence is written in Rust where the reference ships the string.\n\
Resolve it by key instead — `keyed_line`/`keyed_line_s` where a `UiScript` is in hand, \
`UiError::key` into a queue at the net bridge, `GlueStrings::text` on the glue screens.\n\
(decision 2035){}",
over.join("")
);
}
/// Cut every `#[cfg(test)]` block out of a source file before scanning it.
///
/// Without this the walk reads test fixtures as production text — `ui_action::cast_fail`'s unit
/// tests build a *fake* GlobalStrings map whose 48 entries are, by construction, byte-identical to
/// the shipped strings. Those are the test doing its job, not drift.
fn strip_test_modules(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut rest = src;
while let Some(at) = rest.find("#[cfg(test)]") {
out.push_str(&rest[..at]);
// skip to the opening brace of the item that attribute guards, then brace-match it
let after = &rest[at..];
let Some(open) = after.find('{') else { break };
let mut depth = 0usize;
let mut end = None;
for (i, c) in after[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(open + i + 1);
break;
}
}
_ => {}
}
}
match end {
Some(e) => rest = &after[e..],
None => break,
}
}
out.push_str(rest);
out
}
/// Split a source file into `(fn name, body)` pairs — crude but enough to ask "does the enclosing
/// function resolve a key?", which is the only question here.
fn split_fns(src: &str) -> Vec<(&str, &str)> {
let mut out = Vec::new();
let bytes = src.as_bytes();
let mut starts: Vec<(usize, &str)> = Vec::new();
for (i, _) in src.match_indices("fn ") {
// a top-levelish `fn`: preceded only by whitespace/visibility on its line
let line_start = src[..i].rfind('\n').map_or(0, |n| n + 1);
let prefix = &src[line_start..i];
if !prefix.trim_start().is_empty()
&& !matches!(
prefix.trim(),
"pub" | "pub(crate)" | "pub(super)" | "const" | "async"
)
&& !prefix.trim().starts_with("pub(")
{
continue;
}
let rest = &src[i + 3..];
let name_end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(rest.len());
starts.push((i, &rest[..name_end]));
}
for (n, (i, name)) in starts.iter().enumerate() {
let end = starts.get(n + 1).map_or(bytes.len(), |(j, _)| *j);
out.push((*name, &src[*i..end]));
}
if out.is_empty() {
out.push(("<file>", src));
} else if let Some((first, _)) = starts.first() {
out.push(("<consts>", &src[..*first])); // module-level `const … : &str = "…"`
}
out
}
/// Every double-quoted literal in a chunk, skipping comment lines and developer-facing macros.
fn string_literals(body: &str) -> Vec<String> {
let mut out = Vec::new();
for line in body.lines() {
let t = line.trim_start();
if t.starts_with("//") {
continue;
}
if [
"debug!",
"info!",
"warn!",
"error!",
"trace!",
"println!",
"eprintln!",
"panic!",
"assert",
"unreachable!",
"todo!",
".expect(",
"unwrap_or_else",
]
.iter()
.any(|m| line.contains(m))
{
continue;
}
let mut rest = line;
while let Some(start) = rest.find('"') {
let after = &rest[start + 1..];
let Some(end) = after.find('"') else { break };
let lit = &after[..end];
if lit.len() >= 6 && lit.contains(' ') && lit.chars().any(|c| c.is_ascii_lowercase()) {
out.push(lit.to_string());
}
rest = &after[end + 1..];
}
}
out
}