use std::collections::{HashMap, HashSet}; use crate::layout::{LayoutInput, LayoutSolver, Rect}; use crate::widget::{FrameHandle, RegionHandle, WidgetArena}; use super::{ auction, backdrop, bank, bind_confirm, camera_view, char_stats, chat_window, colorselect, container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider, social, spellbook, stable, 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 bool>; /// The host's texture **texel-size** oracle — see [`Model::texture_size_probe`]. pub type TextureSizeProbe = Box Option<(u32, u32)>>; /// The host's font-path resolvability oracle — see [`Model::font_probe`]. pub type FontProbe = Box bool>; /// A map from a minted object id to its handle, indexed directly: ids are dense (`next_id` /// counts from 1), and every scripted method call on a frame or region starts with this lookup /// — a few hundred a frame from the stock `OnUpdate` sweep alone (decision 1979's UI tick), so /// the hash a `HashMap` paid per call was the wrong price for a one-word index. #[derive(Debug, Clone)] pub(crate) struct IdMap(Vec>); impl Default for IdMap { fn default() -> Self { Self(Vec::new()) } } impl IdMap { pub(crate) fn get(&self, id: &u32) -> Option<&T> { self.0.get(*id as usize).and_then(Option::as_ref) } pub(crate) fn contains_key(&self, id: &u32) -> bool { self.get(id).is_some() } pub(crate) fn insert(&mut self, id: u32, v: T) -> Option { let i = id as usize; if self.0.len() <= i { self.0.resize(i + 1, None); } self.0[i].replace(v) } pub(crate) fn remove(&mut self, id: &u32) -> Option { self.0.get_mut(*id as usize).and_then(Option::take) } } /// 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, /// **The Lua index space** — positions into [`Self::addons`], `## Title`-sorted and /// hidden-filtered (decision 2175). NOT the registry: the reference keeps two structures and /// they are different permutations of different sets (wow-re /// `system/ui/scratch/addon-registry-scan-and-order.md` §7). pub(crate) addon_index: Vec, /// The lowercased names `SMSG_ADDON_INFO` marked `status = 2`, or **`None` when no reply has /// arrived this session** — and `None` is why [`Self::addon_index`] can be legitimately empty /// (decision 2175). `[0xbe1b90]` is zeroed by the registry reset `0x51fad1` and written /// nowhere but the reply's own rebuild, so `GetNumAddOns()` really is 0 until the server /// answers. pub(crate) addon_info_hidden: Option>, /// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding. pub(crate) addons_root: Option, /// The host's reader for a chain-sourced addon's files (`AddOnInfo::chain`), by /// chain-internal path — `None` in a VM with no chain behind it, where such an addon /// answers `MISSING`. pub(crate) addons_chain_reader: Option, /// 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>, /// 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, /// The host's texture **texel-size** oracle ([`super::UiScript::set_texture_size_probe`]): how /// many texels wide and tall is the art at this path? /// /// What lets a region whose authored size on an axis is exactly `0` take its span from its /// CONTENT, the way the real client's size getters do — `CSimpleTexture::GetWidth 0x770720` / /// `GetHeight 0x770790` return the authored value only when it is not `0.0`, and otherwise read /// the loaded texture's `[tex+0x144]`/`[tex+0x148]` through the same converter `` /// uses, so **one texel is one FrameXML unit** (wow-re `region-size-fallback.md` §2, decision /// 1349). The host answers off the same candidate walk the renderer decodes with and memoises, /// so the size layout resolves with is the size the screen shows. /// /// `None` in an engine-less VM (tests, the addon harness), where a zero-size texture keeps the /// rect it always had — there is no art to measure without a backend. pub(crate) texture_size_probe: Option, /// The host's font-path oracle ([`super::UiScript::set_font_probe`]): does this font reference /// load — patch chain or loose addon folder? What lets `SetFont`'s path form answer the /// reference's **1 | nil**, whose nil is a *load failure* and not an argument error (wow-re /// `system/ui/ui.md`: `0x79f345`/`0x79f361`, originating at `0x5c1ae0` under the `0x44d040` /// font-factory cache) — `!OmniCC/main.lua:41`'s `if not Font:SetFont(saved, size) then /// revert end` is exactly that probe. /// /// **`None` answers 1 for a non-empty path, which is where this differs from /// [`Self::texture_probe`]** (decision 2103). A VM with no font backend has no font store to /// fail against, so nil there would report a load failure that never happened — and it would /// send `!OmniCC` down its revert branch in every harness run. The texture probe could take /// the opposite default because nil was already what its engine-less callers observed; here 1 /// is. pub(crate) font_probe: Option, /// Where per-addon saved variables live: the account-scoped folder, then this character's. /// Both are directories holding one `.lua` per declaring addon. pub(crate) addons_saved_account: Option, pub(crate) addons_saved_character: Option, /// 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>, /// **`FrameXML_Debug`'s flag** — the loader's own trace-severity switch (decision 2160). /// /// `0x488440` is a get-or-set over the single global `[0xceea30]`, which boots at 0 and is /// read at six sites image-wide: the binding itself and five inside the XML loader, each /// gating a severity-0 trace line behind `flag > 0` (`0x6ee298 jle` — **greater than**, not /// non-zero, which is why this is signed). The one site walked to the bytes is /// `Instantiate 0x6ee280`, whose gated line is `0x871154 "-- Creating %s named %s"`. /// /// Here rather than on `UiScript` for `framexml_templates`' reason: the loader runs from a /// bare `&Lua`, and so does the binding that writes this. pub(crate) framexml_debug: std::cell::Cell, /// 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>, /// 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`/`SetWidth`/… mutate it; `resolve` runs the graph over them. pub(crate) layout_inputs: HashMap, /// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`. pub(crate) resolved: HashMap, /// 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, /// 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>, /// The MEASURE lane's ledger — [`Self::layout_touched`]'s twin for the text-measure sweep /// (`UiScript::fontstrings_needing_measure`), which used to walk every `region_data` row /// twice a frame to *discover* silent writes (`SetText` touches no layout until the extent /// moves — that silence is the lane's defining property, decision 1370's ~300 µs standing /// `resolve` lap at a city pin). `Some(handles)` = every write since the last sweep that /// could move a region's measure key ([`crate::script::types::RegionData::measure_key`]: /// text, font path/height, text height, outline, wrap width, owner scale) named its region /// here, so the sweep asks exactly these. `None` = some write could not name one (a /// font-object fan-out, a scale propagation, `invalidate_text_measures`) and the next sweep /// walks the whole roster exactly as it always did — which is also the initial state. /// /// UNLIKE the layout ledger, `None` is not re-entered by unmigrated sites automatically: /// nothing at a `region_data` field write *had* to announce itself before this existed, so /// completeness of the enrolled sites is the correctness claim — and it is machine-checked, /// not argued: with `WOW_LAYOUT_VERIFY` on (forced for every benilla-ui test) the sweep /// re-runs the whole-roster walk beside the drained ledger and panics on any row the ledger /// missed. A candidate the ledger names that the roster walk doesn't is fine (a same-value /// write — the staleness check absorbs it); the reverse is a stale label waiting to ship. pub(crate) measure_dirty: Option>, /// The message-line sweep's clean tokens: frame → (lines_gen, env hash) at its last /// ZERO-REQUEST sweep. A frame matching both is skipped whole (`message_lines_needing_measure` /// hashes none of its lines); a sweep that produced requests stores nothing, so an unanswered /// request re-requests forever exactly like the region lane's ledger (1410's rule). Stale /// entries for destroyed frames are inert (generational handles never re-match) and the map /// holds a handful of message frames, so no destroy hook. pub(crate) msg_swept: std::collections::HashMap, /// Lines actually hashed by the sweep — the skip's honest meter (tests assert 0 on a settled /// second sweep; 0735's counts-never-milliseconds rule). pub(crate) msg_lines_hashed: u64, /// `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, /// 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>, /// 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, /// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `` 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, /// 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 (`` → resolved paint), keyed by name. /// Populated by the loader as it walks top-level `` nodes; read by `SetFontObject` and by /// FontString `inherits=` resolution. Data only — no Lua handles (MAXCSTACK discipline). /// The named `` 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, /// 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, /// Monotonic id source (starts at 1; `0` is [`SCREEN`]). pub(crate) next_id: u32, pub(crate) id_to_frame: IdMap, pub(crate) frame_to_id: HashMap, pub(crate) id_to_region: IdMap, pub(crate) region_to_id: HashMap, /// 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, /// 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>, /// The frames carrying an `OnUpdate` script, maintained by `SetScript` — `scripts`' one /// writer — so the tick iterates exactly the OnUpdate population instead of re-filtering /// (and re-allocating from) the whole scripts map every frame (decision 1446). Order is /// irrelevant here: the tick sorts its visible subset by frame id every pass (the /// deterministic-dispatch law stays where it was). pub(crate) on_update_frames: Vec, /// The frames carrying an `OnSizeChanged` script — 1446's list, for the layout resolve's /// "before" snapshot instead of the tick. Same one writer, same reason: that snapshot's own /// comment says it covers "a handful, usually zero", and it found them by filtering the WHOLE /// `scripts` map with a string compare per entry, on every let-through resolve — 36–72 µs a /// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble /// (decision 1634's `[layout-pre] watched=`). pub(crate) on_size_changed_frames: Vec, /// The frames carrying an `OnUpdateModel` script — the list the tick fires at the top of /// every paint of a visible model pane (`0x76d1a0`'s first act; decision 2007). Same one /// writer as the two lists above. pub(crate) on_update_model_frames: Vec, /// What the engine knows about each model **file** a pane has named — the sequences and /// bounds a pane's clock needs ([`crate::widget::ModelFileFacts`]), handed over by the host /// once the asset is resident, keyed by [`crate::widget::model_key`]. The reference reads /// these off the loaded `MD20`; this engine parses no M2, so the facts arrive from the app's /// loader through `UiScript::set_model_facts`. pub(crate) model_facts: HashMap>, /// Model files a pane named that no facts have arrived for yet — drained by the host /// (`UiScript::model_facts_wanted`) to load them. Deduplicated on push. pub(crate) model_facts_wanted: Vec, /// Edit boxes whose text changed and whose `OnTextChanged` has **not fired yet** — the /// reference's `textChanged` dirty bit (`[E+0x31c]` bit 0), which `SetText`/`Insert` raise and /// only the dirty-word drain `0x77d3e0` clears (decision 1831). /// /// The fire is deferred, not immediate: `SetText` returns to Lua before any handler runs, and N /// changes to one box between two drains produce exactly ONE fire carrying the final text. The /// reference's own FrameXML depends on both halves — `MoneyInputFrame_SetCopper` sets three /// boxes while counting the fires it expects back, and errors if one arrives mid-loop. /// /// A hidden box stays on this list: `Hide` splices it out of the chain the update walk follows, /// so its pending fire waits until it is shown again (or a key/mouse-down reaches it). pub(crate) dirty_editboxes: Vec, /// `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>, /// `frame → its registered events` (for UnregisterEvent / cleanup). pub(crate) frame_events: HashMap>, /// The frames registered for **every** event (`RegisterAllEvents`), in registration order — /// the same ordered-Vec-never-a-set discipline as [`Self::event_to_frames`], and dispatched /// after it for the same reason: a frame that asks for all events asks *after* the frames /// already listening for a given one, so it takes the tail of that event's list. /// /// **A flag rather than an expansion.** 1.12's event ids run to `0x225` = 549 /// (`FrameScript_InitEvents`), so writing the frame into every per-event list on registration /// would mean 549 vector pushes per call and 549 scans per `UnregisterAllEvents` — and would /// also invent a name list this engine has no business owning (ours registers by string, and /// an event the server sends that no name list knows about would be silently excluded). /// `UnregisterAllEvents` empties this alongside the per-event registrations, which is the half /// AceEvent-2.0 depends on: it calls `frame:UnregisterAllEvents()` and then re-registers each /// individual event it still wants. pub(crate) all_event_frames: Vec, /// 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, /// 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, /// The hover **re-pick** is due: the world under a stationary cursor changed — the hovered /// frame hid (its `OnLeave` already fired at hide time), or a frame was shown that may now /// be the topmost under the cursor. The reference keeps exactly this flag on the frame /// manager (`[root+0x1100]`) and its per-tick pump re-runs the hover walk at the **saved** /// cursor position, explicitly bypassing the didn't-move coalesce — so the newly exposed /// frame gets `OnEnter` with no physical mouse move (wow-re /// `ui/scratch/hover-hide-and-tooltip-owner-law.md`: writers `0x764cbb`/`0x764b8d`, pump /// tail `0x7657a1` → `0x7660d0` self-alias). [`UiScript::tick`] drains it the same way. pub(crate) hover_repick: bool, /// 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, /// The client's single mouse-capture slot **`root+0x80`** — the frame a press captured, held /// until every button is up. The same fact as [`Model::mouse_down_on`] in the one-slot form, /// and the two differ only while more than one button is held; this is the form the /// mouse-down **raise** reads, which is what needs the precedence to be unambiguous. /// /// Written at `0x7663e6` with the resolved target — capture-**else**-hover, so an existing /// capture survives a chorded press over a different frame — and cleared at `0x7664bb` only /// when the post-event button mask is empty, so a chorded release keeps it. A title-region /// press never reaches `0x7663e6` and so captures nothing. pub(crate) mouse_capture: Option, /// 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, /// 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, /// 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, /// The **retained** script-error log — what [`errors`](Self::errors) and the load walk both /// throw away once logged (decision 1495). `errors` is a per-frame drain for the host's /// terminal; this is the session's memory, deduplicated and bounded, and the only thing a /// player can actually read. See [`super::diagnostics`] for why the reference's own dialog is /// not enough on its own. pub(crate) diagnostics: super::diagnostics::DiagnosticLog, /// Non-fatal warnings surfaced to the host (e.g. `CreateFrame`'s ignored `inherits=` template). pub(crate) warnings: Vec, /// 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, /// 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>, /// Spell ids `CancelUnitBuff` queued since the app's last drain — one `CMSG_CANCEL_AURA` each. pub(crate) cancel_aura_requests: Vec, /// 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, /// Selection asks queued since the app's last [`UiScript::take_selection_requests`] drain — the /// outbound twin of `units` above, carrying `TargetUnit` / `AssistUnit` / `TargetLastEnemy` in /// call order because the reference reaches selection through one helper for all three /// ([`super::SelectionRequest`]). The app resolves each to a streamed entity and commits the /// selection; one it can't resolve is a no-op, as the real client no-ops `TargetUnit` on a /// nonexistent unit ([`unit`]). pub(crate) selection_requests: Vec, /// `TargetNearestFriend([reverse])` presses queued since the app's last /// [`UiScript::take_target_nearest_friend_requests`] drain — one entry per call, `true` for the /// reverse (backward) cycle. The app runs the friendly half of the TAB scan ([`unit`]). pub(crate) target_nearest_friend_requests: Vec, /// `(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, /// 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>, 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, /// The ready-check deadline and the unanswered flags the timeout tick reads (decision 1989). pub(crate) ready_check: party::ReadyCheckState, /// The saved raid lockouts the Raid tab's info panel reads (decision 1549), pushed by the app /// from `SMSG_RAID_INSTANCE_INFO`. Its own field rather than a [`party::PartyState`] member /// because it arrives on its own packet and survives every roster change. pub(crate) saved_instances: Vec, /// `SetRaidRosterSelection`'s cursor — a client-side raid row index, never sent anywhere. pub(crate) raid_selection: i64, /// The current map's `Map.dbc` `InstanceType`, pushed by the app — `IsInInstance()`'s whole /// input ([`instance`]). `None` = the map id has no DBC row. pub(crate) instance_type: Option, /// `CanShowResetInstances()`'s answer, pushed by the app (decision 1748) — the four-term /// predicate the reference computes at `0x495c90` over state only the app holds. pub(crate) can_reset_instances: bool, /// `ResetInstances()` calls queued since the app's last /// [`super::UiScript::take_reset_instance_asks`] drain — the outbound seam ([`instance`]). pub(crate) reset_instance_asks: u32, /// 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, /// The client's three LFG slot words (`[0xbc70a0]`) and its comment (`[0xbc6e98]`, a /// 0x80-byte buffer), the BSS `SetLookingForGroup` writes and `GetLookingForGroup` reads /// back — never the server's (wow-re `lfg-set-get-law.md`, 1961). The slots are always zero /// in the reference: see [`social`]. pub(crate) lfg_slots: [u32; 3], pub(crate) lfg_comment: String, /// 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, /// The guild-charter snapshot the app pushes (the registrar's price and the open petition — /// decision 1672): `GetPetitionInfo`/`GetPetitionNameInfo`/`GetGuildCharterCost`/… read it /// ([`petition`]). Names and the proposed guild's title are already resolved, because in the /// real client too they come from caches the engine owns and not from the packet that opens /// the window. pub(crate) petition: petition::PetitionState, /// Charter intents (`BuyGuildCharter`/`SignPetition`/`TurnInGuildCharter`/…) queued since the /// app's last [`super::UiScript::take_petition_requests`] drain — the outbound seam /// ([`petition`]). pub(crate) petition_requests: Vec, /// 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 ` (the unit popup's WHISPER action; [`party`] registers the global). pub(crate) tell_requests: Vec, /// 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, /// Per-chat-window **look** — background tint, background alpha, font size — index 0 = /// `ChatFrame1` (decision 1589, [`chat_window`]). The engine's own per-window record narrowed /// to the three fields the tab menu's Display block can move; the host seeds it from the /// player's saved file and persists what Lua writes back. pub(crate) chat_window_looks: [chat_window::ChatWindowLook; chat_window::NUM_CHAT_WINDOWS], /// The 0-based window indices whose look Lua moved since the app's last /// [`super::UiScript::take_chat_window_changes`] drain — the persist cue. A set, so a slider /// drag costs one entry however many steps it took. pub(crate) chat_window_changes: HashSet, /// The chat-type colour registry (`0xb4e518`): 94 fixed entries + 10 `CHANNELn` extras /// (`chat_types`). Seeded from the compiled defaults; the app overlays `chat-cache.txt`'s /// `COLORS` block through [`super::UiScript::set_chat_colors`]. pub(crate) chat_colors: Vec, /// A Lua-side colour write landed — [`super::UiScript::take_chat_color_changes`]. pub(crate) chat_colors_changed: bool, /// The languages this character knows, `Languages.dbc` row order /// ([`super::UiScript::set_known_languages`]). pub(crate) known_languages: Vec, /// The zone-channel rows for the current zone ([`super::UiScript::set_zone_channel_catalog`]). pub(crate) zone_channel_catalog: Vec, /// Channel verbs since the last [`super::UiScript::take_channel_commands`] drain. pub(crate) channel_commands: Vec, /// **The guild-recruitment auto-join latch** — the reference's int global `[0x843608]`, which /// `GetGuildRecruitmentMode` returns and `SetGuildRecruitmentMode` writes (decision 2115). /// /// `0` = STANDARD, `1` = AUTO, and those two words are literally what the per-character chat /// cache stores it as (`OPTION_GUILD_RECRUITMENT_CHANNEL STANDARD|AUTO`; wow-re /// `system/ui/scratch/chat-cache-grammar.md` — the reader maps `STANDARD` to 0 and anything /// else, `AUTO` included, to 1). It boots at **1**: every one of the 33 `chat-cache.txt` files /// the reference client itself wrote in this repo's install says `AUTO`, on characters that /// never opened the option. pub(crate) guild_recruitment_mode: u8, /// A `SetGuildRecruitmentMode(1)` since the last drain — `0x49ea70`'s tail-jump into the /// cascade `0x49ea90`, which the app runs (decision 2144). Keyed on the *new value alone*, not /// on a change: the reference's store is unconditional and the jump reads only `ecx == 1`. pub(crate) guild_recruitment_cascade: bool, /// Whether Lua has moved [`Self::guild_recruitment_mode`] since the last drain — the chat /// cache's dirty signal, the peer of `chat_window_changes`. pub(crate) guild_recruitment_changed: bool, /// `DoEmote` calls since the last drain. pub(crate) emote_requests: Vec, /// `RandomRoll` calls since the last drain. pub(crate) roll_requests: Vec<(u32, u32)>, /// `UninviteByName` calls since the last drain. pub(crate) uninvite_requests: Vec, /// `ConsoleExec` lines that were not CVar writes. pub(crate) console_lines: Vec, /// `LoggingChat` / `LoggingCombat` — the two flags and their change cue. pub(crate) logging_chat: bool, pub(crate) logging_combat: bool, pub(crate) logging_changed: bool, /// Whether a **user-placed** frame's geometry moved since the app's last /// [`super::UiScript::take_user_placed_change`] drain — the layout cache's persist cue /// ([`super::layout_cache`]). One bit rather than a set of handles, because the file is /// rewritten whole either way; set by the move/resize pumps and by `SetUserPlaced` itself. pub(crate) user_placed_changed: bool, /// 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, /// 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, /// 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, /// Camera-view intents (`SetView`/`SaveView`/`ResetView`/`NextView`/`PrevView`/ /// `FlipCameraYaw`) queued since the app's last /// [`super::UiScript::take_camera_view_requests`] drain — the outbound seam /// ([`camera_view`]). Snapshot-less like the follow queue: the five views live on the app's /// camera rig, and they reach Lua the way the reference's do — as the /// `cameraView` / `camera{Distance,Pitch,Yaw}{,A..D}` CVars — not as a second copy here. pub(crate) camera_view_requests: Vec, /// 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, /// 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, /// The local player's honor snapshot ([`pvp::HonorState`], decision 1512) — the PRIVATE honor /// descriptor fields the app decodes and pushes ([`super::UiScript::set_honor`]). `None` /// before the first push, which the six self getters read as a zeroed snapshot (see /// [`pvp`]'s module doc for why they never return short). pub(crate) honor: Option, /// The last `MSG_INSPECT_HONOR_STATS` reply the app pushed ([`pvp::InspectHonorData`]). Its /// PRESENCE is `HasInspectHonorData`'s answer — the latch the reference's inspect pane gates /// its request on — so `None` is a state, not a gap. pub(crate) inspect_honor: Option, /// `RequestInspectHonorData` calls since the app's last /// [`super::UiScript::take_inspect_honor_requests`] drain. A count for [`Self::pvp_toggles`]'s /// reason: the binding takes no argument, and the guid the send needs is the app's own inspect /// target. With [`Self::inspect_honor_pending`] gating the binding it can only ever drain `0` /// or `1` — the engine has at most one query outstanding. pub(crate) inspect_honor_requests: u32, /// The engine's `pending` flag (`0xb71fcc`): a `MSG_INSPECT_HONOR_STATS` query is queued and /// no reply has landed. `RequestInspectHonorData` (`0x4c80a0`) refuses while it is set, which /// is what stops a pane shown/hidden/shown in quick succession sending duplicate queries. /// Cleared by **either** arm of [`super::UiScript::set_inspect_honor`] — a reply landed /// (`0x4c6f4c`), or the app invalidated the slot (`0x4c6f9d`). pub(crate) inspect_honor_pending: bool, /// The two **equipment-display** preferences as the VM currently believes them — /// `ShowingHelm()` / `ShowingCloak()` ([`worn_display`], decision 1472). Not a setting: the /// truth is `PLAYER_FLAGS`' `HIDE_HELM`/`HIDE_CLOAK` bits, pushed here on the descriptor edge /// by [`super::UiScript::set_worn_display`]. Both start **shown**, which is both the reference's /// panel default and the zero-flags state a fresh character logs in with. pub(crate) helm_shown: bool, /// The cloak half of [`Self::helm_shown`]. pub(crate) cloak_shown: bool, /// Worn-display flips (`ShowHelm`/`ShowCloak` asked for a state we were not in) queued since /// the app's last [`super::UiScript::take_worn_display_toggles`] drain — one /// `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` each. A list, not a count like [`Self::pvp_toggles`]: /// the two slots are different packets. pub(crate) worn_display_toggles: Vec, /// The server's `PLAYER_FIELD_BYTES` **byte 2** — which of the four extra action bars are on /// ([`action_bar_toggles`], wow-re `system/ui/scratch/action-bar-toggles.md`). `None` until the /// app pushes it ([`super::UiScript::set_action_bar_toggles`]), which is the ONLY thing that /// moves it: the real client never writes this cell, so `SetActionBarToggles` deliberately /// leaves it alone and the value lags the setter by a round trip. That is the opposite /// arrangement to [`Self::helm_shown`] next door, and the difference is the wire verb's — a /// blind flip has to be predicted, an absolute post does not. pub(crate) action_bar_toggles: Option, /// `CMSG_SET_ACTIONBAR_TOGGLES` payloads queued since the app's last /// [`super::UiScript::take_action_bar_toggle_sends`] drain — one per `SetActionBarToggles` /// call. A list, because the binding gates nothing: two calls in a frame are two packets. pub(crate) action_bar_toggle_sends: Vec, /// 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, /// **The UI-load sound-suppression depth** — `[0xb05fa0]` in the reference, a counted scope /// with exactly three references image-wide: `0x458f50` (`inc`), `0x458f60` (`dec`), and one /// reader at `0x458046`, inside `PlaySoundByName 0x458030`. The whole-UI load `0x48fbf0` /// brackets ITSELF in it — `0x48fbfa` on entry, `0x49016d` on exit, spanning the TOC walk, /// `Bindings.xml` and the AddOns — and both of its callers, login (`0x48f681`) and `/reloadui` /// (`0x495669`), therefore load silently. /// /// The reader bails at `0x45804d` **before** the `MasterSoundEffects` CVar and the kit-hash /// lookup, so a suppressed call is **dropped**: not muted, not queued, no Lua error, and the /// binding still answers its usual `(willPlay, handle)`. Scoped to the NAME-keyed entry only — /// id-keyed `PlaySound(kitId)` (`0x457fb0`/`0x457ff0`), `PlaySoundFile`, `PlayMusic` and /// `StopMusic` all run normally inside the scope. /// /// This is what makes stock `TargetFrame_OnHide`'s `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")` /// inaudible at every login: the frame really is shown when its `OnLoad` hides it, the OnHide /// really does fire, the call really is made — and the engine throws it away. Decision 1033's /// "no sound during the load" is the client's own law, enforced here rather than by hoping no /// handler rings. pub(crate) sound_suppression: u32, /// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered /// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`. pub(crate) cvars: HashMap, /// 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, /// `(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, /// The device-enumerated multisample formats the Video options dropdown offers, in the order /// it offers them — pushed by the host ([`super::UiScript::set_multisample_formats`]) from what /// the render adapter actually accepts, and read by `GetMultisampleFormats` / /// `GetCurrentMultisampleFormat` / `SetMultisampleFormat`. /// /// In the reference this is the distilled `{colorBits, depthBits, multisample}` triple list at /// `[0xb4b444]` (count `[0xb4b440]`), built by `0x48c3e0` from the D3D /// `CheckDeviceMultiSampleType` sweep or the GL `wglGetPixelFormatAttribivARB` sweep. Empty /// until the host pushes it — a VM with no device behind it (every extract test) offers no /// formats rather than inventing some. pub(crate) multisample_formats: Vec, /// 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, /// 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, /// 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, pub(crate) bonus_bar_offset: u8, pub(crate) action_uses: Vec, /// `(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 ; 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, /// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`] /// drain. pub(crate) spell_casts: Vec, /// 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, /// 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, /// 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)>, /// `ConfirmTalentWipe()` calls queued since the app last drained them — each is one outbound /// `MSG_TALENT_WIPE_CONFIRM`. A COUNT for [`Self::binder_confirms`]'s reason: the app holds /// the trainer's guid, so the intent carries no payload of its own (decision 1580). pub(crate) talent_wipe_confirms: u32, /// Is a trainer's respec question still live and in range — the answer /// `CheckTalentMasterDist()` gives, pushed by the app each frame /// ([`super::UiScript::set_talent_master_pending`]). The CONFIRM_TALENT_WIPE dialog polls it /// from OnUpdate and hides itself when it goes false; the binder question's twin, and the same /// range gate stands behind both (decision 1580). pub(crate) talent_master_pending: bool, // ── The dialog engine's verbs (decision 1963; wow-re `staticpopup-dialog-bindings.md`) ── /// `ConfirmPetUnlearn()` calls since the app's last drain — the pet trainer's twin of /// [`Self::talent_wipe_confirms`]; the app holds the latched trainer and the money gate. pub(crate) pet_unlearn_confirms: u32, /// Whether the pet trainer's question is pending AND the trainer is in reach — what /// `CheckPetUntrainerDist()` answers, pushed by the app each frame. pub(crate) pet_untrainer_pending: bool, /// `GetInstanceBootTimeRemaining()`'s answer in whole seconds, 0 when idle — the app derives /// it from the `SMSG_RAID_GROUP_ONLY` deadline each frame. pub(crate) instance_boot_secs: u32, /// Whether a battleground spirit healer is the cached current-area healer (a non-zero /// `[0xb4e330/334]`), and the seconds to its next wave (`[0xb4e338]`), both the app's. pub(crate) area_spirit_healer_cached: bool, pub(crate) area_spirit_secs: u32, /// `AcceptAreaSpiritHeal()` calls since the last drain (each one `CMSG_AREA_SPIRIT_HEALER_QUEUE` /// with the cached healer — the app holds the guid). pub(crate) area_spirit_accepts: u32, /// `AcceptBattlefieldPort(index, accept)` calls: the 1-based slot and the normalised answer. pub(crate) battlefield_port_requests: Vec<(u8, bool)>, /// The battleground scoreboard (1972): the pushed rows, the faction filter, the sorted order. pub(crate) battlefield_board: super::battlefield_score::ScoreBoard, /// `GetBattlefieldInstanceRunTime()`'s answer, pushed each frame by the app's clock. pub(crate) battlefield_run_time_ms: u32, /// `RequestBattlefieldScoreData()` calls since the last drain. pub(crate) battlefield_score_requests: u32, /// `LeaveBattlefield()` calls that passed the "ended" gate since the last drain. pub(crate) battlefield_leave_requests: u32, /// The battleground instance list and its scalars (1974), as the app last pushed them. pub(crate) battlefield_list: super::battlefield_queue::BattlefieldListView, /// `[0xb6eba0]` — the selected instance id (a VALUE; `SetSelectedBattlefield` writes it). pub(crate) battlefield_selected: u32, /// The three queue slots (1974), pushed each frame with their clock-shaped values reduced. pub(crate) battlefield_slots: Vec, /// `GetBattlefieldInstanceExpiration()`'s answer, pushed each frame. pub(crate) battlefield_instance_expiration_ms: u32, /// `JoinBattlefield` calls since the last drain: `(instance id, as group)`. pub(crate) battlefield_join_requests: Vec<(u32, bool)>, /// `ShowBattlefieldList` calls that passed their gates: the queued slot's map id. pub(crate) battlefield_list_requests: Vec, /// The battleground teammates' map positions as the app resolved them (1980), the flag /// carrier, the active map's icon scale, and `RequestBattlefieldPositions()` calls. pub(crate) battlefield_positions: Vec, pub(crate) battlefield_flag: Option, pub(crate) battlefield_icon_scale: f32, pub(crate) battlefield_position_requests: u32, /// `CancelMeetingStoneRequest()` calls since the last drain — the app gates on leadership. pub(crate) meeting_stone_cancels: u32, /// The meeting stone's queued area (`[0xb72038]`) and cached status text (`[0xb7203c]`), as the /// app last pushed them (1974). pub(crate) meeting_stone_area: u32, pub(crate) meeting_stone_status_text: Option, /// The tutorial system's acknowledged bank, as the app last pushed it (1976); `None` before /// `SMSG_TUTORIAL_FLAGS` — `TutorialsEnabled()`'s only input. pub(crate) tutorial_bank: Option>, /// `FlagTutorial(n)` calls since the last drain (0-based, in range). pub(crate) tutorial_flag_requests: Vec, pub(crate) tutorial_clears: u32, pub(crate) tutorial_resets: 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, /// Form spell ids `CastShapeshiftForm` queued since the app's last /// [`super::UiScript::take_shapeshift_casts`] drain. pub(crate) shapeshift_casts: Vec, /// 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, /// 1-based slot indices `TogglePetAutocast` queued. pub(crate) pet_autocast_toggles: Vec, /// `PetStopAttack()` calls queued — a count, since the verb takes no argument. pub(crate) pet_stop_attacks: u32, /// The pet one-shot orders (`PetAttack`, `PetFollow`, `PetWait`, the three modes), each the /// packed slot word the bar's own command or reaction slot would carry — the app's drain /// presses them exactly as a slot (1958). pub(crate) pet_orders: Vec, /// `HasFullControl`'s flag — the reference's `[0xb4b3e4]`, written by /// `SMSG_CLIENT_CONTROL_UPDATE` naming the local player and read as "if zero, refuse" by /// every cast, item and cursor gate (wow-re `control-loss-and-restore.md`). Boot value 1. pub(crate) player_control: bool, /// 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>, /// `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, /// 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, 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, /// 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, /// 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, /// `(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 armed gift wrap** — the `(bag, slot)` of a piece of wrapping paper whose right-click /// took the reference's begin-wrap arm (`0x5edea0`: lock the paper, cursor mode 2, no packet). /// `None` = nothing armed, which is nearly always. Decision 1934. /// /// Deliberately **not** a [`cursor::CursorPayload`]: the reference writes no payload global at /// all here, only the displayed-cursor mode, so `CursorHasItem()` stays nil and none of the /// thirteen stock `CursorHasItem()` gates open. Making it a payload would silently open every /// one of them — the same trap decision 1677's mode-5 note names for the vendor cursor. pub(crate) pending_wrap: Option, /// `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued, drained by the app /// into `CMSG_WRAP_ITEM`. The paper's pair leads, as the wire does. pub(crate) container_wraps: Vec<(i64, u32, i64, 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, /// 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)>, /// Auto-stores queued by `PutItemInBag`/`PutItemInBackpack` (`cursor`'s `bag_verbs` submodule) — /// drained by the app into `CMSG_AUTOSTORE_BAG_ITEM` (or `CMSG_SPLIT_ITEM` for a split carry). pub(crate) bag_autostores: Vec, /// 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>, /// 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, /// 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, /// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same /// `StopMovingOrSizing` (`0x776990`). pub(crate) sizing: Option, /// 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, /// The in-flight ColorSelect drag — the colour picker's wheel/value-strip twin of /// [`Self::slider_drag`], held from press to release. The widget keeps two independent capture /// flags rather than one target ([`colorselect::ColorDrag`]). pub(crate) color_drag: Option, /// 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, pub(crate) gossip_selects: Vec, 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, /// 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, pub(crate) merchant_buys: Vec<(u32, u32)>, /// `PickupMerchantItem`'s SELL arm — the `(bag, slot)` the cursor was holding when it was /// called over the vendor window. The app resolves the guid and sends `CMSG_SELL_ITEM`. pub(crate) merchant_cursor_sells: Vec<(i64, u32)>, /// A held vendor row dropped into a bag — `(bag, slot, item entry)`, the app's /// `CMSG_BUY_ITEM_IN_SLOT`. pub(crate) merchant_slot_buys: Vec<(i64, 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, pub(crate) repair_all: bool, pub(crate) repair_mode: bool, /// The stable window's whole state — the pushed snapshot, the selection, the frame-local /// drag, and the queued verbs (the stable seam, [`stable`], decision 1676). One sub-struct /// rather than five loose fields because the close path resets several of them together. pub(crate) stable: stable::StableModel, /// 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, 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, pub(crate) trainer_buys: Vec, 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, /// 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, pub(crate) taxi_takes: Vec, 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, /// `(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, /// 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, /// 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 `EquipPendingItem`/`CancelPendingEquip` answers the app drains, in call order, and the /// `ConfirmBindOnUse()` count — the non-loot soulbind confirmations' whole Lua side /// ([`bind_confirm`], decision 1750). The pending records themselves are the app's. pub(crate) pending_equip_answers: Vec, pub(crate) bind_on_use_confirms: u32, /// The open loot's row snapshot the app pushes (`None` = no loot open), the row-pick intents it /// drains, and whether `CloseLoot` was called — the loot seam ([`loot`]). /// /// **The picks come in two flavours, and the flavour is load-bearing** (decision 1744): the /// reference's take dispatcher `0x4c2790(slot, flag)` is one function with two entries, and the /// flag decides whether a bind-on-pickup row raises the LOOT_BIND confirm or is taken outright. /// `flag == 0` is the row click, which in 1.12 is the C `CLootButton`'s own behaviour and here /// is `BenillaTakeLootSlot`; `flag != 0` is the confirm continuation, which is the ONLY thing /// the 1.12 Lua binding `LootSlot` does (`0x4c2e70` passes `edx = 1`, and its sole FrameXML /// caller is `StaticPopupDialogs["LOOT_BIND"].OnAccept`). pub(crate) loot: Option, pub(crate) loot_picks: Vec, /// The `LootSlot(slot)` confirm continuations — 1-based display rows, drained separately from /// [`Self::loot_picks`] so the app can honour the reference's pending-slot gate. pub(crate) loot_confirms: Vec, pub(crate) loot_close: bool, /// The `GiveMasterLoot(slot, candidateIndex)` assignments the app drains — both 1-based and /// both display-side: the row as the window numbers it, and the candidate's position in /// [`loot::LootState::master_candidates`]. The app owns the translation to the wire slot and /// the recipient's guid (decision 1675). pub(crate) loot_master_gives: Vec<(u32, u32)>, /// 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, pub(crate) item_text_close: bool, pub(crate) item_text_page_turns: Vec, pub(crate) mail: Option, pub(crate) mail_check_inbox: bool, pub(crate) mail_opens: Vec, pub(crate) mail_take_items: Vec, pub(crate) mail_take_money: Vec, pub(crate) mail_deletes: Vec, pub(crate) mail_returns: Vec, pub(crate) mail_take_texts: Vec, 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, /// The usable stationery list the app pushes, in the picker's order (1970). pub(crate) mail_stationeries: Vec, /// `SelectStationery`'s stored `Stationery.dbc` id — 0 = none, which silences `SendMail`. pub(crate) mail_stationery: u32, /// `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 auction house's snapshot the app pushes (`None` = no auctioneer session) and the /// intents the app drains — the auction seam ([`auction`], decision 1511). `auction_selected` /// is engine-local per list ([`auction::LIST`]/`BIDDER`/`OWNER`) and holds the selected /// **auction id**, not a row position (wow-re §5 TU-5): an id follows its row through a /// re-sort, where an index would quietly come to mean the auction that took its place. `0` = /// nothing selected. It is engine-local because the reference reads the selection back /// synchronously inside the same handler that sets it, so it cannot be a round trip. `auction_can_query` is the app's 5 s browse /// throttle, pushed separately from the snapshot because the Search button polls it every /// frame and it would otherwise churn the snapshot's diff. pub(crate) auction: Option, /// The Browse tab's class tree, login-scoped (`set_auction_item_classes`, 1971). pub(crate) auction_item_classes: Vec, pub(crate) auction_selected: [u32; 3], pub(crate) auction_can_query: bool, pub(crate) auction_query: Option, pub(crate) auction_owner_query: Option, pub(crate) auction_bidder_query: Option, pub(crate) auction_bids: Vec, pub(crate) auction_cancels: Vec, pub(crate) auction_start: Option, pub(crate) auction_sorts: Vec<(usize, String)>, pub(crate) auction_close: bool, /// The sell slot's staged item (a cursor drop, decision 0216) — carried until `StartAuction` /// fires, when the app resolves its `(bag, slot)` to the wire item guid. pub(crate) auction_sell_item: Option, /// 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, pub(crate) trade_initiates: Vec, pub(crate) trade_accept: bool, pub(crate) trade_unaccept: bool, pub(crate) trade_close: bool, /// `BeginTrade()` / `CancelTrade()` since the last drain (decision 1963). pub(crate) trade_begin: bool, pub(crate) trade_cancel: bool, pub(crate) trade_set_money: Option, /// 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, /// 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, pub(crate) quest_selects: Vec, pub(crate) quest_actions: Vec, /// 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, /// 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, /// Quest ids `QuestLogPushQuest()` queued for the app to push to the party (decision 1733). /// **Ids, not entry indices** — unlike the abandon mark above, whose two-step confirm is the /// whole reason it pins an index: a push is one click, so the id is resolved right then and a /// log shuffle between click and drain cannot retarget it. pub(crate) quest_log_pushes: Vec, /// How many times `ConfirmAcceptQuest()` was called — the escort confirm's Yes. A counter, not /// a quest id: the reference's verb takes no argument and answers whatever confirm the client /// is holding, so the id lives app-side with the pending confirm (decision 1733). pub(crate) quest_confirms: u32, /// The shared item-template store: `item id → full tooltip view` ([`item_stats`] module doc, /// decision 0274 P1). pub(crate) item_templates: HashMap, /// 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, /// 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, pub(crate) item_set_asks: HashSet, /// The **random-suffix roll** store (`ItemRandomProperties` id → its resolved view) — pushed /// WHOLE at startup, not asked for (decision 1547). It is a static DBC the app has in memory /// from load, and its consumers are click-driven (a chat-link tooltip has no hover re-enter /// loop to repaint on a late answer), so the ask-once shape the template store uses would /// leave a first click showing an item with no lines. This mirrors the reference exactly: the /// builder resolves its `+0x424` against the loaded DBC store `0xc0dbd4` at draw time, and /// every source supplies only the id (wow-re `tooltip-content-law.md` §1-ENCHANT §E5). pub(crate) random_properties: HashMap, /// 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, pub(crate) spell_tooltip_asks: HashSet, /// 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, /// 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, /// 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, /// `PLAYER_FLAGS` bit 12 — **PARTIAL_PLAY_TIME**, what `PartialPlayTime()` answers /// (`0x48eb70`, `shr eax,0xc` / `and al,1`; decision 1746 §"`PLAYER_FLAGS` bit `0x1000` is /// read after all"). The play-time regime a realm with an anti-addiction policy puts an /// account into; on a western realm it never sets. pub(crate) partial_play_time: bool, /// `PLAYER_FLAGS` bit 13 — what `NoPlayTime()` answers (`0x48ebe0`, the neighbour of the /// above and the harsher half of the same regime). pub(crate) no_play_time: bool, /// The account's accumulated **rested billing minutes**, from `SMSG_AUTH_RESPONSE` — what /// `GetBillingTimeRested()` returns (decision 1820, binding `0x48ec50`). Minutes is the /// server's convention and the engine converts nothing; stock `PlayerFrame.lua:246` divides by /// 60 for hours. Reached only from inside the two play-time bits above, so with those clear /// nothing in stock FrameXML ever reads it. pub(crate) billing_time_rested: u32, /// Is a cinematic playing? What `InCinematic()` reports, pushed by the cinematic plugin. /// /// Read by more than the letterbox: `StaticPopup_Show` refuses any dialog whose entry lacks /// `interruptCinematic` while this is set, exactly as the reference's `StaticPopup.lua:1454` /// does — which is what stops a "your character will be logged out" box from landing on top /// of a first login's race intro. pub(crate) in_cinematic: 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, /// 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, /// 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, pub(crate) inventory_slots: char_stats::InventorySlots, /// The six bank-bag slots (live ids 64..=69) — the third band of /// [`char_stats::Model::inv_slot`], and a store rather than a view because a bank bag has no /// container slot to be read out of: it IS the container. Fed beside /// [`Self::inventory_slots`] off the player descriptor's own guids. pub(crate) bank_bag_slots: char_stats::BankBagSlots, /// 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], /// 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, /// 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; 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, /// Unit tokens queued by `NotifyInspect` — drained by the app into `CMSG_INSPECT`. pub(crate) inspect_notifies: Vec, /// `ClearInspectPlayer` was called — drained by the app, which drops its inspect target. pub(crate) inspect_clear: bool, /// The dressing room's queued intents (decision 1060) — the `DressUpModel` widget's /// `SetUnit`/`Dress`/`Undress`/`TryOn`, drained by the app in order (see [`super::dressup`] on /// why order matters). The pane's yaw is its own `ModelState` facing, like every migrated /// window's (`UiScript::model_pane_facing`, 1751/1969). pub(crate) dressup_intents: Vec, /// The tabard designer (1977): each `TabardModel`'s five values, the last seeded/cycled five /// (the app's body preview), the host facts, and the queued intents. pub(crate) tabard_designs: HashMap, pub(crate) tabard_preview: Option<[i32; 5]>, pub(crate) tabard_host: super::tabard::TabardHost, pub(crate) tabard_intents: Vec, /// The WorldFrame type's one-shot registry record (1984): `true` once the first is made. pub(crate) world_frame_made: bool, /// Unit token (lowercase) → what the app resolved about it this frame, for **every** token /// that named a **live unit object**, creature as readily as player — the input to both /// verified range predicates (`CanInspect`, `CheckInteractDistance`). An absent token is one /// the object manager holds nothing for, and both answer `nil` there (see /// [`UiScript::set_unit_reach`] and [`super::UnitReach`]). pub(crate) unit_reach: HashMap, /// 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, /// 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, /// The engine-held selection, by SKILL ID (not visible index — see [`skills`]'s module doc). pub(crate) skills_selected: Option, /// 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, /// 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, /// 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, /// The engine-held selection, by REPUTATION-LIST SLOT (not visible index — see /// [`reputation`]'s module doc). pub(crate) reputation_selected: Option, /// 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, /// 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, /// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the /// engine-owned selection. pub(crate) worldmap: super::worldmap::WorldMapState, /// The V-key nameplate pool — engine-owned `Button` widgets under the `WorldFrame`, grown on /// demand and never shrunk, in creation order (decision 2148). Lives here rather than on /// [`super::UiScript`] because it IS model state: the plates are arena frames, and an addon /// walking `WorldFrame:GetChildren()` reaches them like any other. pub(crate) nameplates: super::nameplate::NamePlates, /// The always-up world-state readout's rows ([`worldstate`](super::worldstate)), already /// gated and resolved app-side. pub(crate) worldstate: super::worldstate::WorldStateUiState, /// 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)>, /// 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), /// A `Minimap:PingLocation(x, y)` call, parked for the app to drain **in the same frame** /// ([`UiScript::take_minimap_ping_request`]): centre-relative offsets in **UI units** /// (x right, y up — `GetCursorPosition`'s own space, which is NOT the window-pixel space /// the app's minimap geometry lives in; the app applies the 0582 seam scale on the way in, /// and getting that wrong is decision 1596's first root cause). pub(crate) minimap_ping_request: Option<(f32, f32)>, /// The minimap ping's **normalized** offsets from the widget centre (fractions of the widget /// side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished by the app /// every frame from the stored world point against the player's live position. Behind /// `Minimap:GetPingPosition()`, which answers **two numbers always** — the reference /// recomputes them per call from a pair of statics nothing ever clears (wow-re /// `minimap-ping-law.md`), and the stock `Minimap_OnUpdate` multiplies the answer without a /// nil test for the whole of its 5 s timer (1974; 1596's nil answer stood until then). There /// is exactly one ping, so it lives here rather than on each Minimap widget's /// [`KindState`](crate::widget::KindState) — one write, one read, no arena walk. pub(crate) minimap_ping: (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, /// 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, /// `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, /// `Screenshot()` calls queued since the app last drained them — each is one capture /// (decision 1487). A COUNT for [`Self::played_time_asks`]'s reason: the request carries no /// payload, so two calls in a frame are two captures. pub(crate) screenshot_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. /// The `GMTicketCategory.dbc` rows behind `GetGMTicketCategories()` — `(id, name)` in file /// order, pushed once by the app ([`super::UiScript::set_gm_ticket_categories`]). The ids are /// the wire values, so this is an ordered pair list rather than an indexable table /// (decision 1673). pub(crate) gm_ticket_categories: Vec<(u32, String)>, /// Every ticket verb the window called since the app's last drain, **in call order** — ask, /// status-ask, delete, create, edit, one packet each. ONE queue rather than a counter per verb /// so that `DeleteGMTicket(); GetGMTicket()` in a single chunk reaches the wire in that order; /// per-verb drains cannot express it, and the get would answer with the pre-delete state /// (decision 1673). pub(crate) gm_ticket_intents: Vec, /// `Stuck()` calls queued — each is one cast of spell 7355, the Help window's Auto-Unstuck. pub(crate) stuck_casts: u32, 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, /// The summon question's three resolved reads, pushed by the app each frame /// ([`super::UiScript::set_summon_confirm`]) — what `GetSummonConfirmSummoner`, /// `GetSummonConfirmAreaName` and `GetSummonConfirmTimeLeft` answer ([`super::summon`]). /// Default (two empty strings and a zero) is exactly what the reference's getters answer with /// nothing pending, so the pre-push window behaves like the post-expiry one (decision 1747). pub(crate) summon_confirm: super::summon::SummonConfirmUiState, /// `ConfirmSummon()` calls queued since the app's last drain — each is one outbound /// `CMSG_SUMMON_RESPONSE`. A COUNT for [`Self::binder_confirms`]'s reason: the app holds the /// summoner's guid, so the intent carries no payload of its own. pub(crate) summon_confirms: u32, /// 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) { // Three channels now, and the third is the one with a memory (decision 1495): the dialog // shows a burst's first message and the host drain empties every frame, so without this // the 1,112 raises after the first exist nowhere a player can reach. self.diagnostics .record(super::diagnostics::DiagnosticKind::Error, &msg); self.pending_error_dispatch.push(msg.clone()); self.errors.push(msg); } /// **Record one non-fatal warning — the channel's one door** (decision 2135). Both halves: /// the host's per-frame `warnings` drain (a `warn!` line in the terminal, for whoever is /// running the client), and the retained diagnostic log, which is the only copy a player or an /// instrument can read after the frame that produced it. /// /// Before this there was only the first half, and it was a dead end: `take_warnings` empties /// every frame, so a warning existed for the length of one terminal line and then nowhere at /// all. The messages it carries are the failures that never announce themselves — a dropped /// `inherits=`, an unresolved anchor, an unregistered CVar — which is precisely the class the /// diagnostic log was built for. /// /// Its sibling is [`Self::warn_host_only`], for the one message already retained under a /// truer kind. pub(crate) fn record_warning(&mut self, msg: impl Into) { let msg = msg.into(); self.diagnostics .record(super::diagnostics::DiagnosticKind::Warning, &msg); self.warnings.push(msg); } /// The host channel **alone** — for a message that is already retained under a kind that says /// more than `Warning` does. Exactly one caller: `LoadAddOn`'s failure line, which /// `record_load_failure` has already filed as a [`Load`](super::diagnostics::DiagnosticKind) /// row ("the addon isn't running", which is the fact worth keeping) and which still wants its /// terminal line. Retaining it twice would put the same sentence in the log under two kinds. pub(crate) fn warn_host_only(&mut self, msg: String) { self.warnings.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(), addon_index: Vec::new(), addon_info_hidden: None, addons_root: None, addons_chain_reader: None, measurer: None, texture_probe: None, texture_size_probe: None, font_probe: None, addons_saved_account: None, addons_saved_character: None, framexml_templates: Default::default(), framexml_debug: 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, measure_dirty: None, msg_swept: std::collections::HashMap::new(), msg_lines_hashed: 0, 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: IdMap::default(), frame_to_id: HashMap::new(), id_to_region: IdMap::default(), region_to_id: HashMap::new(), region_names: HashMap::new(), scripts: HashMap::new(), on_update_frames: Vec::new(), on_size_changed_frames: Vec::new(), on_update_model_frames: Vec::new(), model_facts: HashMap::new(), model_facts_wanted: Vec::new(), dirty_editboxes: Vec::new(), event_to_frames: HashMap::new(), frame_events: HashMap::new(), all_event_frames: Vec::new(), focused_editbox: None, mouseover: None, hover_repick: false, mouse_down_on: HashMap::new(), mouse_capture: None, last_click: HashMap::new(), pending_size_changed: Vec::new(), errors: Vec::new(), pending_error_dispatch: Vec::new(), diagnostics: Default::default(), 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, selection_requests: Vec::new(), target_nearest_friend_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(), ready_check: party::ReadyCheckState::default(), saved_instances: Vec::new(), raid_selection: 0, instance_type: None, can_reset_instances: false, reset_instance_asks: 0, social: social::SocialState::default(), social_requests: Vec::new(), lfg_slots: [0; 3], lfg_comment: String::new(), guild: guild::GuildState::default(), guild_control: guild::GuildRankEdit::default(), guild_requests: Vec::new(), petition: petition::PetitionState::default(), petition_requests: Vec::new(), tell_requests: Vec::new(), open_chat_requests: Vec::new(), // Seeded per window, because the stock `DOCKED` position is not the same for all // seven (`ChatWindowLook::stock`): 1 and 2 are the dock, 3..7 are undocked. chat_window_looks: std::array::from_fn(chat_window::ChatWindowLook::stock), chat_window_changes: HashSet::new(), chat_colors: super::chat_types::seed(), chat_colors_changed: false, known_languages: Vec::new(), zone_channel_catalog: Vec::new(), channel_commands: Vec::new(), guild_recruitment_mode: 1, guild_recruitment_cascade: false, guild_recruitment_changed: false, emote_requests: Vec::new(), roll_requests: Vec::new(), uninvite_requests: Vec::new(), console_lines: Vec::new(), logging_chat: false, logging_combat: false, logging_changed: false, user_placed_changed: false, default_language: None, duel_requests: Vec::new(), follow_requests: Vec::new(), camera_view_requests: Vec::new(), session_requests: Vec::new(), pvp_toggles: 0, honor: None, inspect_honor: None, inspect_honor_requests: 0, inspect_honor_pending: false, helm_shown: true, cloak_shown: true, worn_display_toggles: Vec::new(), action_bar_toggles: None, action_bar_toggle_sends: Vec::new(), sound_queue: Vec::new(), sound_suppression: 0, cvars: HashMap::new(), cvars_saved_base: HashMap::new(), cvar_changes: Vec::new(), cvars_warned: HashSet::new(), multisample_formats: Vec::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(), talent_wipe_confirms: 0, talent_master_pending: false, pet_unlearn_confirms: 0, pet_untrainer_pending: false, instance_boot_secs: 0, area_spirit_healer_cached: false, area_spirit_secs: 0, area_spirit_accepts: 0, battlefield_port_requests: Vec::new(), battlefield_board: Default::default(), battlefield_run_time_ms: 0, battlefield_score_requests: 0, battlefield_leave_requests: 0, battlefield_list: Default::default(), battlefield_selected: 0, battlefield_slots: Vec::new(), battlefield_instance_expiration_ms: 0, battlefield_join_requests: Vec::new(), battlefield_list_requests: Vec::new(), battlefield_positions: Vec::new(), battlefield_flag: None, battlefield_icon_scale: 1.0, battlefield_position_requests: 0, meeting_stone_cancels: 0, meeting_stone_area: 0, meeting_stone_status_text: None, tutorial_bank: None, tutorial_flag_requests: Vec::new(), tutorial_clears: 0, tutorial_resets: 0, 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_orders: Vec::new(), player_control: true, 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(), pending_wrap: None, container_wraps: Vec::new(), ui_cursor: None, ui_cursor_dirty: false, container_autoequips: Vec::new(), bag_autostores: Vec::new(), drag_registered: HashMap::new(), drag: None, moving: None, sizing: None, slider_drag: None, color_drag: None, gossip: None, gossip_selects: Vec::new(), gossip_close: false, gossip_quest_selects: Vec::new(), merchant: None, merchant_buys: Vec::new(), merchant_cursor_sells: Vec::new(), merchant_slot_buys: Vec::new(), merchant_close: false, merchant_buybacks: Vec::new(), repair_all: false, repair_mode: false, stable: Default::default(), 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, pending_equip_answers: Vec::new(), bind_on_use_confirms: 0, loot_picks: Vec::new(), loot_confirms: Vec::new(), loot_close: false, loot_master_gives: Vec::new(), 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, mail_stationeries: Vec::new(), mail_stationery: 0, has_new_mail: false, auction: None, auction_item_classes: Vec::new(), auction_selected: [0; 3], auction_can_query: true, auction_query: None, auction_owner_query: None, auction_bidder_query: None, auction_bids: Vec::new(), auction_cancels: Vec::new(), auction_start: None, auction_sorts: Vec::new(), auction_close: false, auction_sell_item: None, trade: None, trade_initiates: Vec::new(), trade_accept: false, trade_unaccept: false, trade_close: false, trade_begin: false, trade_cancel: 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(), quest_log_pushes: Vec::new(), quest_confirms: 0, item_templates: HashMap::new(), item_sets: HashMap::new(), item_set_asks: HashSet::new(), random_properties: HashMap::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(), nameplates: super::nameplate::NamePlates::default(), worldstate: super::worldstate::WorldStateUiState::default(), pending_events: Vec::new(), cursor_pos: (0.0, 0.0), minimap_ping_request: None, minimap_ping: (0.0, 0.0), chat_sends: Vec::new(), addon_sends: Vec::new(), played_time_asks: 0, screenshot_asks: 0, realm_name: String::new(), bind_location: String::new(), gm_ticket_categories: Vec::new(), gm_ticket_intents: Vec::new(), stuck_casts: 0, binder_confirms: 0, binder_pending: false, summon_confirm: super::summon::SummonConfirmUiState::default(), summon_confirms: 0, 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, partial_play_time: false, no_play_time: false, billing_time_rested: 0, in_cinematic: 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(), bank_bag_slots: Default::default(), inventory_alerts: [0; 12], inventory_uses: Vec::new(), weapon_enchants: [None; 2], inspect: None, inspect_notifies: Vec::new(), inspect_clear: false, dressup_intents: Vec::new(), tabard_designs: HashMap::new(), tabard_preview: None, tabard_host: Default::default(), tabard_intents: Vec::new(), world_frame_made: false, unit_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.give_up_naming(); 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(), } } /// An anchor RETARGET that names its node: the write moved node `h`'s (or `rh`'s) set of /// anchor TARGETS, and hands over both target lists so the cached graph's edges can be /// re-pointed instead of thrown away (decision 1625, extending 1388). /// /// `old`/`new` are the node's FULL anchor-target lists either side of the write. Falls back to /// the conservative touch whenever the scope refuses the patch — the same "worst case is a /// derivation" safety the other precise touches have. pub(crate) fn touch_layout_retarget_frame(&mut self, h: FrameHandle, old: &[u32], new: &[u32]) { match self.frame_to_id.get(&h).copied() { // Already conservative ⇒ the next resolve rebuilds every edge from scratch anyway, so // patching them would be work with no reader. Some(id) if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => { self.touch_layout_node(id); } _ => self.touch_layout(), } } /// [`Self::touch_layout_retarget_frame`]'s region twin. pub(crate) fn touch_layout_retarget_region( &mut self, rh: RegionHandle, old: &[u32], new: &[u32], ) { match self.region_to_id.get(&rh).copied() { Some(id) if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => { self.touch_layout_node(id); } _ => self.touch_layout(), } } /// A write that could move region `rh`'s measure key — its text, its font face/height/ /// outline, its text height, its explicit size (the wrap width) — names the region on the /// measure ledger ([`Self::measure_dirty`]). Push-only and unfiltered: non-FontStrings and /// same-value writes cost one `Vec` push here and one staleness probe at the next sweep, /// which is the price of keeping every enrolled site a one-liner. pub(crate) fn touch_measure(&mut self, rh: RegionHandle) { if let Some(list) = &mut self.measure_dirty { list.push(rh); } } /// A write that moves measure keys WITHOUT being able to name the regions — a font-object /// edit fanning out to every inheriting region, a scale change propagating down a subtree, /// [`super::UiScript::invalidate_text_measures`]. The next sweep walks the whole roster, /// exactly as every sweep did before the ledger existed. All human-rate paths. pub(crate) fn touch_measure_all(&mut self) { self.measure_dirty = None; } /// 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.give_up_naming(), } } } /// [`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.give_up_naming(), } } } /// 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.give_up_naming(), Some(list) => list.push(id), } self.bump_layout_epoch(); } /// Give up naming what this write moved: the cached graph can no longer be trusted, so the /// next resolve derives it in full. **Every** site that sets [`Self::layout_touched`] to /// `None` goes through here, because that transition is the only event worth a name. /// /// `WOW_LAYOUT_DERIVE_TRACE=:` — backtrace the first `n` of them after `secs`. 1388 /// left `layout_derivations` as "the counter to watch", and a non-zero reading as the proof /// that "a write site somewhere fell back to the conservative touch" — but gave nobody a way /// to find out WHICH, and `WOW_LAYOUT_TOUCH_TRACE` cannot answer it: it fires on every touch, /// and the precise ones outnumber the poisoning one by hundreds to one. Only the Some → None /// EDGE is printed: once a frame has given up, the fallbacks behind it are consequences, not /// causes, and printing them buries the one line that matters. fn give_up_naming(&mut self) { if self.layout_touched.is_some() && derive_trace_armed() { eprintln!( "[layout-derive] the ledger gave up naming, at:\n{}", std::backtrace::Backtrace::force_capture() ); } self.layout_touched = None; } /// 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=` — 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 `:`: 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> = 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() ); } } } } /// `WOW_LAYOUT_DERIVE_TRACE=:`'s latch — arm after `secs` (past UI load, whose thousands /// of legitimate births are all conservative by construction and would eat the budget), then allow /// `n` prints. Same spec shape as `WOW_LAYOUT_TOUCH_TRACE`, deliberately: one thing to remember. fn derive_trace_armed() -> bool { use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::OnceLock; static SPEC: OnceLock> = OnceLock::new(); let Some((t0, delay, left)) = SPEC.get_or_init(|| { let v = std::env::var("WOW_LAYOUT_DERIVE_TRACE").ok()?; let (secs, n) = v.split_once(':')?; Some(( std::time::Instant::now(), secs.trim().parse().ok()?, AtomicU32::new(n.trim().parse().ok()?), )) }) else { return false; }; t0.elapsed().as_secs_f64() >= *delay && left .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1)) .is_ok() }