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
553 lines
27 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|
|
}
|