diff --git a/crates/benilla-ui/src/script/editbox/flush_tests.rs b/crates/benilla-ui/src/script/editbox/flush_tests.rs index 43fe6c9e..2862277a 100644 --- a/crates/benilla-ui/src/script/editbox/flush_tests.rs +++ b/crates/benilla-ui/src/script/editbox/flush_tests.rs @@ -93,6 +93,19 @@ fn the_flush_follows_the_boxs_own_on_update_cursor_then_text() { assert!(s.errors().is_empty(), "{:?}", s.errors()); } +/// The box's place in the walk is its stratum's, not its creation's: a BACKGROUND box updates and +/// flushes before the MEDIUM frames made around it (`0x7656c8`). +#[test] +fn a_box_flushes_after_its_on_update_in_its_strata_place() { + let mut s = script(); + logged_box(&mut s); + s.run(r#"E:SetFrameStrata("BACKGROUND") E:SetText("abc")"#) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "update cursor@21 text:abc early late"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + /// An edit a box makes in its own `OnUpdate` is flushed by the same update, so its events fire in /// the same tick. #[test] diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e20e92bf..e12857a2 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -133,6 +133,7 @@ mod tooltip_spell; mod tooltip_unit; mod ui_errors; mod ui_scale; +mod update_walk; pub use tooltip_unit::TooltipTint; mod trade; mod tradeskill; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 36564a26..d63c5ac0 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -176,8 +176,11 @@ pub(crate) struct Model { /// Which handlers each frame has; the closures themselves live in Lua's `REG_SCRIPTS` table. pub(crate) scripts: HashMap>, - /// Frames with an `OnUpdate` script, kept by `SetScript`; the tick sorts them by frame id. + /// Frames with an `OnUpdate` script, kept by `SetScript`, from which the update walk starts. pub(crate) on_update_frames: Vec, + /// Bumped each time a frame joins [`Self::on_update_frames`], so the walk notices a frame that + /// gains an `OnUpdate` before its turn. + pub(crate) on_update_joins: u64, /// Frames with an `OnSizeChanged` script, kept by `SetScript`, for the resolve's size snapshot. pub(crate) on_size_changed_frames: Vec, /// Frames with an `OnUpdateModel` script, fired as a visible model pane paints (`0x76d1a0`). @@ -1098,6 +1101,7 @@ impl Model { region_names: HashMap::new(), scripts: HashMap::new(), on_update_frames: Vec::new(), + on_update_joins: 0, on_size_changed_frames: Vec::new(), on_update_model_frames: Vec::new(), model_facts: HashMap::new(), diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 333d21a0..ecf96af4 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -240,7 +240,10 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option) -> ml // The first handler of a kind joins that kind's list; nothing else writes `scripts`. if model.scripts.entry(h).or_default().insert(kind) { match kind { - "OnUpdate" => model.on_update_frames.push(h), + "OnUpdate" => { + model.on_update_frames.push(h); + model.on_update_joins += 1; + } "OnSizeChanged" => model.on_size_changed_frames.push(h), "OnUpdateModel" => model.on_update_model_frames.push(h), _ => {} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index c47fe2dc..a7468ce1 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -52,6 +52,7 @@ mod tooltip_item; mod tooltip_spell; mod tooltip_unit; mod toplevel; +mod update_walk; mod visibility; mod widget_surface; mod worldframe; diff --git a/crates/benilla-ui/src/script/tests/update_walk.rs b/crates/benilla-ui/src/script/tests/update_walk.rs new file mode 100644 index 00000000..0eb1a076 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/update_walk.rs @@ -0,0 +1,216 @@ +//! The update walk (`0x765650`): strata from the bottom up (`0x7656c8`–`0x765793`), each +//! stratum's levels from 0 up (`0x765774`), and each level's list of linked frames oldest link +//! first, the list being head-inserted (`0x7648c9`) and walked from its tail (`0x7656e5`–`0x765707`). +//! The walk saves the next frame before each call (`0x765707`), a hide repairs that cursor +//! (`0x76498f`), and a frame linked into the level being walked is staged until the level ends +//! (`0x76489b`, `0x76573e`). + +use crate::script::UiScript; + +use super::common::script; + +/// `LOG` joined by spaces, then emptied. +fn take_log(s: &UiScript) -> String { + s.eval::("local l = table.concat(LOG, ' ') LOG = {} return l") + .unwrap() +} + +/// Lua that makes a frame `name`, logging `tag` from its `OnUpdate`. +const LOGGED: &str = r#" + LOG = {} + function Logged(name, tag) + local f = CreateFrame("Frame", name) + f:SetScript("OnUpdate", function() table.insert(LOG, tag) end) + return f + end +"#; + +#[test] +fn the_walk_runs_strata_then_level_not_creation_order() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("WalkTip", "tooltip"):SetFrameStrata("TOOLTIP") + Logged("WalkBack", "background"):SetFrameStrata("BACKGROUND") + Logged("WalkHigh", "level5"):SetFrameLevel(5) + Logged("WalkLow", "level1"):SetFrameLevel(1) + "#, + ) + .unwrap(); + take_log(&s); + s.tick(0.016); + assert_eq!(take_log(&s), "background level1 level5 tooltip"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Within a level the oldest link goes first, and a show links at the end: a frame hidden and +/// shown again walks after one created later. +#[test] +fn a_reshown_frame_walks_after_its_levels_older_links() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("LinkA", "a") + Logged("LinkB", "b") + LinkA:Hide() + LinkA:Show() + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "b a"); +} + +#[test] +fn a_frame_hidden_by_an_earlier_on_update_is_not_updated() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("HideA", "a"):SetScript("OnUpdate", function() + table.insert(LOG, "a") + HideB:Hide() + end) + Logged("HideB", "b") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "a"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A frame hidden and shown again before its turn relinks into the level being walked, which +/// stages it: it waits for the next tick. (A blink every tick would stage it every tick.) +#[test] +fn a_frame_reshown_before_its_turn_in_the_walked_level_waits_a_tick() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("BlinkA", "a"):SetScript("OnUpdate", function() + table.insert(LOG, "a") + if not BLINKED then + BLINKED = 1 + BlinkB:Hide() + BlinkB:Show() + end + end) + Logged("BlinkB", "b") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "a"); + s.tick(0.016); + assert_eq!(take_log(&s), "a b"); +} + +/// A frame shown into a level the walk has not reached yet is updated that tick; one shown into +/// the level being walked or one already walked waits for the next. +#[test] +fn a_frame_shown_mid_walk_is_updated_only_if_its_level_is_still_ahead() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("ShowA", "a"):SetScript("OnUpdate", function() + table.insert(LOG, "a") + ShowTip:Show() + ShowSame:Show() + ShowBack:Show() + end) + Logged("ShowTip", "tip"):SetFrameStrata("TOOLTIP") + Logged("ShowSame", "same") + Logged("ShowBack", "back"):SetFrameStrata("BACKGROUND") + ShowTip:Hide() + ShowSame:Hide() + ShowBack:Hide() + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "a tip"); + s.tick(0.016); + assert_eq!(take_log(&s), "back a same tip"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A level change relinks a shown frame at the head of its new level (`0x76a54c`), so a frame that +/// moves itself to a level still ahead is walked again there. +#[test] +fn a_frame_that_moves_itself_to_a_level_ahead_is_walked_again() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("Mover", "m"):SetScript("OnUpdate", function() + table.insert(LOG, "m" .. this:GetFrameLevel()) + if this:GetFrameLevel() == 0 then this:SetFrameLevel(3) end + end) + Logged("Mid", "mid"):SetFrameLevel(2) + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "m0 mid m3"); + s.tick(0.016); + assert_eq!(take_log(&s), "mid m3"); +} + +/// The update reads the frame's handler when its turn comes (`0x76b2c3`), so an `OnUpdate` set by +/// an earlier frame's runs that tick. +#[test] +fn an_on_update_set_before_the_frames_turn_runs_that_tick() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + Logged("Setter", "setter"):SetScript("OnUpdate", function() + table.insert(LOG, "setter") + if not SET then + SET = true + Earlier:SetScript("OnUpdate", function() table.insert(LOG, "earlier") end) + Later:SetScript("OnUpdate", function() table.insert(LOG, "later") end) + end + end) + CreateFrame("Frame", "Earlier"):SetFrameStrata("BACKGROUND") + CreateFrame("Frame", "Later"):SetFrameStrata("HIGH") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "setter later"); + s.tick(0.016); + assert_eq!(take_log(&s), "earlier setter later"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// "Ahead" is by stratum first: a frame shown into a later stratum is updated that tick even at a +/// lower level than the one being walked, as the reference walks every stratum after this one. +#[test] +fn a_frame_shown_into_a_later_stratum_at_a_lower_level_runs_that_tick() { + let mut s = script(); + s.run(LOGGED).unwrap(); + s.run( + r#" + local late = Logged("LateHigh", "high") + late:SetFrameStrata("HIGH") + late:SetFrameLevel(1) + late:Hide() + local shower = Logged("ShowerMed", "medium") + shower:SetFrameLevel(5) + shower:SetScript("OnUpdate", function() + table.insert(LOG, "medium") + LateHigh:Show() + end) + "#, + ) + .unwrap(); + take_log(&s); + s.tick(0.016); + assert_eq!(take_log(&s), "medium high"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 87f20777..41fd5d18 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -5,7 +5,7 @@ use mlua::Lua; use crate::widget::FrameHandle; -use super::{editbox, event, tooltip, Model, ScriptValue, UiScript}; +use super::{event, tooltip, Model, ScriptValue, UiScript}; impl UiScript { /// Queue an event for the start of the next tick, behind everything queued earlier and before @@ -70,10 +70,10 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { } impl super::UiScript { - /// Advance a frame: the `GetTime()` clock and queued events, then the walk (`0x765650`), - /// each shown frame's update in turn: its `OnUpdate(self, elapsed)` (`0x704f10`), and for an - /// EditBox its flush and caret blink after it; then the engine's own fades, model panes and - /// hover. + /// Advance a frame: the `GetTime()` clock and queued events, then the update walk + /// (`0x765650`), each linked frame's update in strata, level and link order: its + /// `OnUpdate(self, elapsed)` (`0x704f10`), and for an EditBox its flush and caret blink after + /// it; then the engine's own fades, model panes and hover. pub fn tick(&mut self, elapsed: f32) { self.model_mut().now += f64::from(elapsed); // Events queued since the last tick fire before this tick's OnUpdate. @@ -81,74 +81,7 @@ impl super::UiScript { for (event, args) in pending { self.fire_event(&event, args); } - // Each entry: the frame's id, whether it has an `OnUpdate`, and the box if an EditBox. - let walk: Vec<(u32, bool, Option)> = { - let mut model = self.model_mut(); - // `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here. - let shown = |h: FrameHandle| model.arena.frame(h).is_some_and(|f| f.effective_visible); - let mut frames: Vec<(FrameHandle, bool)> = model - .on_update_frames - .iter() - .copied() - .filter(|&h| shown(h)) - .map(|h| (h, false)) - .collect(); - // The walk reaches every shown frame, not only those with a script, and an EditBox's - // update (`0x77a790`, vtable `+0x38`) flushes it whether or not it has an `OnUpdate`. - frames.extend( - model - .arena - .editbox_kinds() - .iter() - .copied() - .filter(|&h| shown(h)) - .map(|h| (h, true)), - ); - if model - .on_update_frames - .iter() - .any(|&h| model.arena.frame(h).is_none()) - { - let arena = &model.arena; - let live: Vec = model - .on_update_frames - .iter() - .copied() - .filter(|&h| arena.frame(h).is_some()) - .collect(); - model.on_update_frames = live; - } - let mut walk: Vec<(u32, bool, Option)> = frames - .into_iter() - .map(|(h, editbox)| (model.frame_id(h), !editbox, editbox.then_some(h))) - .collect(); - // Creation order, by frame id: getters settle on demand, so a handler sees what an - // earlier one did this sweep, and the order must be stable. The reference walks each - // strata level's list of shown frames (`0x765650`). A box with an `OnUpdate` is - // listed twice, and the two entries merge. - walk.sort_unstable_by_key(|&(id, ..)| id); - walk.dedup_by(|later, earlier| { - let same = later.0 == earlier.0; - if same { - earlier.1 |= later.1; - earlier.2 = earlier.2.or(later.2); - } - same - }); - walk - }; - for (id, on_update, editbox) in walk { - if on_update { - if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) { - self.push_error(e); - } - } - // The box's own update runs its flush after its `OnUpdate` (`0x77a79a`, then - // `0x77a7a1`), so the events an edit raised fire in the box's place in the walk. - if let Some(h) = editbox { - editbox::update(&self.lua, h, elapsed); - } - } + self.update_walk(elapsed); self.tick_model_panes(elapsed); // The engine's line fades, apart from any OnUpdate script: ScrollingMessageFrame's // (`0x788460`) and MessageFrame's (`0x786200`), which also keeps only the lines that fit @@ -255,7 +188,7 @@ impl UiScript { }) .collect(); let mut ids: Vec = frames.into_iter().map(|h| model.frame_id(h)).collect(); - ids.sort_unstable(); // creation order, as in the OnUpdate sweep + ids.sort_unstable(); // creation order ids }; for id in update_ids { diff --git a/crates/benilla-ui/src/script/update_walk.rs b/crates/benilla-ui/src/script/update_walk.rs new file mode 100644 index 00000000..0b2e3aa9 --- /dev/null +++ b/crates/benilla-ui/src/script/update_walk.rs @@ -0,0 +1,131 @@ +//! The update walk (`0x765650`), each linked frame's per-frame update in the reference's order: the +//! nine strata from the bottom up (`0x7656c8`–`0x765793`), each stratum's levels from 0 up +//! (the bound re-read after each level, `0x765774`), and each level's list of linked frames from its +//! tail (`0x7656e5`–`0x765707`). Every link is a head insert (`0x7648c9`), so a level walks oldest +//! link first: the order of [`Frame::insertion_seq`](crate::widget::Frame::insertion_seq). +//! +//! The walk is live. It saves the next frame before each call (`0x765707`), and a hide that +//! unlinks that frame moves the cursor past it (`0x76498f`), so a frame hidden before its turn is +//! not updated. A frame linked while its own level is being walked is staged until that level +//! ends (`0x76489b`, spliced at `0x76573e`) and so waits a tick, as does one linked into a level +//! already walked; one linked into a level still ahead is updated there, after that level's +//! older links. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::widget::{FrameHandle, FrameKind}; + +use super::{editbox, event, UiScript}; + +/// A place in the walk: stratum, level, then within the level the frames linked before the walk +/// began (0, by link stamp) ahead of those linked during it (1, by the order they linked). +type Place = (u8, u16, u8, u64); + +impl UiScript { + /// Run the walk: each frame's `OnUpdate(self, elapsed)` (`0x76b2c0`, reading the handler when + /// its turn comes), and for an EditBox its flush after it (`0x77a79a`, then `0x77a7a1`), so the + /// events an edit raised fire in the box's place. + pub(super) fn update_walk(&mut self, elapsed: f32) { + let mut ahead: BTreeMap = BTreeMap::new(); + let (renumbers, mut joins, mut seen) = { + let mut model = self.model_mut(); + // `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here. + if model + .on_update_frames + .iter() + .any(|&h| model.arena.frame(h).is_none()) + { + let arena = &model.arena; + let live: Vec = model + .on_update_frames + .iter() + .copied() + .filter(|&h| arena.frame(h).is_some()) + .collect(); + model.on_update_frames = live; + } + // Only frames with an `OnUpdate` or an EditBox's flush do anything when walked. + let mut seen = HashSet::new(); + for &h in model + .on_update_frames + .iter() + .chain(model.arena.editbox_kinds()) + { + if let Some(f) = model.arena.frame(h).filter(|f| f.effective_visible) { + let place = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq)); + ahead.insert(place, h); + seen.insert(h); + } + } + model.arena.open_link_log(); + (model.arena.renumbers(), model.on_update_joins, seen) + }; + // Each frame linked during the walk, by the number of its latest link. + let mut relinked: HashMap = HashMap::new(); + let mut links = 0u64; + while let Some((place, h)) = ahead.pop_first() { + let (on_update, editbox) = { + let mut model = self.model_mut(); + let Some(f) = model.arena.frame(h) else { + continue; + }; + // Still at this place: linked, and not relinked since the place was taken. + let here = f.effective_visible + && match place.2 { + 0 => !relinked.contains_key(&h), + _ => relinked.get(&h) == Some(&place.3), + }; + let editbox = f.kind == FrameKind::EditBox; + let on_update = here + && model + .scripts + .get(&h) + .is_some_and(|s| s.contains("OnUpdate")); + (on_update.then(|| model.frame_id(h)), here && editbox) + }; + if let Some(id) = on_update { + if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) { + self.push_error(e); + } + } + if editbox { + editbox::update(&self.lua, h, elapsed); + } + let mut model = self.model_mut(); + for linked in model.arena.take_linked() { + links += 1; + relinked.insert(linked, links); + let Some(f) = model.arena.frame(linked) else { + continue; + }; + if (f.strata as u8, f.level) > (place.0, place.1) { + ahead.insert((f.strata as u8, f.level, 1, links), linked); + } + } + // A frame that gained an `OnUpdate` keeps its place if the walk has not passed it. Its + // stamp compares with the walk's only while no renumber came between; else it waits. + if model.on_update_joins != joins { + joins = model.on_update_joins; + if model.arena.renumbers() == renumbers { + let joined: Vec = model + .on_update_frames + .iter() + .copied() + .filter(|h| !seen.contains(h) && !relinked.contains_key(h)) + .collect(); + for j in joined { + seen.insert(j); + let Some(f) = model.arena.frame(j).filter(|f| f.effective_visible) else { + continue; + }; + let at = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq)); + if at > place { + ahead.insert(at, j); + } + } + } + } + } + self.model_mut().arena.close_link_log(); + } +} diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index eef79a28..26c5e4ae 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -250,6 +250,12 @@ pub struct WidgetArena { regions: Arena, names: HashMap, next_insertion: u32, + /// How many times the link stamps were renumbered at the cap, so a reader holding stamps can + /// tell they are still comparable with the live ones. + renumbers: u32, + /// While the update walk runs, every frame linked into a bucket since it opened, in link order + /// ([`Self::open_link_log`]). + link_log: Option>, /// 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. @@ -301,6 +307,8 @@ impl WidgetArena { regions: Arena::new(), names: HashMap::new(), next_insertion: 0, + renumbers: 0, + link_log: None, minimap_created: 0, order_cache: Default::default(), ticked_kinds: Vec::new(), @@ -381,14 +389,42 @@ impl WidgetArena { } } self.next_insertion = order.len() as u32; + self.renumbers += 1; } let seq = self.next_insertion; if let Some(f) = self.frame_mut(h) { f.insertion_seq = seq; self.next_insertion += 1; + if let Some(log) = &mut self.link_log { + log.push(h); + } } } + /// Start recording every link into a bucket, for the update walk (`0x765650`), which must see + /// the frames a handler links while it runs. + pub(crate) fn open_link_log(&mut self) { + self.link_log = Some(Vec::new()); + } + + /// The frames linked since the last call, in link order, the log staying open. + pub(crate) fn take_linked(&mut self) -> Vec { + self.link_log + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } + + /// Stop recording links. + pub(crate) fn close_link_log(&mut self) { + self.link_log = None; + } + + /// How many times the link stamps have been renumbered; stamps read under one count compare. + pub(crate) fn renumbers(&self) -> u32 { + self.renumbers + } + /// 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() @@ -502,8 +538,14 @@ impl WidgetArena { | FrameKind::DressUpModel | FrameKind::TabardModel ); + let visible = frame.effective_visible; let (index, generation) = self.frames.insert(frame); let handle = FrameHandle { index, generation }; + if visible { + if let Some(log) = &mut self.link_log { + log.push(handle); + } + } if ticked { self.ticked_kinds.push(handle); }