Files
benilla-octo/crates/benilla-ui/src/script/tick_prof.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

729 lines
28 KiB
Rust

//! The per-phase split of one [`UiScript::tick`](super::UiScript::tick) (`WOW_UI_TICK=<secs>`):
//! where the frame's VM time goes when it is **not** in a handler body.
//!
//! **Why this needs an instrument of its own.** `WOW_UI_COST=1`'s `tick=` column is the whole of
//! `UiScript::tick`, and `WOW_UI_HANDLERS=<secs>` attributes every handler *body* fired inside it
//! ([`super::handler_prof`]). A session where those two disagree — `tick=38000` against
//! `self 440 us/f` — says only "37 ms is engine work", and `tick` has eleven engine phases: the
//! clock, the edit-box flush, the queued-event drain, the `OnUpdate` roster build, the dispatch
//! loop's own wrapper, the model panes, the message fades, the tooltip fades, the hover re-pick,
//! the `C_Timer` drain and the reports. One number cannot name which, and the phases that can go
//! wide (the hover walk is O(frames), the re-pick fires on any show or hide) are exactly the ones
//! a bench does not reproduce, because they need a cursor over a live interface.
//!
//! So this charges every microsecond of a tick to exactly one [`Phase`] and prints the row per
//! frame, beside the counts that explain it — dispatches, re-picks, armed timers. One run names
//! the phase.
//!
//! **And then the phase.** Naming `timers` was only half an answer: `C_Timer`'s drain fires
//! callbacks through [`super::event::protected_call`] rather than through a frame script, so
//! [`super::handler_prof`] never sees one and the whole phase was a single opaque number — a
//! session reported 40,371 us of a 40,841 us tick in it with nothing to say which of the eight
//! armed rows that was. So the drain also charges each fire to its callback's **definition site**
//! ([`charge_timer`]) and counts the live rows that share it ([`note_timer_rows`]), and the report
//! prints the costliest few. One run names the timer, and a row whose live count climbs names a
//! leak rather than a slow body.
//!
//! Off, it is one relaxed atomic load per tick and nothing else ([`armed`]).
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use mlua::Lua;
use super::UiScript;
/// Whether any VM in the process splits its tick: process-wide, so the per-tick check needs no
/// `app_data` lookup, as the handler profiler's gate is.
static ARMED: AtomicBool = AtomicBool::new(false);
#[inline]
pub(super) fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// The report period from `WOW_UI_TICK=<secs>`, read once; unparseable or non-positive is off.
fn env_period() -> Option<f32> {
static PERIOD: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
*PERIOD.get_or_init(|| {
std::env::var("WOW_UI_TICK")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|secs| *secs > 0.0)
})
}
/// One phase of [`UiScript::tick`](super::UiScript::tick), in the order the tick runs them. Every
/// microsecond of a tick is charged to exactly one of these, which is what makes the row a
/// decomposition rather than a sample: the phases must sum to the `[ui-cost] tick=` figure.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Phase {
/// The `GetTime()` advance: two globals, through `lua_gettable`/`lua_settable`.
Clock,
/// The edit-box tick: caret blink, the multi-line relayout and the two dirty-word drains.
EditBox,
/// The queued-event drain, `OnEvent` bodies included.
Events,
/// Building the `OnUpdate` roster: the visibility filter, the id map and the sort.
Roster,
/// The dispatch loop, `OnUpdate` bodies included.
Dispatch,
/// [`UiScript::tick_model_panes`](super::UiScript::tick_model_panes).
Panes,
/// The Message/ScrollingMessage line fades and the viewport trim.
Messages,
/// `GameTooltip`'s `FadeOut` ramp.
Tooltips,
/// The hover re-pick: a whole `hit_test` over the frame tree when one is due.
Hover,
/// `C_Timer`'s drain, callback bodies included.
Timers,
/// The periodic `[ui-handlers]`/`[ui-raises]`/`[ui-tick]` reports themselves.
Reports,
}
/// Every [`Phase`] in tick order; the row prints them in this order so the eye reads the tick.
const PHASES: [Phase; 11] = [
Phase::Clock,
Phase::EditBox,
Phase::Events,
Phase::Roster,
Phase::Dispatch,
Phase::Panes,
Phase::Messages,
Phase::Tooltips,
Phase::Hover,
Phase::Timers,
Phase::Reports,
];
impl Phase {
/// The column label, short enough for one line.
const fn label(self) -> &'static str {
match self {
Phase::Clock => "clock",
Phase::EditBox => "editbox",
Phase::Events => "events",
Phase::Roster => "roster",
Phase::Dispatch => "dispatch",
Phase::Panes => "panes",
Phase::Messages => "messages",
Phase::Tooltips => "tooltips",
Phase::Hover => "hover",
Phase::Timers => "timers",
Phase::Reports => "reports",
}
}
const fn slot(self) -> usize {
self as usize
}
}
/// A count the row prints beside the times, because a phase's cost is only readable against how
/// many times its work ran.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tally {
/// `OnUpdate` handlers dispatched.
Dispatch,
/// Queued events drained.
Events,
/// Hover re-picks run — a whole `hit_test` each.
Repick,
/// Registered listeners the event drain stepped over, the `all-events` roster included. The
/// walk re-finds its position by a linear scan each step (`0x703ee8`), so this is the square
/// root of the engine's own event cost: 40 events over 200 listeners is 1.6 M comparisons a
/// tick that no handler row accounts for.
EventSteps,
/// `C_Timer` entries walked by the drain. Their callbacks go through
/// [`super::event::protected_call`] directly, **not** through `fire`, so `[ui-handlers]`
/// cannot see them and this phase is the only place their cost shows.
Timers,
/// Frames in `ticked_kinds`, walked three times a tick.
Ticked,
/// `on_update_frames` before the visibility filter: the roster `SetScript` keeps, which only
/// a destroyed frame compacts out of.
Roster,
/// Live frames in the arena. Every O(frames) pass in the tick — the roster filter, the hover
/// walk's draw list, the scroll-clip map — is linear in this, so a report without it cannot
/// tell a slow pass from a wide one.
Frames,
/// Live regions in the arena, the other half of the draw list the hover walk builds.
Regions,
}
const TALLIES: [Tally; 9] = [
Tally::Dispatch,
Tally::Events,
Tally::Repick,
Tally::EventSteps,
Tally::Timers,
Tally::Ticked,
Tally::Roster,
Tally::Frames,
Tally::Regions,
];
impl Tally {
const fn label(self) -> &'static str {
match self {
Tally::Dispatch => "dispatch",
Tally::Events => "events",
Tally::Repick => "repick",
Tally::EventSteps => "eventsteps",
Tally::Timers => "timers",
Tally::Ticked => "ticked",
Tally::Roster => "roster",
Tally::Frames => "frames",
Tally::Regions => "regions",
}
}
const fn slot(self) -> usize {
self as usize
}
}
/// The split's state, one per VM. Its own `app_data` slot, not the model's, for the same reason
/// the handler profiler's is: a mark is taken where a model borrow may be held.
#[derive(Default)]
pub(crate) struct TickProf {
/// Whether this VM records; [`armed`] is the process-wide gate.
on: bool,
/// Seconds between reports; `0.0` records and never prints.
period: f32,
/// Wall seconds and ticks since the last report, the divisor of every printed rate.
window: f32,
ticks: u32,
phase_ns: [u64; PHASES.len()],
tally: [u64; TALLIES.len()],
/// The costliest single tick in the window and the phase that dominated it. A mean hides a
/// spike, and the sessions this instrument is for have both: a steady 37 ms with 90 ms and
/// 118 ms frames in it, which are not necessarily the same phase.
peak_ns: u64,
peak_phase: &'static str,
/// The `timers` phase broken out by the callback's definition site, since that phase is the
/// one no other profiler can see into. Keyed by site so a ticker re-armed every drain and a
/// thousand copies of the same closure fold into one row.
timers: BTreeMap<std::rc::Rc<str>, TimerTally>,
}
/// One timer site's share of the `timers` phase over the window.
#[derive(Default, Clone, Copy)]
struct TimerTally {
/// Wall nanoseconds inside the callback, the `protected_call` itself included.
ns: u64,
/// Fires charged here.
fires: u64,
/// Live rows carrying this site, summed over the window's drains: divided by the tick count
/// this is "how many of these are armed", which is what separates one slow callback from a
/// leak of cheap ones.
rows: u64,
/// The single costliest fire, because a row firing once at 100 ms and one firing every tick at
/// 1 ms read identically per tick and need different fixes.
peak_ns: u64,
}
/// One tick's open split: the running mark plus this tick's own accumulators, folded into the VM's
/// [`TickProf`] by [`Split::close`]. Inert — and allocation-free — while the split is off, so the
/// marks can sit in `tick` unconditionally.
pub(super) struct Split {
/// `None` while this VM is not recording, which makes every method a no-op.
at: Option<Instant>,
phase_ns: [u64; PHASES.len()],
tally: [u64; TALLIES.len()],
}
impl Split {
/// Open a split at the top of a tick. Cheap and inert unless this VM is recording.
pub(super) fn open(lua: &Lua) -> Split {
let at =
(armed() && lua.app_data_ref::<TickProf>().is_some_and(|p| p.on)).then(Instant::now);
Split {
at,
phase_ns: [0; PHASES.len()],
tally: [0; TALLIES.len()],
}
}
/// Charge the time since the previous mark to `phase`, and re-arm. Every phase of the tick
/// marks, so the row sums to the tick.
pub(super) fn mark(&mut self, phase: Phase) {
if let Some(at) = self.at {
let now = Instant::now();
self.phase_ns[phase.slot()] += now.saturating_duration_since(at).as_nanos() as u64;
self.at = Some(now);
}
}
/// Whether this split records, so a caller can skip a look-up taken only for a count.
pub(super) fn recording(&self) -> bool {
self.at.is_some()
}
/// Add `n` to a count the report prints beside the times.
pub(super) fn count(&mut self, tally: Tally, n: usize) {
if self.at.is_some() {
self.tally[tally.slot()] += n as u64;
}
}
/// Fold this tick into the VM's window. Called at the very end of a tick, after the last mark.
pub(super) fn close(self, lua: &Lua) {
if self.at.is_none() {
return;
}
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
return;
};
prof.ticks += 1;
let total: u64 = self.phase_ns.iter().sum();
if total > prof.peak_ns {
prof.peak_ns = total;
prof.peak_phase = PHASES
.iter()
.max_by_key(|p| self.phase_ns[p.slot()])
.map_or("none", |p| p.label());
}
for (slot, ns) in self.phase_ns.iter().enumerate() {
prof.phase_ns[slot] += ns;
}
for (slot, n) in self.tally.iter().enumerate() {
prof.tally[slot] += n;
}
}
}
/// Charge one `C_Timer` fire to the site its callback was written at. Called by
/// [`UiScript::tick`](super::UiScript::tick)'s `drain_timers` around each `protected_call`, which
/// is the only place a timer body's cost is observable: it does not go through `fire`, so
/// [`super::handler_prof`] never opens a window on it.
///
/// Takes and drops the `app_data` borrow itself, because the drain holds none across a callback.
pub(super) fn charge_timer(lua: &Lua, site: Option<&std::rc::Rc<str>>, ns: u64) {
if !armed() {
return;
}
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
return;
};
if !prof.on {
return;
}
// A row armed before the split was (or by a path that skipped the label) still has to show,
// or the rows would not sum to the phase.
let key = site.cloned().unwrap_or_else(|| std::rc::Rc::from("?"));
let row = prof.timers.entry(key).or_default();
row.ns += ns;
row.fires += 1;
row.peak_ns = row.peak_ns.max(ns);
}
/// Count the rows armed at the top of one drain, per site. The *count* is the half of the answer
/// the times cannot give: a site at 40 ms a tick is one pathological callback if its row count is
/// 1 and an accumulating leak of cheap ones if it climbs.
pub(super) fn note_timer_rows(lua: &Lua, sites: impl Iterator<Item = Option<std::rc::Rc<str>>>) {
if !armed() {
return;
}
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
return;
};
if !prof.on {
return;
}
for site in sites {
let key = site.unwrap_or_else(|| std::rc::Rc::from("?"));
prof.timers.entry(key).or_default().rows += 1;
}
}
/// One `C_Timer` site's share of the `timers` phase, for [`UiScript::timer_profile`].
#[derive(Clone, Debug, PartialEq)]
pub struct TimerSiteRow {
/// `<chunk>:<line the callback was written on>`.
pub site: String,
/// Microseconds a tick spent inside callbacks from this site.
pub us_per_tick: f64,
/// Fires a tick from this site; below 1.0 for anything slower than the frame.
pub fires_per_tick: f64,
/// Rows from this site armed at the average drain.
pub rows_per_tick: f64,
/// The costliest single fire from this site, in microseconds.
pub peak_us: f64,
}
/// One phase's cost over the split's current window.
#[derive(Clone, Debug, PartialEq)]
pub struct TickPhaseRow {
pub phase: &'static str,
/// Microseconds a tick in this phase.
pub us_per_tick: f64,
}
impl UiScript {
/// Arm or disarm the tick split for this VM, reporting every `period_secs` (`0.0`: no report,
/// read [`UiScript::tick_profile`]). Call it between ticks.
pub fn profile_tick(&self, on: bool, period_secs: f32) {
if on {
ARMED.store(true, Ordering::Relaxed);
}
let Some(mut prof) = self.lua.app_data_mut::<TickProf>() else {
return;
};
*prof = TickProf {
on,
period: period_secs,
..TickProf::default()
};
}
/// This VM's tick phases over the current window, in tick order, costliest first only if the
/// caller sorts: tick order is the readable one.
pub fn tick_profile(&self) -> Vec<TickPhaseRow> {
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
return Vec::new();
};
let per = f64::from(prof.ticks.max(1));
PHASES
.iter()
.map(|p| TickPhaseRow {
phase: p.label(),
us_per_tick: prof.phase_ns[p.slot()] as f64 / 1000.0 / per,
})
.collect()
}
/// The costliest single tick of the window: `(microseconds, the phase that dominated it)`.
pub fn tick_profile_peak(&self) -> (f64, &'static str) {
self.lua
.app_data_ref::<TickProf>()
.map_or((0.0, "none"), |p| (p.peak_ns as f64 / 1000.0, p.peak_phase))
}
/// Ticks since the last report, [`UiScript::tick_profile`]'s denominator.
pub fn tick_profile_ticks(&self) -> u32 {
self.lua.app_data_ref::<TickProf>().map_or(0, |p| p.ticks)
}
/// The `timers` phase by callback site, costliest first — the rows that name which `C_Timer`
/// a wide `timers` phase belongs to. Their `us_per_tick` sums to the phase's own.
pub fn timer_profile(&self) -> Vec<TimerSiteRow> {
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
return Vec::new();
};
let per = f64::from(prof.ticks.max(1));
let mut rows: Vec<TimerSiteRow> = prof
.timers
.iter()
.map(|(site, t)| TimerSiteRow {
site: site.to_string(),
us_per_tick: t.ns as f64 / 1000.0 / per,
fires_per_tick: t.fires as f64 / per,
rows_per_tick: t.rows as f64 / per,
peak_us: t.peak_ns as f64 / 1000.0,
})
.collect();
// Costliest first: unlike the phases, there is no meaningful order among sites, and the
// only question asked of this list is "which one".
rows.sort_by(|a, b| b.us_per_tick.total_cmp(&a.us_per_tick));
rows
}
/// The counts beside the times: `(label, per-tick)` in [`TALLIES`] order.
pub fn tick_profile_counts(&self) -> Vec<(&'static str, f64)> {
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
return Vec::new();
};
let per = f64::from(prof.ticks.max(1));
TALLIES
.iter()
.map(|t| (t.label(), prof.tally[t.slot()] as f64 / per))
.collect()
}
/// Print the `[ui-tick]` block when due, at the end of [`UiScript::tick`]. The `elapsed` is
/// the tick's own, so the window is wall time like the other two reports'.
pub(super) fn report_tick_profile(&mut self, elapsed: f32) {
if !armed() {
return;
}
let due = match self.lua.app_data_mut::<TickProf>() {
Some(mut prof) if prof.on => {
prof.window += elapsed;
prof.period > 0.0 && prof.window >= prof.period
}
_ => false,
};
if !due {
return;
}
// The rows first, then the mutable borrow: both go through `app_data`.
let rows = self.tick_profile();
let counts = self.tick_profile_counts();
let peak = self.tick_profile_peak();
let timers = self.timer_profile();
let mut prof = self
.lua
.app_data_mut::<TickProf>()
.expect("tick prof app_data — checked above");
print_report(&rows, &counts, &timers, peak, prof.window, prof.ticks);
prof.window = 0.0;
prof.ticks = 0;
prof.phase_ns = [0; PHASES.len()];
prof.tally = [0; TALLIES.len()];
prof.peak_ns = 0;
prof.peak_phase = "none";
prof.timers.clear();
}
}
/// The `[ui-tick]` block: the window, the total, then every phase in tick order and the counts.
/// Microseconds, like `[ui-cost] tick=`, so the two columns compare without arithmetic.
fn print_report(
rows: &[TickPhaseRow],
counts: &[(&'static str, f64)],
timers: &[TimerSiteRow],
peak: (f64, &'static str),
window: f32,
ticks: u32,
) {
let total: f64 = rows.iter().map(|r| r.us_per_tick).sum();
let phases: Vec<String> = rows
.iter()
.map(|r| format!("{} {:.1}", r.phase, r.us_per_tick))
.collect();
let tallies: Vec<String> = counts
.iter()
.map(|(label, n)| format!("{label} {n:.2}"))
.collect();
eprintln!(
"[ui-tick] {window:.2}s · {ticks} ticks · {total:.1} us/tick · worst tick {:.1} us \
(mostly {})",
peak.0, peak.1,
);
eprintln!("[ui-tick] us/tick: {}", phases.join(" "));
eprintln!("[ui-tick] per tick: {}", tallies.join(" "));
// The `timers` phase's own split. Printed only when there is one, so a tick with no armed
// timer keeps the block at three lines; `TIMER_ROWS` of them, because the question is "which
// one", and a tail of 0.1 us rows only buries the answer.
for r in timers
.iter()
.take(TIMER_ROWS)
.filter(|r| r.us_per_tick > 0.0)
{
eprintln!(
"[ui-tick] timer {:9.1} us/tick · {:6.2} fires/tick · {:6.2} armed · \
worst fire {:.1} us · {}",
r.us_per_tick, r.fires_per_tick, r.rows_per_tick, r.peak_us, r.site,
);
}
}
/// How many timer sites the report prints. Eight: the shipped addon set arms that many persistent
/// tickers, so the whole steady-state population fits and anything beyond it is noise.
const TIMER_ROWS: usize = 8;
/// Install the split's `app_data` slot, armed from the environment. Unconditional, like the other
/// two profilers': mlua refuses to insert app data while any is borrowed, so the slot must exist
/// before the VM runs.
pub(super) fn install(lua: &Lua) {
let period = env_period();
lua.set_app_data(TickProf {
on: period.is_some(),
period: period.unwrap_or_default(),
..TickProf::default()
});
if period.is_some() {
eprintln!(
"[ui-tick] armed — splitting UiScript::tick into its phases every {:.1}s \
(WOW_UI_TICK)",
period.unwrap_or_default()
);
ARMED.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole point of the instrument: the phases are a *decomposition*, so their sum is the
/// tick. A mark left out of `tick` would make the row quietly understate whichever phase it
/// belongs to, and nothing else would notice — which is the failure mode that wasted the
/// sessions this exists for.
#[test]
fn every_microsecond_of_a_tick_is_charged_to_exactly_one_phase() {
let s = UiScript::new().expect("VM");
s.profile_tick(true, 0.0);
// An `OnUpdate` that burns a measurable, unambiguous amount of time.
s.run(
r#"
local f = CreateFrame("Frame")
f:SetScript("OnUpdate", function()
local x = 0
for i = 1, 40000 do x = x + i end
end)
"#,
)
.expect("chunk");
let mut script = s;
let t = std::time::Instant::now();
script.tick(0.016);
let whole_us = t.elapsed().as_micros() as f64;
let rows = script.tick_profile();
let sum: f64 = rows.iter().map(|r| r.us_per_tick).sum();
assert_eq!(script.tick_profile_ticks(), 1, "one tick in the window");
// Within a few percent either way: the marks' own clock reads land inside the sum and
// outside the outer measurement, so the two cannot be identical.
assert!(
sum >= whole_us * 0.80 && sum <= whole_us * 1.20,
"the phases must sum to the tick: phases {sum:.1} us against the tick's \
{whole_us:.1} us — rows {rows:?}"
);
}
/// A phase's row has to point at the phase that actually ran, or the report sends the next
/// session to the wrong place. The burning handler is dispatched, so `dispatch` must dominate.
#[test]
fn a_burning_on_update_handler_lands_in_the_dispatch_phase() {
let s = UiScript::new().expect("VM");
s.profile_tick(true, 0.0);
s.run(
r#"
local f = CreateFrame("Frame")
f:SetScript("OnUpdate", function()
local x = 0
for i = 1, 60000 do x = x + i end
end)
"#,
)
.expect("chunk");
let mut script = s;
script.tick(0.016);
let rows = script.tick_profile();
let dispatch = rows
.iter()
.find(|r| r.phase == "dispatch")
.expect("a dispatch row")
.us_per_tick;
let rest: f64 = rows
.iter()
.filter(|r| r.phase != "dispatch")
.map(|r| r.us_per_tick)
.sum();
assert!(
dispatch > rest,
"the handler's cost belongs to `dispatch`: dispatch {dispatch:.1} us against \
{rest:.1} us everywhere else — rows {rows:?}"
);
let counts = script.tick_profile_counts();
assert_eq!(
counts
.iter()
.find(|(l, _)| *l == "dispatch")
.map(|&(_, n)| n),
Some(1.0),
"one handler dispatched: {counts:?}"
);
}
/// The hover re-pick is the phase this split was built to see: it runs a whole `hit_test` over
/// the frame tree, and it is armed by any show or hide, so a interface that shows something
/// every frame pays for a hover walk every frame. The count has to say so.
#[test]
fn a_show_arms_the_hover_repick_and_the_count_reports_it() {
let s = UiScript::new().expect("VM");
s.profile_tick(true, 0.0);
// A frame shown this tick arms the re-pick (`0x764b8d`), which the tick then runs.
s.run(
r#"
local f = CreateFrame("Frame", "ShowMeEveryTick")
f:Hide()
local up = true
local driver = CreateFrame("Frame")
driver:SetScript("OnUpdate", function()
-- 5.0 has no `%` operator, so the toggle is a boolean.
if up then ShowMeEveryTick:Show() else ShowMeEveryTick:Hide() end
up = not up
end)
"#,
)
.expect("chunk");
let mut script = s;
script.tick(0.016);
script.tick(0.016);
let counts = script.tick_profile_counts();
let repick = counts
.iter()
.find(|(l, _)| *l == "repick")
.map(|&(_, n)| n)
.expect("a repick count");
assert!(
repick > 0.0,
"a show must arm the re-pick the next tick runs: {counts:?}"
);
}
/// The reason the per-timer rows exist: `timers` was the one phase with no way in. A `C_Timer`
/// callback is not a frame script, so `handler_prof` never opens a window on it, and a session
/// that reported 40,371 us of a 40,841 us tick in this phase could not say which of eight
/// armed rows that was. The row has to name the callback's own source line.
#[test]
fn the_timers_phase_names_the_source_line_of_the_callback_that_cost_it() {
let s = UiScript::new().expect("VM");
s.profile_tick(true, 0.0);
// Two tickers, one burning and one not, so the row that wins is a claim and not a default.
s.run(
r#"
C_Timer.NewTicker(0, function() local y = 1 end)
C_Timer.NewTicker(0, function()
local x = 0
for i = 1, 60000 do x = x + i end
end)
"#,
)
.expect("chunk");
let mut script = s;
script.tick(0.016);
let rows = script.timer_profile();
assert_eq!(rows.len(), 2, "one row per site: {rows:?}");
// The chunk's own lines: 1 is the newline after `r#"`, 2 the cheap ticker, 3 the `function()`
// the burning body opens on. Both tickers are armed from the *same* two-line region, so a
// label that named the file, or the arm site, or the chunk, would fold them into one row —
// the two line numbers are the whole claim.
assert_eq!(
(
rows[0].site.rsplit(':').next(),
rows[1].site.rsplit(':').next()
),
(Some("3"), Some("2")),
"each row must name the line its own callback was written on: {rows:?}"
);
assert!(
rows[0].us_per_tick > rows[1].us_per_tick,
"and the burning one must be the one it blames: {rows:?}"
);
assert_eq!(
(rows[0].fires_per_tick, rows[0].rows_per_tick),
(1.0, 1.0),
"one fire from one armed row — the pair that separates a slow callback from a leak \
of cheap ones: {rows:?}"
);
// The split is a decomposition here too: the sites must account for the phase.
let phase = script
.tick_profile()
.into_iter()
.find(|r| r.phase == "timers")
.expect("a timers row")
.us_per_tick;
let sites: f64 = rows.iter().map(|r| r.us_per_tick).sum();
assert!(
sites >= phase * 0.80,
"the per-site rows must account for the phase they split: {sites:.1} us against the \
phase's {phase:.1} us"
);
}
}