loot rows wear their art, W holds through chat, and food greys in combat

- loot rows show their Quickslot border, depress on click and light up under the cursor: the stock `<LootButton>` is parsed by the Button loader, as the reference's vtable has it (2195)
- holding W keeps you running through Enter to chat and M to close the map: a UI frame eating a key never releases a held movement binding (2196)
- food, drink, bandages and mounts grey out on the action bar in combat: an ITEM slot runs its on-use spell's usable walk, and its cooldown gate is the item-keyed on-hold record (2200, 2201)
- re-clicking the character you already have selected keeps its facing instead of snapping it square (2194)
- the shift-compare plate seats beside the tooltip and is owned by the engine, so a stale plate never rides over the next hover (2202)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:52:36 +07:00
co-authored by Claude Fable 5.1
parent 1116efbb68
commit 46673c3f08
23 changed files with 1112 additions and 217 deletions
+140 -40
View File
@@ -15,8 +15,11 @@
//! probe is always first). Super held matches nothing;
//! - [`Kind::Held`] commands **latch** on the matching press and unlatch on the *base key's*
//! release — the reference's `runOnUp` movement law, which is why tapping Shift mid-run does
//! not stop you, and why a chat box taking focus stops movement (latches clear on the capture
//! edge — the reference's own focus handler, `0x514490`) without eating the release;
//! not stop you, and why **nothing the UI does stops you**: a chat box taking focus and a
//! fullscreen frame eating a key both suppress the *press* and release nothing already held
//! (decision 2196). The only things that end a latch are the base key's release, the
//! [stuck-latch sweep](latch_and_dispatch) that stands in for a release the window never saw
//! (OS focus loss, the loading cover), and the VM swap;
//! - [`Kind::Edge`]/[`Kind::EdgeUpDown`] run their 1.12 Lua bodies in the VM;
//! - [`Kind::Host`] lands in [`BindingsState::fired`] for engine consumers (chat open, TAB
//! targeting, nameplates, autorun, camera zoom, …).
@@ -131,8 +134,6 @@ pub(crate) struct BindingsState {
/// Accumulated analog amount per host command this frame (wheel notches; a key press adds
/// the reference's own 1.0 step) — the camera zoom's input.
amounts: Vec<(Cmd, f32)>,
/// Rising edge of the keyboard-capture gate last frame (internal: clears Held latches once).
was_typing: bool,
}
impl BindingsState {
@@ -513,23 +514,29 @@ fn latch_and_dispatch(
// latches or fires happen while armed.
}
// ── The typing edge ── a box taking focus stops movement (the reference's focus handler
// releases every direction bit, `0x514490`): Held latches clear once on the rising edge;
// EdgeUpDown latches stay armed — their release half still fires (the reference delivers
// the up of a pressed binding regardless).
// ── Who owns this frame's keys ── a focused EditBox eats every key for as long as it holds
// focus; a shown keyboard frame ate the particular keys in `capture.consumed`. Both suppress a
// PRESS below and do nothing else — **a UI focus change releases nothing already held**.
//
// This is where bug 2196 lived. The block here used to drop every [`Kind::Held`] latch on the
// rising edge of `capture.typing`, on a misreading of `0x514490` as "the reference's chat-focus
// handler": its sole caller `0x493058` hangs off the CSimpleTop root's WM_ACTIVATE callback
// slot (`[root+0x1134]`, event category 2, payload 0 = deactivate), so it is the **OS
// window-deactivate** handler, not a UI-focus one (wow-re `loading-screen-input-law.md`; the
// conflated phrasing was `rf79-autorun-cancel-set.md`'s "Chat EditBox / window focus" row).
// In the reference a focused box merely turns the movement handlers into no-ops and the
// direction bits are *frozen, not cleared* — so holding W and pressing ENTER keeps you
// running, and the world map eating the `M` that closes it keeps you running too. Both
// regressions were one clear.
//
// The window-deactivate clear needs no code of its own: bevy's `KeyboardFocusLost` →
// `ButtonInput::release_all` makes every latched base key read up, and the stuck-latch sweep
// at the bottom of this system turns that into real releases (the loading cover reaches it the
// same way, through `loading_screen::input`'s `swallow`).
//
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on the
// two ownership terms and the capture arm); release edges unlatch and fire the runOnUp up-half.
let typing = capture.typing;
if typing && !state.was_typing {
state.latched.retain(|&(_, b)| match b {
Bound::Spec(c) => !matches!(SPECS[c.0 as usize].kind, Kind::Held),
// An addon's `runOnUp` latch is an EdgeUpDown pair by another name — its release half
// is a Lua body that must still run, so it stays armed for the same reason.
Bound::Addon(_) => true,
});
}
state.was_typing = typing;
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on
// typing and the capture arm); release edges unlatch and fire the runOnUp up-half.
for ev in keyboard.read() {
let key = chord::normalize_key(ev.key_code);
match ev.state {
@@ -546,7 +553,11 @@ fn latch_and_dispatch(
| KeyCode::ArrowUp
| KeyCode::ArrowDown
);
if armed || (typing && !arrow_exempt) || sup || ev.repeat {
// A keyboard frame's existence gate ate this one key (decision 1319) — the
// world map's fullscreen `OnKeyDown`, a cinematic, the stack-split spinner. Per
// key, so the map eating its own `M` leaves every other binding alone.
let eaten = capture.consumed.contains(&ev.key_code);
if armed || (typing && !arrow_exempt) || eaten || sup || ev.repeat {
continue;
}
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
@@ -678,9 +689,14 @@ fn latch_and_dispatch(
}
}
// ── The stuck-latch sweep ── a release the window never saw (focus loss, the macOS
// modifier eater's cousin): any latch whose base key reads up in the input state unlatches
// now, firing its up-half so a pushed action button unsticks visibly.
// ── The stuck-latch sweep ── a release the window never saw: any latch whose base key reads
// up in the input state unlatches now, firing its up-half so a pushed action button unsticks
// visibly. This is also where the reference's two real bulk clears land, because bevy already
// zeroes `ButtonInput` for both: **OS window deactivate** (`KeyboardFocusLost` →
// `release_all` — `0x514490`'s `and eax,0xfffff00f`, the direction bits released while
// autorun survives) and the loading cover (`loading_screen::input`'s `swallow` — the
// world-enter cascade's `0x5144c0`, which clears everything). Nothing about the UI's own
// keyboard focus reaches here, and that is the point (2196).
let mut stuck: Vec<BindKey> = Vec::new();
for &(k, _) in &state.latched {
let up = match k {
@@ -880,6 +896,13 @@ mod tests {
app.world().resource::<BindingsState>()
}
/// What a keyboard FRAME ate this frame — the list `feed_ui_input` rewrites every pass, which
/// this harness has no copy of, so it is **set** rather than pushed (an accumulating list would
/// keep suppressing a key the frame stopped eating rounds ago).
fn frame_ate(app: &mut App, keys: &[KeyCode]) {
app.world_mut().resource_mut::<UiKeyboardCapture>().consumed = keys.to_vec();
}
/// One addon's `Bindings.xml`, in the reference's own shape: a `runOnUp` binding whose single
/// body forks on `keystate`, and a one-shot beside it. Each half counts itself in a global, so
/// the assertions below read what the VM actually ran rather than what we told it to run.
@@ -1020,14 +1043,6 @@ mod tests {
);
}
/// A `runOnUp` addon latch survives a chat box taking focus, and its release half still runs.
///
/// The typing edge clears [`Kind::Held`] latches (the reference's focus handler releasing the
/// direction bits) and deliberately leaves [`Kind::EdgeUpDown`] armed, because the up half of
/// a *pressed* binding is delivered regardless of focus. An addon's `runOnUp` binding is that
/// same pair wearing one chunk, so it must follow the same rule — dropped on the focus edge,
/// it would leave whatever its down half started running forever, with no key left to press
/// to stop it.
/// **The alt-arrow exemption: you can turn while the chat box has focus.**
///
/// A focused EditBox swallows every key — that is the reference's own handler returning 1 on
@@ -1076,8 +1091,16 @@ mod tests {
);
}
/// **Nothing a box taking focus does releases what is already held** (2196), and a `runOnUp`
/// addon latch still delivers its up-half when the key finally goes up.
///
/// The reference's focused box turns the movement handlers into no-ops — the direction bits are
/// frozen, not cleared — so a held binding of ANY kind rides the focus change out. This test
/// used to assert the opposite for [`Kind::Held`] (`assert!(!pressed(MOVE_FORWARD))`), on the
/// misreading of `0x514490` as a chat-focus handler that 2196 corrects: its sole caller hangs
/// off the WM_ACTIVATE slot, so it is the OS window-deactivate clear.
#[test]
fn a_run_on_up_addon_latch_survives_the_typing_edge_and_still_releases() {
fn a_held_latch_rides_out_a_box_taking_focus_and_still_releases() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.register_addon_bindings(
@@ -1093,18 +1116,28 @@ mod tests {
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert_eq!(lua_count(&app, "PROBE_DOWN"), 1);
// A box takes focus: movement stops, the addon's latch stays.
// A box takes focus. Both latches ride it out: the reference freezes the direction bits,
// it does not clear them.
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"holding W and opening the chat box keeps you running (2196)"
);
assert_eq!(
lua_count(&app, "PROBE_UP"),
0,
"the focus edge is not a release — nothing has run the up half yet"
);
// And the keys still stop when the player lets go, box focused or not.
release_key(&mut app, KeyCode::KeyW);
release_key(&mut app, KeyCode::KeyJ);
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"releasing W stops you, while typing exactly as otherwise"
);
assert_eq!(
lua_count(&app, "PROBE_UP"),
1,
@@ -1445,19 +1478,25 @@ mod tests {
assert!(!state(&app).pressed(by_name("BONUSACTIONBUTTON1")));
}
/// **The typing gate blocks NEW presses and releases nothing already held** (2196).
///
/// The two halves used to be one: the gate's rising edge dropped every [`Kind::Held`] latch, so
/// holding W and pressing ENTER stopped you dead. It rests on nothing — `0x514490`, the clear
/// that reading cited, is the OS window-deactivate handler (sole caller `0x493058`, off the
/// WM_ACTIVATE callback slot), and the reference's focused box only makes the movement handlers
/// no-ops: the bits are frozen, not cleared.
#[test]
fn the_typing_gate_blocks_new_input_and_clears_held_latches_once() {
fn the_typing_gate_blocks_new_input_but_a_held_binding_keeps_running() {
let mut app = harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// A box takes focus: movement stops (the reference's focus handler releases the
// direction bits), and new presses do nothing.
// A box takes focus. You keep running, and new presses type instead of binding.
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"latches clear on the capture edge"
state(&app).pressed(cmd::MOVE_FORWARD),
"the capture edge is not a release — holding W keeps you running while you type"
);
press_key(&mut app, KeyCode::KeyX);
app.update();
@@ -1465,6 +1504,13 @@ mod tests {
!state(&app).fired(cmd::SIT_OR_STAND),
"typed keys are not bindings"
);
// Letting go still stops you, box focused or not.
release_key(&mut app, KeyCode::KeyW);
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"the release is delivered regardless of focus"
);
// Focus drops; keys work again.
release_key(&mut app, KeyCode::KeyX);
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = false;
@@ -1474,6 +1520,60 @@ mod tests {
assert!(state(&app).fired(cmd::SIT_OR_STAND));
}
/// **The world map's shape** (2196, the director's report): a shown keyboard-enabled frame eats
/// the key that closes it, and that must cost the key its binding and nothing else.
///
/// `WorldMapFrame` is `frameStrata="FULLSCREEN" enableKeyboard="true"` with an `<OnKeyDown>`
/// that re-matches `GetBindingKey("TOGGLEWORLDMAP")` in Lua and calls `RunBinding` itself, so
/// under the existence gate (decision 1319) it consumes every key while shown — including the
/// `M` that closes it. benilla reported that consumption as `typing`, whose rising edge then
/// dropped the movement latch: holding W and tapping `M` twice left you standing in a closed
/// map. The consumption is per KEY now, and releases nothing.
#[test]
fn a_keyboard_frame_eating_its_own_toggle_key_does_not_stop_a_held_run() {
let mut app = harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// The map is open and eats this frame's `M` (its own Lua runs the toggle). `M` carries a
// `Kind::Edge` binding, which a no-VM harness cannot observe — so the suppression half is
// asserted on `X` below, and this leg asserts the half the report is about.
frame_ate(&mut app, &[KeyCode::KeyM]);
press_key(&mut app, KeyCode::KeyM);
app.update();
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"the frame ate the toggle key; you are still running (2196)"
);
// The eaten key loses its binding…
release_key(&mut app, KeyCode::KeyM);
app.update();
frame_ate(&mut app, &[KeyCode::KeyX]);
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(
!state(&app).fired(cmd::SIT_OR_STAND),
"the frame ate this key: its binding must not also fire"
);
// …and only that key. The old whole-frame flag suppressed every binding in the frame.
release_key(&mut app, KeyCode::KeyX);
app.update();
frame_ate(&mut app, &[KeyCode::KeyM]);
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(
state(&app).fired(cmd::SIT_OR_STAND),
"consumption is per key, not per frame"
);
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"and W has been held throughout"
);
}
#[test]
fn mouse_buttons_bind_only_over_the_world_and_the_wheel_respects_ui() {
let mut app = harness();
+8 -6
View File
@@ -64,13 +64,15 @@ pub(super) fn select_input(
// within the window is the double-click → enter world.
let double =
last_click.is_some_and(|(row, at)| row == i && now - at < DOUBLE_CLICK_SECS);
let was = roster.selected();
*last_click = Some((i, now));
// The ref selects on **every** click, the row you were already on included — which
// is what re-squares the facing (`Roster::select_seq`). Only the double still
// needs the "already selected" test.
roster.select(Some(i));
if was == Some(i) && double {
// Selecting is gated on the row actually CHANGING — the ref's own
// `CharacterSelectButton_OnClick` is that gate and nothing else, so the row you
// are already on is not re-selected and keeps the facing you dragged into it
// (`Roster::click_row`, decision 2194).
roster.click_row(i);
// `OnDoubleClick` runs the same gated select and then enters the world
// unconditionally — it does not re-test what was selected before.
if double {
enter_world = true;
}
}
+54 -3
View File
@@ -183,10 +183,16 @@ pub(crate) struct Roster {
///
/// The ref's `SelectCharacter` zeroes the select facing **unconditionally**: `0x472950`'s
/// `mov ds:0xb4217c, 0` sits one instruction *above* the already-built discriminator, so it
/// dominates both legs, and the merged tail re-applies it geometrically — so re-clicking the
/// row you are already on snaps the character square again (wow-re
/// dominates both legs, and the merged tail re-applies it geometrically (wow-re
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
/// `selected`, because "the same index again" is a selection.
/// `selected`, because the engine re-squares on a *re*-selection of the same index too — which
/// is exactly what a roster refresh does, calling it with the index it already holds.
///
/// **The click is the one caller that does not reach it**, and the gate is in the stock Lua
/// rather than in the engine — see [`Roster::click_row`] and decision 2194. The caller census
/// is wow-re `glue/scratch/select-character-caller-gate.md`: of the ten Lua call sites only the
/// two click handlers are gated, and of `0x472740`'s four C callers two are the roster teardown
/// passing `-1` (so `0x472950` exits above the reset) — every ungated path re-squares.
pub(super) select_seq: u64,
/// The guid we answered the IO thread with; `Some` = a login is requested/live.
pub(super) pending_pick: Option<u64>,
@@ -261,6 +267,24 @@ impl Roster {
self.select_seq = self.select_seq.wrapping_add(1);
}
/// A click on a character row — the ref's `CharacterSelectButton_OnClick`, whose entire body is
/// the gate `if ( id ~= CharacterSelect.selectedIndex ) then CharacterSelect_SelectCharacter(id)`
/// (shipped `CharacterSelect.lua` l.305-310; `OnDoubleClick`, l.312-318, repeats it verbatim
/// before entering the world, and the ten `CharSelectCharacterButtonTemplate` buttons carry
/// `id="1".."10"` 1:1 with the character index, so `id` *is* the row).
///
/// So the row you are already on is never re-selected from a click, and the facing zero
/// [`Self::select`] owes never fires for it: the angle you dragged the character to survives
/// clicking it again. Decision 2194, correcting 1533 — the engine function is unconditional as
/// recorded (verified again, three ways, in wow-re
/// `glue/scratch/select-character-caller-gate.md`), but the click never reaches it: the binding
/// has exactly one live call site, and the two handlers that lead to it both gate.
pub(super) fn click_row(&mut self, row: usize) {
if self.selected != Some(row) {
self.select(Some(row));
}
}
/// The selected row, if any.
pub(super) fn selected(&self) -> Option<usize> {
self.selected
@@ -1467,6 +1491,33 @@ mod tests {
);
}
/// **The report**: drag the character round on the select screen, then click the row it is
/// already standing on, and it snapped square again — the reference keeps the angle.
/// `CharacterSelectButton_OnClick`'s whole body is `if ( id ~= CharacterSelect.selectedIndex )`,
/// so that click never reaches the engine's unconditional facing zero at all (2194, correcting
/// 1533 — which verified the engine function and never asked what calls it). The facing reset
/// rides `select_seq`, so "did it re-square" is exactly "did the counter move".
#[test]
fn re_clicking_the_selected_row_keeps_the_facing() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, "Xero")],
..Roster::default()
};
roster.click_row(0);
let squared = roster.select_seq;
roster.click_row(0);
assert_eq!(
roster.select_seq, squared,
"a click on the row already selected must not re-select — the dragged angle survives",
);
roster.click_row(1);
assert_eq!(
roster.select_seq,
squared + 1,
"…while a click on a DIFFERENT row selects, and squares the character it brings up",
);
}
/// …but a just-created character still wins, which is the reference's precedence and the
/// order it comes in: the C side pushes the restored index into Lua first, and
/// `UpdateCharacterList`'s deferred `selectLast` flag overwrites it (B119 stays fixed).
@@ -211,8 +211,10 @@ pub(super) fn feed_glue_preview(
// glue screens, so the reset lives at the selection edge rather than in the resource.
//
// Keyed on the selection **counter**, not on who is shown: the ref's `SelectCharacter` zeroes
// the facing unconditionally (`0x472950`, above the already-built discriminator), so clicking
// the row you are already on re-squares the character too. See `Roster::select_seq`.
// the facing unconditionally (`0x472950`, above the already-built discriminator), so selecting
// the same index again — what a roster refresh does — re-squares the character too. A *click*
// on the selected row is gated out one level up and never gets here (`Roster::click_row`,
// 2194). See `Roster::select_seq`.
if *showing != Some(roster.select_seq) {
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
+55 -5
View File
@@ -568,11 +568,24 @@ impl Cooldowns {
/// set; `+0x28`/`+0x2c` are never referenced and no time source is called). Its reference
/// consumers are all bit25/cooldown-on-event gates: the usable walk's grey-while-parked leg
/// (`0x6e3fb1`) — ours — and the cast-fail on-hold revert.
pub(crate) fn has_on_hold_record(&self, spell_id: u32, spell: Option<&SpellDisplay>) -> bool {
let category = spell.map_or(0, |s| s.category);
///
/// **It is item-keyed, and that is not cosmetic** (wow-re `action-button-state-api.md` §2c.4,
/// 2026-09-13). The node walk's spell-id leg is `node+0x08 == spellId && node+0x0c == itemId`
/// (`6e173f`/`6e1744`), and `0x6e2fc0` — the action bar's ITEM gate, and the **sole** consumer
/// of the item-keyed form image-wide — passes the item ENTRY as that second argument
/// (`6e3037 push esi`). Every other caller passes `0` (`0x6e2fa0`'s `push 0` at `6e2fa9`).
/// So an item's own parked record is only findable under its entry; querying a spell slot's
/// `(spell, 0)` form can never see it.
///
/// `category` is the caller's for the same reason: `0x6e1690` starts from the spell's own
/// `[SpellRec+0x8]` and, when `itemId != 0`, **overwrites** it from the item's fifth spell
/// array `spellcategory[5]` (`rec+0x16c+4i`, `6e16f6`–`6e171b`, last match wins). Our wire
/// hands that resolved value over as `ItemUseSpell::category`, so the item's call site passes
/// the item's category and a spell's passes the spell's.
pub(crate) fn has_on_hold_record(&self, spell_id: u32, item_entry: u32, category: u32) -> bool {
self.records.iter().any(|r| {
r.on_hold
&& ((r.spell_id == spell_id && r.item_id == 0)
&& ((r.spell_id == spell_id && r.item_id == item_entry)
|| (category != 0
&& r.category == category
&& !r.category_recovery.duration.is_zero()))
@@ -728,7 +741,7 @@ mod tests {
// 0379's INTERIM: there is no separate GCD site); the corrected `0x6e1690` on-hold
// predicate stays false (no parked record).
assert!(cds.not_ready(133, 0, Some(&fireball()), mid));
assert!(!cds.has_on_hold_record(133, Some(&fireball())));
assert!(!cds.has_on_hold_record(133, 0, fireball().category));
}
/// The director's Frost Nova report (decision 0947): a cooldown-carrying spell's GO
@@ -815,7 +828,7 @@ mod tests {
);
assert!(cds.not_ready(5384, 0, Some(&fd), t0 + Duration::from_secs(60)));
assert!(
cds.has_on_hold_record(5384, Some(&fd)),
cds.has_on_hold_record(5384, 0, fd.category),
"the corrected 0x6e1690: an on-hold record — the usable walk's grey-while-parked"
);
@@ -884,6 +897,43 @@ mod tests {
);
}
/// **`0x6e1690`'s item-keyed form** (wow-re `action-button-state-api.md` §2c.4). The node
/// walk's spell-id leg is `node+0x08 == spellId && node+0x0c == itemId`, and the action bar's
/// ITEM gate `0x6e2fc0` is the sole caller image-wide that passes a **non-zero** `itemId` —
/// the item ENTRY, at `6e3037 push esi`. Our store keys an item's record `(use_spell, entry)`
/// to match, so the spell-slot form `(spell, 0)` must NOT find it: a query keyed `0` walking
/// into an item's parked record would be the leg firing for the wrong button, and a query
/// keyed on the entry failing to find it is the bug this pins — the item's gate would have
/// rested on the category leg alone.
#[test]
fn an_on_hold_record_is_findable_only_under_the_key_that_armed_it() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// An on-use item whose spell carries bit 25 (cooldown-on-event), category 0 so the
// category leg cannot mask the spell-id leg's answer either way.
let use_spell = ItemUseSpell {
spell_id: 5384,
cooldown_ms: 30_000,
category: 0,
category_cooldown_ms: 0,
};
let on_event = spell(0, 30_000, 0, (0, 0), 0x0200_0000);
cds.start_item(1487, &use_spell, Some(&on_event), t0);
assert!(
cds.has_on_hold_record(5384, 1487, 0),
"the ITEM gate's own query — `0x6e2fc0` passes the entry as itemId"
);
assert!(
!cds.has_on_hold_record(5384, 0, 0),
"the spell-slot form must not reach an item's record: itemId is part of the match"
);
assert!(
!cds.has_on_hold_record(5384, 999, 0),
"and not another item's entry either"
);
}
#[test]
fn item_use_cooldowns_key_on_the_item_and_respect_the_wire_triple() {
let t0 = Instant::now();
+3 -1
View File
@@ -186,7 +186,9 @@ pub(super) fn control(
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
// works. The gate is `UiKeyboardCapture`, which the focused chat EditBox drives; the free-fly
// works. The gate is `UiKeyboardCapture::typing`, which the focused chat EditBox drives —
// deliberately not its per-key `consumed` twin (2196): that one says a keyboard FRAME ate one
// key, whose business is that key's binding, not whether a dev free-cam may read WASD. The free-fly
// chord below is deliberately outside it, like every dev chord ([`modkeys::dev_chord`]).
let typing = ui_capture.typing;
// The rebindable inputs all read `binds` (decision 0997): the dispatch already enforced the
+8 -2
View File
@@ -153,8 +153,10 @@ pub(super) fn move_axes(
// BUTTON4 — the latter is winit's `Forward`, the thumb button this toggle lived on before
// the table existed, kept by the codec's BUTTON4 mapping). A latched mode, not a held key:
// the keyboard chord is typing-gated at dispatch like every binding, the mouse chord is
// not — and the reference agrees, its focus-loss handler releasing every direction bit
// while preserving `0x1000` (`0x514490`'s `and eax,0xfffff00f`, VERIFIED).
// not — and the reference agrees, its **OS window-deactivate** handler releasing every
// direction bit while preserving `0x1000` (`0x514490`'s `and eax,0xfffff00f`, VERIFIED;
// "window-deactivate" and not "focus-loss", which read as *UI* focus and cost us 2196 —
// its sole caller `0x493058` hangs off the WM_ACTIVATE callback slot).
let mut autorun_armed = false;
if binds.fired(crate::bindings::cmd::TOGGLE_AUTORUN) {
player.autorun = !player.autorun;
@@ -194,6 +196,10 @@ pub(super) fn move_axes(
//
// Deliberately absent, each VERIFIED as a *survivor*: a jump, a chat EditBox taking focus, and
// a zone change. Mounting is genuinely unsettled in the reference and left alone here.
//
// A chat box taking focus survives *more* than autorun, note: it releases nothing at all —
// the movement handlers simply become no-ops and the direction bits are frozen (2196), which
// is why holding W through an ENTER keeps you running.
let both_buttons_engaged = (both_buttons
&& (rig.world_mouse.down(LookButton::Left) || rig.world_mouse.down(LookButton::Right)))
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
+138 -2
View File
@@ -576,8 +576,33 @@ pub(super) fn feed_action_state(
== Some(button.action)
})
});
st.usable = count > 0 || st.equipped;
if let Some(u) = template.as_ref().and_then(|t| t.use_spell) {
// The rest of `0x4e5050`'s ITEM arm — the count gate, `IsItemOnCooldown`, and
// the item's on-use spell run through the SAME `0x6e3d60` walk a spell slot
// takes ([`super::usable::item_usable`]). Food greys in combat from leg 8 there.
let use_spell = template.as_ref().and_then(|t| t.use_spell);
// No active player and the reference answers (0,0) before resolving anything
// (§2a P0) — which is `ActionState::default()`'s `usable`.
if let Some((store, _, _, _)) = me {
let ctx = usable::UsableCtx {
store,
target_store: target.map(|(s, _)| s),
factions: factions.as_deref(),
reputations: &reputations,
cooldowns: &cooldowns,
};
let (u, oom) = usable::item_usable(
button.action,
use_spell.as_ref(),
count > 0 || st.equipped,
&ctx,
spells.as_deref(),
&mut items,
&commands,
);
st.usable = u;
st.not_enough_mana = oom;
}
if let Some(u) = use_spell {
let d = spells.as_ref().and_then(|s| s.catalog.get(u.spell_id));
let info = cooldowns.info(u.spell_id, button.action, d, now);
st.cooldown = info.ui_triple(anchor, ui_now);
@@ -847,6 +872,117 @@ mod tests {
);
}
/// **Food on the bar greys in combat** — the feed end to end, at the symptom. An ITEM slot's
/// usable verdict is the reference's `0x4e5050` ITEM arm, which resolves the item's on-use
/// spell (`0x4e5a50`) and walks it through `Spell_C::IsSpellUsableNow 0x6e3d60`; every
/// Food/Drink spell in the shipped `Spell.dbc` carries `Attributes` bit 28
/// (`ATTR_NOT_IN_COMBAT`, the walk's leg 8), so a stack of food is grey while
/// `UNIT_FLAG_IN_COMBAT` is up and full-colour the moment it drops. Before this test the
/// ITEM arm answered `count > 0 || equipped` and nothing else, and food stayed lit.
#[test]
fn food_on_the_bar_greys_while_the_player_is_in_combat() {
use benilla_protocol::messages::{ActionButton, ItemUseSpell};
use benilla_protocol::ObjectFields;
// `Conjured Muffin`-shaped: one ON_USE block casting spell 433 "Food", which the shipped
// DBC gives `Attributes = 0x18000100` — bit 28 among them.
const FOOD_ITEM: u32 = 1487;
const FOOD_SPELL: u32 = 433;
// Descriptor indices, raw (the codebase's test idiom): `ITEM_FIELD_STACK_COUNT` and
// `PLAYER_FIELD_PACK_SLOT_1` — the backpack's first slot, the walker's CARRIED section.
const STACK: u16 = 14;
const PACK_SLOT_1: u16 = 532;
let lit = |in_combat: bool| {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut actions = PlayerActions::default();
actions.buttons.insert(
0,
ActionButton {
slot: 0,
action: FOOD_ITEM,
kind: ACTION_KIND_ITEM,
},
);
let mut items = Items::default();
items.insert_object(
0xF0,
ObjectFields::from_pairs(&[(3, FOOD_ITEM), (STACK, 10)]),
);
items.insert_template(
FOOD_ITEM,
Some(benilla_protocol::messages::ItemInfo {
use_spell: Some(ItemUseSpell {
spell_id: FOOD_SPELL,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
}),
..crate::items::test_template("Conjured Muffin")
}),
);
let food = SpellDisplay {
attributes: 0x1800_0100,
..Default::default()
};
app.insert_resource(actions)
.insert_resource(crate::ui_macro::MacroBoundSpells::default())
.insert_resource(Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[(FOOD_SPELL, food)].into_iter().collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
})
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_cast::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::Reputations>()
.insert_resource(items)
.insert_resource(NetCommands(tx));
// The player: alive, with the ten muffins in backpack slot 1.
let flags = (1u32 << 3)
| if in_combat {
crate::player::UNIT_FLAG_IN_COMBAT
} else {
0
};
app.world_mut().spawn((
SelfPlayer,
Transform::default(),
ObjectStore(ObjectFields::from_pairs(&[
(22, 100),
(23, 500),
(46, flags),
(PACK_SLOT_1, 0xF0),
])),
));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, feed_action_state);
app.update();
app.world()
.non_send_resource::<UiScript>()
.eval::<bool>("return (IsUsableAction(1)) and true or false")
.unwrap()
};
assert!(lit(false), "out of combat the muffins are full-colour");
assert!(
!lit(true),
"in combat the food's on-use spell fails leg 8 and the button greys"
);
}
/// The mounted refusal (`0x609c6c`): a live mount blocks unless Attributes carries the
/// bit-24 exemption (`0x609c6f`); unmounted always passes; a missing record can claim no
/// exemption, so the gate holds.
+78 -1
View File
@@ -19,6 +19,8 @@
//! durability model), and the ghost state beyond plain death. CanAssist inside 10b is the
//! reaction-rank stand-in the ring/`can_attack` share, pending the true `0x6066f0` walk.
use benilla_protocol::messages::ItemUseSpell;
use crate::ui_items::{count_of, InventoryScope};
use benilla_formats::{
SpellDisplay, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED,
@@ -256,6 +258,81 @@ fn equipped_slots_match(
})
}
/// The **ITEM arm** of the usable compute — `0x4e5050`'s item branch, whole. benilla answered
/// `count > 0 || equipped` here and nothing else, which left a stack of food full-colour in
/// combat; the reference runs three more gates, and the third is the whole spell walk.
///
/// 1. **the count cache** `[0xbc6390+slot*4] == 0` ⇒ `(false, false)` (`4e50ab`). `held` is our
/// stand-in: carried copies ([`InventoryScope::CARRIED`], decision 1158's documented narrowing)
/// or a copy worn on an equipment slot. The reference fills that cache from `0x4e6d20`, whose
/// mask `0x4e6d20`→`0x622439` rewrites to **`0x47`** — equipment `0..0x12`, bag slots, the
/// backpack, container contents and **the keyring**, bank excluded. So the `|| equipped` half
/// is *redundant* there rather than an addition (worn copies already count), and two narrower
/// divergences stay open and named: our `CARRIED` omits the **keyring** (a key on the bar reads
/// count 0 where the reference reads 1), and we do not model the reference's charges fork —
/// a `spellcharges[0] ∉ {0, -1}` item sums `|ITEM_FIELD_SPELL_CHARGES[0]|`, so a spent
/// 0-charge copy greys at `4e50ab`. Both are count-scope work, not this arm's.
/// 2. **`IsItemOnCooldown 0x6e2fc0`** (`4e50d0`) ⇒ `(false, false)`. It resolves the item's FIRST
/// ON_USE block — `rec+0x11c[i] > 0 && rec+0x130[i] == 0`, `6e3006`–`6e3023`, the same scan
/// behind [`benilla_protocol::messages::ItemInfo::use_spell`] — and asks
/// `0x6e1690(spellId, itemId = the item ENTRY)`. That predicate is the **on-hold-record** test,
/// NOT a general on-cooldown one (wow-re `spell/scratch/gcd-power-gate.md` §3, which corrects
/// `wave-cooldown.md`'s published gloss): it reads neither a running timed cooldown nor the
/// GCD. So a potion mid-cooldown keeps its colour under its own sweep, and nothing on the bar
/// greys for the global cooldown. **The entry is the key**, not `0`: `0x6e2fc0` is the sole
/// consumer of `0x6e1690`'s item-keyed form image-wide (`6e3037 push esi`), every other caller
/// passing `0` — so querying leg 11's `(spell, 0)` form here would never find an item's own
/// parked record, which is how our cooldown store keys it (`(use_spell, entry)`).
/// 3. **the plain-spell walk.** The resolver `0x4e5a50`'s ITEM arm hands that same first ON_USE
/// spell back with `*outType = 0` **hard-written** (wow-re `action-button-state-api.md` §0), so
/// `4e51ab`/`4e51b0 je 0x4e521d` is taken and the button runs the entire
/// `Spell_C::IsSpellUsableNow 0x6e3d60` gate walk — [`spell_usable`] — exactly as a spell slot
/// does. **This is where food greys in combat**: every `Food`/`Drink` row in the shipped
/// `Spell.dbc` (433/434/435, 1127/1129/1131, 1133/1135/1137, 5004–5007, 6410, …) carries
/// `Attributes = 0x18000100`, bit 28 among them — [`ATTR_NOT_IN_COMBAT`], the walk's leg 8. It
/// is not a food rule: 453 of the shipped rows carry that bit (bandages, mounts, disguises).
/// 4. an item with **no** on-use spell resolves 0 and falls into the resolver-0 leg, whose ITEM-tag
/// test answers `usable = 1` outright (`4e5127`–`4e5135`; §2b.3's consequence iv, "once the
/// item-count and item-cooldown checks of §2 pass"). An equipped sword on the bar is
/// full-colour, not grey — and so is an item whose template is still in flight, which is what
/// keeps a freshly-seen slot from flickering grey for a frame.
///
/// A resolved on-use spell the catalog has no row for reads **grey** with `notEnoughMana = 0` —
/// the reference's `4e5193 jg` / `4e51a0 jne` fall-through at `4e51aa`, and the same answer the
/// SPELL arm already gives an unknown id.
pub(crate) fn item_usable(
entry: u32,
use_spell: Option<&ItemUseSpell>,
held: bool,
ctx: &UsableCtx,
spells: Option<&Spells>,
items: &mut Items,
commands: &NetCommands,
) -> (bool, bool) {
if !held {
return (false, false);
}
// Gate 4's early answer: no on-use spell (or no template yet) — the ITEM tag alone lights it.
let Some(use_spell) = use_spell else {
return (true, false);
};
let spell_id = use_spell.spell_id;
let d = spells.and_then(|s| s.catalog.get(spell_id));
// Gate 2, keyed as `0x6e2fc0` keys it: the item ENTRY as `0x6e1690`'s `itemId`, and the
// item's own resolved category where it has one (the `spellcategory[5]` override, §2c.4).
let category = match use_spell.category {
0 => d.map_or(0, |d| d.category),
c => c,
};
if ctx.cooldowns.has_on_hold_record(spell_id, entry, category) {
return (false, false);
}
let (Some(d), Some(spells)) = (d, spells) else {
return (false, false);
};
spell_usable(spell_id, d, spells, ctx, items, commands)
}
/// The walk. Returns `(usable, not_enough_mana)` — the `IsUsableAction` pair.
pub(crate) fn spell_usable(
spell_id: u32,
@@ -365,7 +442,7 @@ pub(crate) fn spell_usable(
// predicate is the corrected `0x6e1690` (an on-hold-record test, wow-re `gcd-power-gate.md`
// §3): Stealth greys while its record is PARKED; once the event starts the clocks — and for
// every ordinary cooldown — the button never greys from here.
if d.cooldown_on_event() && ctx.cooldowns.has_on_hold_record(spell_id, Some(d)) {
if d.cooldown_on_event() && ctx.cooldowns.has_on_hold_record(spell_id, 0, d.category) {
return (false, false);
}
// Leg 12 (`0x6e3fba`–`0x6e3feb`): the power gate — the SOLE notEnoughMana writer (B2).
@@ -307,6 +307,40 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
"the ref's out-of-power blue-grey"
);
// The PLAIN grey — `usable = false` with `notEnoughMana = false`, which is what an unusable
// item reads (food in combat, the whole point of the ITEM arm's spell walk). Stock
// `ActionButton_UpdateUsable`'s `else` dims the ICON to 0.4 and leaves the normal texture
// at full strength, so the ring keeps its colour under a dead icon.
s.set_action_state(
1,
Some(ActionState {
usable: false,
not_enough_mana: false,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
let c = s
.extract()
.into_iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => Some(*color),
_ => None,
})
.expect("icon quad")
.expect("vertex color set");
assert_eq!(
(c[0], c[1], c[2]),
(0.4, 0.4, 0.4),
"the ref's unusable grey — the icon the director sees on food in combat"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+21 -12
View File
@@ -62,7 +62,7 @@ impl PointerFeed<'_> {
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
/// char/key dispatch, publishing [`PlayerUiHover`] (so the pointer arbiter yields world-pick/camera to
/// the UI) and [`UiKeyboardCapture`] (so gameplay/dev keyboard readers yield to a focused box).
/// the UI) and [`UiKeyboardCapture`] (so a key a box or frame ate never also fires its binding).
///
/// Runs in [`UiInput`], before `WorldStage::Input` and every other keyboard reader — a key a focused
/// box consumes must never also reach the world in the same frame.
@@ -110,12 +110,14 @@ pub(super) fn feed_ui_input(
let Some(mut script) = script else {
capture.typing = false;
capture.arrows_fall_through = false;
capture.consumed.clear();
payload_held.0 = false;
return;
};
let Ok((window, raw_handle)) = window.single() else {
capture.typing = false;
capture.arrows_fall_through = false;
capture.consumed.clear();
payload_held.0 = false;
return;
};
@@ -229,6 +231,8 @@ pub(super) fn feed_ui_input(
// box) but BEFORE feeding keys (an Escape that clears focus is still "captured" this frame, so the
// world doesn't also act on it). Gameplay/dev readers run after `UiInput` and see this value.
capture.typing = script.has_keyboard_focus();
// The per-key half is this frame's alone (a frame's existence gate ate THIS key).
capture.consumed.clear();
// ── The alt-arrow exemption (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED) ─────────
// A focused EditBox in alt-arrow mode (`ignoreArrows` in XML, `SetAltArrowKeyMode` in Lua)
// does NOT consume LEFT/UP/RIGHT/DOWN unless ALT is held: the reference's handler returns 0
@@ -291,7 +295,10 @@ pub(super) fn feed_ui_input(
};
if let Some(name) = frame_named {
if script.frame_key_input(name) {
capture.typing = true; // consumed by a frame: its binding must not also fire
// Consumed by a frame: THIS key's binding must not also fire. Not `typing` — a
// frame eating a key is not a text box taking focus, and reading it as one is what
// stopped a held W dead when the world map ate the `M` that closed it (2196).
capture.consumed.push(ev.key_code);
continue;
}
}
@@ -308,14 +315,15 @@ pub(super) fn feed_ui_input(
// handler has to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would
// not need to if unhandled keys fell through to their bindings.
//
// Consumption sets the capture gate and nothing else: it suppresses the binding and the
// world/gameplay readers, and deliberately does NOT suppress `char_input` below — `OnChar`
// is a separate channel off a separate dispatcher (`0x765df0`), which is exactly how the
// stack-split spinner receives a digit whose key-down its own `OnKeyDown` already ate.
// Consumption suppresses THE KEY'S BINDING and nothing else. It deliberately does NOT
// suppress `char_input` below — `OnChar` is a separate channel off a separate dispatcher
// (`0x765df0`), which is exactly how the stack-split spinner receives a digit whose
// key-down its own `OnKeyDown` already ate. And it is emphatically not a focus change: it
// releases nothing that is already held (2196).
else if named.is_none() {
if let Some(token) = crate::bindings::chord::key_token(ev.key_code) {
if script.frame_key_input(token) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
}
}
@@ -326,11 +334,12 @@ pub(super) fn feed_ui_input(
// dispatch (decision 0997, `crate::bindings`), which runs right after this pass and
// reads the capture gate written above, so a key a focused box consumed this frame
// never also fires its binding — the real client's ESC precedence, table-wide.
// Consumption suppresses the binding — a keyboard FRAME that took this key counts
// exactly as a focused box does (decision 1319; `capture.typing` above already covers the
// box case, and this adds the frame one).
// Consumption suppresses this key's binding — a keyboard FRAME that took the key
// counts as the focused box does for *that key* (decision 1319; `capture.typing` above
// covers the box, which takes every key for as long as it holds focus, and this adds
// the frame's one-key case).
if script.key_input(name) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
} else if let Some(chord) = chord {
// The gate, on the KEY rather than the action: a gated arrow is not handed to the box
@@ -382,7 +391,7 @@ pub(super) fn feed_ui_input(
// row IS a binding (the action buttons), so a digit typed into a keyboard frame —
// the stack-split spinner — must not also fire action button 3 (decision 1319).
if script.char_input(text) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
}
}
@@ -697,6 +697,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
keys.typing = false;
keys.arrows_fall_through = false;
keys.consumed.clear();
}
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
*held = CursorPayloadHeld::default();
@@ -1202,3 +1202,91 @@ fn loot_row_awaiting_its_template_opens_clean() {
"the resolved row is white, got {white:?}"
);
}
/// **A `<LootButton>` wears `ItemButtonTemplate`'s three state textures, and the highlight tracks
/// the cursor.** The loader's Button leg used to gate on the two tags `Button`/`CheckButton`, so
/// the stock rows — whose tag is `LootButton` — were built with no `<NormalTexture>`, no
/// `<PushedTexture>` and no `<HighlightTexture>` at all: no Quickslot border on the icons, and
/// nothing to light under the mouse. The gate is wrong about the reference: `CLootButton`'s
/// geometry vtable differs from `CSimpleButton`'s in exactly one slot — the destructor thunk — and
/// `LoadXML` is not it, so `0x7788c0` parses a `<LootButton>` element verbatim (wow-re
/// `ui/scratch/lootbutton-widget-type.md` §4).
///
/// Asserted through the ENGINE's hover path rather than off the state: what a player sees is the
/// emitted quad, and the quad is what was missing.
#[test]
fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The inherited template's own art reached every visible row.
s.resolve();
let border = |quads: &[ExtractedQuad]| {
quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot2"))
})
.count()
};
assert_eq!(
border(&s.extract()),
3,
"ItemButtonTemplate's <NormalTexture> is the Quickslot border on each of the three rows"
);
// The highlight is not on screen until the cursor is on a row.
let hilite = |quads: &[ExtractedQuad]| {
quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("ButtonHilight-Square") =>
{
q.rect
}
_ => None,
})
.collect::<Vec<_>>()
};
assert!(
hilite(&s.extract()).is_empty(),
"no row is lit with the mouse away"
);
// Hover the second row: exactly one highlight, over THAT row's icon.
let icon = |quads: &[ExtractedQuad], needle: &str| {
quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
.and_then(|q| q.rect)
.unwrap_or_else(|| panic!("no icon quad for {needle}"))
};
let wool = icon(&s.extract(), "INV_Fabric_Wool_01");
super::test_ui::hover(&mut s, "LootButton2");
s.resolve();
let lit = hilite(&s.extract());
assert_eq!(lit.len(), 1, "one row lights, not three: {lit:?}");
assert_eq!(
lit[0], wool,
"the highlight covers the hovered row's icon square"
);
// And it leaves with the cursor.
super::test_ui::unhover(&mut s);
s.resolve();
assert!(
hilite(&s.extract()).is_empty(),
"the highlight is not latched: it goes out when the cursor leaves"
);
}
+24 -7
View File
@@ -178,16 +178,33 @@ pub(crate) struct UiCostWanted(pub(crate) bool);
#[derive(Resource, Default)]
pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// Set each frame by [`feed_ui_input`] to [`UiScript::has_keyboard_focus`]: true while an EditBox owns
/// keyboard focus and is eating every key. The gameplay/dev keyboard readers (movement, the Z sheath,
/// the HUD/panel/inspect toggles, chat-open, target-clear) gate on it so a key typed into a focused box
/// never also drives the world — the app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082
/// §1). One mechanism, written once here; every reader is ordered after `UiInput` so it sees this
/// frame's value, not last frame's.
/// **Who owns this frame's keys** — written by [`feed_ui_input`], read by the binding dispatch and the
/// dev keyboard readers, all of which are ordered after `UiInput` so they see this frame's value and
/// not last frame's. The app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082 §1).
///
/// **Two fields because the reference has two mechanisms, and collapsing them into one boolean was
/// bug 2196.** A focused EditBox swallows *every* key for as long as it holds focus
/// ([`typing`](Self::typing)); a shown keyboard-enabled *frame* swallows *the one key* its
/// existence gate ate this frame ([`consumed`](Self::consumed)). Both suppress the key's binding —
/// and nothing more. Neither releases anything already held: the only things that clear the
/// reference's direction bits are the OS **window deactivate** (`0x514490`, whose sole caller
/// `0x493058` hangs off the WM_ACTIVATE callback slot) and the world-enter cascade (`0x5144c0`).
/// A UI focus change clears nothing — wow-re `rf79-autorun-cancel-set.md`, and the reason holding
/// W keeps you running while you type or read the map. See `decisions/2196`.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture {
/// True while a focused EditBox is eating every key.
/// True while a focused EditBox is eating every key (`0x77b35e` returns 1 on every path but
/// the alt-arrow one below). Whole-frame, because there is at most one focused box.
pub(crate) typing: bool,
/// The keys a shown keyboard-enabled **frame** consumed this frame (decision 1319's existence
/// gate, wow-re `frame-key-script-delivery.md` §3) — `WorldMapFrame`'s fullscreen `OnKeyDown`,
/// `CinematicFrame`, the stack-split spinner. **Per key, not per frame**: the map eating its
/// own `M` must not also suppress an unrelated binding, and — the bug this list exists for —
/// must not be mistaken for a text box taking focus.
///
/// Raw [`bevy::input::keyboard::KeyCode`]s, as the message carried them (the binding dispatch
/// normalizes for chord lookup, but matches this list on the raw code it read).
pub(crate) consumed: Vec<bevy::input::keyboard::KeyCode>,
/// **The four arrow keys are exempt this frame** — the focused box is in alt-arrow mode
/// (`ignoreArrows` / `SetAltArrowKeyMode`) and ALT is not held, so the reference's own key
/// handler declines LEFT/UP/RIGHT/DOWN at `0x77b1c4` and the strata walk carries them down to
@@ -2366,7 +2366,15 @@ mod tests {
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
(
"SHOW_COMPARE_TOOLTIP",
"PaperDollFrame.lua — the second `TRADE_REQUEST` (decision 1764): event 377 is \
registered in 5875 and signalled from NOWHERE (zero fire sites in wow-re's own \
census, `merchant-compare-item-law.md` §8), so this listener is dead code THERE \
too. benilla fired it from 0283 until 2202 and took it back out — decision 2202; the \
shopping plates are driven the way the reference's one LIVE caller drives them, \
`MerchantFrame.xml:63-80`. Producing this would be a divergence, not a fix",
),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
(
@@ -1,8 +1,15 @@
//! The shopping-compare + chat-link tooltips over the REAL shipped XMLs (decision 0274 P4):
//! shift over a bag item fires `SHOW_COMPARE_TOOLTIP`, CharacterFrame's doll-slot listener
//! (ref PaperDollFrame.lua:621-640) seats `ShoppingTooltip1` on the matching slot, the armed
//! `SetInventoryItem` renders the byte law's compare shape over the template's own adopted
//! small-font ladder, and `SetItemRef` fills the parked `ItemRefTooltip`.
//! The shopping-compare + chat-link tooltips over the REAL shipped XMLs (decision 0274 P4,
//! re-based by 2202): shift over an item hover seats `ShoppingTooltip1/2` BESIDE the tooltip at
//! `MerchantFrame.xml:63-80`'s own geometry, their armed `SetInventoryItem` renders the byte law's
//! compare shape over the template's own adopted small-font ladder, and `SetItemRef` fills the
//! parked `ItemRefTooltip`.
//!
//! **Why not the paper-doll listener these tests used to drive.** `SHOW_COMPARE_TOOLTIP` (event
//! 377) has zero fire sites in 5875 — `PaperDollFrame.lua:621-640` is dead code there, and the
//! vendor row is the only live consumer of the shopping plates (wow-re
//! `merchant-compare-item-law.md` §8). benilla fired it anyway until 2202, which is why a compare
//! needed the character window open, landed across the screen from the tooltip it compared to, and
//! could be left stale by a hover whose slot listener never answered.
//!
//! **The bag end of that flow is the REFERENCE's own code since 1751.** The hover source used to be
//! benilla's `BenillaBagSlot_OnEnter` on a `BenillaBagFrame`; it is the reference's
@@ -180,14 +187,14 @@ fn seed_items(s: &mut UiScript) {
);
}
/// Shift over a bag helm with the character window OPEN: `ShoppingTooltip1` seats on the head
/// doll slot (ANCHOR_RIGHT), renders gray "Currently Equipped" + the equipped helm through the
/// template's ADOPTED small-font ladder (line 1 = GameFontNormalSmall's 10px face — the
/// engine-created lines of the MAIN tooltip stay on its own faces), the compact cut drops the
/// description, and releasing shift hides the pair. With the window CLOSED nothing shows —
/// the 1.12 behavior.
/// Shift over a bag helm: `ShoppingTooltip1` seats at the tooltip's own TOPRIGHT (0, −10),
/// renders gray "Currently Equipped" + the equipped helm through the template's ADOPTED small-font
/// ladder (line 1 = GameFontNormalSmall's 10px face — the engine-created lines of the MAIN tooltip
/// stay on its own faces), the compact cut drops the description, and releasing shift hides the
/// pair. **The character window is irrelevant to all of it** — it is checked both closed and open,
/// because the listener that used to make it matter is dead code in 5875 (2202).
#[test]
fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
fn shift_compare_over_a_bag_item_seats_beside_the_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(player()));
@@ -202,15 +209,26 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
hover(&mut s, &btn);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
// Character window CLOSED: the shift edge fires, no listener answers, nothing shows.
// Character window CLOSED — and the compare shows anyway, beside the tooltip.
s.set_modifiers(true, false, false);
assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors());
let ok: bool = s
.eval(
"local p, rel, rp, x, y = ShoppingTooltip1:GetPoint() \
return ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \
and rel:GetName() == \"GameTooltip\" \
and p == \"TOPLEFT\" and rp == \"TOPRIGHT\" and x == 0 and y == -10",
)
.unwrap();
assert!(
!s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"no compare with the character window closed"
ok,
"the compare plate seats beside the tooltip with the character window closed"
);
s.set_modifiers(false, false, false);
// Open the window; the shift edge now seats the compare on the head slot.
// Opening the character window changes nothing about where it lands.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
hover(&mut s, &btn);
@@ -220,12 +238,11 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
.eval(
"local p, rel = ShoppingTooltip1:GetPoint() \
return ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \
and rel:GetName() == \"CharacterHeadSlot\" and p == \"BOTTOMLEFT\"",
and rel:GetName() == \"GameTooltip\" and p == \"TOPLEFT\"",
)
.unwrap();
assert!(ok, "the compare plate seats on the head doll slot");
assert!(ok, "the open character window does not move the plate");
// The adopted ladder: the shopping plate's line 1 wears GameFontNormalSmall (10px); the
// MAIN tooltip's engine-created line 1 keeps the header face — different sizes.
let ok: bool = s
@@ -390,3 +407,164 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A **quest reward** row, which is where the director found this: shift over an equippable
/// choice shows what is worn in that slot, beside the tooltip, with no character window open —
/// and a reward whose slot is EMPTY shows nothing at all rather than the plate an earlier hover
/// left behind.
///
/// That last clause is the reported symptom, reproduced before it was fixed: a Back-slot cape
/// hover painted `Currently Equipped / Rustmetal Bracers` — a *wrist* plate from an earlier
/// compare — sitting over the tooltip. It survived because the old drive fired
/// `SHOW_COMPARE_TOOLTIP` and left the plate's whole lifecycle to a paper-doll listener that
/// answers only while the character window is visible: with the window closed, nothing hid it and
/// nothing refilled it. The engine owns both plates on every drive now, so the miss path hides.
#[test]
fn shift_compare_over_a_quest_reward_never_shows_a_stale_plate() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = quest_harness();
// Worn: bracers on the WRIST slot. The BACK slot is empty.
let mut inv: InventorySlots = Default::default();
inv[9] = Some(worn_item(7000, "Rustmetal Bracers"));
s.set_inventory_slots(inv);
s.set_item_template(7000, armor_template("Rustmetal Bracers", 9));
s.set_item_template(8000, armor_template("Short Duskbat Cape", 16));
s.set_item_template(8001, armor_template("Other Bracers", 9));
// A wrist compare first — the plate this hover must not inherit. Rendered straight onto the
// main tooltip, because what made the stale plate stick was the tooltip never HIDING between
// the two renders (a mouse move between buttons hides it and would mask the bug).
s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") GameTooltip:BenillaSetItemById(8001)"#)
.unwrap();
s.set_modifiers(true, false, false);
let ok: bool = s
.eval(
"return ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft2:GetText() == \"Rustmetal Bracers\"",
)
.unwrap();
assert!(ok, "fixture: the wrist compare really is on screen");
// Now the cape, still holding shift, without the tooltip hiding in between: the back slot is
// empty, so the plate goes away instead of riding along.
s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") GameTooltip:BenillaSetItemById(8000)"#)
.unwrap();
assert!(
!s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"an empty slot shows no compare, and never the previous hover's plate"
);
// And with something worn on the back, the real quest row compares against it — the mouse on
// the stock reward button, no character window open.
s.set_modifiers(false, false, false);
let mut inv: InventorySlots = Default::default();
inv[9] = Some(worn_item(7000, "Rustmetal Bracers"));
inv[15] = Some(worn_item(7001, "Old Cloak"));
s.set_inventory_slots(inv);
s.set_item_template(7001, armor_template("Old Cloak", 16));
s.fire_event("QUEST_COMPLETE", vec![]);
hover(&mut s, "QuestRewardItem1");
s.set_modifiers(true, false, false);
assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors());
let ok: bool = s
.eval(
"local p, rel, rp, x, y = ShoppingTooltip1:GetPoint() \
return not CharacterFrame:IsVisible() \
and GameTooltipTextLeft1:GetText() == \"Short Duskbat Cape\" \
and ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Old Cloak\" \
and rel:GetName() == \"GameTooltip\" \
and p == \"TOPLEFT\" and rp == \"TOPRIGHT\" and x == 0 and y == -10",
)
.unwrap();
assert!(
ok,
"the quest reward compares against the worn cloak, beside its own tooltip"
);
assert!(
!s.eval::<bool>("return ShoppingTooltip2:IsShown()").unwrap(),
"one candidate slot fills one plate"
);
// Release hides it; the reward hover itself stays.
s.set_modifiers(false, false, false);
let ok: bool = s
.eval("return not ShoppingTooltip1:IsShown() and GameTooltip:IsShown()")
.unwrap();
assert!(ok, "release hides the compare, keeps the hover");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// [`harness`] plus the questgiver window, showing a one-choice reward panel.
fn quest_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let mut seen = Vec::new();
load_once(&s, &mut seen, CHARACTER_UI);
load_once(&s, &mut seen, &ROUTER_UI);
load_once(
&s,
&mut seen,
&[
"Interface\\FrameXML\\QuestFrame.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
],
);
s.set_money(0);
s.set_unit("player", Some(player()));
s.set_quest(Some(benilla_ui::script::QuestState {
panel: benilla_ui::script::QuestPanel::Reward,
title: "A Threat Within".into(),
body: "Here — have one of the better items I've found.".into(),
choices: vec![benilla_ui::script::QuestItemView {
name: Some("Short Duskbat Cape".into()),
texture: Some("Interface\\Icons\\INV_Misc_Cape_01".into()),
count: 1,
quality: 1,
item_id: 8000,
usable: true,
link: Some("|cffffffff|Hitem:8000:0:0:0|h[Short Duskbat Cape]|h|r".into()),
}],
..Default::default()
}));
s.fire_event("QUEST_COMPLETE", vec![]);
s
}
/// One worn armour piece, template-backed by [`armor_template`].
fn worn_item(item_id: u32, name: &str) -> InvSlotView {
InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
flags: 0,
item_id,
icon: None,
count: 1,
contents_count: None,
quality: 1,
name: Some(name.into()),
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
locked: false,
equip_slots: Vec::new(),
creator: None,
enchants: Vec::new(),
}
}
/// An armour template in one `InventoryType`. The CLASS matters: the selection law only compares
/// a worn item of the same item class (wow-re `merchant-compare-item-law.md` §3).
fn armor_template(name: &str, inventory_type: u32) -> ItemTemplateView {
ItemTemplateView {
name: name.into(),
quality: 1,
class: 4,
subclass: 1,
inventory_type,
armor: 5,
..Default::default()
}
}
+17 -1
View File
@@ -81,7 +81,23 @@ impl Loader<'_> {
dbg: &str,
) {
let is_check = el.tag.eq_ignore_ascii_case("CheckButton");
if !is_check && !el.tag.eq_ignore_ascii_case("Button") {
// **`<LootButton>` takes this leg too, and taking only two tags is what hid the loot
// window's hover highlight.** `CLootButton` is a `CSimpleButton` subclass that overrides
// six vtable slots on the primary table (dtor, the Lua lookup, the two type predicates,
// `GetObjectType`, and the click virtual) and exactly ONE on the geometry table — slot 1,
// the destructor's adjustor thunk. `LoadXML` is geometry slot 2 (`0x81c7c8[2]` for Button,
// `0x804594[2]` for LootButton), and those two entries are the same pointer: a
// `<LootButton>` element is parsed by `CSimpleButton::LoadXML 0x7788c0` *verbatim* (wow-re
// `ui/scratch/lootbutton-widget-type.md` §4 + `button-disabled-state-texture-law.md`).
// Stock `LootButtonTemplate` inherits `ItemButtonTemplate`, whose whole art is three state
// textures — so with the tag rejected here the rows lost their Quickslot border, their
// depress art and the `ButtonHilight-Square` that lights a row under the cursor. It is the
// BUTTON leg and not the check one: `CheckButton::LoadXML 0x785170` is a different slot 2
// that calls this one first, and nothing routes a LootButton through it.
if !is_check
&& !el.tag.eq_ignore_ascii_case("Button")
&& !el.tag.eq_ignore_ascii_case("LootButton")
{
return;
}
let tex = |tag: &str, method: &str, this: &mut Self| {
+3 -2
View File
@@ -837,8 +837,9 @@ impl UiScript {
model.modifiers = (shift, ctrl, alt);
was
};
// The shift EDGE drives the shopping-compare tooltips (0274 P4): press over a live
// equippable item hover fires SHOW_COMPARE_TOOLTIP, release hides the pair.
// The shift EDGE drives the shopping-compare tooltips (0274 P4, re-based by 2202): press
// over a live equippable item hover seats the plates beside the tooltip, release hides
// them.
if shift_was != shift {
tooltip_item::on_shift_edge(&self.lua, shift);
}
@@ -6,14 +6,14 @@ use std::collections::HashMap;
use super::script;
use crate::script::*;
/// The shopping-compare pipeline end-to-end (0274 P4): a bag-ring hover on the main GameTooltip
/// with shift held fires `SHOW_COMPARE_TOOLTIP` once per finger slot; a ref-shaped listener
/// (PaperDollFrame.lua:621-640) seats ShoppingTooltip1/2, whose ARMED `SetInventoryItem` renders
/// the byte law's compare shape — the gray CURRENTLY_EQUIPPED header, WHITE name, the compact cut (the
/// description never prints). Releasing shift hides the pair; a shift-up hover fires nothing
/// until the rising edge.
/// The shopping-compare pipeline end-to-end (0274 P4, re-based by 2202): a bag-ring hover on the
/// main GameTooltip with shift held seats ShoppingTooltip1/2 **beside the tooltip**, at
/// `MerchantFrame.xml:63-80`'s own geometry, and their ARMED render carries the byte law's compare
/// shape — the gray CURRENTLY_EQUIPPED header, WHITE name, the compact cut (the description never
/// prints). Two finger slots fill both plates. Releasing shift hides the pair; nothing shows until
/// the rising edge, and no `SHOW_COMPARE_TOOLTIP` is ever fired (that event is dead in 5875).
#[test]
fn shift_compare_fires_seats_and_renders_the_compare_shape() {
fn shift_compare_seats_beside_the_tooltip_and_renders_the_compare_shape() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut inv: InventorySlots = Default::default();
@@ -98,23 +98,15 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
CreateFrame("GameTooltip", "GameTooltip"):Hide()
CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide()
CreateFrame("GameTooltip", "ShoppingTooltip2"):Hide()
-- A ref-shaped SHOW_COMPARE_TOOLTIP listener, kept as the NEGATIVE control: 5875 never
-- signals event 377 (zero fire sites, wow-re merchant-compare-item-law.md §8), so nothing
-- benilla does may reach this handler.
compare_calls = {}
for slot = 11, 12 do
local f = CreateFrame("Button", "Doll" .. slot)
f:SetPoint("CENTER", 100, 0); f:SetWidth(8); f:SetHeight(8)
f.invSlotId = slot
f:RegisterEvent("SHOW_COMPARE_TOOLTIP")
f:SetScript("OnEvent", function()
if arg1 ~= this.invSlotId or arg2 > 2 then return end
table.insert(compare_calls, arg1 .. ":" .. arg2)
local tooltip = getglobal("ShoppingTooltip" .. arg2)
local anchor = "ANCHOR_RIGHT"
if arg2 > 1 then anchor = "ANCHOR_BOTTOMRIGHT" end
tooltip:SetOwner(this, anchor)
local hasItem = tooltip:SetInventoryItem("player", this.invSlotId)
if not hasItem then tooltip:Hide() end
end)
end
local watcher = CreateFrame("Frame", "CompareWatcher")
watcher:RegisterEvent("SHOW_COMPARE_TOOLTIP")
watcher:SetScript("OnEvent", function()
table.insert(compare_calls, arg1 .. ":" .. arg2)
end)
"#,
)
.unwrap();
@@ -123,8 +115,8 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
r#"
GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT")
GameTooltip:SetBagItem(0, 1)
assert(table.getn(compare_calls) == 0, "no compare while shift is up")
assert(not ShoppingTooltip1:IsShown())
assert(table.getn(compare_calls) == 0, "the dead event never fires")
assert(not ShoppingTooltip1:IsShown(), "no compare while shift is up")
"#,
)
.unwrap();
@@ -132,8 +124,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
s.set_modifiers(true, false, false);
s.run(
r#"
assert(table.getn(compare_calls) == 2, "two ring compares, got " .. table.getn(compare_calls))
assert(compare_calls[1] == "11:1" and compare_calls[2] == "12:2", "slot:index order")
assert(table.getn(compare_calls) == 0, "the dead event still never fires")
assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown())
assert(ShoppingTooltip1TextLeft1:GetText() == "[CURRENTLY_EQUIPPED]")
assert(ShoppingTooltip1TextLeft2:GetText() == "Old Loop")
@@ -143,11 +134,13 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
assert(getglobal("ShoppingTooltip1TextLeft" .. i):GetText() ~= "\"Round.\"",
"compact cut dropped the description")
end
-- The pair anchors to the DOLL slots (ref: SetOwner(this, ...)), index 2 below-right.
local p1, r1 = ShoppingTooltip1:GetPoint()
local p2, r2 = ShoppingTooltip2:GetPoint()
assert(r1:GetName() == "Doll11" and p1 == "BOTTOMLEFT", "1 rides ANCHOR_RIGHT")
assert(r2:GetName() == "Doll12" and p2 == "TOPLEFT", "2 rides ANCHOR_BOTTOMRIGHT")
-- The pair rides BESIDE the tooltip, at MerchantFrame.xml:68-78's own seats.
local p1, r1, rp1, x1, y1 = ShoppingTooltip1:GetPoint()
local p2, r2, rp2, x2, y2 = ShoppingTooltip2:GetPoint()
assert(p1 == "TOPLEFT" and r1:GetName() == "GameTooltip" and rp1 == "TOPRIGHT"
and x1 == 0 and y1 == -10, "plate 1 hangs off the tooltip's TOPRIGHT")
assert(p2 == "TOPLEFT" and r2:GetName() == "ShoppingTooltip1" and rp2 == "TOPRIGHT"
and x2 == 0 and y2 == 0, "plate 2 hangs off plate 1")
"#,
)
.unwrap();
@@ -174,6 +167,35 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
);
let name = color_of("Old Loop");
assert_eq!(name, [1.0, 1.0, 1.0, 1.0], "compare name is WHITE");
// A ONE-slot hover after a two-slot one takes plate 2 down with it — the selection is
// monotone, so plate 2's miss is the only thing that can clear what the rings left there.
s.set_item_template(
7003,
ItemTemplateView {
name: "New Band".into(),
quality: 2,
inventory_type: 2, // neck: one candidate slot, and nothing worn in it
..Default::default()
},
);
s.run(
r#"
GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT")
GameTooltip:BenillaSetItemById(7003)
assert(not ShoppingTooltip1:IsShown(), "an empty neck slot shows no plate")
assert(not ShoppingTooltip2:IsShown(), "and plate 2 does not survive the ring hover")
"#,
)
.unwrap();
// Back to the rings, so the release assertions below have a live pair to take down.
s.run(
r#"
GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT")
GameTooltip:SetBagItem(0, 1)
assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown())
"#,
)
.unwrap();
// Releasing shift hides the pair; the main tooltip stays.
s.set_modifiers(false, false, false);
s.run(
+1 -1
View File
@@ -330,7 +330,7 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
// Content-scoped like the rest; `compare_armed` and `padding` survive on purpose —
// the arm spans FrameXML's SetOwner (kinds.rs has the seam note), the padding is a
// frame property (ref: set once in OnLoad).
t.compare_slots.clear();
t.compare_offer = None;
}
// The mouseover health bar is UNIT content: it hides with the lines (the ref bar exists
// only while a unit shows; the byte law's watcher re-shows it on the next unit render).
+142 -63
View File
@@ -14,14 +14,15 @@
//! Residual INTERIMs cited inline: the dual-wield/off-hand proficiency exception
//! (`0x5eab70`), the type cell's override red, the set-owned count source.
//!
//! **Compare mode** (0274 P4): `SetInventoryItem` on an ARMED shopping tooltip renders the byte
//! law's compare shape — gray "Currently Equipped" first (`[arg+0x18]≠0`), the NAME white
//! instead of quality-colored, and the compact early-return at `0x52e14c` (`[arg+0x14]≠0`):
//! nothing after the charges/cooldown block. The engine fires `SHOW_COMPARE_TOOLTIP(slot, n)`
//! for an equippable item on the MAIN GameTooltip while shift is held (ref PaperDollFrame.lua's
//! listener seats `ShoppingTooltip<n>` on the paperdoll slot — so compares only show with the
//! character window open, exactly the 1.12 behavior). When the real engine fires (render vs
//! shift edge) is untraced — INTERIM: both, `arm_compare` + `on_shift_edge`.
//! **Compare mode** (0274 P4, re-based by 2202): `SetInventoryItem` on an ARMED shopping tooltip
//! renders the byte law's compare shape — gray "Currently Equipped" first (`[arg+0x18]≠0`), the
//! NAME white instead of quality-colored, and the compact early-return at `0x52e14c`
//! (`[arg+0x14]≠0`): nothing after the charges/cooldown block. **What seats the plates is
//! [`drive_compare`]**, transcribed from `MerchantFrame.xml:63-80` — the only LIVE compare caller
//! in 1.12.1, since `SHOW_COMPARE_TOOLTIP` has no fire site in the whole image and the paper-doll
//! listener is dead code there (wow-re `merchant-compare-item-law.md` §8). So a compare rides
//! beside the tooltip it compares to, needs no window open, and is driven by SHIFT — the one
//! named departure from the vendor row, which compares unconditionally.
//!
//! **The red "you can't use this" law** (the director's explicit ask) — byte-verified §1-RED:
//! red is the AddLine color `0xffff2020`, applied to requirement lines the ACTIVE player fails
@@ -41,6 +42,7 @@ use mlua::{Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty};
use super::{ItemTemplateView, Model};
use crate::widget::CompareOffer;
mod names;
mod render;
@@ -65,7 +67,7 @@ fn compare_against_worn(
lua: &Lua,
this: &Table,
h: crate::widget::FrameHandle,
offered: &ItemTemplateView,
offered: CompareOffer,
mut left: f64,
) -> mlua::Result<Value> {
let found = {
@@ -224,11 +226,6 @@ fn render_by_id(
Ok(())
}
/// The paperdoll slots an item of this InventoryType equips into (the 1.12
/// `GetInventorySlotInfo` slot ids) — the `SHOW_COMPARE_TOOLTIP` targets. Two-slot families
/// (rings, trinkets, one-hand weapons, and a two-hander displacing both hands) fire two
/// shopping tooltips, ref PaperDollFrame.lua's `arg2`. Twin of the app-side
/// `ui_items::find_equip_slot` (the equip-click fit rule) — one law, two consumers.
/// The quest-item hover: the row's item by id, or an empty tooltip when the row is not there.
fn set_quest_item_view(
lua: &Lua,
@@ -249,6 +246,11 @@ fn set_quest_item_view(
}
}
/// The paperdoll slots an item of this InventoryType equips into (the 1.12
/// `GetInventorySlotInfo` slot ids) — the compare drive's candidate slots. Two-slot families
/// (rings, trinkets, one-hand weapons, and a two-hander displacing both hands) can fill both
/// shopping plates. Twin of the app-side `ui_items::find_equip_slot` (the equip-click fit rule) —
/// one law, two consumers.
fn equip_slots_for(inventory_type: u32) -> &'static [u32] {
match inventory_type {
1 => &[1], // head
@@ -280,89 +282,157 @@ fn equip_slots_for(inventory_type: u32) -> &'static [u32] {
}
}
/// After an item render on the MAIN GameTooltip: remember the compare targets and, when shift
/// is already held, fire the compare event now. (Other tooltip frames — shopping, ItemRef —
/// never arm; the shift-edge drive (`on_shift_edge`) covers pressing shift mid-hover. Whether
/// the real engine fires at render, at the edge, or both is untraced — INTERIM, both.)
/// The two fields the compare selection law reads, out of a rendered template.
fn offer_of(v: &ItemTemplateView) -> CompareOffer {
CompareOffer {
class: v.class,
inventory_type: v.inventory_type,
}
}
/// After an item render on the MAIN GameTooltip: remember what the hover is OFFERING, and when
/// shift is already held, seat the compare plates now. Other tooltip frames — the shopping plates
/// themselves, ItemRef — never arm. An item with no equip slot arms nothing, so the drive's own
/// gate (`compare_offer`) stays `None` and it never touches a plate somebody else is showing.
fn arm_compare(lua: &Lua, h: crate::widget::FrameHandle, v: &ItemTemplateView) {
let (is_main, shift) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let is_main = model.arena.lookup("GameTooltip") == Some(h);
let shift = model.modifiers.0;
if is_main {
let slots = equip_slots_for(v.inventory_type);
let offer = (!equip_slots_for(v.inventory_type).is_empty()).then(|| offer_of(v));
if let Ok(t) = super::tooltip::tip_mut(&mut model, h) {
t.compare_slots = slots.to_vec();
t.compare_offer = offer;
}
}
(is_main, shift)
};
if is_main && shift {
fire_compare(lua, h);
drive_compare(lua, h);
}
}
/// Fire `SHOW_COMPARE_TOOLTIP(slot, n)` for the main tooltip's remembered targets, ARMING
/// `ShoppingTooltip<n>` for the compare render first (the listener's `SetOwner` +
/// `SetInventoryItem` run inside the fire — ref PaperDollFrame.lua:621-640; the arm survives
/// the SetOwner content clear, kinds.rs has the seam note).
fn fire_compare(lua: &Lua, h: crate::widget::FrameHandle) {
let slots: Vec<u32> = {
/// Seat and fill `ShoppingTooltip1/2` beside the main tooltip for the item it is offering — the
/// **live** 1.12.1 compare, transcribed from its one reference caller.
///
/// The paper-doll path this used to drive is **dead in 5875**: `SHOW_COMPARE_TOOLTIP` (event 377)
/// has zero fire sites in the whole image, so `PaperDollFrame.lua:621-640` never runs there and
/// the vendor row is the only live consumer of the shopping plates (wow-re
/// `merchant-compare-item-law.md` §8, `system/ui/ui.md`). Firing a registered-but-never-signalled
/// event is a divergence, not a fidelity win — the `TRADE_REQUEST` precedent, decision 1764 — and
/// driving the compare through a listener that answers only while the character window is open is
/// what left a stale plate from an earlier hover sitting over an unrelated tooltip.
///
/// So the geometry and the sequence here are `MerchantFrame.xml:63-80`'s, verbatim: plate 1 owned
/// by the tooltip at `TOPLEFT`/`GameTooltip` `TOPRIGHT` (0, −10), plate 2 hanging off plate 1's
/// `TOPRIGHT`, each **filled, re-seated, filled again** (the reference's own double build — the
/// `SetOwner` between the two clears the first one) and shown only on a hit. What differs from the
/// vendor row, deliberately and namedly, is *when*: the vendor compares unconditionally, this
/// compares while SHIFT is held. 1.12 has no `COMPAREITEMS` modified click to inherit — the token
/// does not occur anywhere in its FrameXML — so the modifier is benilla's, and the director's ask.
///
/// A `compare_offer` of `None` returns without touching either plate: the vendor row's own
/// `SetMerchantCompareItem` pair is not ours to hide.
fn drive_compare(lua: &Lua, h: crate::widget::FrameHandle) {
let Some(offer) = ({
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match super::tooltip::tip_mut(&mut model, h) {
Ok(t) => t.compare_slots.clone(),
Err(_) => return,
}
super::tooltip::tip_mut(&mut model, h)
.ok()
.and_then(|t| t.compare_offer)
}) else {
return;
};
for (i, slot) in slots.iter().take(2).enumerate() {
let n = (i + 1) as i64;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(sh) = model.arena.lookup(&format!("ShoppingTooltip{n}")) {
if let Ok(t) = super::tooltip::tip_mut(&mut model, sh) {
t.compare_armed = true;
}
}
}
super::event::fire_global(
lua,
"SHOW_COMPARE_TOOLTIP",
&[
super::ScriptValue::Int(i64::from(*slot)),
super::ScriptValue::Int(n),
],
);
if let Err(e) = seat_compare_plates(lua, offer) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
}
/// [`drive_compare`]'s body, split out so its Lua calls can use `?`.
fn seat_compare_plates(lua: &Lua, offer: CompareOffer) -> mlua::Result<()> {
let globals = lua.globals();
let Ok(main) = globals.get::<Table>("GameTooltip") else {
return Ok(());
};
// Plate 2 anchors to plate 1, so the loop carries the previous plate as the next one's owner.
let mut owner = main;
let mut dy = -10.0;
for n in 1..=2u32 {
let Ok(plate) = globals.get::<Table>(format!("ShoppingTooltip{n}")) else {
return Ok(());
};
let ph = frame_handle_of(lua, &plate)?;
let hit = compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))? != Value::Nil;
if !hit {
// A miss leaves the plate's content alone (the selection's own nil contract), so the
// hide is what keeps an earlier hover's plate from riding under this one. **Both**
// plates are walked even after a miss: the selection is monotone, so plate 1 missing
// means plate 2 misses too, but a two-slot hover (a ring) leaves plate 2 shown and the
// one-slot hover after it has to take it down.
super::tooltip::hide_tooltip(lua, ph);
continue;
}
plate.get::<mlua::Function>("SetOwner")?.call::<()>((
plate.clone(),
owner.clone(),
"ANCHOR_NONE",
))?;
plate
.get::<mlua::Function>("ClearAllPoints")?
.call::<()>(plate.clone())?;
plate.get::<mlua::Function>("SetPoint")?.call::<()>((
plate.clone(),
"TOPLEFT",
owner.clone(),
"TOPRIGHT",
0.0,
dy,
))?;
compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))?;
plate
.get::<mlua::Function>("Show")?
.call::<()>(plate.clone())?;
owner = plate;
dy = 0.0;
}
Ok(())
}
/// The shift-edge compare drive (called from `UiScript::set_modifiers` on a shift transition):
/// pressing shift over a live equippable item-hover fires the compares; releasing hides the
/// shopping tooltips (INTERIM lifecycle — the real engine's edge handling is untraced).
/// pressing shift over a live equippable item-hover seats the compare plates; releasing hides
/// them. Both edges are gated on the main tooltip actually OFFERING something, so neither one
/// reaches a plate the stock vendor row is driving. (The real engine has no shift-compare at all —
/// see [`drive_compare`] — so this lifecycle is benilla's, not an INTERIM reading of a trace.)
pub(super) fn on_shift_edge(lua: &Lua, down: bool) {
let (main, shown, has_targets) = {
let (main, shown, offering) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(h) = model.arena.lookup("GameTooltip") else {
return;
};
let shown = model.arena.frame(h).map(|f| f.shown).unwrap_or(false);
let has = match super::tooltip::tip_mut(&mut model, h) {
Ok(t) => !t.compare_slots.is_empty(),
let offering = match super::tooltip::tip_mut(&mut model, h) {
Ok(t) => t.compare_offer.is_some(),
Err(_) => false,
};
(h, shown, has)
(h, shown, offering)
};
if !offering {
return;
}
if down {
if shown && has_targets {
fire_compare(lua, main);
if shown {
drive_compare(lua, main);
}
} else {
for n in 1..=2 {
let sh = {
let plate = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.lookup(&format!("ShoppingTooltip{n}"))
};
if let Some(sh) = sh {
super::tooltip::hide_tooltip(lua, sh);
if let Some(plate) = plate {
super::tooltip::hide_tooltip(lua, plate);
}
}
}
@@ -994,7 +1064,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
return Ok(Value::Nil);
};
compare_against_worn(lua, &this, h, &offered, left)
compare_against_worn(lua, &this, h, offer_of(&offered), left)
},
)?,
)?;
@@ -1065,7 +1135,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
let Some(offered) = view_of(lua, item_id) else {
return Ok(Value::Nil);
};
compare_against_worn(lua, &this, h, &offered, left)
compare_against_worn(lua, &this, h, offer_of(&offered), left)
},
)?,
)?;
@@ -1100,7 +1170,16 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
match view_of(lua, row.item_id) {
Some(v) => {
render_view(lua, &this, &v, false, None)?;
arm_compare(lua, h, &v);
// **The vendor tab arms nothing** — `MerchantFrame.xml:67-80` seats
// `ShoppingTooltip1/2` itself on every hover of it, unconditionally, and
// that is the reference's own live compare. Arming here would put the
// shift drive on the same two plates the stock row already owns, and its
// release edge would then hide a compare the player never asked it to.
// The BUYBACK tab is the other branch of the same button and seats
// nothing, so it arms like any other hover.
if buyback {
arm_compare(lua, h, &v);
}
}
None => {
// Template in flight: the row's own stat head as a minimal view.
+25 -10
View File
@@ -381,17 +381,18 @@ pub struct TooltipState {
pub world_owned: bool,
/// ARMED for a shopping-compare render: the next `SetInventoryItem` on this frame renders in
/// the byte law's compare mode (`[arg+0x14]≠0` compact + `[arg+0x18]≠0` "Currently Equipped"
/// — wow-re tooltip-content-law.md). Set by the engine right before it fires
/// `SHOW_COMPARE_TOOLTIP` for this frame's index and consumed by that render. Survives
/// `SetOwner`'s content clear (FrameXML SetOwners between the arm and the render — ref
/// PaperDollFrame.lua:621-640); how the real engine plumbs the flag to `0x52b650` is
/// unrecorded, so this seam is the INTERIM model of it.
/// — wow-re tooltip-content-law.md). Set by the engine right before it fills a shopping
/// plate, and consumed by that render. Survives `SetOwner`'s content clear, because the
/// reference's own live compare caller (`MerchantFrame.xml:67-72`) SetOwners *between* the
/// arm and the render; how the real engine plumbs the flag to `0x52b650` is unrecorded, so
/// this seam is the INTERIM model of it.
pub compare_armed: bool,
/// The paperdoll slot ids the item currently shown could equip into (empty = not
/// equippable / not an item tooltip) — set by the item render on the main GameTooltip, read
/// by the shift-edge compare drive to (re)fire `SHOW_COMPARE_TOOLTIP`. Dropped with the
/// content.
pub compare_slots: Vec<u32>,
/// What the MAIN GameTooltip is currently offering for comparison — the class and
/// `InventoryType` of the equippable item it shows (`None` = not equippable, not an item
/// tooltip, or a surface whose own FrameXML drives the plates). It is what the shift-held
/// compare drive re-runs the selection law against, and the gate that keeps that drive from
/// touching plates it does not own. Dropped with the content.
pub compare_offer: Option<CompareOffer>,
/// `SetPadding(w)` — extra width beyond the measured content (ref ItemRefTooltip's
/// OnLoad `SetPadding(16)`: room for the corner close button). 0 for ordinary tooltips.
pub padding: f32,
@@ -402,6 +403,20 @@ pub struct TooltipState {
pub xml_declared_lines: bool,
}
/// What a compare drive selects a worn item *against* — the two template fields the byte-verified
/// selection law reads (wow-re `merchant-compare-item-law.md` §3/§5): the offered item's
/// `InventoryType`, which names the candidate slots, and its item CLASS, which every candidate's
/// worn item must match. Carried instead of a whole `ItemTemplateView` because these two fields
/// are the entire input — a hover that re-drives on a shift edge must not depend on the rest of a
/// template that may have been replaced under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CompareOffer {
/// `ItemTemplateView::class`.
pub class: u32,
/// `ItemTemplateView::inventory_type`.
pub inventory_type: u32,
}
/// The tooltip plate's text inset — the real template seats `TextLeft1` at TOPLEFT (10,−10).
pub const TOOLTIP_PAD: f32 = 10.0;
/// Inter-line gap — each `TextLeftN` hangs at the previous line's BOTTOMLEFT (0,−2).
+8 -7
View File
@@ -174,13 +174,14 @@ impl<T> Arena<T> {
mod kinds;
pub use kinds::{
model_key, slider_fraction, slider_grab, slider_set_value, ArmedSequence, ButtonFont,
ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome,
EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState,
ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState,
ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipAnchor, TooltipState,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
ButtonState, ButtonVisualState, ColorSelectState, CompareOffer, EditAction, EditBoxState,
EditOutcome, EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine,
MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind,
ScrollFrameState, ScrollingMessageState, SequenceFacts, SliderState, StatusBarState,
TooltipAnchor, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK,
MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN,
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
TOOLTIP_WRAP_WIDTH,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────