Files
benilla-octo/crates/benilla-ui/src/script/model.rs
T
SamandClaude Fable 5 2381df5a58 the layout graph is maintained, and the shine draws for real
- the interface's layout graph is maintained as anchors change instead of rediscovered from scratch: 1.48 ms per mutated frame becomes 34 µs, and end to end a running cast now costs +0.14 ms where it cost +4.33
- the autocast shine draws its authored population — 1200 particles through the star's own projection, spinning as the model says
- the shine is clipped to its own widget: the model frame installs its rect as the viewport, so nothing it draws leaves the button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:01:46 +07:00

1596 lines
100 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, guild,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log,
reputation, session, simplehtml, 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 host's texture-path resolvability oracle — see [`Model::texture_probe`].
pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// 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>,
/// The host's font engine ([`super::UiScript::set_text_measurer`]) — what lets a metric read
/// answer inside the Lua call that asked, instead of a frame later. `None` in every VM without
/// a font atlas, which is the measure round-trip's own world and behaves exactly as it did.
pub(crate) measurer: Option<Box<dyn super::TextMeasure>>,
/// The host's texture-path oracle ([`super::UiScript::set_texture_probe`]): does this sprite
/// reference resolve to a file — patch chain or loose addon folder? What lets the path form of
/// `SetTexture` return the reference's **1 | nil** load verdict inline (wow-re
/// `widget-api-batch-benilla.md` Q1 — Atlas branches on it to pick its map art). `None` in an
/// engine-less VM (tests, the addon harness), where the path form keeps answering nil: no
/// backend, nothing loads — which is also exactly what those tests always saw.
pub(crate) texture_probe: Option<TextureProbe>,
/// 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,
/// [`Self::layout_epoch`]'s **precise** form: the nodes a write actually NAMED since the last
/// converged resolve — tier 1 promoted from "something moved" to "*these* moved" (decision
/// 1388).
///
/// `Some(nodes)` carries a second, stronger claim than its contents: **every** write since the
/// cached graph was built named its node *and* left the graph's SHAPE alone, so
/// [`super::layout::LayoutScope`]'s roster, edges and per-node hashes still describe the live
/// model and a resolve may seed the dirty closure straight from this list. `None` is the
/// conservative state — a write that could not name its node, or one that moved the roster or
/// an anchor's TARGET — and forces the next resolve to derive the graph from scratch, which is
/// exactly what every resolve did before 1388.
///
/// That asymmetry is the safety argument. A site that keeps calling plain
/// [`Model::touch_layout`] is *correct by construction* (it lands in `None`, i.e. today's
/// behaviour); only a site that opts into [`Model::touch_layout_region`] /
/// [`Model::touch_layout_frame`] can be wrong, and `WOW_LAYOUT_VERIFY` re-resolves every
/// incremental pass in full scope and compares rects, so being wrong fails a test rather than
/// shipping a stale one.
/// The list holds **layout ids**, not handles: the touch sites below already have to prove the
/// node is in the cached roster before they may name it, and that proof is a `node_of[id]`
/// probe — which hands back the roster row, and with it the handle, for free at resolve time.
pub(crate) layout_touched: Option<Vec<u32>>,
/// `WOW_LAYOUT_VERIFY`'s flag that the resolve just taken was the incremental one and owes a
/// full-derive re-run to prove it (see `layout::UiScript::resolve_layout`). Always `false` in
/// production — nothing reads it unless the verify build set it.
pub(crate) layout_verify_recheck: bool,
/// Per-node input hashes + the dirty-closure scratch — what makes a resolve cost the nodes
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
pub(crate) layout_scope: super::layout::LayoutScope,
/// The [`Self::layout_epoch`] value the last CONVERGED resolve closed on. `None` forces the
/// next resolve through tier 1: the initial state, a cycle-bailed pass, and
/// [`super::UiScript::force_full_layout_resolve`].
///
/// **Every converged resolve closes tier 1** (decision 1385) — a real solve as much as a
/// skipping one. It could not, while the fingerprint was hashed over the 0294 seeds as well
/// as the inputs: a solve outgrew the value it had just stored, so a mutated frame paid three
/// whole-roster walks (solve, settle, skip) instead of one. Hashing inputs alone makes the
/// stored fingerprint exactly the one the next mutation-free resolve recomputes, so a
/// per-frame layout write — the castbar's spark, any addon that animates a region — costs one.
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 times a resolve got **past tier 1** — the count of whole-roster preamble WALKS
/// (decision 1385). This, not [`Self::layout_solves`], is the honest cost counter for the
/// gate: a walk that ends in `gate_skips` still rebuilt the ids/plan, re-synced every frame's
/// scale and re-hashed all ~10k anchored regions to conclude "nothing moved" — ~1.0 ms at the
/// Stormwind pin — and `layout_solves` cannot see it, because it counts only the walks that
/// went on to solve.
///
/// The castbar pin is why it exists: one moving spark cost **three** walks per frame and only
/// two of them were solves, so the counter the tests asserted on under-reported the bug by a
/// third. `resolve_bench::a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui`
/// is the guard that reads it.
///
/// **Unlike [`Self::layout_solves`], this is NOT verify-independent.** Under
/// `layout_verify_enabled` a tier-1-clean resolve falls through and walks anyway, so a verify
/// build counts walks production never pays — which is the honest reading (the walk happened)
/// but makes the number useless as an assertion there. Assert on it only from a crate that
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
/// production's; inside this crate's own tests, assert on `layout_solves`.
pub(crate) layout_gate_walks: u64,
/// How many times a resolve **derived the layout graph** — the whole-roster walk that rebuilds
/// the frame/region roster, the reverse edges and every per-node hash (decision 1388).
///
/// This is the cost counter [`Self::layout_gate_walks`] used to be. 1385 got a moving castbar
/// spark down from three walks per frame to one; 1388 made that one walk *cheap* by seeding the
/// dirty closure from a ledger of named nodes instead of rediscovering it, so "walks" no longer
/// separates the 1.48 ms case from the 20 µs one and this does. A UI that animates anything
/// should hold this at **zero** frame after frame — a non-zero steady-state reading means some
/// write site is falling back to the conservative `touch_layout`, and that is the regression.
///
/// Verify-independent in the direction that matters: the `WOW_LAYOUT_VERIFY` re-run's
/// derivation is counted, but `resolve_layout` restores the meter afterwards, so a test reads
/// the same number in both modes.
pub(crate) layout_derives: u64,
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
/// axis that used to be "the whole UI" and is the one a growing UI silently inflates.
pub(crate) layout_last_scope: (usize, usize),
/// 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>,
/// Per-`SimpleHTML` widget state (decision-free transcription of `CSimpleHTML`'s own members):
/// the four element fonts `+0x350`, the hyperlink format `+0x360`, and the CONTENTNODE list
/// `+0x340` of blocks the last `SetText` built. Stored beside the arena, like `backdrops`,
/// because its contents are script-layer types and `widget::kinds` is the layer below.
pub(crate) simple_html: simplehtml::SimpleHtmlStates,
/// 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).
/// The named `<Font>` objects, **keyed by the ASCII-LOWERCASED name**.
///
/// 1.12's font registry hashes the name and compares keys with `SStrCmpI` — case-INSENSITIVE
/// (`0x783870`/`0x7838c7`, wow-re `system/ui/scratch/font-object-lua-surface.md`: *"Font names
/// are matched case-insensitively"*), and a name string handed to `SetFontObject` folds the
/// same way. `Recap/RecapOptions.xml:32` inherits `GameFontHighLightSmall` — the shipped font
/// is `GameFontHighlightSmall`, one letter's case apart — and on the real client that resolves.
///
/// Read it through [`Model::font_object`], never directly: that is where the fold lives, and
/// the name says so because nothing else guards the invariant (1247's shape).
pub(crate) font_objects_by_lower: 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>,
/// Script errors awaiting dispatch to the **Lua-side** error handler (decision 1305) — the
/// reference calls `geterrorhandler()`'s function on every caught script error, which is how
/// FrameXML's `_ERRORMESSAGE` puts the red ScriptErrors dialog on the player's screen. Every
/// message recorded through [`Model::record_script_error`] lands here as well as in `errors`;
/// [`super::UiScript::dispatch_script_errors_to_handler`] drains it at a safe seam (never from
/// inside the failed call). A message produced *by* the handler itself goes to `errors` only —
/// that asymmetry is the recursion guard.
pub(crate) pending_error_dispatch: 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).
///
/// **Keyed by the ASCII-LOWERCASED token, and the name says so because the invariant has no
/// other guard.** 1.12's shared token resolver `0x515970` compares every literal with
/// `SStrCmpI` → `_strnicmp`, which folds `'A'..'Z'` by `+0x20`; not one of its ten compares
/// reaches the case-sensitive sibling. So `"Player"`, `"PLAYER"` and `"player"` are the same
/// unit on the real client, and ~10 corpus addons rely on it — `Accountant.lua:107`'s
/// `UnitFactionGroup("Player")` is the one that cost an addon its session.
///
/// Read it through [`Model::unit`], never directly: that is where the fold lives. The field was
/// called `units` until the fold landed, and it was renamed precisely so that every existing
/// reader had to come through here rather than be trusted to remember (wow-re
/// `system/ui/scratch/unit-token-grammar.md`).
pub(crate) units_by_lower: 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<Option<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>,
/// The guild snapshot the app pushes (roster, ranks, MOTD, info text — decision 1257):
/// `GetNumGuildMembers`/`GetGuildRosterInfo`/`GuildControlGetRankFlags`/… read it
/// ([`guild`]). Already display-resolved and already sorted + filtered, because the sort
/// field and the show-offline toggle live app-side where the roster does.
pub(crate) guild: guild::GuildState,
/// The rank-control popup's staging buffer ([`guild::GuildRankEdit`]) — deliberately NOT part
/// of [`Self::guild`], because a snapshot push mid-edit would discard the user's unsaved
/// checkbox clicks. Flushed by `GuildControlSaveRank`.
pub(crate) guild_control: guild::GuildRankEdit,
/// Guild intents (`GuildInviteByName`/`GuildSetMOTD`/`GuildControlSaveRank`/…) queued since
/// the app's last [`super::UiScript::take_guild_requests`] drain — the outbound seam
/// ([`guild`]).
pub(crate) guild_requests: Vec<guild::GuildRequest>,
/// 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>,
/// The persisted values registration honors (decision 1291): lowercase name → the config
/// file's value, set by the host **before** any registration. A CVar registered while its
/// name is in here — host-registered or an addon's `RegisterCVar` — starts at the saved
/// value, not the default. This is what makes a knobless CVar (`statusBarText`) and an
/// addon-declared one survive the VM being replaced: in the reference the table is engine
/// memory and outlives every `ReloadUI`; ours is per-VM, so the file value is the bridge.
pub(crate) cvars_saved_base: HashMap<String, String>,
/// `(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>,
/// The reputation-pane snapshot the app pushes ([`reputation::ReputationState`]) and the
/// synthesized display tree built from it ([`reputation::UiScript::set_reputation`]) — the
/// reputation seam ([`reputation`]).
pub(crate) reputation: reputation::ReputationState,
pub(crate) reputation_groups: Vec<reputation::FactionGroup>,
/// The header groups the player has folded, by HEADER KEY (a `Faction.dbc` id, or the
/// synthetic `0` "Other" / `-1` "Inactive") — an identity a re-push cannot move, unlike a row
/// index. Reset to all-expanded-but-Inactive on every push, exactly as
/// [`Model::skills_collapsed`] is: the client's own rebuild does the same.
pub(crate) reputation_collapsed: HashSet<i64>,
/// The engine-held selection, by REPUTATION-LIST SLOT (not visible index — see
/// [`reputation`]'s module doc).
pub(crate) reputation_selected: Option<u32>,
/// Reputation verbs the pane's bindings queued since the app's last
/// [`UiScript::take_reputation_sends`] drain — the outbound seam. Unlike the skills abandon
/// above, the engine DOES mutate locally first: none of the three sends is acked.
pub(crate) reputation_sends: Vec<reputation::ReputationSend>,
/// 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>,
/// Broadcasts an addon queued with `SendAddonMessage`, drained by the app into the wire
/// (decision 1235). Its own queue rather than [`Self::chat_sends`] because it is a different
/// wire: `LANG_ADDON` in the language field, a four-value distribution set, and a payload the
/// binding already composed as `prefix` TAB `message`.
pub(crate) addon_sends: Vec<super::addon_message::AddonSend>,
/// `RequestTimePlayed()` asks queued since the app last drained them — each is one
/// `CMSG_PLAYED_TIME`. A COUNT, not a payload, for [`super::pvp`]'s reason: the packet is
/// empty, so two asks in a frame are two sends rather than one collapsed intent.
pub(crate) played_time_asks: u32,
pub(crate) realm_name: String,
/// The hearthstone bind location's NAME, behind `GetBindLocation()` — the app resolves the
/// `SMSG_BINDPOINTUPDATE` area id through the same AreaTable catalog the hearthstone's `$z`
/// token already uses, and pushes the resolved string here.
///
/// Empty only before that packet has landed — `""`, never nil, matching [`Self::realm_name`]
/// beside it: a consumer concatenates this (`Necrosis.lua:1089`), so nil would be a raise.
pub(crate) bind_location: String,
/// `ConfirmBinder()` calls queued since the app last drained them — each is one
/// `CMSG_BINDER_ACTIVATE`. A COUNT for [`Self::played_time_asks`]'s reason: the intent carries
/// no payload (the app holds the innkeeper's guid), so two calls are two sends.
pub(crate) binder_confirms: u32,
/// Is an innkeeper's bind question still live and in range — the answer `CheckBinderDist()`
/// gives, pushed by the app each frame ([`super::UiScript::set_binder_pending`]). The
/// CONFIRM_BINDER dialog polls it from OnUpdate and hides itself when it goes false, which is
/// how walking away from the innkeeper takes the question off screen (decision 1331).
pub(crate) binder_pending: bool,
/// 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 {
/// A unit token's snapshot, **case-folded the way the client folds it**.
///
/// 1.12's resolver `0x515970` compares each of its literals with `SStrCmpI` → `_strnicmp`,
/// whose fold is `'A'..'Z' += 0x20` and nothing else: the `jb`/`ja` bounds are unsigned, so a
/// byte ≥ 0x80 is never folded. `to_ascii_lowercase` is exactly that rule, and the reason this
/// does not use `to_lowercase()` — a locale-aware fold would map bytes the client leaves alone.
///
/// The uppercase scan is a fast path, not an optimisation for its own sake: every internal
/// caller passes a lowercase literal (`"player"`, `"target"`), so the common case allocates
/// nothing and only an addon's `"Player"` pays for a `String`.
/// A named font object, **case-folded the way the client folds it** — `SStrCmpI` over the font
/// registry's keys. ASCII only, and the uppercase scan is a fast path: every internal caller
/// passes an exact shipped name, so only an addon's odd spelling pays for a `String`.
pub(crate) fn font_object(&self, name: &str) -> Option<&FontObject> {
if name.bytes().any(|b| b.is_ascii_uppercase()) {
self.font_objects_by_lower.get(&name.to_ascii_lowercase())
} else {
self.font_objects_by_lower.get(name)
}
}
/// Record one script error on **both** channels: the host's `errors` vec (the instruments'
/// channel — the harness blocker tables, the app's log drain) and the handler-dispatch queue
/// (the player's channel — decision 1305). Every engine-caught script error goes through here;
/// a failure raised by the error handler *itself* is pushed to `errors` directly instead,
/// which is what keeps the dispatch from recursing.
pub(crate) fn record_script_error(&mut self, msg: String) {
self.pending_error_dispatch.push(msg.clone());
self.errors.push(msg);
}
pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> {
if token.bytes().any(|b| b.is_ascii_uppercase()) {
self.units_by_lower.get(&token.to_ascii_lowercase())
} else {
self.units_by_lower.get(token)
}
}
pub(crate) fn new() -> Model {
Model {
addons: Vec::new(),
addons_root: None,
measurer: None,
texture_probe: 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_touched: None,
layout_verify_recheck: false,
layout_derives: 0,
layout_scope: super::layout::LayoutScope::default(),
layout_last_scope: (0, 0),
layout_epoch_resolved: None,
layout_solves: 0,
layout_gate_walks: 0,
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
chat_tab: false,
region_data: HashMap::new(),
backdrops: HashMap::new(),
simple_html: simplehtml::SimpleHtmlStates::new(),
font_objects_by_lower: 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(),
pending_error_dispatch: 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_by_lower: 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(),
guild: guild::GuildState::default(),
guild_control: guild::GuildRankEdit::default(),
guild_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(),
cvars_saved_base: 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(),
addon_sends: Vec::new(),
played_time_asks: 0,
realm_name: String::new(),
bind_location: String::new(),
binder_confirms: 0,
binder_pending: false,
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(),
reputation: reputation::ReputationState::default(),
reputation_groups: Vec::new(),
reputation_collapsed: HashSet::new(),
reputation_selected: None,
reputation_sends: 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) {
// The conservative half of the tier-1 ledger (decision 1388): this write did not name a
// node, so the cached graph can no longer be trusted to describe the live model and the
// next resolve must derive it in full. Every site that has NOT been migrated to a precise
// touch lands here, which is why migration can be incremental and a missed one is slow
// rather than wrong.
self.layout_touched = None;
self.bump_layout_epoch();
}
/// A write that moved **one region's** layout inputs and changed nothing else about the graph:
/// its anchor OFFSETS, its explicit size, its measured extent. Not its anchor targets, not its
/// membership, not its liveness — those move edges or the roster, and belong on
/// [`Self::touch_layout`].
///
/// Falls back to the conservative touch whenever it cannot prove the region is a node of the
/// **cached** graph: no minted id, an id past the roster's arrays, or an id the last derive
/// left unmapped (an anchor-less region, a region born since). That fallback is what makes the
/// call safe to reach for — the worst a mistaken one can do is derive the graph again.
pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) {
match self.region_to_id.get(&rh) {
Some(&id) => self.touch_layout_node(id),
None => self.touch_layout(),
}
}
/// [`Self::touch_layout_region`]'s frame twin — an anchor offset, a width/height, and nothing
/// that moves an edge or the roster.
pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) {
match self.frame_to_id.get(&h) {
Some(&id) => self.touch_layout_node(id),
None => self.touch_layout(),
}
}
/// Name a node **without opening tier 1** — for the resolve's own pre-pass
/// (`tooltip::layout_tooltips`), which writes layout inputs derived from state that already
/// arrived through touched paths.
///
/// It deliberately does not call [`Self::touch_layout`]: bumping the epoch from inside a
/// resolve would pin the gate open forever (the pre-pass runs on every let-through resolve, so
/// it would re-dirty the very resolve it is running in). But the ledger still has to hear about
/// it, or an incremental pass would inherit a node whose hash the pre-pass moved and no write
/// site named — the one shape that could ship a stale rect. Naming it is free: the hash it
/// recomputes is unchanged on the frames the pre-pass writes idempotently, which is nearly all
/// of them, so no dirty seed comes of it.
pub(crate) fn note_layout_frame_write(&mut self, h: FrameHandle) {
if self.layout_touched.is_some() {
match self.frame_to_id.get(&h) {
Some(&id) if self.layout_scope.has_node(id) => {
self.layout_touched
.as_mut()
.expect("checked above")
.push(id);
}
_ => self.layout_touched = None,
}
}
}
/// [`Self::note_layout_frame_write`]'s region twin.
pub(crate) fn note_layout_region_write(&mut self, rh: RegionHandle) {
if self.layout_touched.is_some() {
match self.region_to_id.get(&rh) {
Some(&id) if self.layout_scope.has_node(id) => {
self.layout_touched
.as_mut()
.expect("checked above")
.push(id);
}
_ => self.layout_touched = None,
}
}
}
/// Name `id` as the only thing this write moved — if the cached graph has a node for it and no
/// earlier write in this frame already gave up on naming.
fn touch_layout_node(&mut self, id: u32) {
let in_graph = self.layout_scope.has_node(id);
match &mut self.layout_touched {
// Already conservative: a precise touch cannot un-say an imprecise one.
None => {}
Some(_) if !in_graph => self.layout_touched = None,
Some(list) => list.push(id),
}
self.bump_layout_epoch();
}
/// Tier 1's counter, shared by every touch above. Bumping it is what re-opens the gate; which
/// of [`Self::layout_touched`]'s two states the caller leaves behind is what decides how much
/// the re-opened resolve has to derive.
fn bump_layout_epoch(&mut self) {
self.layout_epoch = self.layout_epoch.wrapping_add(1);
// `WOW_LAYOUT_TOUCH_TRACE=<n>` — name the epoch's per-frame toucher: print a backtrace
// for the first n touches (a mechanism probe, meaningful in a debuginfo build). Built
// because the SW meters read `resolve≈240 µs, solves=0` on every steady frame: the
// tier-1 gate is being defeated by a toucher whose writes never move a fingerprint,
// and 48 call sites is too many to tag by hand.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::OnceLock;
// Spec `<secs>:<n>`: arm after `secs` (past UI load + settle, so the trace names the
// STEADY toucher, not the thousands of legitimate load-time touches), then print `n`.
static SPEC: OnceLock<Option<(std::time::Instant, f64, AtomicU32)>> = OnceLock::new();
let spec = SPEC.get_or_init(|| {
let v = std::env::var("WOW_LAYOUT_TOUCH_TRACE").ok()?;
let (secs, n) = v.split_once(':')?;
Some((
std::time::Instant::now(),
secs.trim().parse().ok()?,
AtomicU32::new(n.trim().parse().ok()?),
))
});
if let Some((t0, delay, left)) = spec {
if t0.elapsed().as_secs_f64() >= *delay
&& left
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1))
.is_ok()
{
eprintln!(
"[layout-touch] epoch={} at:\n{}",
self.layout_epoch,
std::backtrace::Backtrace::force_capture()
);
}
}
}
}