frames: EnumerateFrames, the walk over every frame the client has

A real 1.12 global we did not have (`reference/1.12-globals.tsv`), and the
natural way for an addon to ask the live UI which frames carry some script
or property — the player's backlog item, raised because nothing answered.

Read off `0x705f60`. The frame manager owns a Storm list (`[0xcf0bd8]+0xcc0`,
link offset `0x304` written by its ctor at `0x7641b8`) and the `CSimpleFrame`
ctor links every new frame through `0x7646a0`, which puts the newcomer in the
sentinel's own slot `[0xcc4]` and the frame it displaced in the newcomer's
`+0x308`. So the list is walked **newest to oldest**: with no frame the
binding answers `[0xcc4]`, with one `[frame+0x308]`, and the first frame ever
created holds the tagged sentinel `&head|1` there, which is the `test $0x1,%al`
that ends the walk with nil.

Three details that are not guesses:

- The only type test is `lua_type(L, 1) == LUA_TTABLE` (`cmp $0x5`), so a
  number, a string or a boolean is not an error — it falls through to the head
  and starts the walk over.
- A table is resolved through `T[0]` exactly as our `decode_id` does, and both
  rejections raise through `luaL_error` (`0x6f4940`, which never returns) with
  the client's own wording: `0x872f08` for a table with no object behind it,
  `0x872ecc` for one that is some other kind of object — a region, which is
  not in this list at all.
- Regions and the walk's order aside, hidden and anonymous frames are in it:
  the link is the ctor's, not the draw list's.

The arena grows the list it already implied. `Frame::insertion_seq` could not
serve: that is the draw-order stamp, and a `Show` or a strata change restamps
it (`resequence_to_tail`), where creation order never moves. So `created` is
the handle list in creation order with `created_seq` the frame's index into
it, and a step is one lookup. A destroyed frame's slot is kept, not
compacted — its handle stops resolving and the walk skips it — because an
index shift would break every other frame's `created_seq`, and nothing in
play destroys a frame anyway, as nothing in 1.12 can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-18 12:25:57 -06:00
co-authored by Claude Opus 5
parent 780ca25833
commit 2a51c9817a
3 changed files with 162 additions and 0 deletions
+45
View File
@@ -331,6 +331,48 @@ pub(super) fn as_f64(lua: &Lua, v: &Value) -> f64 {
}
}
/// `EnumerateFrames([frame])` (`0x705f60`): one step along the global frame list, newest first.
///
/// With a frame, the frame created before it; with nothing — or with any argument that is not a
/// table, since the only type test is `lua_type(L, 1) == LUA_TTABLE` (`0x6f3400`, `cmp $0x5`) and
/// anything else falls through to the head — the newest frame; nil past the oldest. Regions are
/// not in the list: the walk is `CSimpleFrame`'s own, and the reference confirms the argument's
/// type through the virtual `IsObjectType` before stepping. See
/// [`crate::widget::WidgetArena::newest_frame`] for the list itself.
///
/// Both rejections raise, as `luaL_error` (`0x6f4940`) does, and with the reference's own wording:
/// a table with no object behind it is "Couldn't find 'this' in current object" (`0x872f08`), a
/// table that is some other kind of object "Wrong current object type, expected frame"
/// (`0x872ecc`).
fn enumerate_frames(lua: &Lua, arg: Value) -> mlua::Result<Value> {
let from = match arg {
// A table is resolved; every other type, nil included, starts the walk over.
Value::Table(t) => {
let id = decode_id(&t).map_err(|_| {
mlua::Error::runtime("EnumerateFrames: Couldn't find 'this' in current object")
})?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let h = model.id_to_frame.get(&id).copied().ok_or_else(|| {
mlua::Error::runtime("EnumerateFrames: Wrong current object type, expected frame")
})?;
Some(h)
}
_ => None,
};
let next = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let handle = match from {
Some(h) => model.arena.frame_created_before(h),
None => model.arena.newest_frame(),
};
handle.map(|h| model.frame_id(h))
};
match next {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// install
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -362,6 +404,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let create_frame = lua.create_function(create_frame)?;
lua.globals().set("CreateFrame", create_frame)?;
let enumerate_frames = lua.create_function(enumerate_frames)?;
lua.globals().set("EnumerateFrames", enumerate_frames)?;
// `GetScreenWidth`/`GetScreenHeight`: the screen root's size in UI units.
lua.globals().set(
"GetScreenWidth",
@@ -49,3 +49,78 @@ fn non_overwriting_named_publish() {
.unwrap();
assert!(ok);
}
/// `EnumerateFrames` walks the global frame list newest to oldest, and nothing else: the
/// reference's `[0xcf0bd8]+0xcc4` holds the frame linked last and each frame's `+0x308` the one it
/// displaced (`0x7646a0`), so the walk starts at the newest and ends on the sentinel.
#[test]
fn enumerate_frames_walks_every_frame_newest_first() {
let s = script();
s.run(
r#"
local a = CreateFrame("Frame", "EnumA")
local b = CreateFrame("Frame", "EnumB", a)
local c = CreateFrame("Frame") -- anonymous, and hidden
c:Hide()
-- The three newest, in reverse creation order.
local first = EnumerateFrames()
assert(first == c, "the newest frame heads the list, named or not, shown or not")
assert(EnumerateFrames(first) == b, "then the one created before it")
assert(EnumerateFrames(b) == a, "and the one before that")
-- The whole walk terminates, visits each frame once, and reaches the three above.
local seen, n, f = {}, 0, EnumerateFrames()
while f do
assert(not seen[f], "a frame is visited once")
seen[f] = 1
n = n + 1
assert(n < 10000, "the walk terminates")
f = EnumerateFrames(f)
end
assert(seen[a] and seen[b] and seen[c], "every frame is in the list")
assert(n >= 3, "and the count is the whole arena, not just these")
"#,
)
.unwrap();
}
/// A region is not a frame, and the two rejections carry the reference's own wording
/// (`0x872f08`/`0x872ecc`, raised through `luaL_error 0x6f4940`); any non-table argument is not a
/// rejection at all — the only type test is `lua_type(L, 1) == LUA_TTABLE`, so it starts over.
#[test]
fn enumerate_frames_rejects_a_non_frame_and_restarts_on_a_non_table() {
let s = script();
let restart: bool = s
.eval(
r#"
local f = CreateFrame("Frame", "EnumRestart")
-- A number, a string and a boolean all fall through to the head of the list.
return EnumerateFrames(7) == f and EnumerateFrames("f") == f
and EnumerateFrames(true) == f
"#,
)
.unwrap();
assert!(restart, "a non-table argument restarts the walk");
let plain = s
.eval::<bool>(r#"return EnumerateFrames({}) ~= nil"#)
.unwrap_err()
.to_string();
assert!(
plain.contains("Couldn't find 'this' in current object"),
"a table with no object behind it raises the reference's first message: {plain}"
);
let region = s
.eval::<bool>(
r#"
local f = CreateFrame("Frame", "EnumRegionOwner")
local t = f:CreateTexture()
return EnumerateFrames(t) ~= nil
"#,
)
.unwrap_err()
.to_string();
assert!(
region.contains("Wrong current object type, expected frame"),
"a region raises the reference's second message: {region}"
);
}
+42
View File
@@ -214,6 +214,10 @@ pub struct Frame {
/// The link position in the `(strata, level)` bucket, re-stamped at the tail on each relink.
/// Drawing puts the later-linked frame on top; the hit test probes the earlier-linked first.
pub insertion_seq: u32,
/// This frame's index in the arena's creation list, which is **not** [`Self::insertion_seq`]:
/// that one is the draw-order stamp and a `Show` or a strata change restamps it
/// ([`WidgetArena::resequence_to_tail`]). Creation order never moves.
pub(crate) created_seq: u32,
pub kind_state: KindState,
next_decl: u32,
}
@@ -243,6 +247,11 @@ pub struct WidgetArena {
regions: Arena<Region>,
names: HashMap<String, FrameHandle>,
next_insertion: u32,
/// Every frame ever created, in creation order: the reference's own global frame list, which
/// `EnumerateFrames` walks ([`Self::newest_frame`]). A destroyed frame's handle stops
/// resolving and the walk skips it, so the slot is kept rather than compacted — the client
/// never destroys a frame either, and a shifted index would break every `created_seq`.
created: Vec<FrameHandle>,
/// The draw list's fingerprint cache ([`crate::order::traversal`]).
pub(crate) order_cache: crate::order::OrderCache,
/// Minimap frames ever created, so the state feed notices a new one without a walk.
@@ -294,6 +303,7 @@ impl WidgetArena {
regions: Arena::new(),
names: HashMap::new(),
next_insertion: 0,
created: Vec::new(),
minimap_created: 0,
order_cache: Default::default(),
ticked_kinds: Vec::new(),
@@ -389,6 +399,36 @@ impl WidgetArena {
}
}
/// The newest live frame: where `EnumerateFrames()` with no frame starts.
///
/// The reference's global frame list is a Storm `TSList` on the frame manager
/// (`[0xcf0bd8]+0xcc0`, link offset `0x304` written by its ctor at `0x7641b8`), and the
/// `CSimpleFrame` ctor links every new frame through `0x7646a0`, which writes the new frame
/// into the sentinel's own slot `[0xcc4]` and the frame it displaced into the newcomer's
/// `+0x308`. So `[0xcc4]` is the **newest** frame and `+0x308` is the one created before it:
/// `EnumerateFrames` (`0x705f60`) walks newest to oldest and stops on the tagged sentinel
/// (`&head|1`, the `test $0x1,%al` at `0x705ffb`), which is what the first frame's `+0x308`
/// holds.
pub fn newest_frame(&self) -> Option<FrameHandle> {
self.created
.iter()
.rev()
.copied()
.find(|&h| self.frame(h).is_some())
}
/// The live frame created just before `h`, or `None` at the oldest: one step of
/// `EnumerateFrames` ([`Self::newest_frame`] has the reference's list).
pub fn frame_created_before(&self, h: FrameHandle) -> Option<FrameHandle> {
let seq = self.frame(h)?.created_seq as usize;
self.created
.get(..seq)?
.iter()
.rev()
.copied()
.find(|&h| self.frame(h).is_some())
}
/// The first frame created with `name`; a later duplicate never takes it over (`0x701bd0`).
pub fn lookup(&self, name: &str) -> Option<FrameHandle> {
self.names.get(name).copied()
@@ -459,6 +499,7 @@ impl WidgetArena {
ignore_parent_scale: false,
wow_id: 0,
insertion_seq,
created_seq: self.created.len() as u32,
kind_state: match kind {
FrameKind::StatusBar => KindState::StatusBar(StatusBarState::default()),
// `CLootButton` and `CSimpleCheckbox` extend `CSimpleButton` by one field each,
@@ -503,6 +544,7 @@ impl WidgetArena {
);
let (index, generation) = self.frames.insert(frame);
let handle = FrameHandle { index, generation };
self.created.push(handle);
if ticked {
self.ticked_kinds.push(handle);
}