Files
benilla-octo/crates/benilla-app/src/ui_script/verb_event_gate.rs
T
SamandClaude Fable 5.1 b7c1d84729 a nameplate takes a right-click, and four wrong verb rows come off
- a right-click on a nameplate selects and interacts again: the plate registers the right button, as the reference's does (2233)
- the verb-events table files each fire site against its function's verified extent, and four rows it had misattributed are gone (2257)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:15 +07:00

408 lines
16 KiB
Rust

//! **The VERB-FIRED event gate** — `reference/1.12-verb-events.tsv` against the module that
//! registers each verb (decision 2251).
//!
//! ## The third question on the event seam
//!
//! Two gates compare event NAMES (`reference_ui`: a stock listener nothing feeds, 1889; a fire the
//! reference has no name for, 1883) and one compares ARGUMENTS (`event_shape_gate`, 2140). None of
//! them asks **who** fires it, and that is the hole B389/2244 fell through. The reference's
//! `SetTrainerServiceTypeFilter` commits through `0x4d8c90`, whose whole body is *write the mask,
//! re-run the finalizer, fire `TRAINER_UPDATE`*; so the stock `Blizzard_TrainerUI.lua` never
//! repaints the list itself — it calls the verb for its side effect. benilla's verb set the mask
//! and fired nothing. The name gate stayed green because the packet arm fires the same event, and
//! the tests stayed green because they asserted the mask. Our own retired `TrainerFrame.xml` had
//! repainted explicitly, so the behaviour left with the file that was compensating for it (1957),
//! and the director found it twelve days later. Every window that went stock (1751) carries the
//! same exposure: a verb the stock Lua calls *for the event*.
//!
//! ## The rule
//!
//! For every pair the reference fires on the verb's own call path (the table: the verb's body, or
//! a helper that only registered verbs call), and every verb benilla registers: **a file that
//! registers the verb also fires the event** — or the pair is declared below, and the declaration
//! is *checked*, never trusted:
//!
//! * [`ELSEWHERE`] — benilla fires it from a named feed instead, on the state the verb changed
//! (the verb bumps a generation, sets a touched flag, queues an intent the app drains). That
//! file must fire it, and the verb's own file must not, or the row is stale.
//! * [`GAP`] — nothing fires it on this verb's path, for a stated reason. The verb's own file
//! must still not fire it, or the row is stale.
//!
//! A verb no file registers is skipped: unbuilt verbs are `scripts/api-coverage.sh`'s queue
//! (1178), not this gate's.
//!
//! ## What "registers" and "fires" mean here, and the limit
//!
//! Text, not execution: the quoted literal `"Verb"` or `"EVENT"` in a non-test source file, with
//! comment lines dropped and the trailing test module cut. Lenient in exactly one direction — any
//! file that names the verb and fires the event passes — because the honest alternative, proving
//! the path *executes*, is a per-verb fixture, and 2244's own test is the measure of a fixture that
//! asserts the wrong thing. What this cannot see: a fire that is in the file but off the verb's
//! path — an `ELSEWHERE` row is a claim the session that wrote it read at the feed. What it sees
//! every time is the migration class itself: a verb whose module fires nothing.
use std::collections::BTreeSet;
use std::path::PathBuf;
/// One row of `reference/1.12-verb-events.tsv`.
struct Pair {
verb: String,
event: String,
shape: String,
via: String,
}
/// Pairs benilla fires from a feed, on the state the verb changes. `(verb, event, the file that
/// fires it)`; the file is matched as a path suffix. Each was read at the feed when the row was
/// written — the trigger is named so the next reader can check it in one grep.
const ELSEWHERE: &[(&str, &str, &str)] = &[
// The sell slot's diff (`auction_sell_item` moved, or `sell_slot_dirty`).
(
"ClickAuctionSellItemButton",
"NEW_AUCTION_UPDATE",
"benilla-app/src/ui_auction/mod.rs",
),
// The query goes to the wire; the inbox feed announces the list when it lands.
(
"CheckInbox",
"MAIL_INBOX_UPDATE",
"benilla-app/src/ui_mail/mod.rs",
),
// `craft_close` / `trade_skill_close` are drained by the app, whose store transition
// `(Some, None)` is the fire.
("CloseCraft", "CRAFT_CLOSE", "benilla-app/src/ui_craft.rs"),
(
"CloseTradeSkill",
"TRADE_SKILL_CLOSE",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_quest_log_collapses`: the app owns the collapse set, re-feeds the list, fires.
(
"CollapseQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
(
"ExpandQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
// `trade_skill_touched`, set by all four verbs, drained by `take_trade_skill_touched`.
(
"CollapseTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"ExpandTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillInvSlotFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillSubClassFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
// `repeat_count` moved (`repeat_changed`).
(
"DoTradeSkill",
"UPDATE_TRADESKILL_RECAST",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_loot_roll_confirms`: a Need/Greed on a bind-on-pickup roll is diverted to the popup.
(
"ConfirmLootRoll",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"ConfirmLootRoll",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
// `keybinds.generation` moves; `sync_dispatch` re-derives the table and fires.
(
"SetBinding",
"UPDATE_BINDINGS",
"benilla-app/src/bindings.rs",
),
// The toggle goes to the wire and the bar's pushed key changes on the reply — one round trip
// later than the reference, which fires locally from the toggle.
(
"TogglePetAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
(
"ToggleSpellAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
];
/// Pairs nothing fires on the verb's path, each with its reason. A row is a gap with a name,
/// never a reason to stop; the reference's own site is in the table for the grep into wow-re.
const GAP: &[(&str, &str, &str)] = &[
(
"CancelSkillUps",
"SKILL_LINES_CHANGED",
"the reference's verb (0x4d3e30) calls the temp-point reset and fires unconditionally at \
0x4d3e35; here the reset runs over a table this model keeps empty (`skills.rs`), so \
nothing moves and `ui_char.rs`'s feed has nothing to announce — the stock SkillFrame \
would only repaint under its own Close",
),
(
"ClickTargetTradeButton",
"TRADE_REPLACE_ENCHANT",
"the reference runs the enchant clash check 0x496170 when a spell is on the cursor and \
fires this for the replace dialog (wow-re staticpopup-dialog-bindings.md §3.1); casting \
an enchant onto the partner's slot is not built here — `ui_trade.rs` only mirrors the \
wire's enchant slot — so the verb is the money arm alone",
),
(
"CloseTrade",
"PLAYER_TRADE_MONEY",
"fired only when the trade did not complete (`[0xb71748] == 0`, wow-re \
incoming-trade-request-law.md), zeroing the cancelled offer under a frame TRADE_CLOSED \
hides; here `TradeSession::begin` resets the whole session at the next trade, so the \
next window opens at zero either way",
),
(
"CollapseCraftSkillLine",
"CRAFT_UPDATE",
"the reference commits through the 21-byte thunk 0x4f6be0 (the trainer's 0x4d8c90 shape); \
a no-op here because the craft list is flat — the header law was never ported from \
TradeSkill (0446, 0530's follow-up) — so there is nothing to repaint. Whether any 1.12 \
craft list carries more than one group is not established: 0446 covers Enchanting, \
Beast Training is unchecked",
),
(
"ExpandCraftSkillLine",
"CRAFT_UPDATE",
"the same thunk and the same no-op as CollapseCraftSkillLine",
),
(
"PetDismiss",
"PET_DISMISS_START",
"the worker 0x4bd6e0 sends the dismiss and fires this with a duration (`%d`, 10000 — \
wow-re pet-action-bar-api.md §10.8); no shipped file listens, the dismiss reaches the \
wire through the app's drain, and the pet frame follows the unit's departure",
),
(
"SelectGossipOption",
"GOSSIP_ENTER_CODE",
"a coded option raises the code-entry popup in the reference (the worker 0x4e2320); here \
coded options are greyed and unselectable (0081 v1, `reference_ui`'s UNPRODUCED row), and \
vmangos's `gossip_menu_option` carries zero coded rows, so no NPC on this server reaches it",
),
];
fn table() -> Vec<Pair> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-verb-events.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-verb-events.tsv");
text.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("verb\t"))
.filter_map(|l| {
let f: Vec<&str> = l.split('\t').collect();
(f.len() >= 4).then(|| Pair {
verb: f[0].to_string(),
event: f[1].to_string(),
shape: f[2].to_string(),
via: f[3].to_string(),
})
})
.collect()
}
/// Every non-test `.rs` under `crates/`: comment lines dropped, the trailing test module cut.
fn sources() -> Vec<(PathBuf, String)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut out = Vec::new();
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);
continue;
}
// The same file-name rule the sibling gates use: a test's own literals are not a
// surface we ship — and neither are the gates' own declared lists, this file's
// included (a `*_gate.rs` is a `#[cfg(test)]` module whose name does not say so).
let name = p.to_string_lossy();
if p.extension().is_none_or(|x| x != "rs")
|| name.contains("test")
|| name.ends_with("_gate.rs")
{
continue;
}
let whole = std::fs::read_to_string(&p).unwrap_or_default();
let head = whole
.rfind("#[cfg(test)]\nmod ")
.map_or(whole.as_str(), |i| &whole[..i]);
let text: String = head
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
out.push((p, text));
}
}
out.sort();
out
}
fn quoted(text: &str, name: &str) -> bool {
text.contains(&format!("\"{name}\""))
}
fn short(p: &std::path::Path) -> String {
let s = p.to_string_lossy();
s.split_once("/crates/")
.map_or(s.to_string(), |(_, t)| t.to_string())
}
/// The files that register `verb`, and whether any of them fires `event`.
fn verb_module_fires(src: &[(PathBuf, String)], verb: &str, event: &str) -> (Vec<String>, bool) {
let registrars: Vec<&(PathBuf, String)> = src.iter().filter(|(_, t)| quoted(t, verb)).collect();
let fires = registrars.iter().any(|(_, t)| quoted(t, event));
(registrars.iter().map(|(p, _)| short(p)).collect(), fires)
}
/// **Every event the reference fires from inside a verb is fired by the module that registers
/// the verb** — or declared, with the declaration checked.
#[test]
fn every_event_the_reference_fires_from_a_verb_is_fired_by_the_verb_s_module() {
let src = sources();
assert!(src.len() > 100, "source walk found {} files", src.len());
let declared: BTreeSet<(&str, &str)> = ELSEWHERE
.iter()
.chain(GAP.iter())
.map(|(v, e, _)| (*v, *e))
.collect();
let mut surprises = Vec::new();
let mut seen = 0;
for pair in table() {
let (registrars, fires) = verb_module_fires(&src, &pair.verb, &pair.event);
if registrars.is_empty() {
continue; // an unbuilt verb: api-coverage.sh's queue, not this gate's
}
seen += 1;
if fires || declared.contains(&(pair.verb.as_str(), pair.event.as_str())) {
continue;
}
let fired_by: Vec<String> = src
.iter()
.filter(|(_, t)| quoted(t, &pair.event))
.map(|(p, _)| short(p))
.collect();
surprises.push(format!(
"{} -> {} [{}{}] registered in {}; {}",
pair.verb,
pair.event,
pair.shape,
if pair.via == "-" {
String::new()
} else {
format!(" via {}", pair.via)
},
registrars.join(", "),
if fired_by.is_empty() {
"fired NOWHERE in benilla".to_string()
} else {
format!("fired only by {}", fired_by.join(", "))
},
));
}
assert!(
seen > 20,
"only {seen} pairs had a registered verb — the walk or the table is off"
);
assert!(
surprises.is_empty(),
"the reference fires these events from inside the verb, and the module that registers \
the verb here fires nothing — the stock Lua that calls the verb for its side effect \
repaints nothing (2244's class). Fire it from the verb, or declare the pair: ELSEWHERE \
when a feed fires it on THIS verb's change and you read that at the feed, GAP with the \
reason otherwise:\n {}",
surprises.join("\n ")
);
}
/// **A declaration is checked, not trusted** — an `ELSEWHERE` file that stopped firing, a verb
/// module that now fires what its row says it does not, a row for a pair the table no longer
/// carries: each fails until the row is corrected.
#[test]
fn the_declared_rows_are_still_true() {
let src = sources();
let table = table();
let in_table =
|verb: &str, event: &str| table.iter().any(|p| p.verb == verb && p.event == event);
let mut wrong = Vec::new();
for (verb, event, file) in ELSEWHERE {
if !in_table(verb, event) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: not a pair in the table any more"
));
}
let named: Vec<&(PathBuf, String)> = src
.iter()
.filter(|(p, _)| p.to_string_lossy().ends_with(file))
.collect();
if named.is_empty() {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: no source file ends with {file}"
));
} else if !named.iter().any(|(_, t)| quoted(t, event)) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: {file} no longer fires it"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
for (verb, event, _) in GAP {
if !in_table(verb, event) {
wrong.push(format!(
"GAP {verb} -> {event}: not a pair in the table any more"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"GAP {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
assert!(
wrong.is_empty(),
"stale declarations:\n {}",
wrong.join("\n ")
);
}