From 46673c3f089dc1a01293186ee289aaba75b59c18 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Sep 2026 16:52:36 +0700 Subject: [PATCH] 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 `` 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 --- crates/benilla-app/src/bindings.rs | 180 +++++++++++---- crates/benilla-app/src/char_select/input.rs | 14 +- crates/benilla-app/src/char_select/mod.rs | 57 ++++- crates/benilla-app/src/char_select/refresh.rs | 6 +- crates/benilla-app/src/cooldowns.rs | 60 ++++- crates/benilla-app/src/player/controller.rs | 4 +- crates/benilla-app/src/player/input.rs | 10 +- crates/benilla-app/src/ui_action/state.rs | 140 +++++++++++- crates/benilla-app/src/ui_action/usable.rs | 79 ++++++- .../src/ui_script/action_bar_tests.rs | 34 +++ crates/benilla-app/src/ui_script/input.rs | 33 ++- crates/benilla-app/src/ui_script/lifecycle.rs | 1 + .../benilla-app/src/ui_script/loot_tests.rs | 88 +++++++ crates/benilla-app/src/ui_script/mod.rs | 31 ++- .../benilla-app/src/ui_script/reference_ui.rs | 10 +- .../src/ui_script/tooltip_compare_tests.rs | 216 ++++++++++++++++-- crates/benilla-ui/src/loader/widgets.rs | 18 +- crates/benilla-ui/src/script/mod.rs | 5 +- .../src/script/tests/tooltip_item/bindings.rs | 86 ++++--- crates/benilla-ui/src/script/tooltip/mod.rs | 2 +- .../benilla-ui/src/script/tooltip_item/mod.rs | 205 ++++++++++++----- crates/benilla-ui/src/widget/kinds/mod.rs | 35 ++- crates/benilla-ui/src/widget/mod.rs | 15 +- 23 files changed, 1112 insertions(+), 217 deletions(-) diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index 80e71075..1e5999b3 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -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 = Vec::new(); for &(k, _) in &state.latched { let up = match k { @@ -880,6 +896,13 @@ mod tests { app.world().resource::() } + /// 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::().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(®istry_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::().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::().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::().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 `` + /// 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(); diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs index dbc2c763..97224ae7 100644 --- a/crates/benilla-app/src/char_select/input.rs +++ b/crates/benilla-app/src/char_select/input.rs @@ -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; } } diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index bba9ce94..767218d1 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -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, @@ -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 { 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). diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index de287ec7..a540dc2d 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -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; diff --git a/crates/benilla-app/src/cooldowns.rs b/crates/benilla-app/src/cooldowns.rs index 9ec23afa..54f294a4 100644 --- a/crates/benilla-app/src/cooldowns.rs +++ b/crates/benilla-app/src/cooldowns.rs @@ -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(); diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 0676fcc0..12878b02 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -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 diff --git a/crates/benilla-app/src/player/input.rs b/crates/benilla-app/src/player/input.rs index 970fc324..0d3f4bb1 100644 --- a/crates/benilla-app/src/player/input.rs +++ b/crates/benilla-app/src/player/input.rs @@ -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); diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 3b9881be..e145192a 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::() + .eval::("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. diff --git a/crates/benilla-app/src/ui_action/usable.rs b/crates/benilla-app/src/ui_action/usable.rs index e7f66859..e45e12b2 100644 --- a/crates/benilla-app/src/ui_action/usable.rs +++ b/crates/benilla-app/src/ui_action/usable.rs @@ -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). diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index 565644a6..1c3ab265 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -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()); } diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 7a833d7d..d1c99bd0 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -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); } } } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 097db0db..2289ecd2 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -697,6 +697,7 @@ pub(crate) fn end_ui_session(world: &mut World) { if let Some(mut keys) = world.get_resource_mut::() { keys.typing = false; keys.arrows_fall_through = false; + keys.consumed.clear(); } if let Some(mut held) = world.get_resource_mut::() { *held = CursorPayloadHeld::default(); diff --git a/crates/benilla-app/src/ui_script/loot_tests.rs b/crates/benilla-app/src/ui_script/loot_tests.rs index c96ed104..41e3b654 100644 --- a/crates/benilla-app/src/ui_script/loot_tests.rs +++ b/crates/benilla-app/src/ui_script/loot_tests.rs @@ -1202,3 +1202,91 @@ fn loot_row_awaiting_its_template_opens_clean() { "the resolved row is white, got {white:?}" ); } + +/// **A `` 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 ``, no +/// `` and no `` 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 `` 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 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::>() + }; + 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" + ); +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 4368d878..fea1c03a 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -178,16 +178,33 @@ pub(crate) struct UiCostWanted(pub(crate) bool); #[derive(Resource, Default)] pub(crate) struct PlayerUiHover(pub(crate) Option); -/// 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, /// **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 diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 5149bd64..a8730d8e 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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"), ( diff --git a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs index 566f7a5c..4405a45d 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -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::("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::("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::("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() + } +} diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 0134a3cf..250520a8 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -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") { + // **`` 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 + // `` 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| { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index cd3aacfb..66ad634d 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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); } diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index aa1e8307..66ccbbe9 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -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( diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index f1afd12b..46f199e0 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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). diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 03011410..dfde745c 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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` 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 { 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::().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` 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 = { +/// 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::().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::().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::() + .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::("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::
(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::("SetOwner")?.call::<()>(( + plate.clone(), + owner.clone(), + "ANCHOR_NONE", + ))?; + plate + .get::("ClearAllPoints")? + .call::<()>(plate.clone())?; + plate.get::("SetPoint")?.call::<()>(( + plate.clone(), + "TOPLEFT", + owner.clone(), + "TOPRIGHT", + 0.0, + dy, + ))?; + compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))?; + plate + .get::("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::().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::().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. diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index bd1a6538..80fe977e 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -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, + /// 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, /// `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). diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 033f28c2..4b0c6137 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -174,13 +174,14 @@ impl Arena { 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, }; // ─────────────────────────────────────────────────────────────────────────────────────────────