//! The Lua scripting host — the engine-free VM that turns the arena/layout/order *model* into a
//! live, addon-facing runtime (decision 0068). No Bevy, no GPU: this module embeds mlua's Lua 5.1
//! (the Classic Era version) and wires it to [`crate::widget`]'s frame arena, [`crate::layout`]'s
//! anchor resolver, and [`crate::order`]'s draw-order traversal, exposing the WoW FrameScript object
//! model and the slice-of stdlib addons expect.
//!
//! ## Ground truth
//!
//! - **RF-0023** (`wow-5875-re/system/ui/ui.md` §"The Lua frame object model"): a frame's Lua value
//! is a table `T` with `T[0] = lightuserdata(handle)`, one *shared* metatable
//! `__framescript_meta` whose `__index` dispatches by method name; named frames auto-publish to
//! `_G` non-overwriting. See [`object`].
//! - **RF-0025** (`ui.md` §"FrameScript handler-firing context"): a handler is fired via `pcall`
//! with `this`/`event`/`arg1..argN` set as **globals**, saved-then-restored around the call
//! (nesting-safe). We also pass the *modern* `(self, event, ...)` arguments the Era addons expect
//! (the transition-era client did both). See [`event`].
//!
//! ## The `LUAI_MAXCSTACK` discipline (decision 0068, probe A)
//!
//! Probe A found the hard constraint that Rust must **never** hold thousands of persistent mlua
//! handles (each owned `Table`/`Function` occupies a slot on mlua's reference thread, capped by the
//! vendored build's `LUAI_MAXCSTACK = 8000`). So this host holds **zero** persistent Lua handles in
//! Rust: every piece of Lua-side state — the two shared metatables, the two method tables, the
//! wrapper cache (`id → wrapper table`), and every frame's script closures (`id → {name → fn}`) —
//! lives in the Lua **registry** under named keys and is fetched transiently per call. The Rust-side
//! [`Model`] is *plain data* (arena, layout inputs, id↔handle maps, region visuals, event
//! registrations, errors) stored in `lua.app_data`, reachable from callbacks via `RefCell`-style
//! dynamic borrows. Every callback takes a *short* borrow and drops it before re-entering Lua.
//!
//! ## Identity: ids, not handle bits
//!
//! RF-0023 stores the `CScriptObject*` in the lightuserdata. Benilla's [`crate::widget::FrameHandle`]
//! is an opaque generational handle with private fields (no bit accessor), so we mint a stable `u32`
//! **id** per handle and store *that* in the lightuserdata (`id as *mut c_void`); [`Model`] owns the
//! `id ↔ handle` bijection. The id doubles as the [`crate::layout::Handle`] used by the layout graph
//! (frames only; the screen root is the reserved id [`SCREEN`]). This is faithful to RF-0023's intent
//! (an opaque identity in a lightuserdata) — only the *encoding* differs.
mod action;
mod action_bar_toggles;
mod addon_message;
mod auction;
mod aura;
mod backdrop;
mod bank;
mod bind_confirm;
mod binder;
mod binding_abi;
// The five camera views + FlipCameraYaw — the reference's `UIUtil\Camera.cpp` Lua surface.
mod button;
mod camera_view;
mod channel;
mod char_stats;
mod chat_send;
mod chat_window;
mod clip;
mod colorselect;
mod container;
mod cooldown;
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 cvars;
mod dressup;
mod duel;
pub(crate) mod event;
mod extract;
mod follow;
pub(crate) mod font;
mod font_block;
mod gm_ticket;
mod gossip;
mod guild;
mod handler_prof;
mod screenshot;
pub use handler_prof::HandlerRow;
mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod layout_cache;
mod loot;
mod loot_roll;
mod lua50;
mod macros;
mod mail;
mod measure;
mod merchant;
mod messageframe;
mod minimap;
mod model;
mod modelframe;
mod net_stats;
mod object;
mod party;
mod pet;
mod petition;
mod pointer;
mod pvp;
mod quest;
mod quest_log;
pub(crate) mod region;
/// The 19 Region-map methods, shared by frames and regions the way the reference shares them
/// (decision 1501) — its header is the byte-verified chain and the bug that found the split.
mod region_map;
mod reputation;
mod saved;
mod scrollframe;
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::TooltipTint;
mod trade;
mod tradeskill;
mod trainer;
mod types;
mod unit;
mod weapon_enchant;
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 bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use char_stats::{
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, UnitCombatStats,
BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_send::ChatSend;
pub use chat_window::ChatWindowLook;
pub use container::{
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
RandomPropertyView, UiCursorMode,
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
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 petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
PETITION_TYPE_PETITION,
};
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};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub(crate) use model::Model;
pub use model::{TextureProbe, TextureSizeProbe};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
pub use quest::{QuestAction, QuestItemView, QuestPanel, 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::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::{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::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
ScriptValue, TexCoords,
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
};
pub use weapon_enchant::WeaponEnchant;
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 key names — the only place Lua-side roots are kept alive (the MAXCSTACK discipline).
const REG_FRAME_META: &str = "__benilla_frame_meta";
const REG_REGION_META: &str = "__benilla_region_meta";
const REG_FRAME_METHODS: &str = "__benilla_frame_methods";
const REG_REGION_METHODS: &str = "__benilla_region_methods";
/// The full table's registry key, exposed for the exhaustiveness gate in `tests::reference_surface`
/// — the split's correctness is a property of what the VM HOLDS, not of the source that builds it.
#[cfg(test)]
pub(crate) const REG_REGION_METHODS_FOR_TEST: &str = REG_REGION_METHODS;
/// The **title region's** method table + 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 two region LEAF tables + metatables — Texture and FontString each answer their own map.
const REG_TEXTURE_METHODS: &str = "__benilla_texture_methods";
const REG_TEXTURE_META: &str = "__benilla_texture_meta";
const REG_FONTSTRING_METHODS: &str = "__benilla_fontstring_methods";
const REG_FONTSTRING_META: &str = "__benilla_fontstring_meta";
/// The stdlib's out-of-the-box error handler, kept by identity so
/// [`UiScript::dispatch_script_errors_to_handler`] can tell "nobody chose a handler" (skip — the
/// default already reports into the host channel) from "FrameXML or an addon installed one"
/// (dispatch — that is what the pair exists for). Stored at [`stdlib::install`] time, the one
/// moment the default is known to be what `geterrorhandler()` answers.
const REG_DEFAULT_ERRORHANDLER: &str = "__benilla_default_errorhandler";
/// The **Region method map** (`0xcf54b4`) — the 19 names every region leaf reaches through its own
/// lookup's fallback, carved in wow-re `system/ui/scratch/font-object-lua-surface.md` and asserted
/// as a SET by `tests::reference_surface`. Named here because two things need the same list: that
/// test, and the title region's narrower method table (1250 §5).
/// Names on **both** region leaves — and each leaf registers its own copy, so these are NOT on the
/// Region map and must not be hoisted into it (wow-re
/// `system/ui/scratch/texture-fontstring-method-split.md`, stated there as a trap in as many words).
/// `GetDrawLayer` is the setter's pair on both leaves (Texture `0x79a6c0`, FontString `0x79c660`)
/// and is here now — it was carved as absent, which was true and was a gap, not a decision; pfUI's
/// `GetNoNameObject` reads it off every child of a frame it reskins.
pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
"SetDrawLayer",
"GetDrawLayer",
"SetVertexColor",
"SetAlpha",
"GetAlpha",
"Show",
"Hide",
"IsVisible",
"IsShown",
];
/// **Texture-only.** Note `GetVertexColor` sits here while `SetVertexColor` is shared — an asymmetry
/// no reasonable partition invents, and the carve calls it out. Also note the client's Texture map
/// has `SetGradientAlpha` where FontString has `SetAlphaGradient`: a near-miss pair, and we install
/// only the FontString one.
///
/// The tail three are OURS, not 1.12's, and are parked here rather than pruned: `SetPortraitToTexture`
/// and `SetRotation` are texture verbs the carve's 22 does not list, and `SetSize` is an Era
/// geometry verb absent from the Region map. Removing a superset is a separate question per name —
/// this landing partitions, it does not prune.
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 11] = [
"SetGradient",
"SetGradientAlpha",
"GetTexture",
"SetTexture",
"GetTexCoord",
"SetTexCoord",
"SetBlendMode",
"SetDesaturated",
"GetVertexColor",
"SetRotation",
"SetSize",
];
/// **FontString-only** — the font/text/justify/shadow block plus the string metrics.
///
/// The tail two are OURS: `SetFormattedText` is not in the client's 32, and `SetSize` is the same
/// Era geometry verb the Texture list carries. Parked, not pruned, pending their own checks.
///
/// `GetStringHeight` was here and is GONE (1251's first prune): byte-verified absent from 1.12 in
/// every encoding, ours was a byte-identical duplicate of `GetHeight`, and every call site — two of
/// our own XML files, two tests, and `Button:GetTextHeight`'s delegate — now goes through the
/// Region method the reference itself uses.
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",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetAlphaGradient",
"SetFormattedText",
"SetSize",
];
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";
/// The reserved layout [`crate::layout::Handle`] of the screen root (the client's `CSimpleTop` /
/// `UIParent`), whose rect is the physical screen. Top-level frames whose `SetPoint` omits a
/// `relativeTo` anchor to it. Real frame ids are minted from `1` upward so they never collide.
pub const SCREEN: crate::layout::Handle = 0;
/// The FrameScript handler kinds this host models. The first five are the lifecycle/event set; the
/// six mouse handlers are driven by the hit-testing API in [`pointer`] ([`UiScript::mouse_move`] /
/// [`mouse_button`](UiScript::mouse_button) / [`mouse_wheel`](UiScript::mouse_wheel)) — the app-side
/// event feed (net/window → these calls) is the Bevy side's job (decision 0068). `OnValueChanged`
/// is shared by the StatusBar (RF-28 `+0x32c`) and the Slider (`+0x330`; decision 0250) — one name,
/// each kind dispatching to its own value-changed slot. The eight
/// `On*Pressed`/text/focus slots are the EditBox's specialized scripts (RF-0082 §2): a focused EditBox
/// fires ONLY these, never generic `OnKeyDown`/`OnChar` (its C++ override replaces those slots).
/// `OnVerticalScroll`/`OnScrollRangeChanged` are the ScrollFrame's own slots (decision 0112).
/// `OnDragStart`/`OnDragStop`/`OnReceiveDrag` are the drag trio (decision 0216 §3) — driven by
/// `RegisterForDrag` + the same mouse path as the six mouse handlers above, not a separate one.
/// `OnColorSelect` is the ColorSelect's own slot (RF-28 `+0x338`), fired by its `SetColorRGB`.
///
/// **Every name here is FIRED by something.** A kind that the engine can accept but never raise is
/// strictly worse than the `SetScript: unsupported script` error it replaces — the addon's handler
/// silently never runs and nothing anywhere says so (the bug class decisions 1203/1205/1211 each
/// record). So a script name earns its row here only together with the code that fires it, and the
/// names the reference has that we do NOT fire stay OUT, with the reason recorded at
/// [`crate::script::object::events_regions::set_script`].
///
/// **This list is FLAT; the reference's set is per widget type** (RF-0028's script-name→slot
/// resolvers: base map `0x76a0d0` + the type's own additions — a `` has no `OnClick`). That
/// divergence is deliberate and measured: our own transcribed FrameXML relies on it in 9 places
/// (`PlayerFrame`/`TargetFrame`/`PetFrame`/`PartyMemberFrame1-4` are mouse-enabled ``s
/// carrying `OnClick` — see the note at `assets/ui/UnitFrames.xml`'s `PlayerFrame_OnClick` — plus
/// `ChatFrame1`/`ChatFrame2`), and the corpus in 21 more (20 `EditBox` + 1 ``
/// `OnEscapePressed`), all of which fire today ([`button::click_button`]'s "plain frames can carry
/// one too" arm). Going per-type is the faithful shape and is what would give us `HasScript` (948
/// corpus call sites across 91 addons), but it removes working behaviour from those 30 sites, so it
/// is a change to make deliberately with FrameXML fixed first — not a side effect of widening this
/// list.
const SCRIPT_KINDS: [&str; 35] = [
"OnLoad",
"OnEvent",
"OnUpdate",
"OnShow",
"OnHide",
"OnClick",
"OnEnter",
"OnLeave",
"OnMouseDown",
"OnMouseUp",
"OnMouseWheel",
"OnValueChanged",
"OnEnterPressed",
"OnEscapePressed",
"OnSpacePressed",
"OnTabPressed",
"OnTextChanged",
"OnTextSet",
"OnEditFocusGained",
"OnEditFocusLost",
"OnVerticalScroll",
"OnScrollRangeChanged",
"OnDragStart",
"OnDragStop",
"OnReceiveDrag",
// A release over a message-frame hyperlink span (`OnHyperlinkClick(link, text, button)` —
// the ChatFrameTemplate wires it to SetItemRef; decision 0288 P2).
"OnHyperlinkClick",
// The GameTooltip's engine-fired widget scripts (decision 0274; the real template wires all
// three: money render, money clear, world-hover default placement).
"OnTooltipAddMoney",
"OnTooltipCleared",
"OnTooltipSetDefaultAnchor",
// The ColorSelect's own slot (RF-28 `0x78b4f0` script-map, `+0x338`): `OnColorSelect(r, g, b)`
// — how the colour picker paints its preview swatch, and how TipBuddy's two private
// `` frames learn a colour changed.
"OnColorSelect",
// The Button/CheckButton double click (RF-28 script-map `0x778c50`, `+0x4d4`) —
// `OnDoubleClick(self, button)`, fired by [`pointer`]'s release-edge detector *instead of* the
// second `OnClick`, 300 ms (wow-re `ui/scratch/button-doubleclick-law.md`, a §5 cross-check
// dispatched from this work). **The corpus's
// single biggest script gap**: 250 call sites across 85 addons, and the *only* thing behind the
// harness's entire `SetScript: unsupported script` blocker row — 8 addons, 5 dying at load and
// 3 at session start, every one of them a FuBar plugin or a Titan panel button
// (`FuBarPlugin-2.0:CreateBasicPluginFrame` wires one, unguarded, on its panel Button).
"OnDoubleClick",
// The layout event (base map `0x76a0d0` `+0x120`), fired from the resolve pass by
// [`crate::layout::size_changed`]'s byte-verified epsilon test — see
// [`UiScript::resolve_layout`]. `OnSizeChanged(self, width, height)`.
"OnSizeChanged",
// The three KEY channels, unblocked by [`keyboard`]'s walk (wow-re
// `scratch/frame-key-script-delivery.md`, VERIFIED). They were the standing exception in
// [`object::events_regions`]'s note — accepted only once something fired them, which is that
// module's whole rule. `OnKeyUp` rides in with the other two deliberately: it is *gated* today
// (a frame carrying only an OnKeyUp consumes every key-down and runs nothing — the reference's
// own asymmetry) even though this engine's host feeds no key-up to fire it with, and accepting
// the name is what makes that consumption reachable.
"OnChar",
"OnKeyDown",
"OnKeyUp",
];
// ─────────────────────────────────────────────────────────────────────────────────────────────
// UiScript — the public host
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The Lua scripting host. Owns the mlua VM; the frame arena, layout store, and event registry live
/// inside it (in `lua.app_data`, per the MAXCSTACK discipline) and are driven through this API.
///
/// Construction sandboxes the VM (removes `io`/`os`/`package`/`require`/`dofile`/`loadfile`/`debug`,
/// text-only chunk loading — see [`stdlib`]), installs the WoW stdlib layer (the global aliases, the
/// positional `format`, the `strsplit` family, `getglobal`/`setglobal`, `wipe`, …), and installs the
/// FrameScript object model (`CreateFrame` + the widget/region method surface, the shared metatables).
///
/// The host surface is split, `impl UiScript` blocks beside their concern (the `layout.rs` pattern):
/// the render-list builder in [`extract`], the per-frame runtime loop in [`tick`], the EditBox text
/// seam in [`editbox::seam`], the layout resolve in [`layout`], and the pointer/hit-test in
/// [`pointer`]. What stays here is construction and the small host-facing state pushes/queries.
pub struct UiScript {
lua: Lua,
/// VM instructions executed, counted only while a budget is installed
/// ([`UiScript::set_instruction_budget`]). `Arc` because the hook callback outlives this
/// borrow; `Relaxed` because nothing orders against it — it is a bound, not a clock.
instructions: std::sync::Arc,
/// **This VM's identity** — see [`UiScript::session`].
session: u64,
}
/// Hands out [`UiScript::session`] ids. Process-global and monotone, so an id is never reused and
/// two of them can be compared without holding either VM.
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// How often the instruction hook fires — the resolution of an instruction budget.
///
/// mlua's own documentation warns that a low value "can incur a very high overhead", and nothing
/// wants a precise bound: the question a budget answers is "is this chunk ever going to stop?",
/// where being out by a million instructions costs nothing and firing every instruction would cost
/// the whole survey's runtime.
pub const INSTRUCTION_HOOK_STEP: u32 = 1_000_000;
/// The chunk name the CLIENT gives an addon's file: `Interface\AddOns\\`.
///
/// Backslashes, and the `Interface\AddOns\` prefix, because addons PARSE this. `FuBarPlugin-2.0`
/// derives each plugin's own folder from a stack trace —
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")` (`FuBarPlugin-2.0.lua:752`) — and
/// feeds the capture straight into `format("Interface\\AddOns\\%s\\icon", self.folderName)`.
/// With no name set, mlua defaults the chunk to the Rust caller location, the pattern misses,
/// `folderName` is nil, and every FuBar plugin dies formatting it. That was 20 addons.
///
/// The greedy `(.*)` in their pattern is why the FILE has to be in the name too: it captures up to
/// the LAST backslash, so `…\AddOns\FuBar_BagFu\FuBar_BagFu.lua` yields `FuBar_BagFu`. A name
/// stopping at the folder would capture the empty string.
pub fn addon_chunk_name(folder: &str, file: &str) -> String {
// `@` is Lua's "this chunk is a file" marker; the traceback then 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 nothing holds an app-data borrow — the two
// conditions the profiler's slot has to be installed under (decision 1395).
handler_prof::install(&lua);
addon::install(&lua)?;
addon_message::install(&lua)?;
chat_send::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 (decision 1194).
lua50::install(&lua)?;
stdlib::install(&lua)?;
object::install(&lua)?;
// After `object` (it reuses the frame side's `publish_global`), before any FrameXML is
// loaded — `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)?;
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)?;
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)?;
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)?;
cooldown::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
worldstate::install(&lua)?;
net_stats::install(&lua)?;
diagnostics::install(&lua)?;
let s = UiScript {
lua,
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
session: NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
};
Ok(s)
}
/// The embedded VM — for the Bevy plugin / TOC-XML loader to add the game-state API bindings
/// (decision 0068 §1: "the Bevy side owns … the API bindings that touch ECS/net") on top of the
/// object model this crate installs.
pub fn lua(&self) -> &Lua {
&self.lua
}
/// **Which VM this is** — a fresh number for every [`UiScript::new`], never reused.
///
/// The client destroys its Lua state at logout and builds another at the next world entry
/// (the reference's own `0x490bd0` ↔ `0x48fbf0` pair), so a host that remembers *what it last
/// pushed into the VM* is remembering something that may no longer exist. Anything the host
/// seeded — a registry, a catalog, a change-detection memo — is only valid for the session it
/// was seeded into, and this number is what says so.
///
/// A host keying its memory on this cannot go stale by omission: a new VM simply does not
/// match, so the seed happens again. That is the property, and it is why this is a VM-side fact
/// rather than a host-side counter the host must remember to bump.
pub fn session(&self) -> u64 {
self.session
}
/// **Bound how long a chunk may run, so an infinite loop reports instead of hanging.**
///
/// A missing capability in this engine has always been a silently WRONG ANSWER — a setter that
/// ignores you, a getter that says nil (1203/1205/1211/1230). Decision 1247 met the other kind:
/// `date("*t")` returned a string where Lua returns a table, so `Accountant_WeekStart`'s
/// `while thisDay ~= weekstart` never terminated and the addon spun the VM forever. That is
/// invisible to every instrument we own, because an instrument that never returns produces no
/// roster to diff, no column to compare and no error row to read — the 218-addon survey simply
/// stopped finishing, and the cause was found by bisecting the corpus BY HAND.
///
/// So a caller that runs untrusted chunks can bound them. Past `budget` VM instructions the
/// hook raises, and the raise propagates like any other Lua error: the addon reports as failed
/// with a distinctive message, and everything after it still runs.
///
/// **This is opt-in, and the app arms it only on the world-entry load edge** (decision 1306;
/// it began harness-only, e463649e). A real session must not kill a player's addon for being
/// slow, so steady state — every OnUpdate, every event — runs unhooked; but a load walk that
/// never returns is a client frozen on the loading screen with zero diagnostics (B271's
/// class), so the entry edge is bounded and [`Self::clear_instruction_budget`] disarms before
/// the session's first frame.
///
/// The hook fires every [`INSTRUCTION_HOOK_STEP`] instructions rather than every instruction:
/// mlua's own docs warn that a low value "can incur a very high overhead", and the step is the
/// resolution of the bound, which nothing here needs to be precise.
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 an installed instruction budget — the load edge's disarm (decision 1306): the bound
/// covers the world-entry walk, and a session's steady state runs unhooked exactly as before.
/// The counter keeps its last value, so [`Self::instructions_used`] still answers for the
/// phase that just ended.
pub fn clear_instruction_budget(&self) {
self.lua.remove_hook();
}
/// VM instructions executed since the last [`Self::set_instruction_budget`], to the resolution
/// of [`INSTRUCTION_HOOK_STEP`]. Zero when no budget was ever set — the hook is what counts.
///
/// Reported rather than merely used, because the budget has to be CHOSEN from the corpus and a
/// number nobody can read is a number nobody can revisit.
pub fn instructions_used(&self) -> u64 {
self.instructions.load(std::sync::atomic::Ordering::Relaxed)
}
/// Set the screen-root rect from a pixel size (`[0,0]` origin, y-up). Top-level frames anchor to
/// it; changing it invalidates the next `resolve`. The app calls this every frame with an
/// almost-always-identical size — compared before writing so the per-frame idiom doesn't
/// dirty the layout gate's tier 1 (`Model::touch_layout`).
///
/// **Returns whether the size actually changed.** Anchors follow the new rect on their own, so
/// most of the UI needs nothing; what does not follow is anything that *computed* a seat from
/// the old height and stored it — the open-bag stack wraps into a fresh column from
/// `GetScreenHeight()`, and that answer is stale the moment the window is resized. The 1.12
/// client never had to care (its resolution changed through a restart, and its own
/// `DISPLAY_SIZE_CHANGED` listeners are three model panes, none of them the manage pass), but
/// benilla runs in a freely resizable window, so the caller re-runs
/// `UIParent_ManageFramePositions()` on a true return. Decision 1499.
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();
true
}
/// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads.
/// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/
/// `IsAltKeyDown`. The app's input pass calls this BEFORE feeding the frame's mouse events, so
/// a click handler's modifier fork (the reference's shift-split / ctrl-dressup /
/// shift-pickup) reads the state as of the click.
pub fn set_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool) {
let shift_was = {
let mut model = self.model_mut();
let was = model.modifiers.0;
model.modifiers = (shift, ctrl, alt);
was
};
// The shift EDGE drives the shopping-compare tooltips (0274 P4): press over a live
// equippable item hover fires SHOW_COMPARE_TOOLTIP, release hides the pair.
if shift_was != shift {
tooltip_item::on_shift_edge(&self.lua, shift);
}
}
/// Push the player's WMO-containment state onto every Minimap widget (the client's `0xceaa60`).
/// It selects which of the two persisted zoom indices `GetZoom`/`SetZoom` act on, so the zoom
/// buttons drive the indoor level while indoors and the outdoor level while outside. The app
/// owns the containment test, so it owns this push — call it before the script tick. The
/// caller pushes on the inside↔outside edge and whenever [`Self::minimap_widgets_created`]
/// moved (a widget born after the last transition still gets told, without paying the arena
/// walk every frame).
pub fn set_minimap_inside(&mut self, inside: bool) {
self.for_each_minimap(|m| m.inside = inside);
}
/// Run `f` over every live Minimap widget's state. Goes through the arena's Minimap registry
/// rather than a full `iter_frames_mut`, for the reason that registry exists: a corpus UI is
/// three to four thousand frames and there is normally exactly one Minimap, and one of these
/// feeds ([`Self::set_minimap_player_facing`]) runs every frame.
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 world facing (radians) onto every Minimap's **player-arrow** `Model` —
/// the client's `CMinimap::SetPlayerFacing 0x4eb8e0`, whose whole 34-byte body is
/// `[minimap+0x33c] = arg` then, guarded on non-null, `[[minimap+0x338]+0x39c] = arg`: a raw
/// dword copy into the same field `Model:SetFacing 0x76dce0` writes, with **no negation, offset
/// or unit change**. Its one caller (`0x4eb0a4`–`0x4eb0b1`) feeds it the player's `GetFacing()`
/// straight off the FPU stack, so what an addon reads back is the unit's orientation exactly.
///
/// This is what makes `({Minimap:GetChildren()})[9]:GetFacing()` — Questie's `GetPlayerFacing`,
/// and pfQuest's `compat/client.lua` — return the truth rather than the ctor default `0`, which
/// is the difference between a waypoint arrow that points at the waypoint and one aimed
/// permanently north. Called every frame by the app, which owns the player.
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 the live minimap ping's **normalized** offsets from the widget centre (fractions
/// of the widget side, x right / y up), or `None` when no ping is live —
/// `Minimap:GetPingPosition()`'s source. The app recomputes this from the ping's WORLD point
/// every frame a ping is live, before the tick, so a poller sees it track the world as the
/// player walks.
///
/// One field, not one per widget: there is exactly one ping (decision 1596), and the old
/// per-widget push walked the whole ~3k-frame arena every frame a ping was live.
pub fn set_minimap_ping(&mut self, ping: Option<(f32, f32)>) {
self.model_mut().minimap_ping = ping;
}
/// Drain a `Minimap:PingLocation(x, y)` call — centre-relative offsets in **UI units**
/// (x right, y up). The app converts: UI units × the seam scale = window px, ÷ the live
/// `px_per_yd` = yards, and the click resolves against the geometry drawn in the same frame.
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
self.model_mut().minimap_ping_request.take()
}
/// The mask art the Minimap widget asks its renderer for — `Minimap:SetMaskTexture`'s value,
/// or `None` while no widget has set one (the app then keeps
/// [`crate::widget::MINIMAP_DEFAULT_MASK`]).
///
/// The **first** Minimap in the registry answers, not every one: the mask is a property of the
/// map the app draws, and the app draws one. (Everything else in `MinimapState` is per widget
/// because the Lua API reads it back per widget; this has no getter in 1.12 to read back.)
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,
}
})
}
/// Monotonic count of Minimap widgets ever created in this VM — the O(1) signal that a new
/// one exists and needs the containment state pushed.
pub fn minimap_widgets_created(&self) -> u64 {
self.model_ref().arena.minimap_created()
}
/// Seed every Minimap widget's two zoom indices from the persisted levels — the client's
/// minimap reset path copying each CVar object's parsed int into its live index
/// (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`; wow-re
/// `wmo-interior-minimap.md`, VERIFIED). Called **once**, when the in-game UI materializes and
/// the widget exists — not per frame: from then on the widget's index is the live truth and
/// `Minimap:SetZoom` keeps the CVar following it, so a repeated push would fight the +/- buttons.
/// Both indices clamp into `[0, MINIMAP_ZOOM_LEVELS)` exactly like `set_zoom`, so a hand-edited
/// `config.toml` cannot seed an out-of-range level (decision 1131).
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 Lua chunk (text-only; the sandbox rejects bytecode). Errors propagate to the
/// caller (a *load-time* error is the caller's to see; *handler* errors during events go to
/// [`UiScript::errors`]).
pub fn run(&self, chunk: &str) -> mlua::Result<()> {
self.run_chunk(chunk.as_bytes())
}
/// [`UiScript::run`] over a chunk that came off disk, which is **bytes** (decision 1193).
///
/// The reference slurps the file and hands the buffer to `luaL_loadbuffer` with no conversion
/// (wow-5875-re `system/ui/ui.md`), and Lua 5.0 strings are byte strings — so a cp1252 locale
/// file runs there and its literals carry the raw bytes. Reading such a file as `String` is
/// what made 76 of a real corpus's `.lua` files read as *absent* rather than as text with an
/// odd glyph. The two front-door transforms the reference's own compiler applies — the UTF-8
/// BOM strip and the `#`-line skip — are applied 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 CLIENT would give it — `Interface\AddOns\\`
/// for an addon file (build it with [`addon_chunk_name`]).
///
/// The name is not cosmetic. Without `set_name`, mlua defaults a chunk to the **Rust** caller
/// location, so every error an addon raised was reported against `crates/benilla-ui/src/...`
/// — wrong in any error an addon shows a player, and load-bearing for the addons that PARSE a
/// traceback. `FuBarPlugin-2.0.lua:752` derives a plugin's own folder with
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, which cannot match a Rust path.
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(crate::source::chunk(chunk))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
/// Load and evaluate a Lua chunk, returning its result. Primarily for tests / one-shot queries.
pub fn eval(&self, chunk: &str) -> mlua::Result {
self.lua.load(chunk).set_mode(mlua::ChunkMode::Text).eval()
}
/// The owning frame's name for an [`ExtractedQuad`] target — a debugging affordance for
/// capture/probe tooling, which sees quads but not widgets ("whose quad is this?").
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 to a named [`FrameKind::ScrollingMessageFrame`] (the app's chat feed → the seam,
/// the analogue of the loot/merchant snapshot pushes). `r`/`g`/`b` are `0..1`; the engine
/// byte-quantizes them round-half-up and drives the line's alpha via the fade. A missing frame,
/// or one of another kind, is a no-op (returns `false`) — the app decides whether that's a bug.
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,
}
}
/// Open the named EditBox for typing: show it and grab keyboard focus (so [`has_keyboard_focus`]
/// gates the world's keys). The app's chat-open key (ENTER) drives this; the box's own
/// `OnEnterPressed`/`OnEscapePressed` handlers hide + `ClearFocus` it on submit/cancel. A missing
/// frame, or one that isn't an EditBox, is a no-op (`false`).
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
}
// The EditBox text-UI seam (advance table, caret/selection geometry, clipboard) lives in
// `editbox/seam.rs` — an `impl UiScript` block beside its concern, the layout.rs pattern.
/// Drain the chat lines submitted through the input EditBox since the last call (the
/// `SubmitChatInput` Lua binding queued them). The app parses each into an outbound chat command.
pub fn take_chat_input(&mut self) -> Vec {
std::mem::take(&mut self.model_mut().chat_input)
}
/// Queue a line as if it had been typed into the chat EditBox and submitted — the headless
/// twin of `SubmitChatInput`, for scripted probes (`WOW_PROBE_CHAT`). Going through this seam
/// rather than straight to the wire is what lets a probe drive **client-side** slash commands
/// (`/duel`, `/reaction`); a plain line or a `.gm`-style server command still reaches the wire
/// exactly as before, because that is what the chat drain does with anything it doesn't own.
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 (`BenillaChatTabPressed`)
/// — the whisper-target cycle's trigger (decision 0288 P5).
pub fn take_chat_tab(&mut self) -> bool {
std::mem::take(&mut self.model_mut().chat_tab)
}
/// Replace the frame-keyed hyperlink spans (`(frame, rect, link, markup)`, rects in the
/// engine's y-up screen space) — the app feeds these each frame after rasterizing message
/// lines (it alone knows where the glyphs actually landed). A release inside a span fires
/// `OnHyperlinkClick(link, markup, button)` on the owning 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));
}
}
/// Invoke a compiled handler `func` under the RF-0025 frame-globals convention (`this`/`self` =
/// `wrapper`), the same set/restore path registry handlers use. For the [`crate::loader`], which
/// holds the `OnLoad` `Function` directly (to fire it bottom-up) rather than through the registry:
/// this keeps the convention in one home instead of duplicating it. Errors are returned so the
/// caller routes them (the loader records them in its own report).
/// Resolve every frame's rect: sync each frame's effective scale from the arena into its layout
/// input, run the [`crate::layout`] graph (screen root as the external base), and cache the
/// resolved rects. `GetWidth`/`GetHeight`/`extract` read this cache.
/// Frames whose size moved fire `OnSizeChanged` here, once the borrow is released
/// ([`event::fire_size_changes`]) — this is the drain the resolver's queue exists for, and the
/// one every per-frame host tick runs.
pub fn resolve(&mut self) {
{
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
// With a font engine installed ([`Self::set_text_measurer`]) the VM closes the measure
// round-trip itself, right here — solve, measure what the solve revealed, solve again.
// That is the same two-solve shape the host's own drive loop already runs, moved inside so
// a FontString's box is right in the frame its text was set rather than the frame after.
// Without an engine this is a no-op and the host's batch pass stays the only answer.
if self.fill_measures() {
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
event::fire_size_changes(&self.lua);
}
/// Store host measurements for [`MeasureRequest`]s (`(id, w, h, natural_w, key)` — id/key
/// verbatim from the request). The next [`UiScript::resolve`] uses `w`/`h` as the FontStrings'
/// implicit size.
///
/// `w`/`h` are the text **as laid out** (wrapped inside a declared width); `natural_w` is what
/// it would take **unwrapped**, and is what `GetStringWidth` reports — see [`MeasuredText`] for
/// why the two must not be conflated. For a region with no declared width they are the same
/// number, which is why [`Self::set_measured_text_unwrapped`] exists for tests.
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 its own measure forever); the
// EPOCH moves only if the laid-out extent did — see `MeasuredText::layout_moved`.
moved = MeasuredText::layout_moved(d.measured, new);
d.measured = Some(new);
}
if moved {
// Measured extents are the auto-size axes' inputs — the layout gate's read set.
// Touched PER REGION rather than once for the batch (decision 1388): the batch
// touch could only say "some extent moved", which is exactly the whole-roster
// question the ledger exists to stop asking.
model.touch_layout_region(rh);
}
}
}
/// [`Self::set_measured_text`] for text with **no wrap constraint**, where the laid-out and
/// natural widths are by definition the same number. Test-facing: a hand-fed measure for an
/// unconstrained string should not have to say `w` twice, and a test that DOES care about the
/// distinction (a declared-width region) should be forced to spell it out.
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);
}
/// Drop every cached host text metric — FontString measures, message-line row counts, editbox
/// advance tables. The host calls this when its **raster environment** changes (a window
/// resize / fullscreen toggle / uiScale move — anything that shifts the px-per-unit seam its
/// answers were taken under): glyph advances step to whole pixels at the drawn raster size,
/// so a metric measured under one environment does not rescale to another — the font-size
/// snap alone moves a string's unit width by several percent, which is exactly enough for a
/// boot-size measure to fail the post-resize ellipsis fit test and truncate text that fits
/// (the director's fullscreen "Contr..." rows). The environment is the host's to watch — the
/// engine deliberately never sees the seam scale — so staleness is the host's to declare; the
/// round-trips re-answer on the frames that follow (the same one-frame convergence every
/// measure already has).
/// Force the next [`UiScript::resolve`] to rebuild the **whole** layout graph rather than the
/// dirty closure a scoped resolve would (decision 1350) — and to run at all, rather than stop
/// at either change gate.
///
/// This is the scoped resolve's own falsifier, and it exists so the claim the scope rests on —
/// *a node whose own inputs and whose dependencies did not move recomputes to the rect it
/// already holds* — is something a test can **disprove** rather than something the engine
/// argues. Drive a change, resolve, read the rects; call this, resolve again, read them again;
/// they must be identical. `WOW_LAYOUT_VERIFY` makes the same comparison automatically on every
/// SETTLED frame; this is for the frames that never settle, which is exactly the hover sweep
/// the scope was built for.
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();
}
pub fn invalidate_text_measures(&mut self) {
let mut model = self.model_mut();
for d in model.region_data.values_mut() {
d.measured = None;
}
// The frame-side caches key on content hashes; a bumped stored key can never equal the
// recomputed one, so the next needing-measure sweep re-requests without this method
// having to know what the keys hash.
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);
}
_ => {}
}
}
// Measured extents are auto-size inputs — the layout gate's read set, same as
// [`Self::set_measured_text`]'s.
model.touch_layout();
// Every stored measure was just wiped; only the whole-roster sweep can re-request them.
model.touch_measure_all();
}
// ── Input: pointer-leaves-window cleanup (decision 0216 §3; the hit-test/mouse dispatch that
// used to sit here now lives in [`pointer`]) ─────────────────────────────────────────────────
/// The OS pointer left the window (decision 0216 §3's drag-gesture leak): clears
/// [`Model::drag`] AND [`Model::mouse_down_on`] — the two press-tracked states nothing else
/// clears when the pointer leaves the window mid-press, since the matching release is never
/// fed. Left uncleared, a stale armed [`Model::drag`] would fire a spurious `OnDragStart` the
/// instant the pointer re-enters and crosses the threshold against a press point that no
/// longer means anything; a stale [`Model::mouse_down_on`] entry would fire a same-frame
/// `OnClick` if the pointer re-enters and releases over the very frame it left from. The app
/// calls this from the same branch that fires the synthetic `OnLeave` (`ui_script/input.rs`).
pub fn pointer_left_window(&mut self) {
// **A started drag ENDS here, it is not merely forgotten.** The reference cannot reach
// this state at all — the OS holds the pointer for the whole of a button-held drag, so its
// release always arrives and `OnDragStop` always runs. Ours has no such capture: walk the
// cursor off the window edge mid-drag and no release is ever fed. Dropping the gesture
// silently (what this did) leaves the addon's `OnDragStop → StopMovingOrSizing` unrun, so
// the engine's single `Model::moving` slot stays taken and `object::movable::advance_move`
// glues that frame to the cursor for the rest of the session — where it also swallows
// every press aimed at whatever is underneath. That is B310: one raid row dragged off the
// window edge, and no row could be dragged again. Firing the stop is the faithful end,
// because it is the same end the reference's own release gives the handler.
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();
model.mouse_down_on.clear();
// …and its one-slot twin `root+0x80`, which the mouse-down raise reads: a capture left
// behind would aim the next press's raise at whatever the pointer was last holding.
model.mouse_capture = None;
// [`Model::last_click`] is deliberately NOT cleared here. It looks like it belongs in this
// list, and the binary says otherwise: `[CButton+0x334]` has exactly three writers
// image-wide (the ctor, the fired-double zero, the fired-single stamp) and none of them is
// a hide, a disable, or a mouse-leave — so a half-finished double click really does survive
// the cursor leaving the window and coming back inside the 300 ms (wow-re
// `ui/scratch/button-doubleclick-law.md`, "state hygiene").
// A thumb drag in progress when the pointer leaves is abandoned too (decision 0250 §5) —
// the release that would end it is never fed, same leak as the drag gesture above.
model.slider_drag = None;
}
// ── Keyboard entry (RF-0082 §1/§2: the EditBox focus + key/char routing) ─────────────────────
//
// benilla speaks *key names*, not scancodes — the host maps its window keycodes to these. The
// routing is the client's exactly: if a box is focused it processes and CONSUMES every event; if
// none is focused, the topmost effectively-visible `autoFocus` box self-acquires focus and
// processes this same event; otherwise nothing is consumed. Today the ways a box gets focus here
// are this self-acquire path, a click, and Lua `SetFocus` — the reference has a fourth, an
// `autoFocus` box focusing itself on SHOW, which we do not implement yet (the flag's own doc
// carries the correction and what it waits on).
/// A typed character (may be multi-byte UTF-8) arriving from the host. Routes per §1/§2 and, on a
/// focused box, inserts it (numeric/cap/password rules apply) or — for the Ctrl+A control code —
/// selects all. Returns `true` if consumed (a focused box consumes every char).
pub fn char_input(&mut self, text: &str) -> bool {
// The frame walk first ([`keyboard`]): the focused box is a PARTICIPANT in it, at its own
// strata/level, so this is not "frames before boxes" — it is the reference's one dispatcher
// in the reference's order. An event no frame consumed still falls through to the box
// routing, which owns focus acquisition (`autoFocus` self-acquire and kin).
keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text)
}
/// Paste text from the host OS clipboard into the focused EditBox. The engine-free runtime can't
/// reach the clipboard itself, so the app reads it (Cmd+V on macOS / Ctrl+V elsewhere) and hands
/// the string here; newlines survive only in a `multiLine` box, other control chars are dropped,
/// and the remainder inserts as one edit. Returns `true` if a box consumed it.
pub fn paste(&mut self, text: &str) -> bool {
editbox::paste(&self.lua, text)
}
/// A non-character key press arriving from the host, by name — the three *box-event* keys
/// (`"ENTER"`, `"ESCAPE"`, `"TAB"`) fire their FrameXML scripts; editing keys arrive as
/// semantic [`EditAction`]s via [`Self::editbox_action`] instead (the host's per-OS keymap
/// owns which chord means what). Routes per §1/§2; a focused box consumes the key even when
/// it does nothing with it. Returns `true` if consumed.
pub fn key_input(&mut self, key: &str) -> bool {
// Same two-stage shape as `char_input` — see its note.
keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key)
}
/// A key the host delivers to a focused EditBox as an [`EditAction`] chord rather than by name
/// (BACKSPACE, DELETE, the arrows, HOME, END), offered to the **keyboard frames** first.
///
/// Returns `true` if a frame consumed it, in which case the caller must NOT also dispatch the
/// chord — and the key's binding must not fire either (consumption suppresses it, wow-re §3).
/// A `false` means either nothing wanted it or the focused box owns it; the caller proceeds
/// exactly as it did before this entry point existed. See [`keyboard::frame_key_input`] for
/// why declining at the box is the faithful answer rather than skipping it.
pub fn frame_key_input(&mut self, key: &str) -> bool {
keyboard::frame_key_input(&self.lua, key)
}
/// One semantic text-editing operation on the focused EditBox — the output of the host's
/// per-OS keymap (decision 0301). Same routing/consumption law as [`Self::key_input`].
/// Returns `true` if consumed.
pub fn editbox_action(&mut self, action: EditAction) -> bool {
editbox::action(&self.lua, action)
}
/// Is the focused EditBox in **alt-arrow mode** — the flag the XML spells `ignoreArrows` and
/// the Lua surface spells `SetAltArrowKeyMode` (`[editbox+0x318] & 0x10`)?
///
/// The host asks this to decide whether an arrow key reaches the box at all. With the flag set
/// and ALT not held, the reference's key handler returns 0 at `0x77b1c4` for the four arrow
/// codes — **not consumed** — and the strata walk carries down to `CGWorldFrame`, which runs
/// the key's binding. That is what lets you turn while the chat box has focus, and it is why
/// the gate is on the key rather than on the [`EditAction`]: `Move { unit: Edge }` reaches the
/// box from HOME/END as well as from an arrow, and only the arrow is gated.
///
/// `false` when nothing is focused, which is the same answer as an unflagged box: neither
/// declines the key.
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 an EditBox currently holds keyboard focus (and is effectively visible) — the app gates
/// world/player key input on this, matching the client's `DAT_00cf4dc8 != 0` test (RF-0082 §1).
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))
}
/// How many resolves the layout change gate has let through (`Model::layout_solves`) — the
/// gate's effectiveness, readable by tests and the app's cost meters (a per-frame delta of 0
/// means the fingerprint judged the frame quiet).
pub fn layout_solves(&self) -> u64 {
self.model_ref().layout_solves
}
/// How many resolves got past **tier 1** and paid the whole-roster preamble
/// ([`Model::layout_gate_walks`], decision 1385) — the gate's true cost counter, ≥
/// [`Self::layout_solves`] because a walk that concludes "nothing moved" pays the same
/// preamble and never reaches the solve counter.
pub fn layout_gate_walks(&self) -> u64 {
self.model_ref().layout_gate_walks
}
/// How many times a resolve DERIVED the layout graph from scratch ([`Model::layout_derives`])
/// — the whole-roster walk, and since decision 1388 the only expensive thing a resolve can do.
/// A UI that merely animates should hold this flat.
pub fn layout_derivations(&self) -> u64 {
self.model_ref().layout_derives
}
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
/// depth.
pub fn layout_rounds(&self) -> u64 {
self.model_ref().layout_rounds
}
/// The last solve's SCOPE — `(frames solved, regions swept)`, decision 1350's meter.
///
/// The third axis of a solve's cost, and the one that used to be "all of it": solves says how
/// OFTEN, rounds says how DEEP, this says how WIDE. A change that touches ten FontStrings must
/// read a handful here however large the UI grows; a scope that tracks the graph is the
/// regression, and it is asserted as a COUNT because milliseconds have twice failed to catch
/// this class (0735, 0771).
pub fn layout_last_scope(&self) -> (usize, usize) {
self.model_ref().layout_last_scope
}
/// Is `name` a registered FrameXML template — one `CreateFrame`'s fourth argument or an
/// `inherits=` can resolve (decision 1203)?
///
/// A pure query on the VM's live registry, for the corpus harness: an addon naming a template
/// we have not transcribed gets a bare frame and **no load error**, so nothing else can see it.
/// **Folded**, because the resolution it reports on is folded. An exact `contains_key` made this
/// census disagree with the loader the moment `inherits=` became case-insensitive: it went on
/// listing `UIDropdownMenuTemplate`, `CT_RaCheckButtonTemplate` and `MSBTColorSwatchTemplate` as
/// missing while the loader was resolving all three. An instrument that reports a gap the code
/// does not have is worse than no instrument — it is a build queue pointing at finished work
/// (1242/1246, and 1251 §3's rule that a source-derived answer be checked against the runtime
/// artefact).
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))
}
/// Is `name` a registered FONT object — the *other* thing an `inherits=` may legally name?
///
/// [`Self::has_framexml_template`]'s twin, and only useful beside it: `inherits=` is one
/// attribute over two namespaces (`` names a font,
/// `