- the pet stable and the flight map are the reference's own files: `TaxiRouteFrame` maps to a plain Frame (factory `0x495ba0` allocates the base size, the ctor adds no field, no new virtual, no method table — and `GetObjectType` is not overridden, so its own kind would have been the divergence), and the swap found three silent behaviours — the close button did nothing (an unknown `inherits=` is a WARNING, so it loaded as a bare button), the "no paths" line drew empty, and the title is READ off `UnitName("npc")`, not received; the WMO skybox rides the interior crossfade — one weight (`[0xce9bdc]`, the same number the MFOG lerp rides), a 4 s alpha fade, so a skybox the flood publishes through a doorway draws at weight 0 instead of popping in whole
- a widget predicate answers the NUMBER 1 or nil, never a boolean: eleven of them (`IsShown`, `IsVisible`, `IsMovable`, `GetChecked`…) move to the 1.12 shape — the reference proves it in its own source (`tostring(this:GetChecked())` saved and read back `== "1"`; `tostring(true)` would never match and buff timers would never appear), 21 corpus sites compare directly (`IsVisible() == nil`, `GetChecked() == 1`), and one of our own tests had the guild rank editor sending `true` where a real client sends 1
- the vendor's refusals are the reference's table: `SMSG_BUY_FAILED`/`SMSG_SELL_ITEM` route through `0x5dcd60`/`0x5dd1f0`'s switches, six lines were wrong (code 3 printed "That vendor can't be found." — vmangos's identifier — where 1.12 prints "That merchant doesn't like you."), silence is now expressible, and keying them gives the vendor a voice (`ERR_NOT_ENOUGH_MONEY` carries error-speech line 0x28); the bank's three ids were right and its "purchase failed (n)" an invention; the logout refusal is one key, `ERR_LOGOUT_FAILED`, for every non-zero reason; the two mail codes we said had no string have one (`ERR_PLAYER_WRONG_FACTION`, `ERR_RESTRICTED_ACCOUNT`) and a sent mail says `ERR_MAIL_SENT` in yellow; a test runs the chain's own `GlobalStrings.lua` and requires non-empty text for all 193 `ERR_*` the workspace names
- a clicked quest timer no longer raises — stock `QuestTimerFrame.lua` calls `QuestLog_SetSelection`/`QuestLog_Update`, which existed nowhere in this tree; our quest log owns the reference's two names now, which also shrinks the delta when that window itself migrates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
271 lines
12 KiB
Rust
271 lines
12 KiB
Rust
//! Every `ERR_*` key this workspace names must be a real row of the client's message catalog.
|
|
//!
|
|
//! benilla writes these keys as string literals at ~160 call sites, and each one used to arrive by
|
|
//! hand-tracing a `push <id>; call CGGameUI::DisplayError` in the binary. A typo, a key from a
|
|
//! later expansion, or a plausible-looking invention would all have been invisible: the string
|
|
//! simply would not resolve in `GlobalStrings.lua`, and the reference's own data-suppression rule
|
|
//! means benilla shows *nothing* for an unresolvable key. Silence is the failure mode, which is the
|
|
//! worst kind — nobody files a bug about a toast they never saw.
|
|
//!
|
|
//! Since decision 1770 the surface those keys are shown on is read from
|
|
//! [`benilla_ui::messages`] rather than carried beside them, so a key that is not in the catalog
|
|
//! also silently takes the fallback. This walk is what keeps that fallback unreachable: it is the
|
|
//! tripwire that turns "we believe these are message ids" into a checked claim.
|
|
//!
|
|
//! It reads source text rather than any registry the code keeps, deliberately — a registry could
|
|
//! only contain what somebody remembered to register.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::Path;
|
|
|
|
/// Not message ids, and not meant to be — both are fixtures for the unknown-key *fallback*, which
|
|
/// needs a key the client does not have in order to be tested at all: `ui_petition::lines`'
|
|
/// degrade-to-generic arm, and `benilla_ui::messages`' own proof that `kind_of` answers for a key
|
|
/// it has never heard of. Anything else appearing here is a defect, not an entry to add.
|
|
const NOT_A_MESSAGE: &[&str] = &["ERR_SOMETHING_UNCARVED", "ERR_NOT_A_REAL_MESSAGE"];
|
|
|
|
/// The generated table itself, which is the answer and not a question — walking it would make
|
|
/// every key trivially present and the check vacuous.
|
|
const GENERATED: &str = "catalog.rs";
|
|
|
|
fn walk(dir: &Path, into: &mut Vec<String>) {
|
|
for entry in std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) {
|
|
let path = entry.expect("dir entry").path();
|
|
if path.is_dir() {
|
|
walk(&path, into);
|
|
} else if path.extension().is_some_and(|e| e == "rs")
|
|
&& path.file_name().is_some_and(|n| n != GENERATED)
|
|
{
|
|
into.push(std::fs::read_to_string(&path).expect("read source"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull every `"ERR_…"` string literal out of a source file, the way the call sites write them.
|
|
fn err_keys(src: &str) -> impl Iterator<Item = String> + '_ {
|
|
src.match_indices("\"ERR_").filter_map(|(i, _)| {
|
|
let rest = &src[i + 1..];
|
|
let end = rest.find('"')?;
|
|
let key = &rest[..end];
|
|
key.chars()
|
|
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
|
.then(|| key.to_string())
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn every_error_key_in_the_source_is_a_catalog_row() {
|
|
let mut sources = Vec::new();
|
|
walk(Path::new("src"), &mut sources);
|
|
walk(Path::new("../benilla-ui/src"), &mut sources);
|
|
assert!(sources.len() > 100, "the walk found almost nothing to read");
|
|
|
|
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
|
|
// 163 at the time of writing — the point is only that the walk found the vocabulary and not
|
|
// a handful of files, so this is a floor and not a ledger to keep updated.
|
|
assert!(
|
|
keys.len() > 150,
|
|
"expected the hand-written `ERR_*` vocabulary, found {}",
|
|
keys.len()
|
|
);
|
|
|
|
let strays: Vec<&String> = keys
|
|
.iter()
|
|
.filter(|k| !NOT_A_MESSAGE.contains(&k.as_str()))
|
|
.filter(|k| benilla_ui::messages::by_key(k).is_none())
|
|
.collect();
|
|
assert!(
|
|
strays.is_empty(),
|
|
"these keys are not rows of the 5875 message catalog, so the client would show nothing \
|
|
for them: {strays:?}"
|
|
);
|
|
}
|
|
|
|
/// **The claim this file's own doc has always made, finally checked** (decision 1821): a key the
|
|
/// source names must not only be a catalog row, it must **resolve to text**.
|
|
///
|
|
/// The two are not the same, and the difference is the failure this file exists to catch. A row's
|
|
/// `key` is a `GlobalStrings.lua` *lookup*, and the shipped file has no entry for a good many of
|
|
/// them — that absence is the reference's own data-suppression face, and a raise site that picks
|
|
/// one of those rows is silent for good. The walk above would pass it.
|
|
///
|
|
/// Worth closing now because 1821 moved four more windows' refusals onto keys (the vendor's two
|
|
/// tables, the banker's, the mailbox's), where a superseded or mistyped key costs a whole window's
|
|
/// worth of lines rather than one toast. Skips without client data.
|
|
#[test]
|
|
fn every_error_key_in_the_source_resolves_to_real_text() {
|
|
/// Keys whose row the shipped `GlobalStrings.lua` has **no string for**, so the reference
|
|
/// itself shows nothing when it raises them. Both are documented where they are raised —
|
|
/// `ui_items::equip_error` (errorId 362) and `ui_action::cast_fail` (the pet-happiness arm).
|
|
/// A third entry here is a defect until someone proves otherwise.
|
|
const SILENT_IN_5875: &[&str] = &["ERR_CANT_BE_DISENCHANTED", "ERR_NOT_HAPPY_ENOUGH"];
|
|
|
|
let data = match benilla_formats::wow_data() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let src = chain
|
|
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
|
.expect("GlobalStrings.lua in the chain");
|
|
let vm = benilla_ui::script::UiScript::new().expect("VM");
|
|
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
|
|
|
let mut sources = Vec::new();
|
|
walk(Path::new("src"), &mut sources);
|
|
walk(Path::new("../benilla-ui/src"), &mut sources);
|
|
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
|
|
|
|
let mut resolved = 0;
|
|
let silent: Vec<&String> = keys
|
|
.iter()
|
|
.filter(|k| !NOT_A_MESSAGE.contains(&k.as_str()))
|
|
.filter(|k| !SILENT_IN_5875.contains(&k.as_str()))
|
|
.filter(|k| {
|
|
let text: String = vm.lua().globals().get(k.as_str()).unwrap_or_default();
|
|
resolved += usize::from(!text.is_empty());
|
|
text.is_empty()
|
|
})
|
|
.collect();
|
|
assert!(
|
|
silent.is_empty(),
|
|
"these keys are catalog rows but resolve to NOTHING in the shipped GlobalStrings.lua, so \
|
|
every line raised through them is invisible: {silent:?}"
|
|
);
|
|
// A floor, not a ledger: the point is that the VM really loaded the file, so an empty `vm`
|
|
// cannot make the sweep above vacuously green.
|
|
assert!(
|
|
resolved > 150,
|
|
"only {resolved} keys resolved — did GlobalStrings load?"
|
|
);
|
|
}
|
|
|
|
/// **The other half of the same claim, on real data: a key that carries a VOICE line can actually
|
|
/// be spoken** (decision 1815).
|
|
///
|
|
/// 56 catalog rows put an error-speech id in `+0x0c` instead of a cue name, and the client says
|
|
/// those aloud in the player's own race and gender. The join runs
|
|
/// `key → MessageRecord::type_tag → VocalUISounds.dbc(race, line) → SoundEntries(sex)`, across
|
|
/// four tables and two crates, and every link is a lookup that can come back empty *silently* —
|
|
/// the same failure mode the walk above exists for, one layer deeper.
|
|
///
|
|
/// So: for every voice-tagged key this workspace raises, every playable race must have a row, and
|
|
/// that row must resolve real audio for both sexes. The two exceptions are exceptions **in the
|
|
/// shipped data**, named here so a regression cannot hide behind them. Skips without client data.
|
|
#[test]
|
|
fn every_voiced_key_the_source_raises_has_audio_for_every_playable_race() {
|
|
use benilla_formats::VOCAL_UI_LINES;
|
|
|
|
/// Lines the 5875 data ships no audio for, in any race: `0x07` (`ERR_FOOD_COOLDOWN`) and
|
|
/// `0x20` (`ERR_LOOT_BAD_FACING`). Both have a `VocalUISounds` row per race whose kit ids are
|
|
/// `-1` — the file's own "no line recorded" — so the client is silent for them by data.
|
|
const NO_AUDIO_IN_5875: &[u8] = &[0x07, 0x20];
|
|
/// Single `(race, sex)` gaps in the shipped file — one voice actor's line that never got
|
|
/// recorded. Listed rather than tolerated wholesale, so a *new* hole fails the test.
|
|
const RACE_SEX_GAPS: &[(u8, u32, u32)] = &[
|
|
(0x21, 3, 0), // ERR_LOOT_LOCKED, Dwarf male
|
|
(0x21, 4, 0), // ERR_LOOT_LOCKED, Night Elf male
|
|
(0x31, 2, 0), // ERR_MUST_EQUIP_ITEM, Orc male
|
|
];
|
|
|
|
let data = match benilla_formats::wow_data() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let vocal = benilla_formats::load_vocal_ui_sounds(&mut chain).expect("VocalUISounds.dbc");
|
|
let kits = benilla_formats::load_sound_kit_catalog(&mut chain).expect("SoundEntries.dbc");
|
|
|
|
let mut sources = Vec::new();
|
|
walk(Path::new("src"), &mut sources);
|
|
walk(Path::new("../benilla-ui/src"), &mut sources);
|
|
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
|
|
|
|
let mut voiced = 0;
|
|
for key in &keys {
|
|
let Some(record) = benilla_ui::messages::by_key(key) else {
|
|
continue; // the walk above is what polices this
|
|
};
|
|
let tag = record.type_tag;
|
|
if usize::from(tag) >= VOCAL_UI_LINES {
|
|
continue; // a cue row, not a spoken one
|
|
}
|
|
voiced += 1;
|
|
if NO_AUDIO_IN_5875.contains(&tag) {
|
|
continue;
|
|
}
|
|
for race in 1..=8u32 {
|
|
let row = vocal
|
|
.rows()
|
|
.iter()
|
|
.find(|r| r.race == race && r.line == u32::from(tag))
|
|
.unwrap_or_else(|| panic!("{key} (line {tag:#04x}) has no row for race {race}"));
|
|
for sex in 0..2u32 {
|
|
if RACE_SEX_GAPS.contains(&(tag, race, sex)) {
|
|
continue;
|
|
}
|
|
let kit = row
|
|
.normal_kit(sex)
|
|
.and_then(|k| kits.get(k))
|
|
.unwrap_or_else(|| {
|
|
panic!("{key} (line {tag:#04x}) is silent for race {race} sex {sex}")
|
|
});
|
|
assert!(
|
|
!kit.files.is_empty(),
|
|
"{key}: kit {} has no files for race {race} sex {sex}",
|
|
kit.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// benilla raises a good share of the 45 distinct voiced lines — a floor, so a refactor that
|
|
// quietly stopped routing refusals through the catalog fails here instead of going silent.
|
|
assert!(
|
|
voiced >= 30,
|
|
"only {voiced} voice-tagged keys reached the catalog — the raise sites stopped naming them"
|
|
);
|
|
}
|
|
|
|
/// **Why benilla may hang a message's sound off its display, even though the reference does not.**
|
|
///
|
|
/// `CGGameUI::DisplayError 0x496720` sounds first (`0x49673d`) and only then guards on the row's
|
|
/// key (`0x4967bd`/`0x4967c5`) and, in the sink, on the resolved text (`0x4945b4`) — so *there*, a
|
|
/// message with no GlobalStrings string still makes its noise. benilla drops an empty line before
|
|
/// it reaches `show_messages`, so it would not. That is a divergence with **no case in 5875's
|
|
/// data**, and this is the measurement that says so: every row that sounds also has text.
|
|
///
|
|
/// If this ever fails, `sound::message` has to sound the row independently of the display rather
|
|
/// than alongside it. Skips without client data.
|
|
#[test]
|
|
fn every_sounding_catalog_row_also_has_text_to_show() {
|
|
let data = match benilla_formats::wow_data() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let src = chain
|
|
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
|
.expect("GlobalStrings.lua in the chain");
|
|
let vm = benilla_ui::script::UiScript::new().expect("VM");
|
|
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
|
|
|
let mut sounding = 0;
|
|
for r in benilla_ui::messages::CATALOG {
|
|
let sounds = usize::from(r.type_tag) < benilla_formats::VOCAL_UI_LINES || r.sound.is_some();
|
|
if !sounds {
|
|
continue;
|
|
}
|
|
sounding += 1;
|
|
let text: String = vm.lua().globals().get(r.key).unwrap_or_default();
|
|
assert!(
|
|
!text.is_empty(),
|
|
"{} sounds (tag {:#04x}, cue {:?}) but has no 1.12 string — benilla would be silent \
|
|
where the reference is not",
|
|
r.key,
|
|
r.type_tag,
|
|
r.sound
|
|
);
|
|
}
|
|
assert_eq!(sounding, 86, "56 voice lines + 30 named cues");
|
|
}
|