Files
benilla-octo/crates/benilla-ui/src/script/model.rs
T
gromandClaude Opus 5 400457ff82 superwow: TrackUnit/UntrackUnit, and the four chat-bubble CVars
TrackUnit is the one with a live consumer and a real feature behind it:
1.12 draws party members on the minimap and raid members NOWHERE, and
pfUI puts the whole raid on it with these two — for every party and raid
token on PARTY_MEMBERS_CHANGED/RAID_ROSTER_UPDATE/PLAYER_ENTERING_WORLD,
each inside a pcall, so on a client without the function the feature is
simply absent, which is what it has been here.

The set is guids, not tokens, so a tracked raid member keeps its dot
while the roster shuffles its token out from under it. The dot itself is
drawn by a fourth always-show clause beside the reference's own three
(`shows_tracking_dot`) — `tracked_creature` stays exactly `0x5ed210`, so
the predicate that file documents is still the client's, and a tracked
unit gets the ordinary cell, hover name and distance cull with nothing
new drawn. The set is read off the VM handle the blip system already
holds rather than behind a resource of its own: `BlipInputs` is at the
16-param ceiling and its last element is already a nested pair for that
very reason.

The bubble four come straight off the DLL's string pool. Three are
nampower's `NP_ChatBubbles*` under SuperWoW's unprefixed names, writing
the same fields — two mods, one knob, last writer wins, as the nameplate
reach already is — each keeping 0 because the reference bubbles none of
those kinds. `ChatBubblesCreatures` is SuperWoW's alone and the only
behaviour of the four: it NARROWS `ChatBubbles` rather than standing
beside it, because the mod hooks the reference's own gate and a switch
added at that site can only subtract; the other reading would bubble
creatures with the master switch off. It ships 1, so an untouched client
bubbles monster say and yell exactly as 1.12 does. All four are welded to
their struct literals in the defaults-mirror test.

CREATE_CHATBUBBLE is deliberately NOT fired, and the row says why: its
payload can only be the bubble, and this client's bubbles are not Lua
frames — the same wall `C_ChatBubbles.GetAllChatBubbles` already answers
an empty list for. Argless would be worse than silence, since an addon
written against upstream reaches for arg1's regions and raises once per
bubble.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 12:58:31 -06:00

2575 lines
140 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, MusicRequest,
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ── The Rust-side model ──
/// The host's answer to whether a texture path resolves to a file.
pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// The host's answer to a texture path's size in texels.
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
/// The host's answer to whether a font path loads.
pub type FontProbe = Box<dyn Fn(&str) -> bool>;
/// A spell's `Spell.dbc` display row, reduced to the three strings ClassicAPI's `C_Spell` getters
/// quote. Plain data: `benilla-ui` must not name `benilla_formats::SpellDisplay`, so the host maps
/// its DBC row onto this on the way out.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SpellLookup {
/// `Spell.dbc` `Name` enUS.
pub name: String,
/// `SpellNameSubtext` enUS: "Rank 3", "Racial", or absent.
pub rank: Option<String>,
/// `SpellIcon.dbc`'s `TextureFilename`, extension-free, exactly as a spellbook slot carries it
/// (`Interface\Icons\Spell_Nature_Rejuvenation`).
pub icon: Option<String>,
/// `Spell.dbc`'s `Reagent[8]`/`ReagentCount[8]` pairs (item id, count), **empty rows dropped**
/// — so this is the reagent list, not a fixed-width array with holes, and a spell needing
/// nothing has an empty `Vec`. Backs `C_Spell.GetSpellReagents`.
///
/// Kept in DBC column order, which is the order the reference's own pre-send possession check
/// walks them in (`0x6e4000`), so "the first reagent" means the same thing here as there —
/// pfUI's action-bar reagent counter reads exactly `reagents[1].itemID`
/// (`modules/actionbar.lua:1553`).
pub reagents: Vec<(u32, u32)>,
/// `Spell.dbc`'s `Dispel` id — the dispel class an aura belongs to (Magic, Curse, Disease,
/// Poison…), `0` for none. Backs `C_Spell.GetSpellDispelType`.
///
/// The raw id, not the localized name `SpellDispelType.dbc` gives: the caller maps it itself
/// (pfUI keys a `dispelTypeMap` on the number, `libs/libdebuff.lua:379`), and a name would
/// make a localized realm answer something its table cannot match.
pub dispel: u32,
/// Whether this spell's aura is kept out of the aura UI: `ATTR_DO_NOT_DISPLAY`,
/// `ATTR_EX_NO_AURA_ICON`, or a tracking aura. Exactly the predicate `benilla-app`'s
/// `ui_aura::shown_in_aura_ui` applies to the buff and debuff lists, so this answers for the
/// same auras those omit. Backs nampower's `IsAuraHidden`.
pub hidden_aura: bool,
}
/// The host's answer for *any* spell id, learned or not: `Spell.dbc`'s whole display table, which
/// `benilla-ui` has no access to. The spellbook can only answer for the ~200 spells the player
/// knows, and ClassicAPI's getters are asked about arbitrary ids at addon file scope (pfUI's
/// `api/unitframes.lua:42` keys a table on `GetSpellTexture(774)` on every class). `None` falls
/// back to the spellbook.
pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
/// The host's answer for an `ItemDisplayInfo` row id: its inventory icon as an extensionless
/// `Interface\Icons\…` path. Keyed on the **display** id, not the item id, because that is the
/// column an item template carries and the one the reference's own vendor-cursor path resolves
/// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent.
pub type ItemIconProbe = Box<dyn Fn(u32) -> Option<String>>;
/// A `DisenchantAll(itemIdOrName | quality, [includeSoulbound])` call, as the verb received it.
///
/// The argument is carried through as **text**, unparsed: upstream's grammar (a number is an item
/// id, `greens|blues|purples` are qualities, anything else is a name) is parsed app-side in
/// `spell/disenchant.rs`, where the bag walk that uses it lives. Parsing it twice would be two
/// readings of `"greens|Linen Cloth"`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisenchantAsk {
pub filter: String,
pub include_soulbound: bool,
}
/// One nampower queue request: `QueueSpellByName(name, [priority])` or
/// `QueueScript(script, [priority])`.
///
/// The name is not resolved here. `QueueSpellByName` takes a *name*, and resolving one to a spell
/// id means the player's book then the host's `Spell.dbc` — which is the same walk
/// `CastSpellByName` makes app-side, so the request carries the name and the app does the walk
/// once. The alternative would be two resolvers that can disagree about which rank "Fireball"
/// means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueueRequest {
Spell { name: String, priority: i64 },
Script { chunk: String, priority: i64 },
}
/// nampower's `EncryptPassword(password)`: the blob, or `None` with no config home to keep a key
/// file in (a hermetic capture). The scheme is `benilla-app`'s `credential.rs`.
pub type PasswordEncryptProbe = Box<dyn Fn(&str) -> Option<String>>;
/// What `WriteCustomFile`'s optional third argument asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomFileMode {
/// The default: the file is replaced.
Truncate,
/// `"a"`/`"append"`: the content is added to whatever is there.
Append,
}
/// nampower's custom-file family, **fenced by the app to one directory**
/// (`benilla-app`'s `local_state::custom_file_path`).
///
/// A handle rather than direct `std::fs` calls, for the reason every other engine fact here is a
/// feed: `benilla-ui` is engine-free and touches no filesystem. It also puts the fence where it
/// belongs — the app owns the one directory these verbs may reach, and the VM side cannot widen
/// it however a `filename` is spelled. `None` on every closure's return is "refused or failed",
/// which the verbs report as nil; the app logs which it was.
pub struct CustomFileAccess {
pub read: CustomFileReader,
pub write: CustomFileWriter,
pub exists: CustomFileProbe,
}
/// A fenced name's bytes, or `None` for a refused or absent file.
pub type CustomFileReader = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// Write (or append) to a fenced name; `false` is refused or failed.
pub type CustomFileWriter = Box<dyn Fn(&str, &[u8], CustomFileMode) -> bool>;
/// Whether a fenced name is a readable file.
pub type CustomFileProbe = Box<dyn Fn(&str) -> bool>;
/// One `SpellRange.dbc` row by its index: `(min, max, flags)`, for nampower's
/// `GetSpellRangeData(rangeIndex)`. `None` for an index no row occupies.
///
/// 1.12's Lua reaches a range only through `SpellRangeFunc`-formatted tooltip text, never the two
/// yard numbers, and `C_Spell.IsSpellInRange` answers the *verdict* from the live usable map — so
/// the yards themselves have no other path out of this client.
pub type SpellRangeProbe = Box<dyn Fn(u32) -> Option<(f32, f32, u32)>>;
/// One column of a record as nampower's by-name readers hand it to Lua — `GetSpellRec` /
/// `GetSpellRecField` over `Spell.dbc`, and `GetUnitData` / `GetUnitField` over the unit
/// descriptor. One type for both, because the two families answer the same shapes and a second
/// copy would drift.
///
/// The array variants are a **1-based Lua table**, which for the spell record is not a guess:
/// `SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
/// `for i = 1, 3 do local em = effectMechanics[i]` (`Utility.lua:7776-7794`), and does the same
/// with `effectApplyAuraName`.
#[derive(Clone, Debug, PartialEq)]
pub enum RecValue {
Int(i64),
Num(f64),
Str(String),
Ints(Vec<i64>),
Nums(Vec<f64>),
}
/// One field of a spell's `Spell.dbc` row by nampower's own name for it, or `None` for a name this
/// client does not parse (and for an id no spell owns).
///
/// **The name-to-column mapping lives in `benilla-app`,** beside the `SpellDisplay` it reads: this
/// is a mapping from upstream's spelling to that struct's fields, so it belongs where that struct
/// is, and a copy here would be free to drift from it.
pub type SpellRecFieldProbe = Box<dyn Fn(u32, &str) -> Option<RecValue>>;
/// The whole row as `(name, value)` pairs in `Spell.dbc` column order, for `GetSpellRec`. Built
/// by walking the same field list through the same per-field function the probe above uses, so
/// the two verbs cannot answer differently for one name.
pub type SpellRecordProbe = Box<dyn Fn(u32) -> Option<Vec<(&'static str, RecValue)>>>;
/// nampower's `GetSpellModifiers(spellId, modifierType)`: the `(flat, percent)` pair the client's
/// own `GetSpellModifiers 0x6e6b30` folds out of its two talent-modifier tables, or `None` for
/// any of that reader's four false exits (the spell has no family, it is not the player's class
/// family, it carries `AttributesEx3` bit 29, or both sums came out zero).
///
/// **A probe, not a pushed snapshot, and reinstalled when the tables change.** The arithmetic has
/// four gates and a 64-bit fold, and it is already written once in `benilla-app`'s
/// `spell/mods.rs` — the only reader of the two `i32[64][29]` tables the wire fills. Pushing the
/// tables here and folding them again would be a second implementation of that reader, free to
/// drift from the one the power cost is computed with. So the closure calls the real one, and
/// `ui_script/lifecycle.rs` reinstalls it whenever the wire writes a cell.
pub type SpellModifierProbe = Box<dyn Fn(u32, u8) -> Option<(i32, i32)>>;
/// A spell id to its `Spell.dbc` `Mechanic` column and that mechanic's enUS name from
/// `SpellMechanic.dbc`, for ClassicAPI's `C_Spell.GetSpellMechanicByID`.
///
/// Its own probe rather than two more columns on [`SpellLookup`]: the mechanic is asked for by
/// exactly one consumer, the name needs a second DBC the spell catalog does not carry, and
/// widening `SpellLookup` would touch all eleven of its construction sites for a field none of
/// them has an answer for.
///
/// `None` is "no such spell"; `Some((0, _))` is a real spell with no mechanic — a distinction the
/// consumer states outright ("the ID is 0 for a known spell with no mechanic, and the whole call
/// is nil for an invalid spell ID", `SuperCleveRoidMacros/ClassicAPI.lua:252-255`).
pub type SpellMechanicProbe = Box<dyn Fn(u32) -> Option<SpellDbcExtras>>;
/// The `Spell.dbc` columns ClassicAPI exposes that a spellbook slot does not carry.
///
/// One probe for the set rather than one each: they come from the same row, the same two catalogs
/// and the same install site, and a caller asking for any of them has already paid the lookup.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpellDbcExtras {
/// `Mechanic` (the spell-level column), `0` for a spell with no mechanic.
pub mechanic: u32,
/// That mechanic's enUS `SpellMechanic.dbc` name, lower-case as the data holds it; `None` for
/// mechanic 0 and for an id with no row.
pub mechanic_name: Option<String>,
/// `EffectMechanic[3]`, the per-effect column, where [`Self::mechanic`] is the spell-level
/// one. Vanilla puts some mechanics on an effect instead of the spell — Rake's bleed is the
/// consumer's own example — so a caller that reads only the spell-level field misses them.
/// `0` in a slot means that effect carries no mechanic.
pub effect_mechanics: [u32; 3],
/// `manaCost` (column 32): the flat cost, in [`Self::power_type`]'s unit.
pub mana_cost: u32,
/// `powerType`: 0 mana, 1 rage, 3 energy — the unit [`Self::mana_cost`] is counted in.
pub power_type: u32,
/// `SpellIconID`, the `SpellIcon.dbc` row id — nampower's `GetSpellRecField(id,
/// "spellIconID")`, whose whole purpose is to be handed to `GetSpellIconTexture`. `0` for a
/// spell with no icon.
pub icon_id: u32,
/// nampower's `GetSpellDuration`: the spell's own aura length in ms, `0` where this client
/// cannot say — no `SpellDuration.dbc` row, the permanent sentinel, or a row that scales with
/// level or combo points. The same three cases `ui_cast_events::aura_duration_ms` answers 0
/// for, and for the same reason: a consumer treats 0 as absent and falls back, but a number it
/// trusts and draws as a timer.
pub duration_ms: u32,
}
/// `SpellIcon.dbc` by row id: nampower's `GetSpellIconTexture`. Installed by the app, which owns
/// the DBC; `None` for a row id the file has no entry for.
pub type SpellIconProbe = Box<dyn Fn(u32) -> Option<String> + Send + Sync>;
/// What a creature display id resolves to for a model pane.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CreatureModelView {
/// The `CreatureModelData` path, as `SetModel` would be given it.
pub path: String,
/// The display's three creature skin variations, by M2 skin slot. A creature M2 names no
/// texture of its own for those slots, so without these the pane draws the model **white** —
/// which is exactly what the shop's mount and companion cards did.
pub skins: [Option<String>; 3],
}
/// A creature display id to its model and skins (`CreatureDisplayInfo` → `CreatureModelData`),
/// for `PlayerModel:SetUnit(<displayID>)`. Installed by the app, which owns the DBCs.
pub type CreatureModelProbe = Box<dyn Fn(u32) -> Option<CreatureModelView> + Send + Sync>;
/// One known spell's live usability, nampower's `IsSpellUsable`/`IsSpellInRange` answer
/// (`compat/nampower.rs`), pushed by `ui_action::state`'s feed.
///
/// Keyed by spell id and covering the player's own book alone, which is the contract upstream
/// states: "nil on error (spell not in spellbook)".
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SpellUsable {
/// `IsSpellUsable`'s first return.
pub usable: bool,
/// Its second: the power gate alone failed, upstream's `oom`.
pub out_of_power: bool,
/// `IsSpellInRange` **against the current target**; `None` when the spell has no range at all
/// or there is no target to measure to, which is the one case that answers nil.
pub in_range: Option<bool>,
}
/// One `C_Timer.After`/`NewTimer`/`NewTicker` row (`compat/classic_api.rs`), drained by
/// [`super::UiScript::tick`] against `GetTime()`.
pub(crate) struct PendingTimer {
pub(crate) id: u64,
/// `GetTime()` seconds this fires at; re-armed by `interval` after firing, for a ticker.
pub(crate) fire_at: f64,
/// `NewTicker`'s repeat, `None` for `After`/`NewTimer` (fires once).
pub(crate) interval: Option<f64>,
/// `NewTicker("iterations")`'s remaining fire count, `None` for unlimited/non-repeating.
pub(crate) iterations_left: Option<u32>,
/// Set by the handle's `:Cancel()`; skipped and dropped on the next drain rather than removed
/// on the spot, so cancelling from inside another timer's own callback is safe.
pub(crate) cancelled: bool,
pub(crate) callback: mlua::RegistryKey,
/// Where this row's callback was *written*, `Interface\AddOns\pfUI\modules\minimap.lua:135`,
/// for the per-timer rows of `WOW_UI_TICK`'s report ([`super::tick_prof`]). `None` unless that
/// split is armed: it costs a `lua_getinfo` per arm, and `RunNextFrame` arms every frame.
///
/// The callback's definition site, not the `C_Timer.NewTicker` call's: a ticker armed as
/// `NewTicker(FALLBACK_INTERVAL, UpdateDisplay)` must name `UpdateDisplay`, which is the body
/// that costs, rather than the one line every such ticker shares.
pub(crate) site: Option<std::rc::Rc<str>>,
}
/// The server's wall clock as the login packet broke it down, plus how far the session has run.
/// Plain data: no `Instant`, so the VM holds nothing that needs ticking.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerClock {
/// Civil year, e.g. 2026.
pub year: i64,
/// 1-12.
pub month: u32,
/// 1-31.
pub day: u32,
pub hour: u32,
pub min: u32,
}
/// One `SetUnitBlip`/`SetObjectTypeBlip` call (`compat/vanilla_helpers.rs`), queued for
/// `crates/benilla-app/src/minimap/blips` to drain: a texture path, not yet a loaded asset, and a
/// scale (`None` leaves the baked-in size).
pub struct BlipOverrideRequest {
pub key: BlipOverrideKey,
pub texture: String,
pub scale: Option<f32>,
}
/// Which blip a [`BlipOverrideRequest`] retargets: a unit by GUID (`SetUnitBlip`, resolved from
/// its token at call time, same as every other GUID-keyed thing in `compat/`) or a landmark by its
/// `AreaPOI` icon id (`SetObjectTypeBlip`'s "type" — the only existing per-landmark key this
/// client already carries, `benilla-formats::AreaPoi::icon`).
pub enum BlipOverrideKey {
Unit(u64),
ObjectType(u32),
}
/// One playable race as `ChrRaces.dbc` carries it, fed once per session
/// (`compat/classic_api/creature_info.rs`). Plain data: this crate reads no DBC itself, so the app
/// hands it the two string columns already resolved.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RaceInfo {
/// `ChrRaces` field 17, the localized display name (`"Night Elf"`, and `"High Elf"` on a realm
/// whose row 10 says so).
pub name: String,
/// `ChrRaces` field 15, the file token texture paths key on (`"NightElf"`, `"BloodElf"`).
/// Genuinely distinct from [`Self::name`], not the name with spaces stripped.
pub client_file_string: String,
/// `"Alliance"` or `"Horde"`; `None` where the app cannot place the race on a side.
pub faction_group: Option<String>,
}
/// One class as `ChrClasses.dbc` carries it, fed once per session
/// (`compat/classic_api/creature_info.rs`). Plain data for the same reason [`RaceInfo`] is: this
/// crate reads no DBC, so the app hands over the two string columns already resolved.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClassInfo {
/// `ChrClasses` field 5, the localized display name (`"Warrior"`).
pub name: String,
/// `ChrClasses` field 14, the uppercase file token FrameXML keys `RAID_CLASS_COLORS` and
/// `LOCALIZED_CLASS_NAMES_MALE` on (`"WARRIOR"`). A separate column from [`Self::name`], not
/// that name uppercased — the same independence [`RaceInfo::client_file_string`] documents.
pub class_file: String,
}
/// One remembered player, as [`Model::player_cache`] holds them. Every field
/// is a value a caller handed `RememberPlayer`; none is derived here.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlayerCacheEntry {
/// `OBJECT_FIELD_GUID` as the caller spelled it — the 16-hex-digit token
/// `script/unit/mod.rs`'s `guid_token` mints, not a number, since that is what `UnitGUID`
/// answers and what the caller passes straight through.
pub guid: String,
/// The name as given, for the display spelling; the map key is its lowercase form.
pub name: String,
/// `RememberPlayer`'s third argument, upstream's `classToken`.
pub class: Option<String>,
/// `RememberPlayer`'s fourth, which pfUI fills with `UnitRace(unit)`'s first (localized)
/// return.
pub race: Option<String>,
/// `RememberPlayer`'s fifth, `UnitSex(unit)`'s 2 male / 3 female.
pub sex: Option<u32>,
}
/// The self player's posture, pushed once per tick (`compat/classic_api/state.rs`). 1.12's own
/// Lua has no path to any of these at all (no mounted/swim/indoor getter exists on the client),
/// so there is no reference default to match — `Default` (everything `false`/`0`) is only ever
/// observed for the one frame before the feed system's first run.
// No `Eq`: [`SelfMovement::speed`] is an `f32`. Nothing compares two snapshots for equality —
// the feed assigns a fresh one each tick — so the bound was never load-bearing.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SelfMovement {
/// `MOVEFLAG_SWIMMING`.
pub swimming: bool,
/// A live `UNIT_FIELD_MOUNTDISPLAYID`.
pub mounted: bool,
/// The area pass's own indoor bit (`benilla-app`'s `area.rs`), the same one `ZONE_CHANGED_INDOORS`
/// keys on.
pub indoors: bool,
/// `MOVEFLAG_FALLING`, set for the whole airborne arc.
pub falling: bool,
/// `MOVEFLAG_ROOT`, for nampower's `PlayerIsRooted()`. Not 1.12 data either: the client reads
/// its own movement word, and no stock binding exposes it.
pub rooted: bool,
/// Any of the six movement bits set — forward, backward, the two strafes, the two keyboard
/// turns — for nampower's `PlayerIsMoving()`. The turn keys count because they are movement
/// input to the same word the client tests, and a turn-in-place does interrupt a cast.
pub moving: bool,
/// `UNIT_FIELD_BYTES_2` byte 3, `0` not shapeshifted (`ShapeshiftForm.dbc` row otherwise).
pub shapeshift_form: u8,
/// SuperWoW's `GetSpeed()`: the player's current speed in yd/s, `benilla-app`'s
/// `net::current_speed` over this unit's own speed block and move flags — the reference's
/// `CMovement::GetCurrentSpeed 0x7c4c90`. 0 standing still, as that getter is.
pub speed: f32,
/// `IsMouselooking()`: the camera rig's own mouse-look flag, the reference's `[cam+0x90] & 1`
/// (`CameraControl::mouselooking`) — a right-held turn or a both-button run, not a
/// left-button orbit. This one *is* 1.12 data; it is here rather than behind its own feed
/// because this snapshot is the one per-tick push of the self player's posture and the rig is
/// posture.
pub mouselooking: bool,
}
/// The player's own cast state, pushed once per tick (`benilla-app`'s `ui_self_cast.rs`) for
/// nampower's [`GetCurrentCastingInfo`](crate::script::compat) and `GetCastInfo` — the two
/// most-called nampower functions in grom's corpus.
///
/// **Why a snapshot and not a read of `unit_casts`.** The cast *window* (`UnitCastState`) is what
/// a bar draws: one row per caster, start and end, nothing about the caster's own machinery. These
/// five ids and four flags are the reference's own cast globals — the inflight slot `0xceca88`,
/// the cast with the bar `0xceac48`, the auto-repeat key `0xceac30`, the queued strike, the
/// melee toggle — which live in `benilla-app`'s resources and have no descriptor field at all.
/// `GetCastInfo` then folds the two: its timing columns come from the player's own window, its
/// identity and GCD columns from here, pushed in the same frame by a system ordered after the
/// window drain so the pair can never describe different casts.
///
/// `Default` (everything zero) is what a frame before the feed's first run reads, and it is also
/// exactly "not casting", which is the honest answer then.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SelfCast {
/// `GetCurrentCastingInfo`'s first return, which upstream spells `castId` and every consumer
/// reads as a **spell id**: `SuperCleveRoidMacros` assigns it to `CurrentSpell.castingSpellId`
/// (`Init.lua:89`) and hands it to `C_Spell.GetSpellName` (`Core.lua:5427-5433`). It is the
/// reference's inflight slot `0xceca88` — the last committed, unresolved cast of any class.
pub inflight_spell: u32,
/// `visId`: the cast that owns the bar, the reference's `0xceac48`. Read as a spell id too
/// (`Core.lua:5347`, the `GetCastInfo` fallback), and `Init.lua:99` stores it as
/// `visualSpellId`.
pub visual_spell: u32,
/// `autoId`: the live auto-repeat spell, the reference's `0xceac30`.
pub auto_repeat_spell: u32,
/// `casting`: a cast or auto-repeat is in flight. Deliberately **not** the same bool as
/// [`Model::casting`](crate::script) — that one answers `SpellStopCasting`'s question (what
/// Esc can stop, which includes an auto-repeat and excludes a channel), and upstream's pair
/// here is `casting` *and* `channeling` as two independent columns, which is why the consumer
/// tests `casting == 1 or channeling == 1`.
pub casting: bool,
/// `channeling`: a channel is running (`0xceac58`).
pub channeling: bool,
/// `onswing`: an on-next-swing strike is queued (Heroic Strike, Cleave). Three macro
/// conditionals read this column alone (`Conditionals.lua:7587`, `:7594`, `:9120`).
pub on_swing: bool,
/// `autoattack`: melee auto-attack is running. `Core.lua:94` reads this one with no existence
/// guard at all — `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()` — so on a client
/// without the function that line raises.
pub auto_attack: bool,
/// `GetCastInfo`'s `castId`, which is a **different** number from
/// [`Self::inflight_spell`](Self) despite the shared upstream name: that table carries
/// `castId` and `spellId` as separate columns, so this one is the monotonic count of
/// committed casts (`PendingCast::arm`'s own counter), not a spell id.
pub cast_id: u32,
/// `GetCastInfo`'s `castType`, decided at the commit: `0` normal, `1` non-GCD, `2` on-swing,
/// `3` channel, `4` targeting, `5` targeting non-GCD. The channel value is applied by the
/// binding from the live window rather than stored here, because a cast's type is fixed at
/// send and whether it is *currently* channelling is not.
pub cast_type: u8,
/// `GetCastInfo`'s `guid`: the committed cast's target. Written as a GUID token by the
/// binding, `0` meaning no explicit target.
pub target_guid: u64,
/// The running global cooldown on the `GetTime()` clock — `GetCastInfo`'s `gcdEndS`, with
/// `gcdRemainingMs` derived from it. `0.0` for both when no GCD is running.
///
/// One number for what 1.12 keys per `startRecoveryCategory`, because that is the shape the
/// consumer wants: `API.GetGCDRemainingMs` divides it and the `[gcd]`/`[nogcd]` macro
/// conditionals compare it against a number of seconds. The app publishes the **longest**
/// running GCD leg, which is the one a player is actually waiting on.
pub gcd_start: f64,
pub gcd_end: f64,
}
/// Minted id to handle, indexed directly: ids are dense and every scripted call looks one up.
#[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, in `lua.app_data`. It holds no mlua handles, whose
/// persistent count the vendored `LUAI_MAXCSTACK` caps.
pub(crate) struct Model {
/// Every discovered addon in load order: the AddOn API's registry, filled at world entry.
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The Lua index space into `addons`, a title-sorted, hidden-filtered list (`0x51da70`).
pub(crate) addon_index: Vec<usize>,
/// Lowercased names `SMSG_ADDON_INFO` marked `status = 2`; `None` until the reply arrives, and
/// until then `GetNumAddOns()` is 0, as in the reference (`[0xbe1b90]`, reset at `0x51fad1`).
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 chain-sourced addon files; without one such an addon is `MISSING`.
pub(crate) addons_chain_reader: Option<super::addon::SharedChainReader>,
/// The host's font engine, so a metric answers inside the asking call, not a frame later.
pub(crate) measurer: Option<Box<dyn super::TextMeasure>>,
/// Whether a texture path resolves (patch chain or loose addon file), so the path form of
/// `SetTexture` returns the reference's 1 or nil inline (`0x79bb40`). `None` answers nil.
/// ClassicAPI's `C_PlayerCache`, name (lowercased) to what a caller told
/// `RememberPlayer` about that player. Not fed by the app at all — see
/// `compat/classic_api/player_cache.rs`'s header on why the writer and the reader being the
/// same addon is what makes it answerable.
pub(crate) player_cache: HashMap<String, PlayerCacheEntry>,
/// `C_PlayerCache`'s two enable toggles, `[IsEnabled, IsScanEnabled]`, both on at boot.
/// Preference state with nothing downstream, as `bag_autostore` is.
pub(crate) player_cache_enabled: [bool; 2],
pub(crate) texture_probe: Option<TextureProbe>,
/// A path's texel size, which an axis authored as 0 takes, one texel per unit, as the client's
/// `GetWidth` (`0x770720`) and `GetHeight` (`0x770790`) do; `None` leaves it as authored.
pub(crate) texture_size_probe: Option<TextureSizeProbe>,
/// Whether a font path loads: `SetFont`'s 1 or nil, nil meaning a load failure (`0x79f345`,
/// `0x79f361`, from `0x5c1ae0` in the `0x44d040` cache). Unlike `texture_probe`, `None` answers
/// 1 for a non-empty path: without a font backend nothing failed, and addons revert their font
/// on a nil.
pub(crate) font_probe: Option<FontProbe>,
/// `Spell.dbc` by id for `C_Spell`'s getters, past the player's own book; `None` restricts
/// them to the book, which is all a VM with no host can see.
pub(crate) spell_probe: Option<SpellProbe>,
pub(crate) spell_mechanic_probe: Option<SpellMechanicProbe>,
/// Saved-variables folders, one `<Addon>.lua` each: the account's, then this character's.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
pub(crate) addons_saved_character: Option<std::path::PathBuf>,
/// FrameXML templates, global across files as the client's (`0x6ee500`); held here so the
/// loader runs from a bare `&Lua`, which lets `LoadAddOn` load inside a binding.
pub(crate) framexml_templates:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// `FrameXML_Debug`'s flag (`0x488440` over `[0xceea30]`, boots 0); the loader's trace lines
/// print while it is greater than 0 (`0x6ee298`), so it is signed.
pub(crate) framexml_debug: std::cell::Cell<i32>,
/// The FrameXML font registry, a namespace apart from templates (a font inherits only a font).
pub(crate) framexml_fonts:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
pub(crate) arena: WidgetArena,
/// Per-frame anchors, size and scale; every live frame has one from creation on.
pub(crate) layout_inputs: HashMap<FrameHandle, LayoutInput>,
/// Each resolvable frame's rect from the last resolve.
pub(crate) resolved: HashMap<FrameHandle, Rect>,
/// The anchor solver, kept across resolves so a steady round allocates nothing; scratch only.
pub(crate) solver: LayoutSolver,
/// Tier 2 of the resolve gate, the last converged inputs' fingerprint; `None` forces a resolve.
pub(crate) layout_fingerprint: Option<super::layout::InputFingerprint>,
/// Tier 1 of the resolve gate: every layout write bumps it, and a resolve returns at once while
/// it equals `layout_epoch_resolved`. Never bump it per frame: tier 2 hashes the whole UI.
pub(crate) layout_epoch: u64,
/// Layout ids named by writes since the last converged resolve, each write claiming it left the
/// cached graph's shape alone, so a resolve seeds its dirty closure from them. `None` derives
/// the graph in full; `WOW_LAYOUT_VERIFY` checks every incremental pass against a full one.
pub(crate) layout_touched: Option<Vec<u32>>,
/// Regions a write may have re-keyed for text measure (text, font, outline, wrap, scale) since
/// the last sweep, which asks only these; `None` walks all. A site that forgets to name its
/// region is caught by `WOW_LAYOUT_VERIFY`, on for every test here, which panics on the miss.
pub(crate) measure_dirty: Option<Vec<crate::widget::RegionHandle>>,
/// Per message frame, `(lines_gen, env hash)` at its last request-free sweep; a match skips it.
/// A destroyed frame's entry is inert: generational handles never match again.
pub(crate) msg_swept: std::collections::HashMap<crate::widget::FrameHandle, (u64, u64)>,
/// Lines the message sweep hashed; a settled second sweep hashes none.
pub(crate) msg_lines_hashed: u64,
/// Under `WOW_LAYOUT_VERIFY`, the resolve just taken was incremental and owes a full re-run.
pub(crate) layout_verify_recheck: bool,
/// Per-node input hashes and dirty-closure scratch: which nodes a let-through resolve solves.
pub(crate) layout_scope: super::layout::LayoutScope,
/// The epoch the last converged resolve closed on; `None` sends the next one past tier 1.
pub(crate) layout_epoch_resolved: Option<u64>,
/// Resolves the gate let through, counted at its decision, so verify mode reads the same.
pub(crate) layout_solves: u64,
/// Resolves that got past tier 1, including those that then skipped: the whole-roster walk's
/// cost, which `layout_solves` misses. Verify mode walks anyway, so assert on this only from a
/// crate that depends on `benilla-ui`; inside this crate, assert on `layout_solves`.
pub(crate) layout_gate_walks: u64,
/// Full layout-graph derivations: 0 on a steady UI, so a rise means a write fell back to
/// `touch_layout`. The `WOW_LAYOUT_VERIFY` re-run's derivation is not counted.
pub(crate) layout_derives: u64,
/// Where the writes that force a whole-graph derivation came from: `file:line` of the Lua
/// binding or engine site, to a count. The derivation count on its own says a frame paid for
/// one and not which of the forty-odd `touch_layout` sites asked; this is that half, and it is
/// what a fix has to be aimed at. Populated only while [`Self::layout_derive_sites`] has been
/// asked for, so a session that does not look pays one branch.
pub(crate) layout_derive_sites: Option<std::collections::HashMap<(&'static str, u32), u64>>,
/// The last solve's width, `(frames solved, regions swept)`.
pub(crate) layout_last_scope: (usize, usize),
/// Fixpoint rounds run across all solves.
pub(crate) layout_rounds: u64,
/// Per frame, rasterized links `(y-up rect, link, full |H…|h markup)` for `OnHyperlinkClick`.
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
/// The hyperlink span the cursor is inside, `(frame id, link, markup)`, for the
/// `OnHyperlinkEnter`/`OnHyperlinkLeave` edges ([`super::pointer`]). The *markup* identifies
/// the span rather than the rect: a chat frame scrolls its lines under a still cursor, and
/// re-entering the same link at a new y is not a new hover.
pub(crate) hovered_link: Option<(u32, String, String)>,
/// Tab was pressed in the chat edit box (`BenillaChatTabPressed`), for the whisper cycle.
pub(crate) chat_tab: bool,
/// Region visuals (texture, colour, text) and layout (anchors, size, justify).
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
/// Each frame's backdrop plate (`<Backdrop>` or `SetBackdrop`, the client's `frame+0x1ac`).
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Backdrop>,
/// `CSimpleHTML`'s state: element fonts `+0x350`, link format `+0x360`, content nodes `+0x340`.
pub(crate) simple_html: simplehtml::SimpleHtmlStates,
/// Named `<Font>` objects by lowercased name, as 1.12 matches them with `SStrCmpI` (`0x783870`,
/// `0x7838c7`); read them only through `font_object`, where the fold lives.
pub(crate) font_objects_by_lower: HashMap<String, FontObject>,
/// Anchored regions' owner-relative rects from the last resolve; anchor-less ones are absent.
pub(crate) region_resolved: HashMap<RegionHandle, Rect>,
/// The next object id to mint; ids start at 1, since 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 to id, first wins: `SetPoint` anchors to a sibling region by name, after frames.
pub(crate) region_names: HashMap<String, u32>,
/// Which handlers each frame has; the closures themselves live in Lua's `REG_SCRIPTS` table.
pub(crate) scripts: HashMap<FrameHandle, HashSet<&'static str>>,
/// Frames with an `OnUpdate` script, kept by `SetScript`; the tick sorts them by frame id.
pub(crate) on_update_frames: Vec<FrameHandle>,
/// Frames with an `OnSizeChanged` script, kept by `SetScript`, for the resolve's size snapshot.
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// Frames with an `OnUpdateModel` script, fired as a visible model pane paints (`0x76d1a0`).
pub(crate) on_update_model_frames: Vec<FrameHandle>,
/// Per model file, a pane clock's sequences and bounds, read by the host off the `MD20`.
pub(crate) model_facts: HashMap<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
/// Model files still waiting for their facts, for the host to load; deduplicated.
pub(crate) model_facts_wanted: Vec<String>,
/// Edit boxes owed `OnTextChanged`: the reference's dirty bit (`[E+0x31c]` bit 0), set by
/// `SetText`/`Insert`, cleared only by `0x77d3e0`: one fire per drain, with the final text, as
/// `MoneyInputFrame_SetCopper` needs. A hidden box's fire waits for a show, key or mouse-down.
pub(crate) dirty_editboxes: Vec<FrameHandle>,
/// Event name to its frames in registration order, never a set: `SignalEvent` (`0x703e50`)
/// dispatches in that order across frames. Re-registering keeps a frame's position.
pub(crate) event_to_frames: HashMap<String, Vec<FrameHandle>>,
pub(crate) frame_events: HashMap<FrameHandle, HashSet<String>>,
/// ClassicAPI's `SetAttribute`/`GetAttribute` (`compat/classic_api/frame.rs`): a plain
/// per-frame key/value store, which is all those two verbs fundamentally are. The *secure*
/// half of the modern secure-template system (restricted execution, the state and attribute
/// drivers, `RegisterUnitWatch`) is not built and this is not it — 1.12 has no such subsystem
/// to be faithful to. Keys are strings; values are whatever Lua put there, held as
/// [`ScriptValue`] so this crate keeps no Lua handle.
pub(crate) frame_attributes: HashMap<FrameHandle, HashMap<String, ScriptValue>>,
/// `RegisterAllEvents` frames in registration order, dispatched after an event's own list and
/// cleared only by `UnregisterAllEvents`. Deviation: a flag, not the reference's append to
/// every event's list (`0x7023e0`, which orders by registration time and lets `UnregisterEvent`
/// drop one event), because events register here by name and no list of every name exists.
pub(crate) all_event_frames: Vec<FrameHandle>,
/// The focused edit box (`0xcf4dc8`), taking all keys while visible; `None` admits `autoFocus`.
pub(crate) focused_editbox: Option<FrameHandle>,
/// The mouse-enabled frame under the cursor, for the next move's `OnLeave`/`OnEnter`.
pub(crate) mouseover: Option<FrameHandle>,
/// A frame under a still cursor hid or showed, so the tick re-runs the hover walk at the saved
/// position and a newly exposed frame gets `OnEnter` without a mouse move: the reference's
/// `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into `0x7660d0`).
pub(crate) hover_repick: bool,
/// Per button, the frame its press landed on, for `OnClick`'s same-frame release test.
pub(crate) mouse_down_on: HashMap<String, FrameHandle>,
/// The client's one mouse-capture slot (`root+0x80`), read by the mouse-down raise. Set at
/// `0x7663e6` to the capture, else the hovered frame, so a chorded press keeps the capture;
/// cleared at `0x7664bb` once no button is down. A title-region press captures nothing.
pub(crate) mouse_capture: Option<FrameHandle>,
/// Per frame, when its last single `OnClick` fired: the client's `[CButton+0x334]`, the whole
/// double-click state. It has no button identity (left then right makes a double), is zeroed
/// when a double fires, and nothing else clears it, not hide, disable or the cursor leaving.
pub(crate) last_click: HashMap<FrameHandle, f64>,
/// Frames the last resolve resized, `(id, width, height)`, owed `OnSizeChanged` outside it.
pub(crate) pending_size_changed: Vec<(u32, f32, f32)>,
/// Script errors caught from `pcall`'d handlers, drained by the host each frame.
pub(crate) errors: Vec<String>,
/// Errors owed to `geterrorhandler()`, which the reference calls on each caught error; one the
/// handler itself raises skips this queue, which stops the recursion.
pub(crate) pending_error_dispatch: Vec<String>,
/// The session's retained diagnostic log, deduplicated and bounded; `errors` drains each frame.
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
/// Non-fatal warnings for the host, such as a dropped `inherits=` template.
pub(crate) warnings: Vec<String>,
/// Things the host should log at `info!` because they are neither a fault nor noise — an
/// on-demand `LoadAddOn` is the first: the startup walk names every addon it loads, this path
/// named none, so a session log could not say which window was open when it stalled.
pub(crate) notices: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
pub(crate) screen: Rect,
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
pub(crate) units_by_lower: HashMap<String, UnitState>,
/// Live cast/channel windows by caster GUID, behind ClassicAPI's
/// `C_Spell.UnitCastingInfo`/`UnitChannelInfo` — read it only through [`Self::unit_cast`],
/// where the token -> GUID fold lives. A `Vec`, not a map, for the reason
/// [`Self::unit_by_guid`] is a scan: at most the handful of units actually mid-cast, and the
/// app prunes each window the tick it ends.
pub(crate) unit_casts: Vec<super::unit::UnitCastState>,
/// Per unit token, auras in display order: the player's by insertion, the rest by aura slot.
pub(crate) auras: HashMap<String, Vec<AuraState>>,
/// Spell ids the cancel verbs queued (`CancelPlayerBuff`, `CancelTrackingBuff`, …), one
/// `CMSG_CANCEL_AURA` each.
pub(crate) cancel_aura_requests: Vec<u32>,
/// The player's active tracking aura, behind `GetTrackingTexture`.
pub(crate) tracking: Option<super::aura::TrackingState>,
/// `TargetUnit`, `AssistUnit` and `TargetLastEnemy` calls in call order, one queue as the
/// reference routes all three through one helper; an unresolvable one is a no-op, as there.
pub(crate) selection_requests: Vec<super::SelectionRequest>,
/// `TargetNearestFriend` calls, `true` for the reverse cycle.
pub(crate) target_nearest_friend_requests: Vec<bool>,
/// `TargetByName(name, exactMatch)` calls, for the app's by-name resolver.
pub(crate) target_by_name_requests: Vec<(String, bool)>,
/// `ClearTarget()` fired with a live target, the last step of `ToggleGameMenu`'s ESC chain.
pub(crate) target_clear: bool,
/// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item.
pub(crate) drop_item_on_unit: Vec<String>,
/// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor.
pub(crate) spell_target_unit: Vec<String>,
/// Channels the server confirmed, in join order: the numbers `GetChannelName` answers.
pub(crate) joined_channels: Vec<Option<String>>,
/// The party or raid roster the app pushes; the default is not in a group.
pub(crate) party: party::PartyState,
/// Party and loot-method calls (`AcceptGroup`, `InviteToParty`, `SetLootMethod`, …) queued.
pub(crate) party_requests: Vec<party::PartyRequest>,
/// The ready-check deadline and the unanswered flags the timeout tick reads.
pub(crate) ready_check: party::ReadyCheckState,
/// Saved raid lockouts from `SMSG_RAID_INSTANCE_INFO`, which outlive any roster change.
pub(crate) saved_instances: Vec<party::SavedInstanceInfo>,
/// `SetRaidRosterSelection`'s raid row index, client-side only.
pub(crate) raid_selection: i64,
/// The current map's `Map.dbc` `InstanceType`, all `IsInInstance()` reads; `None` for no row.
pub(crate) instance_type: Option<u32>,
/// `CanShowResetInstances()`, the reference's four-term predicate (`0x495c90`), app-computed.
pub(crate) can_reset_instances: bool,
/// `ResetInstances()` calls queued.
pub(crate) reset_instance_asks: u32,
/// Friends, ignores and the last `/who`, display-resolved as the reference resolves them.
pub(crate) social: social::SocialState,
/// Social calls (`AddFriend`, `RemoveFriend`, `SendWho`, …) queued.
pub(crate) social_requests: Vec<social::SocialRequest>,
/// The client-local LFG slot words (`[0xbc70a0]`) and comment (`[0xbc6e98]`, 0x80 bytes) that
/// `SetLookingForGroup` writes and `GetLookingForGroup` reads; the reference's slots stay 0.
pub(crate) lfg_slots: [u32; 3],
pub(crate) lfg_comment: String,
/// Guild roster, ranks, MOTD and info, sorted and filtered by the app, which holds the toggles.
pub(crate) guild: guild::GuildState,
/// The rank editor's unsaved edits, apart from `guild` so a push cannot discard them.
pub(crate) guild_control: guild::GuildRankEdit,
/// Guild calls (`GuildInviteByName`, `GuildSetMOTD`, `GuildControlSaveRank`, …) queued.
pub(crate) guild_requests: Vec<guild::GuildRequest>,
/// The charter price and open petition, names resolved from caches as the reference does.
pub(crate) petition: petition::PetitionState,
/// Charter calls (`BuyGuildCharter`, `SignPetition`, `TurnInGuildCharter`, …) queued.
pub(crate) petition_requests: Vec<petition::PetitionRequest>,
/// Per chat window from `ChatFrame1`, the tint, alpha and font size its tab menu can change.
pub(crate) chat_window_looks: [chat_window::ChatWindowLook; chat_window::NUM_CHAT_WINDOWS],
/// 0-based windows whose look Lua changed, the persist cue.
pub(crate) chat_window_changes: HashSet<usize>,
/// Chat-type colours (`0xb4e518`): 94 fixed and 10 `CHANNELn`, overlaid by `chat-cache.txt`.
pub(crate) chat_colors: Vec<super::chat_types::ChatTypeColor>,
pub(crate) chat_colors_changed: bool,
/// The languages this character knows, in `Languages.dbc` row order.
pub(crate) known_languages: Vec<String>,
pub(crate) zone_channel_catalog: Vec<super::channel::ZoneChannelRow>,
pub(crate) channel_commands: Vec<super::channel::ChannelCommand>,
/// The recruitment auto-join latch `[0x843608]`: 0 `STANDARD`, 1 `AUTO` (`0x49ea70` maps any
/// other word to 1). Boots at 1, as the reference writes `AUTO` for an untouched option.
pub(crate) guild_recruitment_mode: u8,
/// Any `SetGuildRecruitmentMode(1)`, even unchanged: the reference runs the cascade `0x49ea90`.
pub(crate) guild_recruitment_cascade: bool,
/// Lua changed the recruitment mode: the chat cache's dirty signal.
pub(crate) guild_recruitment_changed: bool,
/// `DoEmote` calls queued.
pub(crate) emote_requests: Vec<super::chat_misc::EmoteRequest>,
/// `RandomRoll` calls queued.
pub(crate) roll_requests: Vec<(u32, u32)>,
/// `UninviteByName` calls queued.
pub(crate) uninvite_requests: Vec<String>,
/// `ConsoleExec` lines that were not CVar writes.
pub(crate) console_lines: Vec<String>,
/// SuperWoW's `TrackUnit`/`UntrackUnit` set, by guid: the units the minimap draws a tracking
/// dot for on top of whatever the player's own tracking spell finds
/// (`benilla-app`'s `minimap::blips`). Sorted, so the app can binary-search it per candidate
/// without building an index of its own.
pub(crate) tracked_units: std::collections::BTreeSet<u64>,
/// SuperWoW's `CombatLogAdd` lines, queued for the app to append to `WoWCombatLog.txt`
/// (`compat/superwow.rs`). Not chat: nothing is printed to a window.
pub(crate) combat_log_adds: Vec<String>,
/// `LoggingChat` and `LoggingCombat`'s flags, and their change cue.
pub(crate) logging_chat: bool,
pub(crate) logging_combat: bool,
pub(crate) logging_changed: bool,
/// A user-placed frame moved or resized, or `SetUserPlaced` ran: the layout cache's save cue.
pub(crate) user_placed_changed: bool,
/// The default chat language; `None` is no player, so `GetDefaultLanguage()` returns nothing.
pub(crate) default_language: Option<String>,
/// `AcceptDuel`, `CancelDuel` and `StartDuel*` calls; Lua sees duels only through events.
pub(crate) duel_requests: Vec<duel::DuelRequest>,
/// `FollowUnit` and `FollowByName` calls; Lua sees following only as `AUTOFOLLOW_*` events.
pub(crate) follow_requests: Vec<follow::FollowRequest>,
/// `SetView`, `SaveView`, `ResetView`, `NextView`, `PrevView` and `FlipCameraYaw` calls; the
/// views reach Lua only as CVars, `cameraView` and `camera{Distance,Pitch,Yaw}{,A..D}`.
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// `TogglePVP` calls, a count because `CMSG_TOGGLE_PVP` has no body.
pub(crate) pvp_toggles: u32,
/// The player's private honor fields; before the first push the six self getters read zeros.
pub(crate) honor: Option<pvp::HonorState>,
/// The last `MSG_INSPECT_HONOR_STATS` reply; its presence is `HasInspectHonorData`'s answer.
pub(crate) inspect_honor: Option<pvp::InspectHonorData>,
/// `RequestInspectHonorData` calls; the pending gate keeps this at 0 or 1.
pub(crate) inspect_honor_requests: u32,
/// An inspect-honor query is out (`0xb71fcc`); `RequestInspectHonorData` (`0x4c80a0`) refuses
/// while set. Cleared by a reply (`0x4c6f4c`) or by the app clearing the slot (`0x4c6f9d`).
pub(crate) inspect_honor_pending: bool,
/// `ShowingHelm()`: `PLAYER_FLAGS`' `HIDE_HELM` bit as pushed, set ahead on `ShowHelm` because
/// the wire verb is a blind toggle. Starts shown, as a fresh character's flags are 0.
pub(crate) helm_shown: bool,
/// `ShowingCloak()`, the same for `HIDE_CLOAK`.
pub(crate) cloak_shown: bool,
/// Flips from `ShowHelm`/`ShowCloak`, one `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` each.
pub(crate) worn_display_toggles: Vec<super::worn_display::WornDisplay>,
/// `PLAYER_FIELD_BYTES` byte 2 (descriptor `+0x102a`), which extra action bars are on. Only
/// the server moves it, as in the reference: `SetActionBarToggles` leaves it a round trip late.
pub(crate) action_bar_toggles: Option<u8>,
/// One `CMSG_SET_ACTIONBAR_TOGGLES` payload per `SetActionBarToggles` call, ungated.
pub(crate) action_bar_toggle_sends: Vec<u8>,
/// `PlaySound` and `PlaySoundFile` calls queued.
pub(crate) sound_queue: Vec<SoundRequest>,
/// `PlayMusic` and `StopMusic` calls in call order, for the Lua music stream (`[0xb06ccc]`).
pub(crate) music_queue: Vec<MusicRequest>,
/// The UI-load sound-suppression depth, the reference's `[0xb05fa0]` (`0x458f50` inc,
/// `0x458f60` dec), held across the whole UI load `0x48fbf0`, so login and `/reloadui` are
/// silent. Only name-keyed `PlaySound` (`0x458030`) reads it: the call is dropped before any
/// lookup yet answers `(willPlay, handle)` as usual. `PlaySound(kitId)` (`0x457fb0`),
/// `PlaySoundFile` and music play normally.
pub(crate) sound_suppression: u32,
/// The CVar table, by lowercased name.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
/// Saved CVar values, set before any registration so a CVar starts at its saved value: the
/// reference's table outlives `ReloadUI`, while ours is per VM.
pub(crate) cvars_saved_base: HashMap<String, String>,
/// `(registered name, new value)` per Lua `SetCVar`, the app's sync and save cue.
pub(crate) cvar_changes: Vec<(String, String)>,
/// `(name, default)` per addon `RegisterCVar` that created a slot.
pub(crate) cvar_registrations: Vec<(String, String)>,
pub(crate) cvars_warned: HashSet<String>,
/// The adapter's multisample formats in dropdown order, the reference's list `[0xb4b444]` of
/// `{colorBits, depthBits, multisample}` (count `[0xb4b440]`, built by `0x48c3e0`).
pub(crate) multisample_formats: Vec<super::cvars::MultisampleFormat>,
/// This display's resolutions, ascending, behind `GetScreenResolutions`; empty until pushed.
pub(crate) screen_resolutions: Vec<super::cvars::ScreenResolution>,
/// The live size's index in that list, which the host always includes; `None` while the list
/// is empty. `GetCurrentResolution` answers it 1-based.
pub(crate) current_resolution: Option<usize>,
/// What this device and presentation path offer, behind `GetVideoCaps`.
pub(crate) video_caps: super::cvars::VideoCaps,
/// `RestartGx()` calls, the video window's apply button.
pub(crate) restart_gx_asks: u32,
/// Globals `RegisterForSave` declared, in order: written at logout and re-run at load.
pub(crate) saved_names: Vec<String>,
/// Saved-variables files that failed to load; the shutdown write leaves them untouched.
pub(crate) held_saved_files: Vec<std::path::PathBuf>,
/// The key-binding table the Key Bindings window edits, with its account and character sets.
pub(crate) keybinds: super::keybind::KeybindState,
/// Action slots by Lua action id, 1..120.
pub(crate) actions: HashMap<u32, ActionSlot>,
/// Per action, its usable, range, current and cooldown state; an absent one reads cold.
pub(crate) action_states: HashMap<u32, super::action::StoredActionState>,
pub(crate) bonus_bar_offset: u8,
pub(crate) action_uses: Vec<super::action::ActionUse>,
/// `(action id, packed)` per slot `PickupAction`/`PlaceAction` changed, 0 clearing it: one
/// `CMSG_SET_ACTION_BUTTON` each, so a drag swap is two sends.
pub(crate) action_sets: Vec<(u32, u32)>,
/// GlobalStrings keys of refusals this crate raises, fired by the app as `UI_ERROR_MESSAGE`
/// (the reference's `DisplayError` `0x496720`), each a literal from `0xb4b498` (stride `0x14`).
pub(crate) ui_errors: Vec<&'static str>,
pub(crate) spellbook: spellbook::SpellBookState,
/// Live usability per known spell, by spell id ([`SpellUsable`]).
pub(crate) spell_usable: std::collections::HashMap<u32, SpellUsable>,
/// `SpellIcon.dbc` lookup ([`SpellIconProbe`]).
pub(crate) spell_icon_probe: Option<SpellIconProbe>,
pub(crate) item_icon_probe: Option<ItemIconProbe>,
pub(crate) spell_modifier_probe: Option<SpellModifierProbe>,
pub(crate) spell_rec_field_probe: Option<SpellRecFieldProbe>,
pub(crate) spell_record_probe: Option<SpellRecordProbe>,
pub(crate) spell_range_probe: Option<SpellRangeProbe>,
pub(crate) custom_files: Option<CustomFileAccess>,
pub(crate) password_encrypt_probe: Option<PasswordEncryptProbe>,
/// `DisenchantAll` calls, drained by `benilla-app`'s `drain_spell_casts`.
pub(crate) disenchant_asks: Vec<DisenchantAsk>,
/// nampower's queue requests, drained by `benilla-app`'s `drain_spell_casts`.
pub(crate) queue_requests: Vec<QueueRequest>,
/// `ChannelStopCastingNextTick()` calls; the app cancels the channel on the next frame.
pub(crate) channel_stop_next_tick: u32,
/// `EncryptedServerLogin(username, blob)` calls, drained by `benilla-app`'s
/// `ui_script::drain_credential_logins`.
pub(crate) credential_logins: Vec<(String, String)>,
/// Creature display id to model path ([`CreatureModelProbe`]).
pub(crate) creature_model_probe: Option<CreatureModelProbe>,
/// The pet's spellbook, a second array as in the reference (`0xb700f0`, `0xb6f098`).
pub(crate) pet_book: spellbook::PetBookState,
/// The player's macros, owned here as 1.12 macros have no server side; the app persists them.
pub(crate) macros: macros::MacroState,
/// A script changed the macros: the save and `UPDATE_MACROS` cue.
pub(crate) macros_dirty: bool,
/// Bumped by every seed and change, for readers that must not drain `macros_dirty`.
pub(crate) macros_generation: u64,
/// The macro icon paths from `SpellIcon.dbc`, behind `GetMacroIconInfo`.
pub(crate) macro_icons: Vec<String>,
/// The modern enumeration's two buckets, behind `GetMacroIcons`/`GetMacroItemIcons` and so
/// behind `IconDataProviderMixin`: `(ABILITY_*`/`SPELL_*`, `INV_*)` as **uppercase basenames**
/// without the `Interface\Icons\` prefix or an extension, which is the shape upstream's own
/// four mutators push and the mixin re-prefixes. The spell bucket is the same files
/// `macro_icons` above holds, differently spelled; the item bucket is the ~2.4k `INV_*` icons
/// 1.12's own chooser filter throws away.
pub(crate) macro_icon_buckets: (Vec<String>, Vec<String>),
/// 1-based macro index to the spell id that macro casts, the reference's own `[rec+0x564]`,
/// behind ClassicAPI's `GetMacroSpell`. Derived app-side and fed in
/// ([`UiScript::set_macro_bound_spells`](crate::script::UiScript::set_macro_bound_spells)),
/// which is what `script/macros.rs`'s header already said this fact would need: the slash
/// alias table and the player's book, neither of which this crate owns. Only macros that bind
/// a *resolved* spell have a row; "casts nothing" and "casts something unknown" are both
/// absence, because `GetMacroSpell` has one way to say either.
pub(crate) macro_bound_spells: std::collections::HashMap<u32, u32>,
/// Spell ids `CastSpell` queued.
pub(crate) spell_casts: Vec<u32>,
/// SuperWoW: `CastSpellByName(name, "CLICK")` ids, cast at the `"mouseover"` unit instead of
/// the current selection (`CastSpellByName`'s doc, `script/spellbook.rs`); a separate queue
/// so the stock `spell_casts` path (current-selection target) is untouched.
pub(crate) mouseover_spell_casts: Vec<u32>,
/// nampower's `CastSpellNoQueue` / `CastSpellByNameNoQueue` ids: the same send as
/// [`Self::spell_casts`] with the press queue bypassed, which is upstream's whole reason for
/// the pair. A separate queue rather than a flag per id, because the app's drain reaches the
/// two through different ladder entries (`send` and `send_now`) and a list it has to re-sort
/// is a list it can get wrong.
pub(crate) no_queue_spell_casts: Vec<u32>,
/// `CastSpell(id, "pet")` ids, sent as `CMSG_PET_ACTION` with a type-1 word (`0x4b34ce`).
pub(crate) pet_spell_casts: Vec<u32>,
/// `ToggleSpellAutocast` ids for `CMSG_PET_SPELL_AUTOCAST` (`0x2F3`), which names a spell.
pub(crate) pet_spell_autocasts: Vec<u32>,
/// An auto-repeat or cast that `SpellStopCasting()` can stop, not a channel (`0x6e6e80`). Its
/// 1 or nil matters: the ESC chain (`UIParent.lua:1489`) reaches `CloseAllWindows()` on nil.
pub(crate) casting: bool,
/// `SpellStopCasting()` fired while casting: the ESC cancel.
pub(crate) spell_stop: bool,
/// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`,
/// whose nil the ESC chain falls through on (`UIParent.lua:1490`).
pub(crate) spell_targeting: bool,
/// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit
/// frames before they call `SpellTargetUnit`.
pub(crate) spell_targetable_units: HashSet<String>,
/// `SpellStopTargeting()` fired while targeting: the ESC targeting cancel.
pub(crate) spell_stop_targeting: bool,
pub(crate) talents: super::talent::TalentUiState,
/// `LearnTalent(tab, index)` calls queued.
pub(crate) talent_learns: Vec<(u32, u32)>,
/// `ConfirmTalentWipe()` calls, one `MSG_TALENT_WIPE_CONFIRM` each; the app holds the trainer.
pub(crate) talent_wipe_confirms: u32,
/// `CheckTalentMasterDist()`: the respec question is live and in range; false hides its dialog.
pub(crate) talent_master_pending: bool,
// ── The dialog engine's verbs (`0x48dca0` is the first below) ──
/// `ConfirmPetUnlearn()` calls; the app holds the trainer and the money gate.
pub(crate) pet_unlearn_confirms: u32,
/// `CheckPetUntrainerDist()`: the pet trainer's question is pending and in reach.
pub(crate) pet_untrainer_pending: bool,
/// `GetInstanceBootTimeRemaining()` in whole seconds, from the `SMSG_RAID_GROUP_ONLY` deadline.
pub(crate) instance_boot_secs: u32,
/// A cached area spirit healer (`[0xb4e330/334]`) and seconds to its wave (`[0xb4e338]`).
pub(crate) area_spirit_healer_cached: bool,
pub(crate) area_spirit_secs: u32,
/// `AcceptAreaSpiritHeal()` calls, one `CMSG_AREA_SPIRIT_HEALER_QUEUE` each.
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: pushed rows, faction filter, sort order.
pub(crate) battlefield_board: super::battlefield_score::ScoreBoard,
/// `GetBattlefieldInstanceRunTime()`, pushed each frame.
pub(crate) battlefield_run_time_ms: u32,
/// `RequestBattlefieldScoreData()` calls.
pub(crate) battlefield_score_requests: u32,
/// `LeaveBattlefield()` calls that passed the "ended" gate.
pub(crate) battlefield_leave_requests: u32,
/// The battleground instance list and its scalars, as pushed.
pub(crate) battlefield_list: super::battlefield_queue::BattlefieldListView,
/// The selected instance id (`[0xb6eba0]`), not an index; written by `SetSelectedBattlefield`.
pub(crate) battlefield_selected: u32,
/// The three queue slots, pushed each frame with their clocks reduced to values.
pub(crate) battlefield_slots: Vec<super::battlefield_queue::BattlefieldQueueSlot>,
/// `GetBattlefieldInstanceExpiration()`, pushed each frame.
pub(crate) battlefield_instance_expiration_ms: u32,
/// `JoinBattlefield` calls, `(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>,
/// Teammates' map positions, the flag carrier, the icon scale and position requests.
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; the app gates on leadership.
pub(crate) meeting_stone_cancels: u32,
/// The meeting stone's queued area (`[0xb72038]`) and status text (`[0xb7203c]`), as pushed.
pub(crate) meeting_stone_area: u32,
pub(crate) meeting_stone_status_text: Option<String>,
/// Acknowledged tutorials, `None` before `SMSG_TUTORIAL_FLAGS`; all `TutorialsEnabled()` reads.
pub(crate) tutorial_bank: Option<Vec<u8>>,
/// `FlagTutorial(n)` calls, 0-based and in range.
pub(crate) tutorial_flag_requests: Vec<u32>,
pub(crate) tutorial_clears: u32,
pub(crate) tutorial_resets: u32,
/// The stance bar's forms, in bar order.
pub(crate) shapeshift_forms: Vec<super::shapeshift::StoredShapeshiftForm>,
/// Form spell ids `CastShapeshiftForm` queued.
pub(crate) shapeshift_casts: Vec<u32>,
/// The pet bar's ten slots and two bar-wide bits, replaced whole by every `SMSG_PET_SPELLS`.
pub(crate) pet_bar: super::pet::PetBarState,
/// 1-based slots `CastPetAction` queued.
pub(crate) pet_actions_pressed: Vec<u32>,
/// 1-based slot indices `TogglePetAutocast` queued.
pub(crate) pet_autocast_toggles: Vec<u32>,
/// `PetStopAttack()` calls.
pub(crate) pet_stop_attacks: u32,
/// `PetAttack`, `PetFollow`, `PetWait` and mode orders, as the packed word of their bar slot.
pub(crate) pet_orders: Vec<u32>,
/// `HasFullControl`, the reference's `[0xb4b3e4]`: set by `SMSG_CLIENT_CONTROL_UPDATE` for the
/// player, boots 1, and every cast, item and cursor gate refuses at 0.
pub(crate) player_control: bool,
/// One `CMSG_PET_SET_ACTION` per entry, its one or two `(position, packed word)` pairs: the
/// server tells the forms apart by body size, so a move's two pairs must not be flattened.
pub(crate) pet_set_actions: Vec<Vec<(u32, u32)>>,
/// `PetAbandon()` and `PetDismiss()` calls, counted apart though both end at one opcode.
pub(crate) pet_abandons: u32,
pub(crate) pet_dismisses: u32,
/// Names `PetRename` queued, from the `PETRENAMECONFIRM` popup.
pub(crate) pet_renames: Vec<String>,
/// Bag contents by API bag id (0 is the backpack), and the queued `UseContainerItem` calls.
pub(crate) containers: HashMap<i64, container::ContainerState>,
pub(crate) container_uses: Vec<(i64, u32)>,
/// Per `(bag, slot)`, `(start, duration, enabled)` in `GetTime` seconds, stamped at push.
pub(crate) container_cooldowns: HashMap<(i64, u32), (f64, f64, bool)>,
/// `HasKey()`: a keys-family item in equipment, bags, bank or keyring; gates the keyring UI.
pub(crate) has_key: bool,
/// ClassicAPI's bag-sort preference toggles (`compat/classic_api/container.rs`): 1.12 has no
/// bag-sort feature at all, so these are stored preferences with nothing downstream reading
/// them yet — a real `SortBags`/`SortBankBags` would need a sort algorithm this client doesn't
/// have, not just a flag.
pub(crate) bag_sort_right_to_left: bool,
pub(crate) backpack_autosort_disabled: bool,
pub(crate) bank_autosort_disabled: bool,
/// What the cursor carries; nothing moves locally, the server's updates settle the bags.
pub(crate) cursor: Option<cursor::CursorPayload>,
/// The action bar's drop grid: shown on the cursor's empty-to-held edge for any payload but a
/// pet action or a vendor row (mode 5; the grab setter `0x4950f0` shows it for mode 7 only).
pub(crate) cursor_grid_shown: bool,
/// The pet bar's grid, lit only by a pet-action pickup (`0x494f28`); never both grids at once.
pub(crate) pet_grid_shown: bool,
/// The world pick under the cursor (`[this+0x350]`): a world click drops no payload on an
/// object, only an item on terrain, and anything over nothing.
pub(crate) world_pick: cursor::WorldPick,
/// Held-item bag moves, sent as `CMSG_SWAP_INV_ITEM` or `CMSG_SPLIT_ITEM`.
pub(crate) container_moves: Vec<container::ContainerMove>,
/// `(bag, slot)` clicks made in repair mode.
pub(crate) container_repairs: Vec<(i64, u32)>,
/// Targeting wants an item (`0x6e6330`): a bag or paper-doll click becomes a pick.
pub(crate) item_pick_armed: bool,
/// Picked `(bag, slot)`s; a paper-doll click is `EQUIPMENT_BAG` and its 1-based inventory slot.
pub(crate) item_picks: Vec<(i64, u32)>,
/// `BindEnchant()` and `ReplaceEnchant()`, the enchant popups' answers.
pub(crate) enchant_confirms: Vec<cursor::EnchantConfirm>,
/// `DeleteCursorItem`'s `(bag, slot, count)`, 0 for the whole stack, as `CMSG_DESTROYITEM`.
pub(crate) container_destroys: Vec<(i64, u32, u32)>,
/// The wrapping paper a right-click armed (`0x5edea0`: lock it, cursor mode 2, no packet). Not
/// a cursor payload: the reference sets only the cursor mode, so `CursorHasItem()` stays nil.
pub(crate) pending_wrap: Option<container::PendingWrap>,
/// `(giftBag, giftSlot, itemBag, itemSlot)` per completed wrap, in `CMSG_WRAP_ITEM`'s order.
pub(crate) container_wraps: Vec<(i64, u32, i64, u32)>,
/// The cursor mode Lua set (`0xbe2c2c`); `ResetCursor` returns it to the world's mode.
pub(crate) ui_cursor: Option<container::UiCursorMode>,
/// A FrameXML cursor call ran. The mode is a write, not a level: `0xbe2c2c` keeps its value
/// until the world or a hover handler writes it, so a handler-less frame leaves it be.
pub(crate) ui_cursor_dirty: bool,
/// `AutoEquipCursorItem`'s `(bag, slot)` sources, sent as `CMSG_AUTOEQUIP_ITEM`.
pub(crate) container_autoequips: Vec<(i64, u32)>,
/// `PutItemInBag` and `PutItemInBackpack` stores, sent as `CMSG_AUTOSTORE_BAG_ITEM`, or
/// `CMSG_SPLIT_ITEM` for a split stack.
pub(crate) bag_autostores: Vec<container::BagAutoStore>,
/// `RegisterForDrag` buttons per frame, compared case-insensitively. Nothing destroys a frame,
/// so this, `scripts` and `frame_events` are never pruned; a destroy path must prune all three.
pub(crate) drag_registered: HashMap<FrameHandle, HashSet<String>>,
/// The drag gesture, armed by a press on a drag-registered frame, started past the threshold.
pub(crate) drag: Option<cursor::DragGesture>,
/// The one `StartMoving()` in flight, the client's `root+0xcfc` slot, apart from `drag` as in
/// the reference: a move outlives the button, since mouse-up's auto-stop skips a Lua drag.
pub(crate) moving: Option<super::object::FrameMove>,
/// The `StartSizing` in flight, cleared by `StopMovingOrSizing` (`0x776990`).
pub(crate) sizing: Option<super::object::FrameSizing>,
/// A Slider thumb drag, left press to release or pointer leave, handled here with no Lua.
pub(crate) slider_drag: Option<slider::SliderDrag>,
/// A ColorSelect wheel or value-bar drag, press to release.
pub(crate) color_drag: Option<colorselect::ColorDrag>,
/// The open gossip menu, the `SelectGossipOption` calls and whether `CloseGossip` ran.
pub(crate) gossip: Option<gossip::GossipMenu>,
pub(crate) gossip_selects: Vec<u32>,
pub(crate) gossip_close: bool,
/// `SelectGossipQuest` 1-based rows, each sent as `CMSG_QUESTGIVER_QUERY_QUEST` for its quest.
pub(crate) gossip_quest_selects: Vec<u32>,
/// The open vendor's stock, the `BuyMerchantItem` calls and whether `CloseMerchant` ran.
pub(crate) merchant: Option<merchant::MerchantState>,
/// `GetRepairAllCost`'s total, which the app pushes every frame a vendor is open.
pub(crate) repair_all_cost: u32,
pub(crate) merchant_buys: Vec<(u32, u32)>,
/// The held `(bag, slot)` when `PickupMerchantItem` sells, sent as `CMSG_SELL_ITEM`.
pub(crate) merchant_cursor_sells: Vec<(i64, u32)>,
/// `(bag, slot)` of each grey stack `C_MerchantFrame.SellAllJunkItems` asked to sell. Its
/// own queue rather than sharing the cursor one above, whose name is a statement about where
/// the item came from; the app drains both into the same `CMSG_SELL_ITEM` loop.
pub(crate) merchant_junk_sells: Vec<(i64, u32)>,
/// A held vendor row dropped in a bag, `(bag, slot, item entry)`, for `CMSG_BUY_ITEM_IN_SLOT`.
pub(crate) merchant_slot_buys: Vec<(i64, u32, u32)>,
pub(crate) merchant_close: bool,
/// `BuybackItem` 1-based slots, the `RepairAllItems` flag and repair mode (`InRepairMode`).
pub(crate) merchant_buybacks: Vec<u32>,
pub(crate) repair_all: bool,
pub(crate) repair_mode: bool,
/// The stable window: snapshot, selection, drag and queued calls, reset together on close.
pub(crate) stable: stable::StableModel,
/// The open bank's slot-purchase row and calls; its contents are bags -1 and 5..=10.
pub(crate) bank: Option<bank::BankState>,
pub(crate) bank_purchase: bool,
pub(crate) bank_close: bool,
/// The open trainer, its buys, selection and close. The selection is a spell id, since
/// filters, collapses and re-lists all move row numbers.
pub(crate) trainer: Option<trainer::TrainerState>,
pub(crate) trainer_buys: Vec<u32>,
pub(crate) trainer_selection: Option<u32>,
pub(crate) trainer_close: bool,
/// Available, unavailable and used filters, all on by default; they hide services, not headers.
pub(crate) trainer_filter: [bool; 3],
/// Collapsed skill-line ids, kept across a re-push and cleared when the trainer closes.
pub(crate) trainer_collapsed: HashSet<u32>,
/// The open taxi map, the `TakeTaxiNode` calls, whether `CloseTaxiMap` ran and whether we ride.
pub(crate) taxi: Option<taxi::TaxiUiState>,
pub(crate) taxi_takes: Vec<usize>,
pub(crate) taxi_close: bool,
pub(crate) taxi_riding: bool,
/// The whole `TaxiNodes.dbc` table for ClassicAPI's `C_TaxiMap.GetTaxiNodesForMap`, resolved
/// to zones and map percentages app-side. Nothing to do with [`Self::taxi`]: that is the open
/// window's *known* nodes and is cleared when it closes, this is static file data that stands
/// for the session (`compat::classic_api::taxi_map`).
pub(crate) taxi_catalog: Vec<taxi::TaxiMapNode>,
/// The open tradeskill window's recipes.
pub(crate) trade_skill: Option<tradeskill::TradeSkillState>,
/// `DoTradeSkill` calls, `(spell id, count)`.
pub(crate) trade_skill_dos: Vec<(u32, u32)>,
/// The 1-based selection, 0 for none, carried across a re-push by the recipe's spell id.
pub(crate) trade_skill_selection: u32,
pub(crate) trade_skill_close: bool,
/// Collapsed recipe groups by `(ItemClass, ItemSubClass)` (`0x55ba30`), kept across a re-push
/// and a same-profession reopen like the mask `0x84dd68`; a profession switch resets them.
pub(crate) trade_skill_collapsed: HashSet<(u32, u32)>,
/// Groups the SubClass filter hides, empty for all, kept by key like the client's header flag
/// (`header+0xc`, `0x4fca20`, mask `0x84dd60`); a profession switch resets them.
pub(crate) trade_skill_subclass_hidden: HashSet<(u32, u32)>,
/// The InvSlot filter mask (`0x84dd64`), a set bit shown and all ones for all slots. Never
/// pruned on a re-push, as in the client, so a later slot stays hidden; a switch resets it.
pub(crate) trade_skill_invslot_mask: u32,
/// The skill line the state was last built for (`0xbde064`): a push for another line resets
/// the filters, collapses and selection; the same line keeps them, even across a reopen.
pub(crate) trade_skill_last_line: u32,
/// The selected recipe's spell id (`0xbde044`), which carries it across reopen and re-push.
pub(crate) trade_skill_selected_spell: u32,
/// The open craft window's recipes.
pub(crate) craft: Option<craft::CraftState>,
/// `DoCraft` spell ids; no count, as the 1.12 CraftFrame has no Create All.
pub(crate) craft_dos: Vec<u32>,
/// The 1-based selection, 0 for none, carried across a re-push by the recipe's spell id.
pub(crate) craft_selection: u32,
pub(crate) craft_close: bool,
/// `EquipPendingItem`/`CancelPendingEquip` answers in order, and `ConfirmBindOnUse()` calls.
pub(crate) pending_equip_answers: Vec<bind_confirm::PendingEquipAnswer>,
pub(crate) bind_on_use_confirms: u32,
/// The open loot, the row clicks and whether `CloseLoot` ran. A click is the reference's take
/// `0x4c2790` with flag 0, raising `LOOT_BIND` for a bind-on-pickup row (the C `CLootButton`,
/// here `BenillaTakeLootSlot`); `LootSlot` passes 1 (`0x4c2e70`), taking it after the confirm.
pub(crate) loot: Option<loot::LootState>,
pub(crate) loot_picks: Vec<u32>,
/// `LootSlot(slot)` rows, 1-based, apart from clicks for the reference's pending-slot gate.
pub(crate) loot_confirms: Vec<u32>,
pub(crate) loot_close: bool,
/// `GiveMasterLoot(slot, candidateIndex)`, both 1-based display positions the app translates.
pub(crate) loot_master_gives: Vec<(u32, u32)>,
/// The open group-loot rolls, and the `RollOnLoot` `(roll_id, roll_type)` votes.
pub(crate) loot_rolls: loot_roll::LootRollsState,
pub(crate) loot_roll_votes: Vec<(u32, u8)>,
/// Need or Greed on a bind-on-pickup roll: a `CONFIRM_LOOT_ROLL` popup ask, not a vote.
pub(crate) loot_roll_confirms: Vec<(u32, u8)>,
/// The item-text reader: the pushed page, then the close and page-turn calls.
pub(crate) item_text: Option<item_text::ItemTextState>,
pub(crate) item_text_close: bool,
pub(crate) item_text_page_turns: Vec<i32>,
/// The open inbox and mail calls, rows 1-based: `mail_opens` are rows `GetInboxText` read.
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 item, resolved to its guid when the send fires.
pub(crate) mail_send_item: Option<cursor::CursorItem>,
/// The usable stationery, in the picker's order.
pub(crate) mail_stationeries: Vec<mail::StationeryView>,
/// `SelectStationery`'s `Stationery.dbc` id; 0 is none, which silences `SendMail`.
pub(crate) mail_stationery: u32,
/// `HasNewMail()` (`0x4afea0`), from `MSG_QUERY_NEXT_MAIL_TIME` and `SMSG_RECEIVED_MAIL`.
pub(crate) has_new_mail: bool,
/// The open auction house and the auction calls. `auction_selected` is each list's selected
/// auction id, 0 for none (`0x4cfda0`/`0x4cfec0`); `auction_can_query` is the 5 s throttle.
pub(crate) auction: Option<auction::AuctionState>,
/// The Browse tab's class tree, pushed once per login.
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 item, resolved to its guid when `StartAuction` fires.
pub(crate) auction_sell_item: Option<cursor::CursorItem>,
/// The open trade and its calls: `InitiateTrade` tokens, the accept, unaccept and cancel flags,
/// and `SetTradeMoney` copper (`CMSG_SET_TRADE_GOLD`).
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()` and `CancelTrade()` calls.
pub(crate) trade_begin: bool,
pub(crate) trade_cancel: bool,
pub(crate) trade_set_money: Option<u32>,
/// Items dropped on our slots, `(1-based trade slot, bag, slot)`, and the slots clicks cleared.
pub(crate) trade_set_items: Vec<(u32, i64, u32)>,
pub(crate) trade_clear_items: Vec<u32>,
/// The open questgiver panel, its greeting-row selects and its button calls.
pub(crate) quest: Option<quest::QuestState>,
pub(crate) quest_selects: Vec<quest::QuestSelect>,
pub(crate) quest_actions: Vec<quest::QuestAction>,
/// Death countdowns and offers, and the release, reclaim and resurrect calls.
pub(crate) death: death::DeathUiState,
pub(crate) death_actions: Vec<death::DeathAction>,
/// The quest log, selection (1-based), abandon mark (a quest id, `0xbb7484`) and abandons.
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>,
/// `QuestLogPushQuest()` quest ids, resolved at the click so a log change cannot retarget one.
pub(crate) quest_log_pushes: Vec<u32>,
/// `ConfirmAcceptQuest()` calls, the escort confirm; the app holds the pending quest.
pub(crate) quest_confirms: u32,
/// Item id to its tooltip view.
pub(crate) item_templates: HashMap<u32, ItemTemplateView>,
/// Item ids asked for and missing, for the app to answer.
pub(crate) item_stat_asks: HashSet<u32>,
/// Set id to its tooltip set block (`0x854b1c`), and the misses asked for.
pub(crate) item_sets: HashMap<u32, super::ItemSetView>,
pub(crate) item_set_asks: HashSet<u32>,
/// `ItemRandomProperties` id to its view, pushed whole at startup: a clicked tooltip cannot
/// repaint on a late answer. The reference reads its loaded store `0xc0dbd4` (`0x52c991`).
pub(crate) random_properties: HashMap<u32, super::RandomPropertyView>,
/// The player's level, class, race and skills, for a tooltip's red requirement lines.
pub(crate) player_req: PlayerReqState,
/// Spell id to its tooltip view, and the misses asked for.
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) spell_tooltip_asks: HashSet<u32>,
/// `CollapseQuestHeader`/`ExpandQuestHeader` as `(1-based entry, collapse)`, entry 0 for all.
pub(crate) quest_log_collapses: Vec<(u32, bool)>,
/// Watched quest ids in watch order, pruned when a quest leaves the log.
pub(crate) quest_log_watched: Vec<u32>,
/// The server's clock in Unix seconds, `None` before `SMSG_QUERY_TIME_RESPONSE`. A quest
/// deadline is a stamp in it, and `GetQuestTimers` subtracts per call, as the reference does.
pub(crate) server_unix_time: Option<f64>,
/// `GetMoney()` in copper, from `PLAYER_FIELD_COINAGE`.
pub(crate) money: u64,
/// `GetNetStats`'s third return, the average round trip in ms; 0 before any sample.
pub(crate) net_latency_ms: u32,
/// `UnitXP("player")` and `UnitXPMax("player")`, from the private `PLAYER_XP` fields.
pub(crate) player_xp: u32,
pub(crate) player_next_level_xp: u32,
/// Raw combo points (`PLAYER_FIELD_BYTES` byte 1) and target, ungated: a warrior banks them too
/// (Overpower); `GetComboPoints` (`0x51a190`) applies the class and current-target gates.
pub(crate) combo_points: u8,
pub(crate) combo_target: u64,
/// `PLAYER_BYTES_2` byte 3 (1 rested, 2 normal), defaulting to 2 as every 1.12 character has
/// it: a 0 has no Exhaustion row, and `GetRestState()`'s nil would raise at the unguarded
/// `>= 3` of `MainMenuBar.lua:25`. `rest_pool` is in base kill-XP units, and `resting` is
/// `PLAYER_FLAGS_RESTING` (0x20).
pub(crate) rest_state: u8,
pub(crate) rest_pool: u32,
pub(crate) resting: bool,
/// `PLAYER_FLAGS` bit 12, `PartialPlayTime()` (`0x48eb70`), an anti-addiction regime's flag.
pub(crate) partial_play_time: bool,
/// `PLAYER_FLAGS` bit 13, `NoPlayTime()` (`0x48ebe0`), the same regime's harsher half.
pub(crate) no_play_time: bool,
/// `GetBillingTimeRested()` (`0x48ec50`) in minutes from `SMSG_AUTH_RESPONSE`, unconverted.
pub(crate) billing_time_rested: u32,
/// `InCinematic()`. While set, `StaticPopup_Show` refuses any dialog without
/// `interruptCinematic` (`StaticPopup.lua:1454`).
pub(crate) in_cinematic: bool,
/// `Exhaustion.dbc` by rest byte, `(name, factor)`: `GetRestState` indexes it and row 1 scales
/// `GetXPExhaustion` (`0x48d3f0`). enUS rows seed it until the app loads the install's own.
pub(crate) exhaustion: HashMap<u8, (String, f64)>,
/// The paper doll's combat stats, `None` until the first push.
pub(crate) player_combat_stats: Option<char_stats::UnitCombatStats>,
/// The pet's combat stats, which the reference's pet sheet reads as `UnitStat("pet", …)`.
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
pub(crate) inventory_slots: char_stats::InventorySlots,
/// The six bank-bag slots, ids 64..=69, fed from the player descriptor's guids.
pub(crate) bank_bag_slots: char_stats::BankBagSlots,
/// `GetInventoryAlertStatus` in `0x806eb8` order; every push fires `UPDATE_INVENTORY_ALERTS`.
pub(crate) inventory_alerts: [u8; 12],
/// `UseInventoryItem` slot ids, sent as `CMSG_USE_ITEM` on the equipped item.
pub(crate) inventory_uses: Vec<u32>,
/// Equipped slot ids clicked while the merchant repair cursor is armed.
pub(crate) inventory_repairs: Vec<u32>,
/// Main- and off-hand temporary enchants in `GetWeaponEnchantInfo`'s order, pushed each frame.
pub(crate) weapon_enchants: [Option<weapon_enchant::WeaponEnchant>; 2],
/// The inspected unit's gear, from its public `PLAYER_VISIBLE_ITEM_*` fields.
pub(crate) inspect: Option<inspect::InspectView>,
/// `NotifyInspect` unit tokens, sent as `CMSG_INSPECT`.
pub(crate) inspect_notifies: Vec<String>,
/// `ClearInspectPlayer` ran; the app drops its inspect target.
pub(crate) inspect_clear: bool,
/// `DressUpModel` `SetUnit`, `Dress`, `Undress` and `TryOn` calls, applied in call order.
/// Queued dressing-room intents, each with the pane it was called on: an addon may own
/// many `DressUpModel` panes, and each keeps its own substitutions.
pub(crate) dressup_intents: Vec<(crate::widget::FrameHandle, super::dressup::DressUpIntent)>,
/// Tabard designs per `TabardModel`, the app's preview five, the host facts and queued calls.
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 record: true once the first is made.
pub(crate) world_frame_made: bool,
/// Per lowercased live-unit token, reach for `CanInspect`/`CheckInteractDistance`; absent: nil.
pub(crate) unit_reach: HashMap<String, super::UnitReach>,
/// VanillaHelpers' `SetUnitBlip`/`SetObjectTypeBlip` (`compat/vanilla_helpers.rs`), queued and
/// drained by `crates/benilla-app/src/minimap/blips`'s own override resource — not a 1.12
/// verb, so no reference shape to match.
pub(crate) blip_overrides: Vec<BlipOverrideRequest>,
/// The skills pane's snapshot and the display tree built from it.
pub(crate) skills: skills::SkillsState,
pub(crate) skills_groups: Vec<skills::SkillGroup>,
/// Collapsed skill categories by id, kept across a re-push.
pub(crate) skills_collapsed: HashSet<u32>,
/// The selection as a skill id, not a row.
pub(crate) skills_selected: Option<u32>,
/// `AbandonSkill` ids for `CMSG_UNLEARN_SKILL`; nothing changes until the server's update.
pub(crate) skill_abandons: Vec<u32>,
/// The reputation pane's snapshot and the display tree built from it.
pub(crate) reputation: reputation::ReputationState,
pub(crate) reputation_groups: Vec<reputation::FactionGroup>,
/// Folded headers by key (a `Faction.dbc` id, 0 "Other", -1 "Inactive"), reset on every push
/// to all open but Inactive, as the client's rebuild does.
pub(crate) reputation_collapsed: HashSet<i64>,
/// The selection as a reputation-list slot, not a row.
pub(crate) reputation_selected: Option<u32>,
/// Reputation calls queued, applied locally first because none of the three sends is acked.
pub(crate) reputation_sends: Vec<reputation::ReputationSend>,
/// Lines the chat box submitted (`SubmitChatInput`), for the app's slash-command parser.
pub(crate) chat_input: Vec<String>,
/// The world map's pushed catalog and feed, and its selection.
pub(crate) worldmap: super::worldmap::WorldMapState,
/// Nameplate `Button`s under `WorldFrame`, never shrunk, visible to `GetChildren()`.
pub(crate) nameplates: super::nameplate::NamePlates,
/// The world-state readout's rows, gated and resolved by the app.
pub(crate) worldstate: super::worldstate::WorldStateUiState,
/// Events bindings raise. Deviation: fired at the next tick, not synchronously as in the
/// reference, because a binding runs inside Lua and cannot re-enter handler dispatch.
pub(crate) pending_events: Vec<(String, Vec<ScriptValue>)>,
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
pub(crate) cursor_pos: (f32, f32),
/// ClassicAPI's `C_Timer` (`compat/classic_api.rs`): not a 1.12 verb at all. Drained by
/// `UiScript::tick` against [`UiScript::now`], the same `GetTime()` clock every cooldown and
/// aura timer already reads, so a timer survives exactly as long as anything else does (a
/// `ReloadUI`/relog rebuilds the VM and drops every pending one, same as the reference's own
/// `C_Timer` handles never surviving one).
/// SuperWoW's `SetMouseoverUnit(unit)` (`compat/superwow.rs`): a GUID the `"mouseover"` token
/// resolves to instead of whatever the cursor is over, until `SetMouseoverUnit()` clears it.
/// Stored as a GUID rather than a snapshot so the override stays live — it re-resolves through
/// the current frame's unit set every read, instead of freezing one tick's health and position.
/// Seconds to add to a UTC unix timestamp to get the **player's own wall clock**, pushed by
/// `benilla-app` (which owns the OS call). `0` until pushed, which is UTC — the behaviour this
/// client had everywhere before, so a VM with no host still renders what it always did.
///
/// `date()` applies it, as Lua's `os.date` applies the zone. `C_DateAndTime::
/// GetServerTimeLocal` subtracts it, so `date(fmt, GetServerEpoch())` still prints the
/// SERVER's wall clock: the two compose, and neither is correct without the other.
pub(crate) local_utc_offset_secs: i32,
pub(crate) mouseover_override: Option<u64>,
/// Every `(lowercased query, exactMatch)` a script has passed to `TargetByName`, so the app
/// can hold each one's resolved unit ready *before* the next call ([`Self::named_targets`]).
///
/// Capped at [`BY_NAME_ASKED_CAP`]: an addon's watch list is a fixed set, but nothing stops
/// one feeding this a new string every frame, and each entry costs the app a candidate rank
/// per streamed unit per frame. Past the cap a query simply keeps the one-frame-late path it
/// has always had.
pub(crate) by_name_asked: std::collections::BTreeSet<(String, bool)>,
/// Those queries resolved against the streamed units, pushed once per frame by the app
/// (`benilla-app`'s `target::by_name::feed_named_targets`) and **not** written here.
pub(crate) named_targets: HashMap<(String, bool), UnitState>,
/// The unit a `TargetByName` selected during this Lua frame: `"target"`'s answer until the
/// app's own push replaces it, cleared at the top of every tick.
///
/// The reference selects inside the call — `TargetByName 0x489d60` runs the resolver and
/// `CGGameUI::Target` before it returns — so an addon can read `UnitName("target")` on the
/// very next line and see what it just picked. Here the selection is a request the app drains
/// later in the frame, and the token snapshots were pushed *before* Lua ran, so without this
/// that read answers the previous target. One frame is the whole window: unitscan probes for
/// a rare by targeting its name, reading the name back, and calling `TargetLastTarget` to put
/// the old target back only `if prevTarget ~= foundTarget` (`unitscan.lua:112-130`). A frame
/// late, the probing frame sees no change and the frame that *does* see the mob has already
/// re-read `prevTarget` as the mob — so the restore never fires and the scan keeps your
/// target. That is the "unitscan steals my target" report, and it is this field's whole job.
pub(crate) target_override: Option<UnitState>,
/// ClassicAPI's `FocusUnit(unit)`/`ClearFocus()` (`compat/classic_api/focus.rs`): the focus
/// unit's GUID, the persistent secondary selection 1.12 has no verb and no token for. Stored
/// as a GUID for the same reason [`Self::mouseover_override`] is — the `"focus"` token must
/// answer the unit's *live* health, power and auras, not a frozen copy of the tick it was
/// assigned on. The app reads it back (`benilla-app`'s `ui_unit.rs`) to push the `"focus"` and
/// `"focustarget"` token snapshots and fire their `UNIT_*` edges, which is the only way either
/// token resolves once no other token names the unit. `None` is no focus, which a fresh VM (a
/// `/reload`, a relog) restores — the reference's own selection survives neither.
pub(crate) focus_guid: Option<u64>,
/// SuperWoW's `Clickthrough([0|1])`: click-through on lootless corpses. Stored and reported
/// faithfully; **nothing consumes it yet** — the world-click corpse path has not been wired to
/// read it, so an addon's toggle round-trips correctly but does not change picking. Tracked in
/// `docs/ADDON_COMPAT.md`.
pub(crate) clickthrough_corpses: bool,
/// Playable races by id, from the player's own `ChrRaces.dbc`, fed once at startup
/// (`benilla-app`'s `ui_chr_races::feed_race_info`). Empty until then, and on a run with no
/// install — `C_CreatureInfo.GetRaceInfo` answers nil from an empty table, which is also what
/// it answers past the last race, so a caller walking ids until nil terminates either way.
pub(crate) race_info: std::collections::BTreeMap<u32, RaceInfo>,
/// Classes by id, from the player's own `ChrClasses.dbc`, fed once at startup
/// (`benilla-app`'s `ui_chr_classes::feed_class_info`). Empty until then and on a run with no
/// install, which `C_CreatureInfo.GetClassInfo` answers as nil — the same "no such class"
/// answer it gives for 1.12's unused ids 6 and 10, so a caller walking ids cannot tell a
/// chainless run from the gaps the real table has anyway.
pub(crate) class_info: std::collections::BTreeMap<u32, ClassInfo>,
/// The server's own broken-down wall clock, fed from `SMSG_LOGIN_SETTIMESPEED`'s packed date
/// (`benilla-app`'s `minimap::feed_server_clock`), for ClassicAPI's `C_DateAndTime`. `None`
/// before login, which that namespace reports as nil — the shape pfUI documents and relies on.
pub(crate) server_clock: Option<ServerClock>,
pub(crate) timers: Vec<PendingTimer>,
/// Ids `:Cancel()` could not reach, because the drain had moved their rows out of
/// [`Self::timers`] with `mem::take` — which is every cancel taken from inside a `C_Timer`
/// callback, the commonest shape there is (`ticker:Cancel()` on the fire that finished the
/// job). Without this the cancel silently did nothing and a self-stopping ticker ran for the
/// session: `ItemMixin:ContinueOnItemLoad`'s poller re-ran its payload **every tick**, which on
/// pfQuest's hearthstone check (`database.lua:2242`) meant a whole `BuildNameIndex` rebuild per
/// frame. Read and cleared by `drain_timers` at the end of the pass it was filled in; ids are
/// monotonic and never reused, so a stale entry cannot hit the wrong row.
pub(crate) timers_cancelled: Vec<u64>,
/// The next [`PendingTimer::id`], so `:Cancel()` can find its row after others fire and are
/// removed.
pub(crate) next_timer_id: u64,
/// The self player's movement/posture snapshot (`compat/classic_api/state.rs`'s
/// `IsSwimming`/`IsMounted`/`IsIndoors`/`IsOutdoors`/`GetShapeshiftFormID`, SuperWoW's own
/// lowercase-initial siblings pending): not 1.12 verbs, so fed only for `"player"`, not the
/// generic per-token reach the rest of `UnitState` has.
pub(crate) self_movement: SelfMovement,
/// The player's own cast machinery, pushed once per tick beside it.
pub(crate) self_cast: SelfCast,
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
pub(crate) minimap_ping_request: Option<(f32, f32)>,
/// The ping's offsets as fractions of the minimap's side, updated by the app every frame.
/// `GetPingPosition()` always answers both, from statics never cleared (`0x4eefd0`).
pub(crate) minimap_ping: (f32, f32),
/// `SendChatMessage` lines, apart from chat-box input, whose drain runs the slash grammar.
pub(crate) chat_sends: Vec<super::chat_send::ChatSend>,
/// `SendAddonMessage` broadcasts: `LANG_ADDON`, four distributions, `prefix` TAB `message`.
pub(crate) addon_sends: Vec<super::addon_message::AddonSend>,
/// `RequestTimePlayed()` calls, one empty `CMSG_PLAYED_TIME` each.
pub(crate) played_time_asks: u32,
/// `Screenshot()` calls, one capture each.
pub(crate) screenshot_asks: u32,
/// `GetRealmName()`: `""` until pushed, never nil, since addons index tables with it at load.
pub(crate) realm_name: String,
/// The local player record (`0xc27d80`), seeded at world entry before addons load, then kept by
/// `"player"` pushes that carry it: corrected, never blanked. A per-login rebuild is unseen, as
/// its verbs exist only in game (`0x850438`), not at the glue screen (`0x46abb0`).
pub(crate) player_record: super::PlayerRecord,
/// `GetBindLocation()`, the bind point's area name: `""` before it lands, never nil.
pub(crate) bind_location: String,
/// `GMTicketCategory.dbc` `(id, name)` rows in file order; the ids are wire values.
pub(crate) gm_ticket_categories: Vec<(u32, String)>,
/// GM ticket calls in call order, so a delete then a get reach the wire in that order.
pub(crate) gm_ticket_intents: Vec<super::gm_ticket::GmTicketIntent>,
/// `Stuck()` calls, each a cast of spell 7355, the Help window's Auto-Unstuck.
pub(crate) stuck_casts: u32,
/// `ConfirmBinder()` calls, one `CMSG_BINDER_ACTIVATE` each; the app holds the innkeeper.
pub(crate) binder_confirms: u32,
/// `CheckBinderDist()`: the innkeeper's question is live and in range; false hides its dialog.
pub(crate) binder_pending: bool,
/// The summon getters' answers, defaulting to the reference's `""`, `""`, 0 with none pending.
pub(crate) summon_confirm: super::summon::SummonConfirmUiState,
/// `ConfirmSummon()` calls, one `CMSG_SUMMON_RESPONSE` each; the app holds the summoner.
pub(crate) summon_confirms: u32,
/// `GetFramerate()`, pushed each tick.
pub(crate) framerate: f64,
/// `(shift, ctrl, alt)` for `Is*KeyDown`, pushed before the frame's mouse events.
pub(crate) modifiers: (bool, bool, bool),
}
impl Model {
/// The selected quest-log row's detail, resolved per call as the reference's bindings do.
pub(crate) fn selected_quest_detail(&self) -> Option<&quest_log::QuestLogDetail> {
let index = usize::try_from(self.quest_log_selection.checked_sub(1)?).ok()?;
self.quest_log.entries.get(index)?.detail.as_ref()
}
}
impl Model {
/// A named font object, matched case-insensitively (ASCII) as the client's `SStrCmpI` does.
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)
}
}
/// Records a caught script error for the host, the retained log and the Lua error handler; an
/// error raised by the handler itself goes to `errors` directly, which stops the recursion.
pub(crate) fn record_script_error(&mut self, msg: String) {
self.diagnostics
.record(super::diagnostics::DiagnosticKind::Error, &msg);
self.pending_error_dispatch.push(msg.clone());
self.errors.push(msg);
}
/// Records a non-fatal warning for the host's terminal and the retained diagnostic log.
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);
}
/// [`Self::record_warning`] for a warning a single session repeats: the retained log takes
/// every occurrence (its row's `count` is the real total), the host's terminal takes only the
/// first. For a diagnostic about a call site the player cannot fix, where the 1st line is the
/// whole value and the 283rd is noise — one real session logged 283 copies of the `SetOwner`
/// unknown-anchor warning from one line of TurtleWoW's own FrameXML.
///
/// Dedup is the retained log's own row identity (`DiagnosticLog::record`), so the caller's
/// dedup key is whatever it folds into the message, and the fold is exact: two call sites, two
/// first lines. Past [`super::diagnostics::DIAGNOSTIC_LOG_CAP`] distinct messages an evicted
/// row can print once more; that is the cap's price everywhere and not worth a second table.
pub(crate) fn record_warning_once(&mut self, msg: impl Into<String>) {
let msg = msg.into();
if self
.diagnostics
.record(super::diagnostics::DiagnosticKind::Warning, &msg)
{
self.warnings.push(msg);
}
}
/// A warning for the host's terminal only, for a message already retained under another kind.
pub(crate) fn warn_host_only(&mut self, msg: String) {
self.warnings.push(msg);
}
/// A model pane's subject: the guid its `SetUnit` pinned ([`crate::widget::ModelState::unit_guid`]),
/// else its token re-read, for a pane seated before its unit streamed in.
///
/// One reader for the paint gate and the extract, so the two cannot disagree about whether the
/// pane has anything to draw. `Some(0)` is a token naming a unit with no guid, which only a
/// harness feed produces; the app resolves it to no entity, as it always has.
pub(crate) fn unit_pane_subject(&self, m: &crate::widget::ModelState) -> Option<u64> {
m.unit_guid.or_else(|| {
m.unit
.as_deref()
.and_then(|token| self.unit(token))
.map(|u| u.guid)
})
}
/// A unit token's state, folded as 1.12's resolver `0x515970` folds (`_strnicmp`: `A`..`Z`
/// only, never a byte of 0x80 or more), so `to_ascii_lowercase`, never `to_lowercase`.
pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> {
let lower = token.to_ascii_lowercase();
// SuperWoW's `SetMouseoverUnit`: an explicit override wins over whatever the cursor is
// over, which is the whole point — an addon sets it from a unit frame's OnEnter so a
// `mouseover` macro works off the frame rather than the 3-D world. Checked before the
// token map, since the app overwrites the real `"mouseover"` snapshot every frame.
if lower == "mouseover" {
if let Some(guid) = self.mouseover_override {
return self.unit_by_guid(guid);
}
}
// A `TargetByName` that resolved during this very tick, for the reason above: the
// reference's selection is synchronous and ours is a queued request, so the call's own
// read has to see it. Dropped at the top of the next tick, so it never outlives the Lua
// frame that made it.
if lower == "target" {
if let Some(state) = &self.target_override {
return Some(state);
}
}
// ClassicAPI's focus (`compat/classic_api/focus.rs`), both its tokens. Checked before the
// token map for the reason `"mouseover"` is: the app pushes both snapshots off
// [`Self::focus_guid`], so a `FocusUnit`/`ClearFocus` run *this* frame has to win over
// whatever the last push left there. With no focus set both tokens name nothing at once,
// so a cleared focus does not keep answering for a frame. With one set, `"focus"` answers
// from any token already naming that unit (the assigning frame, before the feed catches
// up); otherwise both fall through to the map, which is the steady state — the app's push
// is then the only resolver, and for `"focustarget"` always is, since the focus's
// `UNIT_FIELD_TARGET` is a hop this crate cannot make.
if lower == "focus" || lower == "focustarget" {
let guid = self.focus_guid?;
if lower == "focus" {
if let Some(u) = self.unit_by_guid(guid) {
return Some(u);
}
}
}
if let Some(u) = self.units_by_lower.get(&lower) {
return Some(u);
}
// SuperWoW: a raw GUID names whichever currently-reachable unit carries it, and
// `"<base>owner"` the owner (`UNIT_FIELD_SUMMONEDBY`, else charmer, else creator) of
// whatever `<base>` itself names. Both fall back to the live reach, so a unit no longer
// named by any token (target moved on, mouseover left) is not findable this way either —
// a narrower reach than SuperWoW's own (any loaded object), not yet built.
if let Some(guid) = super::unit::token_guid(&lower) {
return self.unit_by_guid(guid);
}
if let Some(base) = lower.strip_suffix("owner").filter(|b| !b.is_empty()) {
let owner = self.units_by_lower.get(base)?.owner;
return (owner != 0).then(|| self.unit_by_guid(owner)).flatten();
}
// SuperWoW: `"mark1"`..`"mark8"` name whichever currently-reachable unit carries that
// raid-target icon.
if let Some(mark) = lower
.strip_prefix("mark")
.and_then(|n| n.parse::<u8>().ok())
.filter(|n| (1..=8).contains(n))
{
return self.units_by_lower.values().find(|u| u.raid_target == mark);
}
None
}
/// Whichever currently-reachable unit's snapshot carries this GUID, for [`Self::unit`]'s
/// SuperWoW fallbacks.
///
/// A linear scan over `units_by_lower`, which is the live *token* set, never a server's whole
/// object list — but no longer "a handful" either: 40 plates and each one's target push 80
/// `nameplate<N>` keys on top of the stock singles and the party/raid families, so a crowded
/// pull holds ~90. Measured against that set
/// (`script::tests::unit_token_bench`): a token hit through `Self::unit` is 30 ns and a
/// raw-GUID hit through this scan 90 ns, so the scan costs ~60 ns on the paths that reach it
/// — the raw-GUID and `mark<n>` spellings, and `"mouseover"`/`"focus"` while an override is
/// set. A GUID index would buy that 60 ns back and nothing else: no shipped addon asks a unit
/// verb by raw GUID per frame (pfUI compares cached GUID *strings*, `modules/nameplates.lua`),
/// and the whole verb is 220 ns of which 44 ns is the Rust/Lua boundary alone. Left a scan
/// until something measured wants it.
fn unit_by_guid(&self, guid: u64) -> Option<&UnitState> {
self.units_by_lower.values().find(|u| u.guid == guid)
}
/// The live cast or channel window of whatever unit `token` names, for ClassicAPI's
/// `C_Spell.UnitCastingInfo`/`UnitChannelInfo` (`docs/ADDON_COMPAT.md`).
///
/// The whole fold is here, in one place: resolve the token to a snapshot ([`Self::unit`],
/// which already understands every token spelling this client answers — the stock ones, a raw
/// SuperWoW GUID string, `"mark<n>"`, `"<base>owner"`, ClassicAPI's `"focus"`), take its GUID,
/// then find that GUID's window. `want_channel` picks between the two verbs, which are
/// disjoint by construction: a channel never answers `UnitCastingInfo` and a cast never
/// answers `UnitChannelInfo`, as upstream's pair behaves and as every consumer's
/// `if not name then ...` fallback assumes.
///
/// A unit the client holds no snapshot for answers `None` even if it is casting: the GUID is
/// the window's key, but a token is the only way in, and with nothing naming the unit there is
/// no token to ask with. Nothing is invented for one.
pub(crate) fn unit_cast(
&self,
token: &str,
want_channel: bool,
) -> Option<&super::unit::UnitCastState> {
let guid = self.unit(token).map(|u| u.guid).filter(|g| *g != 0)?;
self.unit_casts
.iter()
.find(|c| c.guid == guid && c.channel == want_channel)
}
/// ClassicAPI's `UnitTokenFromGUID`: the token key whose snapshot carries this GUID, the
/// other half of [`Self::unit_by_guid`]. Same reach: only a currently-named token, never a
/// server's whole object list.
///
/// **One token, and that is all this verb promises.** `UnitTokenFromGUID` returns a single
/// string, so a unit answering to three names has to be collapsed to one; which one is
/// whichever the map walk reaches first. [`Self::tokens_for_guid`] is the honest plural, and
/// exists because the `UNIT_SPELLCAST_*` events genuinely need *all* of them.
pub(crate) fn token_for_guid(&self, guid: u64) -> Option<&str> {
self.units_by_lower
.iter()
.find(|(_, u)| u.guid == guid)
.map(|(k, _)| k.as_str())
}
/// **Every** token this client currently answers for `guid` — the fan-out a per-unit event has
/// to fire under, since one unit routinely answers to several names at once (`"target"` and
/// `"nameplate3"` and `"focus"` can all be the same mob, and each drives a different frame).
///
/// The set is the live token keys, i.e. exactly what the app's feeds have pushed: `"player"`,
/// `"target"`, `"targettarget"`, `"focus"`/`"focustarget"`, `"mouseover"`, `"npc"`, the
/// party/raid/pet families, and the `nameplate<N>` pair. Deliberately **not** the derived
/// spellings [`Self::unit`] also resolves — a raw 16-hex GUID, `"mark<n>"`, `"<base>owner"`.
/// Those are ways a *caller* can spell a unit it already has in hand, not names the client
/// publishes: nothing registers a handler keyed on one, and fanning an event out across
/// `mark1..8` plus an `owner` form per base would multiply every cast edge for no reader.
///
/// Sorted, so a caller's fan-out is deterministic and a test can assert the list. Nothing
/// depends on the *order*: every consumer in the corpus keys on its own token
/// (`pfUI/modules/castbar.lua:355` `arg1 ~= unit`,
/// `modules/nameplates.lua:651` `strfind(arg1, "^nameplate")`), never on which came first.
///
/// A 0 GUID answers nothing: it names no unit, and a window keyed on it could otherwise be
/// matched by any snapshot whose GUID has not resolved yet.
pub(crate) fn tokens_for_guid(&self, guid: u64) -> Vec<&str> {
if guid == 0 {
return Vec::new();
}
let mut out: Vec<&str> = self
.units_by_lower
.iter()
.filter(|(_, u)| u.guid == guid)
.map(|(k, _)| k.as_str())
.collect();
out.sort_unstable();
out
}
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,
spell_probe: None,
item_icon_probe: None,
spell_modifier_probe: None,
spell_rec_field_probe: None,
spell_record_probe: None,
spell_range_probe: None,
custom_files: None,
password_encrypt_probe: None,
credential_logins: Vec::new(),
disenchant_asks: Vec::new(),
queue_requests: Vec::new(),
channel_stop_next_tick: 0,
spell_mechanic_probe: None,
player_cache: HashMap::new(),
player_cache_enabled: [true, true],
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_derive_sites: None,
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(),
hovered_link: None,
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(),
frame_attributes: 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(),
notices: Vec::new(),
// 1024x768 until the host calls `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(),
unit_casts: Vec::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(),
spell_target_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(),
// Per window, as the stock dock differs: 1 and 2 are the dock, the rest 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(),
tracked_units: std::collections::BTreeSet::new(),
combat_log_adds: 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(),
music_queue: Vec::new(),
sound_suppression: 0,
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
cvar_changes: Vec::new(),
cvar_registrations: Vec::new(),
cvars_warned: HashSet::new(),
multisample_formats: Vec::new(),
screen_resolutions: Vec::new(),
current_resolution: None,
video_caps: super::cvars::VideoCaps::default(),
restart_gx_asks: 0,
saved_names: Vec::new(),
held_saved_files: 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(),
spell_usable: std::collections::HashMap::new(),
spell_icon_probe: None,
creature_model_probe: None,
pet_book: spellbook::PetBookState::default(),
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_icons: Vec::new(),
macro_icon_buckets: (Vec::new(), Vec::new()),
macro_bound_spells: std::collections::HashMap::new(),
spell_casts: Vec::new(),
no_queue_spell_casts: Vec::new(),
mouseover_spell_casts: Vec::new(),
pet_spell_casts: Vec::new(),
pet_spell_autocasts: Vec::new(),
casting: false,
spell_stop: false,
spell_targeting: false,
spell_targetable_units: HashSet::new(),
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,
bag_sort_right_to_left: false,
backpack_autosort_disabled: false,
bank_autosort_disabled: 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,
repair_all_cost: 0,
merchant_buys: Vec::new(),
merchant_cursor_sells: Vec::new(),
merchant_junk_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: None,
trainer_close: false,
trainer_filter: [true; 3],
trainer_collapsed: HashSet::new(),
taxi: None,
taxi_takes: Vec::new(),
taxi_close: false,
taxi_riding: false,
taxi_catalog: Vec::new(),
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,
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),
local_utc_offset_secs: 0,
mouseover_override: None,
by_name_asked: std::collections::BTreeSet::new(),
named_targets: HashMap::new(),
target_override: None,
focus_guid: None,
clickthrough_corpses: false,
race_info: std::collections::BTreeMap::new(),
class_info: std::collections::BTreeMap::new(),
server_clock: None,
timers: Vec::new(),
timers_cancelled: Vec::new(),
next_timer_id: 0,
self_movement: SelfMovement::default(),
self_cast: SelfCast::default(),
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(),
player_record: super::PlayerRecord::default(),
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(),
inventory_repairs: 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(),
blip_overrides: Vec::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);
// Every creation path mints here, so a new frame always opens the layout gate.
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 new region can join the resolve's external set, so it opens the gate too.
self.touch_layout();
id
}
/// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or
/// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
#[track_caller]
pub(crate) fn touch_layout(&mut self) {
// Naming nothing, the next resolve derives the graph in full: slow, never wrong.
self.give_up_naming();
self.bump_layout_epoch();
}
/// A write that moved one region's offsets, size or measured extent, not its targets or
/// liveness; a region missing from the cached graph falls back to `touch_layout`.
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(),
}
}
/// The same for a frame: an anchor offset or size, 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(),
}
}
/// A write that moved the **effective scale** of `root`'s subtree and nothing else: a
/// `SetParent`, or a `SetScale` on `root` itself. Anchor targets are ids fixed at `SetPoint`,
/// so no edge moves and the roster is unchanged — only each node's scale input.
///
/// Both callers are hot and for the same reason: addon pools reparent hundreds of frames a
/// redraw, and pfUI's action-button press animation walks a button overlay's scale a step
/// every frame for the ~60 frames its fade lasts. Giving up naming for either one costs a
/// whole-graph derivation per call — 232 of the 463 in one measured session, and the four
/// costliest five-second windows of that run were almost nothing else. See the `[scale]` row
/// in `benilla-app`'s `tick_bench`.
pub(crate) fn touch_layout_subtree_scale(&mut self, root: FrameHandle) {
let mut subtree = vec![root];
let mut at = 0;
while at < subtree.len() {
let h = subtree[at];
at += 1;
if let Some(f) = self.arena.frame(h) {
subtree.extend(f.children.iter().copied());
}
}
// Write the new scale here: only a full derivation syncs it, and a named node skips that.
let Model {
arena,
layout_inputs,
measure_dirty,
..
} = self;
for &h in &subtree {
if let Some(f) = arena.frame(h) {
layout_inputs.entry(h).or_default().scale = f.effective_scale;
// The owner's effective scale is in every one of its FontStrings' measure keys
// (`layout::stale_measure_key`), and it is the *only* thing outside a region's own
// data that key reads — so the subtree's regions are exactly the ones that can go
// stale, and the ledger does not have to be dropped for them. A `None` ledger
// makes the next `fontstrings_needing_measure` walk all 43,000 regions of a real
// interface to issue, typically, no requests at all. The walk is kept honest by
// the verify build, which runs it beside the drain and panics on any stale region
// the ledger missed.
if let Some(list) = measure_dirty.as_mut() {
list.extend(f.regions.iter().copied());
}
}
}
for h in subtree {
self.touch_layout_frame(h);
}
}
/// A write changed frame `h`'s anchor targets; `old` and `new` are its full target lists, so
/// the cached edges are re-pointed. Falls back to `touch_layout` when the scope refuses.
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, so patching is wasted.
Some(id)
if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) =>
{
self.touch_layout_node(id);
}
_ => self.touch_layout(),
}
}
/// `touch_layout_retarget_frame` for a region.
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(),
}
}
/// Names `rh` on the measure ledger for a write that may move its measure key.
pub(crate) fn touch_measure(&mut self, rh: RegionHandle) {
if let Some(list) = &mut self.measure_dirty {
list.push(rh);
}
}
/// A measure-key write that names no region, such as a font object: the next sweep walks all.
pub(crate) fn touch_measure_all(&mut self) {
self.measure_dirty = None;
}
/// Names a frame without bumping the epoch, for the resolve's own tooltip pre-pass: a bump
/// inside a resolve would hold the gate open for good, yet an unnamed write could go stale.
#[track_caller]
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(),
}
}
}
/// `note_layout_frame_write` for a region.
#[track_caller]
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(),
}
}
}
/// Names `id` as what this write moved, if the cached graph has it and naming is still on.
#[track_caller]
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();
}
/// Every write that gives up naming comes through here, so the next resolve derives in full.
/// `WOW_LAYOUT_DERIVE_TRACE=<secs>:<n>` backtraces the first `n` of these after `secs`.
#[track_caller]
fn give_up_naming(&mut self) {
if self.layout_touched.is_some() {
if derive_trace_armed() {
eprintln!(
"[layout-derive] the ledger gave up naming, at:\n{}",
std::backtrace::Backtrace::force_capture()
);
}
// `#[track_caller]` all the way down this chain, so the location is the write site —
// one of the forty-odd `touch_layout` callers — and not this function. A backtrace
// says the same thing and costs a thousand times as much, which is why the trace above
// has to be rationed to `n` prints and this does not.
if let Some(sites) = self.layout_derive_sites.as_mut() {
// `(&'static str, u32)`, not a formatted `String`: `Location::caller()` hands back
// a `&'static Location`, so the file is already static and the key costs nothing.
// This path runs thousands of times a session; formatting it would be the
// instrument's own cost showing up in the measurement.
let at = std::panic::Location::caller();
*sites.entry((at.file(), at.line())).or_insert(0) += 1;
}
}
self.layout_touched = None;
}
/// Bumps tier 1's counter, reopening the gate; `layout_touched` decides how much it derives.
fn bump_layout_epoch(&mut self) {
self.layout_epoch = self.layout_epoch.wrapping_add(1);
// `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>`: backtraces the first `n` touches after `secs`.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::OnceLock;
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>`: armed after `secs`, past the UI load, then `n` prints.
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()
}