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
404 lines
16 KiB
Rust
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");
|
|
}
|
|
}
|