Files
benilla-octo/crates/benilla-ui/src/script/tick.rs
T
gromandClaude Opus 5 68f397af72 app+protocol: nampower's combat-log family — 35 events off one decode
Every packet this family reports was already decoded: `combat_log/`
builds its chat lines from exactly these structs. `ui_np_events.rs` is a
second consumer of that decode, not a second decoder, which is also why
its arguments cannot drift from the chat log's.

Built: `SPELL_DAMAGE_EVENT_*`, `AUTO_ATTACK_*`, the three `SPELL_HEAL_*`,
the three `SPELL_ENERGIZE_*`, `SPELL_MISS_*`, `DAMAGE_SHIELD_*`,
`ENVIRONMENTAL_DMG_*`, `SPELL_DISPEL_BY_*`, `UNIT_DIED`, the
`*_UPDATE_DURATION_SELF` pair, the thirteen `UNIT_*_GUID`/
`PLAYER_GUILD_UPDATE_GUID` rows and `UNIT_COMBAT_GUID`.

**A registration is the switch.** Upstream's README calls every
`NP_Enable*Events` CVar deprecated — "registering the event is
preferred" — and three of the eleven families have no CVar at all, so a
CVar-only reading would leave `SPELL_DAMAGE_EVENT_*` unreachable for
every addon and `SPELL_ENERGIZE_*` unreachable for all of them, since
nothing installed writes its CVar. `refresh_listeners` reads the VM's own
registration table (the same one `GetFramesRegisteredForEvent` reads), so
a family is wanted when its CVar is set OR something is listening. That
is also what keeps the cost honest: with nobody registered a damage
packet queues nothing, rather than building eight arguments for a fire
that reaches no frame.

Nine `NP_*` CVars move from stored-only to LIVE, and
`NP_EnableSpellEnergizeEvents` is registered for the first time — the
first pass took the names off `NampowerSettings`' writes rather than off
the DLL's own CVar table. The two reasons written at the auto-attack and
heal rows were both about the *cast* path, and both were answered by the
combat-log decode that was already there.

The descriptor indices the GUID-keyed family watches are written out and
then cross-checked against the two tables the tree already keeps —
`unit_rec::FIELDS` and the `FIELD_UNIT_*` constants. A hand-written
descriptor index is exactly the number that is plausibly wrong and
silently so: the event simply never fires, which no amount of play
distinguishes from "the server never sent one".

`AttackerState` gains `sub_damage_count`, which is `AUTO_ATTACK_*`'s
arg6; vmangos writes one block, but a server splitting a swing across
schools sends more.

Two test notes worth keeping: the edge tests run through the schedule,
because `run_system_once` builds a fresh system each call and the
`MessageReader` cursor restarts — a test written that way passes whatever
the system does with the second edge. And the data-gated suite held at
exactly 269, after the event gate correctly named all 35 new names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 16:53:28 -06:00

553 lines
27 KiB
Rust

//! The host runtime loop: event fan-out ([`UiScript::fire_event`]), the per-frame advance
//! ([`UiScript::tick`]) and the `GetTime()` clock ([`UiScript::now`]).
//!
//! What a tick costs, phase by phase: `WOW_UI_TICK=<secs>` ([`super::tick_prof`]). It is the
//! instrument for the gap between `[ui-cost] tick=` — the whole of [`UiScript::tick`] — and
//! `[ui-handlers]`' handler-body total, which is every handler the tick fired and nothing else.
//! When those two disagree the difference is engine work in one of the eleven phases marked below,
//! and only the split says which.
use mlua::Lua;
use crate::widget::FrameHandle;
use super::tick_prof::{Phase, Split, Tally};
use super::{editbox, event, tooltip, Model, ScriptValue, UiScript};
/// What one [`UiScript::fire_event`] reached: how many frames were on the event's list when the
/// dispatch began, and how many of their handlers ran. The two differ only where the walk ended
/// early, which is a handler having unregistered the frame the walk saved as its next (see
/// [`UiScript::fire_event`]).
///
/// It exists because "the event never arrived", "the list was cut short before the frame that acts
/// on it" and "every handler ran and none of them acted" have one symptom — a UI verb nobody
/// called — and three different fixes. Only the engine can see which, and only at the fire.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventReach {
/// Frames registered for the event when the dispatch began.
pub registered: usize,
/// Frames whose handler actually ran. Less than `registered` is a cut-short walk; a handler
/// that raised still counts, because it ran.
pub reached: usize,
/// The frame the walk stopped on, named if it has a name: the registration a handler had just
/// removed. `None` when the walk ran to the end. It names the addon to look at, which the two
/// counts alone cannot.
pub cut_at: Option<String>,
}
impl UiScript {
/// Queue an event for the start of the next tick, behind everything queued earlier and before
/// that tick's `OnUpdate` pass. A drain runs a step behind its input, so firing at once would
/// overtake events that input already queued: the soulbind confirm for an item placed into a
/// worn slot must follow that placement's `CURSOR_UPDATE`, whose handler hides it
/// (`UIParent.lua:357-361`).
pub fn queue_event(&mut self, event: &str, args: Vec<ScriptValue>) {
self.model_mut()
.pending_events
.push((event.to_string(), args));
}
/// Fire an event to every frame registered for it, shown or not, setting both the
/// `this`/`event`/`arg1..argN` globals and the `(self, event, ...)` arguments (`0x704d50`,
/// `0x704f10`); handler errors go to [`UiScript::errors`]. Frames fire in registration order,
/// as `SignalEvent` (`0x703e50`) walks its tail-appended list (`0x7052d0`), and consumers rely
/// on it: both ZoneText frames write `PVPInfoTextString` on one event, and the last one wins.
///
/// The walk steps by the next listener saved before each handler runs (`0x703ee8`), so a
/// handler that unregisters itself does not skip its successor. Deviation: a handler that
/// unregisters the saved next ends the dispatch, because the reference then reads a freed node.
pub fn fire_event(&mut self, event: &str, args: Vec<ScriptValue>) {
fire_event_into(&self.lua, event, args);
}
/// Whether any frame is registered for `event`.
///
/// For a producer whose work is only worth doing if somebody is listening — nampower's
/// combat-log family, whose own CVar switches upstream now calls *deprecated* in favour of
/// exactly this test ("registering the event is preferred"). The same lookup
/// `GetFramesRegisteredForEvent` reads, so the two can never disagree about who is listening.
#[must_use]
pub fn has_listener(&self, event: &str) -> bool {
self.lua
.app_data_ref::<Model>()
.expect("model app_data set")
.event_to_frames
.get(event)
.is_some_and(|f| !f.is_empty())
}
/// [`Self::fire_event`], answering what the dispatch reached ([`EventReach`]). Nothing about
/// the fire changes; the number is simply kept rather than dropped, for the one caller that
/// has to explain a silent event afterwards (`benilla-app`'s `area::feed_zone_events`).
pub fn fire_event_reach(&mut self, event: &str, args: Vec<ScriptValue>) -> EventReach {
fire_event_into(&self.lua, event, args)
}
}
/// [`UiScript::fire_event`] for a caller holding `&Lua`, such as a Lua binding.
pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) -> EventReach {
let model_mut = || lua.app_data_mut::<Model>().expect("model app_data set");
// `WOW_UI_EVENTS=<secs>`: this whole fan-out is one fire of `event`, which is the unit a
// dropped frame is measured in. Inert and one atomic load unarmed.
let _dispatch =
super::event_prof::armed().then(|| super::event_prof::Dispatch::open(lua, event));
let mut reach = EventReach::default();
{
let mut at = {
let model = model_mut();
let list = model.event_to_frames.get(event);
reach.registered = list.map_or(0, Vec::len);
list.and_then(|l| l.first().copied())
};
while let Some(h) = at {
let mut model = model_mut();
// A saved next that the previous handler unregistered ends the walk.
let Some(pos) = model
.event_to_frames
.get(event)
.and_then(|l| l.iter().position(|&x| x == h))
else {
reach.cut_at = Some(
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "an unnamed frame".to_string()),
);
break;
};
let next = model
.event_to_frames
.get(event)
.and_then(|l| l.get(pos + 1).copied());
let id = model.frame_id(h);
drop(model);
reach.reached += 1;
if let Err(e) = event::fire_event_handler(lua, id, event, &args) {
model_mut().record_script_error(e.to_string());
}
at = next;
}
}
event::fire_all_event_listeners(lua, event, &args);
reach
}
impl super::UiScript {
/// The current `GetTime()` value in seconds; the app stamps absolute expiries with it, such as
/// an aura's `expirationTime`.
pub fn now(&self) -> f64 {
self.lua.globals().get("__benilla_now").unwrap_or(0.0)
}
/// Start this VM's `GetTime()` clock at `secs`, so a rebuilt VM (a relog, a `ReloadUI`) keeps
/// the process's clock. The reference's `GetTime` (`0x515ea0`, through `0x42c010` and
/// `0x42b790`) is `GetTickCount` scaled by 0.001, an OS clock that never restarts, which stock
/// `Cooldown.lua` relies on (`start > 0`). Set once, at construction; after that only
/// [`Self::tick`] moves it.
pub fn set_now(&mut self, secs: f64) {
if let Err(e) = self.lua.globals().set("__benilla_now", secs) {
self.push_error(e);
}
}
/// Advance a frame: the `GetTime()` clock, the edit boxes and queued events, then
/// `OnUpdate(self, elapsed)` on every visible frame that has one (`0x704f10`), then the
/// engine's own fades, model panes and hover.
pub fn tick(&mut self, elapsed: f32) {
// This frame's Lua starts here, for [`Self::set_runaway_limit`]: a frame is not charged
// the one before it, and a handler reached later in the frame (a click, a fired event)
// measures from this mark, which can only make the bound tighter, never looser.
self.mark_vm_entry();
// `WOW_UI_TICK=<secs>`: the phase split ([`super::tick_prof`]). Inert when off, and every
// phase below marks, so the row sums to the `[ui-cost] tick=` figure.
let mut split = Split::open(&self.lua);
// A `TargetByName` answers `"target"` for the rest of the Lua frame it ran in and no
// longer: by the next tick the app has drained the selection and pushed the real token
// snapshot, so an override left standing could only contradict it.
self.model_mut().target_override = None;
let clock = {
let g = self.lua.globals();
let now: f64 = g.get("__benilla_now").unwrap_or(0.0);
g.set("__benilla_now", now + f64::from(elapsed))
};
if let Err(e) = clock {
self.push_error(e);
}
split.mark(Phase::Clock);
// The focused edit box's caret blink (`0x77a790`, on the client's frame tick).
editbox::tick_blink(&self.lua, elapsed);
// Then the flush (`0x77d3e0`): a multi-line box's relayout sizes it to its text
// (`0x77d4d0`), then the `OnTextChanged`s an edit only marked fire. benilla hoists it ahead
// of the OnUpdate sweep; the reference runs it per box inside the pump's walk, from the
// box's update (`0x77a790`, vtable `+0x38`), after that box's own Lua `OnUpdate`
// (`0x77a79a`, then `0x77a7a1`).
editbox::relayout_multi_line(&self.lua);
editbox::drain_text_changed(&self.lua);
// Then the caret flush (`0x77d3e0` → `0x77da80`): `OnCursorChanged` when the caret moved,
// which `ScrollingEdit_OnUpdate` scrolls by.
editbox::drain_cursor_changed(&self.lua);
split.mark(Phase::EditBox);
// Events queued since the last tick fire before this tick's OnUpdate.
let pending = std::mem::take(&mut self.model_mut().pending_events);
split.count(Tally::Events, pending.len());
for (event, args) in pending {
if split.recording() {
// The listener steps this dispatch will take, counted before it runs because a
// handler may register or unregister: `fire_event_into`'s walk re-finds its
// position by a linear scan each step, and a frame registered with no `OnEvent`
// script is dispatched to and returns before the handler profiler's window opens,
// so neither `[ui-handlers]` nor a handler row can account for this fan-out.
let model = self.model_ref();
let steps = model.event_to_frames.get(&event).map_or(0, Vec::len)
+ model.all_event_frames.len();
drop(model);
split.count(Tally::EventSteps, steps);
}
self.fire_event(&event, args);
}
split.mark(Phase::Events);
let ids: Vec<u32> = {
let mut model = self.model_mut();
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
let frames: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
.collect();
if model
.on_update_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(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`).
ids.sort_unstable();
ids
};
split.count(Tally::Dispatch, ids.len());
if split.recording() {
let model = self.model_ref();
let (frames, regions) = model.arena.live_counts();
let roster = model.on_update_frames.len();
drop(model);
split.count(Tally::Roster, roster);
split.count(Tally::Frames, frames);
split.count(Tally::Regions, regions);
}
split.mark(Phase::Roster);
for id in ids {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
self.push_error(e);
}
}
split.mark(Phase::Dispatch);
self.tick_model_panes(elapsed);
split.mark(Phase::Panes);
// 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
// its height, so its rows are read from the resolved rect before the mutable walk.
let mut model = self.model_mut();
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
split.count(Tally::Ticked, ticked.len());
let message_frames: Vec<(FrameHandle, usize)> = ticked
.iter()
.copied()
.filter(|&h| {
model
.arena
.frame(h)
.is_some_and(|f| matches!(f.kind_state, crate::widget::KindState::Message(_)))
})
.map(|h| (h, Self::message_viewport_rows(&model, h)))
.collect();
for (h, viewport_rows) in message_frames {
if let Some(crate::widget::KindState::Message(mf)) =
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
{
mf.tick(elapsed);
mf.trim_to_viewport(viewport_rows);
}
}
for &h in &ticked {
let Some(frame) = model.arena.frame_mut(h) else {
continue;
};
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
smf.tick(elapsed);
}
}
drop(model);
split.mark(Phase::Messages);
// Tooltip fades: `FadeOut`'s ramp and the hide at its end.
tooltip::tick_fades(&self.lua);
split.mark(Phase::Tooltips);
// A frame hid or showed under a still cursor: re-run the hover walk at the saved position,
// as the reference's pump tail does (`0x765650` → `0x7660d0`). The hidden frame's OnLeave
// already fired, so only the new winner's `OnEnter` fires.
let repick = {
let mut model = self.model_mut();
let due = model.hover_repick;
model.hover_repick = false;
due.then_some(model.cursor_pos)
};
if let Some((x, y)) = repick {
split.count(Tally::Repick, 1);
self.mouse_move(x, y);
}
split.mark(Phase::Hover);
// ClassicAPI's `C_Timer` (`compat/classic_api.rs`): after everything else, so a callback
// armed this same tick (by an OnUpdate or an event handler above) is never due before its
// first real tick elapses.
if split.recording() {
split.count(Tally::Timers, self.model_ref().timers.len());
}
self.drain_timers();
split.mark(Phase::Timers);
// `WOW_UI_HANDLERS=<secs>`: last, so its report covers everything this tick fired.
self.report_handler_profile(elapsed);
// `WOW_UI_RAISES=<secs>`: beside it, and for the same window.
self.report_raise_profile(elapsed);
// `WOW_UI_VERBS=<secs>`: the same window one level down — which binding the handlers above
// spent their self time inside.
super::verb_prof::report_window(elapsed);
// `WOW_UI_EVENTS=<secs>`: the same window one level *up* — which event the `OnEvent` rows
// above were answering, since the libraries fan out from one frame.
self.report_event_profile(elapsed);
// `WOW_UI_TICK=<secs>`: this tick's phases fold in before the report, so the row the
// report prints includes the tick that triggered it.
split.mark(Phase::Reports);
split.close(&self.lua);
self.report_tick_profile(elapsed);
}
/// Fire every `C_Timer` entry whose deadline has passed, against [`Self::now`].
/// `iterations_left` counts fires still owed *including the one about to run*: consumed first,
/// so a ticker armed for 3 fires three times then stops, never a fourth "0 left" fire. A
/// callback that reads its own remaining count sees the value after this fire, not before, and
/// an errored callback still stays armed (or still drops, if this was its last), same as a
/// cooldown's own bookkeeping would.
///
/// A `:Cancel()` taken *from inside* a callback — the self-stopping ticker, the whole point of
/// the handle — cannot reach its row's `cancelled` flag, because the `mem::take` below has the
/// list: it lands on [`Model::timers_cancelled`](super::Model::timers_cancelled) instead, and
/// this pass honours it both for rows it has not reached yet and for rows it already kept.
fn drain_timers(&mut self) {
let now = self.now();
let all = std::mem::take(&mut self.model_mut().timers);
// `WOW_UI_TICK`: which sites the armed rows belong to, before any of them fires. A site's
// row count is what tells a leak of cheap timers from one expensive callback.
super::tick_prof::note_timer_rows(&self.lua, all.iter().map(|t| t.site.clone()));
let mut keep = Vec::with_capacity(all.len());
for mut t in all {
if t.cancelled || self.timer_cancelled_mid_drain(t.id) {
let _ = self.lua.remove_registry_value(t.callback);
continue;
}
if t.fire_at > now {
keep.push(t);
continue;
}
if let Some(n) = t.iterations_left {
t.iterations_left = Some(n - 1);
}
// `After`/`NewTimer` (no interval) always stop after their one fire; a `NewTicker`
// stops once this fire consumed its last owed iteration.
let is_last = t.interval.is_none() || t.iterations_left == Some(0);
if !is_last {
if let Some(interval) = t.interval {
t.fire_at = now + interval;
}
}
// Through Lua's own `xpcall` ([`super::event::protected_call`]), never mlua's: mlua's
// message handler searches the whole global table for a name for each stack level, so
// a `C_Timer` callback that raises cost 6.1 ms a fire on the shipped addon set against
// 7.5 us here (`benilla-app`'s `addon_harness::tick_bench`).
// `WOW_UI_TICK` splits this phase per callback site ([`super::tick_prof`]): the fire is
// the only observable here, since the call below is not a frame script and the handler
// profiler therefore never opens a window on it. The clock read is skipped entirely
// while the split is off.
let at = super::tick_prof::armed().then(std::time::Instant::now);
let result: mlua::Result<()> = self
.lua
.registry_value::<mlua::Function>(&t.callback)
.and_then(|f| super::event::protected_call(&self.lua, &f, &[]));
if let Some(at) = at {
let ns = at.elapsed().as_nanos() as u64;
super::tick_prof::charge_timer(&self.lua, t.site.as_ref(), ns);
}
if let Err(e) = result {
self.push_error(e);
}
if is_last {
let _ = self.lua.remove_registry_value(t.callback);
} else {
keep.push(t);
}
}
// Then the cancels this pass could not route to a flag, applied to what it kept. A ticker
// that stopped itself is already in `keep` by the time its own `:Cancel()` has been seen,
// and so is a row some *later* callback in this same drain cancelled, so the ledger has to
// be read after the loop as well as inside it. Rows armed *during* the drain are back in
// `model.timers`, where `:Cancel()` reaches their flag directly — nothing to do for those.
let cancelled = std::mem::take(&mut self.model_mut().timers_cancelled);
let mut timers = Vec::with_capacity(keep.len());
for t in keep {
if cancelled.contains(&t.id) {
let _ = self.lua.remove_registry_value(t.callback);
} else {
timers.push(t);
}
}
// Merge, don't assign. A callback can arm new timers — a self-rescheduling `After` chain,
// or a `RunNextFrame` raised from inside another timer — and those pushed onto the vec this
// drain emptied with `mem::take`. Overwriting it with `keep` discarded them, so such a
// chain fired exactly once and then stopped dead, with nothing logged.
let armed_during_drain = std::mem::take(&mut self.model_mut().timers);
timers.extend(armed_during_drain);
self.model_mut().timers = timers;
}
/// Whether `id` was cancelled while this drain held the timer list. Empty in all but the pass
/// a cancel landed in, so the scan costs a `Vec::is_empty` on the ordinary tick.
fn timer_cancelled_mid_drain(&self, id: u64) -> bool {
let model = self.model_ref();
!model.timers_cancelled.is_empty() && model.timers_cancelled.contains(&id)
}
}
impl UiScript {
/// The model panes' per-frame pass, for visible panes, in the reference's order: the pane's
/// `OnUpdate` (`0x76d7f0`) advances its clock by `trunc(elapsed · 1000)` ms, the paint
/// (`0x76d1a0`) fires `OnUpdateModel`, and the animate fires `OnAnimFinished` (`0x76cdc0`) once
/// per arm when the armed sequence completes naturally, a loop's first pass included
/// (`0x719370`). A hidden pane's clock stands still. Completion is read after `OnUpdateModel`,
/// whose handler may re-arm.
fn tick_model_panes(&mut self, elapsed: f32) {
let dt_ms = (elapsed * 1000.0).trunc().max(0.0) as u64;
let update_ids: Vec<u32> = {
let mut model = self.model_mut();
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
for h in ticked {
let Some(f) = model.arena.frame_mut(h) else {
continue;
};
if !f.effective_visible {
continue;
}
if let crate::widget::KindState::Model(m) = &mut f.kind_state {
m.clock_ms += dt_ms;
}
}
// Kept by `SetScript`; a destroyed frame's handle compacts out on its first miss.
if model
.on_update_model_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_model_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_model_frames = live;
}
// Only visible Model panes with a subject, resident or streaming: the paint that fires
// the handler (`0x76d1bc`) is gated on `[widget+0x318] ≠ 0` (`0x76d24c`), which is the
// pane's **loaded model**, not the file string.
//
// A `SetUnit` pane has one of those too, and keeps no path at all — its subject is
// the unit it pinned (`modelframe.rs`'s `SetUnit` writes `unit`/`unit_guid` and nils
// `path`) — so gating on `path` alone left every unit pane's handler permanently
// inert. Not cosmetic: unitscan's alert model is made visible *only* from inside its
// own `OnUpdateModel` (`unitscan.lua:273-284` counts ten paints as a settle delay
// after `reset()` has set alpha 0), so the alert drew a correctly placed, correctly
// sized, fully transparent model and the horn played over an empty card. A pane with
// no subject still fires nothing, which is the `≠ 0` half of the gate. On any other
// kind the script is inert.
let frames: Vec<FrameHandle> = model
.on_update_model_frames
.iter()
.copied()
.filter(|&h| {
model.arena.frame(h).is_some_and(|f| {
f.effective_visible
&& match &f.kind_state {
crate::widget::KindState::Model(m) => {
m.path.is_some() || model.unit_pane_subject(m).is_some()
}
_ => false,
}
})
})
.collect();
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
ids.sort_unstable(); // creation order, as in the OnUpdate sweep
ids
};
for id in update_ids {
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnUpdateModel", Vec::new()) {
self.push_error(e);
}
}
let finished_ids: Vec<u32> = {
let mut model = self.model_mut();
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
let mut due: Vec<FrameHandle> = Vec::new();
for h in ticked {
let Some(f) = model.arena.frame(h) else {
continue;
};
if !f.effective_visible {
continue;
}
let crate::widget::KindState::Model(m) = &f.kind_state else {
continue;
};
let Some(path) = m.path.as_deref() else {
continue;
};
let Some(facts) = model.model_facts.get(&crate::widget::model_key(path)) else {
continue;
};
if m.completion_due(facts) {
due.push(h);
}
}
let mut ids = Vec::with_capacity(due.len());
for h in due {
if let Some(crate::widget::KindState::Model(m)) =
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
{
if let Some(a) = &mut m.armed {
a.finished = true;
}
}
ids.push(model.frame_id(h));
}
ids.sort_unstable();
ids
};
for id in finished_ids {
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnAnimFinished", Vec::new())
{
self.push_error(e);
}
}
}
}