Files
benilla-octo/crates/benilla-ui/src/script/model.rs
T
SamandClaude Fable 5 77dc8f8ead pets get their action bar, macros arrive, and hunters draw the bow
- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles
- the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar
- hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight
- the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:13 +07:00

964 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{HashMap, HashSet};
use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, session, skills,
slider, social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The Rust-side model (plain data; lives in lua.app_data)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The Rust-side model behind the Lua VM — the arena, the layout inputs + resolved rects, the
/// id↔handle bijection, region visuals, and the event/script registrations. Held in `lua.app_data`
/// (interior-mutable) so callbacks reach it; contains **no** mlua handles (the MAXCSTACK discipline).
pub(crate) struct Model {
/// The frame arena (create/destroy + show/hide/strata/level/scale/alpha propagation).
pub(crate) arena: WidgetArena,
/// Per-frame layout input (anchors/size/scale). Every live frame has one (created at
/// `CreateFrame`); `SetPoint`/`SetSize`/… mutate it; `resolve` runs the graph over them.
pub(crate) layout_inputs: HashMap<FrameHandle, LayoutInput>,
/// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`.
pub(crate) resolved: HashMap<FrameHandle, Rect>,
/// The anchor-graph solver, kept alive across resolves so its per-handle arrays and the
/// per-frame anchor `Vec`s are reused — a steady round allocates nothing (see
/// [`LayoutSolver`]). Scratch only: it holds no state between rounds that `begin` doesn't
/// clear, and `resolved`/`region_resolved` remain the model's own answer.
pub(crate) solver: LayoutSolver,
/// The fingerprint of the input set the last CONVERGED resolve ran against — the change gate
/// (see `script::layout::InputFingerprint`). `None` forces the next resolve to run: the
/// initial state, and what a non-converging (cyclic) pass leaves behind so its progress can
/// carry into the next frame.
pub(crate) layout_fingerprint: Option<super::layout::InputFingerprint>,
/// The layout mutation epoch — tier 1 of the resolve change gate (the fingerprint is tier 2).
/// Bumped by [`Model::touch_layout`] at every write path that can move the anchor solve's
/// read set; `resolve` returns immediately while it still equals [`Self::layout_epoch_resolved`],
/// paying a `u64` compare instead of fingerprinting ~2k inputs. Tier 2 remains the correctness
/// backstop for a write that bumps this without moving anything; it is not a licence to bump
/// per frame, because *reaching* it costs the whole-UI hash. The bag-hover re-enter loop used
/// to do exactly that (~0.65 ms/frame at solves=0) until its wrap-pin round trip was made
/// change-gated — `tooltip::append_line`, and the gate
/// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
pub(crate) layout_epoch: u64,
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
/// real solve (whose stored fingerprint answers for now-outgrown seeds — layout.rs, "Tier 1
/// closes ONLY on a settled frame").
pub(crate) layout_epoch_resolved: Option<u64>,
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
/// unconditionally every frame, so the gap between call count and this is the gate's whole
/// value; it is also what lets a test assert that a no-op resolve really was one, rather than
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// How many fixpoint ROUNDS those solves ran in total. A solve's cost is rounds × the whole
/// graph, so this is what separates "the gate let too much through" from "each pass is doing
/// too much" — the two have different fixes.
pub(crate) layout_rounds: u64,
/// Hyperlink spans per frame — `(rect, link payload, full |H…|h markup)`, rects in the
/// engine's y-up screen space. Fed by the app each frame after it rasterizes message lines
/// ([`super::UiScript::set_link_spans`]); consumed by the pointer's release dispatch
/// (`OnHyperlinkClick` — decision 0288 P2).
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
/// Tab was pressed in the chat edit box since the last drain (`BenillaChatTabPressed` →
/// [`super::UiScript::take_chat_tab`]) — the app's whisper-target cycle reads it.
pub(crate) chat_tab: bool,
/// Region visuals (texture path/color/text) + region layout (anchors/size/justify).
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
/// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `<Backdrop>` or
/// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data`
/// (the arena models structure, not paint). The client's `frame+0x1ac` pointer.
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Backdrop>,
/// The named virtual **Font object** registry (`<Font name=…>` → resolved paint), keyed by name.
/// Populated by the loader as it walks top-level `<Font>` nodes; read by `SetFontObject` and by
/// FontString `inherits=` resolution. Data only — no Lua handles (MAXCSTACK discipline).
pub(crate) font_objects: HashMap<String, FontObject>,
/// The last [`UiScript::resolve`] result for **anchored** regions (those with a non-empty
/// [`RegionData::anchors`]): the region's own resolved rect, owner-relative (see [`extract`]).
/// Regions with no anchors are absent here — they fall to the size-centered / fill-owner path.
pub(crate) region_resolved: HashMap<RegionHandle, Rect>,
/// Monotonic id source (starts at 1; `0` is [`SCREEN`]).
pub(crate) next_id: u32,
pub(crate) id_to_frame: HashMap<u32, FrameHandle>,
pub(crate) frame_to_id: HashMap<FrameHandle, u32>,
pub(crate) id_to_region: HashMap<u32, RegionHandle>,
pub(crate) region_to_id: HashMap<RegionHandle, u32>,
/// Region name → layout id — the region twin of the arena's frame-name publish (same
/// non-overwriting first-wins rule). `SetPoint`'s string `relativeTo` resolves through frames
/// first, then here — the real client anchors regions to *sibling regions* by name everywhere
/// (e.g. the merchant label plate to its `$parentSlot` texture), which owner-fallback silently
/// mis-anchored before (the jutting-plates bug the director's A/B caught).
pub(crate) region_names: HashMap<String, u32>,
/// Which handler kinds each frame has a script for (presence mirror; the closures live Lua-side
/// in the `REG_SCRIPTS` table). Lets `tick` find OnUpdate frames without scanning Lua.
pub(crate) scripts: HashMap<FrameHandle, HashSet<&'static str>>,
/// `event name → frames registered for it` (RegisterEvent), in **registration order** — an
/// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener
/// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line
/// bug: two ZoneText frames write the same FontString on one event — last writer decides).
/// Re-registering is a no-op (position kept); Unregister removes. Data only — no Lua handles.
pub(crate) event_to_frames: HashMap<String, Vec<FrameHandle>>,
/// `frame → its registered events` (for UnregisterEvent / cleanup).
pub(crate) frame_events: HashMap<FrameHandle, HashSet<String>>,
/// The EditBox that currently owns keyboard focus — the engine's twin of the client's
/// class-owned focus global `DAT_00cf4dc8` (`CSimpleEditBox* E`, 0 = none; RF-0082 §1). A focused
/// box consumes every key/char; `None` lets an `autoFocus` box self-acquire on the first event.
/// Gated on effective-visibility at read time (a box hidden while focused stops taking input).
pub(crate) focused_editbox: Option<FrameHandle>,
/// The frame currently under the cursor (the last [`UiScript::mouse_move`] capture), so the next
/// move knows whether to fire `OnLeave`/`OnEnter`. `None` = cursor over no mouse-enabled frame.
pub(crate) mouseover: Option<FrameHandle>,
/// Per-button, the frame a mouse-down last captured (`button name → frame`), for the `OnClick`
/// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton`
/// press is not cleared by a `RightButton` release.
pub(crate) mouse_down_on: HashMap<String, FrameHandle>,
/// Script errors collected from `pcall`'d handlers (never panics, never prints — decision 0068).
pub(crate) errors: Vec<String>,
/// Non-fatal warnings surfaced to the host (e.g. `CreateFrame`'s ignored `inherits=` template).
pub(crate) warnings: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
pub(crate) screen: Rect,
/// The per-unit-token game-state snapshot the app pushes in each frame (`"player"`, `"target"`,
/// …), read by the `Unit*` Lua bindings ([`unit`]). Plain data — the engine never touches the
/// ECS/net; the app's feed writes here via [`UiScript::set_unit`] (decision 0068 §3). A token
/// absent from the map is a non-existent unit (`UnitExists` false, numbers `0`/nil).
pub(crate) units: HashMap<String, UnitState>,
/// Per-unit-token aura list, **in display order**, pushed by the app's aura feed each frame and
/// read by the `UnitAura` family ([`super::aura`]). The order is the app's decision, not the
/// engine's: the local player's is a maintained insertion-order cache, every other unit's is
/// ascending aura slot (decision 0257). A token absent here has no auras.
pub(crate) auras: HashMap<String, Vec<AuraState>>,
/// Spell ids `CancelUnitBuff` queued since the app's last drain — one `CMSG_CANCEL_AURA` each.
pub(crate) cancel_aura_requests: Vec<u32>,
/// The player's active tracking aura (the reference's `GetTrackingTexture` global — see
/// [`super::aura::TrackingState`]), pushed by the app's aura feed beside `auras`. `None` =
/// no tracking active.
pub(crate) tracking: Option<super::aura::TrackingState>,
/// Unit tokens `TargetUnit` queued since the app's last [`UiScript::take_target_requests`] drain
/// — the outbound twin of `units` above (the reference's `TargetUnit` Lua shim → SetSelection).
/// The app resolves each token to a streamed entity and commits the selection; a token it can't
/// resolve is a no-op, as the real client no-ops `TargetUnit` on a nonexistent unit ([`unit`]).
pub(crate) target_requests: Vec<String>,
/// Set when `ClearTarget()` fired with a live target — the ESC chain's LAST leg
/// (`ToggleGameMenu`'s order: the clear runs only when nothing earlier ate the press).
/// Drained by [`super::UiScript::take_target_clear`]; the app commits the deselect ([`unit`]).
pub(crate) target_clear: bool,
/// The party/raid roster snapshot the app pushes (`GroupState`'s merged view, decision 0434
/// §2) — `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/… read it ([`party`]).
/// `PartyState::default()` = not in a group (every getter reports the solo-player shape).
/// Per-member game state rides the `units` map above, under `"party1"`..`"party4"` tokens.
pub(crate) party: party::PartyState,
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
pub(crate) party_requests: Vec<party::PartyRequest>,
/// The social snapshot the app pushes (friends, ignores, the last `/who` — decision 0668):
/// `GetNumFriends`/`GetFriendInfo`/`GetWhoInfo`/… read it ([`social`]). Already
/// display-resolved (names, class/zone names) because the reference resolves them
/// engine-side too.
pub(crate) social: social::SocialState,
/// Social intents (`AddFriend`/`RemoveFriend`/`SendWho`/…) queued since the app's last
/// [`super::UiScript::take_social_requests`] drain — the outbound seam ([`social`]).
pub(crate) social_requests: Vec<social::SocialRequest>,
/// Whisper targets `ChatFrame_SendTell` queued since the app's last
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
pub(crate) tell_requests: Vec<String>,
/// Duel intents (`AcceptDuel`/`CancelDuel`/`StartDuel*`) queued since the app's last
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
pub(crate) duel_requests: Vec<duel::DuelRequest>,
/// Follow intents (`FollowUnit`/`FollowByName`) queued since the app's last
/// [`super::UiScript::take_follow_requests`] drain — the outbound seam ([`follow`]). The duel
/// queue's twin, snapshot-less for the same reason: the only thing the UI reads back is the
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` pair the app fires, which carries its own argument.
pub(crate) follow_requests: Vec<follow::FollowRequest>,
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
/// arrives as the `PLAYER_CAMPING`/`PLAYER_QUITING`/`LOGOUT_CANCEL` events, not as state.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// PvP-flag toggles (`TogglePVP`) queued since the app's last
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
/// payload: `CMSG_TOGGLE_PVP` carries no body.
pub(crate) pvp_toggles: u32,
/// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
pub(crate) sound_queue: Vec<SoundRequest>,
/// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered
/// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
/// `(registered name, new value)` per Lua `SetCVar` since the app's last
/// [`super::UiScript::take_cvar_changes`] drain — the knob-sync + config-dirty cue.
pub(crate) cvar_changes: Vec<(String, String)>,
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
pub(crate) cvars_warned: HashSet<String>,
/// The action-slot snapshot (keyed by Lua action id 1..120) + the stance page offset the app
/// pushes, and the `UseAction` intents it drains — the action seam ([`action`]).
pub(crate) actions: HashMap<u32, ActionSlot>,
/// The per-action **dynamic** state (usable/range/current/cooldown — decision 0137 phase 4),
/// pushed beside `actions` by the app's feed; the `IsUsableAction`/`GetActionCooldown` family
/// reads it ([`action`]). Keyed like `actions`; an absent action reads cold/nil.
pub(crate) action_states: HashMap<u32, super::action::StoredActionState>,
pub(crate) bonus_bar_offset: u8,
pub(crate) action_uses: Vec<u32>,
/// `(lua action id, packed)` pairs queued by `PickupAction`/`PlaceAction` (decision 0216 §7,
/// slice 4) — one entry per local slot mutation, `packed == 0` clearing the slot. Drained by
/// the app into `CMSG_SET_ACTION_BUTTON`, one send per entry (client-authoritative, per-change
/// — 0218 §4: a drag-swap is two sends, never atomic).
pub(crate) action_sets: Vec<(u32, u32)>,
/// GlobalStrings keys for **client-local refusals the ENGINE raises** — the engine-free half
/// of the app's `ui_action::UiErrorKeys`, which cannot reach in here. The reference does this
/// inline (`push <errorId>; call CGGameUI::DisplayError 0x496720`) because its engine owns
/// both the refusal and the message; ours splits at the crate boundary, so the refusal queues
/// its key and the app's action feed resolves it against the VM's own GlobalStrings and fires
/// `UI_ERROR_MESSAGE`. Always `&'static str`: every tenant is a literal read out of the ref's
/// errorId table (`0xb4b498`, stride `0x14`), never runtime-built.
pub(crate) ui_errors: Vec<&'static str>,
/// The player's known-spell book (decision 0216 §8, slice 5) — tabs + the flat slot list the
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
/// `actions` above, never `Option` — "no known spells yet" is simply empty vectors.
pub(crate) spellbook: spellbook::SpellBookState,
/// The player's **macros** (decision 0983) — the one game-state table this crate owns
/// outright, because 1.12 macros have no server side at all ([`macros`]'s module docs). The
/// app seeds it from `benilla/macros/…` and reads it back to persist.
pub(crate) macros: macros::MacroState,
/// A script mutated [`Self::macros`] since the app's last
/// [`super::UiScript::take_macros_dirty`] drain — the save + `UPDATE_MACROS` trigger.
pub(crate) macros_dirty: bool,
/// Bumped by every seed and every mutation — the *undrained* change signal per-frame
/// consumers gate on ([`super::UiScript::macros_generation`]), so the drained
/// [`Self::macros_dirty`] edge keeps its single owner.
pub(crate) macros_generation: u64,
/// The macro-icon chooser list (full texture paths, app-built off `SpellIcon.dbc`) —
/// `GetNumMacroIcons`/`GetMacroIconInfo`.
pub(crate) macro_icons: Vec<String>,
/// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`]
/// drain.
pub(crate) spell_casts: Vec<u32>,
/// Whether the app's own cast lifecycle holds something `SpellStopCasting()` can stop — a
/// running auto-repeat or an in-flight cast; a channel is NOT stoppable there (wow-re
/// `esc-stopcasting.md`; pushed each frame by the app's cast feed,
/// [`super::UiScript::set_casting`]). The 1/nil return is load-bearing:
/// `ToggleGameMenu`'s ESC chain (`UIParent.lua:1489`) only falls through to
/// `CloseAllWindows()` on nil.
pub(crate) casting: bool,
/// Set when `SpellStopCasting()` fired while [`Self::casting`] — the ESC local-cancel
/// trigger, drained by [`super::UiScript::take_spell_stop`] ([`spellbook`]).
pub(crate) spell_stop: bool,
/// Whether the app's spell-targeting cursor mode is active — the `flag_word != 0` mirror
/// (`SpellIsTargeting 0x6e6cd0`, decision 0792). Pushed each frame by the app's targeting
/// feed ([`super::UiScript::set_spell_targeting`]); read by `SpellIsTargeting()` and gating
/// `SpellStopTargeting()`, whose 1/nil return the ESC chain's rung (`UIParent.lua:1490`)
/// falls through on, exactly like [`Self::casting`]'s.
pub(crate) spell_targeting: bool,
/// Set when `SpellStopTargeting()` fired while [`Self::spell_targeting`] — the ESC-chain
/// targeting cancel, drained by [`super::UiScript::take_stop_targeting`] ([`spellbook`]).
pub(crate) spell_stop_targeting: bool,
/// The player's talent pages (decision 0304) — tabs + per-tab talents the
/// `GetNumTalentTabs`/`GetTalentInfo`/… bindings read ([`super::talent`]). Durable player
/// state like `spellbook`; "no talents yet" is simply empty.
pub(crate) talents: super::talent::TalentUiState,
/// `LearnTalent(tab, index)` clicks queued since the app's last
/// [`super::UiScript::take_talent_learns`] drain.
pub(crate) talent_learns: Vec<(u32, u32)>,
/// The stance/shapeshift bar's form list (bar order) the app pushes — the
/// `GetNumShapeshiftForms`/`GetShapeshiftFormInfo` family reads it ([`super::shapeshift`]).
/// Durable player state like `spellbook`; "no forms" (a mage) is simply empty.
pub(crate) shapeshift_forms: Vec<super::shapeshift::StoredShapeshiftForm>,
/// Form spell ids `CastShapeshiftForm` queued since the app's last
/// [`super::UiScript::take_shapeshift_casts`] drain.
pub(crate) shapeshift_casts: Vec<u32>,
/// The pet action bar the app pushes — the ten slots plus the two bar-wide bits the
/// `PetHasActionBar`/`GetPetActionsUsable`/`GetPetActionInfo` family reads ([`super::pet`],
/// decision 0982). Unlike the stance bar's, this state is **server-authoritative**: it is
/// replaced wholesale on every `SMSG_PET_SPELLS`, and is empty whenever there is no pet.
pub(crate) pet_bar: super::pet::PetBarState,
/// 1-based slot indices `CastPetAction` queued since the app's last
/// [`super::UiScript::take_pet_actions`] drain.
pub(crate) pet_actions_pressed: Vec<u32>,
/// 1-based slot indices `TogglePetAutocast` queued.
pub(crate) pet_autocast_toggles: Vec<u32>,
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
pub(crate) pet_stop_attacks: u32,
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
pub(crate) containers: HashMap<i64, container::ContainerState>,
pub(crate) container_uses: Vec<(i64, u32)>,
/// Per-(bag, slot) use-cooldowns in `GetTime` seconds `(start, duration, enabled)` — stamped
/// at `set_container` push time (the action-state pattern), read by
/// `GetContainerItemCooldown`.
pub(crate) container_cooldowns: HashMap<(i64, u32), (f64, f64, bool)>,
/// `HasKey()` — whether the player owns any item of `BagFamily` KEYS, anywhere the reference's
/// own search reaches (equipment, bags, backpack, **bank**, keyring). App-resolved like every
/// other item fact; the engine holds no item knowledge of its own. Gates the whole keyring UI
/// (decision 0765).
pub(crate) has_key: bool,
/// What the cursor carries (`PickupContainerItem`/`SplitContainerItem`/… set it; `None` =
/// empty cursor) — the real client's transient drag state, typed by payload arm
/// ([`cursor::CursorPayload`], decision 0216). Purely visual + intent-routing: no item moves
/// locally, the server's field updates settle the bag ([`container`]). The app draws the
/// held icon at the mouse and reads `None`/`Some` to show/hide it.
pub(crate) cursor: Option<cursor::CursorPayload>,
/// Mirrors "is `cursor` currently `Some`" across transitions — [`cursor::queue_cursor_update`]
/// compares it against the live state on every call to derive `ACTIONBAR_SHOWGRID`/
/// `ACTIONBAR_HIDEGRID` (decision 0216 §7): fires exactly on a None↔Some edge, any payload
/// arm, any surface (bags/doll/actions alike — the reference's own "any placeable payload
/// shows the bar's drop grid"). A Some→Some transition (the action hop) updates nothing here,
/// so no spurious HIDE+SHOW churns out of one gesture.
pub(crate) cursor_grid_shown: bool,
/// The app's world pick under the cursor — the reference's click-time pick state
/// (`[this+0x350]`: nothing / terrain / object), fed once per frame
/// ([`super::UiScript::set_world_pick`]; stays `Nothing` in tests/captures). Routes
/// [`cursor::world_drop_click`]'s legs (decisions 0571 + 0574): an `Object` pick drops no
/// payload at all, `Terrain` drops items only (a spell/action survives the ground click),
/// `Nothing` drops any arm.
pub(crate) world_pick: cursor::WorldPick,
/// Backpack pick/place/swap intents queued by `PickupContainerItem`/`SplitContainerItem` when
/// the cursor was holding (src bag/slot → dst bag/slot, live-API space, optional split count),
/// drained by the app into `CMSG_SWAP_INV_ITEM`/`CMSG_SPLIT_ITEM`.
pub(crate) container_moves: Vec<container::ContainerMove>,
/// Repair-mode clicks the pickup intercept queued (`(bag, slot)`; [`container`]).
pub(crate) container_repairs: Vec<(i64, u32)>,
/// The targeting cursor's **item** half is up (`TargetingWantsItem 0x6e6330`, decision 0923;
/// first built for the CraftFrame enchant pick, 0437 phase 3): while set, a bag OR paper-doll
/// click queues into [`Self::item_picks`] instead of running the cursor gesture.
pub(crate) item_pick_armed: bool,
/// `(bag, slot)` clicks consumed by the armed item pick, drained by the app. A doll click
/// reports as [`super::EQUIPMENT_BAG`] + its 1-based inventory slot, so both seams speak the
/// one bag space the drain already resolves.
pub(crate) item_picks: Vec<(i64, u32)>,
/// `BindEnchant()`/`ReplaceEnchant()` — the two enchant-confirm popups' answers to a pick the
/// app already parked, drained by it (decision 0928; [`cursor::EnchantConfirm`]).
pub(crate) enchant_confirms: Vec<cursor::EnchantConfirm>,
/// `(bag, slot, count)` triples queued by `DeleteCursorItem` (`count == 0` = the whole
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
/// `CMSG_DESTROYITEM`.
pub(crate) container_destroys: Vec<(i64, u32, u32)>,
/// The Lua-set **displayed-cursor override** (`ShowContainerSellCursor`/`ShowMerchantSellCursor`/
/// `ShowBuybackSellCursor`/`ShowInspectCursor` set it, `ResetCursor` clears it; [`container`] +
/// [`merchant`]) — the single "displayed mode" the real client keeps at `0xbe2c2c`, restored to
/// the base (world-classifier) mode by `ResetCursor`. `None` = no override (show the base mode).
pub(crate) ui_cursor: Option<container::UiCursorMode>,
/// `(bag, slot)` sources queued by `AutoEquipCursorItem` (decision 0208 phase 1b, `cursor`'s
/// `doll` submodule) — drained by the app into `CMSG_AUTOEQUIP_ITEM`.
pub(crate) container_autoequips: Vec<(i64, u32)>,
/// Per-frame drag-button registrations (`RegisterForDrag`) — kind-independent (any Frame, not
/// just a Button), so it lives beside [`Model::mouse_down_on`] rather than inside a per-kind
/// state. Verbatim button-name sets (`"LeftButton"`, …); [`UiScript::mouse_button`]/
/// [`UiScript::mouse_move`] compare case-insensitively (RF's `RegisterForClicks` precedent).
/// Same destroy-cleanup status as [`Model::scripts`]/[`Model::frame_events`]: nothing in this
/// engine destroys a frame yet (no Lua `Destroy`, no call to `WidgetArena::destroy` outside
/// its own tests), so none of the three is pruned today — a future destroy path must prune
/// all three together.
pub(crate) drag_registered: HashMap<FrameHandle, HashSet<String>>,
/// The in-flight drag gesture: armed at mouse-down on a [`Model::drag_registered`] frame,
/// `started` once the cursor has moved past the drag-start threshold — `None` between
/// gestures ([`cursor::DragGesture`], decision 0216 §3).
pub(crate) drag: Option<cursor::DragGesture>,
/// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held
/// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag —
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
/// ([`slider::SliderDrag`]).
pub(crate) slider_drag: Option<slider::SliderDrag>,
/// The open gossip menu the app pushes (`None` = no menu), the `SelectGossipOption` intents it
/// drains, and whether `CloseGossip` was called — the gossip seam ([`gossip`]).
pub(crate) gossip: Option<gossip::GossipMenu>,
pub(crate) gossip_selects: Vec<u32>,
pub(crate) gossip_close: bool,
/// 1-based quest-row selects queued by `SelectGossipQuest` (decision 0088) — the app maps each
/// to the row's quest id and sends `CMSG_QUESTGIVER_QUERY_QUEST`.
pub(crate) gossip_quest_selects: Vec<u32>,
/// The open merchant's stock snapshot the app pushes (`None` = no vendor open), the
/// `BuyMerchantItem` intents it drains, and whether `CloseMerchant` was called — the merchant
/// seam ([`merchant`]).
pub(crate) merchant: Option<merchant::MerchantState>,
pub(crate) merchant_buys: Vec<(u32, u32)>,
pub(crate) merchant_close: bool,
/// `BuybackItem` intents (1-based buyback slots), the `RepairAllItems` flag, and the
/// client-side repair-mode latch (`ShowRepairCursor`/`InRepairMode`) — the rest of the
/// merchant seam.
pub(crate) merchant_buybacks: Vec<u32>,
pub(crate) repair_all: bool,
pub(crate) repair_mode: bool,
/// The open bank's purchase-row snapshot the app pushes (`None` = no bank open), the
/// `PurchaseSlot` intent, and whether `CloseBankFrame` was called — the bank seam ([`bank`],
/// decision 0604; the bank's *contents* ride the container seam as bags −1/5..=10).
pub(crate) bank: Option<bank::BankState>,
pub(crate) bank_purchase: bool,
pub(crate) bank_close: bool,
/// The open trainer's service snapshot the app pushes (`None` = no trainer open), the
/// `BuyTrainerService` intents it drains, the engine-held 1-based selection (0 = none), and
/// whether `CloseTrainer` was called — the trainer seam ([`trainer`], decision 0237).
pub(crate) trainer: Option<trainer::TrainerState>,
pub(crate) trainer_buys: Vec<u32>,
pub(crate) trainer_selection: u32,
pub(crate) trainer_close: bool,
/// The three state filters (available / unavailable / used) — the real client hides filtered
/// service rows itself ([`trainer`]); all shown by default. A state filter hides *services*, never
/// headers (decision 0247).
pub(crate) trainer_filter: [bool; 3],
/// The skill lines the player has collapsed in the tree — their services hide, the header stays
/// (decision 0247). Keyed by skill-line id so it survives a content update (`set_trainer` keeps
/// only still-present lines); cleared when the trainer closes.
pub(crate) trainer_collapsed: HashSet<u32>,
/// The open taxi map's snapshot the app pushes (`None` = closed), the `TakeTaxiNode` intents
/// it drains, whether `CloseTaxiMap` was called, and whether our own player is riding a
/// taxi — the taxi seam ([`taxi`], decision 0484).
pub(crate) taxi: Option<taxi::TaxiUiState>,
pub(crate) taxi_takes: Vec<usize>,
pub(crate) taxi_close: bool,
pub(crate) taxi_riding: bool,
/// The open tradeskill window's recipe snapshot the app pushes (`None` = no window open) — the
/// tradeskill seam ([`tradeskill`], decision 0437 phase 2).
pub(crate) trade_skill: Option<tradeskill::TradeSkillState>,
/// `(spell id, count)` intents `DoTradeSkill` queued since the app's last
/// [`super::UiScript::take_trade_skill_dos`] drain.
pub(crate) trade_skill_dos: Vec<(u32, u32)>,
/// The engine-held 1-based selection (0 = none), preserved across a `set_trade_skill` re-push by
/// the recipe's spell id (see [`super::UiScript::set_trade_skill`]).
pub(crate) trade_skill_selection: u32,
/// Whether `CloseTradeSkill` was called since the app's last
/// [`super::UiScript::take_trade_skill_close`] drain.
pub(crate) trade_skill_close: bool,
/// The recipe groups the player has collapsed, by group key `(ItemClass, ItemSubClass)`
/// (wow-re `tradeskill` TU-B) — a group's recipes hide, its header stays (the
/// trainer/skills-pane precedent, [`Model::trainer_collapsed`]/[`Model::skills_collapsed`]).
/// Survives a `set_trade_skill` content re-push (pruned to the groups the fresh recipes still
/// produce) AND a same-profession close→reopen (wow-re `tradeskill` TU-G §6 — the collapse
/// mask `0x84dd68` round-trips the rebuild by header key); reset on a profession switch
/// ([`Model::trade_skill_last_line`]).
pub(crate) trade_skill_collapsed: HashSet<(u32, u32)>,
/// Group keys the SubClass filter dropdown has hidden (empty = "All Subclasses"). Keyed like
/// [`Model::trade_skill_collapsed`] — the real client's per-header shown flag (`header+0xc`)
/// survives a rebuild by exactly this `(ItemClass, ItemSubClass)` key match (`0x4fca20`'s
/// save→restore loop; the position mask `0x84dd60` is the derived form — wow-re `tradeskill`
/// TU-G). Persists across a same-profession close/reopen; reset on a profession switch
/// ([`Model::trade_skill_last_line`]).
pub(crate) trade_skill_subclass_hidden: HashSet<(u32, u32)>,
/// The InvSlot filter mask (`0x84dd64`, wow-re `tradeskill` TU-G): **bit set = slot shown**,
/// all-ones = "All Slots". The real client's build never touches this static — it is NOT
/// pruned on a re-push, and an exclusive pick therefore keeps every OTHER bit clear even for
/// slots that appear later. Reset (all-ones) only on a profession switch.
pub(crate) trade_skill_invslot_mask: u32,
/// The skill line the tradeskill state was last built for (`0xbde064`, wow-re TU-G's cache
/// key): a `set_trade_skill` push for a DIFFERENT line resets the two filters, the collapse
/// set, and the selection; the same line (including a close→reopen round trip) keeps them.
pub(crate) trade_skill_last_line: u32,
/// The selected recipe's SPELL ID (`0xbde044` — the real client stores the selection by spell
/// id, not index), shadowing [`Model::trade_skill_selection`]'s flat position so the selection
/// survives a same-profession close→reopen (TU-G: untouched on that path) and remaps across a
/// re-push.
pub(crate) trade_skill_selected_spell: u32,
/// Set by the engine-side mutators the real client answers with a `TRADE_SKILL_UPDATE` event
/// from inside the C call (`Set*Filter`/`Expand/CollapseTradeSkillSubClass` → the
/// `0x4fd710`/`0x4fd730`/`0x4fd750` writer trio → recompute+resort `0x4fd180` + event
/// **0x13a**); the app drains it ([`super::UiScript::take_trade_skill_touched`]) and fires
/// the event the same frame.
pub(crate) trade_skill_touched: bool,
/// The open craft window's recipe snapshot the app pushes (`None` = no window open) — the craft
/// seam ([`craft`], decision 0437 phase 3), TradeSkill's exact twin.
pub(crate) craft: Option<craft::CraftState>,
/// Spell id intents `DoCraft` queued since the app's last [`super::UiScript::take_craft_dos`]
/// drain — no count (the 1.12 CraftFrame has no Create All, unlike `trade_skill_dos`).
pub(crate) craft_dos: Vec<u32>,
/// The engine-held 1-based selection (0 = none), preserved across a `set_craft` re-push by the
/// recipe's spell id (see [`super::UiScript::set_craft`]).
pub(crate) craft_selection: u32,
/// Whether `CloseCraft` was called since the app's last [`super::UiScript::take_craft_close`]
/// drain.
pub(crate) craft_close: bool,
/// The open loot's row snapshot the app pushes (`None` = no loot open), the `LootSlot` row-pick
/// intents it drains, and whether `CloseLoot` was called — the loot seam ([`loot`]).
pub(crate) loot: Option<loot::LootState>,
pub(crate) loot_picks: Vec<u32>,
pub(crate) loot_close: bool,
/// The open group-loot rolls the app pushes (empty = none open) and the `RollOnLoot`
/// `(roll_id, roll_type)` votes it drains — the roll seam ([`loot_roll`], decision 0591).
pub(crate) loot_rolls: loot_roll::LootRollsState,
pub(crate) loot_roll_votes: Vec<(u32, u8)>,
/// Need/Greed on a **bind-on-pickup** roll: not a vote but a request for the
/// `CONFIRM_LOOT_ROLL` popup, which `ConfirmLootRoll` then re-enters past the gate
/// (decision 0594's binary correction).
pub(crate) loot_roll_confirms: Vec<(u32, u8)>,
/// The open mailbox's inbox snapshot the app pushes (`None` = no mailbox open) and the intents
/// the app drains — the mail seam ([`mail`], decision 0544). `mail_opens` are the 1-based rows
/// `GetInboxText` touched (the app marks each read + ask-once fetches its body); the take/delete/
/// return vecs are 1-based row picks; `mail_send` is the pending `SendMail(target,subject,body)`
/// the app folds `mail_send_money`/`mail_send_cod`/`mail_send_item` into at drain.
/// The item-text reader session ([`item_text`]): the pushed snapshot + the close/page-turn
/// intents the app drains.
pub(crate) item_text: Option<item_text::ItemTextState>,
pub(crate) item_text_close: bool,
pub(crate) item_text_page_turns: Vec<i32>,
pub(crate) mail: Option<mail::MailState>,
pub(crate) mail_check_inbox: bool,
pub(crate) mail_opens: Vec<u32>,
pub(crate) mail_take_items: Vec<u32>,
pub(crate) mail_take_money: Vec<u32>,
pub(crate) mail_deletes: Vec<u32>,
pub(crate) mail_returns: Vec<u32>,
pub(crate) mail_take_texts: Vec<u32>,
pub(crate) mail_close: bool,
pub(crate) mail_send: Option<(String, String, String)>,
pub(crate) mail_send_money: u32,
pub(crate) mail_send_cod: u32,
/// The Send tab's attached bag item (a cursor drop, decision 0216) — carried until the send
/// fires; the app resolves its `(bag, slot)` to the wire item guid then.
pub(crate) mail_send_item: Option<cursor::CursorItem>,
/// `HasNewMail()` — login-scoped (survives the mailbox window closing, unlike [`Self::mail`]
/// above): the app's `MSG_QUERY_NEXT_MAIL_TIME`/`SMSG_RECEIVED_MAIL`-fed countdown reduced to
/// one flag (decision 0544 P3, wow-re §5 `mail-interaction.md`). The reference minimap icon
/// (`MiniMapMailFrame`) reads this on `UPDATE_PENDING_MAIL`.
pub(crate) has_new_mail: bool,
/// The open trade window's snapshot the app pushes (`None` = no trade open) and the intents the
/// app drains — the trade seam ([`trade`], decision 0592 P1). `trade_initiates` are the unit
/// tokens `InitiateTrade` queued (the app resolves each → guid → `CMSG_INITIATE_TRADE`); the
/// three flags are the accept / un-accept / cancel verbs (`CMSG_ACCEPT_TRADE` /
/// `CMSG_UNACCEPT_TRADE` / `CMSG_CANCEL_TRADE`). `trade_set_money` is the copper `SetTradeMoney`
/// offered (→ `CMSG_SET_TRADE_GOLD`, decision 0592 P2).
pub(crate) trade: Option<trade::TradeState>,
pub(crate) trade_initiates: Vec<String>,
pub(crate) trade_accept: bool,
pub(crate) trade_unaccept: bool,
pub(crate) trade_close: bool,
pub(crate) trade_set_money: Option<u32>,
/// Cursor-drop placements onto our trade slots: `(trade_id 1-based, bag, slot)` → the app resolves
/// `(bag, slot)` → wire position → `CMSG_SET_TRADE_ITEM`. `trade_clear_items` are the 1-based ids
/// an empty-cursor click cleared (→ `CMSG_CLEAR_TRADE_ITEM`). Decision 0592 P2.
pub(crate) trade_set_items: Vec<(u32, i64, u32)>,
pub(crate) trade_clear_items: Vec<u32>,
/// The open questgiver panel the app pushes (`None` = no window), the greeting-row selects and
/// the button intents it drains — the questgiver seam ([`quest`]).
pub(crate) quest: Option<quest::QuestState>,
pub(crate) quest_selects: Vec<quest::QuestSelect>,
pub(crate) quest_actions: Vec<quest::QuestAction>,
/// The death-arc seam ([`death`], decision 0308): the snapshot the app pushes (countdowns +
/// offer bits) and the drained release/reclaim/resurrect intents.
pub(crate) death: death::DeathUiState,
pub(crate) death_actions: Vec<death::DeathAction>,
/// The quest-log seam ([`quest_log`]): the snapshot the app pushes, the engine-owned
/// synchronous selection + click-time abandon mark (1-based entry indices, `0` = none), and
/// the drained abandon intents.
pub(crate) quest_log: quest_log::QuestLogState,
pub(crate) quest_log_selection: u32,
pub(crate) quest_log_abandon_mark: u32,
pub(crate) quest_log_abandons: Vec<u32>,
/// The shared item-template store: `item id → full tooltip view` ([`item_stats`] module doc,
/// decision 0274 P1).
pub(crate) item_templates: HashMap<u32, ItemTemplateView>,
/// Item ids the renderer asked for that the store lacks — drained by the app
/// ([`UiScript::take_item_stat_asks`]), answered via [`UiScript::set_item_template`].
pub(crate) item_stat_asks: HashSet<u32>,
/// The item-set store (`set id → §22 SET-block view`) + its ask-once misses — the same
/// push/ask flow as the templates ([`item_stats`] module doc).
pub(crate) item_sets: HashMap<u32, super::ItemSetView>,
pub(crate) item_set_asks: HashSet<u32>,
/// The red-line law's player state (level/class/race/skills — [`item_stats`] module doc).
pub(crate) player_req: PlayerReqState,
/// The spell-tooltip store: `spell id → resolved view` ([`tooltip_spell`] module doc,
/// decision 0274 P2) + the ask-once misses the app drains.
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) spell_tooltip_asks: HashSet<u32>,
/// Header collapse/expand intents `(1-based entry index, collapse)` — `CollapseQuestHeader`/
/// `ExpandQuestHeader` push, the app drains ([`UiScript::take_quest_log_collapses`]); index 0
/// = all headers.
pub(crate) quest_log_collapses: Vec<(u32, bool)>,
/// The watched (tracked) quests, by stable quest id in watch order — the tracker HUD's set
/// (engine-owned like the selection; pruned by `set_quest_log` when a quest leaves the log).
pub(crate) quest_log_watched: Vec<u32>,
/// The player's purse in copper (`GetMoney`), pushed each frame it changes by the app's
/// `PLAYER_FIELD_COINAGE` feed ([`UiScript::set_money`]). Plain data — the money display + the
/// merchant window's coin line read it.
pub(crate) money: u64,
/// The reported connection round trip in ms — `GetNetStats`'s third return, pushed by the app's
/// net feed ([`UiScript::set_latency_ms`]). The AVERAGE over the app's RTT ring, not the last
/// sample; `0` = nothing measured yet. See [`super::net_stats`].
pub(crate) net_latency_ms: u32,
/// The player's experience within the current level (`UnitXP("player")`) and the amount required
/// to level (`UnitXPMax("player")`), pushed each frame they change by the app's `PLAYER_XP` /
/// `PLAYER_NEXT_LEVEL_XP` feed ([`UiScript::set_player_xp`]). A player-level pair (like `money`),
/// not a per-unit-token field: PLAYER_XP is a PRIVATE descriptor field, only ever our own avatar's.
pub(crate) player_xp: u32,
pub(crate) player_next_level_xp: u32,
/// The player's banked combo points and the unit they sit on — the raw `PLAYER_FIELD_BYTES`
/// byte 1 and `PLAYER_FIELD_COMBO_TARGET` GUID, pushed together each frame either moves
/// ([`UiScript::set_combo_points`]). Player-global PRIVATE fields, like `money`.
///
/// **Raw wire values, deliberately ungated.** The server banks a point here for a *warrior*
/// too (the Overpower window), and the usable walk's leg 5 consumes exactly that. The two
/// gates the real `GetComboPoints 0x51a190` applies — rogue-or-druid only, and the banked
/// target must be the CURRENT target — live in the binding, where the binary puts them
/// (decision 0875). Read [`Self::combo_points`] directly and you are reading the wire, not
/// what the reference UI can see.
pub(crate) combo_points: u8,
pub(crate) combo_target: u64,
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
/// seam ([`char_stats`], decision 0208 §3). All player-only data: the backing descriptor
/// fields are PRIVATE/OWNER_ONLY.
pub(crate) player_combat_stats: Option<char_stats::PlayerCombatStats>,
pub(crate) inventory_slots: char_stats::InventorySlots,
/// The 12 alert-region statuses (`GetInventoryAlertStatus`, DurabilityFrame's armor-guy
/// feed) in the client's own `0x806eb8` table order (11 equipment regions + the low-ammo
/// 12th) — recomputed on every inventory push, which fires `UPDATE_INVENTORY_ALERTS`
/// unconditionally, the client's own shape (see [`char_stats`]).
pub(crate) inventory_alerts: [u8; 12],
pub(crate) paperdoll_yaw: f32,
/// Inventory-slot ids queued by `UseInventoryItem` (decision 0208 phase 1b, `cursor`'s `doll`
/// submodule) — drained by the app into `CMSG_USE_ITEM` against the equipped position.
pub(crate) inventory_uses: Vec<u32>,
/// The inspected unit's equipment view, keyed by unit token ([`inspect`], decision 0631) —
/// the *second* source behind the unit-keyed `GetInventoryItem*` family. Unlike
/// [`Self::inventory_slots`] this is PUBLIC descriptor data (`PLAYER_VISIBLE_ITEM_*`), which
/// is the whole reason a foreign player's gear can be read at all. `None` = nothing inspected.
pub(crate) inspect: Option<inspect::InspectView>,
/// Unit tokens queued by `NotifyInspect` — drained by the app into `CMSG_INSPECT`.
pub(crate) inspect_notifies: Vec<String>,
/// `ClearInspectPlayer` was called — drained by the app, which drops its inspect target.
pub(crate) inspect_clear: bool,
/// The inspect model pane's bake yaw, the twin of [`Self::paperdoll_yaw`].
pub(crate) inspect_yaw: f32,
/// Unit token → squared distance from the player, for every popup token the app resolved to a
/// live inspectable player this frame — the input to both verified range predicates
/// (`CanInspect`, `CheckInteractDistance`). Absent token = in range (see
/// [`UiScript::set_inspect_reach`]).
pub(crate) inspect_reach: HashMap<String, f64>,
/// The skills-pane snapshot the app pushes ([`skills::SkillsState`], decision 0437 phase 4) and
/// the synthesized display tree built from it ([`skills::UiScript::set_skills`]) — the skills
/// seam ([`skills`]).
pub(crate) skills: skills::SkillsState,
pub(crate) skills_groups: Vec<skills::SkillGroup>,
/// The categories the player has collapsed (by `category_id`) — survives a content update the
/// same way [`Model::trainer_collapsed`] does.
pub(crate) skills_collapsed: HashSet<u32>,
/// The engine-held selection, by SKILL ID (not visible index — see [`skills`]'s module doc).
pub(crate) skills_selected: Option<u32>,
/// Skill line ids the `AbandonSkill` Lua binding queued since the app's last
/// [`UiScript::take_skill_abandons`] drain — the outbound unlearn seam (the app sends each as
/// a `CMSG_UNLEARN_SKILL`; the engine mutates NOTHING locally — the real client waits for the
/// server's skill-field update, vmangos `SkillHandler.cpp`'s `SetSkill(id, 0, 0)` round trip).
pub(crate) skill_abandons: Vec<u32>,
/// Chat lines the input EditBox submitted (its `OnEnterPressed` → the `SubmitChatInput` Lua
/// binding) since the app's last [`UiScript::take_chat_input`] drain — the outbound Lua→app seam
/// for the chat input (the twin of `loot_picks`). The app routes each through its slash-command
/// parser into a `CMSG_MESSAGECHAT`/`CMSG_TEXT_EMOTE`.
pub(crate) chat_input: Vec<String>,
/// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the
/// engine-owned selection.
pub(crate) worldmap: super::worldmap::WorldMapState,
/// Events (name + args) queued by Lua bindings (`SetMapZoom` → `WORLD_MAP_UPDATE`;
/// `PickupContainerItem`/`ClearCursor`/… → `CURSOR_UPDATE`/`ITEM_LOCK_CHANGED`/
/// `DELETE_ITEM_CONFIRM`, decision 0216 §4/§5) to fire at the next
/// [`UiScript::tick`](super::UiScript::tick) — a binding executes *inside* Lua, so it can't
/// re-enter the handler dispatch synchronously; one tick of deferral is invisible at frame
/// rate (the reference fires these synchronously as pure repaint triggers).
pub(crate) pending_events: Vec<(String, Vec<ScriptValue>)>,
/// The last cursor position [`UiScript::mouse_move`](super::UiScript::mouse_move)/`mouse_button`
/// saw (UI space: logical px, y-up — the same frame `resolve` rects live in). Behind Lua's
/// `GetCursorPosition()`; the reference world map polls it every OnUpdate for hover/click math.
pub(crate) cursor_pos: (f32, f32),
/// The modifier-key mirror `(shift, ctrl, alt)` behind `IsShiftKeyDown`/`IsControlKeyDown`/
/// `IsAltKeyDown` — pushed by the app's input pass ([`UiScript::set_modifiers`]) BEFORE the
/// frame's mouse events, so a click handler's fork reads the state at click time.
pub(crate) modifiers: (bool, bool, bool),
}
impl Model {
pub(crate) fn new() -> Model {
Model {
arena: WidgetArena::new(),
layout_inputs: HashMap::new(),
solver: LayoutSolver::new(),
layout_fingerprint: None,
layout_epoch: 0,
layout_epoch_resolved: None,
layout_solves: 0,
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
chat_tab: false,
region_data: HashMap::new(),
backdrops: HashMap::new(),
font_objects: HashMap::new(),
region_resolved: HashMap::new(),
next_id: 1,
id_to_frame: HashMap::new(),
frame_to_id: HashMap::new(),
id_to_region: HashMap::new(),
region_to_id: HashMap::new(),
region_names: HashMap::new(),
scripts: HashMap::new(),
event_to_frames: HashMap::new(),
frame_events: HashMap::new(),
focused_editbox: None,
mouseover: None,
mouse_down_on: HashMap::new(),
errors: Vec::new(),
warnings: Vec::new(),
// Classic Era's UIParent virtual space is 1024×768-ish; a sensible default the host can
// override with `set_screen_size`. y-up: [bottom, left, top, right].
screen: Rect::new(0.0, 0.0, 768.0, 1024.0),
units: HashMap::new(),
auras: HashMap::new(),
cancel_aura_requests: Vec::new(),
tracking: None,
target_requests: Vec::new(),
target_clear: false,
party: party::PartyState::default(),
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
duel_requests: Vec::new(),
follow_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
sound_queue: Vec::new(),
cvars: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
actions: HashMap::new(),
action_states: HashMap::new(),
bonus_bar_offset: 0,
action_uses: Vec::new(),
action_sets: Vec::new(),
ui_errors: Vec::new(),
spellbook: spellbook::SpellBookState::default(),
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_icons: Vec::new(),
spell_casts: Vec::new(),
casting: false,
spell_stop: false,
spell_targeting: false,
spell_stop_targeting: false,
talents: super::talent::TalentUiState::default(),
talent_learns: Vec::new(),
shapeshift_forms: Vec::new(),
shapeshift_casts: Vec::new(),
pet_bar: super::pet::PetBarState::default(),
pet_actions_pressed: Vec::new(),
pet_autocast_toggles: Vec::new(),
pet_stop_attacks: 0,
containers: HashMap::new(),
container_uses: Vec::new(),
container_cooldowns: HashMap::new(),
has_key: false,
cursor: None,
cursor_grid_shown: false,
world_pick: cursor::WorldPick::default(),
container_moves: Vec::new(),
container_repairs: Vec::new(),
item_pick_armed: false,
item_picks: Vec::new(),
enchant_confirms: Vec::new(),
container_destroys: Vec::new(),
ui_cursor: None,
container_autoequips: Vec::new(),
drag_registered: HashMap::new(),
drag: None,
slider_drag: None,
gossip: None,
gossip_selects: Vec::new(),
gossip_close: false,
gossip_quest_selects: Vec::new(),
merchant: None,
merchant_buys: Vec::new(),
merchant_close: false,
merchant_buybacks: Vec::new(),
repair_all: false,
repair_mode: false,
bank: None,
bank_purchase: false,
bank_close: false,
trainer: None,
trainer_buys: Vec::new(),
trainer_selection: 0,
trainer_close: false,
trainer_filter: [true; 3],
trainer_collapsed: HashSet::new(),
taxi: None,
taxi_takes: Vec::new(),
taxi_close: false,
taxi_riding: false,
trade_skill: None,
trade_skill_dos: Vec::new(),
trade_skill_selection: 0,
trade_skill_close: false,
trade_skill_collapsed: HashSet::new(),
trade_skill_subclass_hidden: HashSet::new(),
trade_skill_invslot_mask: u32::MAX,
trade_skill_last_line: 0,
trade_skill_selected_spell: 0,
trade_skill_touched: false,
craft: None,
craft_dos: Vec::new(),
craft_selection: 0,
craft_close: false,
loot: None,
loot_picks: Vec::new(),
loot_close: false,
loot_rolls: loot_roll::LootRollsState::default(),
loot_roll_votes: Vec::new(),
loot_roll_confirms: Vec::new(),
item_text: None,
item_text_close: false,
item_text_page_turns: Vec::new(),
mail: None,
mail_check_inbox: false,
mail_opens: Vec::new(),
mail_take_items: Vec::new(),
mail_take_money: Vec::new(),
mail_deletes: Vec::new(),
mail_returns: Vec::new(),
mail_take_texts: Vec::new(),
mail_close: false,
mail_send: None,
mail_send_money: 0,
mail_send_cod: 0,
mail_send_item: None,
has_new_mail: false,
trade: None,
trade_initiates: Vec::new(),
trade_accept: false,
trade_unaccept: false,
trade_close: false,
trade_set_money: None,
trade_set_items: Vec::new(),
trade_clear_items: Vec::new(),
quest: None,
quest_selects: Vec::new(),
quest_actions: Vec::new(),
death: death::DeathUiState::default(),
death_actions: Vec::new(),
quest_log: quest_log::QuestLogState::default(),
quest_log_selection: 0,
quest_log_abandon_mark: 0,
quest_log_abandons: Vec::new(),
item_templates: HashMap::new(),
item_sets: HashMap::new(),
item_set_asks: HashSet::new(),
item_stat_asks: HashSet::new(),
player_req: PlayerReqState::default(),
spell_tooltips: HashMap::new(),
spell_tooltip_asks: HashSet::new(),
quest_log_collapses: Vec::new(),
quest_log_watched: Vec::new(),
worldmap: super::worldmap::WorldMapState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
modifiers: (false, false, false),
money: 0,
net_latency_ms: 0,
player_xp: 0,
player_next_level_xp: 0,
combo_points: 0,
combo_target: 0,
player_combat_stats: None,
inventory_slots: Default::default(),
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
inventory_uses: Vec::new(),
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
inspect_reach: HashMap::new(),
chat_input: Vec::new(),
skills: skills::SkillsState::default(),
skills_groups: Vec::new(),
skills_collapsed: HashSet::new(),
skills_selected: None,
skill_abandons: Vec::new(),
}
}
/// Mint (or fetch) the stable id of a frame handle.
pub(crate) fn frame_id(&mut self, h: FrameHandle) -> u32 {
if let Some(&id) = self.frame_to_id.get(&h) {
return id;
}
let id = self.next_id;
self.next_id += 1;
self.frame_to_id.insert(h, id);
self.id_to_frame.insert(id, h);
// A fresh id means a frame just entered the layout graph (`frame_to_id` is the resolve's
// roster) — the mint is the one chokepoint every creation path funnels through (bindings,
// the XML loader, anchor-target resolution), so the tier-1 gate can't miss a birth.
self.touch_layout();
id
}
/// Mint (or fetch) the stable id of a region handle.
pub(crate) fn region_id(&mut self, h: RegionHandle) -> u32 {
if let Some(&id) = self.region_to_id.get(&h) {
return id;
}
let id = self.next_id;
self.next_id += 1;
self.region_to_id.insert(h, id);
self.id_to_region.insert(id, h);
// A fresh region id can seat the region in the resolve's external set — same birth
// chokepoint as `frame_id`'s.
self.touch_layout();
id
}
/// A write that can move [`UiScript::resolve_layout`]'s read set happened: anchors, sizes,
/// measured text, scale, clamp, scroll state, frame/region births, or the screen rect. Every
/// mutating BINDING (and app-facing setter) on that set calls this — tier 1 of the resolve
/// change gate. The resolve's own pre-pass (`tooltip::layout_tooltips`) deliberately does NOT:
/// its writes are derived from state that already arrived through touched paths, and a
/// self-touch would pin the gate open forever. A missed site is a silently stale layout —
/// exactly the failure `WOW_LAYOUT_VERIFY` (on for every benilla-ui test) exists to catch
/// loudly, by proving the fingerprint still matches whenever tier 1 claims quiet.
pub(crate) fn touch_layout(&mut self) {
self.layout_epoch = self.layout_epoch.wrapping_add(1);
}
}