a disarmed fighter punches, and the quest log tags its elite rows

- a disarmed fighter swings bare-fisted: `UNIT_FLAG_DISARMED` was read nowhere but a dev banner, and the reference applies it inside `GetWeapon` on the `visFlag == 0` path, so the swing lands on AttackUnarmed; the RE cross-check (nine workers converging byte-for-byte) corrected the first reading twice — disarm hides exactly ONE weapon, main hand first (`5ec28d je 0x5ec2aa`), so a disarmed dual-wielder keeps swinging its dagger; and the weapon comes OFF the hand: the field-change reflex `0x5ff580` detaches a drawn main-hand weapon when the bit goes up, while a stowed one stays frozen on the back; one pure ladder feeds every unarmed-view site of the 82-site `visFlag` census — parry, the sheath machine, the melee sounds (a disarmed punch does not clang like a sword), Heroic Strike greying out, and the Attack button showing `Spell-Reset` with the weapon still equipped (1863)
- a quest-log row reads `(Elite)`, `(Dungeon)`, `(Raid)` or `(PvP)`: `QuestInfo.dbc` was never parsed and `GetQuestLogTitle`'s third return was a literal nil; the table is seven rows read out of `patch-2.MPQ`, SPARSE (1 Elite · 21 Life · 41 PvP · 62 Raid · 81 Dungeon · 82 World Event · 83 Legendary — vanilla's 1 is "Elite", not TBC's "Group"), and the base `dbc.MPQ` copy has only four, so a chain stopping short would lose Dungeon, World Event and Legendary; the row gains the reference's own right-flush tag string and its precedence — a finished quest's FAILED/COMPLETE word OVERWRITES the tag — replacing the " (Complete)" we appended to the title; the RE round confirmed the tag path and found the arity bug: six values ALWAYS (`mov eax,6`), the level a NUMBER 0 even on a header, where we gave one nil; the reference's title shrink stays out: an explicit width is a WRAP width here (1873)
- the death thud is silent indoors, and that is the content: BOTH the footstep and the thud lookups key on `TerrainType.SoundID`, and TerrainType 10 "None" has SoundID 0 — the footstep table has a row there for 17 classes and `DeathThudLookups` has none, so the floor that creaks underfoot swallows the body that lands on it; 10 075 of 10 299 WMO materials (97.8%) are TerrainType 10 and 694 of 815 WMO roots have no surface that can thud, every inn and tavern among them, the Kharanos inn included — the code was always right and the prose was not; the RE round also confirmed the strict `depth > 2.0` sense and the size-class column, pinned a sound id of 0 as silence, and placed `0x6005d0` as the loot sparkle, not a second sound; `thudcensus` prints all 121 buildings that do thud rather than a head, because a truncated list cannot answer "does THIS one" (1870)
- a questgiver's overhead marker follows its rider on and off the mount: the reference re-runs the attach from the `UNIT_FIELD_MOUNTDISPLAYID` watch handler, and benilla picked the slot once at build, relying on a teardown the reseat rework no longer does (B199), so the seat rode at the WRONG slot; `overhead_slot` is now the one definition of the pick (`0x6074c0`) and the marker rebuilds when it moves (1871); and the chat menu's kit was ours overwriting the reference's — `UIMenu.xml` loads earlier on the chain and our `ChatFrame.xml` carried the same kit under the SAME ten names, faithful enough that deleting it changes nothing; nothing ever opened the chat menu, and now a test does; an event census holds the gap between registered and fired events at exactly eighteen, failing on a surprise AND on a quietly gained producer — features not yet built, none a split pair (1869)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:49:33 +07:00
co-authored by Claude Fable 5.1
parent f38c315d59
commit 94cc4429eb
31 changed files with 2146 additions and 283 deletions
@@ -965,3 +965,66 @@ fn the_combat_log_window_runs_its_own_bottom_button_blink() {
"window 2's bottom-button flash blinks while it is scrolled up"
);
}
/// **The chat menu runs on the REFERENCE's UIMenu kit** — `Interface\FrameXML\UIMenu.xml` and its
/// own `UIMenu.lua`, not a transcription of them.
///
/// Ours kept the reference's ten names in `ChatFrame.xml`, which loads at manifest 939 against the
/// chain's 194, so our copies overwrote the chain's from the day that entry landed — the reverse of
/// 1855's direction, and nothing drove this menu in a test, so nothing said so. Decision 1869.
#[test]
fn the_chat_menu_builds_its_rows_on_the_references_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
"ChatFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let _ = s.errors();
// `ChatMenu_OnLoad` calls `UIMenu_Initialize()` bare, relying on `this` — the reference's own
// idiom — then `UIMenu_AddButton` per row. If the kit were missing, the rows would not exist.
s.run("ChatMenu:Show()").unwrap();
assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors());
assert!(
s.eval::<i64>("return ChatMenu.numButtons or 0").unwrap() >= 7,
"the reference's `UIMenu_AddButton` counted the rows this window adds"
);
assert_eq!(
s.eval::<String>("return ChatMenuButton1:GetText()")
.unwrap(),
"Say",
// The reference labels a row with `button:SetText(text)` — the Button's own text, not a
// named FontString, so there is no `…Button1Text` to read.
"row 1 is the Say row, built by the chain's kit"
);
// A row click runs the owner's func and closes the menu — `UIMenuButton_OnClick`'s contract.
s.run("ChatMenuButton1:Click()").unwrap();
assert!(
s.errors().is_empty(),
"a row click raises: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return ChatMenu:IsShown() and true or false")
.unwrap(),
"the reference's row click hides the menu"
);
assert!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
.starts_with("/s"),
"and it seeded the edit box with the row's slash"
);
}
@@ -624,6 +624,84 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
/// The empty-log v1 simplification (this file's XML header comment): zero entries hides every row,
/// disables Abandon, and shows the centered empty-state message instead of `EmptyQuestLogFrame`.
/// The quest TAG on the list row (`GetQuestLogTitle`'s third return): it paints on its own
/// right-flush `$parentTag` string, wrapped in the ref's parentheses, and a finished quest's
/// Failed/Complete word OVERWRITES it — the ref's own precedence (`QuestLogFrame.lua:187-195`),
/// which is why one row never shows both. Headers carry no tag. The title keeps the bare quest
/// name: the state word is NOT appended to it any more.
#[test]
fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
// The state word comes from the reference's OWN globals (`COMPLETE`/`FAILED`) as of this row's
// ref-verbatim tag handling — the app loads GlobalStrings.lua as `benilla.toc`'s second entry,
// so this harness must too or the override silently reads nil.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
let mut state = eight_entries();
// Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins.
// Row 3: no tag at all, in progress. Row 4: a header, which can carry neither.
state.entries[0].tag = Some("Elite".into());
state.entries[1].tag = Some("Raid".into());
state.entries[1].complete = 1;
state.entries[2].complete = -1;
state.entries[3].is_header = true;
state.entries[3].title = "Elwynn Forest".into();
state.entries[3].tag = None;
s.set_quest_log(state);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let tag = |s: &mut UiScript, i: u32| {
s.eval::<String>(&format!("return QuestLogTitle{i}Tag:GetText()"))
.unwrap()
};
assert_eq!(
tag(&mut s, 1),
"(Elite)",
"the engine's tag, in parentheses"
);
assert_eq!(
tag(&mut s, 2),
"(Complete)",
"COMPLETE overwrites the quest's own tag — ref l.190-192"
);
assert_eq!(tag(&mut s, 3), "(Failed)", "FAILED, same override");
assert_eq!(tag(&mut s, 4), "", "a header carries no tag");
// The title is the bare (indented) name — the state word lives on the tag string now, so
// appending it here too would double it.
let title1 = s
.eval::<String>("return QuestLogTitle1Text:GetText()")
.unwrap();
assert_eq!(title1, " Quest 1");
let title2 = s
.eval::<String>("return QuestLogTitle2Text:GetText()")
.unwrap();
assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title");
// Right-flush at the row's right edge less 2 — the ref's own anchor (its
// QuestLogFrame.xml:10-21).
let row_right = s.eval::<f32>("return QuestLogTitle1:GetRight()").unwrap();
let tag_right = s
.eval::<f32>("return QuestLogTitle1Tag:GetRight()")
.unwrap();
assert!(
(row_right - tag_right - 2.0).abs() < 0.5,
"tag right-flush at row right -2 (row {row_right}, tag {tag_right})"
);
}
#[test]
fn empty_quest_log_hides_rows_and_disables_abandon() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1948,7 +1948,11 @@ mod tests {
for chunk in text.split('<').skip(1) {
let Some(end) = chunk.find('>') else { continue };
let tag = &chunk[..end];
if !tag.trim_start().starts_with("Include") {
// `<Include>` brings a sibling document; `<Script file=>` brings the code, and
// that is where nearly every `RegisterEvent` lives — a reader that follows only
// the first sees a window's frames without its handlers.
let kind = tag.trim_start();
if !kind.starts_with("Include") && !kind.starts_with("Script") {
continue;
}
if let Some(file) = tag_attr(tag, "file") {
@@ -2037,4 +2041,156 @@ mod tests {
stale.join("\n ")
);
}
/// **A stock file listening for an event nothing produces is silent on both sides.**
///
/// This is 1819's shape: `ui_unit.rs` fired the Classic Era power pair while the reference's
/// frames registered the 1.12 per-resource names, so no mana bar could live-update — and
/// nothing anywhere said so, because an event name is a plain string at both ends. 1818 is the
/// same seam one API over. The *arity* half of that class already has an instrument (the shape
/// gate, 1842/1843/1845); this is the event half, which had none.
///
/// A CENSUS with a declared set, not a hard zero: most of these are features we have not built,
/// and listing them is the point. What must not happen is a NEW one appearing — that means a
/// window migration just brought a listener nothing feeds, which is exactly how 1819 arrived.
///
/// **Constructed names have to be declared**, because a literal scan cannot see them:
/// `ui_unit.rs` builds the power events with `format!("UNIT_{}", power_token(...))`, and a gate
/// blind to that reports nine false positives — which is what its first run did.
#[test]
fn every_event_a_chain_file_registers_has_a_producer() {
let _data = benilla_formats::wow_data_or_skip!();
// Names benilla builds at runtime rather than writing as literals. Each is a family, with
// the site that constructs it — an entry here is a promise that something fires it.
const CONSTRUCTED: &[&str] = &[
// `ui_unit.rs`: `format!("UNIT_{}", power_token(ty))` and its `UNIT_MAX…` twin, over
// `power_token`'s five resources (`unit/mod.rs`). Decision 1819.
"UNIT_MANA",
"UNIT_RAGE",
"UNIT_FOCUS",
"UNIT_ENERGY",
"UNIT_HAPPINESS",
"UNIT_MAXMANA",
"UNIT_MAXRAGE",
"UNIT_MAXFOCUS",
"UNIT_MAXENERGY",
"UNIT_MAXHAPPINESS",
];
// Registered by a chain file we load, produced by nothing. Each is a feature we have not
// built; none is 1819-shaped, because no PAIR is split (a half-fired pair is the tell).
const UNPRODUCED: &[(&str, &str)] = &[
("BAG_OPEN", "ContainerFrame.lua"),
("CHAT_MSG_RAID_WARNING", "RaidWarning.lua"),
(
"CVAR_UPDATE",
"TextStatusBar.lua — a CVar change does not repaint the bar text",
),
("DISPLAY_SIZE_CHANGED", "the four paperdoll files"),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"PARTY_MEMBER_DISABLE",
"PartyMemberFrame.lua — the offline grey-out",
),
("PARTY_MEMBER_ENABLE", "PartyMemberFrame.lua"),
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
(
"PLAYER_FLAGS_CHANGED",
"TargetFrame.lua — the AFK/DND badge",
),
(
"PLAYER_REGEN_ENABLED",
"PlayerFrame.lua — and _DISABLED is unfired too, so the pair is whole",
),
("PLAYTIME_CHANGED", "PlayerFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
(
"UNIT_MODEL_CHANGED",
"four files — the paperdoll model refresh",
),
("UNIT_PORTRAIT_UPDATE", "three files — the portrait refresh"),
];
let mut fired: std::collections::HashSet<String> =
CONSTRUCTED.iter().map(|s| (*s).to_string()).collect();
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
let text = std::fs::read_to_string(&p).unwrap_or_default();
let mut from = 0;
while let Some(i) = text[from..].find("fire_event(") {
let at = from + i + "fire_event(".len();
from = at;
let rest = text[at..].trim_start();
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
}
}
}
}
}
}
let toc = &super::super::addons::Addon::builtin().toc.files;
let mut dead: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
for text in entry_sources(entry) {
let mut from = 0;
while let Some(i) = text[from..].find("RegisterEvent(") {
let at = from + i + "RegisterEvent(".len();
from = at;
let rest = text[at..].trim_start();
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
let ev = &body[..end];
if !fired.contains(ev) {
dead.entry(ev.to_string()).or_insert_with(|| entry.clone());
}
}
}
}
}
}
let expected: std::collections::HashSet<&str> =
UNPRODUCED.iter().map(|(e, _)| *e).collect();
let surprises: Vec<String> = dead
.iter()
.filter(|(e, _)| !expected.contains(e.as_str()))
.map(|(e, f)| format!("{e} (registered in {f})"))
.collect();
assert!(
surprises.is_empty(),
"a chain file listens for an event NOTHING fires, and it is not one of the known gaps \
— this is 1819 arriving: a window migration brought a listener with no producer:\n {}",
surprises.join("\n ")
);
let fixed: Vec<&str> = UNPRODUCED
.iter()
.map(|(e, _)| *e)
.filter(|e| !dead.contains_key(*e))
.collect();
assert!(
fixed.is_empty(),
"these now HAVE a producer — take them out of UNPRODUCED so the list keeps meaning \
what it says:\n {fixed:?}"
);
}
}