Files
benilla-octo/crates/benilla-ui/src/script/mod.rs
T
gromandClaude Opus 5 ecee994ab6 app+ui: the player's own cast state, and SPELL_CAST_EVENT off the commit
`GetCurrentCastingInfo` and `GetCastInfo` are the two most-called
nampower functions in grom's corpus — 25 and 20 call sites outside
`NampowerAPI.lua` — and one of them calls with no existence guard at
all: `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()`
(SuperCleveRoidMacros/Core.lua:94), in the frame that keeps clearing the
target while an autoattack is queued. On this client that line raised.

The earlier doc row said the client had "a spell id, a deadline and a
guard bit — no start instant, no channel state, no GCD end". That was
true of `PendingCast` and wrong about the tree: `ui_cast_events.rs`
already folds a cast window per caster off the same packets, with a
start, an end, a channel bit and the accumulated pushback, behind
`C_Spell.UnitCastingInfo`. What was actually missing was the caster's
own machinery — the reference's cast globals, which have no descriptor
field anywhere — and the GCD, which `spell/cooldowns.rs` has run
correctly all along and never published.

So `GetCastInfo` folds two halves: its timing columns come from the
player's own window, its identity and GCD columns from a new per-tick
`SelfCast` snapshot. The feed declares itself after the window drain, so
the two halves can never describe different casts. Added:

- `SelfCast` (benilla-ui) — the inflight spell `0xceca88`, the bar's
  spell `0xceac48`, the auto-repeat key `0xceac30`, casting/channeling/
  onswing/autoattack, the cast counter, the cast type, the target, and
  the GCD's start and end on the GetTime clock.
- `ui_self_cast.rs` (benilla-app) — the push, plus `SPELL_CAST_EVENT`.
- `Cooldowns::gcd` — the running global cooldown as one timer. `info`
  still answers the per-category question every cooldown swirl asks;
  this answers "when does the GCD end", which has no spell to key on
  because its consumers divide it (`API.GetGCDRemainingMs`) or compare
  it against seconds (`[gcd:<1]`).
- `PendingCast`'s cast counter, in the one place every class of commit
  already passes through.

Three decisions where the tempting answer was the wrong one:

- `GetCastInfo` is **nil** when nothing is casting, and an instant's GCD
  does not count. Answering a table whenever the GCD is up would make
  the consumer's fast path always hit — and `API.IsCasting()` *is*
  `GetCastInfo() ~= nil`, so `[casting]` would read true for a second
  and a half after every instant. The wrapper's own comment says what to
  do instead ("GetSpellIdCooldown … works for instant casts").

- `castId` is two different numbers under one upstream name: a counter
  in `GetCastInfo`'s table, beside a separate `spellId`; a **spell id**
  in `GetCurrentCastingInfo`'s returns, which is how both of its readers
  treat it (Init.lua:89 stores it as castingSpellId, Core.lua:5427 hands
  it to C_Spell.GetSpellName). Each is implemented as what its own
  consumer reads, and both are spelled out at the binding.

- `SPELL_CAST_EVENT` fires for commits, not refusals. Upstream's
  `success` can be 0; this client's local refusals funnel through
  `CastErrors`, which carries a spell id and a reason byte and neither a
  cast type nor a target — a row from there would invent two of five
  fields. Nothing in the corpus reads the failing branch
  (Core.lua:5177 is one `if success == 1 then` with no else). Written
  down at the type rather than left to be found.

Tests run the consumers' own bodies transcribed, as `cast_info.rs` next
door does: `API.GetCastInfo` with its `GetCurrentCastingInfo` fallback
arm, `API.IsCasting`, `API.GetGCDRemainingMs`, `API.GetCastRemainingMs`
and `CleveRoids.UpdateCastingState`. A positional seven-return shape and
a ten-key table are only correct relative to the code that reads them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 07:16:30 -06:00

1656 lines
71 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Lua scripting host: the engine-free VM that runs FrameXML and addons over the frame arena,
//! the anchor resolver and the draw order. It embeds mlua's Lua 5.1, reshaped by [`lua50`] to
//! answer as the 1.12 client's Lua 5.0.
//!
//! A frame's Lua value is a table whose `T[0]` lightuserdata (`0x701bd0`) holds a `u32` id where
//! the reference holds the `CScriptObject*`, with an `__index` metatable (`0x7020b0`). A handler
//! is `pcall`ed with `this`, `event` and `arg1..argN` set as globals and restored after
//! (`0x704d50`); it also gets `(self, event, ...)` as arguments, which 1.12 does not pass.
//!
//! The `LUAI_MAXCSTACK` discipline: Rust holds no persistent Lua handle, since each owned `Table`
//! or `Function` takes a slot on mlua's reference thread, capped at the vendored 8000. Lua-side
//! state lives in the registry under named keys, fetched per call; [`Model`] is plain data in
//! `lua.app_data`, borrowed briefly and released before re-entering Lua.
mod action;
mod action_bar_toggles;
mod addon_message;
mod auction;
mod aura;
mod backdrop;
mod bank;
mod battlefield_positions;
mod battlefield_queue;
mod battlefield_score;
mod bind_confirm;
mod binder;
mod binding_abi;
mod dialog_verbs;
mod tutorial;
mod worldmap_arrow;
// `camera_view`: the five camera views and `FlipCameraYaw`, the reference's `UIUtil\Camera.cpp`.
mod button;
mod camera_view;
mod cast_events;
mod channel;
mod char_stats;
mod chat_misc;
mod chat_send;
mod chat_types;
mod chat_window;
mod class_info;
mod clip;
mod colorselect;
mod container;
mod craft;
mod cursor;
mod death;
pub mod diagnostics;
mod editbox;
pub mod instance;
pub(crate) use editbox::{adopt_text_region, editbox_text_region_wrapper};
pub(crate) mod addon;
pub mod addon_gate;
mod client;
mod compat;
mod cvars;
mod dressup;
mod duel;
pub(crate) mod event;
mod event_prof;
mod extract;
mod follow;
pub(crate) mod font;
mod font_block;
mod gm_ticket;
mod gossip;
mod guild;
mod handler_prof;
mod raise_prof;
mod screenshot;
mod tabard;
mod tick_prof;
pub use event_prof::EventRow;
pub use handler_prof::HandlerRow;
pub use raise_prof::RaiseRow;
pub use tick::EventReach;
pub use tick_prof::{Phase as TickPhase, TickPhaseRow, TimerSiteRow};
mod surface;
pub mod verb_prof;
pub use surface::widget_method_census;
mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod layout_cache;
mod loot;
mod loot_roll;
pub(in crate::script) mod lua50;
mod macros;
mod mail;
mod measure;
mod merchant;
mod messageframe;
mod minimap;
pub mod nameplate;
pub use nameplate::{PlateFeed, PlateGeometry, PlateState};
/// What one [`UiScript::named_visibility`] sample is keyed on: a named frame or a named region,
/// by handle rather than by name.
///
/// `Copy` and `Hash` on purpose. The sweep that produces these runs every frame over every named
/// widget — tens of thousands on a loaded interface — so a key that has to be allocated and
/// string-hashed turns a debugging aid into a 6 ms frame tax, which is exactly what it did before
/// this existed. Names come back from [`UiScript::vis_key_name`] for the rows a caller prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum VisKey {
Frame(crate::widget::FrameHandle),
/// The region's widget id, the value `region_names` holds.
Region(u32),
}
mod model;
mod modelframe;
mod net_stats;
pub(crate) mod object;
pub use object::frame_kind_from_tag;
mod party;
mod pet;
mod petition;
mod pointer;
mod pvp;
mod quest;
mod quest_log;
mod race_info;
pub(crate) mod region;
mod region_map;
mod reputation;
mod saved;
mod scrollframe;
mod self_cast;
mod self_movement;
mod server_clock;
mod session;
mod shapeshift;
mod simplehtml;
mod skills;
mod slash;
mod slider;
mod social;
mod sound;
mod spellbook;
mod stable;
mod statusbar;
mod stdlib;
mod summon;
mod talent;
mod taxi;
mod tick;
mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
pub use tooltip_unit::{skill_margin_tint, TooltipTint};
mod trade;
mod tradeskill;
mod trainer;
mod types;
mod unit;
mod weapon_enchant;
mod who_sort;
mod worldmap;
mod worldstate;
mod worn_display;
pub use action::{ActionSlot, ActionState, ActionUse};
pub use addon::AddOnInfo;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
AuctionBid, AuctionCategory, AuctionItemRow, AuctionListState, AuctionQuery,
AuctionStartRequest, AuctionState, AuctionSubCategory, BIDDER, LIST, OWNER, SORT_KEYS,
};
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
pub use battlefield_score::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStatColumn};
pub use bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use channel::{ChannelCommand, ZoneChannelRow};
pub use char_stats::{
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ItemRawFields,
UnitCombatStats, BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_misc::EmoteRequest;
pub use chat_send::ChatSend;
pub use chat_types::ChatTypeColor;
pub use chat_window::{message_group_index, ChatWindowLook, MESSAGE_GROUPS};
pub use container::{
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PendingWrap,
PetitionSlotView, RandomPropertyView, UiCursorMode,
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::money::coin_icon;
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorMerchantItem, CursorMoney, CursorPayload,
CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::{
MultisampleFormat, ScreenResolution, SeededCvar, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA,
CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS,
WORLD_DETAIL_STOPS,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gm_ticket::{GmTicketIntent, GmTicketWrite};
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
};
pub use modelframe::ModelPaneFrame;
pub use petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
PETITION_TYPE_PETITION,
};
pub use tabard::{
emblem_mask_path, TabardHost, TabardIntent, EMBLEM_MASK_TOKEN, TABARD_COUNTS,
TABARD_CREATION_COST,
};
pub use worldmap_arrow::ARROW_MODEL;
pub(crate) use button::{set_label_font_justify_h_lua, LabelFont};
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
pub use layout_cache::{FrameLayout, LayoutPoint};
pub use loot::{LootRow, LootState};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState, StationeryView};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub use model::{
BlipOverrideKey, BlipOverrideRequest, ClassInfo, CreatureModelProbe, CreatureModelView,
RaceInfo,
};
pub use model::{
FontProbe, ItemIconProbe, SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe,
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
};
pub(crate) use model::{Model, PendingTimer};
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
pub use quest::{
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState,
};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua};
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
pub use session::SessionRequest;
pub use shapeshift::ShapeshiftFormView;
pub(crate) use simplehtml::{
apply_element_font_parts as apply_simplehtml_font_parts,
element_of_xml_tag as simplehtml_element_of_xml_tag,
};
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::{MusicRequest, SoundRequest};
pub use spellbook::{
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
};
pub use stable::{StableIntent, StablePetSlot, StableState, NUM_STABLE_SLOTS};
pub use summon::SummonConfirmUiState;
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
pub use taxi::{TaxiMapNode, TaxiNodeType, TaxiUiNode, TaxiUiState};
pub use tooltip_spell::SpellTooltipView;
pub use trade::{TradeSideState, TradeSlotItem, TradeState, TRADE_SLOTS};
pub use tradeskill::{TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState};
pub use trainer::{
TrainerAbilityReq, TrainerGroup, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState, TrainerTooltip, TRAINER_GROUP_KNOWN,
};
pub use types::{
BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject,
FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline,
QuadContent, ScriptValue, TexCoords, UnitPaneSubject,
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{
grey_band, guid_token, level_reads_unknown, power_token, unit_is_grey, PlayerRecord,
SelectionRequest, UnitCastState, UnitState,
};
pub use weapon_enchant::WeaponEnchant;
pub use who_sort::{WhoSortChain, WhoSortKey};
pub use worldmap::{
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
WorldMapZoneView,
};
pub use worldstate::WorldStateUiView;
pub use worn_display::WornDisplay;
use mlua::Lua;
use crate::layout::Rect;
use crate::order::ZTarget;
use crate::widget::{FrameHandle, KindState};
// Registry keys, the only roots of Lua-side state (the `LUAI_MAXCSTACK` discipline).
const REG_FRAME_META: &str = "__benilla_frame_meta";
const REG_REGION_META: &str = "__benilla_region_meta";
pub(in crate::script) const REG_FRAME_METHODS: &str = "__benilla_frame_methods";
const REG_REGION_METHODS: &str = "__benilla_region_methods";
#[cfg(test)]
pub(crate) const REG_REGION_METHODS_FOR_TEST: &str = REG_REGION_METHODS;
/// The title region's method table and metatable: the 19 Region methods and nothing else.
const REG_TITLE_METHODS: &str = "__benilla_title_methods";
const REG_TITLE_META: &str = "__benilla_title_meta";
/// The region leaf tables and metatables: Texture and FontString each answer their own map.
pub(in crate::script) const REG_TEXTURE_METHODS: &str = "__benilla_texture_methods";
const REG_TEXTURE_META: &str = "__benilla_texture_meta";
pub(in crate::script) const REG_FONTSTRING_METHODS: &str = "__benilla_fontstring_methods";
const REG_FONTSTRING_META: &str = "__benilla_fontstring_meta";
/// The stdlib's default error handler, kept by identity for
/// [`UiScript::dispatch_script_errors_to_handler`] to skip; stored at [`stdlib::install`].
const REG_DEFAULT_ERRORHANDLER: &str = "__benilla_default_errorhandler";
/// Names on both region leaves, each leaf registering its own copy (Texture's `SetAlpha`
/// `0x79b580`, FontString's `0x79cb70`), so they are not on the Region map and must not be hoisted
/// into it. `GetDrawLayer` is on both (Texture `0x79a6c0`, FontString `0x79c660`).
pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
"SetDrawLayer",
"GetDrawLayer",
"SetVertexColor",
"SetAlpha",
"GetAlpha",
"Show",
"Hide",
"IsVisible",
"IsShown",
];
/// Texture-only, each in the client's Texture map (`0x87c128`): `GetVertexColor` though
/// `SetVertexColor` is shared, and `SetGradientAlpha`, not FontString's `SetAlphaGradient`. 1.12
/// has no Texture `SetSize` or `SetRotation` (PlayerModel's, `0x84f1fc`/`0x505f00`).
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
"SetGradient",
"SetGradientAlpha",
"GetTexture",
"SetTexture",
"GetTexCoord",
"SetTexCoord",
"SetBlendMode",
"GetBlendMode",
"SetTexCoordModifiesRect",
"GetTexCoordModifiesRect",
"SetDesaturated",
"GetVertexColor",
];
/// FontString-only: the font, text, justify and shadow block and the string metrics.
/// `GetStringHeight`, `SetFormattedText` and `SetSize` are not 1.12 FontString methods.
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [
"SetFont",
"GetFont",
"SetFontObject",
"GetFontObject",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
"SetText",
"GetText",
"SetTextHeight",
"GetStringWidth",
"SetSpacing",
"GetSpacing",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetAlphaGradient",
];
/// The Region method map (`0xcf54b4`): the 19 names every region leaf reaches on a miss.
pub(crate) const REGION_MAP_METHODS: [&str; 19] = [
"GetObjectType",
"IsObjectType",
"GetName",
"GetParent",
"SetParent",
"GetCenter",
"GetLeft",
"GetRight",
"GetTop",
"GetBottom",
"GetWidth",
"SetWidth",
"GetHeight",
"SetHeight",
"GetNumPoints",
"GetPoint",
"SetPoint",
"SetAllPoints",
"ClearAllPoints",
];
const REG_WRAPPERS: &str = "__benilla_wrappers";
const REG_SCRIPTS: &str = "__benilla_scripts";
/// `{ call, args }`: the `xpcall` trampoline every handler fires through ([`event::install`]).
const REG_PROTECTED_CALL: &str = "__benilla_protected_call";
/// The layout handle of the screen root (the client's `CSimpleTop`, not the `UIParent` frame),
/// which a top-level `SetPoint` without `relativeTo` anchors to; frame ids, from 1, are the rest.
pub const SCREEN: crate::layout::Handle = 0;
/// The FrameScript handler kinds `SetScript` accepts, each only with the code that fires it
/// ([`crate::script::object::events_regions::set_script`]). The list is flat, where the reference
/// resolves names per widget type (base map `0x76a0d0` plus the type's own; a `<Frame>` has no
/// `OnClick`), so any widget accepts any kind here.
///
/// `OnValueChanged` is the StatusBar's (`+0x32c`) and the Slider's (`+0x330`) own slot; the
/// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`).
/// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires
/// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`).
const SCRIPT_KINDS: [&str; 42] = [
"OnLoad",
"OnEvent",
"OnUpdate",
// The model pane's two, fired by the tick's model pass (`tick_model_panes`): `OnUpdateModel`
// at the top of a visible pane's paint, `OnAnimFinished` when the armed sequence completes.
"OnUpdateModel",
"OnAnimFinished",
"OnShow",
"OnHide",
"OnClick",
"OnEnter",
"OnLeave",
"OnMouseDown",
"OnMouseUp",
"OnMouseWheel",
"OnValueChanged",
"OnEnterPressed",
"OnEscapePressed",
"OnSpacePressed",
"OnTabPressed",
"OnTextChanged",
"OnTextSet",
// The caret flush's own (`0x77da80`), fired by the tick's `drain_cursor_changed` when the
// caret moved: the edge `ScrollingEdit_OnCursorChanged` scrolls a multiline box by.
"OnCursorChanged",
"OnEditFocusGained",
"OnEditFocusLost",
"OnHorizontalScroll",
"OnVerticalScroll",
"OnScrollRangeChanged",
"OnDragStart",
"OnDragStop",
"OnReceiveDrag",
// A release over a message-frame hyperlink span, `OnHyperlinkClick(link, text, button)`;
// `ChatFrameTemplate` passes it to `SetItemRef` (`ChatFrame.xml:15`, `ChatFrame.lua:1534`).
"OnHyperlinkClick",
// The hover edges for the same spans, `OnHyperlinkEnter/Leave(link, markup)`. **Not 1.12's**
// — that client routes a link only on release — but TurtleWoW's client added them and the
// addons built against it register them at load, so refusing the name raised outright
// (`ChatSuey-ItemLinkHover` is two handlers and nothing else). Fired by
// `pointer::update_hyperlink_hover` off the same rasterized spans the click path reads.
"OnHyperlinkEnter",
"OnHyperlinkLeave",
// The ScrollingMessageFrame's own scroll slot, fired by [`messageframe::scrolling`] when a
// scroll verb moves the view. WIM sets it on every window it builds, so refusing the name
// aborted the builder mid-function and left the window half-made.
"OnMessageScrollChanged",
// The GameTooltip's engine-fired scripts: money render, money clear and world-hover default
// placement, all three wired by the stock template (`GameTooltipTemplate.xml:617-625`).
"OnTooltipAddMoney",
"OnTooltipCleared",
"OnTooltipSetDefaultAnchor",
// The ColorSelect's own slot (script map `0x78b4f0`, `+0x338`), fired by its `SetColorRGB`.
"OnColorSelect",
// The Button/CheckButton double click (script map `0x778c50`, `+0x4d4`), fired by
// [`pointer`]'s release edge in place of the second `OnClick` within 300 ms (`0x77937b`).
"OnDoubleClick",
// The layout event (base map `0x76a0d0`, `+0x120`), fired by the resolve pass on a size change.
"OnSizeChanged",
// The key channels, fired by [`keyboard`]'s walk (`0x765f10`). `OnKeyUp` never fires, as the
// host feeds no key-up, but a frame carrying only `OnKeyUp` still consumes every key-down and
// runs nothing, the reference's own asymmetry.
"OnChar",
"OnKeyDown",
"OnKeyUp",
];
// ─────────────────────────────────────────────────────────────────────────────────────────────
// UiScript: the public host
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The Lua scripting host: owns the VM, whose `Model` lives in `lua.app_data`. Its `impl` blocks
/// sit beside their concerns: [`extract`], [`tick`], [`editbox::seam`], [`layout`] and [`pointer`].
pub struct UiScript {
lua: Lua,
/// VM instructions counted while a budget is installed ([`UiScript::set_instruction_budget`]).
instructions: std::sync::Arc<std::sync::atomic::AtomicU64>,
/// When this frame's Lua began, for [`UiScript::set_runaway_limit`]; nanoseconds on
/// [`mono_nanos`]'s clock, shared with the hook closure.
vm_entry: std::sync::Arc<std::sync::atomic::AtomicU64>,
session: u64,
}
/// Monotonic nanoseconds since the first call, which is a VM's birth: a plain `u64` an atomic can
/// hold and a hook can read without allocating or locking.
pub(crate) fn mono_nanos() -> u64 {
static BASE: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let base = BASE.get_or_init(std::time::Instant::now);
// A u64 of nanoseconds runs for 584 years.
base.elapsed().as_nanos() as u64
}
/// Hands out [`UiScript::session`] ids: process-global and monotone, so an id is never reused.
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// How often the instruction hook fires: an instruction budget's resolution (low values cost).
pub const INSTRUCTION_HOOK_STEP: u32 = 1_000_000;
/// The chunk name the 1.12 client gives an addon's file, `Interface\AddOns\<Folder>\<File>`,
/// backslashed and with the file, because addons parse it: `FuBarPlugin-2.0` finds its folder with
/// the greedy `\\AddOns\\(.*)\\` over `debugstack`, which needs the file after the folder.
pub fn addon_chunk_name(folder: &str, file: &str) -> String {
// `@` marks the chunk as a file, so a traceback prints the path plainly.
format!("@Interface\\AddOns\\{folder}\\{}", file.replace('/', "\\"))
}
impl UiScript {
/// Build a fully sandboxed, stdlib- and object-model-equipped host.
pub fn new() -> mlua::Result<UiScript> {
let lua = Lua::new();
lua.set_app_data(Model::new());
// Before anything can fire a handler and while no app-data borrow is held, as the
// profiler's slot requires.
handler_prof::install(&lua);
event_prof::install(&lua);
// Beside it, and for the same reason: the slot must exist before anything can tick.
tick_prof::install(&lua);
addon::install(&lua)?;
addon_message::install(&lua)?;
chat_send::install(&lua)?;
chat_types::install(&lua)?;
chat_misc::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
screenshot::install(&lua)?;
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions.
lua50::install(&lua)?;
stdlib::install(&lua)?;
// After the stdlib: its chunk defines `debugstack`'s Lua-visible name, which the
// trampoline captures, and before anything can fire a handler.
event::install(&lua)?;
object::install(&lua)?;
// After `object`, whose `publish_global` it reuses, and before any FrameXML loads:
// `Loader::do_font` publishes into the tables this builds.
font::install(&lua)?;
unit::install(&lua)?;
party::install(&lua)?;
social::install(&lua)?;
guild::install(&lua)?;
petition::install(&lua)?;
binder::install(&lua)?;
summon::install(&lua)?;
bind_confirm::install(&lua)?;
instance::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
camera_view::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
worn_display::install(&lua)?;
death::install(&lua)?;
aura::install(&lua)?;
class_info::install(&lua)?;
cvars::install(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
ui_errors::install(&lua)?;
pointer::install(&lua)?;
action::install(&lua)?;
action_bar_toggles::install(&lua)?;
container::install(&lua)?;
cursor::install(&lua)?;
spellbook::install(&lua)?;
macros::install(&lua)?;
talent::install(&lua)?;
dialog_verbs::install(&lua)?;
battlefield_score::install(&lua)?;
battlefield_queue::install(&lua)?;
battlefield_positions::install(&lua)?;
tutorial::install(&lua)?;
worldmap_arrow::install(&lua)?;
shapeshift::install(&lua)?;
pet::install(&lua)?;
gossip::install(&lua)?;
merchant::install(&lua)?;
bank::install(&lua)?;
stable::install(&lua)?;
item_text::install(&lua)?;
mail::install(&lua)?;
auction::install(&lua)?;
trainer::install(&lua)?;
taxi::install(&lua)?;
trade::install(&lua)?;
inspect::install(&lua)?;
dressup::install(&lua)?;
tabard::install(&lua)?;
tradeskill::install(&lua)?;
craft::install(&lua)?;
reputation::install(&lua)?;
skills::install(&lua)?;
item_stats::install(&lua)?;
char_stats::install(&lua)?;
weapon_enchant::install(&lua)?;
loot::install(&lua)?;
loot_roll::install(&lua)?;
quest::install(&lua)?;
quest_log::install(&lua)?;
messageframe::install(&lua)?;
scrollframe::install(&lua)?;
simplehtml::install(&lua)?;
slider::install(&lua)?;
colorselect::install(&lua)?;
minimap::install(&lua)?;
modelframe::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
worldstate::install(&lua)?;
net_stats::install(&lua)?;
diagnostics::install(&lua)?;
// Last: some of its functions extend or wrap stock globals, which must already exist.
compat::install(&lua)?;
// After everything of ours that captures `pcall`/`xpcall` — the handler trampoline above
// most of all — so `WOW_UI_RAISES`' wrappers are seen only by the addon code that loads
// next, and the dispatch paths the host already logs stay on the stock pair.
raise_prof::install(&lua)?;
// Last of all, and only when armed: it wraps every binding installed above, so anything
// installed after it would go unmeasured, and anything loaded after it (FrameXML, addons)
// is not ours to call a verb.
verb_prof::install(&lua)?;
let s = UiScript {
lua,
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
vm_entry: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(mono_nanos())),
session: NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
};
Ok(s)
}
/// The embedded VM, for the app and the loader to add their bindings over this object model.
pub fn lua(&self) -> &Lua {
&self.lua
}
/// Which VM this is: a fresh number per [`UiScript::new`], never reused, so anything the host
/// seeded into a VM is valid only while this still matches.
///
/// The reference keeps one Lua state (`0xceef74`), replaced only by the reset `0x703b80`: from
/// `UI_Init` (`0x48fbf0`, at `0x48fe97`) as it loads FrameXML, `ShutdownGame` (`0x491231`) and
/// the glue builder (`0x46a87b`), never the UI teardown `0x490bd0`. GlueXML and FrameXML never
/// share a state, so world entry mints its VM at the top of its load.
pub fn session(&self) -> u64 {
self.session
}
/// Bound how long a chunk may run: past `budget` VM instructions the hook raises a Lua error,
/// so a loop that never ends fails its addon and the rest still runs. The app arms it only for
/// the world-entry load, so a hung load reports instead of freezing the loading screen, and
/// disarms it ([`Self::clear_instruction_budget`]) before the session's first frame.
pub fn set_instruction_budget(&self, budget: u64) {
let used = self.instructions.clone();
used.store(0, std::sync::atomic::Ordering::Relaxed);
let _ = self.lua.set_hook(
mlua::HookTriggers {
every_nth_instruction: Some(INSTRUCTION_HOOK_STEP),
..Default::default()
},
move |_, _| {
let n = used.fetch_add(
u64::from(INSTRUCTION_HOOK_STEP),
std::sync::atomic::Ordering::Relaxed,
) + u64::from(INSTRUCTION_HOOK_STEP);
if n > budget {
return Err(mlua::Error::runtime(format!(
"benilla: instruction budget exhausted after {n} VM instructions — \
treating this as a non-terminating loop"
)));
}
Ok(mlua::VmState::Continue)
},
);
}
/// Remove the instruction budget; the counter keeps its value for [`Self::instructions_used`].
pub fn clear_instruction_budget(&self) {
self.lua.remove_hook();
}
/// Bound how long **one frame's** Lua may run in wall time: past `limit` the hook raises a Lua
/// error naming the line it was on, so a handler that never returns fails its own call and the
/// client keeps running instead of freezing until the player kills it.
///
/// This is the play-time counterpart of [`Self::set_instruction_budget`], which covers only
/// the load. Wall time rather than instructions because the number has to be obviously beyond
/// anything legitimate and a count is not: a frame still inside Lua after seconds is not slow,
/// it is stuck. [`Self::tick`] restamps every frame, so no frame is charged the one before it.
///
/// It shares the one count hook, which costs **5-13% of pure Lua execution time** (measured,
/// 30M instructions on the player's machine: 235-260 ms bare, 267-274 ms hooked) — a fraction
/// of a percent of a frame during play, where Lua is a fraction of the frame. The load already
/// paid it.
pub fn set_runaway_limit(&self, limit: std::time::Duration) {
let entry = self.vm_entry.clone();
entry.store(mono_nanos(), std::sync::atomic::Ordering::Relaxed);
let limit_ns = u64::try_from(limit.as_nanos()).unwrap_or(u64::MAX);
let _ = self.lua.set_hook(
mlua::HookTriggers {
every_nth_instruction: Some(INSTRUCTION_HOOK_STEP),
..Default::default()
},
move |_, debug| {
let started = entry.load(std::sync::atomic::Ordering::Relaxed);
let now = mono_nanos();
let spent = now.saturating_sub(started);
if spent <= limit_ns {
return Ok(mlua::VmState::Continue);
}
// Restamp before raising: the error unwinds to whatever pcall caught the call, and
// the rest of the frame — and every frame after it — deserves a whole budget.
entry.store(now, std::sync::atomic::Ordering::Relaxed);
let src = debug.source();
Err(mlua::Error::runtime(format!(
"benilla: {:.1} s inside Lua in one frame, at {}:{} — treating it as a \
non-terminating loop and failing the call, so the client keeps running",
spent as f64 / 1e9,
src.short_src.as_deref().unwrap_or("?"),
debug.current_line().unwrap_or(0),
)))
},
);
}
/// Restamp this frame's Lua clock for [`Self::set_runaway_limit`]. [`Self::tick`] calls it, so
/// a host that drives the VM by hand (a test, the harness) can arm a limit and still get a
/// fresh budget per frame.
pub fn mark_vm_entry(&self) {
self.vm_entry
.store(mono_nanos(), std::sync::atomic::Ordering::Relaxed);
}
/// How long this frame has been inside Lua, for a watchdog that wants to say whether a stuck
/// frame is stuck in the VM. Meaningless unless a limit is armed.
pub fn frame_lua_secs(&self) -> f32 {
let started = self.vm_entry.load(std::sync::atomic::Ordering::Relaxed);
mono_nanos().saturating_sub(started) as f32 / 1e9
}
/// VM instructions since the last [`Self::set_instruction_budget`], to the resolution of
/// [`INSTRUCTION_HOOK_STEP`]; zero when no budget was ever set.
pub fn instructions_used(&self) -> u64 {
self.instructions.load(std::sync::atomic::Ordering::Relaxed)
}
/// Set the screen-root rect from a pixel size (origin `[0,0]`, y-up), compared first so the
/// app's every-frame call leaves an unchanged layout clean. On a change (`true`) the caller
/// re-runs `UIParent_ManageFramePositions()`: the window resizes freely, and a seat computed
/// from `GetScreenHeight()`, like the open-bag stack's column wrap, does not follow anchors.
pub fn set_screen_size(&mut self, width: f32, height: f32) -> bool {
let new = Rect::new(0.0, 0.0, height, width);
let mut model = self.model_mut();
if model.screen == new {
return false;
}
model.screen = new;
model.touch_layout();
drop(model);
// The implicit rects are in layout units, which follow the aspect.
self.reapply_implicit_rects();
true
}
/// Push the modifier state behind `IsShiftKeyDown`/`IsControlKeyDown`/`IsAltKeyDown`; the app
/// calls it before the frame's mouse events, so a click handler reads the state of its click.
pub fn set_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool) {
let mut model = self.model_mut();
model.modifiers = (shift, ctrl, alt);
}
/// Push the player's WMO-containment state (the client's `0xceaa60`) onto every Minimap: it
/// picks which persisted zoom index `GetZoom`/`SetZoom` act on. Call it before the script
/// tick, on each inside/outside edge and whenever [`Self::minimap_widgets_created`] moves.
pub fn set_minimap_inside(&mut self, inside: bool) {
self.for_each_minimap(|m| m.inside = inside);
}
fn for_each_minimap(&mut self, mut f: impl FnMut(&mut crate::widget::MinimapState)) {
let mut model = self.model_mut();
for h in model.arena.minimap_kinds().to_vec() {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Minimap(m) = &mut frame.kind_state {
f(m);
}
}
}
}
/// Push the player's facing (radians) onto every Minimap's player-arrow `Model`, every frame,
/// raw: `CMinimap::SetPlayerFacing` (`0x4eb8e0`, fed at `0x4eb0a4`–`0x4eb0b1`) copies it into
/// the field `Model:SetFacing` (`0x76dce0`) writes, so the arrow's `GetFacing()` reads it.
pub fn set_minimap_player_facing(&mut self, facing: f32) {
let mut model = self.model_mut();
let arrows: Vec<crate::widget::FrameHandle> = model
.arena
.minimap_kinds()
.iter()
.filter_map(|&h| match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.player_arrow,
_ => None,
})
.collect();
for h in arrows {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Model(state) = &mut frame.kind_state {
state.facing = facing;
}
}
}
}
/// Publish `Minimap:GetPingPosition()`'s offsets from the widget centre, as fractions of its
/// side (x right, y up); the app recomputes them from the ping's world point each frame.
pub fn set_minimap_ping(&mut self, ping: (f32, f32)) {
self.model_mut().minimap_ping = ping;
}
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in UI units, x right, y up.
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
self.model_mut().minimap_ping_request.take()
}
/// `Minimap:SetMaskTexture`'s value from the first Minimap (the app draws one map), `None`
/// until one is set, while the app keeps [`crate::widget::MINIMAP_DEFAULT_MASK`].
pub fn minimap_mask_texture(&self) -> Option<String> {
let model = self.model_ref();
model.arena.minimap_kinds().iter().find_map(|&h| {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.mask_texture.clone(),
_ => None,
}
})
}
/// Minimap widgets ever created in this VM: a move means a new one needs the containment state.
pub fn minimap_widgets_created(&self) -> u64 {
self.model_ref().arena.minimap_created()
}
/// Seed every Minimap's two zoom indices from the persisted CVars, as the client's reset path
/// does (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`). Call it once,
/// when the UI and the widget exist: from then on `Minimap:SetZoom` keeps the CVar following
/// the widget, and a repeated seed would fight the +/- buttons.
pub fn set_minimap_zoom(&mut self, zoom: u8, inside_zoom: u8) {
let top = crate::widget::MINIMAP_ZOOM_LEVELS - 1;
let (zoom, inside_zoom) = (zoom.min(top), inside_zoom.min(top));
self.for_each_minimap(|m| {
m.zoom = zoom;
m.inside_zoom = inside_zoom;
});
}
/// Load and run a text chunk; handler errors during events go to [`UiScript::errors`] instead.
pub fn run(&self, chunk: &str) -> mlua::Result<()> {
self.run_chunk(chunk.as_bytes())
}
/// [`UiScript::run`] over a file's bytes: the reference hands the file (`0x704bc0`) to
/// `luaL_loadbuffer` (`0x6f5690`) unconverted, so a cp1252 file's literals keep their bytes.
/// Its compiler's UTF-8 BOM strip and `#`-line skip apply here ([`crate::source::chunk`]).
pub fn run_chunk(&self, chunk: &[u8]) -> mlua::Result<()> {
self.run_chunk_named(chunk, "(chunk)")
}
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files),
/// through the installed [`ChunkCache`] when there is one ([`run_source_cached`]).
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
run_source_cached(&self.lua, crate::source::chunk(chunk), name)
}
/// Load and evaluate a Lua chunk, returning its result: for tests and one-shot queries.
pub fn eval<T: mlua::FromLuaMulti>(&self, chunk: &str) -> mlua::Result<T> {
self.lua.load(chunk).set_mode(mlua::ChunkMode::Text).eval()
}
/// How many values the Lua expression `expr` returns (`"GetItemInfo(1)"`, not a chunk), zero
/// values and one `nil` told apart; a raise propagates. `select` is not a 1.12 global, so
/// inside Lua the count is `(function(...) return arg.n end)(expr)`.
pub fn arity(&self, expr: &str) -> mlua::Result<usize> {
let values: mlua::Variadic<mlua::Value> = self.eval(&format!("return {expr}"))?;
Ok(values.len())
}
/// The owning frame's name for an [`ExtractedQuad`] target, for tooling that sees only quads.
pub fn quad_owner_name(&self, target: ZTarget) -> Option<String> {
let model = self.model_ref();
let fh = match target {
ZTarget::Frame(fh) => fh,
ZTarget::Region(rh) => model.arena.region(rh)?.owner,
};
model.arena.frame(fh)?.name.clone()
}
/// Append a line from the app's chat feed to the named ScrollingMessageFrame: `r`/`g`/`b` in
/// `0..1` are byte-quantized round-half-up, and the fade drives the line's alpha.
pub fn add_chat_message(&mut self, frame: &str, text: &str, r: f32, g: f32, b: f32) -> bool {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup(frame) else {
return false;
};
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(crate::widget::KindState::ScrollingMessage(smf)) => {
smf.add(text.to_string(), r, g, b);
true
}
_ => false,
}
}
/// Show the named EditBox and give it keyboard focus, for the app's chat-open key (ENTER).
pub fn focus_editbox(&mut self, name: &str) -> bool {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup(name) else {
return false;
};
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(crate::widget::KindState::EditBox(_))
) {
return false;
}
model.arena.set_shown(h, true);
model.focused_editbox = Some(h);
true
}
/// Drain the chat lines `SubmitChatInput` queued since the last call, for the app to parse.
pub fn take_chat_input(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().chat_input)
}
/// Queue a line as if typed into the chat EditBox and submitted, for probes (`WOW_PROBE_CHAT`).
pub fn push_chat_input(&mut self, line: String) {
self.model_mut().chat_input.push(line);
}
/// Whether Tab was pressed in the chat edit box since the last call: the whisper cycle's cue.
pub fn take_chat_tab(&mut self) -> bool {
std::mem::take(&mut self.model_mut().chat_tab)
}
/// Replace the hyperlink spans `(frame, y-up rect, link, markup)` the app rasterized this
/// frame; a release inside one fires `OnHyperlinkClick` on its frame ([`pointer`]).
pub fn set_link_spans(&mut self, spans: Vec<(FrameHandle, Rect, String, String)>) {
let mut model = self.model_mut();
model.link_spans.clear();
for (fh, rect, link, markup) in spans {
model
.link_spans
.entry(fh)
.or_default()
.push((rect, link, markup));
}
}
/// Every **named** frame's effective visibility, appended to `out` (cleared first).
///
/// For the flash tracer (`WOW_FLASH_TRACE`), which diffs this frame against the last to find
/// what is toggling: a window that "flashes" is one being shown and hidden faster than a
/// player can see, and the only way to name it is to watch every frame at once. The buffer is
/// the caller's so a per-frame sweep allocates nothing.
pub fn named_visibility(&self, out: &mut Vec<(VisKey, bool)>) {
out.clear();
let model = self.model_ref();
out.extend(model.arena.iter_frames().filter_map(|(h, f)| {
// Named only — an anonymous frame has nothing to report — but the *name* is not
// carried: cloning one per named frame per frame is what made this sweep cost 6 ms a
// frame on a 17 000-frame interface, and a key the caller can hash without allocating
// makes the sweep proportional to the walk and nothing else.
f.name
.as_ref()
.map(|_| (VisKey::Frame(h), f.effective_visible))
}));
// **Regions too, not only frames.** A texture shown and hidden every frame flashes just as
// visibly as a window does — Tmog's "Loading" overlay is exactly that — and walking frames
// alone made the tracer answer "nothing is toggling" while something plainly was. A region
// counts as shown when it is not hidden *and* its owner is visible, which is the state a
// player actually sees.
out.extend(model.region_names.iter().filter_map(|(_, &id)| {
let rh = model.id_to_region.get(&id).copied()?;
let hidden = model.region_data.get(&rh).is_some_and(|d| d.hidden);
let owner_visible = model
.arena
.region(rh)
.map(|r| r.owner)
.and_then(|owner| model.arena.frame(owner))
.is_some_and(|f| f.effective_visible);
Some((VisKey::Region(id), !hidden && owner_visible))
}));
}
/// The global name behind a [`VisKey`], for a report.
///
/// Separate from the sweep because it allocates: a caller samples tens of thousands of keys a
/// frame and names the handful it decides to print, once.
pub fn vis_key_name(&self, key: VisKey) -> Option<String> {
let model = self.model_ref();
match key {
VisKey::Frame(h) => model.arena.frame(h).and_then(|f| f.name.clone()),
// `region_names` is keyed the other way, so this is a scan. It runs for the printed
// rows only, which is why the sweep above can afford to carry an id.
VisKey::Region(id) => model
.region_names
.iter()
.find(|(_, &v)| v == id)
.map(|(name, _)| name.clone()),
}
}
/// The handle of the frame with this global name, if it exists.
///
/// The inverse of [`Self::frame_name`], for a caller that keys state by handle and needs to
/// find one stock frame among them — the dressing room's booth, which draws the stock pane
/// while the pooled booths draw the rest.
pub fn frame_handle_by_name(&self, name: &str) -> Option<FrameHandle> {
self.model_ref()
.arena
.iter_frames()
.find(|(_, f)| f.name.as_deref() == Some(name))
.map(|(h, _)| h)
}
/// Whether this frame is shown with every ancestor, by handle.
///
/// [`Self::frame_visible`]'s sibling for a caller holding a handle rather than a name — the
/// dressing-room feed, which keys a room per `DressUpModel` pane and must ask about panes an
/// addon never named.
pub fn frame_handle_visible(&self, h: FrameHandle) -> bool {
self.model_ref()
.arena
.frame(h)
.is_some_and(|f| f.effective_visible)
}
/// Whether this handle still names a live frame; a `/reload` builds a fresh arena.
pub fn frame_handle_alive(&self, h: FrameHandle) -> bool {
self.model_ref().arena.frame(h).is_some()
}
/// Whether the frame with this global name is shown with every ancestor (`IsVisible()`).
pub fn frame_visible(&self, name: &str) -> bool {
self.model_ref()
.arena
.iter_frames()
.any(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
}
/// The named frame's effective alpha while it is effectively visible: the app's minimap ping
/// sprite follows the stock `MiniMapPing` `<Model>` this way.
pub fn frame_effective_alpha(&self, name: &str) -> Option<f32> {
self.model_ref()
.arena
.iter_frames()
.find(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
.map(|(_, f)| f.effective_alpha)
}
/// Resolve and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire
/// `OnSizeChanged` for frames whose size moved ([`event::fire_size_changes`]) and re-seat the
/// text of every resized EditBox.
pub fn resolve(&mut self) {
self.resolve_and_measure();
event::fire_size_changes(&self.lua);
// The EditBox's own `OnSizeChanged` (`0x77a8d0`) runs after the script fire and re-seats a
// resized box's text, which the reference's drain resolves in the same pass.
let reseated = editbox::reseat_resized(&mut self.model_mut());
if reseated {
self.resolve_and_measure();
}
}
/// Resolve, and with a font engine installed ([`Self::set_text_measurer`]) measure what the
/// solve revealed and solve again, so a FontString's box is right in the frame its text was set.
fn resolve_and_measure(&mut self) {
{
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
if self.fill_measures() {
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
}
/// Store host measurements `(id, w, h, natural_w, key)` for [`MeasureRequest`]s; the next
/// [`UiScript::resolve`] sizes the FontStrings by `w`/`h`, the text as laid out (wrapped in a
/// declared width), while `natural_w` is its unwrapped width, which `GetStringWidth` reports.
pub fn set_measured_text(&mut self, measures: &[(u32, f32, f32, f32, u64)]) {
let mut model = self.model_mut();
for &(id, w, h, natural_w, key) in measures {
let Some(&rh) = model.id_to_region.get(&id) else {
continue;
};
let mut moved = false;
if let Some(d) = model.region_data.get_mut(&rh) {
let new = MeasuredText {
w,
h,
natural_w,
key,
};
// The key always lands, or the region re-requests forever; the epoch moves only
// with the laid-out extent.
moved = MeasuredText::layout_moved(d.measured, new);
d.measured = Some(new);
}
if moved {
// Touched per region, so the layout gate need not re-walk the whole roster.
model.touch_layout_region(rh);
}
}
}
/// [`Self::set_measured_text`] for unwrapped text, whose two widths are one; for tests.
pub fn set_measured_text_unwrapped(&mut self, measures: &[(u32, f32, f32, u64)]) {
let widened: Vec<_> = measures
.iter()
.map(|&(id, w, h, key)| (id, w, h, w, key))
.collect();
self.set_measured_text(&widened);
}
/// Force the next [`UiScript::resolve`] past both change gates to rebuild the whole layout
/// graph: the scoped resolve's falsifier, as a full rebuild must reach the same rects.
pub fn force_full_layout_resolve(&mut self) {
let mut model = self.model_mut();
model.layout_scope.invalidate();
model.layout_fingerprint = None;
model.layout_epoch_resolved = None;
model.touch_layout();
}
/// Drop every cached host text metric when the host's raster environment changes (resize,
/// fullscreen, uiScale): glyph advances snap to whole pixels at the drawn size, so a stale
/// measure can fail the ellipsis fit and truncate text that fits.
pub fn invalidate_text_measures(&mut self) {
let mut model = self.model_mut();
for d in model.region_data.values_mut() {
d.measured = None;
}
// A bumped key never matches the recomputed content hash, so the next sweep re-requests.
for (_, frame) in model.arena.iter_frames_mut() {
match &mut frame.kind_state {
KindState::ScrollingMessage(smf) => {
for line in &mut smf.lines {
line.rows_key = line.rows_key.wrapping_add(1);
}
}
KindState::EditBox(eb) => {
eb.advances_key = eb.advances_key.wrapping_add(1);
eb.line_height = None;
}
_ => {}
}
}
// Measured extents are auto-size inputs, the layout gate's read set.
model.touch_layout();
// Every stored measure was just wiped; only the whole-roster sweep can re-request them.
model.touch_measure_all();
}
// ── Input: the pointer leaving the window ────────────────────────────────────────────────────
/// The OS pointer left the window, so no release will be fed: clears [`Model::drag`], which
/// would start a drag on re-entry, and [`Model::mouse_down_on`], which would click the frame
/// it left. The app calls it where it fires the synthetic `OnLeave`.
pub fn pointer_left_window(&mut self) {
// A started drag ends with `OnDragStop`, as the reference's release always ends it; dropped
// silently, its `StopMovingOrSizing` never runs and the frame stays glued to the cursor.
let abandoned = {
let mut model = self.model_mut();
cursor::abandon_drag(&mut model)
};
if let Some(source) = abandoned {
self.fire_drag_stop(source);
}
let mut model = self.model_mut();
let held: Vec<crate::widget::FrameHandle> = model.mouse_down_on.values().copied().collect();
model.mouse_down_on.clear();
// Every held button gets the release edge never fed (`0x7793de`), else it stays pushed.
for h in held {
button::edge(&mut model, h, crate::widget::ButtonState::on_mouse_up);
}
// The capture slot (`root+0x80`) too: a stale one aims the next press's raise at it.
model.mouse_capture = None;
// [`Model::last_click`] stays: `[CButton+0x334]` has no writer on leave, hide or disable,
// so a double click survives the cursor leaving and returning within 300 ms.
// A slider thumb drag is abandoned too: its release is never fed either.
model.slider_drag = None;
}
// ── Keyboard entry ───────────────────────────────────────────────────────────────────────────
//
// Keys arrive by name; the host maps its keycodes. The box routing: a focused box consumes
// every event; with none focused, the topmost visible `autoFocus` box takes focus and
// processes that same event; otherwise nothing is consumed.
/// A typed character (UTF-8) from the host: a focused box inserts it (numeric, cap and
/// password rules apply) or, for Ctrl+A's control code, selects all. `true` if consumed.
pub fn char_input(&mut self, text: &str) -> bool {
// The frame walk first ([`keyboard`]), the focused box a participant at its own strata and
// level, as in the reference's one dispatcher; an unconsumed event goes to the box routing.
keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text)
}
/// Paste host clipboard text into the focused EditBox as one edit: newlines survive only in a
/// `multiLine` box, other control characters drop. `true` if a box consumed it.
pub fn paste(&mut self, text: &str) -> bool {
editbox::paste(&self.lua, text)
}
/// A non-character key by name (`"ENTER"`, `"ESCAPE"`, `"TAB"`); editing keys come through
/// [`Self::editbox_action`]. A focused box consumes a key even when it does nothing with it.
pub fn key_input(&mut self, key: &str) -> bool {
// The same two stages as `char_input`.
keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key)
}
/// An editing key (BACKSPACE, DELETE, the arrows, HOME, END) offered to the keyboard frames
/// before the focused box gets it as an [`EditAction`]. On `true` the caller dispatches neither
/// the action nor the key's binding (consumption suppresses it, `0x76b7d0`).
pub fn frame_key_input(&mut self, key: &str) -> bool {
keyboard::frame_key_input(&self.lua, key)
}
/// One text-editing operation on the focused EditBox, from the host's per-OS keymap.
pub fn editbox_action(&mut self, action: EditAction) -> bool {
editbox::action(&self.lua, action)
}
/// Whether the focused EditBox is in alt-arrow mode (XML `ignoreArrows`, Lua
/// `SetAltArrowKeyMode`, `[editbox+0x318] & 0x10`): without ALT the reference declines the four
/// arrows (`0x77b1c4`), so they reach the world's bindings and turn the player while chat has
/// focus. The gate is on the key, not the [`EditAction`]: HOME and END also move to an edge.
pub fn editbox_alt_arrow_mode(&self) -> bool {
let model = self.model_ref();
model.focused_editbox.is_some_and(|h| {
model.arena.frame(h).is_some_and(|f| {
f.effective_visible
&& matches!(&f.kind_state,
crate::widget::KindState::EditBox(eb) if eb.alt_arrow_key_mode)
})
})
}
/// Whether a visible EditBox holds keyboard focus, which gates the world's keys as the client's
/// `0xcf4dc8 != 0` test does.
pub fn has_keyboard_focus(&self) -> bool {
let model = self.model_ref();
model
.focused_editbox
.is_some_and(|h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
}
/// The focused EditBox's name, unfiltered by visibility, unlike [`Self::has_keyboard_focus`].
/// Host-side only: 1.12's EditBox table has `SetFocus`/`ClearFocus` and no getter.
pub fn focused_editbox_name(&self) -> Option<String> {
let model = self.model_ref();
let h = model.focused_editbox?;
model.arena.frame(h)?.name.clone()
}
/// Resolves the layout change gate let through; a per-frame delta of 0 means a quiet frame.
pub fn layout_solves(&self) -> u64 {
self.model_ref().layout_solves
}
/// Resolves past tier 1, which pay the whole-roster preamble ([`Model::layout_gate_walks`]):
/// the gate's true cost, at least [`Self::layout_solves`], since a walk may find nothing moved.
pub fn layout_gate_walks(&self) -> u64 {
self.model_ref().layout_gate_walks
}
/// Whole-graph layout derivations, a resolve's one expensive step; flat while a UI animates.
pub fn layout_derivations(&self) -> u64 {
self.model_ref().layout_derives
}
/// **Where** those derivations were asked for: `(file:line, count)` since the last take,
/// costliest first. The site is the write's own line, carried by `#[track_caller]` down the
/// naming chain rather than a backtrace, so this is cheap enough to leave on.
///
/// Take-and-rearm, and the first take is what arms it — a caller that asks every window gets
/// that window's rows, a VM nobody asks pays one branch per derivation, and a VM replaced by
/// `/rl` re-arms on the next ask without anybody having to notice it was replaced.
pub fn take_layout_derive_sites(&self) -> Vec<(String, u64)> {
let mut model = self.model_mut();
let taken = model
.layout_derive_sites
.replace(std::collections::HashMap::new());
let Some(sites) = taken else {
return Vec::new();
};
let mut rows: Vec<(String, u64)> = sites
.into_iter()
.map(|((file, line), n)| (format!("{file}:{line}"), n))
.collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
rows
}
/// Fixpoint rounds across every solve; over [`Self::layout_solves`], the per-pass depth.
pub fn layout_rounds(&self) -> u64 {
self.model_ref().layout_rounds
}
/// The last solve's scope, `(frames solved, regions swept)`: a change to ten FontStrings must
/// read a handful here however large the UI grows.
pub fn layout_last_scope(&self) -> (usize, usize) {
self.model_ref().layout_last_scope
}
/// Whether `name` is a registered FrameXML template, case-folded as the loader resolves it, for
/// the corpus harness: an addon naming a missing template gets a bare frame and no load error.
pub fn has_framexml_template(&self, name: &str) -> bool {
let model = self.model_ref();
let templates = model.framexml_templates.borrow();
templates.contains_key(name) || templates.keys().any(|k| k.eq_ignore_ascii_case(name))
}
/// Whether `name` is a registered font object, the other namespace an `inherits=` may name
/// (`loader::expand_region` forks on it), so a census counts no font as a missing template.
pub fn has_font_object(&self, name: &str) -> bool {
self.model_ref().font_object(name).is_some()
}
/// The widget kind of a published name, in `CreateFrame`'s spelling (`SimpleHTML`): host-side,
/// for the corpus harness to attribute a call site to a class unseen by any addon.
pub fn widget_kind(&self, name: &str) -> Option<&'static str> {
let model = self.model_ref();
if let Some(h) = model.arena.lookup(name) {
return model.arena.frame(h).map(|f| match f.kind {
crate::widget::FrameKind::Frame => "Frame",
// A `Frame` to Lua: the registered name never becomes a class.
crate::widget::FrameKind::WorldFrame => "Frame",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
// `GetObjectType` (`0x495b60`) says "LootButton" (`[0x847ce0]` → `0x843414`).
crate::widget::FrameKind::LootButton => "LootButton",
crate::widget::FrameKind::EditBox => "EditBox",
crate::widget::FrameKind::StatusBar => "StatusBar",
crate::widget::FrameKind::Slider => "Slider",
crate::widget::FrameKind::ScrollFrame => "ScrollFrame",
crate::widget::FrameKind::Model => "Model",
crate::widget::FrameKind::PlayerModel => "PlayerModel",
crate::widget::FrameKind::DressUpModel => "DressUpModel",
crate::widget::FrameKind::TabardModel => "TabardModel",
crate::widget::FrameKind::MessageFrame => "MessageFrame",
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
crate::widget::FrameKind::ColorSelect => "ColorSelect",
crate::widget::FrameKind::SimpleHtml => "SimpleHTML",
crate::widget::FrameKind::MovieFrame => "MovieFrame",
crate::widget::FrameKind::GameTooltip => "GameTooltip",
crate::widget::FrameKind::Minimap => "Minimap",
});
}
// Region leaves publish into their own name table, not the arena's.
let id = *model.region_names.get(name)?;
let h = *model.id_to_region.get(&id)?;
model.arena.region(h).map(|r| match r.kind {
crate::widget::RegionKind::Texture => "Texture",
crate::widget::RegionKind::FontString => "FontString",
// A title region is a plain Region (`0x76c440`); `CreateTitleRegion` takes no name.
crate::widget::RegionKind::Title => "Region",
})
}
/// A snapshot of the script errors collected so far (from `pcall`'d handlers).
pub fn errors(&self) -> Vec<String> {
self.model_ref().errors.clone()
}
/// Drain the collected script errors.
pub fn take_errors(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().errors)
}
/// A snapshot of non-fatal host warnings (e.g. ignored `CreateFrame` templates).
pub fn warnings(&self) -> Vec<String> {
self.model_ref().warnings.clone()
}
/// Drain the non-fatal host warnings, for the host to log.
pub fn take_warnings(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().warnings)
}
/// Drain the host notices, for the host to log at `info!`.
pub fn take_notices(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().notices)
}
/// The named frame's own shown bit, as `frame:IsShown()` reads it (`None`: no frame owns that
/// name). Visibility is not the same question — a shown frame under a hidden parent draws
/// nothing — and this is the shown bit, because that is the one addons branch on.
///
/// The engine needs it to read its own log: both addons that answer `ZONE_CHANGED*` by
/// reselecting the world map do it only `if not WorldMapFrame:IsShown()`
/// (`pfQuest/map.lua:1466`, `pfUI/modules/minimap.lua:183`), so one shown bit left set
/// silences both — with no error, no call, and nothing in the VM that says so.
pub fn frame_shown(&self, name: &str) -> Option<bool> {
let model = self.model_ref();
let h = model.arena.lookup(name)?;
Some(model.arena.frame(h)?.shown)
}
/// Report a script error the host caught outside the VM's dispatch (an addon file that failed
/// to compile or raised at file scope): it joins the handler-dispatch queue and the retained
/// log, not `errors`, since the caller has already logged it.
pub fn report_script_error(&self, msg: &str) {
// A Load row, not an Error one: to the player a file scope that raised and a missing file
// are the same fact, the addon is not running.
diagnostics::record_load_failure(&self.lua, msg);
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
}
/// Hand each queued script error to the Lua error handler, as the reference invokes the one
/// `seterrorhandler`/`geterrorhandler` (`0x702900`/`0x702950`) hold on a caught error;
/// FrameXML installs `_ERRORMESSAGE`, the ScriptErrors dialog (`BasicControls.xml:16`). The
/// stdlib default is skipped, as it already reported into [`UiScript::errors`]; a handler that
/// raises is recorded on the host channel only and stops the batch, so the path cannot recurse.
pub fn dispatch_script_errors_to_handler(&mut self) {
let pending = std::mem::take(&mut self.model_mut().pending_error_dispatch);
if pending.is_empty() {
return;
}
let handler: Option<mlua::Function> = self
.lua
.globals()
.get::<mlua::Function>("geterrorhandler")
.ok()
.and_then(|g| g.call::<mlua::Function>(()).ok());
let Some(handler) = handler else { return };
if let Ok(default) = self
.lua
.named_registry_value::<mlua::Function>(REG_DEFAULT_ERRORHANDLER)
{
if handler == default {
return;
}
}
for msg in pending {
if let Err(e) = handler.call::<()>(msg) {
self.model_mut()
.errors
.push(format!("error handler itself failed: {e}"));
break;
}
}
}
/// Register a named [`FontObject`] (a resolved `<Font>`), replacing any of that name, and
/// publish it as the Lua global `name`, the same pair `Loader::do_font` performs.
pub fn register_font_object(&self, name: &str, font: FontObject) {
self.model_mut()
.font_objects_by_lower
.insert(name.to_ascii_lowercase(), font);
// Publishing a fresh table under a string key cannot fail; the record is in place anyway.
let _ = font::publish(&self.lua, name);
}
/// A registered [`FontObject`] by name: its resolved paint.
pub fn font_object(&self, name: &str) -> Option<FontObject> {
self.model_ref().font_object(name).cloned()
}
/// Every registered [`FontObject`]: the glyph atlas bakes outlined cells for the distinct
/// `(font, height, outline)` triples here.
pub fn font_objects(&self) -> Vec<FontObject> {
self.model_ref()
.font_objects_by_lower
.values()
.cloned()
.collect()
}
// ── internals ────────────────────────────────────────────────────────────────────────────
fn model_ref(&self) -> mlua::AppDataRef<'_, Model> {
self.lua
.app_data_ref::<Model>()
.expect("model app_data set")
}
fn model_mut(&self) -> mlua::AppDataRefMut<'_, Model> {
self.lua
.app_data_mut::<Model>()
.expect("model app_data set")
}
fn push_error(&self, e: mlua::Error) {
self.model_mut().record_script_error(e.to_string());
}
}
#[cfg(test)]
mod tests;
/// Where the compiled form of a chunk is kept between sessions. The VM crate has no business
/// knowing where a player's files live, so the host implements this and installs it once
/// ([`install_chunk_cache`]); with none installed every chunk is parsed, as before.
///
/// `key` is the cache's own business: it sees the source bytes and the chunk name (which is in the
/// dump, so two files with the same text and different names are different entries) and returns
/// whatever it wants to look up by. `get` returning stale or foreign bytes is not an error — the
/// loader checks the dump's header and the source is parsed instead.
pub trait ChunkCache: Send + Sync {
fn key(&self, source: &[u8], name: &str) -> String;
fn get(&self, key: &str) -> Option<Vec<u8>>;
fn put(&self, key: &str, bytecode: &[u8]);
/// Drop an entry the loader refused.
fn forget(&self, key: &str);
}
static CHUNK_CACHE: std::sync::OnceLock<Box<dyn ChunkCache>> = std::sync::OnceLock::new();
/// Install the process's chunk cache. Once; a second call is ignored, so a test that installs one
/// cannot change another's answers.
pub fn install_chunk_cache(cache: Box<dyn ChunkCache>) {
let _ = CHUNK_CACHE.set(cache);
}
/// Compile and run `source` under `name`, through the installed cache when there is one.
///
/// The compile is the expensive half of a UI load and the only half that can be kept: measured
/// over the player's enabled addons (1,471 files, 70 MiB of Lua), **parsing is 703 ms and loading
/// the same chunks as 5.1 bytecode is 74 ms**. Running them is the other half — the tables they
/// build are what an addon is — and is not cacheable.
///
/// Safe by construction rather than by bookkeeping: an entry that is stale, truncated or from
/// another Lua build fails `lua_undump`'s header check and the source is parsed, so the worst a
/// bad entry costs is the parse it was meant to save. The dump keeps its debug info, because
/// `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines.
pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> mlua::Result<()> {
let Some(cache) = CHUNK_CACHE.get() else {
let t = std::time::Instant::now();
let f = lua
.load(source)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.into_function();
chunk_stats().miss(t.elapsed().as_nanos(), 0);
return f?.call::<()>(());
};
let t = std::time::Instant::now();
let key = cache.key(source, name);
let keyed = t.elapsed().as_nanos();
if let Some(code) = cache.get(&key) {
let t = std::time::Instant::now();
match lua
.load(&code[..])
.set_name(name)
.set_mode(mlua::ChunkMode::Binary)
.into_function()
{
Ok(f) => {
chunk_stats().hit(t.elapsed().as_nanos(), keyed);
return f.call::<()>(());
}
// Not the player's error: drop the entry and parse.
Err(_) => cache.forget(&key),
}
}
let t = std::time::Instant::now();
let f = lua
.load(source)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.into_function()?;
let dump = f.dump(false);
chunk_stats().miss(t.elapsed().as_nanos(), keyed);
cache.put(&key, &dump);
f.call::<()>(())
}
/// What the chunk cache did this process: hits and misses, and the time each spent turning bytes
/// into a function. `WOW_LUA_CACHE=0` and a run with the folder deleted are the two controls.
#[derive(Default)]
pub struct ChunkStats {
pub hits: u64,
pub misses: u64,
pub hit_ns: u128,
pub miss_ns: u128,
pub key_ns: u128,
}
impl ChunkStats {
fn hit(&mut self, ns: u128, key_ns: u128) {
self.hits += 1;
self.hit_ns += ns;
self.key_ns += key_ns;
}
fn miss(&mut self, ns: u128, key_ns: u128) {
self.misses += 1;
self.miss_ns += ns;
self.key_ns += key_ns;
}
}
fn chunk_stats() -> std::sync::MutexGuard<'static, ChunkStats> {
static STATS: std::sync::OnceLock<std::sync::Mutex<ChunkStats>> = std::sync::OnceLock::new();
STATS
.get_or_init(|| std::sync::Mutex::new(ChunkStats::default()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
/// One line naming what the cache saved **since this was last asked**, and the counters reset:
/// a load edge asks once per load, so every line is that load's own work rather than a running
/// total in which a warm `/reload` would hide behind the cold login before it.
pub fn chunk_cache_line() -> String {
let mut s = chunk_stats();
let line = format!(
"lua chunks: {} compiled in {:.0} ms, {} loaded from cache in {:.0} ms, keys {:.0} ms",
s.misses,
s.miss_ns as f64 / 1e6,
s.hits,
s.hit_ns as f64 / 1e6,
s.key_ns as f64 / 1e6,
);
*s = ChunkStats::default();
line
}