Files
benilla-octo/crates/benilla-ui/src/script/object/frame_state.rs
T
SamandClaude Fable 5.1 9caacad153 brightness saves, the glue box holds still, and Questie's notes hover
- the Brightness slider in the options window changes the picture and keeps its value; it was writing to no setting at all, so every reopen read 50% again (2186)
- the login, realm and character screens share one letterboxed frame, so clicking down the character roster no longer slides the backdrop and every panel over it; at 4:3 nothing changes (2187)
- Questie's world-map notes show their tooltip on hover: the note hands the tooltip a frame where an anchor name goes, which benilla refused with a script error and the reference reads as the default anchor (2176)
- with OmniCC installed, a warrior in a stance sees the countdown on action bar 1 again: raising that bar's buttons by script carried their cooldown sweep up over the count (2189)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:06:04 +07:00

1092 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Frame method-table cluster: Show/Hide/visibility, identity/hierarchy (`GetName`/`GetParent`/
//! `SetParent`), strata/level/scale/alpha, the Backdrop mechanism (`SetBackdrop`/`SetBackdropColor`/
//! `SetBackdropBorderColor`), and mouse-enable. Split out of [`super`] purely for size — the shared
//! id/handle plumbing, `CreateFrame`, and the method-table wiring stay there; this module's
//! [`install`] just populates its share of the one shared method table.
use mlua::{Lua, Table, Value};
use crate::order::Strata;
use crate::script::{event, Backdrop, Insets, Model};
use crate::widget::FrameKind;
use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, strata_from_str};
/// Populate `m`'s visibility/hierarchy/strata/backdrop/mouse methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Show / Hide / visibility. **`SetShown` is not here and must not come back**: the branchless
// setter belongs to a later expansion — no 1.12 method table registers it, and neither the
// stock chain nor either addon corpus calls it (decision 2142's census). A frame is shown or
// hidden by the two verbs the era has.
m.set(
"Show",
lua.create_function(|lua, this: Table| set_shown(lua, &this, true))?,
)?;
m.set(
"Hide",
lua.create_function(|lua, this: Table| set_shown(lua, &this, false))?,
)?;
m.set(
"IsShown",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.arena.frame(h).map(|f| f.shown).unwrap_or(false),
))
})?,
)?;
m.set(
"IsVisible",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model
.arena
.frame(h)
.map(|f| f.effective_visible)
.unwrap_or(false),
))
})?,
)?;
// Identity / hierarchy
m.set(
"GetName",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(h).and_then(|f| f.name.clone())
};
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetID/SetID — the app-meaning-free numeric label (XML `id=`, the client's `+0xb4`): a
// dropdown row's list position, a tab index. Default 0 (see `Frame::wow_id`).
m.set(
"GetID",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.wow_id).unwrap_or(0))
})?,
)?;
m.set(
"SetID",
lua.create_function(|lua, (this, id): (Table, i64)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(f) = model.arena.frame_mut(h) {
f.wow_id = id;
}
Ok(())
})?,
)?;
// ── GetObjectType / IsObjectType, frame side (wow-re `widget-type-identity.md` §6) ──────────
//
// The region side landed first because a FontString found it; `_Nameplates` is why this half
// exists too — `_Nameplates.lua:164` tests `Nameplate:GetObjectType() ~= "Button"` and `:191`
// tests `== "StatusBar"`, on FRAMES, in the same file whose `Region:GetObjectType()` calls the
// region verbs answer.
//
// The chains are a hardcoded straight-line list per class in the binary, not a runtime parent
// walk, so they are a table here too. Three of them are things a reasonable person invents
// wrongly:
//
// · **`ScrollingMessageFrame` derives from `Frame`, NOT from `MessageFrame`.** The name says
// otherwise and the roster is explicit (`0x787940`).
// · **`SimpleHTML` is spelled with a capitalised HTML.** Our enum variant is `SimpleHtml`, so
// anything derived from the variant name — `format!("{:?}")` and friends — would hand
// addons `"SimpleHtml"`, and `GetObjectType` is compared with `==` (`IsObjectType`'s
// case-folding would hide it; the getter's would not).
// · **There is no `Cooldown` type.** 1.12.1's census finds 23 type-name globals and none is
// that: the reference builds its cooldown as a `Model` playing `UI-Cooldown-Indicator.mdx`
// (`CooldownFrameTemplate`), and since decision 2019 so does this engine — the Era-shaped
// `FrameKind::Cooldown` that modelled the mechanism first-class (0137 phase 4) is gone, so
// `CreateFrame("Cooldown")` is the reference's own unknown-type refusal, never an announced
// Era type to a Lua ecosystem that branches on presence (the superset 1189 took back out).
fn type_chain(kind: FrameKind) -> &'static [&'static str] {
match kind {
FrameKind::Frame => &["Frame", "Region"],
FrameKind::WorldFrame => &["Frame", "Region"],
FrameKind::Button => &["Button", "Frame", "Region"],
FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"],
// `CLootButton::IsObjectType 0x495af0` prepends its own name to the base's three
// (`"Button"` `[0x879954]`, `"Frame"` `[0x878560]`, `"Region"` `[0x878870]`) — read
// off the function, not assumed from the class hierarchy.
FrameKind::LootButton => &["LootButton", "Button", "Frame", "Region"],
FrameKind::EditBox => &["EditBox", "Frame", "Region"],
FrameKind::StatusBar => &["StatusBar", "Frame", "Region"],
FrameKind::Slider => &["Slider", "Frame", "Region"],
FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"],
FrameKind::Model => &["Model", "Frame", "Region"],
// 4 deep: `PlayerModel` derives from `Model`, and `DressUpModel` (1969) and
// `TabardModel` (1977) derive from it in turn, for the roster's maximum depth of 5
// (wow-re `ui/scratch/widget-type-identity.md` §6).
FrameKind::PlayerModel => &["PlayerModel", "Model", "Frame", "Region"],
FrameKind::DressUpModel => &["DressUpModel", "PlayerModel", "Model", "Frame", "Region"],
FrameKind::TabardModel => &["TabardModel", "PlayerModel", "Model", "Frame", "Region"],
FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"],
FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"],
FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"],
FrameKind::SimpleHtml => &["SimpleHTML", "Frame", "Region"],
FrameKind::MovieFrame => &["MovieFrame", "Frame", "Region"],
FrameKind::GameTooltip => &["GameTooltip", "Frame", "Region"],
FrameKind::Minimap => &["Minimap", "Frame", "Region"],
}
}
fn chain_of(lua: &Lua, this: &Table) -> mlua::Result<&'static [&'static str]> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.arena
.frame(h)
.map_or(&["Frame", "Region"][..], |f| type_chain(f.kind)))
}
m.set(
"GetObjectType",
lua.create_function(|lua, this: Table| {
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
})?,
)?;
// Same four traps as the region twin (see `script::region`): case-insensitive whole-string,
// number 1 on a hit and nil on a miss, exactly one value either way, and a non-string
// non-number argument raises the reference's own `Usage:` text.
m.set(
"IsObjectType",
lua.create_function(|lua, (this, want): (Table, Value)| {
let chain = chain_of(lua, &this)?;
let want = match &want {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(n) => n.to_string(),
Value::Integer(i) => i.to_string(),
_ => {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"Usage: {who}:IsObjectType(\"TYPE\")"
)));
}
};
Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) {
Value::Number(1.0)
} else {
Value::Nil
})
})?,
)?;
// ── GetFrameType / IsFrameType: the FRAME-side spellings of the pair above (decision 2106) ──
//
// 1.12 registers the type-identity pair **twice, under two names**. The Region script
// (`CScriptRegion`) publishes `GetObjectType`/`IsObjectType` — that is the pair above, and its
// `IsObjectType` binding is `0x7a1290`. The *frame* script (`CSimpleFrameScript.cpp`,
// `__FILE__` `0x879504`) publishes `GetFrameType 0x773640` and `IsFrameType 0x773700` as well,
// and wow-5875-re carved both (`system/ui/scratch/item17-frameapi-batch1.md`, 76-entry
// registrar table verified at the bytes). `GetObjectType`/`IsObjectType` are what LATER
// clients kept; `GetFrameType`/`IsFrameType` are 1.12's own, and we shipped only the first
// pair. That is 1189's superset argument inverted — not an extra name we invented, a real one
// we were missing — and it cost the world map:
//
// Cartographer 2.02, LookNFeel.lua:368, inside OnEnable:
// if v:GetFrameType() == "Model" and not v:GetName() then self.playerModel = v end
//
// With the verb nil that line raised, AceAddon swallowed it, `self.playerModel` stayed nil,
// and the next `Cartographer_ChangeZone` → `SetAlpha` put a red script error on screen from
// `LookNFeel.lua:737` every time the map opened. FuBar's own `FuBar.lua:576`
// (`type(frame:GetFrameType()) ~= "string"`) is the same call.
//
// Both delegate to the same `type_chain` the `Object` pair reads: `GetFrameType` is
// `call [edx+0x1c]` — the identical per-class type-name slot `GetObjectType` reads — then
// `lua_pushstring`, one value, extra arguments ignored. `IsFrameType` walks the chain through
// `[eax+0x18]`, the same case-insensitive whole-string compare, and answers the NUMBER 1 or
// nil.
//
// **One edge is INFERRED and named rather than guessed silently:** wow-re's note glosses
// `IsFrameType`'s *absent-argument* branch as "pushes the frame's own typename (`call [eax+4]`
// GetName)", which is self-contradictory — a typename and a name are different slots — so the
// branch is not settled. We take the sibling's behaviour (the `Usage:` raise) pending a byte
// read. No caller in the 219-addon corpus, the director's AddOns, or the shipped FrameXML ever
// omits the argument: `JIM_toolbox/Config2/Pulse_Config.lua:118` is the corpus's only
// `IsFrameType` site and it passes `"Slider"`.
m.set(
"GetFrameType",
lua.create_function(|lua, this: Table| {
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
})?,
)?;
m.set(
"IsFrameType",
lua.create_function(|lua, (this, want): (Table, Value)| {
let chain = chain_of(lua, &this)?;
let want = match &want {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(n) => n.to_string(),
Value::Integer(i) => i.to_string(),
_ => {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"Usage: {who}:IsFrameType(\"TYPE\")"
)));
}
};
Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) {
Value::Number(1.0)
} else {
Value::Nil
})
})?,
)?;
m.set(
"GetParent",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let parent_id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.arena
.frame(h)
.and_then(|f| f.parent)
.map(|p| model.frame_id(p))
};
match parent_id {
Some(pid) => Ok(Value::Table(frame_wrapper(lua, pid)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── The four structure queries: GetChildren / GetNumChildren / GetRegions / GetNumRegions ──
//
// Registered bindings in 1.12 (`GetNumRegions 0x773e60`, `GetRegions 0x773f60`,
// `GetNumChildren 0x774080`, `GetChildren 0x774180` — wow-re `ui/ledger.tsv`, classified
// ORCHESTRATION: each marshals and delegates, with no fidelity math of its own).
//
// **Why these four are worth more than their size.** They are how an addon walks a frame it did
// not build, which is the whole basis of the "reskin the default UI" genre — and that genre is
// the top of the 1.12 popularity list. `pfUI.api.StripTextures` is the canonical shape:
//
// for _, v in ipairs({ frame:GetRegions() }) do
// if v.SetTexture then ... v:SetTexture(nil) end
// end
//
// With `GetRegions` absent that is `attempt to call method 'GetRegions' (a nil value)`, raised
// out of a library file every one of pfUI, pfQuest, pfQuest-turtle and ShaguDPS embeds — four
// separate top-20 addons dying on one missing verb, plus Questie on `GetChildren`.
//
// **Two lists, one walk each, in the arena's own order.** `Frame::children` is insertion order,
// which our own arena documents as the client's `+0x300` child-list order; `Frame::regions` is
// creation order. Neither is re-sorted here — a structure query reports the structure, and the
// draw order is `crate::order`'s separate concern.
//
// **A detached region is not in the list.** `Region:SetParent(nil)` sets `Region::detached`
// rather than removing the entry, because the owner's `Vec` is what `WidgetArena::destroy`
// frees from — but the client's re-link virtual `0x77fd10` with a null parent genuinely
// **unlinks from the old parent's draw layer and region list** (wow-re
// `widget-api-batch-benilla.md` Q7, VERIFIED), so a detached region must be invisible to both
// region verbs. Our flag is a representation choice; the list these report is the client's.
//
// **The count and the list come from ONE function** ([`regions_of`] / [`children_of`]), never
// two walks with the same filter written twice. `GetNumRegions` disagreeing with
// `#{GetRegions()}` is a bug an addon would hit as an off-by-one deep inside a loop it did not
// write, and the only way to make it impossible is to have one of them BE the other.
//
// **Settled by a wow-re §5 quartet** (`ui/scratch/widget-list-bindings.md`), which confirmed two
// of these choices and corrected a third:
//
// · **The order is link order, oldest first, and no reversal.** `CSimpleFrame` embeds a punned
// `TSList` header whose ctor sets `tail = &header`, `head = (&header)|1`, and BOTH linkers
// append at the TAIL (`0x76a750` for regions, `0x76aa20` for children — one caller each).
// Our insertion-order `Vec` is the same order. (wow-re's own `draw-order-law.md` said
// "tail→head"; that was a direction mislabel and was corrected in the same round.)
// · **Hidden children and regions are returned and counted** — a VERIFIED negative: the
// counting loops never load the visibility word at all.
// · **The TITLE REGION IS NOT RETURNED**, and that one corrected us. Both creation paths
// (`0x773910`, `0x769b79`) dispatch a vtable slot that is a bare `[this+0x9c] = parent` and
// never reach the linker `0x77fd10`, so a title region is not in the list this walks —
// corroborated by `Hide`/`Show` carrying an explicit extra `[frame+0xa8]` case *because* the
// list walk misses it. Ours is in `Frame::regions` (so the arena can free it), so
// [`regions_of`] filters it out.
// · **A Button's label (`+0x338`) and its four state textures ARE in the list** — they link
// through `0x77fd10` like any region, which is what ours do too.
// · **The return shape**: N separate values, never a table; a frame with none returns ZERO
// values (no `lua_newtable` in either body), and `GetNum*` returns one number — `0` when
// empty, never nil.
//
// The one thing still out of reach is not these bindings': Questie reads
// `({Minimap:GetChildren()})[9]` for the player arrow, and the §5 found why that index works —
// `CMinimap`'s ctor `0x4edbc0` builds NINE engine-owned `Model` children before the XML
// `<Frames>` descent, the ninth (`[Minimap+0x338]`) being the arrow `SetPlayerFacing 0x4eb8e0`
// writes. We create none of them, so index 9 is nil here until the minimap grows its engine
// children.
m.set(
"GetChildren",
lua.create_function(|lua, this: Table| {
let ids = children_of(lua, &this)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
out.push(Value::Table(frame_wrapper(lua, id)?));
}
Ok(mlua::MultiValue::from_vec(out))
})?,
)?;
m.set(
"GetNumChildren",
lua.create_function(|lua, this: Table| Ok(children_of(lua, &this)?.len()))?,
)?;
m.set(
"GetRegions",
lua.create_function(|lua, this: Table| {
let ids = regions_of(lua, &this)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
out.push(Value::Table(crate::script::region::region_wrapper(
lua, id,
)?));
}
Ok(mlua::MultiValue::from_vec(out))
})?,
)?;
m.set(
"GetNumRegions",
lua.create_function(|lua, this: Table| Ok(regions_of(lua, &this)?.len()))?,
)?;
// SetParent — the runtime reparent, per the byte-verified law (wow-re
// `ui/scratch/setparent-runtime-strata-level.md`; decision 1323). The binding half
// (`0x7a1550`): the parent argument is a frame table, a NAME string (`0x76c760`), or an
// explicit nil — an ABSENT argument is NOT the nil path (`0x6f3400` returns −1) and raises
// like an unresolvable name; the cycle guard is the binding's own inline ancestor walk and
// RAISES (`0x87cb14`), it does not silently no-op. The arena half is
// `reparent_begin`/`reparent_finish`, split so `OnHide` fires between them the way the
// reference's hide→show round-trip does — `fire_visibility_changes` reads each frame's live
// state for direction, so hide and show must be fired from their own phase, never one
// combined list.
m.set(
"SetParent",
lua.create_function(|lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let this = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a frame")),
};
let h = frame_handle_of(lua, &this)?;
let who = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string())
};
let parent_arg = it.next();
// `_G[name]` is read with no guard alive (`object::NamedTarget`), and **without**
// `$parent` expansion: `0x7a1550` calls `0x76c760` directly, and only the layout
// vtable's `0x76c700` runs the token.
let named = match &parent_arg {
Some(Value::String(s)) => Some(match s.to_str() {
Ok(n) => super::prefetch_named_target(lua, n.as_ref(), None),
Err(_) => super::NamedTarget::unreadable(),
}),
_ => None,
};
let new_parent = {
let model = lua.app_data_ref::<Model>().expect("model");
match &parent_arg {
// Explicit nil reparents to the screen root (the strata/level RESET path).
Some(Value::Nil) => None,
Some(Value::Table(t)) => Some(
decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied())
.ok_or_else(|| {
mlua::Error::runtime(format!(
"{who}:SetParent(): Couldn't find region named ''"
))
})?,
),
Some(Value::String(_)) => {
// `_G[name]` + the **Frame** tag check — the reference's `0x76c760`
// (`0x7a1550`'s NAME-string path) type-guards against `[0xcf0c10]`, the
// narrow Frame id, not `SetPoint`'s root `[0xcf0c3c]`. So a global naming
// a REGION fails here exactly as an absent one does.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = super::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied());
Some(hit.ok_or_else(|| {
mlua::Error::runtime(format!(
"{who}:SetParent(): Couldn't find region named '{}'",
nt.name
))
})?)
}
// Absent argument, or any other type: the reference never reaches the
// reparent slot (`0x87cb48` raise).
_ => {
return Err(mlua::Error::runtime(format!(
"{who}:SetParent(): Couldn't find region named ''"
)))
}
}
};
// The cycle guard raises — `"%s:SetParent(): Would create a loop parenting to %s"`
// (`0x7a177f`'s inline `+0x9c` walk, `newParent == self` included).
if let Some(np) = new_parent {
let model = lua.app_data_ref::<Model>().expect("model");
if np == h || model.arena.is_ancestor(h, np) {
let pname = model
.arena
.frame(np)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"{who}:SetParent(): Would create a loop parenting to {pname}"
)));
}
}
let hidden = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.reparent_begin(h, new_parent)
};
// None = the verified total no-op (same parent, `0x76ab20`): nothing runs, not even
// the layout touch.
let Some(hidden) = hidden else { return Ok(()) };
let was_visible = !hidden.is_empty();
event::fire_visibility_changes(lua, hidden);
let shown = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
// A real reparent moves the subtree's effective scale and anchors — a
// layout-gate input, and the scale is a measure-key input the subtree's
// FontStrings cannot name one by one (human-rate; the sweep walks once).
model.touch_layout();
model.touch_measure_all();
shown
};
event::fire_visibility_changes(lua, shown);
Ok(())
})?,
)?;
// Strata / level / scale / alpha
m.set(
"SetFrameStrata",
lua.create_function(|lua, (this, strata): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let s = strata_from_str(&strata)
.ok_or_else(|| mlua::Error::runtime(format!("unknown frameStrata '{strata}'")))?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_frame_strata(h, s);
Ok(())
})?,
)?;
m.set(
"GetFrameStrata",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let s = model.arena.frame(h).map(|f| f.strata).unwrap_or_default();
Ok(strata_name(s).to_string())
})?,
)?;
m.set(
"SetFrameLevel",
lua.create_function(|lua, (this, level): (Table, i64)| {
let h = frame_handle_of(lua, &this)?;
let lvl = level.clamp(0, i64::from(u16::MAX)) as u16;
// **A script level change carries no children** — the binding `0x774560` calls
// `set_frame_level 0x76a4f0` with `propagate=0` (wow-re `ui/ui.md`, default levels).
// Only the toplevel raise shifts a subtree. Stock `BonusActionButtonTemplate` is written
// for this: it raises the button +2 and then its cooldown +2 by hand, landing the sweep
// one level over the button — carrying the children made it three, over the
// cooldown-count text an addon hangs at button + 2 (decision 2189).
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_frame_level(h, lvl, false);
Ok(())
})?,
)?;
m.set(
"GetFrameLevel",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(i64::from(
model.arena.frame(h).map(|f| f.level).unwrap_or(0),
))
})?,
)?;
m.set(
"SetScale",
lua.create_function(|lua, (this, scale): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Effective-scale changes ride the propagation's own eps gate; the own-scale compare
// is the cheap superset (same own scale => no effective change is possible).
let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale);
model.arena.set_scale(h, scale);
if changed {
model.touch_layout();
// The owner's effective scale is in every descendant FontString's measure key.
model.touch_measure_all();
}
Ok(())
})?,
)?;
m.set(
"GetScale",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.scale).unwrap_or(1.0))
})?,
)?;
m.set(
"SetAlpha",
lua.create_function(|lua, (this, alpha): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
// The 1.12 API clamps to 0..1 — ref Lua leans on it (fade code that passes a 0..255
// alpha stays fully opaque until the value falls below 1/255).
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_alpha(h, alpha.clamp(0.0, 1.0));
Ok(())
})?,
)?;
m.set(
"GetAlpha",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.alpha).unwrap_or(1.0))
})?,
)?;
// Backdrop (backdrop-mechanism.md): SetBackdrop(table|nil) installs (or, with nil, tears down)
// the frame's tiled bg + 8-piece border plate. The two color setters tint the bg / all 8 border
// pieces (never the reverse — spec §4). The Lua-table SetBackdrop defaults both colors to WHITE
// (the ctor), so a caller must SetBackdropColor after to tint (the tooltip's OnLoad does).
m.set(
"SetBackdrop",
lua.create_function(|lua, (this, arg): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let bd = match arg {
Value::Nil => None,
Value::Table(t) => Some(backdrop_from_table(&t)?),
other => {
return Err(mlua::Error::runtime(format!(
"SetBackdrop: expected a table or nil, got {}",
other.type_name()
)))
}
};
let mut model = lua.app_data_mut::<Model>().expect("model");
match bd {
Some(b) => {
model.backdrops.insert(h, b);
}
None => {
model.backdrops.remove(&h);
}
}
Ok(())
})?,
)?;
// `GetBackdrop()` (`0x777370`; wow-re `ui/scratch/widget-api-batch-benilla.md` Q5) — five things
// a plausible implementation gets wrong, so each is spelled out with its reason:
//
// 1. It is a **reconstruction from the struct**, never the caller's table. The reference stores
// no Lua reference anywhere: `SetBackdrop` reads six keys into a fresh 0x68-byte struct at
// `frame+0x1ac` and drops the table, so `0x777426`–`0x7776b9` re-push every key from those
// fields. Handing back a stored clone would leak keys the reader never accepted and would
// keep stale values a later `SetBackdropColor` changed.
// 2. **No backdrop ⇒ ZERO Lua values, not `nil`** — the early bail is `xor eax,eax; ret`, which
// for a *return* path really is "no values" (contrast `binding_abi`'s note: the same two
// bytes after a `luaL_error` are unreachable boilerplate). Observable through the count,
// and it is the shape our `GetTitleRegion` will *not* have when it lands — that one pushes
// nil, i.e. one value. The client cannot distinguish "never set" from `SetBackdrop(nil)`.
// 3. **A partial `SetBackdrop` omits nothing on the way out.** Every `SetBackdrop` allocates a
// fresh struct (`0x777801`, ctor `0x77e5f0`), so a key the caller left out is a *ctor
// default* here, not an absent key and not the previous backdrop's value: `bgFile`/`edgeFile`
// `""`, `tileSize` 0, `edgeSize` **32**. Our `backdrop_from_table` already builds on
// `Backdrop::default()`, so this falls out — but only because `None` maps to `""` below
// rather than to nil.
// 4. **`tile` is the NUMBER `1`, or the key is ABSENT — never `true`/`false`.** The push is
// `0x3ff00000` (the double 1.0) on true and `lua_pushnil` on false, and `lua_settable` with a
// nil value creates no key (and *erases* one from a recycled table, which is why the nil is
// written rather than skipped). An addon reading `if backdrop.tile then` sees the same truth
// either way; one that round-trips the table into `SetBackdrop` is why the number matters,
// since `tile` there goes through a coercer that takes numbers.
// 5. The undocumented **in-place form** (`0x77740e`): `lua_type(L,2) == LUA_TTABLE` skips
// `lua_newtable` and fills arg 2, reusing an existing `insets` subtable rather than replacing
// it. Implemented — it is four lines, and an addon caching one table across frames would
// otherwise silently get a new one each call. A non-table arg 2 is ignored, not an error.
//
// No `bgColor`/`edgeColor`/`alpha` key exists: the two colors live in the struct but the reader
// never pushes them (`GetBackdropColor`/`GetBackdropBorderColor` are their only accessors).
m.set(
"GetBackdrop",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
// Copied out before a single Lua write: filling a *caller-supplied* table can run a
// `__newindex` metamethod, which can re-enter us and would panic on the app-data borrow.
let bd = {
let model = lua.app_data_ref::<Model>().expect("model");
model.backdrops.get(&h).cloned()
};
let Some(bd) = bd else {
return Ok(mlua::MultiValue::new()); // trap 2 — zero values, not nil
};
let t = match target {
Value::Table(t) => t,
_ => lua.create_table()?,
};
t.set("bgFile", bd.bg_file.as_deref().unwrap_or(""))?;
t.set("edgeFile", bd.edge_file.as_deref().unwrap_or(""))?;
t.set(
"tile",
if bd.tile {
Value::Number(1.0) // trap 4
} else {
Value::Nil // trap 4 — erases the key from a recycled table
},
)?;
t.set("tileSize", f64::from(bd.tile_size))?;
t.set("edgeSize", f64::from(bd.edge_size))?;
let insets = match t.get::<Value>("insets") {
Ok(Value::Table(existing)) => existing, // trap 5 — reuse, don't replace
_ => {
let fresh = lua.create_table()?;
t.set("insets", &fresh)?;
fresh
}
};
insets.set("left", f64::from(bd.insets.left))?;
insets.set("right", f64::from(bd.insets.right))?;
insets.set("top", f64::from(bd.insets.top))?;
insets.set("bottom", f64::from(bd.insets.bottom))?;
Ok(mlua::MultiValue::from_vec(vec![Value::Table(t)]))
})?,
)?;
m.set(
"SetBackdropColor",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = color;
}
Ok(())
})?,
)?;
m.set(
"SetBackdropBorderColor",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = color;
}
Ok(())
})?,
)?;
m.set(
"GetBackdropColor",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
Some(bd) => Ok((
bd.bg_color[0],
bd.bg_color[1],
bd.bg_color[2],
bd.bg_color[3],
)),
None => Ok((1.0, 1.0, 1.0, 1.0)),
}
})?,
)?;
m.set(
"GetBackdropBorderColor",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
Some(bd) => Ok((
bd.border_color[0],
bd.border_color[1],
bd.border_color[2],
bd.border_color[3],
)),
None => Ok((1.0, 1.0, 1.0, 1.0)),
}
})?,
)?;
// Mouse interaction (EnableMouse gates hit-testing; keyboard focus is out of scope)
m.set(
"EnableMouse",
lua.create_function(|lua, (this, enable): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_mouse_enabled(h, enable);
Ok(())
})?,
)?;
m.set(
"IsMouseEnabled",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.arena.is_mouse_enabled(h),
))
})?,
)?;
// EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in
// wow-re's frame-API carve, beside the mouse pair above.
//
// 8 corpus addons call this and were RAISING on it (`ColorPickerPlus:258`,
// `Dewdrop-2.0.lua:2021-2022` — which is in ~65 addons — `AckisRecipeList/ARLFrame.lua:1650`).
// The flag round-trips; **key delivery is not gated on it**, exactly as the wheel's flag
// shipped in 1198, because the machinery it would gate does not exist here yet.
//
// Why that is the right half rather than a silent stub, which this codebase has been bitten by
// three times (1203/1205/1211): the majority of the corpus calls pass **false**, and for those
// our behaviour is already the reference's — we deliver no keys to arbitrary frames either. The
// `true` callers want delivery we do not do, but they did not get it before this landed; they
// got a raise that killed the enclosing function. And per
// `frame-key-script-delivery.md` §3.2 being enabled genuinely is separable from having a
// handler, so the flag is a real piece of the model, not a placeholder for one.
m.set(
"EnableKeyboard",
lua.create_function(|lua, (this, enable): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_keyboard_enabled(h, enable);
Ok(())
})?,
)?;
m.set(
"IsKeyboardEnabled",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.arena.is_keyboard_enabled(h),
))
})?,
)?;
// `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag
// from `EnableMouse` in the reference and separate here (decision 1198).
//
// The flag is real and round-trips. **The dispatch is NOT gated on it yet, deliberately.**
// Our wheel dispatch keys off "does this frame carry an `OnMouseWheel` handler", walking up to
// the nearest ancestor that does — more permissive than the reference, which also requires the
// frame to be wheel-enabled so a scroll region can hand the wheel to the window behind it
// without tearing its handler out.
//
// Gating it today would break our own UI: 44 `OnMouseWheel` sites across 14 shipped files and
// **not one of them declares `enableMouseWheel`**, because the loader has never read that
// attribute. The condition to flip it is concrete rather than someday — teach the loader the
// attribute, declare it on those 44 sites, then gate. Until then this is a disclosed superset
// (1189's argument, pointed the other way), and the two corpus addons that stopped on the
// missing *method* are unblocked either way.
// EnableDrawLayer(layer) / DisableDrawLayer(layer) — Frame method table `0x878ec0`, entries
// `0x7755b0` / `0x775680`, between `IsToplevel` and `Show`. See [`crate::widget::Frame`]'s
// `disabled_layers` for what was read and why the mask lives on the frame.
//
// An unknown layer name is a NO-OP here rather than a raise. That is the honest state, not a
// decision: `draw_layer_from_str` is the same parser `SetDrawLayer` and `CreateTexture` use,
// and what the reference does with an unparseable name in THIS pair has not been read out of
// the binary. Both corpus callers (pfUI, MoveAnything) pass literals from the five-name set.
for (name, disable) in [("EnableDrawLayer", false), ("DisableDrawLayer", true)] {
m.set(
name,
lua.create_function(move |lua, (this, layer): (Table, Value)| {
let Some(l) = layer
.as_string()
.and_then(|s| s.to_str().ok().and_then(|s| draw_layer_from_str(&s)))
else {
return Ok(());
};
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(frame) = model.arena.frame_mut(h) {
let bit = 1u8 << l.index();
if disable {
frame.disabled_layers |= bit;
} else {
frame.disabled_layers &= !bit;
}
}
Ok(())
})?,
)?;
}
m.set(
"EnableMouseWheel",
lua.create_function(|lua, (this, enable): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_mouse_wheel_enabled(h, enable);
Ok(())
})?,
)?;
m.set(
"IsMouseWheelEnabled",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.arena.is_mouse_wheel_enabled(h),
))
})?,
)?;
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit4 — layout.md): the layout resolve
// keeps the frame's assembled rect inside the window, size preserved. GameTooltip frames
// default true by construction (widget::Frame::clamped_to_screen — decision 0352).
m.set(
"SetClampedToScreen",
lua.create_function(|lua, (this, clamp): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.is_clamped_to_screen(h) != clamp;
model.arena.set_clamped_to_screen(h, clamp);
if changed {
model.touch_layout();
}
Ok(())
})?,
)?;
m.set(
"IsClampedToScreen",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.arena.is_clamped_to_screen(h),
))
})?,
)?;
// Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks
// the resolved rect by these four before testing the cursor, and nothing else reads them, so a
// frame's geometry/draw/anchor answers are unchanged. The ref sets them wherever a button's
// frame is larger than its art (the micro buttons' 18 px empty header).
m.set(
"SetHitRectInsets",
lua.create_function(
|lua, (this, left, right, top, bottom): (Table, f32, f32, f32, f32)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_hit_rect_insets(h, [left, right, top, bottom]);
Ok(())
},
)?,
)?;
m.set(
"GetHitRectInsets",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let i = model.arena.hit_rect_insets(h);
Ok((i[0], i[1], i[2], i[3]))
})?,
)?;
Ok(())
}
/// Parse a Lua `SetBackdrop` table into a [`Backdrop`] (backdrop-mechanism.md §1). The keys read —
/// exactly the compiled reader's set — are `bgFile`/`edgeFile` (strings), `tile` (boolean, default
/// false), `tileSize`/`edgeSize` (numbers; a missing number leaves the ctor default: tileSize 0,
/// edgeSize 32), and `insets{left,right,top,bottom}` (numbers, each default 0). A non-string file or
/// non-number size is treated as absent (the client's per-key type gate). Colors stay the ctor
/// white — SetBackdrop never reads a color key; the color setters do that.
fn backdrop_from_table(t: &Table) -> mlua::Result<Backdrop> {
let mut bd = Backdrop::default();
if let Ok(Value::String(s)) = t.get::<Value>("bgFile") {
bd.bg_file = Some(s.to_str()?.to_string());
}
if let Ok(Value::String(s)) = t.get::<Value>("edgeFile") {
bd.edge_file = Some(s.to_str()?.to_string());
}
// `tile` — Lua truthiness (nil/false ⇒ false; a missing key reads as Nil).
bd.tile = !matches!(
t.get::<Value>("tile").unwrap_or(Value::Nil),
Value::Nil | Value::Boolean(false)
);
if let Ok(Value::Number(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
}
if let Ok(Value::Number(n)) = t.get::<Value>("edgeSize") {
bd.edge_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("edgeSize") {
bd.edge_size = n as f32;
}
if let Ok(Value::Table(ins)) = t.get::<Value>("insets") {
let num = |k: &str| -> f32 {
match ins.get::<Value>(k) {
Ok(Value::Number(n)) => n as f32,
Ok(Value::Integer(n)) => n as f32,
_ => 0.0,
}
};
bd.insets = Insets {
left: num("left"),
right: num("right"),
top: num("top"),
bottom: num("bottom"),
};
}
Ok(bd)
}
fn strata_name(s: Strata) -> &'static str {
match s {
Strata::World => "WORLD",
Strata::Background => "BACKGROUND",
Strata::Low => "LOW",
Strata::Medium => "MEDIUM",
Strata::High => "HIGH",
Strata::Dialog => "DIALOG",
Strata::Fullscreen => "FULLSCREEN",
Strata::FullscreenDialog => "FULLSCREEN_DIALOG",
Strata::Tooltip => "TOOLTIP",
Strata::Blizzard => "BLIZZARD",
}
}
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
Ok(())
}
/// This frame's child frames as stable ids, in the arena's insertion order — the one walk
/// `GetChildren` and `GetNumChildren` share (see their comment on why they must share it).
fn children_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some(children) = model.arena.frame(h).map(|f| f.children.clone()) else {
return Ok(Vec::new());
};
Ok(children.into_iter().map(|c| model.frame_id(c)).collect())
}
/// This frame's live regions as stable ids, in creation order — **the title region and detached
/// ones excluded** — the one walk `GetRegions` and `GetNumRegions` share.
///
/// Both exclusions are the client's list, not tidiness, and both are VERIFIED (wow-re
/// `ui/scratch/widget-list-bindings.md`, §5 quartet):
///
/// - **Detached.** `SetParent(nil)` reaches `0x77fd55` → the removal virtual `0x76a7f0`, which
/// unlinks the node and frees it, and then skips every re-link. We keep the entry only so the
/// arena can still free the slot; reporting it would hand an addon an object the reference never
/// would, and `StripTextures`-shaped code would "strip" a region already off the screen.
/// - **The title region.** Its two creation paths (`0x773910`, `0x769b79`) dispatch a vtable slot
/// that is a bare `[this+0x9c] = parent` and never reach the linker at all, so it was never in
/// this list — corroborated by `Hide`/`Show` needing an explicit extra `[frame+0xa8]` case
/// *because* the walk misses it. Ours is in [`crate::widget::Frame::regions`] so that
/// `WidgetArena::destroy` still frees it, which makes that a representation detail this walk
/// must not leak.
///
/// A Button's label and its four state textures are deliberately NOT excluded: they link through
/// `0x77fd10` like any other region in the reference, and ours go through `create_region` like any
/// other region here.
fn regions_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some((regions, title)) = model
.arena
.frame(h)
.map(|f| (f.regions.clone(), f.title_region))
else {
return Ok(Vec::new());
};
let live: Vec<_> = regions
.into_iter()
.filter(|r| Some(*r) != title)
.filter(|r| model.arena.region(*r).is_some_and(|reg| !reg.detached))
.collect();
Ok(live.into_iter().map(|r| model.region_id(r)).collect())
}
/// One backdrop colour channel, the reference's own conversion (`SetBackdropColor 0x777d30` /
/// `SetBackdropBorderColor 0x7780d0` — instruction-identical bar the delegate; wow-re
/// `scratch/numeric-arg-coercion-law.md` Q4, VERIFIED).
///
/// **r/g/b are UNGATED and alpha is not**, and that asymmetry is the whole of this function:
///
/// · r/g/b (`lua` indices 2/3/4) go through a bare `lua_tonumber`, so a `nil` channel is `0.0`
/// and the call completes — no raise. ShaguTweaks `helpers.lua:248` passes `color.r` from a
/// table that does not always have one, and benilla raised on it, killing the addon.
/// · alpha (index 5) is `lua_isnumber`-gated at `0x778227` with `1.0f` staged at `0x778220`, so a
/// **missing or nil** alpha is OPAQUE. Reading the asymmetry the other way — nil alpha as `0.0`
/// like its neighbours — would turn every one of those borders transparent, which is a worse
/// bug than the raise it replaced and would look like a rendering fault.
///
/// Then the reference clamps to `[0,1]` with **NaN landing on 1.0** (the compare's unordered arm
/// takes the high clamp), and quantizes `×255 + 0.5` through `__ftol` — round-half-up, not the
/// bare truncate `chat-window-record.md`'s colour path uses. The field is a packed `0xAARRGGBB`
/// byte quad, so nothing finer survives the store and we quantize on the way in rather than
/// pretend to a precision `GetBackdropColor` could not read back.
fn backdrop_channel(lua: &Lua, v: Option<Value>, gated: bool) -> f32 {
let x = if gated {
// `lua_isnumber` — a number or a numeric string; anything else takes the staged default.
v.and_then(|v| lua.coerce_number(v).ok().flatten())
.unwrap_or(1.0)
} else {
crate::script::binding_abi::coerced_number(lua, v)
};
let clamped = if x.is_nan() { 1.0 } else { x.clamp(0.0, 1.0) };
// `×255 + 0.5` then `__ftol` (truncate toward zero) = round-half-up over `[0,1]`.
let byte = (clamped * 255.0 + 0.5) as u8;
f32::from(byte) / 255.0
}
/// The four channels of a backdrop colour setter, in the reference's own gating (see
/// [`backdrop_channel`]). Takes the raw stack so a missing argument and an explicit `nil` reach
/// the same place they do there.
fn backdrop_color(lua: &Lua, args: mlua::MultiValue) -> [f32; 4] {
let a: Vec<Value> = args.into_iter().collect();
[
backdrop_channel(lua, a.first().cloned(), false),
backdrop_channel(lua, a.get(1).cloned(), false),
backdrop_channel(lua, a.get(2).cloned(), false),
backdrop_channel(lua, a.get(3).cloned(), true),
]
}