- your character speaks its refusals: `VocalUISounds.dbc` gets an adapter, `sound::vocal` transcribes `0x4580f0`'s per-race table and `0x458250`'s play cycle — four plays of one line arm the annoyed variations, and the bus cap of one keeps the annoyed and ordinary lines from playing at once; a displayed message now carries its catalog RECORD, so its sound comes off the same row (tag 0x44 plays the named cue, anything else speaks the line), and 36 of the keys benilla raises have a voice — cast failures, inventory refusals, the quest/auction/petition lines, taxi, bank, stable, mail, the talent wipe; `EnableErrorSpeech` joins the CVars at the registrar's own default and the Audio page grows 1.12's fourth Sound checkbox with the dependency ours was missing (the master greys Ambience AND Error Speech, not Music); seven of the twelve taxi refusals turn out to be the YELLOW info line, not the red one they all took - at equal (strata, level) the hit sweep takes the EARLIER-linked frame: `0x764aa0` inserts with a strict `ja`, so equal keys are appended, and `0x7660d0` sweeps each bucket from index 0 upward — the reverse of the reverse-draw-order walk we had, so draw order is unchanged and only the hit sweep reorders within a plane; the wheel becomes its own index, and the five full-area wheel-catcher Buttons (Craft, QuestLog, Skill, TradeSkill, Trainer) are deleted, since under the true tie law each would have swallowed its whole window's clicks — a bug the Skill window's own comment had already recorded once - one rasterizer settle for the whole ground-decal family, and it is the measured one: the shipped client constructs `CGxDeviceD3d`, so the OpenGL arm holding the −4.0 slope factor is never executed (factor 0 on 2485 of 2485 captured `glPolygonOffset` calls), and every number derived from it was 256× too small; the reference groups the blob shadow with the selection RING and the footprints on their own at 10×, the reverse of ours — but ours is reversed-Z float where the bake residual is millimetre-scale and constant with distance, so `DECAL_RASTER` collapses to one value (32768: the ring and the reticle had been under their own conflict at 8192 and simply never walked on a slope) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
212 lines
9.6 KiB
Rust
212 lines
9.6 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 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");
|
|
}
|