- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178) - GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175) - SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176) - a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176) - a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2513 lines
159 KiB
Rust
2513 lines
159 KiB
Rust
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<dyn Fn(&str) -> bool>;
|
||
|
||
/// The host's texture **texel-size** oracle — see [`Model::texture_size_probe`].
|
||
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
|
||
|
||
/// The host's font-path resolvability oracle — see [`Model::font_probe`].
|
||
pub type FontProbe = Box<dyn Fn(&str) -> 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<T>(Vec<Option<T>>);
|
||
|
||
impl<T> Default for IdMap<T> {
|
||
fn default() -> Self {
|
||
Self(Vec::new())
|
||
}
|
||
}
|
||
|
||
impl<T: Copy> IdMap<T> {
|
||
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<T> {
|
||
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<T> {
|
||
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<super::addon::AddOnInfo>,
|
||
/// **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<usize>,
|
||
/// 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<Vec<String>>,
|
||
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
|
||
pub(crate) addons_root: Option<std::path::PathBuf>,
|
||
/// The host's 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<super::addon::SharedChainReader>,
|
||
/// The host's font engine ([`super::UiScript::set_text_measurer`]) — what lets a metric read
|
||
/// answer inside the Lua call that asked, instead of a frame later. `None` in every VM without
|
||
/// a font atlas, which is the measure round-trip's own world and behaves exactly as it did.
|
||
pub(crate) measurer: Option<Box<dyn super::TextMeasure>>,
|
||
/// The host's texture-path oracle ([`super::UiScript::set_texture_probe`]): does this sprite
|
||
/// reference resolve to a file — patch chain or loose addon folder? What lets the path form of
|
||
/// `SetTexture` return the reference's **1 | nil** load verdict inline (wow-re
|
||
/// `widget-api-batch-benilla.md` Q1 — Atlas branches on it to pick its map art). `None` in an
|
||
/// engine-less VM (tests, the addon harness), where the path form keeps answering nil: no
|
||
/// backend, nothing loads — which is also exactly what those tests always saw.
|
||
pub(crate) texture_probe: Option<TextureProbe>,
|
||
/// 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 `<AbsDimension>`
|
||
/// 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<TextureSizeProbe>,
|
||
/// 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<FontProbe>,
|
||
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
|
||
/// Both are directories holding one `<Addon>.lua` per declaring addon.
|
||
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
|
||
pub(crate) addons_saved_character: Option<std::path::PathBuf>,
|
||
/// The FrameXML **template registry**, persisted across [`crate::loader::load`] calls — the
|
||
/// client's template table is global (`0x6ee500`, rf24), so a file may `inherits=` a template
|
||
/// an *earlier file* registered (the real MerchantFrame.xml inherits
|
||
/// CharacterFrameTemplates.xml's tab template). Register-before-use in load order, exactly
|
||
/// the client's rule; a per-document registry silently dropped every cross-file inherit.
|
||
///
|
||
/// **Here rather than on `UiScript`** so the loader can run from a bare `&Lua`: that is what
|
||
/// lets `LoadAddOn` load an addon from inside a Lua binding, synchronously, the way the
|
||
/// reference does (1188 phase 2). `font_objects` below was already here, and these two are
|
||
/// the same kind of thing — VM-global registries the loader fills.
|
||
pub(crate) framexml_templates:
|
||
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
|
||
/// **`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<i32>,
|
||
/// The FrameXML **font-element registry** (a separate namespace — a font inherits a font,
|
||
/// never a frame template), persisted for the same cross-file reason.
|
||
pub(crate) framexml_fonts:
|
||
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
|
||
/// The frame arena (create/destroy + show/hide/strata/level/scale/alpha propagation).
|
||
pub(crate) arena: WidgetArena,
|
||
/// Per-frame layout input (anchors/size/scale). Every live frame has one (created at
|
||
/// `CreateFrame`); `SetPoint`/`SetWidth`/… mutate it; `resolve` runs the graph over them.
|
||
pub(crate) layout_inputs: HashMap<FrameHandle, LayoutInput>,
|
||
/// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`.
|
||
pub(crate) resolved: HashMap<FrameHandle, Rect>,
|
||
/// The anchor-graph solver, kept alive across resolves so its per-handle arrays and the
|
||
/// per-frame anchor `Vec`s are reused — a steady round allocates nothing (see
|
||
/// [`LayoutSolver`]). Scratch only: it holds no state between rounds that `begin` doesn't
|
||
/// clear, and `resolved`/`region_resolved` remain the model's own answer.
|
||
pub(crate) solver: LayoutSolver,
|
||
/// The fingerprint of the input set the last CONVERGED resolve ran against — the change gate
|
||
/// (see `script::layout::InputFingerprint`). `None` forces the next resolve to run: the
|
||
/// initial state, and what a non-converging (cyclic) pass leaves behind so its progress can
|
||
/// carry into the next frame.
|
||
pub(crate) layout_fingerprint: Option<super::layout::InputFingerprint>,
|
||
/// The layout mutation epoch — tier 1 of the resolve change gate (the fingerprint is tier 2).
|
||
/// Bumped by [`Model::touch_layout`] at every write path that can move the anchor solve's
|
||
/// read set; `resolve` returns immediately while it still equals [`Self::layout_epoch_resolved`],
|
||
/// paying a `u64` compare instead of fingerprinting ~2k inputs. Tier 2 remains the correctness
|
||
/// backstop for a write that bumps this without moving anything; it is not a licence to bump
|
||
/// per frame, because *reaching* it costs the whole-UI hash. The bag-hover re-enter loop used
|
||
/// to do exactly that (~0.65 ms/frame at solves=0) until its wrap-pin round trip was made
|
||
/// change-gated — `tooltip::append_line`, and the gate
|
||
/// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
|
||
pub(crate) layout_epoch: u64,
|
||
/// [`Self::layout_epoch`]'s **precise** form: the nodes a write actually NAMED since the last
|
||
/// converged resolve — tier 1 promoted from "something moved" to "*these* moved" (decision
|
||
/// 1388).
|
||
///
|
||
/// `Some(nodes)` carries a second, stronger claim than its contents: **every** write since the
|
||
/// cached graph was built named its node *and* left the graph's SHAPE alone, so
|
||
/// [`super::layout::LayoutScope`]'s roster, edges and per-node hashes still describe the live
|
||
/// model and a resolve may seed the dirty closure straight from this list. `None` is the
|
||
/// conservative state — a write that could not name its node, or one that moved the roster or
|
||
/// an anchor's TARGET — and forces the next resolve to derive the graph from scratch, which is
|
||
/// exactly what every resolve did before 1388.
|
||
///
|
||
/// That asymmetry is the safety argument. A site that keeps calling plain
|
||
/// [`Model::touch_layout`] is *correct by construction* (it lands in `None`, i.e. today's
|
||
/// behaviour); only a site that opts into [`Model::touch_layout_region`] /
|
||
/// [`Model::touch_layout_frame`] can be wrong, and `WOW_LAYOUT_VERIFY` re-resolves every
|
||
/// incremental pass in full scope and compares rects, so being wrong fails a test rather than
|
||
/// shipping a stale one.
|
||
/// The list holds **layout ids**, not handles: the touch sites below already have to prove the
|
||
/// node is in the cached roster before they may name it, and that proof is a `node_of[id]`
|
||
/// probe — which hands back the roster row, and with it the handle, for free at resolve time.
|
||
pub(crate) layout_touched: Option<Vec<u32>>,
|
||
/// 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<Vec<crate::widget::RegionHandle>>,
|
||
/// 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<crate::widget::FrameHandle, (u64, u64)>,
|
||
/// 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<u64>,
|
||
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
|
||
/// unconditionally every frame, so the gap between call count and this is the gate's whole
|
||
/// value; it is also what lets a test assert that a no-op resolve really was one, rather than
|
||
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
|
||
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
|
||
pub(crate) layout_solves: u64,
|
||
/// How many times a resolve got **past tier 1** — the count of whole-roster preamble WALKS
|
||
/// (decision 1385). This, not [`Self::layout_solves`], is the honest cost counter for the
|
||
/// gate: a walk that ends in `gate_skips` still rebuilt the ids/plan, re-synced every frame's
|
||
/// scale and re-hashed all ~10k anchored regions to conclude "nothing moved" — ~1.0 ms at the
|
||
/// Stormwind pin — and `layout_solves` cannot see it, because it counts only the walks that
|
||
/// went on to solve.
|
||
///
|
||
/// The castbar pin is why it exists: one moving spark cost **three** walks per frame and only
|
||
/// two of them were solves, so the counter the tests asserted on under-reported the bug by a
|
||
/// third. `resolve_bench::a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui`
|
||
/// is the guard that reads it.
|
||
///
|
||
/// **Unlike [`Self::layout_solves`], this is NOT verify-independent.** Under
|
||
/// `layout_verify_enabled` a tier-1-clean resolve falls through and walks anyway, so a verify
|
||
/// build counts walks production never pays — which is the honest reading (the walk happened)
|
||
/// but makes the number useless as an assertion there. Assert on it only from a crate that
|
||
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
|
||
/// production's; inside this crate's own tests, assert on `layout_solves`.
|
||
pub(crate) layout_gate_walks: u64,
|
||
/// How many times a resolve **derived the layout graph** — the whole-roster walk that rebuilds
|
||
/// the frame/region roster, the reverse edges and every per-node hash (decision 1388).
|
||
///
|
||
/// This is the cost counter [`Self::layout_gate_walks`] used to be. 1385 got a moving castbar
|
||
/// spark down from three walks per frame to one; 1388 made that one walk *cheap* by seeding the
|
||
/// dirty closure from a ledger of named nodes instead of rediscovering it, so "walks" no longer
|
||
/// separates the 1.48 ms case from the 20 µs one and this does. A UI that animates anything
|
||
/// should hold this at **zero** frame after frame — a non-zero steady-state reading means some
|
||
/// write site is falling back to the conservative `touch_layout`, and that is the regression.
|
||
///
|
||
/// Verify-independent in the direction that matters: the `WOW_LAYOUT_VERIFY` re-run's
|
||
/// derivation is counted, but `resolve_layout` restores the meter afterwards, so a test reads
|
||
/// the same number in both modes.
|
||
pub(crate) layout_derives: u64,
|
||
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
|
||
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
|
||
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
|
||
/// axis that used to be "the whole UI" and is the one a growing UI silently inflates.
|
||
pub(crate) layout_last_scope: (usize, usize),
|
||
/// How many fixpoint ROUNDS those solves ran in total. A solve's cost is rounds × the whole
|
||
/// graph, so this is what separates "the gate let too much through" from "each pass is doing
|
||
/// too much" — the two have different fixes.
|
||
pub(crate) layout_rounds: u64,
|
||
/// Hyperlink spans per frame — `(rect, link payload, full |H…|h markup)`, rects in the
|
||
/// engine's y-up screen space. Fed by the app each frame after it rasterizes message lines
|
||
/// ([`super::UiScript::set_link_spans`]); consumed by the pointer's release dispatch
|
||
/// (`OnHyperlinkClick` — decision 0288 P2).
|
||
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
|
||
/// Tab was pressed in the chat edit box since the last drain (`BenillaChatTabPressed` →
|
||
/// [`super::UiScript::take_chat_tab`]) — the app's whisper-target cycle reads it.
|
||
pub(crate) chat_tab: bool,
|
||
/// Region visuals (texture path/color/text) + region layout (anchors/size/justify).
|
||
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
|
||
/// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `<Backdrop>` or
|
||
/// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data`
|
||
/// (the arena models structure, not paint). The client's `frame+0x1ac` pointer.
|
||
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Backdrop>,
|
||
/// Per-`SimpleHTML` widget state (decision-free transcription of `CSimpleHTML`'s own members):
|
||
/// the four element fonts `+0x350`, the hyperlink format `+0x360`, and the CONTENTNODE list
|
||
/// `+0x340` of blocks the last `SetText` built. Stored beside the arena, like `backdrops`,
|
||
/// because its contents are script-layer types and `widget::kinds` is the layer below.
|
||
pub(crate) simple_html: simplehtml::SimpleHtmlStates,
|
||
/// The named virtual **Font object** registry (`<Font name=…>` → resolved paint), keyed by name.
|
||
/// Populated by the loader as it walks top-level `<Font>` nodes; read by `SetFontObject` and by
|
||
/// FontString `inherits=` resolution. Data only — no Lua handles (MAXCSTACK discipline).
|
||
/// The named `<Font>` objects, **keyed by the ASCII-LOWERCASED name**.
|
||
///
|
||
/// 1.12's font registry hashes the name and compares keys with `SStrCmpI` — case-INSENSITIVE
|
||
/// (`0x783870`/`0x7838c7`, wow-re `system/ui/scratch/font-object-lua-surface.md`: *"Font names
|
||
/// are matched case-insensitively"*), and a name string handed to `SetFontObject` folds the
|
||
/// same way. `Recap/RecapOptions.xml:32` inherits `GameFontHighLightSmall` — the shipped font
|
||
/// is `GameFontHighlightSmall`, one letter's case apart — and on the real client that resolves.
|
||
///
|
||
/// Read it through [`Model::font_object`], never directly: that is where the fold lives, and
|
||
/// the name says so because nothing else guards the invariant (1247's shape).
|
||
pub(crate) font_objects_by_lower: HashMap<String, FontObject>,
|
||
/// The last [`UiScript::resolve`] result for **anchored** regions (those with a non-empty
|
||
/// [`RegionData::anchors`]): the region's own resolved rect, owner-relative (see [`extract`]).
|
||
/// Regions with no anchors are absent here — they fall to the size-centered / fill-owner path.
|
||
pub(crate) region_resolved: HashMap<RegionHandle, Rect>,
|
||
|
||
/// Monotonic id source (starts at 1; `0` is [`SCREEN`]).
|
||
pub(crate) next_id: u32,
|
||
pub(crate) id_to_frame: IdMap<FrameHandle>,
|
||
pub(crate) frame_to_id: HashMap<FrameHandle, u32>,
|
||
pub(crate) id_to_region: IdMap<RegionHandle>,
|
||
pub(crate) region_to_id: HashMap<RegionHandle, u32>,
|
||
/// Region name → layout id — the region twin of the arena's frame-name publish (same
|
||
/// non-overwriting first-wins rule). `SetPoint`'s string `relativeTo` resolves through frames
|
||
/// first, then here — the real client anchors regions to *sibling regions* by name everywhere
|
||
/// (e.g. the merchant label plate to its `$parentSlot` texture), which owner-fallback silently
|
||
/// mis-anchored before (the jutting-plates bug the director's A/B caught).
|
||
pub(crate) region_names: HashMap<String, u32>,
|
||
|
||
/// Which handler kinds each frame has a script for (presence mirror; the closures live Lua-side
|
||
/// in the `REG_SCRIPTS` table). Lets `tick` find OnUpdate frames without scanning Lua.
|
||
pub(crate) scripts: HashMap<FrameHandle, HashSet<&'static str>>,
|
||
/// 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<FrameHandle>,
|
||
/// 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<FrameHandle>,
|
||
/// 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<FrameHandle>,
|
||
/// 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<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
|
||
/// 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<String>,
|
||
/// 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<FrameHandle>,
|
||
/// `event name → frames registered for it` (RegisterEvent), in **registration order** — an
|
||
/// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener
|
||
/// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line
|
||
/// bug: two ZoneText frames write the same FontString on one event — last writer decides).
|
||
/// Re-registering is a no-op (position kept); Unregister removes. Data only — no Lua handles.
|
||
pub(crate) event_to_frames: HashMap<String, Vec<FrameHandle>>,
|
||
/// `frame → its registered events` (for UnregisterEvent / cleanup).
|
||
pub(crate) frame_events: HashMap<FrameHandle, HashSet<String>>,
|
||
/// The 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<FrameHandle>,
|
||
|
||
/// The EditBox that currently owns keyboard focus — the engine's twin of the client's
|
||
/// class-owned focus global `DAT_00cf4dc8` (`CSimpleEditBox* E`, 0 = none; RF-0082 §1). A focused
|
||
/// box consumes every key/char; `None` lets an `autoFocus` box self-acquire on the first event.
|
||
/// Gated on effective-visibility at read time (a box hidden while focused stops taking input).
|
||
pub(crate) focused_editbox: Option<FrameHandle>,
|
||
|
||
/// The frame currently under the cursor (the last [`UiScript::mouse_move`] capture), so the next
|
||
/// move knows whether to fire `OnLeave`/`OnEnter`. `None` = cursor over no mouse-enabled frame.
|
||
pub(crate) mouseover: Option<FrameHandle>,
|
||
/// 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<String, FrameHandle>,
|
||
/// 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<FrameHandle>,
|
||
/// Per **frame**, the [`UiScript::now`] second at which its last single `OnClick` fired — the
|
||
/// double-click detector's entire state, and the faithful stand-in for the client's per-widget
|
||
/// timestamp `[CButton+0x334]` (wow-re `ui/scratch/button-doubleclick-law.md`).
|
||
///
|
||
/// Three properties of that field are load-bearing, and are why this is keyed the way it is.
|
||
/// It lives on the **widget**, so two frames can never pair with each other. It carries **no
|
||
/// button identity** — the detector is button-agnostic, so a left release followed by a right
|
||
/// one on a button registered for both completes a double click (with `arg1` = the *second*
|
||
/// button); what normally confines double-clicks to the left button is `RegisterForClicks`'
|
||
/// `{"LeftButtonUp"}` default, not the detector. And it is written only when a single click
|
||
/// actually fires, then **zeroed** when a double completes — which is what makes four rapid
|
||
/// clicks read `Click · DoubleClick · Click · DoubleClick` rather than one click and three
|
||
/// doubles. Nothing else clears it: not hide, not disable, not the cursor leaving the frame
|
||
/// (`[+0x334]` has exactly three writers binary-wide — ctor, the fired-double zero, and the
|
||
/// fired-single stamp).
|
||
pub(crate) last_click: HashMap<FrameHandle, f64>,
|
||
|
||
/// Frames whose resolved SIZE moved in the last [`UiScript::resolve_layout`], as
|
||
/// `(frame id, width, height)` — queued there (it holds only a `&mut Model`, so it cannot call
|
||
/// Lua) and drained by [`super::event::fire_size_changes`] at the next `&Lua` seam, which turns
|
||
/// each into `OnSizeChanged(self, width, height)`. Same compute-under-borrow / fire-outside
|
||
/// shape as the pointer path's `OnValueChanged`/`OnDragStart` hand-offs.
|
||
pub(crate) pending_size_changed: Vec<(u32, f32, f32)>,
|
||
|
||
/// Script errors collected from `pcall`'d handlers (never panics, never prints — decision 0068).
|
||
pub(crate) errors: Vec<String>,
|
||
/// Script errors awaiting dispatch to the **Lua-side** error handler (decision 1305) — the
|
||
/// reference calls `geterrorhandler()`'s function on every caught script error, which is how
|
||
/// FrameXML's `_ERRORMESSAGE` puts the red ScriptErrors dialog on the player's screen. Every
|
||
/// message recorded through [`Model::record_script_error`] lands here as well as in `errors`;
|
||
/// [`super::UiScript::dispatch_script_errors_to_handler`] drains it at a safe seam (never from
|
||
/// inside the failed call). A message produced *by* the handler itself goes to `errors` only —
|
||
/// that asymmetry is the recursion guard.
|
||
pub(crate) pending_error_dispatch: Vec<String>,
|
||
/// 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<String>,
|
||
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
|
||
pub(crate) screen: Rect,
|
||
|
||
/// The per-unit-token game-state snapshot the app pushes in each frame (`"player"`, `"target"`,
|
||
/// …), read by the `Unit*` Lua bindings ([`unit`]). Plain data — the engine never touches the
|
||
/// ECS/net; the app's feed writes here via [`UiScript::set_unit`] (decision 0068 §3). A token
|
||
/// absent from the map is a non-existent unit (`UnitExists` false, numbers `0`/nil).
|
||
///
|
||
/// **Keyed by the ASCII-LOWERCASED token, and the name says so because the invariant has no
|
||
/// other guard.** 1.12's shared token resolver `0x515970` compares every literal with
|
||
/// `SStrCmpI` → `_strnicmp`, which folds `'A'..'Z'` by `+0x20`; not one of its ten compares
|
||
/// reaches the case-sensitive sibling. So `"Player"`, `"PLAYER"` and `"player"` are the same
|
||
/// unit on the real client, and ~10 corpus addons rely on it — `Accountant.lua:107`'s
|
||
/// `UnitFactionGroup("Player")` is the one that cost an addon its session.
|
||
///
|
||
/// Read it through [`Model::unit`], never directly: that is where the fold lives. The field was
|
||
/// called `units` until the fold landed, and it was renamed precisely so that every existing
|
||
/// reader had to come through here rather than be trusted to remember (wow-re
|
||
/// `system/ui/scratch/unit-token-grammar.md`).
|
||
pub(crate) units_by_lower: HashMap<String, UnitState>,
|
||
/// Per-unit-token aura list, **in display order**, pushed by the app's aura feed each frame and
|
||
/// read by the `UnitAura` family ([`super::aura`]). The order is the app's decision, not the
|
||
/// engine's: the local player's is a maintained insertion-order cache, every other unit's is
|
||
/// ascending aura slot (decision 0257). A token absent here has no auras.
|
||
pub(crate) auras: HashMap<String, Vec<AuraState>>,
|
||
/// Spell ids `CancelUnitBuff` queued since the app's last drain — one `CMSG_CANCEL_AURA` each.
|
||
pub(crate) cancel_aura_requests: Vec<u32>,
|
||
/// The player's active tracking aura (the reference's `GetTrackingTexture` global — see
|
||
/// [`super::aura::TrackingState`]), pushed by the app's aura feed beside `auras`. `None` =
|
||
/// no tracking active.
|
||
pub(crate) tracking: Option<super::aura::TrackingState>,
|
||
/// 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<super::SelectionRequest>,
|
||
/// `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<bool>,
|
||
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
|
||
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
|
||
/// `Self::target_requests`, which takes unit tokens. The app runs the shared by-name
|
||
/// resolver (`crate::target::by_name`, decision 0886) and commits the selection ([`unit`]).
|
||
pub(crate) target_by_name_requests: Vec<(String, bool)>,
|
||
/// Set when `ClearTarget()` fired with a live target — the ESC chain's LAST leg
|
||
/// (`ToggleGameMenu`'s order: the clear runs only when nothing earlier ate the press).
|
||
/// Drained by [`super::UiScript::take_target_clear`]; the app commits the deselect ([`unit`]).
|
||
pub(crate) target_clear: bool,
|
||
/// Unit tokens `DropItemOnUnit` queued since the app's last
|
||
/// [`UiScript::take_drop_item_on_unit`] drain — the cursor's held item being dropped onto a
|
||
/// unit (`0x48d960`). The binding queues the token only; **every gate is the app's**, because
|
||
/// all of them read state the VM does not hold (the pet's owner fields, the learned Feed-Pet
|
||
/// spell, the held item's guid). A token the app refuses is silent and keeps the payload,
|
||
/// which is the reference's own behaviour ([`super::cursor`]).
|
||
pub(crate) drop_item_on_unit: Vec<String>,
|
||
|
||
/// The party/raid roster snapshot the app pushes (`GroupState`'s merged view, decision 0434
|
||
/// §2) — `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/… read it ([`party`]).
|
||
/// `PartyState::default()` = not in a group (every getter reports the solo-player shape).
|
||
/// Per-member game state rides the `units` map above, under `"party1"`..`"party4"` tokens.
|
||
/// The channels this session has CONFIRMED joining, in join order — the client-side number
|
||
/// law `GetChannelName` answers with ([`super::channel`]). Mirrored from `ui_chat`'s
|
||
/// `ChannelState` by `set_joined_channels`; empty until the server's first YOU_JOINED.
|
||
pub(crate) joined_channels: Vec<Option<String>>,
|
||
pub(crate) party: party::PartyState,
|
||
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
|
||
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
|
||
pub(crate) party_requests: Vec<party::PartyRequest>,
|
||
/// The 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<party::SavedInstanceInfo>,
|
||
/// `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<u32>,
|
||
/// `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<social::SocialRequest>,
|
||
/// 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<guild::GuildRequest>,
|
||
/// 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<petition::PetitionRequest>,
|
||
/// Whisper targets `ChatFrame_SendTell` queued since the app's last
|
||
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
|
||
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
|
||
pub(crate) tell_requests: Vec<String>,
|
||
/// Prefill texts `ChatFrame_OpenChat` queued since the app's last
|
||
/// [`super::UiScript::take_open_chat_requests`] drain — `tell_requests`' sibling, one step
|
||
/// less resolved: a name there, a whole draft line here ([`chat_window`] registers the global).
|
||
pub(crate) open_chat_requests: Vec<String>,
|
||
/// 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<usize>,
|
||
/// 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<super::chat_types::ChatTypeColor>,
|
||
/// 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<String>,
|
||
/// The zone-channel rows for the current zone ([`super::UiScript::set_zone_channel_catalog`]).
|
||
pub(crate) zone_channel_catalog: Vec<super::channel::ZoneChannelRow>,
|
||
/// Channel verbs since the last [`super::UiScript::take_channel_commands`] drain.
|
||
pub(crate) channel_commands: Vec<super::channel::ChannelCommand>,
|
||
/// **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<super::chat_misc::EmoteRequest>,
|
||
/// `RandomRoll` calls since the last drain.
|
||
pub(crate) roll_requests: Vec<(u32, u32)>,
|
||
/// `UninviteByName` calls since the last drain.
|
||
pub(crate) uninvite_requests: Vec<String>,
|
||
/// `ConsoleExec` lines that were not CVar writes.
|
||
pub(crate) console_lines: Vec<String>,
|
||
/// `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<String>,
|
||
/// Duel intents (`AcceptDuel`/`CancelDuel`/`StartDuel*`) queued since the app's last
|
||
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
|
||
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
|
||
pub(crate) duel_requests: Vec<duel::DuelRequest>,
|
||
/// Follow intents (`FollowUnit`/`FollowByName`) queued since the app's last
|
||
/// [`super::UiScript::take_follow_requests`] drain — the outbound seam ([`follow`]). The duel
|
||
/// queue's twin, snapshot-less for the same reason: the only thing the UI reads back is the
|
||
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` pair the app fires, which carries its own argument.
|
||
pub(crate) follow_requests: Vec<follow::FollowRequest>,
|
||
/// 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<camera_view::CameraViewRequest>,
|
||
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
|
||
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
|
||
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
|
||
/// arrives as the `PLAYER_CAMPING`/`PLAYER_QUITING`/`LOGOUT_CANCEL` events, not as state.
|
||
pub(crate) session_requests: Vec<session::SessionRequest>,
|
||
/// PvP-flag toggles (`TogglePVP`) queued since the app's last
|
||
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
|
||
/// payload: `CMSG_TOGGLE_PVP` carries no body.
|
||
pub(crate) pvp_toggles: u32,
|
||
/// 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<pvp::HonorState>,
|
||
/// 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<pvp::InspectHonorData>,
|
||
/// `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<super::worn_display::WornDisplay>,
|
||
|
||
/// 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<u8>,
|
||
/// `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<u8>,
|
||
|
||
/// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last
|
||
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
|
||
pub(crate) sound_queue: Vec<SoundRequest>,
|
||
|
||
/// **The 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<String, super::cvars::CvarSlot>,
|
||
/// The persisted values registration honors (decision 1291): lowercase name → the config
|
||
/// file's value, set by the host **before** any registration. A CVar registered while its
|
||
/// name is in here — host-registered or an addon's `RegisterCVar` — starts at the saved
|
||
/// value, not the default. This is what makes a knobless CVar (`statusBarText`) and an
|
||
/// addon-declared one survive the VM being replaced: in the reference the table is engine
|
||
/// memory and outlives every `ReloadUI`; ours is per-VM, so the file value is the bridge.
|
||
pub(crate) cvars_saved_base: HashMap<String, String>,
|
||
/// `(registered name, new value)` per Lua `SetCVar` since the app's last
|
||
/// [`super::UiScript::take_cvar_changes`] drain — the knob-sync + config-dirty cue.
|
||
pub(crate) cvar_changes: Vec<(String, String)>,
|
||
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
|
||
pub(crate) cvars_warned: HashSet<String>,
|
||
|
||
/// The 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<super::cvars::MultisampleFormat>,
|
||
|
||
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
|
||
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
|
||
pub(crate) saved_names: Vec<String>,
|
||
|
||
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
|
||
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
|
||
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
|
||
/// [`super::UiScript::keybinds_generation`] moves and persists on the queued save requests.
|
||
pub(crate) keybinds: super::keybind::KeybindState,
|
||
|
||
/// The action-slot snapshot (keyed by Lua action id 1..120) + the stance page offset the app
|
||
/// pushes, and the `UseAction` intents it drains — the action seam ([`action`]).
|
||
pub(crate) actions: HashMap<u32, ActionSlot>,
|
||
/// The per-action **dynamic** state (usable/range/current/cooldown — decision 0137 phase 4),
|
||
/// pushed beside `actions` by the app's feed; the `IsUsableAction`/`GetActionCooldown` family
|
||
/// reads it ([`action`]). Keyed like `actions`; an absent action reads cold/nil.
|
||
pub(crate) action_states: HashMap<u32, super::action::StoredActionState>,
|
||
pub(crate) bonus_bar_offset: u8,
|
||
pub(crate) action_uses: Vec<super::action::ActionUse>,
|
||
/// `(lua action id, packed)` pairs queued by `PickupAction`/`PlaceAction` (decision 0216 §7,
|
||
/// slice 4) — one entry per local slot mutation, `packed == 0` clearing the slot. Drained by
|
||
/// the app into `CMSG_SET_ACTION_BUTTON`, one send per entry (client-authoritative, per-change
|
||
/// — 0218 §4: a drag-swap is two sends, never atomic).
|
||
pub(crate) action_sets: Vec<(u32, u32)>,
|
||
/// GlobalStrings keys for **client-local refusals the ENGINE raises** — the engine-free half
|
||
/// of the app's `ui_action::UiErrorKeys`, which cannot reach in here. The reference does this
|
||
/// inline (`push <errorId>; call CGGameUI::DisplayError 0x496720`) because its engine owns
|
||
/// both the refusal and the message; ours splits at the crate boundary, so the refusal queues
|
||
/// its key and the app's action feed resolves it against the VM's own GlobalStrings and fires
|
||
/// `UI_ERROR_MESSAGE`. Always `&'static str`: every tenant is a literal read out of the ref's
|
||
/// errorId table (`0xb4b498`, stride `0x14`), never runtime-built.
|
||
pub(crate) ui_errors: Vec<&'static str>,
|
||
|
||
/// The player's known-spell book (decision 0216 §8, slice 5) — tabs + the flat slot list the
|
||
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
|
||
/// `actions` above, never `Option` — "no known spells yet" is simply empty vectors.
|
||
pub(crate) spellbook: spellbook::SpellBookState,
|
||
/// The **pet's** book (decision 1032) — a second flat slot list, no tabs, with its own
|
||
/// add-gate and its own class token ([`spellbook::PetBookState`]). Held apart from
|
||
/// [`Self::spellbook`] because the reference holds two arrays too (`0xb700f0` / `0xb6f098`)
|
||
/// and every `bookType`-taking binding is a fork between them, never a filter over one.
|
||
pub(crate) pet_book: spellbook::PetBookState,
|
||
/// The player's **macros** (decision 0983) — the one game-state table this crate owns
|
||
/// outright, because 1.12 macros have no server side at all ([`macros`]'s module docs). The
|
||
/// app seeds it from `benilla-config/macros/…` and reads it back to persist.
|
||
pub(crate) macros: macros::MacroState,
|
||
/// A script mutated [`Self::macros`] since the app's last
|
||
/// [`super::UiScript::take_macros_dirty`] drain — the save + `UPDATE_MACROS` trigger.
|
||
pub(crate) macros_dirty: bool,
|
||
/// Bumped by every seed and every mutation — the *undrained* change signal per-frame
|
||
/// consumers gate on ([`super::UiScript::macros_generation`]), so the drained
|
||
/// [`Self::macros_dirty`] edge keeps its single owner.
|
||
pub(crate) macros_generation: u64,
|
||
/// The macro-icon chooser list (full texture paths, app-built off `SpellIcon.dbc`) —
|
||
/// `GetNumMacroIcons`/`GetMacroIconInfo`.
|
||
pub(crate) macro_icons: Vec<String>,
|
||
/// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`]
|
||
/// drain.
|
||
pub(crate) spell_casts: Vec<u32>,
|
||
/// Pet spell ids `CastSpell(id, "pet")` queued — a separate list because the wire verb is a
|
||
/// separate opcode (`CMSG_PET_ACTION` with a synthesized type-1 word, `0x4b34ce`).
|
||
pub(crate) pet_spell_casts: Vec<u32>,
|
||
/// Pet spell ids `ToggleSpellAutocast` queued — `CMSG_PET_SPELL_AUTOCAST 0x2F3`, which names a
|
||
/// spell rather than the pet bar's slot ([`Self::pet_autocast_toggles`] is the bar's).
|
||
pub(crate) pet_spell_autocasts: Vec<u32>,
|
||
/// Whether the app's own cast lifecycle holds something `SpellStopCasting()` can stop — a
|
||
/// running auto-repeat or an in-flight cast; a channel is NOT stoppable there (wow-re
|
||
/// `esc-stopcasting.md`; pushed each frame by the app's cast feed,
|
||
/// [`super::UiScript::set_casting`]). The 1/nil return is load-bearing:
|
||
/// `ToggleGameMenu`'s ESC chain (`UIParent.lua:1489`) only falls through to
|
||
/// `CloseAllWindows()` on nil.
|
||
pub(crate) casting: bool,
|
||
/// Set when `SpellStopCasting()` fired while [`Self::casting`] — the ESC local-cancel
|
||
/// trigger, drained by [`super::UiScript::take_spell_stop`] ([`spellbook`]).
|
||
pub(crate) spell_stop: bool,
|
||
/// Whether the app's spell-targeting cursor mode is active — the `flag_word != 0` mirror
|
||
/// (`SpellIsTargeting 0x6e6cd0`, decision 0792). Pushed each frame by the app's targeting
|
||
/// feed ([`super::UiScript::set_spell_targeting`]); read by `SpellIsTargeting()` and gating
|
||
/// `SpellStopTargeting()`, whose 1/nil return the ESC chain's rung (`UIParent.lua:1490`)
|
||
/// falls through on, exactly like [`Self::casting`]'s.
|
||
pub(crate) spell_targeting: bool,
|
||
/// Whether the standing targeting word could bind a UNIT — what `SpellCanTargetUnit` answers
|
||
/// (`0x6e6d00` → `0x6e6460`'s unit leg). Pushed by the app beside `spell_targeting`.
|
||
///
|
||
/// Always `false` today, and *derived* rather than hardcoded so it stops being false the moment
|
||
/// that stops being true: benilla's targeting cursor models the location / item / gameobject
|
||
/// words (0792/0923/0939), and no unit satisfies any of them — a unit-target spell never enters
|
||
/// targeting mode at all, it resolves to `CastWireTarget::Unit` or refuses.
|
||
pub(crate) spell_can_target_unit: bool,
|
||
/// Set when `SpellStopTargeting()` fired while [`Self::spell_targeting`] — the ESC-chain
|
||
/// targeting cancel, drained by [`super::UiScript::take_stop_targeting`] ([`spellbook`]).
|
||
pub(crate) spell_stop_targeting: bool,
|
||
|
||
/// The player's talent pages (decision 0304) — tabs + per-tab talents the
|
||
/// `GetNumTalentTabs`/`GetTalentInfo`/… bindings read ([`super::talent`]). Durable player
|
||
/// state like `spellbook`; "no talents yet" is simply empty.
|
||
pub(crate) talents: super::talent::TalentUiState,
|
||
/// `LearnTalent(tab, index)` clicks queued since the app's last
|
||
/// [`super::UiScript::take_talent_learns`] drain.
|
||
pub(crate) talent_learns: Vec<(u32, u32)>,
|
||
/// `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<super::battlefield_queue::BattlefieldQueueSlot>,
|
||
/// `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<u32>,
|
||
/// 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<super::battlefield_positions::BattlefieldPositionView>,
|
||
pub(crate) battlefield_flag: Option<super::battlefield_positions::BattlefieldFlagView>,
|
||
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<String>,
|
||
/// 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<Vec<u8>>,
|
||
/// `FlagTutorial(n)` calls since the last drain (0-based, in range).
|
||
pub(crate) tutorial_flag_requests: Vec<u32>,
|
||
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<super::shapeshift::StoredShapeshiftForm>,
|
||
/// Form spell ids `CastShapeshiftForm` queued since the app's last
|
||
/// [`super::UiScript::take_shapeshift_casts`] drain.
|
||
pub(crate) shapeshift_casts: Vec<u32>,
|
||
|
||
/// The pet action bar the app pushes — the ten slots plus the two bar-wide bits the
|
||
/// `PetHasActionBar`/`GetPetActionsUsable`/`GetPetActionInfo` family reads ([`super::pet`],
|
||
/// decision 0982). Unlike the stance bar's, this state is **server-authoritative**: it is
|
||
/// replaced wholesale on every `SMSG_PET_SPELLS`, and is empty whenever there is no pet.
|
||
pub(crate) pet_bar: super::pet::PetBarState,
|
||
/// 1-based slot indices `CastPetAction` queued since the app's last
|
||
/// [`super::UiScript::take_pet_actions`] drain.
|
||
pub(crate) pet_actions_pressed: Vec<u32>,
|
||
/// 1-based slot indices `TogglePetAutocast` queued.
|
||
pub(crate) pet_autocast_toggles: Vec<u32>,
|
||
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
|
||
pub(crate) pet_stop_attacks: u32,
|
||
/// The 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<u32>,
|
||
/// `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<Vec<(u32, u32)>>,
|
||
/// `PetAbandon()` and `PetDismiss()` calls queued — two counts, not one, even though both menu
|
||
/// rows end at the same opcode (decision 1066). They are two *bindings*, with two Lua names and
|
||
/// two menu rows the reference shows to different classes, so the seam keeps them apart and
|
||
/// lets the app decide each one's wire; folding them together here would bake a wire fact into
|
||
/// an engine that is supposed to hold none.
|
||
pub(crate) pet_abandons: u32,
|
||
pub(crate) pet_dismisses: u32,
|
||
/// Names `PetRename(name)` queued, in order — the `PETRENAMECONFIRM` popup's payload.
|
||
pub(crate) pet_renames: Vec<String>,
|
||
|
||
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
|
||
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
|
||
pub(crate) containers: HashMap<i64, container::ContainerState>,
|
||
pub(crate) container_uses: Vec<(i64, u32)>,
|
||
/// Per-(bag, slot) use-cooldowns in `GetTime` seconds `(start, duration, enabled)` — stamped
|
||
/// at `set_container` push time (the action-state pattern), read by
|
||
/// `GetContainerItemCooldown`.
|
||
pub(crate) container_cooldowns: HashMap<(i64, u32), (f64, f64, bool)>,
|
||
/// `HasKey()` — whether the player owns any item of `BagFamily` KEYS, anywhere the reference's
|
||
/// own search reaches (equipment, bags, backpack, **bank**, keyring). App-resolved like every
|
||
/// other item fact; the engine holds no item knowledge of its own. Gates the whole keyring UI
|
||
/// (decision 0765).
|
||
pub(crate) has_key: bool,
|
||
/// What the cursor carries (`PickupContainerItem`/`SplitContainerItem`/… set it; `None` =
|
||
/// empty cursor) — the real client's transient drag state, typed by payload arm
|
||
/// ([`cursor::CursorPayload`], decision 0216). Purely visual + intent-routing: no item moves
|
||
/// locally, the server's field updates settle the bag ([`container`]). The app draws the
|
||
/// held icon at the mouse and reads `None`/`Some` to show/hide it.
|
||
pub(crate) cursor: Option<cursor::CursorPayload>,
|
||
/// Mirrors "is `cursor` currently `Some`" across transitions — [`cursor::queue_cursor_update`]
|
||
/// compares it against the live state on every call to derive `ACTIONBAR_SHOWGRID`/
|
||
/// `ACTIONBAR_HIDEGRID` (decision 0216 §7): fires exactly on a None↔Some edge, any payload
|
||
/// arm, any surface (bags/doll/actions alike — the reference's own "any placeable payload
|
||
/// shows the bar's drop grid"). A Some→Some transition (the action hop) updates nothing here,
|
||
/// so no spurious HIDE+SHOW churns out of one gesture.
|
||
///
|
||
/// The **pet** payload arm is excluded (decision 1010): `PlaceAction` refuses it, so lighting
|
||
/// the action bar's empty slots for a payload that cannot land there would be an invitation to
|
||
/// a no-op. It drives [`Self::pet_grid_shown`] instead.
|
||
pub(crate) cursor_grid_shown: bool,
|
||
/// The same mirror for the PET bar's grid — `PET_BAR_SHOWGRID`/`PET_BAR_HIDEGRID`, which the
|
||
/// reference fires from inside the pet-action pickup builder itself (`0x494f28`) rather than
|
||
/// from a shared cursor transition. So the two grids are disjoint: a spell lights the action
|
||
/// bar's, a pet action lights the pet bar's, and neither lights both.
|
||
pub(crate) pet_grid_shown: bool,
|
||
/// The app's world pick under the cursor — the reference's click-time pick state
|
||
/// (`[this+0x350]`: nothing / terrain / object), fed once per frame
|
||
/// ([`super::UiScript::set_world_pick`]; stays `Nothing` in tests/captures). Routes
|
||
/// [`cursor::world_drop_click`]'s legs (decisions 0571 + 0574): an `Object` pick drops no
|
||
/// payload at all, `Terrain` drops items only (a spell/action survives the ground click),
|
||
/// `Nothing` drops any arm.
|
||
pub(crate) world_pick: cursor::WorldPick,
|
||
/// Backpack pick/place/swap intents queued by `PickupContainerItem`/`SplitContainerItem` when
|
||
/// the cursor was holding (src bag/slot → dst bag/slot, live-API space, optional split count),
|
||
/// drained by the app into `CMSG_SWAP_INV_ITEM`/`CMSG_SPLIT_ITEM`.
|
||
pub(crate) container_moves: Vec<container::ContainerMove>,
|
||
/// Repair-mode clicks the pickup intercept queued (`(bag, slot)`; [`container`]).
|
||
pub(crate) container_repairs: Vec<(i64, u32)>,
|
||
/// The targeting cursor's **item** half is up (`TargetingWantsItem 0x6e6330`, decision 0923;
|
||
/// first built for the CraftFrame enchant pick, 0437 phase 3): while set, a bag OR paper-doll
|
||
/// click queues into [`Self::item_picks`] instead of running the cursor gesture.
|
||
pub(crate) item_pick_armed: bool,
|
||
/// `(bag, slot)` clicks consumed by the armed item pick, drained by the app. A doll click
|
||
/// reports as [`super::EQUIPMENT_BAG`] + its 1-based inventory slot, so both seams speak the
|
||
/// one bag space the drain already resolves.
|
||
pub(crate) item_picks: Vec<(i64, u32)>,
|
||
/// `BindEnchant()`/`ReplaceEnchant()` — the two enchant-confirm popups' answers to a pick the
|
||
/// app already parked, drained by it (decision 0928; [`cursor::EnchantConfirm`]).
|
||
pub(crate) enchant_confirms: Vec<cursor::EnchantConfirm>,
|
||
/// `(bag, slot, count)` triples queued by `DeleteCursorItem` (`count == 0` = the whole
|
||
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
|
||
/// `CMSG_DESTROYITEM`.
|
||
pub(crate) container_destroys: Vec<(i64, u32, u32)>,
|
||
/// **The 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<container::PendingWrap>,
|
||
/// `(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<container::UiCursorMode>,
|
||
/// Set by every FrameXML cursor call — `Show*SellCursor` / `ShowInspectCursor` / `SetCursor` /
|
||
/// `ResetCursor` — and drained by the app each frame ([`UiScript::take_cursor_write`]).
|
||
///
|
||
/// **The cursor mode is a WRITE, not a level** (decision 1061), and that distinction is the
|
||
/// whole of B208's regression. The reference keeps one sticky global (`0xbe2c2c`): the world
|
||
/// classifier writes it while the pointer is over the world, FrameXML writes it from a hover
|
||
/// handler, and in between **nothing** writes it — so the last value simply stands. Reading
|
||
/// `ui_cursor` as a level made "no override" mean "show the base", which turned every UI
|
||
/// element with no cursor handler at all (a spellbook button, a panel) into a forced Point and
|
||
/// killed the armed cast cursor the moment the mouse left the world.
|
||
pub(crate) ui_cursor_dirty: bool,
|
||
/// `(bag, slot)` sources queued by `AutoEquipCursorItem` (decision 0208 phase 1b, `cursor`'s
|
||
/// `doll` submodule) — drained by the app into `CMSG_AUTOEQUIP_ITEM`.
|
||
pub(crate) container_autoequips: Vec<(i64, u32)>,
|
||
/// 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<container::BagAutoStore>,
|
||
|
||
/// Per-frame drag-button registrations (`RegisterForDrag`) — kind-independent (any Frame, not
|
||
/// just a Button), so it lives beside [`Model::mouse_down_on`] rather than inside a per-kind
|
||
/// state. Verbatim button-name sets (`"LeftButton"`, …); [`UiScript::mouse_button`]/
|
||
/// [`UiScript::mouse_move`] compare case-insensitively (RF's `RegisterForClicks` precedent).
|
||
/// Same destroy-cleanup status as [`Model::scripts`]/[`Model::frame_events`]: nothing in this
|
||
/// engine destroys a frame yet (no Lua `Destroy`, no call to `WidgetArena::destroy` outside
|
||
/// its own tests), so none of the three is pruned today — a future destroy path must prune
|
||
/// all three together.
|
||
pub(crate) drag_registered: HashMap<FrameHandle, HashSet<String>>,
|
||
/// The in-flight drag gesture: armed at mouse-down on a [`Model::drag_registered`] frame,
|
||
/// `started` once the cursor has moved past the drag-start threshold — `None` between
|
||
/// gestures ([`cursor::DragGesture`], decision 0216 §3).
|
||
pub(crate) drag: Option<cursor::DragGesture>,
|
||
/// The one in-flight `StartMoving()` — the client's single root-side drag slot (`root+0xcfc`
|
||
/// and the cursor sample beside it), `None` between moves ([`super::object::FrameMove`]). Held
|
||
/// beside [`Model::drag`] because that gesture is what normally opens and closes it: the
|
||
/// canonical addon idiom is `OnDragStart → StartMoving`, `OnDragStop → StopMovingOrSizing`.
|
||
/// The two remain separate systems, exactly as in the reference — a drag can carry a payload
|
||
/// with nothing moving, and a move outlives the mouse button (the reference's mouse-up
|
||
/// auto-stop skips the Lua drag type).
|
||
pub(crate) moving: Option<super::object::FrameMove>,
|
||
/// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same
|
||
/// `StopMovingOrSizing` (`0x776990`).
|
||
pub(crate) sizing: Option<super::object::FrameSizing>,
|
||
/// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held
|
||
/// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag —
|
||
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
|
||
/// ([`slider::SliderDrag`]).
|
||
pub(crate) slider_drag: Option<slider::SliderDrag>,
|
||
/// The 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<colorselect::ColorDrag>,
|
||
|
||
/// The open gossip menu the app pushes (`None` = no menu), the `SelectGossipOption` intents it
|
||
/// drains, and whether `CloseGossip` was called — the gossip seam ([`gossip`]).
|
||
pub(crate) gossip: Option<gossip::GossipMenu>,
|
||
pub(crate) gossip_selects: Vec<u32>,
|
||
pub(crate) gossip_close: bool,
|
||
/// 1-based quest-row selects queued by `SelectGossipQuest` (decision 0088) — the app maps each
|
||
/// to the row's quest id and sends `CMSG_QUESTGIVER_QUERY_QUEST`.
|
||
pub(crate) gossip_quest_selects: Vec<u32>,
|
||
|
||
/// The open merchant's stock snapshot the app pushes (`None` = no vendor open), the
|
||
/// `BuyMerchantItem` intents it drains, and whether `CloseMerchant` was called — the merchant
|
||
/// seam ([`merchant`]).
|
||
pub(crate) merchant: Option<merchant::MerchantState>,
|
||
pub(crate) merchant_buys: Vec<(u32, u32)>,
|
||
/// `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<u32>,
|
||
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<bank::BankState>,
|
||
pub(crate) bank_purchase: bool,
|
||
pub(crate) bank_close: bool,
|
||
|
||
/// The open trainer's service snapshot the app pushes (`None` = no trainer open), the
|
||
/// `BuyTrainerService` intents it drains, the engine-held 1-based selection (0 = none), and
|
||
/// whether `CloseTrainer` was called — the trainer seam ([`trainer`], decision 0237).
|
||
pub(crate) trainer: Option<trainer::TrainerState>,
|
||
pub(crate) trainer_buys: Vec<u32>,
|
||
pub(crate) trainer_selection: u32,
|
||
pub(crate) trainer_close: bool,
|
||
/// The three state filters (available / unavailable / used) — the real client hides filtered
|
||
/// service rows itself ([`trainer`]); all shown by default. A state filter hides *services*, never
|
||
/// headers (decision 0247).
|
||
pub(crate) trainer_filter: [bool; 3],
|
||
/// The skill lines the player has collapsed in the tree — their services hide, the header stays
|
||
/// (decision 0247). Keyed by skill-line id so it survives a content update (`set_trainer` keeps
|
||
/// only still-present lines); cleared when the trainer closes.
|
||
pub(crate) trainer_collapsed: HashSet<u32>,
|
||
|
||
/// The open taxi map's snapshot the app pushes (`None` = closed), the `TakeTaxiNode` intents
|
||
/// it drains, whether `CloseTaxiMap` was called, and whether our own player is riding a
|
||
/// taxi — the taxi seam ([`taxi`], decision 0484).
|
||
pub(crate) taxi: Option<taxi::TaxiUiState>,
|
||
pub(crate) taxi_takes: Vec<usize>,
|
||
pub(crate) taxi_close: bool,
|
||
pub(crate) taxi_riding: bool,
|
||
|
||
/// The open tradeskill window's recipe snapshot the app pushes (`None` = no window open) — the
|
||
/// tradeskill seam ([`tradeskill`], decision 0437 phase 2).
|
||
pub(crate) trade_skill: Option<tradeskill::TradeSkillState>,
|
||
/// `(spell id, count)` intents `DoTradeSkill` queued since the app's last
|
||
/// [`super::UiScript::take_trade_skill_dos`] drain.
|
||
pub(crate) trade_skill_dos: Vec<(u32, u32)>,
|
||
/// The engine-held 1-based selection (0 = none), preserved across a `set_trade_skill` re-push by
|
||
/// the recipe's spell id (see [`super::UiScript::set_trade_skill`]).
|
||
pub(crate) trade_skill_selection: u32,
|
||
/// Whether `CloseTradeSkill` was called since the app's last
|
||
/// [`super::UiScript::take_trade_skill_close`] drain.
|
||
pub(crate) trade_skill_close: bool,
|
||
/// The recipe groups the player has collapsed, by group key `(ItemClass, ItemSubClass)`
|
||
/// (wow-re `tradeskill` TU-B) — a group's recipes hide, its header stays (the
|
||
/// trainer/skills-pane precedent, [`Model::trainer_collapsed`]/[`Model::skills_collapsed`]).
|
||
/// Survives a `set_trade_skill` content re-push (pruned to the groups the fresh recipes still
|
||
/// produce) AND a same-profession close→reopen (wow-re `tradeskill` TU-G §6 — the collapse
|
||
/// mask `0x84dd68` round-trips the rebuild by header key); reset on a profession switch
|
||
/// ([`Model::trade_skill_last_line`]).
|
||
pub(crate) trade_skill_collapsed: HashSet<(u32, u32)>,
|
||
/// Group keys the SubClass filter dropdown has hidden (empty = "All Subclasses"). Keyed like
|
||
/// [`Model::trade_skill_collapsed`] — the real client's per-header shown flag (`header+0xc`)
|
||
/// survives a rebuild by exactly this `(ItemClass, ItemSubClass)` key match (`0x4fca20`'s
|
||
/// save→restore loop; the position mask `0x84dd60` is the derived form — wow-re `tradeskill`
|
||
/// TU-G). Persists across a same-profession close/reopen; reset on a profession switch
|
||
/// ([`Model::trade_skill_last_line`]).
|
||
pub(crate) trade_skill_subclass_hidden: HashSet<(u32, u32)>,
|
||
/// The InvSlot filter mask (`0x84dd64`, wow-re `tradeskill` TU-G): **bit set = slot shown**,
|
||
/// all-ones = "All Slots". The real client's build never touches this static — it is NOT
|
||
/// pruned on a re-push, and an exclusive pick therefore keeps every OTHER bit clear even for
|
||
/// slots that appear later. Reset (all-ones) only on a profession switch.
|
||
pub(crate) trade_skill_invslot_mask: u32,
|
||
/// The skill line the tradeskill state was last built for (`0xbde064`, wow-re TU-G's cache
|
||
/// key): a `set_trade_skill` push for a DIFFERENT line resets the two filters, the collapse
|
||
/// set, and the selection; the same line (including a close→reopen round trip) keeps them.
|
||
pub(crate) trade_skill_last_line: u32,
|
||
/// The selected recipe's SPELL ID (`0xbde044` — the real client stores the selection by spell
|
||
/// id, not index), shadowing [`Model::trade_skill_selection`]'s flat position so the selection
|
||
/// survives a same-profession close→reopen (TU-G: untouched on that path) and remaps across a
|
||
/// re-push.
|
||
pub(crate) trade_skill_selected_spell: u32,
|
||
/// Set by the engine-side mutators the real client answers with a `TRADE_SKILL_UPDATE` event
|
||
/// from inside the C call (`Set*Filter`/`Expand/CollapseTradeSkillSubClass` → the
|
||
/// `0x4fd710`/`0x4fd730`/`0x4fd750` writer trio → recompute+resort `0x4fd180` + event
|
||
/// **0x13a**); the app drains it ([`super::UiScript::take_trade_skill_touched`]) and fires
|
||
/// the event the same frame.
|
||
pub(crate) trade_skill_touched: bool,
|
||
|
||
/// The open craft window's recipe snapshot the app pushes (`None` = no window open) — the craft
|
||
/// seam ([`craft`], decision 0437 phase 3), TradeSkill's exact twin.
|
||
pub(crate) craft: Option<craft::CraftState>,
|
||
/// Spell id intents `DoCraft` queued since the app's last [`super::UiScript::take_craft_dos`]
|
||
/// drain — no count (the 1.12 CraftFrame has no Create All, unlike `trade_skill_dos`).
|
||
pub(crate) craft_dos: Vec<u32>,
|
||
/// The engine-held 1-based selection (0 = none), preserved across a `set_craft` re-push by the
|
||
/// recipe's spell id (see [`super::UiScript::set_craft`]).
|
||
pub(crate) craft_selection: u32,
|
||
/// Whether `CloseCraft` was called since the app's last [`super::UiScript::take_craft_close`]
|
||
/// drain.
|
||
pub(crate) craft_close: bool,
|
||
|
||
/// The `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<bind_confirm::PendingEquipAnswer>,
|
||
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<loot::LootState>,
|
||
pub(crate) loot_picks: Vec<u32>,
|
||
/// 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<u32>,
|
||
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<item_text::ItemTextState>,
|
||
pub(crate) item_text_close: bool,
|
||
pub(crate) item_text_page_turns: Vec<i32>,
|
||
pub(crate) mail: Option<mail::MailState>,
|
||
pub(crate) mail_check_inbox: bool,
|
||
pub(crate) mail_opens: Vec<u32>,
|
||
pub(crate) mail_take_items: Vec<u32>,
|
||
pub(crate) mail_take_money: Vec<u32>,
|
||
pub(crate) mail_deletes: Vec<u32>,
|
||
pub(crate) mail_returns: Vec<u32>,
|
||
pub(crate) mail_take_texts: Vec<u32>,
|
||
pub(crate) mail_close: bool,
|
||
pub(crate) mail_send: Option<(String, String, String)>,
|
||
pub(crate) mail_send_money: u32,
|
||
pub(crate) mail_send_cod: u32,
|
||
/// The Send tab's attached bag item (a cursor drop, decision 0216) — carried until the send
|
||
/// fires; the app resolves its `(bag, slot)` to the wire item guid then.
|
||
pub(crate) mail_send_item: Option<cursor::CursorItem>,
|
||
/// The usable stationery list the app pushes, in the picker's order (1970).
|
||
pub(crate) mail_stationeries: Vec<mail::StationeryView>,
|
||
/// `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<auction::AuctionState>,
|
||
/// The Browse tab's class tree, login-scoped (`set_auction_item_classes`, 1971).
|
||
pub(crate) auction_item_classes: Vec<auction::AuctionCategory>,
|
||
pub(crate) auction_selected: [u32; 3],
|
||
pub(crate) auction_can_query: bool,
|
||
pub(crate) auction_query: Option<auction::AuctionQuery>,
|
||
pub(crate) auction_owner_query: Option<u32>,
|
||
pub(crate) auction_bidder_query: Option<u32>,
|
||
pub(crate) auction_bids: Vec<auction::AuctionBid>,
|
||
pub(crate) auction_cancels: Vec<u32>,
|
||
pub(crate) auction_start: Option<auction::AuctionStartRequest>,
|
||
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<cursor::CursorItem>,
|
||
|
||
/// The open trade window's snapshot the app pushes (`None` = no trade open) and the intents the
|
||
/// app drains — the trade seam ([`trade`], decision 0592 P1). `trade_initiates` are the unit
|
||
/// tokens `InitiateTrade` queued (the app resolves each → guid → `CMSG_INITIATE_TRADE`); the
|
||
/// three flags are the accept / un-accept / cancel verbs (`CMSG_ACCEPT_TRADE` /
|
||
/// `CMSG_UNACCEPT_TRADE` / `CMSG_CANCEL_TRADE`). `trade_set_money` is the copper `SetTradeMoney`
|
||
/// offered (→ `CMSG_SET_TRADE_GOLD`, decision 0592 P2).
|
||
pub(crate) trade: Option<trade::TradeState>,
|
||
pub(crate) trade_initiates: Vec<String>,
|
||
pub(crate) trade_accept: bool,
|
||
pub(crate) trade_unaccept: bool,
|
||
pub(crate) trade_close: bool,
|
||
/// `BeginTrade()` / `CancelTrade()` since the last drain (decision 1963).
|
||
pub(crate) trade_begin: bool,
|
||
pub(crate) trade_cancel: bool,
|
||
pub(crate) trade_set_money: Option<u32>,
|
||
/// Cursor-drop placements onto our trade slots: `(trade_id 1-based, bag, slot)` → the app resolves
|
||
/// `(bag, slot)` → wire position → `CMSG_SET_TRADE_ITEM`. `trade_clear_items` are the 1-based ids
|
||
/// an empty-cursor click cleared (→ `CMSG_CLEAR_TRADE_ITEM`). Decision 0592 P2.
|
||
pub(crate) trade_set_items: Vec<(u32, i64, u32)>,
|
||
pub(crate) trade_clear_items: Vec<u32>,
|
||
|
||
/// The open questgiver panel the app pushes (`None` = no window), the greeting-row selects and
|
||
/// the button intents it drains — the questgiver seam ([`quest`]).
|
||
pub(crate) quest: Option<quest::QuestState>,
|
||
pub(crate) quest_selects: Vec<quest::QuestSelect>,
|
||
pub(crate) quest_actions: Vec<quest::QuestAction>,
|
||
|
||
/// The death-arc seam ([`death`], decision 0308): the snapshot the app pushes (countdowns +
|
||
/// offer bits) and the drained release/reclaim/resurrect intents.
|
||
pub(crate) death: death::DeathUiState,
|
||
pub(crate) death_actions: Vec<death::DeathAction>,
|
||
|
||
/// The quest-log seam ([`quest_log`]): the snapshot the app pushes, the engine-owned
|
||
/// synchronous selection + click-time abandon mark (1-based entry indices, `0` = none), and
|
||
/// the drained abandon intents.
|
||
pub(crate) quest_log: quest_log::QuestLogState,
|
||
pub(crate) quest_log_selection: u32,
|
||
pub(crate) quest_log_abandon_mark: u32,
|
||
pub(crate) quest_log_abandons: Vec<u32>,
|
||
/// 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<u32>,
|
||
/// 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<u32, ItemTemplateView>,
|
||
/// Item ids the renderer asked for that the store lacks — drained by the app
|
||
/// ([`UiScript::take_item_stat_asks`]), answered via [`UiScript::set_item_template`].
|
||
pub(crate) item_stat_asks: HashSet<u32>,
|
||
/// The item-set store (`set id → §22 SET-block view`) + its ask-once misses — the same
|
||
/// push/ask flow as the templates ([`item_stats`] module doc).
|
||
pub(crate) item_sets: HashMap<u32, super::ItemSetView>,
|
||
pub(crate) item_set_asks: HashSet<u32>,
|
||
/// The **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<u32, super::RandomPropertyView>,
|
||
/// The red-line law's player state (level/class/race/skills — [`item_stats`] module doc).
|
||
pub(crate) player_req: PlayerReqState,
|
||
/// The spell-tooltip store: `spell id → resolved view` ([`tooltip_spell`] module doc,
|
||
/// decision 0274 P2) + the ask-once misses the app drains.
|
||
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
|
||
pub(crate) spell_tooltip_asks: HashSet<u32>,
|
||
/// Header collapse/expand intents `(1-based entry index, collapse)` — `CollapseQuestHeader`/
|
||
/// `ExpandQuestHeader` push, the app drains ([`UiScript::take_quest_log_collapses`]); index 0
|
||
/// = all headers.
|
||
pub(crate) quest_log_collapses: Vec<(u32, bool)>,
|
||
/// The watched (tracked) quests, by stable quest id in watch order — the tracker HUD's set
|
||
/// (engine-owned like the selection; pruned by `set_quest_log` when a quest leaves the log).
|
||
pub(crate) quest_log_watched: Vec<u32>,
|
||
/// The server's wall clock in unix-epoch seconds, pushed by the app each frame it has one
|
||
/// ([`UiScript::set_server_unix_time`]); `None` before the first `SMSG_QUERY_TIME_RESPONSE`.
|
||
///
|
||
/// Held here rather than folded into the quest-log snapshot because the *engine* owns every
|
||
/// countdown the Lua reads: a timed quest's deadline is an absolute stamp in this epoch, and
|
||
/// `GetQuestTimers` subtracts against it **per call**, exactly as the reference's C binding
|
||
/// does. That is what lets the reference `QuestTimerFrame` tick smoothly from its OnUpdate
|
||
/// while the log snapshot itself only changes when the log does (decision 1150).
|
||
pub(crate) server_unix_time: Option<f64>,
|
||
/// The player's purse in copper (`GetMoney`), pushed each frame it changes by the app's
|
||
/// `PLAYER_FIELD_COINAGE` feed ([`UiScript::set_money`]). Plain data — the money display + the
|
||
/// merchant window's coin line read it.
|
||
pub(crate) money: u64,
|
||
|
||
/// The reported connection round trip in ms — `GetNetStats`'s third return, pushed by the app's
|
||
/// net feed ([`UiScript::set_latency_ms`]). The AVERAGE over the app's RTT ring, not the last
|
||
/// sample; `0` = nothing measured yet. See [`super::net_stats`].
|
||
pub(crate) net_latency_ms: u32,
|
||
|
||
/// The player's experience within the current level (`UnitXP("player")`) and the amount required
|
||
/// to level (`UnitXPMax("player")`), pushed each frame they change by the app's `PLAYER_XP` /
|
||
/// `PLAYER_NEXT_LEVEL_XP` feed ([`UiScript::set_player_xp`]). A player-level pair (like `money`),
|
||
/// not a per-unit-token field: PLAYER_XP is a PRIVATE descriptor field, only ever our own avatar's.
|
||
pub(crate) player_xp: u32,
|
||
pub(crate) player_next_level_xp: u32,
|
||
|
||
/// The player's banked combo points and the unit they sit on — the raw `PLAYER_FIELD_BYTES`
|
||
/// byte 1 and `PLAYER_FIELD_COMBO_TARGET` GUID, pushed together each frame either moves
|
||
/// ([`UiScript::set_combo_points`]). Player-global PRIVATE fields, like `money`.
|
||
///
|
||
/// **Raw wire values, deliberately ungated.** The server banks a point here for a *warrior*
|
||
/// too (the Overpower window), and the usable walk's leg 5 consumes exactly that. The two
|
||
/// gates the real `GetComboPoints 0x51a190` applies — rogue-or-druid only, and the banked
|
||
/// target must be the CURRENT target — live in the binding, where the binary puts them
|
||
/// (decision 0875). Read [`Self::combo_points`] directly and you are reading the wire, not
|
||
/// what the reference UI can see.
|
||
pub(crate) combo_points: u8,
|
||
pub(crate) combo_target: u64,
|
||
|
||
/// The player's rest snapshot, pushed together each frame any part moves
|
||
/// ([`UiScript::set_rest_state`]) — player-globals like `money`. `rest_state` is the raw
|
||
/// `PLAYER_BYTES_2` byte 3 (1 = rested, 2 = normal — the server writes it with hysteresis
|
||
/// off the pool). **Defaults to 2, not 0**: every live 1.12 descriptor carries 2 from
|
||
/// character creation on, and the tick's faithful FrameXML compares `GetRestState() >= 3`
|
||
/// unguarded — a pre-feed 0 would render the binary's nil-triple fail path into an event
|
||
/// handler the real client can only ever run with the byte present. Byte 0 stays reachable
|
||
/// by explicit push, and the fail path stays faithful there. `rest_pool` is the raw
|
||
/// `PLAYER_REST_STATE_EXPERIENCE` value in **base kill-XP units**, `resting` the
|
||
/// `PLAYER_FLAGS_RESTING (0x20)` bit (inside an inn/city). `GetRestState`/`GetXPExhaustion`/
|
||
/// `IsResting` read these; the pool's display scaling is `exhaustion` row 1's factor,
|
||
/// applied in the `GetXPExhaustion` binding, where the real client applies it (decisions
|
||
/// 1082/1087).
|
||
pub(crate) rest_state: u8,
|
||
pub(crate) rest_pool: u32,
|
||
pub(crate) resting: bool,
|
||
|
||
/// `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<u8, (String, f64)>,
|
||
|
||
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
|
||
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
|
||
/// seam ([`char_stats`], decision 0208 §3).
|
||
pub(crate) player_combat_stats: Option<char_stats::UnitCombatStats>,
|
||
/// The **pet's** combat-stats snapshot (decision 1057) — the same shape under the `"pet"`
|
||
/// token, because the reference's own pet sheet calls the very same `UnitStat`/`UnitResistance`/
|
||
/// `PaperDollFrame_Set*(unit, prefix)` family with `unit = "pet"` (ref
|
||
/// `PetPaperDollFrame.lua:73-81`). A second slot rather than a token map: exactly two units
|
||
/// ever have one, they are fed by two different systems on two different clocks, and every
|
||
/// binding's route is a fork between them (the [`Self::pet_book`] precedent).
|
||
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
|
||
pub(crate) inventory_slots: char_stats::InventorySlots,
|
||
/// The 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<u32>,
|
||
/// The two weapons' **temporary** enchantments — `[0]` main hand, `[1]` off hand, in the order
|
||
/// `GetWeaponEnchantInfo` pushes them ([`weapon_enchant`]). A separate slot from
|
||
/// [`Self::inventory_slots`] on purpose: this one carries a live countdown and is pushed every
|
||
/// frame, where the slot snapshot is change-gated and fires an event.
|
||
pub(crate) weapon_enchants: [Option<weapon_enchant::WeaponEnchant>; 2],
|
||
|
||
/// The inspected unit's equipment view, keyed by unit token ([`inspect`], decision 0631) —
|
||
/// the *second* source behind the unit-keyed `GetInventoryItem*` family. Unlike
|
||
/// [`Self::inventory_slots`] this is PUBLIC descriptor data (`PLAYER_VISIBLE_ITEM_*`), which
|
||
/// is the whole reason a foreign player's gear can be read at all. `None` = nothing inspected.
|
||
pub(crate) inspect: Option<inspect::InspectView>,
|
||
/// Unit tokens queued by `NotifyInspect` — drained by the app into `CMSG_INSPECT`.
|
||
pub(crate) inspect_notifies: Vec<String>,
|
||
/// `ClearInspectPlayer` was called — drained by the app, which drops its inspect target.
|
||
pub(crate) inspect_clear: bool,
|
||
/// The 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<super::dressup::DressUpIntent>,
|
||
/// 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<crate::widget::FrameHandle, [i32; 5]>,
|
||
pub(crate) tabard_preview: Option<[i32; 5]>,
|
||
pub(crate) tabard_host: super::tabard::TabardHost,
|
||
pub(crate) tabard_intents: Vec<super::tabard::TabardIntent>,
|
||
/// 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<String, super::UnitReach>,
|
||
|
||
/// The skills-pane snapshot the app pushes ([`skills::SkillsState`], decision 0437 phase 4) and
|
||
/// the synthesized display tree built from it ([`skills::UiScript::set_skills`]) — the skills
|
||
/// seam ([`skills`]).
|
||
pub(crate) skills: skills::SkillsState,
|
||
pub(crate) skills_groups: Vec<skills::SkillGroup>,
|
||
/// The categories the player has collapsed (by `category_id`) — survives a content update the
|
||
/// same way [`Model::trainer_collapsed`] does.
|
||
pub(crate) skills_collapsed: HashSet<u32>,
|
||
/// The engine-held selection, by SKILL ID (not visible index — see [`skills`]'s module doc).
|
||
pub(crate) skills_selected: Option<u32>,
|
||
/// Skill line ids the `AbandonSkill` Lua binding queued since the app's last
|
||
/// [`UiScript::take_skill_abandons`] drain — the outbound unlearn seam (the app sends each as
|
||
/// a `CMSG_UNLEARN_SKILL`; the engine mutates NOTHING locally — the real client waits for the
|
||
/// server's skill-field update, vmangos `SkillHandler.cpp`'s `SetSkill(id, 0, 0)` round trip).
|
||
pub(crate) skill_abandons: Vec<u32>,
|
||
|
||
/// The reputation-pane snapshot the app pushes ([`reputation::ReputationState`]) and the
|
||
/// synthesized display tree built from it ([`reputation::UiScript::set_reputation`]) — the
|
||
/// reputation seam ([`reputation`]).
|
||
pub(crate) reputation: reputation::ReputationState,
|
||
pub(crate) reputation_groups: Vec<reputation::FactionGroup>,
|
||
/// The header groups the player has folded, by HEADER KEY (a `Faction.dbc` id, or the
|
||
/// synthetic `0` "Other" / `-1` "Inactive") — an identity a re-push cannot move, unlike a row
|
||
/// index. Reset to all-expanded-but-Inactive on every push, exactly as
|
||
/// [`Model::skills_collapsed`] is: the client's own rebuild does the same.
|
||
pub(crate) reputation_collapsed: HashSet<i64>,
|
||
/// The engine-held selection, by REPUTATION-LIST SLOT (not visible index — see
|
||
/// [`reputation`]'s module doc).
|
||
pub(crate) reputation_selected: Option<u32>,
|
||
/// Reputation verbs the pane's bindings queued since the app's last
|
||
/// [`UiScript::take_reputation_sends`] drain — the outbound seam. Unlike the skills abandon
|
||
/// above, the engine DOES mutate locally first: none of the three sends is acked.
|
||
pub(crate) reputation_sends: Vec<reputation::ReputationSend>,
|
||
|
||
/// Chat lines the input EditBox submitted (its `OnEnterPressed` → the `SubmitChatInput` Lua
|
||
/// binding) since the app's last [`UiScript::take_chat_input`] drain — the outbound Lua→app seam
|
||
/// for the chat input (the twin of `loot_picks`). The app routes each through its slash-command
|
||
/// parser into a `CMSG_MESSAGECHAT`/`CMSG_TEXT_EMOTE`.
|
||
pub(crate) chat_input: Vec<String>,
|
||
|
||
/// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the
|
||
/// engine-owned selection.
|
||
pub(crate) worldmap: super::worldmap::WorldMapState,
|
||
/// 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<ScriptValue>)>,
|
||
/// The last cursor position [`UiScript::mouse_move`](super::UiScript::mouse_move)/`mouse_button`
|
||
/// saw (UI space: logical px, y-up — the same frame `resolve` rects live in). Behind Lua's
|
||
/// `GetCursorPosition()`; the reference world map polls it every OnUpdate for hover/click math.
|
||
pub(crate) cursor_pos: (f32, f32),
|
||
|
||
/// 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<super::chat_send::ChatSend>,
|
||
/// Broadcasts an addon queued with `SendAddonMessage`, drained by the app into the wire
|
||
/// (decision 1235). Its own queue rather than [`Self::chat_sends`] because it is a different
|
||
/// wire: `LANG_ADDON` in the language field, a four-value distribution set, and a payload the
|
||
/// binding already composed as `prefix` TAB `message`.
|
||
pub(crate) addon_sends: Vec<super::addon_message::AddonSend>,
|
||
/// `RequestTimePlayed()` asks queued since the app last drained them — each is one
|
||
/// `CMSG_PLAYED_TIME`. A COUNT, not a payload, for [`super::pvp`]'s reason: the packet is
|
||
/// empty, so two asks in a frame are two sends rather than one collapsed intent.
|
||
pub(crate) played_time_asks: u32,
|
||
/// `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<super::gm_ticket::GmTicketIntent>,
|
||
/// `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<String>) {
|
||
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=<secs>:<n>` — 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=<n>` — name the epoch's per-frame toucher: print a backtrace
|
||
// for the first n touches (a mechanism probe, meaningful in a debuginfo build). Built
|
||
// because the SW meters read `resolve≈240 µs, solves=0` on every steady frame: the
|
||
// tier-1 gate is being defeated by a toucher whose writes never move a fingerprint,
|
||
// and 48 call sites is too many to tag by hand.
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::OnceLock;
|
||
// Spec `<secs>:<n>`: arm after `secs` (past UI load + settle, so the trace names the
|
||
// STEADY toucher, not the thousands of legitimate load-time touches), then print `n`.
|
||
static SPEC: OnceLock<Option<(std::time::Instant, f64, AtomicU32)>> = OnceLock::new();
|
||
let spec = SPEC.get_or_init(|| {
|
||
let v = std::env::var("WOW_LAYOUT_TOUCH_TRACE").ok()?;
|
||
let (secs, n) = v.split_once(':')?;
|
||
Some((
|
||
std::time::Instant::now(),
|
||
secs.trim().parse().ok()?,
|
||
AtomicU32::new(n.trim().parse().ok()?),
|
||
))
|
||
});
|
||
if let Some((t0, delay, left)) = spec {
|
||
if t0.elapsed().as_secs_f64() >= *delay
|
||
&& left
|
||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1))
|
||
.is_ok()
|
||
{
|
||
eprintln!(
|
||
"[layout-touch] epoch={} at:\n{}",
|
||
self.layout_epoch,
|
||
std::backtrace::Backtrace::force_capture()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `WOW_LAYOUT_DERIVE_TRACE=<secs>:<n>`'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<Option<(std::time::Instant, f64, AtomicU32)>> = 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()
|
||
}
|