- the status-bar numerals kit (`TextStatusBar.xml` + `.lua`) is the reference's own, and the attempt found a class of silent failure: `TextStatusBar_Initialize()` takes no argument in 1.12 and acts on `this`; ours took an optional bar, our unit frames passed one, and the reference's version ignores it — so the numerals would simply never appear, no error, nothing missing; seventeen of our signatures differ from the reference's that way (Lua drops a surplus argument in silence), written down before their files migrate, and the three "ours narrower" ones — `StaticPopup_Hide(which, data)`, `StaticPopup_FindVisible`, `WorldMapButton_OnClick(button, frame)` — closed now, because that direction bites when a STOCK file arrives and calls our version
- a chat link needs no mouse on its frame: wow-re read the client's ctors as mouse-enabling Button, CheckButton, Slider, ColorSelect and EditBox and NOT the scroll frames; the objection ("something makes a 1.12 chat link clickable") resolved as the engine synthesising one mouse-enabled `CSimpleHyperlinkButton` per link span (`0x7a3240`) — so a link span is a hit-test candidate on its own, the chat window is inert everywhere else, and the wheel is its own predicate rather than a borrowed mouse bit; seven `*ScrollFrame` known divergences retire
- the relic fit rule: `IsValidForSlot`'s slot-17 leg is an EQUALITY — `(InventoryType == RELIC) == hasRelicSlot` — so a relic lights the ranged slot for a druid and a bow does not, on the bag bar and the paper doll alike; closing it half-right would have lit the highlight for a druid dragging a bow
- a chain `.xml` whose `.lua` never loaded: `MoneyInputFrame.xml` had been on the chain without its `.lua`, so the template loaded and all ten `MoneyInputFrame_*` verbs read nil, silently — a gate now fails any chain `.xml` whose `.lua` sibling is neither sourced nor listed; the gap report's LOAD column carries the probe's collision caveat, its fx column stops crying wolf (it follows `<Script file=>`, stops reading `.Field(` as a global, subtracts a file's own locals), and a window is its xml PLUS the lua it sources
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1554 lines
78 KiB
Rust
1554 lines
78 KiB
Rust
//! 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;
|
||
pub use object::frame_kind_from_tag;
|
||
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, CursorMerchantItem, 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",
|
||
// `GetObjectType 0x495b60` is two instructions and returns `"LootButton"`
|
||
// (`[0x847ce0]` → `0x843414`). It does NOT answer "Button".
|
||
crate::widget::FrameKind::LootButton => "LootButton",
|
||
crate::widget::FrameKind::EditBox => "EditBox",
|
||
crate::widget::FrameKind::StatusBar => "StatusBar",
|
||
crate::widget::FrameKind::Slider => "Slider",
|
||
crate::widget::FrameKind::ScrollFrame => "ScrollFrame",
|
||
crate::widget::FrameKind::Model => "Model",
|
||
crate::widget::FrameKind::PlayerModel => "PlayerModel",
|
||
crate::widget::FrameKind::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;
|