//! 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 `` 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,
/// 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,
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::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\\`,
/// 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 {
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 = 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 {
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(&self, chunk: &str) -> mlua::Result {
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 {
let values: mlua::Variadic = 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 {
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 {
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 {
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 {
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` `` this way.
pub fn frame_effective_alpha(&self, name: &str) -> Option {
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 = 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 {
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 {
self.model_ref().errors.clone()
}
/// Drain the collected script errors.
pub fn take_errors(&mut self) -> Vec {
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 {
self.model_ref().warnings.clone()
}
/// Drain the non-fatal host warnings, for the host to log.
pub fn take_warnings(&mut self) -> Vec {
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 {
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 {
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 = self
.lua
.globals()
.get::("geterrorhandler")
.ok()
.and_then(|g| g.call::(()).ok());
let Some(handler) = handler else { return };
if let Ok(default) = self
.lua
.named_registry_value::(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 ``), 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 {
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 {
self.model_ref()
.font_objects_by_lower
.values()
.cloned()
.collect()
}
// ── internals ────────────────────────────────────────────────────────────────────────────
fn model_ref(&self) -> mlua::AppDataRef<'_, Model> {
self.lua
.app_data_ref::()
.expect("model app_data set")
}
fn model_mut(&self) -> mlua::AppDataRefMut<'_, Model> {
self.lua
.app_data_mut::()
.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>;
fn put(&self, key: &str, bytecode: &[u8]);
/// Drop an entry the loader refused.
fn forget(&self, key: &str);
}
static CHUNK_CACHE: std::sync::OnceLock> = 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) {
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::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
}