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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user