idle sits you down and camps you, and the addons anchor, parse and draw

- five idle minutes sit you down and flag you AFK, thirty log you out through the ordinary countdown; holding a key is idle, moving the mouse is not, and a swimmer stays afloat (2092)
- a knockdown no longer wedges: the landing froze the clip that held the base-animation lock, leaving a knocked-down player on their back for the session (2098); the perf cost pill starts hidden (2099)
- Lua is exactly 1.12's 5.0 — no vararg expression, no #, no %, the remembered table size — so Cartographer loads without errors and Mik's Scrolling Battle Text shows its text, in its own shipped font now that addon TTFs load (2101, 2102, 2103)
- a widget name is a Lua global for SetPoint and its kin, so Bartender2's bars spread out; a button's NormalText disowns its justify, the raise renumbers hidden frames too, and a missing toc file warns instead of erroring (2105, 2100, 2104, 2107, 2106)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:35:03 +07:00
co-authored by Claude Fable 5.1
parent 057a3061c4
commit d8de29bd9b
52 changed files with 3713 additions and 359 deletions
@@ -193,6 +193,74 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// ── GetFrameType / IsFrameType: the FRAME-side spellings of the pair above (decision 2106) ──
//
// 1.12 registers the type-identity pair **twice, under two names**. The Region script
// (`CScriptRegion`) publishes `GetObjectType`/`IsObjectType` — that is the pair above, and its
// `IsObjectType` binding is `0x7a1290`. The *frame* script (`CSimpleFrameScript.cpp`,
// `__FILE__` `0x879504`) publishes `GetFrameType 0x773640` and `IsFrameType 0x773700` as well,
// and wow-5875-re carved both (`system/ui/scratch/item17-frameapi-batch1.md`, 76-entry
// registrar table verified at the bytes). `GetObjectType`/`IsObjectType` are what LATER
// clients kept; `GetFrameType`/`IsFrameType` are 1.12's own, and we shipped only the first
// pair. That is 1189's superset argument inverted — not an extra name we invented, a real one
// we were missing — and it cost the world map:
//
// Cartographer 2.02, LookNFeel.lua:368, inside OnEnable:
// if v:GetFrameType() == "Model" and not v:GetName() then self.playerModel = v end
//
// With the verb nil that line raised, AceAddon swallowed it, `self.playerModel` stayed nil,
// and the next `Cartographer_ChangeZone` → `SetAlpha` put a red script error on screen from
// `LookNFeel.lua:737` every time the map opened. FuBar's own `FuBar.lua:576`
// (`type(frame:GetFrameType()) ~= "string"`) is the same call.
//
// Both delegate to the same `type_chain` the `Object` pair reads: `GetFrameType` is
// `call [edx+0x1c]` — the identical per-class type-name slot `GetObjectType` reads — then
// `lua_pushstring`, one value, extra arguments ignored. `IsFrameType` walks the chain through
// `[eax+0x18]`, the same case-insensitive whole-string compare, and answers the NUMBER 1 or
// nil.
//
// **One edge is INFERRED and named rather than guessed silently:** wow-re's note glosses
// `IsFrameType`'s *absent-argument* branch as "pushes the frame's own typename (`call [eax+4]`
// GetName)", which is self-contradictory — a typename and a name are different slots — so the
// branch is not settled. We take the sibling's behaviour (the `Usage:` raise) pending a byte
// read. No caller in the 219-addon corpus, the director's AddOns, or the shipped FrameXML ever
// omits the argument: `JIM_toolbox/Config2/Pulse_Config.lua:118` is the corpus's only
// `IsFrameType` site and it passes `"Slider"`.
m.set(
"GetFrameType",
lua.create_function(|lua, this: Table| {
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
})?,
)?;
m.set(
"IsFrameType",
lua.create_function(|lua, (this, want): (Table, Value)| {
let chain = chain_of(lua, &this)?;
let want = match &want {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(n) => n.to_string(),
Value::Integer(i) => i.to_string(),
_ => {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"Usage: {who}:IsFrameType(\"TYPE\")"
)));
}
};
Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) {
Value::Number(1.0)
} else {
Value::Nil
})
})?,
)?;
m.set(
"GetParent",
lua.create_function(|lua, this: Table| {
@@ -334,6 +402,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
.unwrap_or_else(|| "<unnamed>".to_string())
};
let parent_arg = it.next();
// `_G[name]` is read with no guard alive (`object::NamedTarget`), and **without**
// `$parent` expansion: `0x7a1550` calls `0x76c760` directly, and only the layout
// vtable's `0x76c700` runs the token.
let named = match &parent_arg {
Some(Value::String(s)) => Some(match s.to_str() {
Ok(n) => super::prefetch_named_target(lua, n.as_ref(), None),
Err(_) => super::NamedTarget::unreadable(),
}),
_ => None,
};
let new_parent = {
let model = lua.app_data_ref::<Model>().expect("model");
match &parent_arg {
@@ -349,11 +427,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
))
})?,
),
Some(Value::String(s)) => {
let n = s.to_str()?.to_string();
Some(model.arena.lookup(&n).ok_or_else(|| {
Some(Value::String(_)) => {
// `_G[name]` + the **Frame** tag check — the reference's `0x76c760`
// (`0x7a1550`'s NAME-string path) type-guards against `[0xcf0c10]`, the
// narrow Frame id, not `SetPoint`'s root `[0xcf0c3c]`. So a global naming
// a REGION fails here exactly as an absent one does.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = super::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied());
Some(hit.ok_or_else(|| {
mlua::Error::runtime(format!(
"{who}:SetParent(): Couldn't find region named '{n}'"
"{who}:SetParent(): Couldn't find region named '{}'",
nt.name
))
})?)
}
@@ -9,7 +9,35 @@ use crate::layout::{Anchor, Point};
use crate::script::{Model, SCREEN};
use crate::widget::FrameHandle;
use super::{as_f32, decode_id, frame_handle_of, frame_wrapper, point_from_str, point_name};
use super::{
as_f32, decode_id, frame_handle_of, frame_parent_token_base, frame_wrapper, point_from_str,
point_name, prefetch_named_target, resolve_named_target, NamedTarget,
};
/// Phase 1 of a `relativeTo`/`SetAllPoints` target: when the argument is a **name string**, expand
/// a leading `$parent` against `h`'s first named ancestor and read `_G` — all of it with **no**
/// `Model` guard alive, because the reference's read is a gettable and an `__index` on `_G` can run
/// Lua that calls straight back into a binding ([`NamedTarget`]).
///
/// Anything else needs no prefetch and answers `None`.
fn prefetch_relative_to(
lua: &Lua,
arg: Option<&Value>,
h: FrameHandle,
) -> mlua::Result<Option<NamedTarget>> {
let Some(Value::String(s)) = arg else {
return Ok(None);
};
// A name we cannot even read names nothing; the caller's miss path reports it as before.
let Ok(raw) = s.to_str() else {
return Ok(Some(NamedTarget::unreadable()));
};
let base = {
let model = lua.app_data_ref::<Model>().expect("model");
frame_parent_token_base(&model, h)
};
Ok(Some(prefetch_named_target(lua, raw.as_ref(), Some(&base))))
}
/// Populate `m`'s layout (anchor/size) methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
@@ -100,6 +128,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetAllPoints",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
// The `_G` read runs before the guard — see `set_point` and `object::NamedTarget`.
let named = prefetch_relative_to(lua, Some(&target), h)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let rel_id: u32 = match &target {
// Same id_to_region fallback as `set_point` above (and `region.rs`'s own
@@ -110,18 +140,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)
})
.unwrap_or_else(|| default_parent_id(&mut model, h)),
Value::String(s) => {
// Frames first, then the region-name registry: the real XML anchors frames to
// REGIONS too (gossip option rows → the greeting FontString) — resolve() binds
// region targets in its second round.
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| default_parent_id(&mut model, h))
Value::String(_) => {
// `_G[name]` — the client's one widget namespace, frames and regions alike
// (`object::NamedTarget`). The real XML anchors frames to REGIONS too (gossip
// option rows → the greeting FontString), and both publish there.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = resolve_named_target(&model, nt);
hit.unwrap_or_else(|| default_parent_id(&mut model, h))
}
_ => default_parent_id(&mut model, h),
};
@@ -358,6 +383,10 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
let h = frame_handle_of(lua, this)?;
// A name target is read out of `_G` FIRST, with no model guard alive — the reference's read is
// a gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`).
let named = prefetch_relative_to(lua, rest.first(), h)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// relativeTo: a table (wrapper) or a string (name), else default to the parent/screen. A table
@@ -379,23 +408,23 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
})
.unwrap_or_else(|| default_parent_id(&mut model, h))
}
Some(Value::String(s)) => {
Some(Value::String(_)) => {
cursor = 1;
// Frames first, then regions by name (see SetAllPoints above — same rationale).
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| {
// The parent fallback is the client's behavior, but a *named* target that
// doesn't resolve is almost always a bug (a typo, or an XML forward reference —
// anchors resolve at SetPoint time, so a target must be declared before its
// dependents). Say so instead of silently misdirecting the anchor: this exact
// silence cost a hunt twice (QuestLogFrame's reward rows, ItemTextFrame's
// scrollbar track).
// `_G[name]`, frames and regions alike (see SetAllPoints above — same rationale), with
// a leading `$parent` already expanded against this frame's first named ancestor.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = resolve_named_target(&model, nt);
hit.unwrap_or_else(|| {
// **The reference RAISES here** — `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't
// find region named '%s'")`, no fallback and no no-op (`luaG_errormsg`/`luaD_throw`
// contain no `ret`). Ours still falls back to the parent and warns, because we
// cannot yet see how many of our own transcriptions and of the addon corpus that
// raise would abort — the harness reports no engine warnings, which is the
// instrument that has to come first (decision 2105, "Named, not done").
//
// The warning is worth keeping regardless: a *named* target that doesn't resolve is
// almost always a bug, and this exact silence cost a hunt twice (QuestLogFrame's
// reward rows, ItemTextFrame's scrollbar track).
let who = model
.arena
.frame(h)
@@ -403,7 +432,7 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
.unwrap_or_else(|| "<anonymous>".into());
model.warnings.push(format!(
"SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent",
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
nt.name
));
default_parent_id(&mut model, h)
})
@@ -25,9 +25,9 @@
//! 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)`.
//! 3. **The raise, gated on that bit** (`0x7651ac..0x7651e7`): `level_compact 0x764eb0` squeezes the
//! free levels out of the stratum — over **every live frame of it, hidden included**
//! (`ui/scratch/level-compact-law.md`) — 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
@@ -52,8 +52,15 @@
//! 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.
//! renumber of **every frame** in one stratum, hidden ones included: `0x764eb0` walks the client's
//! master frame list filtering on strata and level alone, and never reads `+0xd4`
//! (`ui/scratch/level-compact-law.md`, decision 2104). It changes no draw order by itself; it is
//! what keeps `level := max + 1` from ratcheting upward for the length of a session. Renumbering
//! only the *bucket* (the visible half) is the one shape it may not have: the raise's `propagate`
//! delta is computed in the new numbering and applied to levels that are still in the old one, so
//! a squeeze taken out of a visible child and not out of its hidden sibling inverts a pair that
//! `parent.level + 1` made equal — see that method's own doc for the window it broke, and for the
//! two places ours is deliberately simpler than the bytes.
//! - **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