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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user