Files
benilla-octo/crates/benilla-ui/src/script/mod.rs
T
SamandClaude Fable 5 14bd5ea1a4 the bank is the reference's own window, and far sight freezes your body
- the bank window is the reference's own `BankFrame.xml` run off the player's chain — our 890-line copy is deleted whole; putting bags into the bank's bag slots works (the two verbs its buttons are wired to now exist), the purse is a real `SmallMoneyFrameTemplate` splitting 987654321 copper into three coin buttons instead of one string, `BANKFRAME_OPENED` loses the argument the real client never fires, and the bank stops auto-opening your bags while the vendor and mailbox keep doing it; the empty-slot click still plays the bag sound, because the reference's own line plays it on every click the cursor did not consume — named as faithful rather than quietly fixed
- underneath it, the bank's inventory band is a VIEW over the container feed, not a second copy — inventory ids 40..69 had answered nil because the doll array stops at 24, so the stock file swapped in would have drawn an empty bank; the six bank bag slots become real inventory slots (64..=69) fed off the player descriptor, the three bag verbs are carved from the binary (`PutItemInBackpack` IS `PutItemInBag(0xFF)` through a ten-byte thunk, the occupied-by-an-ordinary-item leg auto-stores with no destination slot at all, and the return is the Lua number 1 or nil — the nil being the whole reason a plain left-click opens a bag), and the swap caught `BankButtonIDToInvSlotID`'s bag arm six slots wrong: it took a bag number 1..6 where the reference passes the button's own id, which is the container id
- far sight freezes your own body while the view is out — the fifth conjunct of the movement predicates' shared precondition, joining the health term so the walk, the turn and the follow teardown go down together; both halves are load-bearing: the `IsActivePlayer` half is what keeps a Mind Control victim walking (possession rides the same PLAYER_FARSIGHT field), and the latch half is the RESOLVED engage rather than the raw field — and the camera is untouched, you still look around freely, which is the whole point of the spell
- the reset offer no longer outlives the character that earned it — log out after walking out of the Deadmines and your next character was offered a reset the first one had earned — and the one lockout leg no probe can ever reach (vmangos writes a last-instance map only from raid binds, which the client's handler discards as not-a-party-dungeon) is now covered end to end through the real system, its module doc saying plainly: tested and never observed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:18:51 +07:00

1550 lines
78 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 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 `<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());
// 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<crate::widget::FrameHandle> = model
.arena
.minimap_kinds()
.iter()
.filter_map(|&h| match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.player_arrow,
_ => None,
})
.collect();
for h in arrows {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Model(state) = &mut frame.kind_state {
state.facing = facing;
}
}
}
}
/// Publish 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<String> {
let model = self.model_ref();
model.arena.minimap_kinds().iter().find_map(|&h| {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.mask_texture.clone(),
_ => None,
}
})
}
/// 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\<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();
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 (`<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::PlayerModel => "PlayerModel",
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 and the retained log, but **not** `errors`: the caller has already
/// logged it (the load walk's per-file `error!` + `failures` contract), so putting it there too
/// would double-log at the app's per-frame drain.
///
/// Its silent sibling is [`Self::report_load_failure`] (decision 1495) — same retention, no
/// dispatch, for the load failures that never raise at all.
pub fn report_script_error(&self, msg: &str) {
let mut model = self.model_mut();
// Retained as a **Load** row, not an Error one (decision 1495): every caller of this is
// the load walk, and from the player's side "the addon's file scope raised" and "the
// addon's file was missing" are the same fact — the addon is not running.
model
.diagnostics
.record(diagnostics::DiagnosticKind::Load, msg);
model.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;