Files
benilla-octo/crates/benilla-ui/src/script/model.rs
T
SamandClaude Fable 5 023235a086 the addon survey seats a populated world, and hidden frames arrive
- the corpus harness now seats a buff, a target and a ticking cooldown instead of an empty world, so "nothing to draw" and "failed to draw" stop sharing a row — and the richer seat immediately exposed two real gaps
- an addon button inheriting our action-button template now works even when it replaces the template's OnLoad: the handlers resolve their regions by name, the way the reference does on every access
- more of the reference's hidden-by-default frames exist by name — ReputationWatchBar, MiniMapBattlefieldFrame, MiniMapMeetingStoneFrame — for the addons that reposition them
- StartSizing lands beside its stop verb, and the sourced bag-anchor code runs against windows we actually build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:29:44 +07:00

1225 lines
77 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, weapon_enchant, 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 {
/// Every discovered addon, in load order — the AddOn API's registry, filled by the host at
/// world entry ([`super::UiScript::register_addons`]). See [`super::addon`].
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
pub(crate) addons_saved_character: Option<std::path::PathBuf>,
/// The FrameXML **template registry**, persisted across [`crate::loader::load`] calls — the
/// client's template table is global (`0x6ee500`, rf24), so a file may `inherits=` a template
/// an *earlier file* registered (the real MerchantFrame.xml inherits
/// CharacterFrameTemplates.xml's tab template). Register-before-use in load order, exactly
/// the client's rule; a per-document registry silently dropped every cross-file inherit.
///
/// **Here rather than on `UiScript`** so the loader can run from a bare `&Lua`: that is what
/// lets `LoadAddOn` load an addon from inside a Lua binding, synchronously, the way the
/// reference does (1188 phase 2). `font_objects` below was already here, and these two are
/// the same kind of thing — VM-global registries the loader fills.
pub(crate) framexml_templates:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// The FrameXML **font-element registry** (a separate namespace — a font inherits a font,
/// never a frame template), persisted for the same cross-file reason.
pub(crate) framexml_fonts:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// 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>,
/// Per **frame**, the [`UiScript::now`] second at which its last single `OnClick` fired — the
/// double-click detector's entire state, and the faithful stand-in for the client's per-widget
/// timestamp `[CButton+0x334]` (wow-re `ui/scratch/button-doubleclick-law.md`).
///
/// Three properties of that field are load-bearing, and are why this is keyed the way it is.
/// It lives on the **widget**, so two frames can never pair with each other. It carries **no
/// button identity** — the detector is button-agnostic, so a left release followed by a right
/// one on a button registered for both completes a double click (with `arg1` = the *second*
/// button); what normally confines double-clicks to the left button is `RegisterForClicks`'
/// `{"LeftButtonUp"}` default, not the detector. And it is written only when a single click
/// actually fires, then **zeroed** when a double completes — which is what makes four rapid
/// clicks read `Click · DoubleClick · Click · DoubleClick` rather than one click and three
/// doubles. Nothing else clears it: not hide, not disable, not the cursor leaving the frame
/// (`[+0x334]` has exactly three writers binary-wide — ctor, the fired-double zero, and the
/// fired-single stamp).
pub(crate) last_click: HashMap<FrameHandle, f64>,
/// Frames whose resolved SIZE moved in the last [`UiScript::resolve_layout`], as
/// `(frame id, width, height)` — queued there (it holds only a `&mut Model`, so it cannot call
/// Lua) and drained by [`super::event::fire_size_changes`] at the next `&Lua` seam, which turns
/// each into `OnSizeChanged(self, width, height)`. Same compute-under-borrow / fire-outside
/// shape as the pointer path's `OnValueChanged`/`OnDragStart` hand-offs.
pub(crate) pending_size_changed: Vec<(u32, f32, f32)>,
/// 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>,
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
/// resolver (`crate::target::by_name`, decision 0886) and commits the selection ([`unit`]).
pub(crate) target_by_name_requests: Vec<(String, bool)>,
/// 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,
/// Unit tokens `DropItemOnUnit` queued since the app's last
/// [`UiScript::take_drop_item_on_unit`] drain — the cursor's held item being dropped onto a
/// unit (`0x48d960`). The binding queues the token only; **every gate is the app's**, because
/// all of them read state the VM does not hold (the pet's owner fields, the learned Feed-Pet
/// spell, the held item's guid). A token the app refuses is silent and keeps the payload,
/// which is the reference's own behaviour ([`super::cursor`]).
pub(crate) drop_item_on_unit: Vec<String>,
/// 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.
/// The channels this session has CONFIRMED joining, in join order — the client-side number
/// law `GetChannelName` answers with ([`super::channel`]). Mirrored from `ui_chat`'s
/// `ChannelState` by `set_joined_channels`; empty until the server's first YOU_JOINED.
pub(crate) joined_channels: Vec<String>,
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>,
/// Prefill texts `ChatFrame_OpenChat` queued since the app's last
/// [`super::UiScript::take_open_chat_requests`] drain — `tell_requests`' sibling, one step
/// less resolved: a name there, a whole draft line here ([`chat_window`] registers the global).
pub(crate) open_chat_requests: Vec<String>,
/// The player's default chat language name, app-resolved from `ChrRaces.BaseLanguage` ×
/// `Languages.dbc` ([`super::UiScript::set_default_language`]). `None` = the reference's
/// no-player-object state, where `GetDefaultLanguage()` returns **zero Lua values**
/// ([`chat_send`]).
pub(crate) default_language: Option<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 globals `RegisterForSave` declared, in registration order — the saved-variables set the
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
pub(crate) saved_names: Vec<String>,
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
/// [`super::UiScript::keybinds_generation`] moves and persists on the queued save requests.
pub(crate) keybinds: super::keybind::KeybindState,
/// 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 **pet's** book (decision 1032) — a second flat slot list, no tabs, with its own
/// add-gate and its own class token ([`spellbook::PetBookState`]). Held apart from
/// [`Self::spellbook`] because the reference holds two arrays too (`0xb700f0` / `0xb6f098`)
/// and every `bookType`-taking binding is a fork between them, never a filter over one.
pub(crate) pet_book: spellbook::PetBookState,
/// 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-config/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>,
/// Pet spell ids `CastSpell(id, "pet")` queued — a separate list because the wire verb is a
/// separate opcode (`CMSG_PET_ACTION` with a synthesized type-1 word, `0x4b34ce`).
pub(crate) pet_spell_casts: Vec<u32>,
/// Pet spell ids `ToggleSpellAutocast` queued — `CMSG_PET_SPELL_AUTOCAST 0x2F3`, which names a
/// spell rather than the pet bar's slot ([`Self::pet_autocast_toggles`] is the bar's).
pub(crate) pet_spell_autocasts: 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,
/// Whether the standing targeting word could bind a UNIT — what `SpellCanTargetUnit` answers
/// (`0x6e6d00` → `0x6e6460`'s unit leg). Pushed by the app beside `spell_targeting`.
///
/// Always `false` today, and *derived* rather than hardcoded so it stops being false the moment
/// that stops being true: benilla's targeting cursor models the location / item / gameobject
/// words (0792/0923/0939), and no unit satisfies any of them — a unit-target spell never enters
/// targeting mode at all, it resolves to `CastWireTarget::Unit` or refuses.
pub(crate) spell_can_target_unit: 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,
/// Pet bar writes queued by the drag ([`cursor::pet`], decision 1010) — **one entry per
/// `CMSG_PET_SET_ACTION`**, each holding the one or two `(0-based position, packed word)` pairs
/// that send names. The nesting is the point: the server tells the one-pair form from the
/// two-pair form **by body size**, so a relocation and its write must travel together and must
/// not be flattened into a stream of singles.
pub(crate) pet_set_actions: Vec<Vec<(u32, u32)>>,
/// `PetAbandon()` and `PetDismiss()` calls queued — two counts, not one, even though both menu
/// rows end at the same opcode (decision 1066). They are two *bindings*, with two Lua names and
/// two menu rows the reference shows to different classes, so the seam keeps them apart and
/// lets the app decide each one's wire; folding them together here would bake a wire fact into
/// an engine that is supposed to hold none.
pub(crate) pet_abandons: u32,
pub(crate) pet_dismisses: u32,
/// Names `PetRename(name)` queued, in order — the `PETRENAMECONFIRM` popup's payload.
pub(crate) pet_renames: Vec<String>,
/// 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.
///
/// The **pet** payload arm is excluded (decision 1010): `PlaceAction` refuses it, so lighting
/// the action bar's empty slots for a payload that cannot land there would be an invitation to
/// a no-op. It drives [`Self::pet_grid_shown`] instead.
pub(crate) cursor_grid_shown: bool,
/// The same mirror for the PET bar's grid — `PET_BAR_SHOWGRID`/`PET_BAR_HIDEGRID`, which the
/// reference fires from inside the pet-action pickup builder itself (`0x494f28`) rather than
/// from a shared cursor transition. So the two grids are disjoint: a spell lights the action
/// bar's, a pet action lights the pet bar's, and neither lights both.
pub(crate) pet_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>,
/// Set by every FrameXML cursor call — `Show*SellCursor` / `ShowInspectCursor` / `SetCursor` /
/// `ResetCursor` — and drained by the app each frame ([`UiScript::take_cursor_write`]).
///
/// **The cursor mode is a WRITE, not a level** (decision 1061), and that distinction is the
/// whole of B208's regression. The reference keeps one sticky global (`0xbe2c2c`): the world
/// classifier writes it while the pointer is over the world, FrameXML writes it from a hover
/// handler, and in between **nothing** writes it — so the last value simply stands. Reading
/// `ui_cursor` as a level made "no override" mean "show the base", which turned every UI
/// element with no cursor handler at all (a spellbook button, a panel) into a forced Point and
/// killed the armed cast cursor the moment the mouse left the world.
pub(crate) ui_cursor_dirty: bool,
/// `(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 one in-flight `StartMoving()` — the client's single root-side drag slot (`root+0xcfc`
/// and the cursor sample beside it), `None` between moves ([`super::object::FrameMove`]). Held
/// beside [`Model::drag`] because that gesture is what normally opens and closes it: the
/// canonical addon idiom is `OnDragStart → StartMoving`, `OnDragStop → StopMovingOrSizing`.
/// The two remain separate systems, exactly as in the reference — a drag can carry a payload
/// with nothing moving, and a move outlives the mouse button (the reference's mouse-up
/// auto-stop skips the Lua drag type).
pub(crate) moving: Option<super::object::FrameMove>,
/// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same
/// `StopMovingOrSizing` (`0x776990`).
pub(crate) sizing: Option<super::object::FrameSizing>,
/// 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 server's wall clock in unix-epoch seconds, pushed by the app each frame it has one
/// ([`UiScript::set_server_unix_time`]); `None` before the first `SMSG_QUERY_TIME_RESPONSE`.
///
/// Held here rather than folded into the quest-log snapshot because the *engine* owns every
/// countdown the Lua reads: a timed quest's deadline is an absolute stamp in this epoch, and
/// `GetQuestTimers` subtracts against it **per call**, exactly as the reference's C binding
/// does. That is what lets the reference `QuestTimerFrame` tick smoothly from its OnUpdate
/// while the log snapshot itself only changes when the log does (decision 1150).
pub(crate) server_unix_time: Option<f64>,
/// 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 player's rest snapshot, pushed together each frame any part moves
/// ([`UiScript::set_rest_state`]) — player-globals like `money`. `rest_state` is the raw
/// `PLAYER_BYTES_2` byte 3 (1 = rested, 2 = normal — the server writes it with hysteresis
/// off the pool). **Defaults to 2, not 0**: every live 1.12 descriptor carries 2 from
/// character creation on, and the tick's faithful FrameXML compares `GetRestState() >= 3`
/// unguarded — a pre-feed 0 would render the binary's nil-triple fail path into an event
/// handler the real client can only ever run with the byte present. Byte 0 stays reachable
/// by explicit push, and the fail path stays faithful there. `rest_pool` is the raw
/// `PLAYER_REST_STATE_EXPERIENCE` value in **base kill-XP units**, `resting` the
/// `PLAYER_FLAGS_RESTING (0x20)` bit (inside an inn/city). `GetRestState`/`GetXPExhaustion`/
/// `IsResting` read these; the pool's display scaling is `exhaustion` row 1's factor,
/// applied in the `GetXPExhaustion` binding, where the real client applies it (decisions
/// 1082/1087).
pub(crate) rest_state: u8,
pub(crate) rest_pool: u32,
pub(crate) resting: bool,
/// Exhaustion.dbc as the rest bindings consume it — rest-state byte → (localized name,
/// factor), the table `GetRestState` indexes directly and whose row 1 scales
/// `GetXPExhaustion` (wow-re rested-xp-bindings.md; decision 1087). Seeded with the shipped
/// 5875 enUS rows so the engine tests and a failed DBC read behave like the shipped client
/// (the GlobalStrings-fallback posture); the app overwrites it with the install's real —
/// localized — rows at startup ([`UiScript::set_exhaustion_rows`]).
pub(crate) exhaustion: HashMap<u8, (String, f64)>,
/// 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).
pub(crate) player_combat_stats: Option<char_stats::UnitCombatStats>,
/// The **pet's** combat-stats snapshot (decision 1057) — the same shape under the `"pet"`
/// token, because the reference's own pet sheet calls the very same `UnitStat`/`UnitResistance`/
/// `PaperDollFrame_Set*(unit, prefix)` family with `unit = "pet"` (ref
/// `PetPaperDollFrame.lua:73-81`). A second slot rather than a token map: exactly two units
/// ever have one, they are fed by two different systems on two different clocks, and every
/// binding's route is a fork between them (the [`Self::pet_book`] precedent).
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
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 two weapons' **temporary** enchantments — `[0]` main hand, `[1]` off hand, in the order
/// `GetWeaponEnchantInfo` pushes them ([`weapon_enchant`]). A separate slot from
/// [`Self::inventory_slots`] on purpose: this one carries a live countdown and is pushed every
/// frame, where the slot snapshot is change-gated and fires an event.
pub(crate) weapon_enchants: [Option<weapon_enchant::WeaponEnchant>; 2],
/// 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,
/// The **pet** paper doll's model-pane bake yaw (decision 1057) — a third scalar for the same
/// reason the inspect pane got a second: character tab 1 and tab 2 are two panes that can sit
/// at two different facings, and the ref carries a `rotation` per `<PlayerModel>`.
pub(crate) pet_paperdoll_yaw: f32,
/// The dressing room's queued intents (decision 1060) — `BenillaDressUpModel_Dress/TryOn/Close`,
/// drained by the app in order (see [`super::dressup`] on why order matters).
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
/// The dressing-room pane's bake yaw, the fourth of those scalars.
pub(crate) dressup_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 realm this session is on, behind `GetRealmName()` (decision 1195). `""` until the app
/// pushes one — the glue screen's own answer, and never `nil`, because the corpus idiom is
/// `db[GetRealmName()] = …` at file scope and a nil index errors one call deeper.
/// Lines an addon queued with `SendChatMessage`, drained by the app into the wire
/// (decision 1199). Deliberately a different queue from the chat box's input: the box's drain
/// runs the slash grammar and this one must not.
pub(crate) chat_sends: Vec<super::chat_send::ChatSend>,
pub(crate) realm_name: String,
/// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the
/// clock this crate does not have.
pub(crate) framerate: f64,
/// 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 {
addons: Vec::new(),
addons_root: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),
framexml_fonts: Default::default(),
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(),
last_click: HashMap::new(),
pending_size_changed: Vec::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_by_name_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
joined_channels: Vec::new(),
party: party::PartyState::default(),
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
default_language: None,
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(),
saved_names: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
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(),
pet_book: spellbook::PetBookState::default(),
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_icons: Vec::new(),
spell_casts: Vec::new(),
pet_spell_casts: Vec::new(),
pet_spell_autocasts: Vec::new(),
casting: false,
spell_stop: false,
spell_targeting: false,
spell_can_target_unit: 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,
pet_set_actions: Vec::new(),
pet_abandons: 0,
pet_dismisses: 0,
pet_renames: Vec::new(),
containers: HashMap::new(),
container_uses: Vec::new(),
container_cooldowns: HashMap::new(),
has_key: false,
cursor: None,
cursor_grid_shown: false,
pet_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,
ui_cursor_dirty: false,
container_autoequips: Vec::new(),
drag_registered: HashMap::new(),
drag: None,
moving: None,
sizing: 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(),
server_unix_time: None,
worldmap: super::worldmap::WorldMapState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
chat_sends: Vec::new(),
realm_name: String::new(),
framerate: 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,
rest_state: 2,
rest_pool: 0,
resting: false,
exhaustion: [
(1, ("Rested".to_string(), 2.0)),
(2, ("Normal".to_string(), 1.0)),
(3, ("XXXTired".to_string(), 1.0)),
(4, ("XXXTired".to_string(), 0.5)),
(5, ("XXXExhausted".to_string(), 0.25)),
]
.into_iter()
.collect(),
player_combat_stats: None,
pet_combat_stats: None,
inventory_slots: Default::default(),
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
inventory_uses: Vec::new(),
weapon_enchants: [None; 2],
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
pet_paperdoll_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_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);
}
}