Files
benilla-octo/crates/benilla-ui/src/loader/scripts.rs
T
SamandClaude Fable 5 251daa14c5 addons come alive: Bagnon draws, hovers and clicks
- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

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

181 lines
8.2 KiB
Rust

use mlua::{Function, ObjectLike, Table, Value};
use crate::framexml::Element;
use super::{children_named, Loader};
impl Loader<'_> {
/// `<Scripts>` → SetScript each handler (rf24 `0x769ef0`). Handler source comes from the element
/// body (`<OnClick>lua…</OnClick>`) or a `function="Global"` attribute. Returns the compiled
/// `OnLoad` handle (if any) so the caller can fire it bottom-up. A body that fails to *compile* is
/// an error; an unsupported handler *name* (e.g. the keyboard-focus handlers `OnKeyDown`/`OnChar`,
/// not yet modeled) is a warn-once gap.
pub(super) fn apply_scripts(
&mut self,
el: &Element,
wrapper: &Table,
dbg: &str,
) -> Option<Function> {
let mut onload = None;
for scripts in children_named(el, "Scripts") {
for handler in &scripts.children {
let name = handler.tag.clone();
let Some(func) = self.compile_handler(handler, &name, dbg) else {
continue;
};
// SetScript stores it; an unsupported name errors — surface as a gap, don't drop hard.
if let Err(e) = wrapper.call_method::<()>("SetScript", (name.clone(), func.clone()))
{
self.warn_once(
&format!("script:{name}"),
format!("{dbg}: SetScript(\"{name}\") unsupported in v1: {e}"),
);
continue;
}
// The real `<Scripts>` walker auto-enables the matching input kind after each
// successful SetScript (`0x769ef0` → `0x76af00(kind,-1)` per handler name; wow-re
// `ui/scratch/scripts-auto-enable.md`, §5 cross-checked): the five MOUSE-kind
// handlers arm the same enable as the XML `enableMouse` attribute — `OnDragStart`
// is in the set, `OnDragStop`/`OnReceiveDrag` are NOT. This is XML-load-time
// ONLY: the Lua SetScript binding (`0x7748d0`) never auto-enables, so the law
// lives here and not in SetScript itself (a runtime-created frame still needs an
// explicit `EnableMouse(true)`, like the real client). The keyboard kinds
// (`OnChar` = kind 0, `OnKeyDown`/`OnKeyUp` = kind 1) and the wheel kind
// (`OnMouseWheel` = kind 3) have separate indexes in the real client that this
// engine doesn't model yet — un-modeled beside the SetScript keyboard gap above.
const MOUSE_KIND: [&str; 5] = [
"OnEnter",
"OnLeave",
"OnMouseDown",
"OnMouseUp",
"OnDragStart",
];
if MOUSE_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) {
self.call(wrapper, "EnableMouse", true, dbg);
}
if name.eq_ignore_ascii_case("OnLoad") {
onload = Some(func);
}
}
}
onload
}
/// Compile a handler element into `function(self, ...) <body> end`, or resolve its
/// `function="Global"` reference. A syntax error in the body is recorded as an error (and the
/// handler dropped), never a panic.
///
/// ## `self` falls back to `this`, and that one clause is a whole class of addon bug
///
/// The 1.12 contract for an XML script body is that it takes **no arguments**: the frame
/// arrives as the `this` global, which [`crate::script::event::invoke_with_globals`] sets
/// around every dispatch. This engine also passes the modern `(self, event, …)` positionals —
/// convenient, and what Era-era addons expect — and our own FrameXML is written against
/// *that* spelling (`<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>`).
///
/// The two spellings agree right up until **an addon captures a script and calls it back**,
/// which is the standard 1.12 hook idiom and the reason `GetScript` exists:
///
/// ```lua
/// bMainBag_OnEnter = MainMenuBarBackpackButton:GetScript("OnEnter") -- Bagnon.lua:61
/// MainMenuBarBackpackButton:SetScript("OnEnter", BagnonBlizMainBag_OnEnter)
/// function BagnonBlizMainBag_OnEnter()
/// …
/// bMainBag_OnEnter() -- l.87: no arguments. The reference's contract.
/// end
/// ```
///
/// The re-entry is perfectly legal, `this` is correctly set (Bagnon's own body reads
/// `this:GetID()` one line above), and our compiled body still took `self` from argument 1 —
/// which was nil. The director saw it as `bad argument #2: error converting Lua nil to table`
/// out of `GameTooltip:SetOwner`, from a handler that works perfectly when the engine calls it.
///
/// So the fallback lives **here**, once, rather than as `(self or this)` sprinkled through
/// ~20 handler bodies: any XML body in any file — ours or an addon's — now works under both
/// callers. It is written on line 1 of the wrapper on purpose, so `{body}` still starts at
/// line 2 and every traceback line number keeps pointing at the source the author wrote.
pub(super) fn compile_handler(
&mut self,
handler: &Element,
name: &str,
dbg: &str,
) -> Option<Function> {
let body = handler.body.trim();
if !body.is_empty() {
let src = format!(
"return function(self, ...) if self == nil then self = this end\n{body}\nend"
);
match self
.lua()
.load(&src)
.set_name(format!("{dbg}:{name}"))
.set_mode(mlua::ChunkMode::Text)
.eval::<Function>()
{
Ok(f) => return Some(f),
Err(e) => {
self.report
.errors
.push(format!("{dbg}: compiling <{name}>: {e}"));
return None;
}
}
}
if let Some(global) = handler.attr("function") {
match self.lua().globals().get::<Value>(global) {
Ok(Value::Function(f)) => return Some(f),
_ => {
self.warn_once(
&format!("fn:{global}"),
format!(
"{dbg}: <{name} function=\"{global}\">: no such global function (yet)"
),
);
return None;
}
}
}
None
}
/// Fire a captured `OnLoad` with the frame wrapper as both the legacy `this` global (RF-0025,
/// set-then-restored) and the modern `self` argument — the same dual convention the host's event
/// path uses; we replicate only the `this` set/restore here because there is no public host API to
/// fire `OnLoad` directly (it is not an event). Handler errors are recorded, never propagated.
pub(super) fn fire_onload(&mut self, wrapper: &Table, func: &Function, dbg: &str) {
// The RF-0025 `this`/`self` convention lives in one home (`UiScript::invoke_handler`); the
// loader doesn't re-implement the set/restore, it just supplies the wrapper + captured func.
if let Err(e) = self.invoke_handler(wrapper, func) {
self.report.errors.push(format!("{dbg}: OnLoad: {e}"));
}
}
/// Call a frame-wrapper method, recording (not propagating) any error.
pub(super) fn call(
&mut self,
wrapper: &Table,
method: &str,
args: impl mlua::IntoLuaMulti,
dbg: &str,
) {
if let Err(e) = wrapper.call_method::<()>(method, args) {
self.report.errors.push(format!("{dbg}: {method}: {e}"));
}
}
/// Call a region-wrapper method, recording (not propagating) any error.
pub(super) fn call_region(
&mut self,
region: &Table,
method: &str,
args: impl mlua::IntoLuaMulti,
dbg: &str,
) {
if let Err(e) = region.call_method::<()>(method, args) {
self.report
.errors
.push(format!("{dbg}: region {method}: {e}"));
}
}
}