Files
benilla-octo/crates/benilla/src/perf.rs
T
SamandClaude Fable 5 ab65981ebf cooldown pies, warmed shaders, and a tamer Frost Nova glow
- the spellbook draws the cooldown pie on a spell that's cooling down, and the active form's slot lights its checked ring
- shader variants warm at startup (with a tripwire if one ever compiles live), so a first cast no longer hitches on shader compile
- additive glow stacks clamp the way the original scene does, taming Frost Nova's blown-out mist, and shoulder cards split only at rigidly separable bones
- the login handshake now reads the same under both SRP encodings, and cooldown events no longer race the net step

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:01:30 +07:00

993 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `perf.rs` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]).
//!
//! Owns the **frame-time measurement layer** that every future subsystem is measured against (the
//! standard). Registers
//! Bevy's diagnostics (frame time / FPS, entity count, render-pass timing), keeps a rolling window of
//! recent real frame durations, and draws a **minimal, always-on FPS pill** (top-center) that you
//! **click to expand** into the full readout; **Ctrl+Cmd+P** toggles the whole HUD. Expanded, it shows:
//! - FPS + last frame ms,
//! - **p50 / p99 / worst** frame ms over the window (worst-frame is the metric that matters, not the
//! average — averages hide the hitch you feel),
//! - **dropped** frames (synced: intervals actually missed) / **over-budget** frames (uncapped) —
//! synced wall time rails at the display's present grant, so the two readings mean different things,
//! - **cpu ms/frame** — process CPU, all threads: the cost meter the rail *can't* fool, the HUD twin
//! of the probes' `cpu_ms` (decision 0711),
//! - a frame-time graph with the budget line,
//! - a **VSync toggle** — the "uncap and see" headroom test. Trust it only when it moves: the uncap
//! is `AutoNoVsync` (explicit `Immediate` rails AND takes 1 s `nextDrawable` stalls on Metal —
//! measured, see `capture::probe_uncap_mode`), and a macOS power state can withhold the >60 grant
//! entirely (0362), in which case no present mode uncaps and `cpu ms` is the number to read.
//!
//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it.** Deep per-system attribution
//! is via Tracy (`cargo run --features tracy`) — the `info_span!` markers on the hot systems feed it.
//!
//! ⚠️ **macOS/Metal:** `RenderDiagnosticsPlugin` records render-pass **CPU** time only — GPU timestamp
//! queries are Vulkan/DX12-only (bevy_render `diagnostic/mod.rs` §Supported platforms). True GPU
//! timing here needs an Xcode/Instruments Metal frame capture; in-client, use the VSync-off headroom
//! test + Tracy for the CPU-vs-GPU picture.
use std::collections::VecDeque;
use bevy::diagnostic::{
DiagnosticsStore, EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin,
};
use bevy::prelude::*;
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
use bevy::render::view::Msaa;
use bevy::time::Real;
use bevy::window::{PresentMode, PrimaryWindow};
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::player::WorldCamera;
/// The frame budget: a 60 fps floor. No frame should exceed this.
pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
/// Recent frames kept for the percentiles + the graph (~5 s at 60 fps).
const SAMPLE_WINDOW: usize = 300;
/// Frame duration above which a frame is logged as a hitch (a stall the player feels). Far above the
/// 16.7 ms budget so it only fires on real stalls (load bursts), not normal over-budget frames.
const HITCH_LOG_MS: f32 = 250.0;
/// Synced, wall frame time rails at the display's present interval and jitters ±1 ms around it, so
/// counting frames over the 16.7 ms budget mostly counts jitter (a perfectly healthy 60 Hz rail
/// reads "48% over budget"). What a synced player *feels* is a missed interval: 1.5× budget (25 ms)
/// sits past any rail jitter and under the doubled frame (33 ms), so the synced HUD counts
/// **dropped** frames against this instead.
const DROPPED_FACTOR: f32 = 1.5;
/// Does this present mode sync to the display? Synced, the HUD's wall-clock numbers measure the
/// present grant, not our cost — the stats lines switch meaning on it.
fn synced_mode(mode: PresentMode) -> bool {
!matches!(
mode,
PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
)
}
pub struct PerfPlugin;
impl Plugin for PerfPlugin {
fn build(&self, app: &mut App) {
app.add_plugins((
FrameTimeDiagnosticsPlugin::default(),
EntityCountDiagnosticsPlugin::default(),
// Render-pass timing (CPU-only on Metal — see the module header). Harmless if the backend
// can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
RenderDiagnosticsPlugin,
))
.init_resource::<FrameStats>()
.init_resource::<PerfHud>()
.init_resource::<StreamActivity>()
.insert_resource(StreamTrace {
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
frame: 0,
log_until: 0,
prev_cpu_secs: None,
prev_pipes_created: 0,
})
.add_systems(
Update,
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its Ctrl+Cmd+P
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
(toggle_hud, sample_frame_time),
)
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
// whether or not anything is tracing, or the counters would accumulate forever.
.add_systems(Last, trace_stream)
.add_systems(EguiPrimaryContextPass, perf_hud_ui);
#[cfg(target_os = "macos")]
stall_sample::plugin(app);
}
}
/// Per-frame terrain-streamer activity, bumped by the `WorldStage::Stream` chain as it works
/// (a handful of integer adds + three self-timers — maintained unconditionally, zeroed by
/// [`trace_stream`] every frame). Exists because B181's symptom — a frame spike on every ADT
/// tile-boundary crossing — is keyed to *what the streamer did that frame*, which no 1 Hz journal
/// row can attribute: the spike frame needs its own row saying what was dropped, requested and
/// spawned beside what the frame cost.
#[derive(Resource, Default)]
pub(crate) struct StreamActivity {
/// Tiles whose owned entities (terrain root + cells, liquid, clutter) were despawned.
pub(crate) tiles_dropped: u32,
/// Shared placements whose refcount hit zero (their entities despawned with them).
pub(crate) placements_dropped: u32,
/// Entity handles despawned with those placements (submeshes, colliders, lights, props).
pub(crate) placement_entities_dropped: u32,
/// New ADT asset requests fired.
pub(crate) tiles_requested: u32,
/// Loaded tiles spawned (root + collider kicked off + placements registered + liquid + clutter).
pub(crate) tiles_spawned: u32,
/// MCNK cell meshes built + spawned by the paced furnisher (decision 0832).
pub(crate) cells_spawned: u32,
/// Model submesh render forms built by the paced model furnisher (decision 0834).
pub(crate) model_meshes_built: u32,
/// Placements (or WMO props) whose model landed and spawned.
pub(crate) placements_spawned: u32,
/// Off-thread colliders attached.
pub(crate) colliders_attached: u32,
/// Self-time of `stream_terrain` / `furnish_tile_cells` / `furnish_model_forms` /
/// `spawn_loaded_placements` / `finish_colliders` (ms).
pub(crate) stream_ms: f32,
pub(crate) furnish_ms: f32,
pub(crate) mfurnish_ms: f32,
pub(crate) spawn_ms: f32,
pub(crate) collider_ms: f32,
}
impl StreamActivity {
/// Did the streamer *do* anything this frame? (The self-timers don't count: the desired-set
/// scan runs every frame and would make every row an "event".)
fn any_event(&self) -> bool {
self.tiles_dropped
+ self.placements_dropped
+ self.tiles_requested
+ self.tiles_spawned
+ self.cells_spawned
+ self.model_meshes_built
+ self.placements_spawned
+ self.colliders_attached
> 0
}
}
/// A frame past this is logged even with no streamer activity — catches a spike whose cost lands
/// outside the Stream chain (command application, render extraction, asset frees frames later).
const TRACE_FRAME_MS: f32 = 25.0;
/// Frames still logged after the last streamer event: the despawn commands apply after the system,
/// the asset frees land when the last handle drops, and the render world reacts a frame later —
/// the tail is where the cost shows, so the window must outlive the event.
const TRACE_TAIL_FRAMES: u64 = 10;
/// The STREAM TRACE (`WOW_STREAM_TRACE=<csv path>`) — B181's instrument. One row per frame in
/// which the terrain streamer did anything (plus a [`TRACE_TAIL_FRAMES`] tail, plus any frame over
/// [`TRACE_FRAME_MS`]): what was dropped/requested/spawned, the Stream chain's own self-times, and
/// the frame's wall delta beside it. **The off-by-one to remember reading it: `delta_ms` on a row
/// is the interval that *ended* at this frame's start, so frame N's cost appears in row N+1.**
#[derive(Resource)]
struct StreamTrace {
path: String,
frame: u64,
log_until: u64,
/// [`process_cpu_secs`] at the previous frame — the row's `cpu_ms` is this frame's process-CPU
/// delta (user+system, all threads): the load-robust cost meter beside the wall delta, because
/// on this machine wall frame time moves with whoever else is compiling (0711; a parallel
/// build polluted this instrument's first A/B).
prev_cpu_secs: Option<f64>,
/// `PipeWatch::created` at the previous frame, so `pipes_new` is per-frame like every other
/// counter (the render world bumps the shared atomic; ±1 frame skew is inherent and fine).
prev_pipes_created: usize,
}
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
attached,adt_freed,meshes_freed,images_freed,adt_added,\
meshes_added,images_added,pipes_new,pipes_pending\n";
#[allow(clippy::too_many_arguments)]
fn trace_stream(
mut trace: ResMut<StreamTrace>,
mut activity: ResMut<StreamActivity>,
time: Res<Time<Real>>,
entities: Query<()>,
mut adt_events: MessageReader<bevy::asset::AssetEvent<benilla_assets::AdtTile>>,
mut mesh_events: MessageReader<bevy::asset::AssetEvent<Mesh>>,
mut image_events: MessageReader<bevy::asset::AssetEvent<bevy::image::Image>>,
pipes: Res<crate::pipe_warm::PipeWatch>,
) {
// Taken every frame — the counters are per-frame by contract, tracing or not.
let a = std::mem::take(&mut *activity);
let pipes_created = pipes.0.created.load(std::sync::atomic::Ordering::Relaxed);
let pipes_settled = pipes.0.settled.load(std::sync::atomic::Ordering::Relaxed);
let pipes_new = pipes_created.saturating_sub(trace.prev_pipes_created);
let pipes_pending = pipes_created.saturating_sub(pipes_settled);
trace.prev_pipes_created = pipes_created;
trace.frame += 1;
if trace.path.is_empty() {
return;
}
let cpu_now = process_cpu_secs();
let cpu_ms = match (trace.prev_cpu_secs, cpu_now) {
(Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0),
_ => String::new(),
};
trace.prev_cpu_secs = cpu_now;
// One pass per reader: `Added` beside `Unused` — B181's recurring spike was the free wave's
// family, but the first-contact head frame is the ADD wave (a landed tile's cell meshes +
// texture arrays hitting the render world's prepare at once), invisible until counted.
//
// The freed columns count `Unused` (last strong handle dropped), not `Removed`: a
// `RENDER_WORLD`-only asset (chunk-cell meshes, the tile arrays — decision 0832) leaves the
// main store at *extract* via the untracked path, so `Removed` never fires for it; `Unused`
// is the release signal both usage kinds emit exactly once, and it is what actually frees
// the GPU copy.
fn count_events<A: bevy::asset::Asset>(
events: &mut MessageReader<bevy::asset::AssetEvent<A>>,
) -> (u32, u32) {
let (mut added, mut unused) = (0u32, 0u32);
for e in events.read() {
match e {
bevy::asset::AssetEvent::Added { .. } => added += 1,
bevy::asset::AssetEvent::Unused { .. } => unused += 1,
_ => {}
}
}
(added, unused)
}
let (added_adt, removed_adt) = count_events(&mut adt_events);
let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
let (added_img, removed_img) = count_events(&mut image_events);
let delta_ms = time.delta_secs() * 1000.0;
if a.any_event() || removed_adt > 0 || added_adt > 0 || pipes_new > 0 {
trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
}
if !(a.any_event()
|| removed_adt > 0
|| added_adt > 0
|| pipes_new > 0
|| delta_ms > TRACE_FRAME_MS
|| trace.frame <= trace.log_until)
{
return;
}
if trace.frame == 1 || !std::path::Path::new(&trace.path).exists() {
let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
}
let line = format!(
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img},{pipes_new},{pipes_pending}\n",
trace.frame,
time.elapsed_secs(),
entities.iter().len(),
a.stream_ms,
a.furnish_ms,
a.mfurnish_ms,
a.spawn_ms,
a.collider_ms,
a.tiles_dropped,
a.placements_dropped,
a.placement_entities_dropped,
a.tiles_requested,
a.tiles_spawned,
a.cells_spawned,
a.model_meshes_built,
a.placements_spawned,
a.colliders_attached,
);
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&trace.path)
{
let _ = f.write_all(line.as_bytes());
}
}
/// Rolling window of recent **real** frame durations (ms) + the derived hitch metrics.
#[derive(Resource)]
struct FrameStats {
samples: VecDeque<f32>,
/// Per-frame process-CPU deltas (ms, **user+system across every thread** —
/// [`process_cpu_secs`]), same window. A blocked present-wait consumes no CPU, so this is the
/// frame-cost meter vsync cannot rail: the HUD twin of the probes' `cpu_ms` (decision 0711),
/// directly comparable with a reporter's "N % CPU".
cpu_samples: VecDeque<f32>,
/// [`process_cpu_secs`] at the previous sample, for the per-frame delta. `None` until the
/// first frame (and forever on platforms where `getrusage` is unavailable).
prev_cpu_secs: Option<f64>,
}
impl Default for FrameStats {
fn default() -> Self {
Self {
samples: VecDeque::with_capacity(SAMPLE_WINDOW),
cpu_samples: VecDeque::with_capacity(SAMPLE_WINDOW),
prev_cpu_secs: None,
}
}
}
impl FrameStats {
fn push(&mut self, ms: f32) {
if self.samples.len() == SAMPLE_WINDOW {
self.samples.pop_front();
}
self.samples.push_back(ms);
}
fn push_cpu(&mut self, ms: f32) {
if self.cpu_samples.len() == SAMPLE_WINDOW {
self.cpu_samples.pop_front();
}
self.cpu_samples.push_back(ms);
}
/// Windowed mean CPU ms/frame, all threads. `None` where the platform can't answer.
fn cpu_ms(&self) -> Option<f32> {
(!self.cpu_samples.is_empty())
.then(|| self.cpu_samples.iter().sum::<f32>() / self.cpu_samples.len() as f32)
}
fn last(&self) -> f32 {
self.samples.back().copied().unwrap_or(0.0)
}
/// `(p50, p99, max)` over the window. Sorts a copy — fine for a few-hundred-element dev HUD.
fn percentiles(&self) -> (f32, f32, f32) {
if self.samples.is_empty() {
return (0.0, 0.0, 0.0);
}
let mut v: Vec<f32> = self.samples.iter().copied().collect();
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
(at(0.50), at(0.99), *v.last().unwrap())
}
fn fps(&self) -> f32 {
if self.samples.is_empty() {
return 0.0;
}
let mean = self.samples.iter().sum::<f32>() / self.samples.len() as f32;
if mean > 0.0 {
1000.0 / mean
} else {
0.0
}
}
/// `(count, fraction)` of windowed frames above `threshold_ms`.
fn frames_over(&self, threshold_ms: f32) -> (usize, f32) {
let n = self.samples.iter().filter(|&&ms| ms > threshold_ms).count();
let frac = if self.samples.is_empty() {
0.0
} else {
n as f32 / self.samples.len() as f32
};
(n, frac)
}
}
/// HUD state. **Ctrl+Cmd+P** toggles `visible` (default on — it's a standing dev surface); `expanded` is the
/// click-to-open full readout (default off — a minimal FPS pill until clicked). `visible` is
/// `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off for pristine,
/// UI-free screenshots.
#[derive(Resource)]
pub(crate) struct PerfHud {
pub(crate) visible: bool,
/// Full stats shown? Toggled by clicking the FPS pill; minimal (FPS only) when `false`.
expanded: bool,
}
impl Default for PerfHud {
fn default() -> Self {
Self {
visible: true,
expanded: false,
}
}
}
fn toggle_hud(keys: Res<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHud>) {
// Ctrl+Cmd+P, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev instrument
// doesn't get to squat on a game binding (decision 0585). The chord can't be mistaken for typed
// text, so unlike the old bare key it needs no chat-bar/EditBox gate.
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyP) {
hud.visible = !hud.visible;
}
}
fn sample_frame_time(time: Res<Time<Real>>, mut stats: ResMut<FrameStats>) {
let ms = time.delta_secs() * 1000.0;
stats.push(ms);
let cpu = process_cpu_secs();
if let (Some(prev), Some(now)) = (stats.prev_cpu_secs, cpu) {
stats.push_cpu(((now - prev) * 1000.0) as f32);
}
stats.prev_cpu_secs = cpu;
// Log hard hitches so a load freeze is attributable from the log alone (one big stall vs many
// medium ones, and roughly when). The first frame's delta is the startup gap, not a hitch.
if ms > HITCH_LOG_MS && stats.samples.len() > 1 {
warn!("frame hitch: {ms:.0} ms (main thread blocked this long)");
}
}
fn perf_hud_ui(
mut contexts: EguiContexts,
mut hud: ResMut<PerfHud>,
stats: Res<FrameStats>,
diagnostics: Res<DiagnosticsStore>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
cam_msaa: Query<&Msaa, With<WorldCamera>>,
) -> Result {
if !hud.visible {
return Ok(());
}
let ctx = contexts.ctx_mut()?;
let fps = stats.fps();
let last = stats.last();
let synced = windows
.single()
.map(|w| synced_mode(w.present_mode))
.unwrap_or(true);
// Synced, a small over is rail jitter, not cost — red only past the dropped threshold there.
let red_above = if synced {
FRAME_BUDGET_MS * DROPPED_FACTOR
} else {
FRAME_BUDGET_MS
};
let red = egui::Color32::from_rgb(240, 120, 120);
let green = egui::Color32::from_rgb(140, 220, 140);
// The pill colour reads *sustained* health off the windowed mean, not the per-frame `last` (which
// would flicker red on every isolated spike): green at/above the 60 fps floor, red when under it.
let pill = if fps < 58.0 { red } else { green };
// A title-less, anchored window — minimal chrome (no title bar, no resize) but the stable
// auto-sizing of a `Window`, so expanding to the full readout doesn't flash a mislaid first frame
// the way a raw `Area` does. `overlay_text` brightens the text and stops "60 fps" from wrapping.
egui::Window::new("perf_hud")
.title_bar(false)
.resizable(false)
.collapsible(false)
.movable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 8.0))
.frame(
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 5))
.corner_radius(5.0)
.fill(OVERLAY_FILL),
)
.show(ctx, |ui| {
overlay_text(ui);
// Minimal by default: just a clickable FPS number. Click toggles the full readout.
let label = egui::RichText::new(format!("{fps:.0} fps"))
.color(pill)
.monospace()
.strong();
let hint = if hud.expanded {
"click to collapse · P to hide"
} else {
"click to expand · P to hide"
};
if ui
.add(egui::Button::new(label).frame(false))
.on_hover_text(hint)
.clicked()
{
hud.expanded = !hud.expanded;
}
if !hud.expanded {
return;
}
ui.separator();
let (p50, p99, max) = stats.percentiles();
let (over_n, over_frac) = stats.frames_over(red_above);
let win_len = stats.samples.len();
let entities = diagnostics
.get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
.and_then(|d| d.value())
.unwrap_or(0.0);
let last_col = if last > red_above { red } else { green };
ui.colored_label(last_col, format!("last {last:>6.2} ms"));
ui.label(
egui::RichText::new(format!("budget {FRAME_BUDGET_MS:.2} ms · 60 fps floor"))
.color(OVERLAY_TEXT_DIM),
);
ui.label(format!(
"p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
));
let ob_col = if over_frac > 0.0 {
red
} else {
OVERLAY_TEXT_DIM
};
// Synced: "dropped" = missed present intervals (> DROPPED_FACTOR × budget) — the felt
// metric; wall time can't say more while it rails at the grant. Uncapped: the honest
// over-budget count against the 16.7 floor.
ui.colored_label(
ob_col,
format!(
"{} {over_n}/{win_len} ({:.0}%)",
if synced { "dropped" } else { "over budget" },
over_frac * 100.0
),
)
.on_hover_text(if synced {
"frames past 1.5× budget — a missed display interval; \
small overs while synced are present-grant jitter, not cost"
} else {
"frames past the 16.7 ms budget (uncapped: wall time ≈ real frame cost)"
});
// The vsync-immune cost meter: process CPU per frame, every thread summed (the probes'
// `cpu_ms`, decision 0711). ~6 ms is the 1.12.1 reference client's whole-scene cost —
// the long-term bar.
if let Some(cpu) = stats.cpu_ms() {
let mean_ms = if fps > 0.0 { 1000.0 / fps } else { 0.0 };
let pct = if mean_ms > 0.0 {
cpu / mean_ms * 100.0
} else {
0.0
};
ui.label(format!("cpu {cpu:>6.2} ms · {pct:.0}% (all threads)"))
.on_hover_text(
"process CPU consumed per frame — measures our work, not the display's \
present grant; comparable with the probes' cpu_ms / a reporter's CPU %",
);
}
ui.label(format!("entities {entities:.0}"));
frame_graph(ui, &stats);
if let Ok(mut window) = windows.single_mut() {
ui.separator();
let mut vsync = !matches!(
window.present_mode,
PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
);
// "Sync to display", not "cap at 60": on the ProMotion panel the display is
// ADAPTIVE (up to 120 Hz — grants ~119 in High Power Mode, pinned to 60 only by
// macOS power state), so synced fps floats with frame cost. A floating 70–119
// with this ON is vsync working, not broken (decision 0294's companion finding;
// measure the current grant with WOW_PROBE_VSYNC=1).
if ui
.checkbox(
&mut vsync,
"VSync — sync to display (120 adaptive; off = uncap)",
)
.changed()
{
// AutoNoVsync, never Immediate: on Metal, explicit Immediate both rails and
// takes ~1 s nextDrawable stalls (measured — `capture::probe_uncap_mode`).
window.present_mode = if vsync {
PresentMode::AutoVsync
} else {
PresentMode::AutoNoVsync
};
}
}
if let Ok(m) = cam_msaa.single() {
// Read-only: switching MSAA at runtime breaks our post-process graph (glow/egui)
// and froze the view, so MSAA is a startup knob now ($WOW_MSAA=off/2/4); A/B by
// restarting.
let s = m.samples();
let txt = if s <= 1 {
"off".to_string()
} else {
format!("{s}×")
};
ui.label(format!("MSAA {txt} · set $WOW_MSAA to change"));
}
});
Ok(())
}
/// A tiny frame-time graph: the windowed samples as a polyline with the budget line in red. Scaled to
/// at least 33 ms so a dropped (doubled) frame reads clearly against the 16.7 ms budget.
fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(232.0, 52.0), egui::Sense::hover());
if stats.samples.is_empty() {
return;
}
let painter = ui.painter_at(rect);
let max_ms = stats.samples.iter().copied().fold(33.0_f32, f32::max);
let to_y = |ms: f32| rect.bottom() - (ms / max_ms).clamp(0.0, 1.0) * rect.height();
let budget_y = to_y(FRAME_BUDGET_MS);
painter.hline(
rect.x_range(),
budget_y,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(200, 80, 80)),
);
let dx = rect.width() / SAMPLE_WINDOW as f32;
let points: Vec<egui::Pos2> = stats
.samples
.iter()
.enumerate()
.map(|(i, &ms)| egui::pos2(rect.left() + i as f32 * dx, to_y(ms)))
.collect();
painter.add(egui::Shape::line(
points,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(140, 220, 140)),
));
}
/// The FPS JOURNAL (`WOW_FPS_JOURNAL=<csv path>`): once a second, append one row of where the
/// player is, what the frame cost, and **what is resident** (see [`JOURNAL_HEADER`] for the column
/// order, written into every fresh file) — the "where does it dip" instrument for a
/// director-driven run. They play normally; the journal turns "it drops in the Dwarven District"
/// into coordinates a headless probe can tele straight back to. Negligible cost: one line of IO a
/// second, samples reused from [`FrameStats`].
///
/// The residency columns make it the **leak curve** instrument too (B131): `FPS_PROBE`'s residency
/// meter samples once per run, which can only compare two runs at one point each — it cannot tell
/// "grows with distance streamed" from "grows with time elapsed", and cannot show *where* on a
/// route the cost arrives. A per-second row of `cpu_ms` beside `mats/images/uv/tint` plots the
/// per-frame cost directly against residency along one continuous leg, on the same time axis as
/// the position — so a same-map traverse (no `MapChange`, so no map-scoped eviction) shows its
/// accumulation as a slope instead of a before/after pair.
pub(crate) struct FpsJournalPlugin;
/// The journal's column order, written as the first line of a fresh file. Appended-to files keep
/// whatever header they were created with — the columns only ever grow at the end.
const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,mats,meshes,images,\
m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz\n";
impl Plugin for FpsJournalPlugin {
fn build(&self, app: &mut App) {
let path = std::env::var("WOW_FPS_JOURNAL").unwrap_or_default();
// The header goes in exactly once, at creation: the rows are appended for the life of the
// run (and across runs, deliberately — a journal accumulates legs).
if !path.is_empty() && !std::path::Path::new(&path).exists() {
let _ = std::fs::write(&path, JOURNAL_HEADER);
}
app.insert_resource(FpsJournal {
path,
window: Vec::new(),
last_flush: 0.0,
cpu_at_flush: process_cpu_secs(),
})
.add_systems(Update, journal_fps);
}
}
#[derive(Resource)]
struct FpsJournal {
path: String,
window: Vec<f32>,
last_flush: f32,
/// Process CPU seconds at the previous flush — the row's `cpu_ms` is this second's CPU cost
/// per frame, the load-robust half of the measurement (see [`process_cpu_secs`]).
cpu_at_flush: Option<f64>,
}
/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
/// arity limit.
///
/// The `Assets<T>` counts are the totals — what the process holds. The [`ArtCensus`] half is the
/// same population **broken down by the cache that holds it** (decision 0793), which is what turns
/// "materials are growing" into a named holder in one row instead of a run-length probe. `evicted`
/// is the running total dropped by distance: on a same-map traverse it was structurally zero before
/// 0793, because nothing but a `MapChange` evicted anything (0729).
#[derive(bevy::ecs::system::SystemParam)]
struct JournalResidency<'w> {
mats: Res<'w, Assets<crate::terrain::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
m2: Res<'w, Assets<benilla_assets::M2Model>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
art: Res<'w, crate::art_scope::ArtCensus>,
/// The **view focus** — where art is actually being asked for. Distinct from the row's `x,y,z`,
/// which is the avatar: through a detached free-fly the body stands still while the camera covers
/// kilometres, so on that leg the position columns describe nothing that is happening. The
/// director's first run was exactly that leg, and reading it needed this column.
scope: Res<'w, crate::art_scope::ArtScopeState>,
}
fn journal_fps(
mut journal: ResMut<FpsJournal>,
time: Res<Time<Real>>,
player: Option<Res<crate::player::Player>>,
streamed: Query<(), With<crate::net::NetEntity>>,
entities: Query<()>,
residency: JournalResidency,
) {
let now = time.elapsed_secs();
journal.window.push(time.delta_secs() * 1000.0);
if now - journal.last_flush < 1.0 {
return;
}
journal.last_flush = now;
let mut v = std::mem::take(&mut journal.window);
if v.is_empty() {
return;
}
v.sort_by(f32::total_cmp);
let mean = v.iter().sum::<f32>() / v.len() as f32;
let p95 = v[((v.len() - 1) as f32 * 0.95).round() as usize];
// Raw WoW coords, so the line pastes straight into a `.go xyz` probe.
let pos = player
.filter(|p| p.active)
.map(|p| benilla_assets::coords::bevy_to_wow(p.pos))
.unwrap_or([0.0; 3]);
// CPU per frame over this second — the number the reporter's "CPU %" compares against, and
// the one that does not move with whatever else is compiling on this machine.
let cpu_now = process_cpu_secs();
let cpu_ms = match (journal.cpu_at_flush, cpu_now) {
(Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0 / v.len() as f64),
_ => String::new(),
};
journal.cpu_at_flush = cpu_now;
let mut line = format!(
"{now:.1},{:.1},{:.1},{:.1},{mean:.2},{p95:.2},{},{},{cpu_ms},{},{},{},{},{},{}",
pos[0],
pos[1],
pos[2],
streamed.iter().len(),
entities.iter().len(),
residency.mats.len(),
residency.meshes.len(),
residency.images.len(),
residency.m2.len(),
residency.uv_reg.0.len(),
residency.tint_reg.0.len(),
);
// The per-cache breakdown, in `ArtSlot::ALL` order — which IS the header's column order.
for slot in crate::art_scope::ArtSlot::ALL {
line.push_str(&format!(",{}", residency.art.live(slot)));
}
line.push_str(&format!(",{}", residency.art.dropped_total()));
match residency.scope.focus() {
Some(f) => line.push_str(&format!(",{:.1},{:.1},{:.1}\n", f[0], f[1], f[2])),
None => line.push_str(",,,\n"),
}
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&journal.path)
{
let _ = f.write_all(line.as_bytes());
}
}
/// Whole-process CPU seconds consumed so far — **user + system, summed across every thread**
/// (`getrusage(RUSAGE_SELF)`).
///
/// The perf probes report wall-clock frame time, which on this machine is not a usable regression
/// instrument: parallel session worktrees build on the same 14 cores, and two identical probe runs
/// of the same pin came back 49.6 ms and 28.6 ms apart purely on machine load. CPU-per-frame moves
/// with the work we actually do, not with who else is compiling — and it is the metric the Mac
/// report is written in ("250 % CPU at 59 fps" against 1.12.1's "100 % at 160"), so a probe that
/// prints it can be compared against a reporter's number directly.
///
/// Non-unix returns `None`: the probes print the field only where the platform answers.
pub(crate) fn process_cpu_secs() -> Option<f64> {
#[cfg(unix)]
{
// SAFETY: `getrusage` writes a fully-initialized `rusage` into the out-param and reads
// nothing from it; zeroed is a valid starting value for a C struct of plain integers.
unsafe {
let mut ru: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
return None;
}
let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6;
Some(secs(ru.ru_utime) + secs(ru.ru_stime))
}
}
#[cfg(not(unix))]
{
None
}
}
/// The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
/// thread notices the main-thread heartbeat has gone stale and shells the stock profiler
/// (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
/// itself the next time it happens** — on anyone's run, no reproduction needed. Two bugs that
/// each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
/// the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
/// backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
/// the hang may own. Samples land in `~/Library/Logs/benilla/` and the path prints on stderr
/// (the tracing subscriber may already be gone during teardown).
///
/// After a teardown sample, an [`EXIT_KILL_MS`](stall_sample::EXIT_KILL_MS) backstop `_exit(0)`
/// ends the wedged process — diagnosis first, then the force-quit the director would otherwise
/// perform by hand. `libc::_exit`, never `process::exit`: it skips the atexit handlers.
///
/// Verification is end-to-end via two injectors (used by the 0713 probe rounds, kept as standing
/// test affordances): `WOW_STALL_INJECT=<at_secs>:<ms>` sleeps the main thread mid-run, and
/// `WOW_TEARDOWN_INJECT=<ms>` wedges World drop via a resource whose `Drop` sleeps — the
/// director's beachball, synthetically.
#[cfg(target_os = "macos")]
mod stall_sample {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use bevy::prelude::*;
use bevy::time::Real;
/// The monotonic epoch every heartbeat is measured against. `Instant`, not `SystemTime`:
/// a wall-clock step (NTP) must not fake a stall — an instrument that lies once is poison.
static START: OnceLock<Instant> = OnceLock::new();
/// Last main-thread heartbeat, ms since [`START`]. 0 = no full frame yet, watchdog stays
/// quiet — startup (shader compiles, first loads) never false-positives.
static HEARTBEAT_MS: AtomicU64 = AtomicU64::new(0);
/// Latched the frame `AppExit` is written: switches the watchdog to the teardown threshold
/// and arms the post-sample `_exit` backstop.
static EXITING: AtomicBool = AtomicBool::new(false);
/// In-frame staleness that means a real stall (ms) — above the ~250 ms loading-screen
/// hitches, below the ~1030 ms stall class it exists to catch.
const STALL_MS: u64 = 600;
/// In-frame sampling stays disarmed this long after launch: startup/login legitimately
/// stalls past [`STALL_MS`] (a 683 ms hitch at 1.7 s uptime, caught by the 0713 pristine-run
/// gate), and the classes this instrument hunts are steady-state. Teardown is unaffected.
const STARTUP_GRACE_MS: u64 = 15_000;
/// Post-`AppExit` staleness that means teardown is wedged, not merely slow (ms). A clean
/// teardown is sub-second (26/26 measured exit cycles); the probe hard-exit backstop sits
/// at 5 s, so a 2.5 s trigger + 2 s capture completes before it.
const EXIT_STALL_MS: u64 = 2_500;
/// Teardown backstop: still alive this long after `AppExit` (sample already taken) → `_exit`.
const EXIT_KILL_MS: u64 = 8_000;
/// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart.
const SAMPLE_CAP: u32 = 5;
const SAMPLE_GAP_MS: u64 = 20_000;
fn since_start_ms() -> u64 {
START.get().map_or(0, |s| s.elapsed().as_millis() as u64)
}
/// `Last`-schedule heartbeat: stamp the clock; latch [`EXITING`] the frame the app decides
/// to quit (no later frame will run to unlatch anything).
fn beat(mut exits: MessageReader<AppExit>) {
if exits.read().next().is_some() {
EXITING.store(true, Ordering::SeqCst);
}
HEARTBEAT_MS.store(since_start_ms().max(1), Ordering::SeqCst);
}
pub(super) fn plugin(app: &mut App) {
if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
return;
}
let Ok(home) = std::env::var("HOME") else {
return;
};
let dir = std::path::PathBuf::from(home).join("Library/Logs/benilla");
let _ = std::fs::create_dir_all(&dir);
START
.set(Instant::now())
.expect("stall_sample plugin built twice");
app.add_systems(Last, beat);
std::thread::Builder::new()
.name("stall-sample".into())
.spawn(move || watchdog(&dir))
.expect("spawn stall-sample watchdog");
// The injectors (doc above): a mid-run main-thread sleep, and a wedged World drop.
if let Some((at, ms)) = std::env::var("WOW_STALL_INJECT")
.ok()
.and_then(|v| v.split_once(':').map(|(a, m)| (a.to_owned(), m.to_owned())))
.and_then(|(a, m)| Some((a.parse::<f32>().ok()?, m.parse::<u64>().ok()?)))
{
app.insert_resource(StallInject {
at,
ms,
fired: false,
})
.add_systems(Update, stall_inject);
}
if let Ok(ms) = std::env::var("WOW_TEARDOWN_INJECT") {
if let Ok(ms) = ms.parse::<u64>() {
app.insert_resource(TeardownWedge(ms));
}
}
}
fn watchdog(dir: &std::path::Path) {
let pid = std::process::id().to_string();
let mut taken = 0u32;
let mut last_sample_ms = 0u64;
let mut exit_seen_ms = 0u64;
loop {
std::thread::sleep(Duration::from_millis(100));
let hb = HEARTBEAT_MS.load(Ordering::SeqCst);
if hb == 0 {
continue;
}
let now = since_start_ms();
let exiting = EXITING.load(Ordering::SeqCst);
if exiting && exit_seen_ms == 0 {
exit_seen_ms = now;
}
if exiting && now.saturating_sub(exit_seen_ms) > EXIT_KILL_MS {
eprintln!(
"stall-sample: teardown still wedged {} ms after AppExit — _exit(0)",
now.saturating_sub(exit_seen_ms)
);
use std::io::Write;
let _ = std::io::stdout().flush();
// SAFETY: terminates the process immediately; no locks, no atexit — the whole
// point, since the wedge may own both.
unsafe { libc::_exit(0) };
}
let age = now.saturating_sub(hb);
if !exiting && now < STARTUP_GRACE_MS {
continue;
}
let threshold = if exiting { EXIT_STALL_MS } else { STALL_MS };
if age > threshold
&& taken < SAMPLE_CAP
// The gap gates *between* samples only — gating the first one against
// `last_sample_ms = 0` would silently forbid any sample in the first
// [`SAMPLE_GAP_MS`] of process uptime (caught by the 0713 injector runs).
&& (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
{
taken += 1;
last_sample_ms = now;
let file = dir.join(format!(
"stall-{}{}.txt",
std::process::id(),
if exiting {
format!("-teardown-{now}")
} else {
format!("-{now}")
}
));
eprintln!(
"stall-sample: main thread stale {age} ms{} — sampling to {}",
if exiting { " (teardown)" } else { "" },
file.display()
);
// 1 s in-frame so the stall dominates its own capture (a ~1 s stall in a 2 s
// window is half idle); 2 s for teardown, where the wedge holds the whole time.
let _ = std::process::Command::new("/usr/bin/sample")
.arg(&pid)
.arg(if exiting { "2" } else { "1" })
.arg("-file")
.arg(&file)
.status();
}
}
}
/// `WOW_STALL_INJECT=<at_secs>:<ms>` — one deliberate main-thread sleep, mid-run.
#[derive(Resource)]
struct StallInject {
at: f32,
ms: u64,
fired: bool,
}
fn stall_inject(mut inject: ResMut<StallInject>, time: Res<Time<Real>>) {
if !inject.fired && time.elapsed_secs() >= inject.at {
inject.fired = true;
warn!("stall-inject: sleeping the main thread {} ms", inject.ms);
std::thread::sleep(Duration::from_millis(inject.ms));
}
}
/// `WOW_TEARDOWN_INJECT=<ms>` — wedge World drop: this resource's `Drop` runs on the main
/// thread during `App` teardown and sleeps there, reproducing the beachball synthetically.
#[derive(Resource)]
struct TeardownWedge(u64);
impl Drop for TeardownWedge {
fn drop(&mut self) {
eprintln!("teardown-inject: wedging World drop {} ms", self.0);
std::thread::sleep(Duration::from_millis(self.0));
}
}
}