Files
benilla-octo/crates/benilla-ui/src/script/object/events_regions.rs
T
SamandClaude Fable 5.1 73ead4e4d2 grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004)
- a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013)
- the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010)
- the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:33:58 +07:00

562 lines
29 KiB
Rust

//! Frame method-table cluster: event registration + script handlers (`RegisterEvent`/
//! `UnregisterEvent`/`SetScript`/`GetScript`), the drag-gesture registration (`RegisterForDrag`,
//! decision 0216 §3), and region creation (`CreateTexture`/`CreateFontString`). Split out of
//! [`super`] purely for size — see its module doc for the shared id/handle plumbing and
//! method-table wiring.
use std::collections::HashSet;
use mlua::{Function, Lua, MultiValue, Table, Value};
use crate::script::binding_abi::optional_string;
use crate::script::region::region_wrapper;
use crate::script::{Model, RegionData, REG_SCRIPTS, SCRIPT_KINDS};
use crate::widget::RegionKind;
use super::{decode_id, draw_layer_from_str, frame_handle_of, publish_global};
/// Populate `m`'s event/script and region-creation methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Events + scripts
m.set(
"RegisterEvent",
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Ordered listener list (the client's SignalEvent walks a LIST — order is law);
// re-register keeps the original position (no duplicate fire).
let list = model.event_to_frames.entry(event.clone()).or_default();
if !list.contains(&h) {
list.push(h);
}
model.frame_events.entry(h).or_default().insert(event);
Ok(())
})?,
)?;
m.set(
"UnregisterEvent",
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(list) = model.event_to_frames.get_mut(&event) {
list.retain(|x| x != &h);
}
if let Some(set) = model.frame_events.get_mut(&h) {
set.remove(&event);
}
Ok(())
})?,
)?;
// `UnregisterAllEvents()` — drop every registration this frame holds, in one call.
//
// 10 corpus addons stop on it (decision 1195), and the idiom is why: an addon's "disable me"
// path is `self.frame:UnregisterAllEvents()`, and a library that pools frames calls it before
// handing one out. Unregistering them one by one is not equivalent — the caller does not know
// what it registered, which is the whole point of the verb.
m.set(
"UnregisterAllEvents",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Take the frame's OWN set first, then walk only the events it actually held — the
// alternative (sweeping every listener list in the model) is O(all events) per call,
// and libraries call this in loops over a frame pool.
let events = model.frame_events.remove(&h).unwrap_or_default();
for event in events {
if let Some(list) = model.event_to_frames.get_mut(&event) {
list.retain(|x| x != &h);
}
}
Ok(())
})?,
)?;
m.set(
"SetScript",
lua.create_function(
|lua, (this, name, func): (Table, String, Option<Function>)| {
set_script(lua, &this, &name, func)
},
)?,
)?;
m.set(
"GetScript",
lua.create_function(|lua, (this, name): (Table, String)| get_script(lua, &this, &name))?,
)?;
// HasScript(name) — "can this widget carry that script kind at all", NOT "does it have one
// set". The distinction is the whole point of the verb: every caller in the corpus uses it to
// decide whether it is safe to hook, then reads the current handler separately.
//
// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
// local script = parent:GetScript("OnMouseDown") -- FuBar-compat:2422
// parent:SetScript("OnMouseDown", function() … end)
// end
//
// It was the top session-start blocker: 32 of the 39 `attempt to call method` failures were
// this one name, across the Tablet/FuBar/oRA2 stack.
//
// ANSWERED FROM THE FLAT `SCRIPT_KINDS`, and that is a deliberate, stated approximation rather
// than the full answer. The reference's table is PER WIDGET TYPE — a plain Frame really does
// answer false for `OnClick` — so ours is over-permissive for the type-specific kinds. It is
// exactly right for the base kinds, which is what every corpus caller asks about
// (`OnMouseDown` here; a Frame genuinely has it). The cost of the over-permission is small and
// worth naming: an addon that asks about a type-specific kind installs a handler that never
// fires, where the reference would have had it skip. That is the same latitude `SetScript`
// already takes — this verb does not widen it, it just stops lying about its existence.
//
// Making it exact means per-type SCRIPT_KINDS, which newly RAISES on 30 sites that work today
// (9 in our own FrameXML). That is behaviour removal and belongs in its own change, with its
// own measurement; it is not a tail on this one.
m.set(
"HasScript",
lua.create_function(|_, (_this, name): (Table, String)| {
Ok(SCRIPT_KINDS.iter().any(|k| k.eq_ignore_ascii_case(&name)))
})?,
)?;
// RegisterForDrag(...varargs of button names) — the drag-gesture twin of `RegisterForClicks`
// (decision 0216 §3), but on the SHARED table: any Frame can be a drag source, not just a
// Button. Replace-the-set semantics (empty varargs clears); `crate::script::cursor`'s
// arm/start/release path consults the set case-insensitively (the `RegisterForClicks`
// precedent). Pruning on frame destroy: see [`Model::drag_registered`]'s doc — nothing in
// this engine destroys a frame yet, so this map is in the same boat as `scripts`/
// `frame_events`.
m.set(
"RegisterForDrag",
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let h = frame_handle_of(lua, &this)?;
let mut set = HashSet::new();
for v in args.iter() {
if let Value::String(s) = v {
set.insert(s.to_str()?.to_string());
}
}
let mut model = lua.app_data_mut::<Model>().expect("model");
model.drag_registered.insert(h, set);
Ok(())
})?,
)?;
// Regions
// `CreateTexture(name, layer, inheritsFrom)` — the third argument accepted for the same reason
// and with the same resolver, though the demand is a rounding error beside FontString's: of
// 1329 corpus call sites exactly TWO pass a third argument, and one of those passes `nil`
// (CustomNameplates:437, a four-argument later-client form). Accepted rather than skipped
// because dropping it silently is the very class this fixes, and one real site is still a site.
m.set(
// **The three argument positions are NOT one rule, and the fourth is the odd one.** Verified
// together in wow-re `ui/scratch/xml-template-name-lookup.md` §5.2:
//
// · `name` and `layer` go through `0x6f3510` (is-number-**or**-string) then `0x6f3690`, and
// **the result is never tested** — so a table is simply absent (unnamed / the pre-staged
// default layer), and a NUMBER is accepted and stringified.
// · `inherits` is gated by a raw `lua_type(L, 4) == LUA_TSTRING` (`0x773d28` / `0x773b06`)
// asked BEFORE anything reads the slot — so a table AND a number are both ignored, and the
// non-string leg jumps to the very block the two template-HIT legs jump to, with both
// template handles NULL. It is the success path, not a bolted-on case.
//
// **Nothing here raises.** This mattered: benilla typed all three `Option<String>`, so mlua
// raised `bad argument #4: error converting Lua table to String` — and pfUI, the most-installed
// 1.12 addon, builds every buff-stack label as
// `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])`, passing the BUTTON as the third
// argument. The real client constructs it, ignores the argument, and logs nothing.
"CreateTexture",
lua.create_function(
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
let name = optional_string(lua, &name);
let layer = optional_string(lua, &layer);
let wrapper = create_region(lua, &this, RegionKind::Texture, name, layer)?;
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, &from)?;
}
Ok(wrapper)
},
)?,
)?;
// ── The title region: Frame:CreateTitleRegion() / GetTitleRegion() ──────────────────────────
//
// wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q6 (`0x773910` / `0x773820`). Four
// details, each one a coin-flip a reimplementation loses:
//
// · **It reads NO argument at all** — nothing in `0x773910`-`0x773a1f` touches Lua index 2. So
// `CustomNameplates/options.lua:73`'s `CreateTitleRegion(optionsFrame)` is harmless and
// identical to the no-arg call, rather than an error or a differently-parented region.
// · **It is IDEMPOTENT, destructively.** One region per frame (`CSimpleFrame+0xA8`): a second
// call runs ClearAllPoints on the existing one and returns THE SAME OBJECT — so calling it
// on an XML-declared `<TitleRegion>` silently wipes that region's anchors. Returning a
// fresh region instead would leave the first one hit-testing forever.
// · **A fresh one has NO anchors** and does nothing until `SetPoint`/`SetAllPoints`. Both
// corpus consumers immediately call `SetAllPoints`, which is the whole-window drag idiom.
// · **`GetTitleRegion` answers 1 value (nil)** when there is none — note the asymmetry with
// `GetBackdrop`, which answers 0 values. Q6 flags it explicitly; both converged
// independently there, so both are reproduced here.
// **ONE NAMED DIVERGENCE, because it is a superset and 1189 is what a superset costs.** Q6 says
// the object answers *exactly* the 19 Region methods — no Show/Hide, no scripts, no textures.
// Ours answers the whole shared region table, because Texture/FontString/Title use one
// metatable here, so `titleRegion:SetTexture(…)` is accepted where the reference would raise
// `attempt to call method`. It is inert rather than wrong — a title region never draws (the
// extract skips its kind outright), so a texture or a Hide set on one changes nothing — but an
// addon that FEATURE-DETECTS would see a method the real client does not have. Splitting the
// metatable is the fix; it is not free, and nothing in the corpus asks, so this is recorded
// rather than hidden.
m.set(
"CreateTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let existing = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(owner).and_then(|f| f.title_region)
};
if let Some(rh) = existing {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
}
model.region_id(rh)
};
return region_wrapper(lua, id);
}
let wrapper = create_region(lua, &this, RegionKind::Title, None, None)?;
let id = decode_id(&wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let rh = *model
.id_to_region
.get(&id)
.expect("the region we just created is registered");
if let Some(f) = model.arena.frame_mut(owner) {
f.title_region = Some(rh);
}
Ok(wrapper)
})?,
)?;
m.set(
"GetTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let found = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.arena
.frame(owner)
.and_then(|f| f.title_region)
.map(|rh| model.region_id(rh))
};
match found {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
// ONE value, and it is nil — not zero values. See the `GetBackdrop` asymmetry above.
None => Ok(Value::Nil),
}
})?,
)?;
// The THIRD argument is `inheritsFrom`, and we were dropping it on the floor.
//
// 49 corpus call sites across 5 distinct addons pass one — AckisRecipeList (28),
// CustomNameplates (10), _LazyPig (6), LibAboutPanel (4), ColorPickerPlus (1) — and **every one
// of them names a FONT OBJECT**: GameFontNormalSmall (15), GameFontNormal (11),
// GameFontHighlightSmall (10), GameFontHighlight (9), GameFontNormalLarge (3), GameFontDisable.
// Five separate addons, so this is not one library file replicated (1207 checked, not assumed).
// Ignoring it is 1203's class exactly: the addon asks for a font, we accept the call, and the
// string comes out in whatever the default is with no failure anywhere.
//
// The resolution order is carved (`0x773c30`): the FONT-object registry FIRST (`0x773d39`,
// create=0), and only on a font miss the template registry (`0x773d47`). This routes through
// the `SetFontObject` binding rather than reaching into the model, which is the same call the
// XML `<FontString inherits=>` path makes — one implementation of "apply a font object", not
// two that can drift.
m.set(
"CreateFontString",
lua.create_function(
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
let name = optional_string(lua, &name);
let layer = optional_string(lua, &layer);
let wrapper = create_region(lua, &this, RegionKind::FontString, name, layer)?;
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, &from)?;
}
Ok(wrapper)
},
)?,
)?;
Ok(())
}
/// `SetScript(name, func)` — store the closure against one of [`SCRIPT_KINDS`], or **raise**.
///
/// ## Why raising is the right answer for a script we cannot fire
///
/// The error is loud and it blocks the addon at load, which looks like the worse outcome and is
/// not: a script name this host *accepts* and never *fires* is a handler that silently never runs,
/// with nothing anywhere saying so. That is the failure mode decisions 1203, 1205 and 1211 each
/// recorded from a different direction, and it is strictly harder to find than a load error naming
/// the exact call. So the rule for this list is one line long: **a name is accepted only once
/// something fires it.**
///
/// The 1.12 script set is fully carved (wow-re `system/ui/scratch/rf28-typed-widget-loadxml.md`
/// l.10-18 for the base map `0x76a0d0`, the per-type sections for each widget's additions;
/// `system/ui/ui.md` l.544-556 summarises it), so what is missing here is never a mystery — it is a
/// deliberate not-yet. What the corpus actually asks for, and why each answer is what it is:
///
/// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising,
/// and now unblocked**: the delivery law was the second §5 this work dispatched, and it landed
/// (wow-re `scratch/frame-key-script-delivery.md`). benilla still has none of the machinery it
/// describes — `EnableKeyboard`/`IsKeyboardEnabled` now exist and the flag round-trips, but there
/// is still no keyboard index and no strata walk, and keys are routed straight to the focused
/// EditBox — so the names stay out until that exists, which is the whole rule above. What has to
/// get built, so the next pass does not have to re-derive it:
///
/// The frame must be in the hit-test root's **kind-0 / kind-1 bucket**
/// (`scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, …)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1; XML `enableKeyboard` enables both, a `<Scripts>` block
/// auto-enables per handler, and Lua `SetScript` auto-enables **nothing** — so a Lua-only frame
/// is not in the bucket at all and its bound handler can never fire). The dispatcher then walks
/// **strata 8 → 0, level high → low, ties oldest-registration-first**, calling each frame's base
/// input virtual (`0x765f10` key-down → vtable `+0x60`; `0x765df0` char → `+0x5c`) and
/// **stopping at the first nonzero return**. `arg1` is a **key-name string** with no modifier
/// prefix, decoded from the *same* table the keybinding chord names use — verified byte-identical
/// over 273 codes, which is the opposite of the mouse case's two-table shape. `OnChar` gets the
/// literal character, UTF-8.
///
/// Two findings that will bite whoever implements it. The consumption gate is **existence, not
/// handling** — a 1.12 handler cannot signal "handled" (the fire's return is discarded at all
/// three sites), so merely having a key script bound suppresses the key's binding; and
/// asymmetrically, a frame with **only** an `OnKeyUp` script consumes every key-down and runs
/// nothing. And `CGWorldFrame` sits at **strata 0 / level 0** — last in the walk — which is
/// precisely why any keyboard-enabled frame pre-empts the entire binding system.
///
/// (The old gloss here called `0x76bba0` a "frame-script pre-gate" walking the index, after
/// `scratch/keybinding-dispatch-law.md` §1. That is refuted: `0x76bba0` is `CSimpleFrame::OnKeyUp`,
/// one frame's base virtual. The walk is one level up, in the dispatcher.)
/// * **`OnCursorChanged`** (4 sites over 3 addons — all of them the Era `ScrollingEdit_OnCursorChanged`
/// auto-scroll idiom) — **raising.** It is the EditBox's own slot (RF-28 `+0x428`), fired by the
/// caret flush `0x77da80` with **four float caret-POSITION args**, and caret geometry is the one
/// thing this engine deliberately does not have: text is measured host-side. Accepting it would
/// hand every caller four zeros, which for its single idiom means a scroll box that silently
/// never follows the caret.
/// * **`OnAttributeChanged`** (1 site, `Roid-Macros`) — **raising, permanently.** It is 2.0's secure
/// frame/attribute system; there is no such slot in any 1.12 resolver. That addon is asking for a
/// later client and should hear so.
/// * **`OnHorizontalScroll` · `OnHyperlinkEnter` · `OnHyperlinkLeave` · `OnMessageScrollChanged` ·
/// `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` · `OnInputLanguageChanged`** — **raising.**
/// Real 1.12 slots that we do not fire, and measured at **zero** call sites across the
/// 218-addon corpus, so there is nothing to weigh against the trap: they land when their
/// mechanism does (horizontal scroll isn't modeled at all — see
/// [`crate::script::scrollframe`]'s module doc).
/// * **`OnUpdateModel` · `OnAnimFinished`** — **accepted since decision 2007**, because the tick
/// fires them: the model pane's scene clock runs in this engine now (`tick.rs`), `OnUpdateModel`
/// at the top of every paint of a visible pane with a file (`0x76d1a0`) and `OnAnimFinished`
/// from a sequence's natural completion (`0x76cdc0`, mode 0 — once per arm, a loop's first
/// pass included). The shipped
/// `Cooldown.xml` and `MainMenuBarBagButtons.xml` declare both.
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
.iter()
.copied()
.find(|&k| k.eq_ignore_ascii_case(name))
.ok_or_else(|| mlua::Error::runtime(format!("SetScript: unsupported script '{name}'")))?;
let h = frame_handle_of(lua, this)?;
let id = lua.app_data_mut::<Model>().expect("model").frame_id(h);
// Store the closure Lua-side (REG_SCRIPTS[id][kind]); update the Rust presence mirror.
let scripts: Table = lua.named_registry_value(REG_SCRIPTS)?;
let per: Table = match scripts.get::<Value>(id)? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
scripts.set(id, t.clone())?;
t
}
};
match func {
Some(f) => {
per.set(kind, f)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// `insert` answering true = this frame did not have the kind before — the tick's
// OnUpdate list rides that edge (decision 1446; `scripts` has no other writer).
if model.scripts.entry(h).or_default().insert(kind) {
match kind {
"OnUpdate" => model.on_update_frames.push(h),
"OnSizeChanged" => model.on_size_changed_frames.push(h),
"OnUpdateModel" => model.on_update_model_frames.push(h),
_ => {}
}
}
}
None => {
per.set(kind, Value::Nil)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(set) = model.scripts.get_mut(&h) {
if set.remove(&kind) {
match kind {
"OnUpdate" => model.on_update_frames.retain(|&x| x != h),
"OnSizeChanged" => {
model.on_size_changed_frames.retain(|&x| x != h);
}
"OnUpdateModel" => model.on_update_model_frames.retain(|&x| x != h),
_ => {}
}
}
}
}
}
Ok(())
}
fn get_script(lua: &Lua, this: &Table, name: &str) -> mlua::Result<Value> {
let kind = match SCRIPT_KINDS
.iter()
.copied()
.find(|&k| k.eq_ignore_ascii_case(name))
{
Some(k) => k,
None => return Ok(Value::Nil),
};
let id = decode_id(this)?;
let scripts: Table = lua.named_registry_value(REG_SCRIPTS)?;
match scripts.get::<Value>(id)? {
Value::Table(t) => t.get::<Value>(kind),
_ => Ok(Value::Nil),
}
}
/// `CreateTexture`/`CreateFontString`'s third argument — `inheritsFrom` — in the carved order.
///
/// **Font-object registry first, template registry second** (`0x773d39` then `0x773d47`). That
/// order is not cosmetic: `inherits=` is one attribute over two namespaces, and every corpus caller
/// of this argument names a font object, so a template-first resolver would miss all 49 of them.
///
/// A name in NEITHER registry raises, which is the same contract 1253 established for `CreateFrame`
/// and the same bytes (`luaL_error`, `0x87957c` / `0x879544`, which never returns).
///
/// A name that IS a registered template but not a font object is a deliberate gap rather than a
/// silent one: the lookup succeeded, so the reference would not raise, and re-interpreting a
/// template node onto an existing region is a different mechanism from applying a font object. It
/// warns and leaves the region usable — the distinction 1253 drew between *the lookup missing* and
/// *the result being unusable*. Zero corpus callers reach it.
fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result<()> {
use mlua::ObjectLike;
// The font object is applied through the binding the XML path uses, so there is one
// implementation of "apply a font object" rather than two that can drift apart.
if wrapper.call_method::<()>("SetFontObject", from).is_ok() {
return Ok(());
}
let is_template = {
let model = lua.app_data_ref::<Model>().expect("model");
let templates = model.framexml_templates.borrow();
templates.contains_key(from) || templates.keys().any(|k| k.eq_ignore_ascii_case(from))
};
if is_template {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.warnings.push(format!(
"CreateTexture/CreateFontString: '{from}' is a registered TEMPLATE, not a font object; the region is created but the template's content is not applied (no corpus caller does this)"
));
return Ok(());
}
Err(mlua::Error::runtime(format!(
"Couldn't find inherited node \"{from}\""
)))
}
/// **The `lua_type(L, idx) == LUA_TSTRING` gate, asked before anything reads the slot** — the
/// region constructors' `inherits` argument and nowhere else (`0x773d28` for `CreateFontString`,
/// `0x773b06` for `CreateTexture`).
///
/// Strictly narrower than [`optional_string`]: a **number is refused here**, because the tag is
/// tested raw with no coercion. That one-argument difference is the whole reason
/// `f:CreateTexture(nil, nil, 5)` silently ignores the 5 while `CreateFrame("Frame", nil, nil, 5)`
/// **raises** `Couldn't find inherited node "5"` — `CreateFrame` runs `lua_tostring(L, 4)` BEFORE
/// its own `cmp 4`, and that retags the number's stack slot in place, so it arrives as a string.
/// Same value, opposite outcome, from argument order alone.
fn strict_string_arg(v: &Value) -> Option<String> {
match v {
Value::String(s) => s.to_str().ok().map(|s| s.to_owned()),
_ => None,
}
}
fn create_region(
lua: &Lua,
this: &Table,
kind: RegionKind,
name: Option<String>,
layer: Option<String>,
) -> mlua::Result<Table> {
let owner = frame_handle_of(lua, this)?;
let dl = layer
.as_deref()
.and_then(draw_layer_from_str)
.unwrap_or_default();
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let rh = model
.arena
.create_region(owner, kind, dl, 0)
.ok_or_else(|| mlua::Error::runtime("CreateTexture/FontString: dead owner frame"))?;
model.region_data.insert(rh, RegionData::default());
model.region_id(rh)
};
let wrapper = region_wrapper(lua, id)?;
if let Some(name) = name {
publish_global(lua, &name, &wrapper)?;
// Publish into the region-name registry too (first-wins, the frame rule) — this is what
// lets a sibling region's SetPoint name us as its `relativeTo` (see `resolve_target`).
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_names.entry(name).or_insert(id);
}
Ok(wrapper)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// `UnregisterAllEvents` drops every registration the frame holds and leaves every other
/// frame's alone — the "disable me" path 10 corpus addons stop on (decision 1195).
///
/// The second half is the one worth asserting: the cheap implementation (sweep every listener
/// list) and the correct one differ only when a *second* frame shares an event, which is the
/// normal case for `PLAYER_ENTERING_WORLD`.
#[test]
fn unregister_all_events_clears_one_frame_and_only_that_frame() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
Mine = CreateFrame("Frame", "UnregAllMine")
Yours = CreateFrame("Frame", "UnregAllYours")
Seen = {}
for _, f in ipairs({ Mine, Yours }) do
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function() Seen[event] = (Seen[event] or 0) + 1 end)
end
"#,
)
.unwrap();
s.fire_event("PLAYER_LOGIN", vec![]);
assert_eq!(s.eval::<i64>("return Seen.PLAYER_LOGIN").unwrap(), 2);
s.run("Mine:UnregisterAllEvents()").unwrap();
s.run("Seen = {}").unwrap();
s.fire_event("PLAYER_LOGIN", vec![]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(
s.eval::<i64>("return Seen.PLAYER_LOGIN").unwrap(),
1,
"the other frame's registration must survive — they shared the event"
);
assert_eq!(
s.eval::<i64>("return Seen.PLAYER_ENTERING_WORLD").unwrap(),
1
);
// Idempotent, and harmless on a frame that never registered anything.
s.run("Mine:UnregisterAllEvents() CreateFrame(\"Frame\"):UnregisterAllEvents()")
.unwrap();
}
}