Files
benilla-octo/crates/benilla-ui/src/script/object/toplevel.rs
T
SamandClaude Fable 5.1 b8e0025bc9 the flight map goes stock, and vendors refuse in 1.12's own words
- the pet stable and the flight map are the reference's own files: `TaxiRouteFrame` maps to a plain Frame (factory `0x495ba0` allocates the base size, the ctor adds no field, no new virtual, no method table — and `GetObjectType` is not overridden, so its own kind would have been the divergence), and the swap found three silent behaviours — the close button did nothing (an unknown `inherits=` is a WARNING, so it loaded as a bare button), the "no paths" line drew empty, and the title is READ off `UnitName("npc")`, not received; the WMO skybox rides the interior crossfade — one weight (`[0xce9bdc]`, the same number the MFOG lerp rides), a 4 s alpha fade, so a skybox the flood publishes through a doorway draws at weight 0 instead of popping in whole
- a widget predicate answers the NUMBER 1 or nil, never a boolean: eleven of them (`IsShown`, `IsVisible`, `IsMovable`, `GetChecked`…) move to the 1.12 shape — the reference proves it in its own source (`tostring(this:GetChecked())` saved and read back `== "1"`; `tostring(true)` would never match and buff timers would never appear), 21 corpus sites compare directly (`IsVisible() == nil`, `GetChecked() == 1`), and one of our own tests had the guild rank editor sending `true` where a real client sends 1
- the vendor's refusals are the reference's table: `SMSG_BUY_FAILED`/`SMSG_SELL_ITEM` route through `0x5dcd60`/`0x5dd1f0`'s switches, six lines were wrong (code 3 printed "That vendor can't be found." — vmangos's identifier — where 1.12 prints "That merchant doesn't like you."), silence is now expressible, and keying them gives the vendor a voice (`ERR_NOT_ENOUGH_MONEY` carries error-speech line 0x28); the bank's three ids were right and its "purchase failed (n)" an invention; the logout refusal is one key, `ERR_LOGOUT_FAILED`, for every non-zero reason; the two mail codes we said had no string have one (`ERR_PLAYER_WRONG_FACTION`, `ERR_RESTRICTED_ACCOUNT`) and a sent mail says `ERR_MAIL_SENT` in yellow; a test runs the chain's own `GlobalStrings.lua` and requires non-empty text for all 193 `ERR_*` the workspace names
- a clicked quest timer no longer raises — stock `QuestTimerFrame.lua` calls `QuestLog_SetSelection`/`QuestLog_Update`, which existed nowhere in this tree; our quest log owns the reference's two names now, which also shrinks the delta when that window itself migrates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:05:15 +07:00

258 lines
14 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: 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** | [`crate::script::UiScript::mouse_button`]'s down arm, first thing — capture-else-hover, unguarded, ahead of the title-region swallow |
//! | `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(crate::script::binding_abi::predicate(
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
}