the addon arc: the AddOns folder opens

- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
@@ -46,6 +46,29 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
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(
@@ -101,6 +124,47 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
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.**
/// They are in the base map, so *every* frame type has them, and the delivery mechanism is
/// `EnableKeyboard` + the hit-test root's **kind-0/kind-1 index** (wow-re
/// `scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, -1)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1) with the frame-script pre-gate `0x76bba0` consuming the key
/// ahead of the whole binding dispatch (`scratch/keybinding-dispatch-law.md` §1). benilla has
/// none of that: no `EnableKeyboard`, no keyboard index, and its keys go straight to the focused
/// EditBox (whose C++ override replaces these slots anyway — RF-0082 §2). Accepting the names
/// before the index exists is the exact silent-no-op trap above; the ordering/consumption law is
/// dispatched to wow-re.
/// * **`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` ·
/// `OnUpdateModel` · `OnAnimFinished` · `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).
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
.iter()
@@ -187,3 +251,53 @@ fn create_region(
}
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();
}
}
@@ -333,6 +333,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(model.arena.is_mouse_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.
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(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).
@@ -255,7 +255,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
/// The frame's effective scale, ε-guarded (a zero would poison the local-unit division).
fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
pub(super) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
let s = model
.arena
.frame(h)
@@ -0,0 +1,316 @@
//! Frame method-table cluster: the movable/resizable window family — `SetMovable`/`IsMovable`,
//! `StartMoving`/`StopMovingOrSizing`, `SetUserPlaced`/`IsUserPlaced`, `SetResizable`/
//! `IsResizable`. Split out of [`super`] purely for size — see its module doc for the shared
//! id/handle plumbing and method-table wiring.
//!
//! This is "drag this bar somewhere", and after events it is the most-used addon frame feature.
//! The canonical idiom is four lines, of which only the two engine verbs were missing here:
//!
//! ```lua
//! f:SetMovable(true)
//! f:RegisterForDrag("LeftButton")
//! f:SetScript("OnDragStart", function() this:StartMoving() end)
//! f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
//! ```
//!
//! and that layering is the reference's own: `RegisterForDrag 0x776d60` + the threshold-gated
//! `OnDragStart`/`OnDragStop` pair (wow-re `cursor-dragdrop-payload.md` §9, our decision 0216 §3 —
//! [`crate::script::cursor`], dispatched from [`crate::script::pointer`]) are a **separate**
//! system from the engine move below. The gesture decides *when* the handlers run; this module
//! owns only what happens between `StartMoving` and `StopMovingOrSizing`.
//!
//! ## The mechanism (wow-re `system/ui`, VERIFIED unless said otherwise)
//!
//! The three flags are bits of the frame's flag word `[frame+0xb4]`, all written by the one
//! generic setter `0x76a3c0` (`if (v) flags |= mask; else flags &= ~mask;` — no calls, no side
//! effects): **movable `0x100`** (`SetMovable 0x776420` / `IsMovable 0x7764d0`), **resizable
//! `0x200`** (`SetResizable 0x776590` / `IsResizable 0x776640`), **userPlaced `0x1000`**
//! (`SetUserPlaced 0x776a50` / `IsUserPlaced 0x776b40`). The XML attributes land on the same
//! setter (`movable="true"` → `0x76a3c0(0x100)`, `resizable="true"` → `0x200` — wow-re
//! `rf24-framexml-loader.md`), which is why the loader now *calls* these instead of warning.
//!
//! `StartMoving 0x776700` tests the movable bit and raises when it is clear, then enters the drag
//! at `0x7652b0`, which `Raise()`s the frame, **sets the userPlaced bit itself**, and records the
//! whole drag in ONE root-side slot: `root+0xcfc` = the dragged frame, `+0xd00` = the drag type
//! (Lua `StartMoving` passes **3**), `+0xd08/+0xd0c` = the cursor at the last sample. One slot
//! means one drag: there is no second frame to move, which is why `StartMoving` carries a
//! `root+0xcfc != 0` guard at `7767e8` and why `StopMovingOrSizing 0x776990` compares
//! `[root+0xcfc] == self` before clearing.
//!
//! Movement is a **pump**, run from the mouse-move handler: `0x7655b0` (a diffed-bit-exact
//! PRIMITIVE) takes `dx = x − root+0xd08`, `dy = y − root+0xd0c`, and — only if either is
//! non-zero — applies them and re-centers the sample. The application (`0x76a660` → the 9-case
//! `geo_768710`, also a diffed-bit-exact PRIMITIVE) selects on the 3×3 region the drag grabbed:
//! the eight edge/corner cases resize, and **case 4, the plain move, accumulates the scaled delta
//! straight into the anchor's offsets in place** — `xOffset += dx/scale`, `yOffset += dy/scale`
//! (`CAnchor+0x4`/`+0x8`, wow-re `frame-model.md`) — then invalidates the layout (`0x7680e0`).
//!
//! Two consequences worth stating, because both are easy to get wrong:
//!
//! - **Nothing is written at stop.** `0x765640` is a fifteen-byte state clear (`root+0xcfc = 0;
//! root+0xd00 = 0`) — there is no `SetPoint`, no `ClearAllPoints`, no rewrite of the anchor set
//! to a single point. The frame keeps where it was dragged to because the *anchors themselves*
//! were being moved the whole time, so `GetPoint()` afterwards reports the frame's own point
//! with the dragged offsets — exactly what an addon then saves.
//! - **The mouse button does not end a Lua move.** The mouse-up handler `0x766420` auto-stops
//! only drag types 1 and 2 (the built-in title-bar/edge drags); type 3 — the one `StartMoving`
//! starts — must be stopped by an explicit `StopMovingOrSizing`. That is why the idiom above
//! wires `OnDragStop` at all.
//!
//! ## What benilla does NOT take from that, and why
//!
//! - **All anchors translate, not just one.** wow-re's note reads the case-4 accumulate as hitting
//! a single anchor record; it is their composition of verified pieces, not a recorded finding,
//! and a one-anchor translation would *deform* a frame stretched between two anchors instead of
//! moving it. Every movable frame in practice carries exactly one anchor, where the two readings
//! are identical, so translating the whole set is the same behaviour everywhere it is observable
//! and the sane behaviour where it is not.
//! - **No clamp rebate.** `geo_768710` rebates the residual delta when the clamp pushes back
//! (`SetClampedToScreen`); we do not, so dragging a clamped frame into the screen edge and back
//! out can lag the cursor. Named, not hidden — the rebate is one of the two halves of that
//! primitive we have not transcribed (the other is the eight resize cases).
//! - **The `Raise()` is wired** (it used to be listed here as a deferral): `0x7652b0` raises the
//! dragged frame before it takes the drag slot, and [`start_moving`] does the same through
//! [`super::toplevel::raise`]. Like every raise it is occlusion-gated and confined to the frame's
//! own stratum, so grabbing a movable window that overlaps nothing still changes no draw order.
//! - **No resize drag.** `StartSizing 0x776830` and the eight resize cases are not built.
//! `SetResizable`/`IsResizable` are the flag only, and `StopMovingOrSizing` — which is one verb
//! for both halves in the reference too — already covers the stop side of a resize that does
//! not exist yet.
use mlua::{Lua, Table};
use crate::script::Model;
use crate::widget::FrameHandle;
use super::frame_handle_of;
use super::layout_methods::eff_scale;
/// The one in-flight `StartMoving` — the client's `root+0xcfc`/`+0xd08`/`+0xd0c` drag slot, held
/// in [`Model::moving`] beside the drag gesture that normally drives it.
///
/// `sample` is the cursor at the last pump, not at the grab: [`advance_move`] applies the
/// *difference* and re-centers, which is `0x7655b0`'s own shape. Nothing else is needed, because
/// the position being edited lives in the frame's anchors rather than here.
///
/// Pruning on frame destroy: the same boat as [`Model::drag_registered`] — nothing in this engine
/// destroys a frame yet; [`advance_move`] ends a move whose frame went away regardless.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FrameMove {
/// The frame being moved (`root+0xcfc`).
frame: FrameHandle,
/// The cursor position at the last pump (`root+0xd08`/`+0xd0c`), UI px, y-up.
sample: (f32, f32),
}
/// Populate `m`'s movable/resizable methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetMovable(flag) / IsMovable() — flag word bit 0x100 (`0x776420`/`0x7764d0` through the
// generic setter `0x76a3c0`). A pure flag write: it moves nothing, and clearing it mid-drag
// stops nothing either (the setter has no side effects — wow-re ledger `0x76a3c0`); it is the
// guard `StartMoving` tests, and the XML `movable="true"` attribute writes the same bit.
// mlua's bool conversion is Lua truthiness, so the corpus's `SetMovable(1)` reads as true —
// matching the reference binding's own `toboolean` marshal.
m.set(
"SetMovable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
set_flag(lua, &this, Flag::Movable, flag)
})?,
)?;
m.set(
"IsMovable",
lua.create_function(|lua, this: Table| get_flag(lua, &this, Flag::Movable))?,
)?;
// SetResizable(flag) / IsResizable() — bit 0x200 (`0x776590`/`0x776640`), `SetMovable`'s exact
// shape. Stored and reported; no resize DRAG is built (see the module doc).
m.set(
"SetResizable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
set_flag(lua, &this, Flag::Resizable, flag)
})?,
)?;
m.set(
"IsResizable",
lua.create_function(|lua, this: Table| get_flag(lua, &this, Flag::Resizable))?,
)?;
// SetUserPlaced(flag) / IsUserPlaced() — bit 0x1000 (`0x776a50`/`0x776b40`), the client's "the
// user placed this frame; persist its position across sessions" bit, and the one setter of the
// three that is GUARDED: `776adb: test ah,0x3` refuses unless the frame is movable OR
// resizable, raising the third of this family's error strings.
//
// benilla stores and reports the flag and **nothing consumes it yet**: persisting a frame's
// position belongs with the layout cache, not with the drag that moved it, and building a
// private position store here would put a second one beside the one that should own it. The
// overwhelmingly common addon shape — set the bit, then save your own coordinates — is
// unaffected either way.
m.set(
"SetUserPlaced",
lua.create_function(|lua, (this, flag): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let ok = model
.arena
.frame(h)
.is_some_and(|f| f.movable || f.resizable);
if !ok {
return Err(not_flagged(&model, h, "movable or resizable"));
}
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = flag;
}
Ok(())
})?,
)?;
m.set(
"IsUserPlaced",
lua.create_function(|lua, this: Table| get_flag(lua, &this, Flag::UserPlaced))?,
)?;
// StartMoving() — enter the drag (`0x776700` → `0x7652b0`). Raises on a frame that is not
// movable, sets the userPlaced bit like the reference's drag-start does, and takes the one
// drag slot; a second call while a move is in flight is refused, since there is only ever one
// slot to take.
m.set(
"StartMoving",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
start_moving(&mut model, h)
})?,
)?;
// StopMovingOrSizing() — leave the drag (`0x776990` → `0x765640`): a state clear, and only
// when `self` IS the frame in the slot. Nothing is written back — the pump has been moving the
// anchors all along, so the frame simply keeps the last position it was dragged to. Harmless
// when nothing is moving, or when something else is (a double call, an addon that also wires
// it to OnMouseUp, an OnDragStop that never had a StartMoving).
m.set(
"StopMovingOrSizing",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.moving.is_some_and(|mv| mv.frame == h) {
model.moving = None;
}
Ok(())
})?,
)?;
Ok(())
}
/// Which bit of the frame's flag word a `Set*`/`Is*` pair addresses (`[frame+0xb4]`, written by
/// `0x76a3c0`) — the three benilla models, as arena fields rather than a packed word.
#[derive(Clone, Copy)]
enum Flag {
/// `0x100`.
Movable,
/// `0x200`.
Resizable,
/// `0x1000`.
UserPlaced,
}
fn set_flag(lua: &Lua, this: &Table, which: Flag, value: bool) -> mlua::Result<()> {
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) {
match which {
Flag::Movable => f.movable = value,
Flag::Resizable => f.resizable = value,
Flag::UserPlaced => f.user_placed = value,
}
}
Ok(())
}
fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result<bool> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).is_some_and(|f| match which {
Flag::Movable => f.movable,
Flag::Resizable => f.resizable,
Flag::UserPlaced => f.user_placed,
}))
}
/// The family's refusal, named frame and all — the reference's own shape (it formats the frame's
/// `GetName()`, substituting a literal when the frame is anonymous).
///
/// **The text is OURS.** wow-re records the guards and the three `.rdata` addresses
/// (`0x879810` StartMoving, `0x879828` StartSizing, `0x879844` SetUserPlaced) but not the strings
/// at them — only the truncated IDA symbol stubs `aFrameSIsNotMov` / `aFrameSIsNotRes` /
/// `aFrameSIsNotM_0`, which is what this wording is shaped after. Nothing should match on it.
fn not_flagged(model: &Model, h: FrameHandle, want: &str) -> mlua::Error {
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<anonymous>".into());
mlua::Error::runtime(format!("Frame {who} is not {want}"))
}
/// `StartMoving()`'s body (`0x776700` → `0x7652b0`): refuse a frame that is not movable, refuse a
/// second move while one is in flight (there is one drag slot), **raise**, then take the slot —
/// setting the userPlaced bit, which the reference's drag-start does itself rather than leaving to
/// `SetUserPlaced`.
fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
if !model.arena.frame(h).is_some_and(|f| f.movable) {
return Err(not_flagged(model, h, "movable"));
}
// `7767e8`'s `root+0xcfc != 0` guard. What the reference does past *not starting a second
// move* is not recorded; refusing silently is the reading that cannot invent behaviour.
if model.moving.is_some() {
return Ok(());
}
// The drag-start raise — `0x7652b0` @`0x7652d7`, before it sets the userPlaced bit and records
// the drag slot, which is the order kept here. Grabbing a window brings it forward; the worker
// supplies the toplevel gate (a non-toplevel movable frame raises nothing).
super::toplevel::raise(model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
});
Ok(())
}
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
/// crosses no boundary at all, so a move parked behind that return would only advance when the
/// cursor happened to leave the frame).
///
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
/// writing to a dead handle.
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
let Some(mv) = model.moving else { return };
if model.arena.frame(mv.frame).is_none() {
model.moving = None;
return;
}
let (dx, dy) = (pos.0 - mv.sample.0, pos.1 - mv.sample.1);
if dx == 0.0 && dy == 0.0 {
return;
}
// Offsets are LOCAL units — the resolver multiplies them by the frame's own layoutScale
// (`anchor_resolve_x`), so the cursor's screen delta divides by it on the way in, exactly the
// `dx/scale` of `geo_768710`'s case 4.
let inv = 1.0 / eff_scale(model, mv.frame);
let (dx, dy) = (dx * inv, dy * inv);
if let Some(input) = model.layout_inputs.get_mut(&mv.frame) {
// Translating the whole set, not slot 0 — see the module doc. An anchorless frame is not
// resolvable and so is not on screen to drag; it simply does not move.
if !input.anchors.is_empty() {
for a in &mut input.anchors {
a.x_off += dx;
a.y_off += dy;
}
// The one mutation path every layout setter shares — the tier-1 epoch, never the
// solver's arrays (see [`super::layout_methods`]'s mutate-only-on-change law; a zero
// delta returned above, so this write always moved something).
model.touch_layout();
}
}
model.moving = Some(FrameMove { sample: pos, ..mv });
}
@@ -0,0 +1,255 @@
//! Frame method-table cluster: the `toplevel` flag and the **raise law** — `SetToplevel`/
//! `IsToplevel`, `Raise`/`Lower`, and the engine-side worker every raise trigger runs. Split out of
//! [`super`] beside [`super::movable`], whose flag word this shares.
//!
//! This is "bring the window I just opened to the front". 82 corpus addons declare `toplevel=` in
//! XML and eleven call `SetToplevel` from Lua; our own shipped windows declare it on thirteen
//! frames. Until this module it was parsed and thrown away with a warn-once, so a benilla dialog
//! opened behind another one stayed behind it.
//!
//! ## The law (wow-re `system/ui/scratch/toplevel-raise.md`, §5 fan-out 2026-07-21 — VERIFIED)
//!
//! The flag is **`[frame+0xb4]` bit `0x1`**, written by the same pure bit-setter `0x76a3c0` as
//! `movable` (`0x100`) and `resizable` (`0x200`): `if (v) flags |= mask; else flags &= ~mask;`, no
//! calls, no list touch. XML `toplevel` lands there at `0x7698ec`; Lua `SetToplevel 0x775440` at
//! `0x7754d4`. **Setting the flag raises nothing** — it only marks the frame.
//!
//! The raise itself is `CSimpleTop::Raise(frame, force) 0x7650f0`, reached from
//! `CSimpleFrame::Raise 0x76a5b0` = `this->root->Raise(this, force = 1)`:
//!
//! 1. **Resolve to the nearest TOPLEVEL self-or-ancestor** up `+0x9c` (`0x7650fb..0x765135`).
//! No toplevel in the chain ⇒ **total no-op**. So the gate lives in the worker, not the callers:
//! every trigger may hand it an arbitrary frame, and the frame that visibly moves may be an
//! *ancestor* of the one that was clicked or shown.
//! 2. **`force != 0` ⇒ recompute the OVERLAPPED bit** (`0x76513e..0x765290`): resolve the frame's
//! pending layout, then scan its stratum bucket from **its own level upward** for a frame that is
//! not itself, is not one of its descendants (`is_descendant 0x767010`), and whose screen rect
//! intersects non-emptily. The answer is stored as `+0xb4` bit `0x10`.
//! 3. **The raise, gated on that bit** (`0x7651ac..0x7651e7`): `level_compact 0x764eb0` renumbers the
//! stratum's occupied levels contiguously into `[0, count)`, then
//! `set_frame_level(bucket->count, propagate = 1)`.
//!
//! Three consequences the tests pin: a raise is **`level := top occupied level + 1`**, not a list
//! reshuffle; it is **occlusion-gated** — `Raise()` on a frame that overlaps nothing changes nothing
//! at all; and it **never changes the stratum** (`0x7650f0` never writes `+0xc0`), so a LOW frame can
//! never be lifted over a MEDIUM one by any number of raises. `propagate = 1` shifts same-strata
//! children by the **same delta** (`0x76a4f0` @`0x76a58a`–`0x76a595`), so the raised subtree keeps
//! its internal order; cross-strata children are untouched.
//!
//! ## How that maps onto benilla's order model, clause by clause
//!
//! Our draw order is a derived total order over the arena ([`crate::order`]), not a materialized
//! array of per-stratum buckets of intrusive level lists. Every clause above still lands, but two
//! of them land as *derivations* and one is deliberately not stored:
//!
//! - **"the stratum bucket"** = the effective-visible frames whose `strata` matches. Bucket
//! membership is exactly the client's link-gate: a frame is in a level list only while
//! effectively visible, which is the same condition [`crate::widget::WidgetArena::resequence_to_tail`]
//! already mirrors for the link stamp.
//! - **"raise within the strata"** = a **frame-level bump**, and it is the honest mapping rather
//! than a re-stamp: the binary writes `+0xc4` through `set_frame_level 0x76a4f0`, and level is the
//! term *above* the link stamp in our key, so a bump lifts the frame over every same-strata frame
//! regardless of when each was last shown. The arithmetic is pinned in
//! `tests/toplevel.rs::a_raise_is_top_occupied_level_plus_one_after_compaction`.
//! - **`level_compact`** is [`crate::widget::WidgetArena::compact_levels`] — an order-preserving
//! renumber of the visible frames in one stratum. It changes no draw order by itself; it is what
//! keeps `level := max + 1` from ratcheting upward for the length of a session.
//! - **The OVERLAPPED bit is computed, never stored** — and that is *exact*, not a shortcut. The bit
//! is written only by `0x7650f0` and the per-tick pass `0x7657d0`, and read only by `0x7650f0`,
//! which with `force = 1` recomputes it immediately before reading it. The stored value is
//! therefore observable **only** through the one `force = 0` call site, `0x764a20`
//! (bucket-invalidate), which rides an already-computed bit. benilla has no bucket-invalidate
//! call — every trigger we have is a `force = 1` one — so the per-tick maintenance `0x7657d0`
//! would be state nothing could read. A local `bool` per raise is bit-for-bit what `force = 1`
//! does. **If a `force = 0` trigger is ever built, the bit has to become a field and `0x7657d0` a
//! per-tick pass; there is no other clause of this law that is unmodelled.**
//!
//! ## The screen rects the gate reads
//!
//! Step 2 needs rects, which live on [`Model::resolved`], not on the arena. `0x7650f0` starts by
//! resolving the frame's *own* pending layout (`0x76513e`: `if (T->+0x60 & 1) …0x7680e0`) and then
//! reads every other frame's cached rect as-is; [`raise`] does the same thing with the one resolve
//! we have — [`crate::script::UiScript::resolve_layout`], whose epoch gate makes it a `u64` compare
//! on the overwhelmingly common quiet call (showing a frame writes no layout input, so a Show-driven
//! raise never pays a solve unless the caller moved something in the same tick). Our solve covers
//! hidden frames too, so a frame being shown for the first time already has a rect to test. A frame
//! with **no** resolved rect (anchorless, so not on screen) overlaps nothing and raises nothing.
//!
//! ## Triggers — the census, and which ones benilla wires
//!
//! wow-re's complete rel32 + absolute-dword census finds six call sites. Ours:
//!
//! | site | what it is | benilla |
//! |---|---|---|
//! | `0x775b0a` (Lua `Frame:Raise()`) | the explicit script call | [`install`] |
//! | `0x76aeec` in `effective_visible_show 0x76ae10` | **the Show trigger** | [`raise_on_show`], run from [`crate::script::event::fire_visibility_changes`] |
//! | `0x7652d7` in begin-move `0x7652b0` | drag start | [`super::movable`]'s `start_moving` |
//! | `0x766392` in the mouse-down handler `0x7662c0` | **the click trigger** | **not wired** — the pointer path is another concern's; the hook is one call in `pointer.rs`'s button-down arm |
//! | `0x764a4c` (`force = 0`) | re-applies an already-computed bit `0x10` | no counterpart (see the OVERLAPPED note above) |
//! | `0x7650d6` | dead — zero references binary-wide | — |
//!
//! **`Frame:Lower()` is inert in build 5875** and inert here: `0x775b20` → `0x76a5c0` → `0x7652a0`,
//! which is `xor eax,eax; ret 4` — a verified no-op stub whose frame argument is never read.
use mlua::{Lua, Table, Value};
use crate::layout::Rect;
use crate::script::{Model, UiScript};
use crate::widget::FrameHandle;
use super::frame_handle_of;
/// Populate `m`'s toplevel/raise methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetToplevel(flag) / IsToplevel() — flag word bit 0x1 (`0x775440`/`0x76a3c0`). A pure flag
// write; the raise is entirely separate. The argument is an **optional** boolean defaulting to
// TRUE (`0x775440` marshals it that way) — unlike `SetMovable`'s, so this takes a `Value` and
// reads absence as true rather than leaning on mlua's nil-is-false bool conversion.
m.set(
"SetToplevel",
lua.create_function(|lua, (this, flag): (Table, Option<Value>)| {
let on = match flag {
None => true,
Some(v) => !matches!(v, Value::Nil | Value::Boolean(false)),
};
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_toplevel(h, on);
Ok(())
})?,
)?;
m.set(
"IsToplevel",
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.is_toplevel(h))
})?,
)?;
// Raise() — `0x775a50` → `0x76a5b0` → `0x7650f0(this, force = 1)`. Note it is legal on any
// frame: the worker substitutes the nearest toplevel self-or-ancestor and does nothing at all
// when there is none, so an addon calling it on a plain frame gets the reference's silence, not
// an error.
m.set(
"Raise",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
raise(&mut model, h);
Ok(())
})?,
)?;
// Lower() — `0x775b20` → `0x76a5c0` → `0x7652a0` = `xor eax,eax; ret 4`. A VERIFIED no-op stub
// in build 5875 (the frame argument is never read, and the call is direct, so this is
// unconditionally what runs). Present because absence is a different behaviour — a nil-call
// error where the reference silently does nothing.
m.set("Lower", lua.create_function(|_, _this: Table| Ok(()))?)?;
Ok(())
}
/// `CSimpleTop::Raise(frame, force = 1)` — `0x7650f0`, the worker every trigger runs. Returns
/// whether a toplevel self-or-ancestor was found at all (the binary's `bool` return); a `true` with
/// nothing moved is the occlusion gate declining, which is the common and correct case.
///
/// The three numbered steps of the module doc, in order.
pub(in crate::script) fn raise(model: &mut Model, frame: FrameHandle) -> bool {
// 1. The nearest TOPLEVEL self-or-ancestor. None ⇒ total no-op.
let Some(t) = nearest_toplevel(model, frame) else {
return false;
};
// 2. force = 1: resolve the pending layout, then recompute OVERLAPPED (a local — see the
// module doc's note on why storing the bit would be unobservable state).
// Any `OnSizeChanged` that resolve produces is QUEUED, not fired: this path holds a bare
// `&mut Model` (no Lua reachable from a raise), so the host's next `UiScript::resolve` —
// which runs every frame — drains it one tick later. The alternative, threading a `&Lua`
// through the raise, would put a Lua handler in the middle of a strata compaction.
UiScript::resolve_layout(model);
if !overlapped(model, t) {
return true;
}
// 3. The raise: compact the stratum's occupied levels, then land one above the top.
let Some(strata) = model.arena.frame(t).map(|f| f.strata) else {
return true;
};
let count = model.arena.compact_levels(strata);
model.arena.set_frame_level(t, count, true);
true
}
/// The Show trigger — `effective_visible_show 0x76ae10` @`0x76aee0`: on a genuine `+0xd4`
/// false→true transition, **test the frame's own toplevel bit** and raise if it is set.
///
/// The own-bit test is load-bearing and is why this is not just [`raise`]: the worker would walk up
/// to a toplevel *ancestor*, so calling it unconditionally would lift a whole window every time any
/// non-toplevel child of it became visible. The binary tests `[esi+0xb4], 0x1` first, and only then
/// calls `0x76a5b0`.
pub(in crate::script) fn raise_on_show(model: &mut Model, h: FrameHandle) {
if model.arena.is_toplevel(h) {
raise(model, h);
}
}
/// Step 1 — walk `+0x9c` from the frame up, returning the first node with the toplevel bit
/// (`0x7650fb..0x765135`). Loop-guarded like the arena's own ancestry walk; a well-formed tree
/// never loops.
fn nearest_toplevel(model: &Model, frame: FrameHandle) -> Option<FrameHandle> {
// The bound is read once, not per hop: a chain longer than the frame count is a cycle, which
// `set_parent`'s own guard already makes unreachable — this is defence, not a hot path.
let bound = model.arena.iter_frames().count();
let mut cur = Some(frame);
let mut guard = 0usize;
while let Some(h) = cur {
let f = model.arena.frame(h)?;
if f.toplevel {
return Some(h);
}
cur = f.parent;
guard += 1;
if guard > bound {
break;
}
}
None
}
/// Step 2's predicate — the scan `0x7650f0` runs inline and `0x765a90` runs standalone: within `t`'s
/// own stratum bucket, from **`t`'s own level upward**, is there a frame that is not `t`, is not one
/// of `t`'s descendants, and whose screen rect meets `t`'s?
///
/// Every clause matters, and each is a separate test:
/// - **`level >= t.level` only.** A toplevel frame that overlaps solely something *below* it is
/// already on top of it, so there is nothing to raise over.
/// - **`is_descendant 0x767010` exclusion.** A window overlaps its own children by construction;
/// without this every toplevel frame with content would raise on every trigger.
/// - **non-empty intersection.** Frames that merely share an edge do not overlap — the intersection
/// has to have positive width *and* height, so a zero-area touch is not an occlusion.
fn overlapped(model: &Model, t: FrameHandle) -> bool {
let Some(tf) = model.arena.frame(t) else {
return false;
};
let (strata, level) = (tf.strata, tf.level);
let Some(t_rect) = model.resolved.get(&t).copied() else {
// No resolved rect ⇒ the frame is not on screen ⇒ it occludes nothing.
return false;
};
model.arena.iter_frames().any(|(h, f)| {
h != t
&& f.effective_visible
&& f.strata == strata
&& f.level >= level
&& !model.arena.is_ancestor(t, h)
&& model
.resolved
.get(&h)
.is_some_and(|r| rects_overlap(t_rect, *r))
})
}
/// `rect_intersect_nonempty` (`0x768320` / `0x766c70` / `0x766bc0`) — reusing the one axis-aligned
/// intersection this crate already has and asking whether what comes out has area.
fn rects_overlap(a: Rect, b: Rect) -> bool {
let i = crate::script::clip::intersect_rect(a, b);
i.width() > 0.0 && i.height() > 0.0
}