- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles - the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar - hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight - the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
713 lines
33 KiB
Rust
713 lines
33 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 aura;
|
||
mod backdrop;
|
||
mod bank;
|
||
mod button;
|
||
mod char_stats;
|
||
mod clip;
|
||
mod container;
|
||
mod cooldown;
|
||
mod craft;
|
||
mod cursor;
|
||
mod death;
|
||
mod editbox;
|
||
pub(crate) use editbox::adopt_text_region;
|
||
mod cvars;
|
||
mod duel;
|
||
mod event;
|
||
mod extract;
|
||
mod follow;
|
||
mod gossip;
|
||
mod inspect;
|
||
mod item_stats;
|
||
mod item_text;
|
||
mod layout;
|
||
mod loot;
|
||
mod loot_roll;
|
||
mod macros;
|
||
mod mail;
|
||
mod merchant;
|
||
mod messageframe;
|
||
mod minimap;
|
||
mod model;
|
||
mod net_stats;
|
||
mod object;
|
||
mod party;
|
||
mod pet;
|
||
mod pointer;
|
||
mod pvp;
|
||
mod quest;
|
||
mod quest_log;
|
||
mod region;
|
||
mod scrollframe;
|
||
mod session;
|
||
mod shapeshift;
|
||
mod skills;
|
||
mod slider;
|
||
mod social;
|
||
mod sound;
|
||
mod spellbook;
|
||
mod statusbar;
|
||
mod stdlib;
|
||
mod talent;
|
||
mod taxi;
|
||
mod tick;
|
||
mod tooltip;
|
||
mod tooltip_item;
|
||
mod tooltip_spell;
|
||
mod tooltip_unit;
|
||
pub use tooltip_unit::TooltipTint;
|
||
mod trade;
|
||
mod tradeskill;
|
||
mod trainer;
|
||
mod types;
|
||
mod unit;
|
||
mod worldmap;
|
||
|
||
pub use action::{ActionSlot, ActionState};
|
||
pub use aura::{AuraState, TrackingState};
|
||
pub use backdrop::{pieces, Backdrop, BackdropPiece, Insets};
|
||
pub use bank::BankState;
|
||
pub use char_stats::{
|
||
weapon_subclass_skill, InvSlotView, InventorySlots, PlayerCombatStats, INVENTORY_SLOT_COUNT,
|
||
SKILL_UNARMED,
|
||
};
|
||
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
|
||
pub use craft::{CraftReagent, CraftRecipe, CraftState};
|
||
pub use cursor::{
|
||
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorSpell, EnchantConfirm, WorldPick,
|
||
EQUIPMENT_BAG,
|
||
};
|
||
pub use death::{DeathAction, DeathUiState};
|
||
pub use duel::DuelRequest;
|
||
pub use follow::FollowRequest;
|
||
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
|
||
pub use inspect::InspectView;
|
||
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
|
||
pub use item_text::ItemTextState;
|
||
pub use loot::{LootRow, LootState};
|
||
pub use loot_roll::{LootRollEntry, LootRollsState};
|
||
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
|
||
pub use mail::{MailInboxRow, MailSendRequest, MailState};
|
||
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
|
||
pub(crate) use model::Model;
|
||
pub use party::{PartyMemberInfo, PartyRequest, PartyState};
|
||
pub use pet::PetActionView;
|
||
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
|
||
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
|
||
pub use session::SessionRequest;
|
||
pub use shapeshift::ShapeshiftFormView;
|
||
pub use skills::{SkillEntry, SkillsState};
|
||
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
|
||
pub use sound::SoundRequest;
|
||
pub use spellbook::{resolve_spell_by_name, SpellBookState, SpellSlotView, SpellTabView};
|
||
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
|
||
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
|
||
pub use tooltip_spell::SpellTooltipView;
|
||
pub use trade::{TradeSideState, TradeSlotItem, TradeState, TRADE_SLOTS};
|
||
pub use tradeskill::{TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState};
|
||
pub use trainer::{
|
||
TrainerAbilityReq, TrainerGroup, TrainerService, TrainerServiceCategory, TrainerSkillReq,
|
||
TrainerState,
|
||
};
|
||
pub use types::{
|
||
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
|
||
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
|
||
TexCoords,
|
||
};
|
||
pub(crate) use types::{MeasuredText, RegionData};
|
||
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
|
||
pub use worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapState, WorldMapZoneView};
|
||
|
||
use mlua::{Function, Lua, Table};
|
||
|
||
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";
|
||
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.
|
||
const SCRIPT_KINDS: [&str; 29] = [
|
||
"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",
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// 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,
|
||
/// The FrameXML **template registry**, persisted across [`crate::loader::load`] calls — the
|
||
/// client's template table is global (`0x6ee500`, rf24), so a file may `inherits=` a template
|
||
/// an *earlier file* registered (the real MerchantFrame.xml inherits
|
||
/// CharacterFrameTemplates.xml's tab template). Register-before-use in load order, exactly
|
||
/// the client's rule; a per-document registry silently dropped every cross-file inherit.
|
||
pub(crate) framexml_templates:
|
||
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
|
||
/// The FrameXML **font-element registry** (a separate namespace — a font inherits a font,
|
||
/// never a frame template), persisted for the same cross-file reason.
|
||
pub(crate) framexml_fonts:
|
||
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
|
||
}
|
||
|
||
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());
|
||
|
||
stdlib::sandbox(&lua)?;
|
||
stdlib::install(&lua)?;
|
||
object::install(&lua)?;
|
||
unit::install(&lua)?;
|
||
party::install(&lua)?;
|
||
social::install(&lua)?;
|
||
duel::install(&lua)?;
|
||
follow::install(&lua)?;
|
||
session::install(&lua)?;
|
||
pvp::install(&lua)?;
|
||
death::install(&lua)?;
|
||
aura::install(&lua)?;
|
||
cvars::install(&lua)?;
|
||
sound::install(&lua)?;
|
||
pointer::install(&lua)?;
|
||
action::install(&lua)?;
|
||
container::install(&lua)?;
|
||
cursor::install(&lua)?;
|
||
spellbook::install(&lua)?;
|
||
macros::install(&lua)?;
|
||
talent::install(&lua)?;
|
||
shapeshift::install(&lua)?;
|
||
pet::install(&lua)?;
|
||
gossip::install(&lua)?;
|
||
merchant::install(&lua)?;
|
||
bank::install(&lua)?;
|
||
item_text::install(&lua)?;
|
||
mail::install(&lua)?;
|
||
trainer::install(&lua)?;
|
||
taxi::install(&lua)?;
|
||
trade::install(&lua)?;
|
||
inspect::install(&lua)?;
|
||
tradeskill::install(&lua)?;
|
||
craft::install(&lua)?;
|
||
skills::install(&lua)?;
|
||
item_stats::install(&lua)?;
|
||
char_stats::install(&lua)?;
|
||
loot::install(&lua)?;
|
||
loot_roll::install(&lua)?;
|
||
quest::install(&lua)?;
|
||
quest_log::install(&lua)?;
|
||
messageframe::install(&lua)?;
|
||
scrollframe::install(&lua)?;
|
||
slider::install(&lua)?;
|
||
minimap::install(&lua)?;
|
||
cooldown::install(&lua)?;
|
||
tooltip::install(&lua)?;
|
||
worldmap::install(&lua)?;
|
||
net_stats::install(&lua)?;
|
||
|
||
Ok(UiScript {
|
||
lua,
|
||
framexml_templates: Default::default(),
|
||
framexml_fonts: Default::default(),
|
||
})
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Set the screen-root rect from a pixel size (`[0,0]` origin, y-up). Top-level frames anchor to
|
||
/// it; changing it invalidates the next `resolve`. The app calls this every frame with an
|
||
/// almost-always-identical size — compared before writing so the per-frame idiom doesn't
|
||
/// dirty the layout gate's tier 1 (`Model::touch_layout`).
|
||
pub fn set_screen_size(&mut self, width: f32, height: f32) {
|
||
let new = Rect::new(0.0, 0.0, height, width);
|
||
let mut model = self.model_mut();
|
||
if model.screen != new {
|
||
model.screen = new;
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
|
||
/// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads.
|
||
/// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/
|
||
/// `IsAltKeyDown`. The app's input pass calls this BEFORE feeding the frame's mouse events, so
|
||
/// a click handler's modifier fork (the reference's shift-split / ctrl-dressup /
|
||
/// shift-pickup) reads the state as of the click.
|
||
pub fn set_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool) {
|
||
let shift_was = {
|
||
let mut model = self.model_mut();
|
||
let was = model.modifiers.0;
|
||
model.modifiers = (shift, ctrl, alt);
|
||
was
|
||
};
|
||
// The shift EDGE drives the shopping-compare tooltips (0274 P4): press over a live
|
||
// equippable item hover fires SHOW_COMPARE_TOOLTIP, release hides the pair.
|
||
if shift_was != shift {
|
||
tooltip_item::on_shift_edge(&self.lua, shift);
|
||
}
|
||
}
|
||
|
||
/// Push the player's WMO-containment state onto every Minimap widget (the client's `0xceaa60`).
|
||
/// It selects which of the two persisted zoom indices `GetZoom`/`SetZoom` act on, so the zoom
|
||
/// buttons drive the indoor level while indoors and the outdoor level while outside. The app
|
||
/// owns the containment test, so it owns this push — call it before the script tick.
|
||
pub fn set_minimap_inside(&mut self, inside: bool) {
|
||
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
|
||
if let KindState::Minimap(m) = &mut frame.kind_state {
|
||
m.inside = inside;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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.lua.load(chunk).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).
|
||
pub(crate) fn invoke_handler(&self, wrapper: &Table, func: &Function) -> mlua::Result<()> {
|
||
event::invoke_with_globals(&self.lua, wrapper.clone(), func, None, Vec::new())
|
||
}
|
||
|
||
/// 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.
|
||
pub fn resolve(&mut self) {
|
||
let mut model = self.model_mut();
|
||
Self::resolve_layout(&mut model);
|
||
}
|
||
|
||
/// Store host measurements for [`MeasureRequest`]s (`(id, w, h, key)` — id/key verbatim from
|
||
/// the request). The next [`UiScript::resolve`] uses them as the FontStrings' implicit size.
|
||
pub fn set_measured_text(&mut self, measures: &[(u32, f32, f32, u64)]) {
|
||
let mut model = self.model_mut();
|
||
let mut changed = false;
|
||
for &(id, w, h, key) in measures {
|
||
let Some(&rh) = model.id_to_region.get(&id) else {
|
||
continue;
|
||
};
|
||
if let Some(d) = model.region_data.get_mut(&rh) {
|
||
let new = MeasuredText { w, h, key };
|
||
changed |= d.measured != Some(new);
|
||
d.measured = Some(new);
|
||
}
|
||
}
|
||
if changed {
|
||
// Measured extents are the auto-size axes' inputs — the layout gate's read set.
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
|
||
/// Drop every cached host text metric — FontString measures, message-line row counts, editbox
|
||
/// advance tables. The host calls this when its **raster environment** changes (a window
|
||
/// resize / fullscreen toggle / uiScale move — anything that shifts the px-per-unit seam its
|
||
/// answers were taken under): glyph advances step to whole pixels at the drawn raster size,
|
||
/// so a metric measured under one environment does not rescale to another — the font-size
|
||
/// snap alone moves a string's unit width by several percent, which is exactly enough for a
|
||
/// boot-size measure to fail the post-resize ellipsis fit test and truncate text that fits
|
||
/// (the director's fullscreen "Contr..." rows). The environment is the host's to watch — the
|
||
/// engine deliberately never sees the seam scale — so staleness is the host's to declare; the
|
||
/// round-trips re-answer on the frames that follow (the same one-frame convergence every
|
||
/// measure already has).
|
||
pub fn invalidate_text_measures(&mut self) {
|
||
let mut model = self.model_mut();
|
||
for d in model.region_data.values_mut() {
|
||
d.measured = None;
|
||
}
|
||
// The frame-side caches key on content hashes; a bumped stored key can never equal the
|
||
// recomputed one, so the next needing-measure sweep re-requests without this method
|
||
// having to know what the keys hash.
|
||
for (_, frame) in model.arena.iter_frames_mut() {
|
||
match &mut frame.kind_state {
|
||
KindState::ScrollingMessage(smf) => {
|
||
for line in &mut smf.lines {
|
||
line.rows_key = line.rows_key.wrapping_add(1);
|
||
}
|
||
}
|
||
KindState::EditBox(eb) => {
|
||
eb.advances_key = eb.advances_key.wrapping_add(1);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// Measured extents are auto-size inputs — the layout gate's read set, same as
|
||
// [`Self::set_measured_text`]'s.
|
||
model.touch_layout();
|
||
}
|
||
|
||
/// Feed the number font's per-digit advance widths (logical px, `'0'..='9'` in order) as the
|
||
/// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`). The
|
||
/// synchronous half of the money layout's metrics (the real `SmallMoneyFrame` calls
|
||
/// `GetTextWidth` mid-update — MoneyFrame.lua l.202 — while our `GetStringWidth` measure
|
||
/// round-trip is a frame late): the app measures NumberFontNormal's digits once per atlas
|
||
/// scale and `BenillaNumberWidth` (MerchantFrame.xml) sums them per price.
|
||
pub fn set_digit_advances(&mut self, advances: &[f32; 10]) {
|
||
let set = || -> mlua::Result<()> {
|
||
let t = self.lua.create_table()?;
|
||
for (i, w) in advances.iter().enumerate() {
|
||
t.set(i.to_string(), *w)?;
|
||
}
|
||
self.lua.globals().set("BENILLA_DIGIT_W", t)
|
||
};
|
||
if let Err(e) = set() {
|
||
self.model_mut()
|
||
.errors
|
||
.push(format!("set_digit_advances: {e}"));
|
||
}
|
||
}
|
||
|
||
// ── Input: pointer-leaves-window cleanup (decision 0216 §3; the hit-test/mouse dispatch that
|
||
// used to sit here now lives in [`pointer`]) ─────────────────────────────────────────────────
|
||
|
||
/// The OS pointer left the window (decision 0216 §3's drag-gesture leak): clears
|
||
/// [`Model::drag`] AND [`Model::mouse_down_on`] — the two press-tracked states nothing else
|
||
/// clears when the pointer leaves the window mid-press, since the matching release is never
|
||
/// fed. Left uncleared, a stale armed [`Model::drag`] would fire a spurious `OnDragStart` the
|
||
/// instant the pointer re-enters and crosses the threshold against a press point that no
|
||
/// longer means anything; a stale [`Model::mouse_down_on`] entry would fire a same-frame
|
||
/// `OnClick` if the pointer re-enters and releases over the very frame it left from. The app
|
||
/// calls this from the same branch that fires the synthetic `OnLeave` (`ui_script/input.rs`).
|
||
pub fn pointer_left_window(&mut self) {
|
||
let mut model = self.model_mut();
|
||
model.drag = None;
|
||
model.mouse_down_on.clear();
|
||
// A thumb drag in progress when the pointer leaves is abandoned too (decision 0250 §5) —
|
||
// the release that would end it is never fed, same leak as the drag gesture above.
|
||
model.slider_drag = None;
|
||
}
|
||
|
||
// ── Keyboard entry (RF-0082 §1/§2: the EditBox focus + key/char routing) ─────────────────────
|
||
//
|
||
// benilla speaks *key names*, not scancodes — the host maps its window keycodes to these. The
|
||
// routing is the client's exactly: if a box is focused it processes and CONSUMES every event; if
|
||
// none is focused, the topmost effectively-visible `autoFocus` box self-acquires focus and
|
||
// processes this same event; otherwise nothing is consumed. `autoFocus` never focuses on show —
|
||
// only this self-acquire path, a click, or Lua `SetFocus` focuses a box.
|
||
|
||
/// A typed character (may be multi-byte UTF-8) arriving from the host. Routes per §1/§2 and, on a
|
||
/// focused box, inserts it (numeric/cap/password rules apply) or — for the Ctrl+A control code —
|
||
/// selects all. Returns `true` if consumed (a focused box consumes every char).
|
||
pub fn char_input(&mut self, text: &str) -> bool {
|
||
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 {
|
||
editbox::key_input(&self.lua, key)
|
||
}
|
||
|
||
/// One semantic text-editing operation on the focused EditBox — the output of the host's
|
||
/// per-OS keymap (decision 0301). Same routing/consumption law as [`Self::key_input`].
|
||
/// Returns `true` if consumed.
|
||
pub fn editbox_action(&mut self, action: EditAction) -> bool {
|
||
editbox::action(&self.lua, action)
|
||
}
|
||
|
||
/// Whether an EditBox currently holds keyboard focus (and is effectively visible) — the app gates
|
||
/// world/player key input on this, matching the client's `DAT_00cf4dc8 != 0` test (RF-0082 §1).
|
||
pub fn has_keyboard_focus(&self) -> bool {
|
||
let model = self.model_ref();
|
||
model
|
||
.focused_editbox
|
||
.is_some_and(|h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
|
||
}
|
||
|
||
/// How many resolves the layout change gate has let through (`Model::layout_solves`) — the
|
||
/// gate's effectiveness, readable by tests and the app's cost meters (a per-frame delta of 0
|
||
/// means the fingerprint judged the frame quiet).
|
||
pub fn layout_solves(&self) -> u64 {
|
||
self.model_ref().layout_solves
|
||
}
|
||
|
||
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
|
||
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
|
||
/// depth.
|
||
pub fn layout_rounds(&self) -> u64 {
|
||
self.model_ref().layout_rounds
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Register a named virtual [`FontObject`] (a resolved `<Font>`), overwriting any prior one of
|
||
/// the same name — the loader's join for top-level `<Font>` nodes. FontString `inherits=` and Lua
|
||
/// `SetFontObject` resolve against this registry.
|
||
pub fn register_font_object(&self, name: &str, font: FontObject) {
|
||
self.model_mut().font_objects.insert(name.to_string(), font);
|
||
}
|
||
|
||
/// 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_objects.get(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.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().errors.push(e.to_string());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|