Files
benilla-octo/crates/benilla-ui/src/script/compat/classic_api/timer.rs
T
gromandClaude Opus 5 4974326381 Bring the nampower, UnitXP_SP3 and ClassicAPI ports up to the installed builds
The three DLLs beside the client were updated; re-measured each against the
thing with evidence behind it rather than against a wiki.

nampower's own string pool is exact — `^NP_[A-Za-z0-9]+$` is the CVar list and
`Usage:` the arity of every function — and both came back identical to this
tree: 49/49 CVars, 63/63 functions. The gap was events. Four built:

  BUFF_ADDED_OTHER / BUFF_REMOVED_OTHER   ui_aura.rs, AuraEdge::OtherBuff
  KEY_DOWN / KEY_UP                       bindings.rs, key_event_code

The buff pair was argued against here before, on the cost of diffing the
helpful half of every streamed unit for events nothing registered. Answered
rather than overruled: both are gated on `has_listener`, which is the test
upstream now recommends over its own deprecated NP_Enable*Events CVars. The key
pair uses the client's own code table, which `chord::key_code` already held with
a comment saying it was the fact these events were blocked on.

UnitXP_SP3 is packed, so its contract comes from the wiki plus the call sites in
the installed addons — and those call sites exposed a live bug. Every
`UnitXP("target", …)` queued a SelectionRequest, but the mod selects INSIDE the
call and SuperCleveRoidMacros steps the cycle and reads `"target"` back on the
next line, fifty times in one Lua frame. Queued, every pass read the same unit
and the scan reported one enemy however many were there — truthy return, no
error. The pick now resolves in-call from a pool the app publishes
(`feed_unitxp_scan`), exactly the bargain TargetByName already strikes. All nine
target modes are built where four were; `behindThreshold` is real (the reach
feed publishes the angle now, not a verdict); `timer` rides the one timer queue;
`notify` reaches winit's user-attention request; `version`,
`performanceProfile` and a non-raising `debug` answer.

ClassicAPI carries its whole Lua addon gzipped in `.rsrc` — 173 files, a length
header then the bodies, keyed by the paths in its string pool — so the spec for
the installed build is on disk. `scripts/dll-surface.py --unpack` writes it out.
Read against a LIVE VM (not `UiScript::new()`, which is engine-only), the gap is
898 globals, and almost all of it is either a locale string or a subsystem this
client models differently. What is between those is now in
`compat/classic_api/shared_xml.rs`: the pure Blizzard_SharedXML helpers, in
1.12's grammar, from the shipped addon's own source. `tostringall` stays absent
on `tests/stdlib.rs`'s own rule.

The native half is mostly widget methods; intersected with what the corpus
actually calls it is four, of which three are built (`SetResizeBounds` over
1.12's own resize pair, `GetNormalFontObject` — which needed 1.12's missing
`GetTextFontObject` implemented first — and a bare `IsEquippedItem`) and
`SetMotionScriptsWhileDisabled` deliberately is not, because both its call sites
guard on it and the behaviour behind it does not exist here.

Also measured and recorded: every addon-load warning in grom's own logs is a
third-party optional dependency he has not installed or an addon's own packaging
bug, not a defect on this side. docs/ADDON_COMPAT.md has the table.

Ambiguity ceiling 4,956 -> 4,966, measured: nine for `feed_unitxp_scan` against
transform writers on non-networked lanes, one for the attention drain against a
system that also reads WinitWindows and neither writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-06 09:28:56 -07:00

404 lines
16 KiB
Rust

//! `C_Timer`: `After`, `NewTimer`, `NewTicker`. Not a 1.12 verb, and probably the single
//! most-depended-on piece of ClassicAPI in practice — most "modern-ish" addons use `After` for
//! some delayed action. Drained each tick by [`super::super::super::UiScript::tick`] against
//! `GetTime()`, so a timer's deadline is exactly as real-time-accurate as everything else this
//! client times by that clock, and a `ReloadUI`/relog (a fresh VM) drops every pending one, same
//! as the reference's own `C_Timer` handles never surviving one.
use mlua::{Function, Lua, Table};
use crate::script::{Model, PendingTimer};
fn now(lua: &Lua) -> f64 {
lua.globals().get("__benilla_now").unwrap_or(0.0)
}
/// Arm a row, returning its id (`NewTimer`/`NewTicker`'s handle keys on it for `:Cancel()`;
/// `After` just discards it, since it returns nothing).
fn arm(
lua: &Lua,
seconds: f64,
callback: Function,
interval: Option<f64>,
iterations_left: Option<u32>,
) -> mlua::Result<u64> {
// Only while `WOW_UI_TICK` is splitting the tick: a `lua_getinfo` and a `format!` per arm is
// nothing beside a callback, but `RunNextFrame` arms one of these every frame and an
// instrument must be free when it is off.
let site = crate::script::tick_prof::armed().then(|| callback_site(&callback));
let callback = lua.create_registry_value(callback)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let id = model.next_timer_id;
model.next_timer_id += 1;
model.timers.push(PendingTimer {
id,
fire_at: now(lua) + seconds.max(0.0),
interval,
iterations_left,
cancelled: false,
callback,
site,
});
Ok(id)
}
/// `<chunk>:<line the callback was written on>`, the label the per-timer rows of `[ui-tick]` group
/// by. `lua_getinfo` on the function itself, so it names the body rather than the arm site — see
/// [`PendingTimer::site`].
fn callback_site(callback: &Function) -> std::rc::Rc<str> {
let info = callback.info();
let src = info
.short_src
.as_deref()
.or(info.source.as_deref())
.unwrap_or("?");
// Strip the prefix every addon chunk shares, so the row is the part that differs.
let src = src
.trim_start_matches('@')
.strip_prefix("Interface\\AddOns\\")
.unwrap_or(src);
std::rc::Rc::from(format!("{src}:{}", info.line_defined.unwrap_or(0)).as_str())
}
/// `NewTimer`/`NewTicker`'s return: a table whose `:Cancel()` flags the row by id: the drain
/// (`UiScript::tick`'s `drain_timers`) skips and drops a cancelled row on its next pass rather
/// than this call reaching into `model.timers` to remove it mid-iteration.
///
/// The row may not be in `model.timers` at all, though: the drain takes the whole list out with
/// `mem::take` for the length of its pass, so **every cancel taken from inside a timer callback**
/// — including the self-stopping `ticker:Cancel()` that is the verb's commonest use — looks up a
/// row that is not there. Those land on [`Model::timers_cancelled`] instead, which the drain
/// consults before it keeps anything. Finding neither is not an error: `:Cancel()` on a one-shot
/// that already fired is a no-op upstream too.
fn handle(lua: &Lua, id: u64) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set(
"Cancel",
lua.create_function(move |lua, _: mlua::Value| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match model.timers.iter_mut().find(|r| r.id == id) {
Some(row) => row.cancelled = true,
None => model.timers_cancelled.push(id),
}
Ok(())
})?,
)?;
Ok(t)
}
/// Arm a row whose callback is a **global looked up by name at fire time**, passed the row's own
/// id — UnitXP_SP3's `UnitXP("timer", "arm", …)` contract (`compat/unitxp.rs`). On this queue
/// rather than a second one, for the reason `RunNextFrame` is.
pub(in crate::script) fn arm_named(
lua: &Lua,
seconds: f64,
interval: Option<f64>,
name: &str,
) -> mlua::Result<u64> {
let name = name.to_string();
// The id is only known after `arm`, so the closure reads it out of a cell the arm fills.
let id_cell = std::rc::Rc::new(std::cell::Cell::new(0u64));
let seen = id_cell.clone();
let callback = lua.create_function(move |lua, ()| {
let f: Option<Function> = lua.globals().get(name.as_str())?;
match f {
Some(f) => f.call::<()>(seen.get()),
// A name that no longer names a function is a silent no-op, as the mod's is: the
// timer keeps running, since the addon may define it later.
None => Ok(()),
}
})?;
let id = arm(lua, seconds, callback, interval, None)?;
id_cell.set(id);
Ok(id)
}
/// Flag a row cancelled by id, both where the drain can see it and where it cannot — the same
/// two places [`handle`]'s `Cancel` writes, and for the same reason.
pub(in crate::script) fn cancel(lua: &Lua, id: u64) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match model.timers.iter_mut().find(|r| r.id == id) {
Some(row) => row.cancelled = true,
None => model.timers_cancelled.push(id),
}
}
/// How many rows are armed and not cancelled: `UnitXP("timer", "size")`.
pub(in crate::script) fn live_count(lua: &Lua) -> i64 {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.timers.iter().filter(|r| !r.cancelled).count() as i64
}
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set(
"After",
lua.create_function(|lua, (seconds, callback): (f64, Function)| {
arm(lua, seconds, callback, None, None)?;
Ok(())
})?,
)?;
t.set(
"NewTimer",
lua.create_function(|lua, (seconds, callback): (f64, Function)| {
handle(lua, arm(lua, seconds, callback, None, None)?)
})?,
)?;
t.set(
"NewTicker",
lua.create_function(
|lua, (seconds, callback, iterations): (f64, Function, Option<u32>)| {
handle(lua, arm(lua, seconds, callback, Some(seconds), iterations)?)
},
)?,
)?;
Ok(t)
}
/// `RunNextFrame(callback)`, the bare global — upstream's "do this once, at the start of the next
/// frame". pfUI uses it 5 times to defer work out of the handler that triggered it
/// (`modules/bubbles.lua:17`, `modules/focus.lua:54`, `libs/libdebuff.lua:48`,
/// `api/ui-widgets.lua:1139`, `modules/addonbuttons.lua:401`), and it is what broke the moment the
/// addon's frames were driven by input.
///
/// A zero-delay `After` row *is* next frame here: `arm` sets `fire_at = now`, and the drain
/// (`tick.rs`'s `drain_timers`) only reaches it on its next pass. Deliberately delegating rather
/// than growing a second mechanism — one queue, one drain, one place for a scheduling bug to live.
pub(super) fn install_globals(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"RunNextFrame",
lua.create_function(|lua, callback: Function| {
arm(lua, 0.0, callback, None, None)?;
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
#[test]
fn c_timer_after_fires_once_at_its_deadline_not_before() {
let mut s = UiScript::new().unwrap();
s.run("fired = 0 C_Timer.After(3, function() fired = fired + 1 end)")
.unwrap();
s.tick(2.0);
assert_eq!(s.eval::<i64>("return fired").unwrap(), 0, "not due yet");
s.tick(1.5); // now 3.5s, past the 3s deadline
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1);
s.tick(10.0); // well past — a one-shot never fires twice
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1);
}
#[test]
fn c_timer_new_ticker_repeats_and_stops_at_its_iteration_count() {
let mut s = UiScript::new().unwrap();
s.run("fired = 0 C_Timer.NewTicker(1, function() fired = fired + 1 end, 3)")
.unwrap();
for _ in 0..3 {
s.tick(1.0);
}
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
3,
"all 3 iterations ran"
);
s.tick(5.0);
assert_eq!(s.eval::<i64>("return fired").unwrap(), 3, "stopped after 3");
}
#[test]
fn c_timer_new_timer_cancel_stops_it_before_it_fires() {
let mut s = UiScript::new().unwrap();
s.run(
"fired = 0 \
timer = C_Timer.NewTimer(2, function() fired = fired + 1 end)",
)
.unwrap();
s.tick(1.0);
s.run("timer:Cancel()").unwrap();
s.tick(5.0);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
0,
"cancelled before its deadline"
);
}
#[test]
fn run_next_frame_waits_for_the_next_tick_then_fires_once() {
let mut s = UiScript::new().unwrap();
s.run("fired = 0 RunNextFrame(function() fired = fired + 1 end)")
.unwrap();
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
0,
"not this frame — the point of the verb is to get out of the current one"
);
s.tick(0.0);
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1);
s.tick(10.0);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
1,
"once, not a ticker"
);
}
/// A callback arming a timer is the common shape — a self-rescheduling `After` chain, or a
/// `RunNextFrame` raised from inside another one. The drain empties `model.timers` with
/// `mem::take` before iterating, so anything armed mid-pass lands in the emptied vec and used to
/// be thrown away when the drain wrote its survivors back: the chain fired once and stopped
/// dead, silently. `drain_timers` merges instead.
#[test]
fn a_timer_armed_from_inside_a_callback_survives_the_drain() {
let mut s = UiScript::new().unwrap();
s.run(
"fired = 0 \
function step() \
fired = fired + 1 \
if fired < 4 then RunNextFrame(step) end \
end \
RunNextFrame(step)",
)
.unwrap();
for _ in 0..5 {
s.tick(0.1);
}
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
4,
"the chain must keep re-arming itself, not fire once and vanish"
);
}
/// Merging has two sides to lose: the rows that were not due (the old assignment kept these)
/// and the rows armed mid-pass (it dropped these). One drain pass must preserve both at once,
/// which is what a merge written the other way round — `armed.extend(keep)` dropped on the
/// floor, or an assignment back to either half alone — would get wrong.
#[test]
fn one_pass_keeps_both_the_not_due_rows_and_the_ones_armed_during_it() {
let mut s = UiScript::new().unwrap();
s.run(
"late, deferred = 0, 0 \
C_Timer.After(5, function() late = late + 1 end) \
RunNextFrame(function() RunNextFrame(function() deferred = deferred + 1 end) end)",
)
.unwrap();
// One pass: fires the outer RunNextFrame, which arms the inner one, while the 5s row waits.
s.tick(0.1);
assert_eq!(
(
s.eval::<i64>("return late").unwrap(),
s.eval::<i64>("return deferred").unwrap()
),
(0, 0),
"neither has fired yet — one armed mid-pass, one not due"
);
s.tick(0.1);
assert_eq!(
s.eval::<i64>("return deferred").unwrap(),
1,
"the row armed during the previous pass survived it"
);
s.tick(6.0);
assert_eq!(
s.eval::<i64>("return late").unwrap(),
1,
"and so did the one that was merely not due"
);
}
/// The whole reason `:Cancel()` exists, and the shape it is used in almost everywhere: a
/// ticker that stops *itself*, from inside its own callback, on the fire that finished the job.
/// The drain takes the timer list out with `mem::take` for the length of its pass, so that
/// `:Cancel()` looked up an id no longer in `model.timers` and silently did nothing — the
/// ticker ran for the rest of the session. A zero-interval ticker fires every tick, so the
/// cost of losing the cancel is the callback's whole body, every frame, forever:
/// `ItemMixin:ContinueOnItemLoad`'s poller resolved pfQuest's hearthstone check and then
/// re-ran its `BuildNameIndex` rebuild per frame, 40,371 us of a 40,841 us tick.
#[test]
fn a_ticker_that_cancels_itself_from_inside_its_own_callback_stops_there_and_then() {
let mut s = UiScript::new().unwrap();
s.run(
"fires = 0 \
local t \
t = C_Timer.NewTicker(0, function() \
fires = fires + 1 \
if fires >= 3 then t:Cancel() end \
end)",
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
}
assert_eq!(
s.eval::<i64>("return fires").unwrap(),
3,
"the third fire cancelled the ticker, so there is no fourth"
);
assert_eq!(
s.model_ref().timers.len(),
0,
"and the row is gone, not merely inert: a cancel that only stopped the *fires* would \
leave the registry value and the drain's per-row walk behind for the session"
);
}
/// The other half of the same ledger: a callback can cancel a timer that is not itself, and
/// that row may already have fired *earlier in this same drain* — in which case the pass has
/// already decided to keep it. So the cancel has to be honoured after the loop as well as
/// inside it, which an implementation that only checked each row before firing would miss.
#[test]
fn a_callback_can_cancel_a_ticker_that_already_fired_earlier_in_the_same_drain() {
let mut s = UiScript::new().unwrap();
// `first` is armed first, so the drain reaches and keeps it before `second` runs.
s.run(
"first_fires, second_fires = 0, 0 \
victim = C_Timer.NewTicker(0, function() first_fires = first_fires + 1 end) \
C_Timer.NewTicker(0, function() \
second_fires = second_fires + 1 \
if second_fires == 1 then victim:Cancel() end \
end)",
)
.unwrap();
for _ in 0..5 {
s.tick(0.016);
}
assert_eq!(
s.eval::<i64>("return first_fires").unwrap(),
1,
"cancelled during the pass that fired it once, so it never fires again"
);
assert_eq!(
s.eval::<i64>("return second_fires").unwrap(),
5,
"the canceller itself is untouched"
);
}
/// A cancel reaching neither a live row nor the drain's ledger must stay a quiet no-op, as
/// upstream's is: cancelling a one-shot that already fired is ordinary addon code (a frame
/// tears down and cancels whatever it armed), and an id ledger that grew without bound or a
/// raise here would both be worse than doing nothing.
#[test]
fn cancelling_a_one_shot_that_already_fired_is_a_quiet_no_op() {
let mut s = UiScript::new().unwrap();
s.run("fired = 0 t = C_Timer.NewTimer(0, function() fired = fired + 1 end)")
.unwrap();
s.tick(0.1);
s.run("t:Cancel() t:Cancel()")
.unwrap_or_else(|e| panic!("a late cancel must not raise: {e}"));
s.tick(0.1);
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1, "fired once");
}
}