diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 68e0c3e6..fc1ebf21 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -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 { + 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::().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::().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", diff --git a/crates/benilla-ui/src/script/tests/object_model.rs b/crates/benilla-ui/src/script/tests/object_model.rs index 29a137b7..0756f298 100644 --- a/crates/benilla-ui/src/script/tests/object_model.rs +++ b/crates/benilla-ui/src/script/tests/object_model.rs @@ -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::(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::( + 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}" + ); +} diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 3c19684b..5051eb9e 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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, names: HashMap, 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, /// 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 { + 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 { + 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 { 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); }