Files
benilla-octo/crates/benilla-ui/src/script/mod.rs
T
SamandClaude Opus 5 8d3d805ec8 the guild window opens: roster, ranks, notes and the message of the day
- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:38:54 +07:00

1271 lines
63 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Lua scripting host — the engine-free VM that 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 addon_message;
mod aura;
mod backdrop;
mod bank;
mod binder;
mod binding_abi;
mod button;
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;
mod editbox;
pub(crate) use editbox::adopt_text_region;
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 gossip;
mod guild;
mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod loot;
mod loot_roll;
mod lua50;
mod macros;
mod mail;
mod measure;
mod merchant;
mod messageframe;
mod minimap;
mod model;
mod net_stats;
mod object;
mod party;
mod pet;
mod pointer;
mod pvp;
mod quest;
mod quest_log;
mod region;
mod reputation;
mod saved;
mod scrollframe;
mod session;
mod shapeshift;
mod skills;
mod slash;
mod slider;
mod social;
mod sound;
mod spellbook;
mod statusbar;
mod stdlib;
mod talent;
mod taxi;
mod tick;
mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
mod trainer;
mod types;
mod unit;
mod weapon_enchant;
mod worldmap;
pub use action::{ActionSlot, ActionState};
pub use addon::AddOnInfo;
pub use addon_message::{AddonDistribution, AddonSend};
pub use aura::{AuraState, TrackingState};
pub use backdrop::{pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use char_stats::{
weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_send::ChatSend;
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
pub use follow::FollowRequest;
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 inspect::InspectView;
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
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, MailSendRequest, MailState};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
pub use model::TextureProbe;
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo};
pub use pet::{PetActionView, PetStats};
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 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 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::{MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
pub use weapon_enchant::WeaponEnchant;
pub use worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapState, WorldMapZoneView};
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 in the client's pair and absent here; absent is absent.
pub(crate) const REGION_LEAF_SHARED: [&str; 8] = [
"SetDrawLayer",
"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 `<Frame>` 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 `<Frame>`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 `<Frame>`
/// `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
// `<ColorSelect>` 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<std::sync::atomic::AtomicU64>,
/// **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\<Folder>\<File>`.
///
/// 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<UiScript> {
let lua = Lua::new();
lua.set_app_data(Model::new());
addon::install(&lua)?;
addon_message::install(&lua)?;
chat_send::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::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)?;
binder::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
pointer::install(&lua)?;
action::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)?;
item_text::install(&lua)?;
mail::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)?;
slider::install(&lua)?;
colorselect::install(&lua)?;
minimap::install(&lua)?;
cooldown::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
net_stats::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`).
pub fn set_screen_size(&mut self, width: f32, height: f32) {
let new = Rect::new(0.0, 0.0, height, width);
let mut model = self.model_mut();
if model.screen != new {
model.screen = new;
model.touch_layout();
}
}
/// 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.
pub fn set_minimap_inside(&mut self, inside: bool) {
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
m.inside = inside;
}
}
}
/// 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));
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
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\<Folder>\<File>`
/// 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<T: mlua::FromLuaMulti>(&self, chunk: &str) -> mlua::Result<T> {
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<String> {
let model = self.model_ref();
let fh = match target {
ZTarget::Frame(fh) => fh,
ZTarget::Region(rh) => model.arena.region(rh)?.owner,
};
model.arena.frame(fh)?.name.clone()
}
/// Append a line 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<String> {
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();
let mut changed = false;
for &(id, w, h, natural_w, key) in measures {
let Some(&rh) = model.id_to_region.get(&id) else {
continue;
};
if let Some(d) = model.region_data.get_mut(&rh) {
let new = MeasuredText {
w,
h,
natural_w,
key,
};
changed |= d.measured != Some(new);
d.measured = Some(new);
}
}
if changed {
// Measured extents are the auto-size axes' inputs — the layout gate's read set.
model.touch_layout();
}
}
/// [`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).
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();
}
// ── 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) {
let mut model = self.model_mut();
model.drag = None;
model.mouse_down_on.clear();
// [`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. `autoFocus` never focuses on show —
// only this self-acquire path, a click, or Lua `SetFocus` focuses a box.
/// 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)
}
/// 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
}
/// 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
}
/// 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 (`<FontString inherits="GameFontNormal">` names a font,
/// `<Button inherits="UIPanelButtonTemplate">` names a template — `loader::expand_region` is
/// where that fork lives). A census that asks only the template registry reports every font in
/// the corpus 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** — `"MessageFrame"`, `"Button"`, `"Texture"`, … — or
/// `None` if nothing by that name is a live widget.
///
/// [`Self::has_framexml_template`]'s sibling, and the same kind of thing: a pure Rust-side query
/// on the live model, for the corpus harness. The harness needs to know what `UIErrorsFrame` in
/// `UIErrorsFrame:AddMessage(…)` actually *is* in our object graph, because a widget-method
/// census that asks "does ANY kind answer this name" cannot see a verb wired to one class and
/// forgotten on its sibling (decision 1228). Attributing the call site to a kind is what makes
/// that question askable, and this is the only honest answer to "what kind is that global": the
/// arena's own record, not a name list.
///
/// **Deliberately NOT a Lua binding.** The reference publishes this as `GetObjectType`, which we
/// do not implement outside font objects; adding it here would put a new verb in front of every
/// addon and move the very numbers the census is measured by. An instrument must be able to
/// claim it perturbed nothing, so it stays on the Rust side where no addon can observe it.
///
/// The spellings are `CreateFrame`'s own (`SimpleHTML`, not `SimpleHtml`), so a caller can
/// compare a name here against a kind it passed to `CreateFrame`.
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",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
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::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",
crate::widget::FrameKind::Cooldown => "Cooldown",
});
}
// The region leaves publish into their own name table (`region_names`), not the arena's —
// and they matter here: `GameTooltipTextLeft1:GetText()` is a FontString call, and the
// corpus scrapes those constantly.
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 and says so (Q6) — and it is unreachable by name
// anyway: `CreateTitleRegion` takes no name argument at all.
crate::widget::RegionKind::Title => "Region",
})
}
/// A snapshot of the script errors collected so far (from `pcall`'d handlers).
pub fn errors(&self) -> Vec<String> {
self.model_ref().errors.clone()
}
/// Drain the collected script errors.
pub fn take_errors(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().errors)
}
/// A snapshot of non-fatal host warnings (e.g. ignored `CreateFrame` templates).
pub fn warnings(&self) -> Vec<String> {
self.model_ref().warnings.clone()
}
/// Drain the accumulated non-fatal host warnings (an ignored `CreateFrame` template, a layout
/// fixpoint that hit its round cap) — the host logs them; un-drained they pile up unseen.
pub fn take_warnings(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().warnings)
}
/// Report a script error the host caught **outside** the VM's own dispatch — an addon file
/// that failed to compile or raised at file scope during the load walk. It joins the
/// handler-dispatch queue only: the caller has already logged it (the load walk's per-file
/// `error!` + `failures` contract), so putting it in `errors` too would double-log at the
/// app's per-frame drain.
pub fn report_script_error(&self, msg: &str) {
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
}
/// Hand every queued script error to the Lua-side error handler — the reference's own shape:
/// `seterrorhandler`/`geterrorhandler` are engine globals (wow-re `scratch/lua-dialect.md`,
/// the captured `_G`), the engine invokes the registered handler on a caught script error
/// (that is the pair's contract — a handler nothing invokes would be two dead globals), and
/// FrameXML answers with `_ERRORMESSAGE` → the red ScriptErrors dialog (decision 1305).
///
/// Called at a safe seam (the app's per-frame drain), never from inside the failed call.
/// Three guards keep it bounded and honest:
/// - **The stdlib default handler is skipped by identity.** It reports into
/// [`UiScript::errors`] — where every queued message already is — so dispatching it would
/// only duplicate. The queue still drains, so a handler installed later starts clean.
/// - **A handler that raises is recorded on the host channel only** and never re-queued:
/// the error path cannot recurse by construction.
/// - **One failure stops the batch** — a broken handler fails the same way for every message,
/// and one line names it.
pub fn dispatch_script_errors_to_handler(&mut self) {
let pending = std::mem::take(&mut self.model_mut().pending_error_dispatch);
if pending.is_empty() {
return;
}
let handler: Option<mlua::Function> = self
.lua
.globals()
.get::<mlua::Function>("geterrorhandler")
.ok()
.and_then(|g| g.call::<mlua::Function>(()).ok());
let Some(handler) = handler else { return };
if let Ok(default) = self
.lua
.named_registry_value::<mlua::Function>(REG_DEFAULT_ERRORHANDLER)
{
if handler == default {
return;
}
}
for msg in pending {
if let Err(e) = handler.call::<()>(msg) {
self.model_mut()
.errors
.push(format!("error handler itself failed: {e}"));
break;
}
}
}
/// Register a named virtual [`FontObject`] (a resolved `<Font>`), overwriting any prior one of
/// the same name, **and publish it as the Lua global `name`** — the same pair
/// `Loader::do_font` performs, so a font registered from Rust is addressable from Lua exactly
/// like one declared in XML (`fs:SetFontObject(Name)`, `Name:GetFont()`). One registration act,
/// one outcome: the two paths cannot drift.
pub fn register_font_object(&self, name: &str, font: FontObject) {
self.model_mut()
.font_objects_by_lower
.insert(name.to_ascii_lowercase(), font);
// Publishing cannot fail for a fresh table + a string key; a registry hiccup is not worth
// an unwrap in a host-facing setter, and the record is already in place either way.
let _ = font::publish(&self.lua, name);
}
/// Look up a registered [`FontObject`] by name (the resolved paint), if any. Used by tests and by
/// the `SetFontObject` binding.
pub fn font_object(&self, name: &str) -> Option<FontObject> {
self.model_ref().font_object(name).cloned()
}
/// Every registered [`FontObject`] (the resolved paints of all loaded `<Font>` nodes) — the
/// host's bake census: the glyph atlas reads the distinct `(font, height, outline)` triples
/// off this to know which outlined cell variants the shipped UI can actually request
/// (the outlined-glyph bake, the fade-composite fold-back record).
pub fn font_objects(&self) -> Vec<FontObject> {
self.model_ref()
.font_objects_by_lower
.values()
.cloned()
.collect()
}
// ── internals ────────────────────────────────────────────────────────────────────────────
fn model_ref(&self) -> mlua::AppDataRef<'_, Model> {
self.lua
.app_data_ref::<Model>()
.expect("model app_data set")
}
fn model_mut(&self) -> mlua::AppDataRefMut<'_, Model> {
self.lua
.app_data_mut::<Model>()
.expect("model app_data set")
}
fn push_error(&self, e: mlua::Error) {
self.model_mut().record_script_error(e.to_string());
}
}
#[cfg(test)]
mod tests;