VSync joins the video options, the chat dock goes quiet

- VSync is a player setting on the Graphics page now — 1.12's own gxVSync, persisted like every other CVar — instead of a dev-HUD checkbox
- the perf HUD collapses to one surface that says everything once, at a deliberate, governed width; the badge stops crying wolf
- the idle chat dock stops repainting itself every frame — the fade applier gets the gate the reference carries twice over, and hovering still reveals the hidden tabs
- the tick can name who spent it: per-handler self/total attribution at the one seam where every handler fires

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:04:53 +07:00
co-authored by Claude Fable 5
parent 5ce93810b0
commit 39f5953a7e
17 changed files with 1525 additions and 278 deletions
+48 -11
View File
@@ -241,6 +241,11 @@
oldX = 0, oldY = 0,
reveal = 0, -- 0..1 master fade the tab/texture alphas scale by
revealTarget = 0,
-- What the apply block below last WROTE, so an unchanged frame writes nothing
-- (decision 1396). -1 is unreachable for both, so the first pass always applies.
appliedReveal = -1,
appliedSelected = -1,
tabsSettled = false, -- both tabs' host-measured label widths have landed
flashTab = 0, -- tab id whose whisper flash runs (0 = none), 0288 P4 wires it
flashLeft = 0, -- remaining flash seconds (ref: 60s, blink 0.25/0.25)
}
@@ -331,14 +336,19 @@
-- PanelTemplates_TabResize(5, tab), re-expressed: the ref sizes every docked tab to its
-- label (FCF_DockUpdate) — middle slice = text width + 5, tab = middle + the two 16px
-- caps; the flash/highlight bands follow the Left/Right pieces by anchor. The label
-- width is host-measured (GetStringWidth), so early frames may see 0 — the OnUpdate
-- below re-applies until it settles, then the change-guard makes this a no-op.
-- width is host-measured (GetStringWidth), so early frames may see 0. It RETURNS whether
-- the width is known yet — which is what lets FCF_OnUpdate's apply gate keep polling
-- until the measurement lands and then stop for good (decision 1396). Without that
-- answer the gate would have to choose between polling forever and shipping tabs stuck
-- at their authored width on whichever session measured late.
function BenillaFCF_TabResize(tab)
local w = getglobal(tab:GetName() .. "Text"):GetStringWidth()
if w == 0 or tab.benillaTabTextW == w then return end
if w == 0 then return false end
if tab.benillaTabTextW == w then return true end
tab.benillaTabTextW = w
getglobal(tab:GetName() .. "Middle"):SetWidth(w + 5)
tab:SetWidth(w + 5 + 32)
return true
end
-- The hover rect: the dock's rect expanded by the ref's MouseIsOver pads
@@ -389,16 +399,43 @@
-- Apply: tabs at 1.0 (selected) / 0.5 (unselected) × reveal; the selected window's
-- background+borders at 0.25 × reveal (ref: fade oldAlpha↔DEFAULT_CHATFRAME_ALPHA).
for i = 1, 2 do
local tab = getglobal("ChatFrame" .. i .. "Tab")
if tab then
local a = (i == BenillaFCF.selected) and 1.0 or 0.5
tab:SetAlpha(a * s.reveal)
BenillaFCF_TabResize(tab)
--
-- GATED on everything the writes below depend on — the reveal, the selected tab, and
-- whether the label measurements have landed (decision 1396). **The reference needs
-- no gate because it does not do this here at all**: ref FCF_OnUpdate hands the fade
-- to UIFrameFadeIn/Out, and UIFrameFade_OnUpdate walks only the frames still fading,
-- so a settled dock costs nothing. This transcription inlined the ramp and lost that
-- property with it, and ~24 getglobals, two table builds and ~20 SetAlpha calls then
-- ran every frame of every session to write the values that were already there —
-- 36 us/frame, the largest single handler in the client (1395 measured it). The
-- guard restores the reference's own behaviour rather than inventing one.
if s.reveal ~= s.appliedReveal or s.selected ~= s.appliedSelected then
for i = 1, 2 do
local tab = getglobal("ChatFrame" .. i .. "Tab")
if tab then
tab:SetAlpha(((i == s.selected) and 1.0 or 0.5) * s.reveal)
end
for _, t in ipairs(BenillaFCF_Textures(i)) do
t:SetAlpha(0.25 * s.reveal)
end
end
for _, t in ipairs(BenillaFCF_Textures(i)) do
t:SetAlpha(0.25 * s.reveal)
s.appliedReveal = s.reveal
s.appliedSelected = s.selected
end
-- The tab widths are a SEPARATE trigger and get their own latch, deliberately: they
-- depend on a host measurement rather than on the fade, and folding them into the
-- gate above would tie the alphas to a width that can legitimately never arrive (an
-- empty label measures 0 forever) — the apply block would then run every frame again
-- for a reason having nothing to do with what it writes. Polls until both land, then
-- stops for good.
if not s.tabsSettled then
local settled = true
for i = 1, 2 do
local tab = getglobal("ChatFrame" .. i .. "Tab")
if tab and not BenillaFCF_TabResize(tab) then settled = false end
end
s.tabsSettled = settled
end
-- The whisper flash blink (0.25 on / 0.25 off, 60s — ref UIFrameFlash args).
+17 -1
View File
@@ -615,7 +615,7 @@
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
"RowEnableMusic", "RowEnableAmbience",
},
Graphics = { "RowUiScale", "RowWorldDetail" },
Graphics = { "RowUiScale", "RowWorldDetail", "RowVerticalSync" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
-- The stock globals that were already defined AND consumed when 1134 opened the store
-- (1136; Buff Durations 1139), in the reference's own on-screen order — its Basic
@@ -2075,6 +2075,22 @@
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowVerticalSync" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Vertical Sync: 1.12's own Video Options checkbox
-- 5 (gxVSync), over the window's present mode.
-- The reference row carries gxRestart = 1; ours
-- does not and must not be marked deferred --
-- wgpu swaps the presentation interval live, so
-- the box takes effect as you click it.
OptionsRow_OnLoad(self, "gxVSync", "Vertical Sync",
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The COMBAT page (decision 1134) — the first page whose rows
+18
View File
@@ -58,6 +58,24 @@
//! and frame scale. 12.80 px at 1024×768; 14.69 at 1280×720. 1386 froze the 4:3 case into a
//! constant, which is why our 16:9 stars were 22 % small until 1390.
//!
//! **"Free of UI scale" is byte-law, not a loose phrase** (wow-re
//! `system/ui/scratch/modelframe-uiscale-law.md`, a §5 cross-check dispatched *because* the
//! phrasing looked too loose to rest on). `uiScale` is an ordinary `SetScale` on the frame named
//! `UIParent` (`0x494550` → `0x76ac10(ecx = [0xb4b44c])`, and `0x494590` "GetUiScale" is literally
//! `fld [[0xb4b44c]+0x7c]`), and it *does* reach both the widget's rect and the model root
//! (`76d1dd fmul [esi+0x7c]`). It cancels anyway: `0x76d240` builds the ortho `m00 = 2/w` **and**
//! the viewport from the SAME rect, so px per eye unit is `√(W² + H²)` whatever `w` is. So:
//!
//! > **the button scales with the dial and the star does not.** At 2560×1440 a newborn star is
//! > **29.37 px at every setting**, against a 37-unit button of 69.4 / 62.4 / 44.4 px at `uiScale`
//! > 1.0 / 0.9 / 0.64 — 42 % / 47 % / **66 %** of the button.
//!
//! And the consequence that actually reads on screen is not that ratio but this: the four emitters
//! sit on a path that DOES shrink with the dial (`31.2 · uiScale` units), so at 0.64 four
//! fixed-size 29 px stars pack into a 37 px square instead of a 59 px one and **overlap into one
//! blob**. Every bit of that is the reference's own behaviour; there is nothing to fix here, and a
//! low-`uiScale` complaint about this widget is a taste call, not a defect.
//!
//! **And the widget is its own scissor** ([`site_quads`], decision 1387): a `<Model>` installs its
//! frame rect as the VIEWPORT for the duration of its draw and restores it after, so the model
//! cannot paint one pixel outside the button. With the path running on the rim and the stars
+41 -2
View File
@@ -36,6 +36,7 @@ use crate::sound::SoundConfig;
use crate::target::ClickConfig;
use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
@@ -141,6 +142,19 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// skip-default rule). No host knob: its consumers are the load walk (via the persisted value,
// [`CvarPersist::addon_version_check`]) and the gate's live per-query read in the VM.
("checkAddonVersion", "1"),
// Vertical Sync — 1.12's own `gxVSync`, the Video Options checkbox at index 5
// (`OptionsFrame.lua`'s `OptionsFrameCheckButtons["VERTICAL_SYNC"]`, in the install's
// FrameXML). The knob is [`crate::video::VideoConfig::vsync`], which the window's
// `present_mode` follows.
//
// Default "1" is BEHAVIOUR-derived, not byte-read: 1.12's registrar value for this var is not
// pinned in wow-re, and "1" is what benilla actually ships — the primary window is born at
// `PresentMode::default()` (Fifo), and a test in `video.rs` welds the two together.
//
// Two knowing departures from the reference row, both stated on [`crate::video`]: its
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
("gxVSync", "1"),
];
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
@@ -213,6 +227,7 @@ struct Knobs<'a> {
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
zoom: &'a mut ZoomLimit,
video: &'a mut VideoConfig,
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -264,6 +279,9 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// and the gate reads the live table — but a KNOWN key, so a toggle dirties the config
// and persists (the statusBarText posture).
"checkaddonversion" => {}
// Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode`
// watches the value and pushes it to the window; nothing else reads it.
"gxvsync" => knobs.video.vsync = v != 0.0,
_ => return false,
}
true
@@ -293,6 +311,7 @@ fn load_config(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut video: ResMut<VideoConfig>,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -306,6 +325,7 @@ fn load_config(
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
video: &mut video,
};
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
@@ -317,6 +337,11 @@ fn load_config(
if std::env::var_os("WOW_CLUTTER_DENSITY").is_some() {
persist.env_overridden.insert("worlddetail".into());
}
// `$WOW_NOVSYNC=1` is the measurement uncap: session-only, exactly like the taste-iteration
// overrides above. Pinning it into the config would make an instrument run sticky.
if crate::video::novsync_env() {
persist.env_overridden.insert("gxvsync".into());
}
let Some(path) = crate::local_state::config_path() else {
return; // hermetic capture, or no install — session-only state
};
@@ -377,6 +402,7 @@ fn sync_cvars(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut video: ResMut<VideoConfig>,
) {
let Some(mut script) = script else {
return;
@@ -396,7 +422,7 @@ fn sync_cvars(
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 23] = [
let session: [(&str, String); 24] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -423,6 +449,7 @@ fn sync_cvars(
("ChatBubblesParty", flag(bubbles.party)),
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
("gxVSync", flag(video.vsync)),
];
for (name, value) in session {
script.set_cvar_host(name, &value);
@@ -447,6 +474,7 @@ fn sync_cvars(
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
video: &mut video,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
@@ -489,6 +517,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
Option<ResMut<MinimapZoom>>,
Option<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
Option<ResMut<VideoConfig>>,
)> = bevy::ecs::system::SystemState::new(world);
let (
script,
@@ -504,6 +533,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
minimap,
bubbles,
zoom,
video,
) = state.get_mut(world);
let (Some(mut script), Some(mut persist)) = (script, persist) else {
return;
@@ -525,8 +555,9 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
Some(mut minimap),
Some(mut bubbles),
Some(mut zoom),
Some(mut video),
) = (
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom,
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, video,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -540,6 +571,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
video: &mut video,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
@@ -704,6 +736,9 @@ mod tests {
assert_eq!(d["minimapZoom"], f32::from(zoom.outdoor));
assert_eq!(d["minimapInsideZoom"], f32::from(zoom.inside));
assert_eq!(zoom.outdoor, benilla_ui::widget::MINIMAP_DEFAULT_ZOOM);
// VSync welds to the video knob, which in turn welds to the window literal's boot
// mode (`video::tests`) — so the registered "1" cannot drift from what we ship.
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
}
#[test]
@@ -725,6 +760,7 @@ mod tests {
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
let mut zoom = ZoomLimit::default();
let mut video = VideoConfig::default();
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -737,6 +773,7 @@ mod tests {
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
video: &mut video,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -854,6 +891,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<VideoConfig>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -922,6 +960,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<VideoConfig>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
+10 -9
View File
@@ -144,6 +144,7 @@ mod ui_tradeskill;
mod ui_trainer;
mod ui_unit;
mod ui_world_map;
mod video;
mod vplates;
mod world_state;
@@ -393,15 +394,12 @@ pub fn run(build: BuildId) -> AppExit {
})
.into()
},
// `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures true frame
// cost, not the vsync ceiling — the same uncap the capture probe flips mid-run, available
// from boot for non-capture probes (perf triage at the glue screens, where no capture
// scenario runs).
present_mode: if std::env::var("WOW_NOVSYNC").as_deref() == Ok("1") {
bevy::window::PresentMode::AutoNoVsync
} else {
bevy::window::PresentMode::default()
},
// The boot present mode. `$WOW_NOVSYNC=1` uncaps presentation so a headless FPS-journal
// run measures true frame cost, not the vsync ceiling — the same uncap the capture probe
// flips mid-run, available from boot for non-capture probes (perf triage at the glue
// screens, where no capture scenario runs). Absent it, we boot synced and the player's
// `gxVSync` setting takes over from `Startup` on ([`crate::video`]).
present_mode: video::present_mode(!video::novsync_env()),
// An instrumented run must never fight the director's screen (decision 0703).
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
// their keystrokes out of another app and typed them into the account box,
@@ -525,6 +523,9 @@ pub fn run(build: BuildId) -> AppExit {
// `SetPortraitTexture`-bound region (the modern high-res 2D model bake).
.add_plugins(PortraitPlugin)
.add_plugins((TextInputPlugin, UiScriptPlugin))
// The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the
// resource exists when `load_config` applies the saved value at Startup.
.add_plugins(video::VideoPlugin)
// The CVar host (decision 0954): registration, knob sync, config.toml persistence. After
// UiScriptPlugin only for reading order — its systems gate on the VM existing anyway.
.add_plugins(cvars::CvarPlugin)
+372 -242
View File
@@ -14,13 +14,19 @@
//! the old red threshold (fps < 58) sat ~5.7× above a healthy frame's cost. fps stays on the pill,
//! dimmed, as the familiar anchor it is — not as the thing being watched.
//!
//! **Detail lives in the hover tooltip, not in the expanded panel.** Hovering costs zero screen,
//! which is the whole point: the expanded view covers the game's own UI, so anything you might want
//! *while playing* has to be reachable without it.
//! **Detail lives behind the click, and nowhere else.** The pill briefly carried a hover tooltip
//! holding the whole readout, on the theory that hovering costs no screen — but the pill is
//! top-center, the mouse crosses it constantly, and a panel-sized popup over the game is worse than
//! the panel it was avoiding. One surface, opened deliberately.
//!
//! **The HUD does not own settings.** It briefly carried a VSync checkbox and an MSAA readout;
//! VSync is a player video option now ([`crate::video`], the Graphics page's `gxVSync` row) and
//! MSAA is a startup knob (`$WOW_MSAA`). An instrument reports — it does not double as the control
//! panel, and this one is `#[cfg(feature = "dev")]`, so anything a player must reach cannot live
//! here at all. The measurement knob the checkbox actually existed for is `$WOW_NOVSYNC=1`.
use bevy::diagnostic::{DiagnosticsStore, EntityCountDiagnosticsPlugin};
use bevy::prelude::*;
use bevy::render::view::Msaa;
use bevy::time::Real;
use bevy::window::{PresentMode, PrimaryWindow};
use bevy_egui::{egui, EguiContexts};
@@ -28,7 +34,6 @@ use bevy_egui::{egui, EguiContexts};
use super::stats::{FrameStats, Series};
use super::FRAME_BUDGET_MS;
use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT, OVERLAY_TEXT_DIM};
use benilla_world::view::WorldCamera;
const RED: egui::Color32 = egui::Color32::from_rgb(240, 120, 120);
const AMBER: egui::Color32 = egui::Color32::from_rgb(240, 190, 110);
@@ -40,6 +45,46 @@ const SPIKE_RED_RATIO: f32 = 3.0;
/// The trend sparkline's footprint on the collapsed pill.
const TREND_SIZE: egui::Vec2 = egui::vec2(72.0, 13.0);
/// **The ruler.** The expanded panel is as wide as this string renders in the monospace face —
/// or as wide as the pill above it, whichever is more — and every line inside either fits that or
/// wraps into it. No line is allowed to decide the width by being long.
///
/// It is a *measured* string rather than a pixel constant so the panel stays correct at any font
/// size or UI scale — and it is written at full precision (`000.0`, not `20.2`) so the widest
/// value a field can ever hold still fits. Three tests hold the invariant from both sides: the
/// panel never exceeds its bound, always reaches it, and the ruler (not the pill) is what governs
/// whenever no spike badge is up.
///
/// **Why a governed width at all, twice over.** First, the numbers change width as they change
/// value (`8.55` → `11.73`), so an auto-sized panel jitters under your eye while you read it.
/// Second — and this is what actually shipped broken — auto-sizing here does not merely wobble, it
/// blows up:
///
/// - [`crate::debug_panel::overlay_text`] sets `TextWrapMode::Extend`, so **every label allocates
/// its full natural width and ignores `max_rect` entirely.** One long line (`SpikeKind::describe`
/// is ~110 characters) therefore *defines* the window width, and `set_width` alone cannot stop it.
/// - `Area` runs its sizing pass only on the frame the area is *created*
/// (`sizing_pass = state.is_none()`, egui 0.33 `containers/area.rs`). This HUD is born collapsed,
/// so that pass measures the pill; every later expand has no sizing pass, and `Separator` falls
/// through to `available_size_before_wrap()` — the room it *could* take.
///
/// Together those are the ragged panel the director reported: prose setting the width, separators
/// and the graph stretching to whatever that came out as, and the short numeric lines not. The fix
/// is both halves — a ruler, and `TextWrapMode::Wrap` inside the panel so prose wraps instead of
/// measuring.
const PANEL_RULER: &str = "trend 000.0 → 000.0 ms (+000.0/min)";
/// The panel's content width: [`PANEL_RULER`], measured in the live monospace face.
///
/// Measured as *glyph advance × character count* rather than by laying the string out: that is
/// what a monospace column actually is, and it costs one cached glyph lookup instead of a full
/// text layout on a path that runs every frame the panel is open.
fn panel_width(ui: &egui::Ui) -> f32 {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let advance = ui.ctx().fonts_mut(|f| f.glyph_width(&font, '0'));
advance * PANEL_RULER.chars().count() as f32
}
/// 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.
pub(super) fn synced_mode(mode: PresentMode) -> bool {
@@ -92,8 +137,7 @@ pub(super) fn perf_hud_ui(
stats: Res<FrameStats>,
time: Res<Time<Real>>,
diagnostics: Res<DiagnosticsStore>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
cam_msaa: Query<&Msaa, With<WorldCamera>>,
windows: Query<&Window, With<PrimaryWindow>>,
) -> Result {
if !hud.visible {
return Ok(());
@@ -121,91 +165,123 @@ pub(super) fn perf_hud_ui(
.fill(OVERLAY_FILL),
)
.show(ctx, |ui| {
overlay_text(ui);
if collapsed_pill(ui, &stats, now).clicked() {
if draw_hud(ui, &stats, &diagnostics, synced, now, hud.expanded) {
hud.expanded = !hud.expanded;
}
if !hud.expanded {
return;
}
ui.separator();
expanded_readout(ui, &stats, &diagnostics, synced);
ui.separator();
present_controls(ui, &mut windows, &cam_msaa);
});
Ok(())
}
/// The collapsed pill: fps (dim), the cost headline, the minute-long trend, and the latched spike.
/// The whole row is one click target.
fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Response {
let spike = stats.spike(now);
let row = ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
/// Lay the whole HUD out into `ui`; returns whether the toggle strip was clicked.
///
/// Split out of [`perf_hud_ui`] so the layout can be driven headless by a real `egui::Context` in
/// a test — the width invariant this panel kept breaking is a *layout* property, and the only
/// honest way to hold it is to lay it out and measure (see `panel_layout_is_governed_by_the_ruler`).
pub(super) fn draw_hud(
ui: &mut egui::Ui,
stats: &FrameStats,
diagnostics: &DiagnosticsStore,
synced: bool,
now: f32,
expanded: bool,
) -> bool {
overlay_text(ui);
let pill = collapsed_pill(ui, stats, now);
if expanded {
// The wider of the ruler and the pill above it. The pill is drawn first and `set_width`
// can only grow an already-allocated `min_rect`, never shrink it — so a pill carrying a
// spike badge is genuinely wider than the ruler, and pretending otherwise would just move
// the raggedness rather than fix it. A panel narrower than its own header is not a panel.
ui.set_width(panel_width(ui).max(pill.width()));
// The other half, and the one `set_width` alone could not do: `overlay_text` leaves labels
// on `TextWrapMode::Extend`, where they allocate their natural width and ignore `max_rect`
// entirely — so prose *measures* the window instead of fitting it (see [`PANEL_RULER`]).
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
ui.separator();
expanded_readout(ui, stats, diagnostics, synced, now);
}
// The click target is the whole top strip, not the row of glyphs: `min_rect` is now the
// laid-out content, so when the readout below is wider than the numbers above, the space
// beside them is still the toggle rather than dead pixels. Registered last, so it sits above
// the labels it spans.
let mut strip = pill;
strip.max.x = strip.max.x.max(ui.min_rect().max.x);
ui.interact(strip, ui.id().with("pill"), egui::Sense::click())
.clicked()
}
// fps stays, dimmed: the familiar anchor, and by construction the number that cannot
// see any of this.
ui.label(
egui::RichText::new(format!("{:.0} fps", stats.fps()))
/// The collapsed pill: fps (dim), the cost headline, the minute-long trend, and the latched spike.
/// Returns the row's rect; the caller makes the strip that spans it the toggle.
///
/// **No hover tooltip.** It used to carry the detail so the expanded panel would be unnecessary
/// while playing — but the pill sits top-center, the mouse passes through constantly, and the
/// result was a panel-sized popup covering the game unbidden. Detail lives behind the click now,
/// where it is asked for.
fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Rect {
let spike = stats.spike(now);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// fps stays, dimmed: the familiar anchor, and by construction the number that cannot
// see any of this.
ui.label(
egui::RichText::new(format!("{:.0} fps", stats.fps()))
.color(OVERLAY_TEXT_DIM)
.monospace(),
);
// The headline: process CPU per frame, the meter vsync cannot rail (0717).
match stats.cpu.mean() {
Some(cpu) => ui.label(
egui::RichText::new(format!("{cpu:.1} ms"))
.color(OVERLAY_TEXT)
.monospace()
.strong(),
),
None => ui.label(
egui::RichText::new("-- ms")
.color(OVERLAY_TEXT_DIM)
.monospace(),
);
),
};
// The headline: process CPU per frame, the meter vsync cannot rail (0717).
match stats.cpu.mean() {
Some(cpu) => ui.label(
egui::RichText::new(format!("{cpu:.1} ms"))
.color(OVERLAY_TEXT)
.monospace()
.strong(),
),
None => ui.label(
egui::RichText::new("-- ms")
.color(OVERLAY_TEXT_DIM)
.monospace(),
),
trend_sparkline(ui, &stats.trend, stats.trend_hi());
// The latch. Present only when something actually happened, so its mere appearance is
// the signal — no scanning a number for a change.
if let Some(s) = spike {
let col = if s.peak_ms >= s.baseline_ms * SPIKE_RED_RATIO {
RED
} else {
AMBER
};
trend_sparkline(ui, &stats.trend);
// The latch. Present only when something actually happened, so its mere appearance is
// the signal — no scanning a number for a change.
if let Some(s) = spike {
let col = if s.peak_ms >= s.baseline_ms * SPIKE_RED_RATIO {
RED
} else {
AMBER
};
ui.label(
egui::RichText::new(format!(
"▲{:.1} {} ×{}",
s.peak_ms,
s.kind.tag(),
s.frames
))
ui.label(
egui::RichText::new(format!("▲{:.1} {} ×{}", s.peak_ms, s.kind.tag(), s.frames))
.color(col)
.monospace()
.strong(),
);
}
})
.response
.interact(egui::Sense::click());
row.on_hover_ui(|ui| pill_tooltip(ui, stats, now))
);
}
})
.response
.rect
}
/// The minute-long trend of per-second median CPU cost. Flat means nothing changed; a step means
/// the scene got more (or less) expensive and stayed there — the only lane that can see that, since
/// a sustained cost is its own baseline in every other one.
fn trend_sparkline(ui: &mut egui::Ui, trend: &Series) {
fn trend_sparkline(ui: &mut egui::Ui, trend: &Series, hi: f32) {
let (rect, _) = ui.allocate_exact_size(TREND_SIZE, egui::Sense::hover());
if trend.len() < 2 {
return;
}
let painter = ui.painter_at(rect);
let hi = trend.iter().fold(f32::MIN, f32::max).max(0.001) * 1.15;
// Scaled to the window's p90, **not its max** — and the current value always fits, so nothing
// that matters right now is ever off the top. One launch sample (a loading frame is ~110 ms
// against a settled 8.5) is enough to press an entire minute of real signal into the bottom 7%
// of the box, which is what the lane looked like on the director's screen: a flat line. The
// clamp in `to_y` lets a true outlier clip instead of flattening its neighbours.
let hi = (hi.max(trend.last())).max(0.001) * 1.15;
let to_y = |ms: f32| rect.bottom() - (ms / hi).clamp(0.0, 1.0) * rect.height();
// Where the window started, so a step reads as a departure from something rather than as an
@@ -231,62 +307,152 @@ fn trend_sparkline(ui: &mut egui::Ui, trend: &Series) {
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, col)));
}
/// Everything the pill cannot fit, on hover — the affordance that makes the expanded panel
/// unnecessary while playing.
fn pill_tooltip(ui: &mut egui::Ui, stats: &FrameStats, now: f32) {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
/// The full readout, in two answers: **is it running well** (the interval we're being granted and
/// how often we missed it), then **where the time goes** (the percentile table, the trend, the
/// graph, and the latched spike in full).
///
/// It used to be nine lines answering those two questions three times over — a `last` sample the
/// graph below already drew, the p50s of `cpu` and `main` restated underneath as means, and a pair
/// of controls — with a *second* copy of most of it in a hover tooltip that opened whenever the
/// mouse crossed the pill. Everything appears once, here, behind the click.
fn expanded_readout(
ui: &mut egui::Ui,
stats: &FrameStats,
diagnostics: &DiagnosticsStore,
synced: bool,
now: f32,
) {
let rail = stats.rail_ms();
// Synced, a small over is rail jitter, not cost — red only past the missed-interval threshold
// there. Uncapped, wall time is our cost and the 60 fps floor is the honest bar.
let red_above = if synced {
stats.dropped_above_ms()
} else {
FRAME_BUDGET_MS
};
let row = |ui: &mut egui::Ui, name: &str, s: &Series| {
let (p50, p99, max) = s.percentiles();
// Both header lines are monospace and short enough to sit inside [`PANEL_RULER`]; the prose
// they used to carry ("observed", "60 fps floor") moved to the hover, which is free.
if synced {
ui.label(
egui::RichText::new(format!(
"{name:<5} p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
"interval {rail:>6.2} ms ({:.0} Hz)",
if rail > 0.0 { 1000.0 / rail } else { 0.0 }
))
.color(OVERLAY_TEXT_DIM)
.monospace(),
)
.on_hover_text(
"the present interval as OBSERVED, not assumed — an adaptive panel has no fixed \
rail (0294), so the only honest answer is what the display has been granting",
);
} else {
ui.label(
egui::RichText::new(format!("uncapped {FRAME_BUDGET_MS:>6.2} ms budget"))
.color(OVERLAY_TEXT_DIM)
.monospace(),
)
.on_hover_text("uncapped: wall time is our real frame cost, against the 60 fps floor");
}
let (over_n, over_frac) = stats.wall.frames_over(red_above);
let ob_col = if over_frac > 0.0 {
RED
} else {
OVERLAY_TEXT_DIM
};
// Synced: "missed" = missed present intervals — 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.label(
egui::RichText::new("per-frame cost over the last ~300 frames")
egui::RichText::new(format!(
"{:<8} {over_n}/{} ({:.0}%)",
if synced { "missed" } else { "over" },
stats.wall.len(),
over_frac * 100.0
))
.color(ob_col)
.monospace(),
)
.on_hover_text(if synced {
"frames past 1.5x the OBSERVED present interval — a missed display interval. \
The interval is measured, not assumed at 60 Hz, so this counts a 120 -> 60 drop \
(which a fixed 25 ms threshold cannot see)"
} else {
"frames past the 16.7 ms budget (uncapped: wall time ~= real frame cost)"
});
ui.add_space(4.0);
// One header instead of the labels repeated on every row — the columns are the same three
// every time, so naming them once is both shorter and easier to read down.
ui.label(
egui::RichText::new(format!("{:<5}{:>8}{:>8}{:>8}", "ms", "p50", "p99", "max"))
.color(OVERLAY_TEXT_DIM)
.monospace(),
);
row(ui, "cpu", &stats.cpu);
row(ui, "main", &stats.main);
row(ui, "wall", &stats.wall);
ui.separator();
let rail = stats.rail_ms();
let (dropped, frac) = stats.wall.frames_over(stats.dropped_above_ms());
ui.label(
egui::RichText::new(format!(
"interval {rail:.2} ms (observed) · missed {dropped}/{} ({:.0}%)",
stats.wall.len(),
frac * 100.0
))
.monospace(),
);
for (name, series, why) in [
(
"cpu",
&stats.cpu,
"process CPU per frame, summed across every thread — our work, not the display's \
present grant; comparable with the probes' cpu_ms and a reporter's CPU %",
),
(
"main",
&stats.main,
"CPU consumed by the main thread alone (CLOCK_THREAD_CPUTIME_ID) — the serialized \
half of the line above. A worker-pool burst inflates `cpu` without touching this \
one, and only this one is what a stutter is made of",
),
(
"wall",
&stats.wall,
"the frame interval itself. While synced this measures the display's grant, not our \
cost (0717) — read it for missed intervals, not for how expensive we are",
),
] {
let (p50, p99, max) = series.percentiles();
ui.label(
egui::RichText::new(format!("{name:<5}{p50:>8.2}{p99:>8.2}{max:>8.2}")).monospace(),
)
.on_hover_text(why);
}
// The creep lane, in words — the sparkline on the pill shows the shape, this names the step.
if let Some((first, last)) = stats.trend_ends() {
let delta = last - first;
ui.label(
egui::RichText::new(format!(
"trend {first:.1} → {last:.1} ms ({delta:+.1}) over the last minute"
))
.color(if delta > first * 0.15 {
AMBER
} else {
OVERLAY_TEXT
})
.monospace(),
egui::RichText::new(format!("trend {first:.1} → {last:.1} ms ({delta:+.1}/min)"))
// `.monospace()` is load-bearing, not styling: the proportional face has no glyph for
// U+2192 and drew a tofu box on the director's screen. The mono face has it.
.monospace()
.color(if delta > first * 0.15 {
AMBER
} else {
OVERLAY_TEXT_DIM
}),
);
}
frame_graph(ui, stats, red_above);
let entities = diagnostics
.get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
.and_then(|d| d.value())
.unwrap_or(0.0);
ui.label(
egui::RichText::new(format!("{:<8} {entities:.0}", "entities"))
.color(OVERLAY_TEXT_DIM)
.monospace(),
);
// The badge in full: the pill can only afford peak/kind/count, and the interesting part of a
// spike is what it was measured against and what that kind of spike means.
ui.separator();
match stats.spike(now) {
Some(s) => {
ui.separator();
ui.label(
egui::RichText::new(format!(
"spike {:.2} ms peak over a {:.2} ms baseline, {} frame(s), {:.1}s ago",
"spike {:.2} ms over {:.2}, {}f, {:.0}s ago",
s.peak_ms,
s.baseline_ms,
s.frames,
@@ -295,6 +461,8 @@ fn pill_tooltip(ui: &mut egui::Ui, stats: &FrameStats, now: f32) {
.color(AMBER)
.monospace(),
);
// The only prose in the panel, and the reason `Wrap` is set: ~110 characters that
// would otherwise measure the window (see [`PANEL_RULER`]).
ui.label(egui::RichText::new(s.kind.describe()).color(OVERLAY_TEXT_DIM));
let bursts = stats.recent_bursts(now);
if bursts > 1 {
@@ -307,119 +475,16 @@ fn pill_tooltip(ui: &mut egui::Ui, stats: &FrameStats, now: f32) {
}
}
None => {
ui.separator();
ui.label(egui::RichText::new("no spike in the last 10 s").color(OVERLAY_TEXT_DIM));
}
}
ui.separator();
ui.label(egui::RichText::new("click to expand · Ctrl+Shift+P to hide").color(OVERLAY_TEXT_DIM));
}
fn expanded_readout(
ui: &mut egui::Ui,
stats: &FrameStats,
diagnostics: &DiagnosticsStore,
synced: bool,
) {
let rail = stats.rail_ms();
// Synced, a small over is rail jitter, not cost — red only past the missed-interval threshold
// there. Uncapped, wall time is our cost and the 60 fps floor is the honest bar.
let red_above = if synced {
stats.dropped_above_ms()
} else {
FRAME_BUDGET_MS
};
let last = stats.wall.last();
let last_col = if last > red_above { RED } else { GREEN };
ui.colored_label(last_col, format!("last {last:>6.2} ms"));
if synced {
ui.label(
egui::RichText::new(format!(
"present interval {rail:.2} ms observed ({:.0} Hz)",
if rail > 0.0 { 1000.0 / rail } else { 0.0 }
))
.color(OVERLAY_TEXT_DIM),
);
} else {
ui.label(
egui::RichText::new(format!("budget {FRAME_BUDGET_MS:.2} ms · 60 fps floor"))
.color(OVERLAY_TEXT_DIM),
);
}
for (name, series) in [
("cpu ", &stats.cpu),
("main", &stats.main),
("wall", &stats.wall),
] {
let (p50, p99, max) = series.percentiles();
ui.label(format!(
"{name} p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
));
}
let (over_n, over_frac) = stats.wall.frames_over(red_above);
let ob_col = if over_frac > 0.0 {
RED
} else {
OVERLAY_TEXT_DIM
};
// Synced: "dropped" = missed present intervals — 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}/{} ({:.0}%)",
if synced { "dropped" } else { "over budget" },
stats.wall.len(),
over_frac * 100.0
),
)
.on_hover_text(if synced {
"frames past 1.5x the OBSERVED present interval — a missed display interval. \
The interval is measured, not assumed at 60 Hz, so this counts a 120 -> 60 drop \
(which a fixed 25 ms threshold cannot see)"
} else {
"frames past the 16.7 ms budget (uncapped: wall time ~= real frame cost)"
});
// ~6 ms is the 1.12.1 reference client's whole-scene cost — the long-term bar (0718).
if let Some(cpu) = stats.cpu.mean() {
let mean_ms = stats.wall.mean().unwrap_or(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 %",
);
}
if let Some(main) = stats.main.mean() {
ui.label(format!("main {main:>5.2} ms (main thread only)"))
.on_hover_text(
"CPU consumed by the main thread alone (CLOCK_THREAD_CPUTIME_ID). The \
serialized half of the line above: a worker-pool burst inflates `cpu` \
without touching this one, and only this one is what a stutter is made of",
);
}
let entities = diagnostics
.get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
.and_then(|d| d.value())
.unwrap_or(0.0);
ui.label(format!("entities {entities:.0}"));
frame_graph(ui, stats, red_above);
}
/// The frame-time graph: the windowed wall samples as a polyline with the missed-interval line in
/// red. Scaled to at least twice the observed interval so a dropped (doubled) frame reads clearly.
fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats, red_above: f32) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(232.0, 52.0), egui::Sense::hover());
let (rect, _) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 52.0), egui::Sense::hover());
if stats.wall.is_empty() {
return;
}
@@ -444,42 +509,107 @@ fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats, red_above: f32) {
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, GREEN)));
}
fn present_controls(
ui: &mut egui::Ui,
windows: &mut Query<&mut Window, With<PrimaryWindow>>,
cam_msaa: &Query<&Msaa, With<WorldCamera>>,
) {
if let Ok(mut window) = windows.single_mut() {
let mut vsync = synced_mode(window.present_mode);
// "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 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
};
#[cfg(test)]
mod tests {
use super::*;
/// The panel, laid out by a real `egui::Context`, headless.
///
/// **It must run more than one pass, and that is the whole point.** `Area` runs its sizing
/// pass only on the frame it is created; the width bug lived exclusively on every *later*
/// frame, where `Separator` and unwrapped labels reach for room the content never asked for.
/// A one-pass test would have passed against the broken build.
///
/// Returns `(content width, ruler width)` after the final pass.
fn lay_out(stats: &FrameStats, now: f32, expanded: bool, passes: usize) -> (f32, f32) {
let ctx = egui::Context::default();
let diagnostics = DiagnosticsStore::default();
let (mut width, mut ruler) = (0.0, 0.0);
for _ in 0..passes {
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::Window::new("perf_hud")
.title_bar(false)
.resizable(false)
.collapsible(false)
.movable(false)
.show(ctx, |ui| {
draw_hud(ui, stats, &diagnostics, true, now, expanded);
width = ui.min_rect().width();
ruler = panel_width(ui);
});
});
}
(width, ruler)
}
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"));
/// A settled window plus a latched `Stalled` spike — whose `describe()` is ~110 characters,
/// the longest string the panel can ever draw, and precisely what used to measure the window.
fn stats_with_a_latched_spike() -> (FrameStats, f32) {
let mut stats = FrameStats::default();
let calm: Vec<_> = (0..400).map(|_| (16.6, 20.0, 5.0)).collect();
let mut t = stats.feed_frames(&calm, 0.0, 1.0 / 60.0);
t = stats.feed_frames(&[(40.0, 20.0, 5.0)], t, 1.0 / 60.0);
t = stats.feed_frames(&[(16.6, 20.0, 5.0)], t, 1.0 / 60.0);
assert!(
stats.spike(t).is_some(),
"the fixture must actually latch — otherwise this measures the easy case"
);
(stats, t)
}
/// **The invariant the panel kept breaking:** the expanded panel is exactly as wide as the
/// wider of [`PANEL_RULER`] and the pill above it — never wider. Prose wraps, numbers fit, and
/// no single long line gets to decide the width by being long.
///
/// Three shipped builds widened this panel, the worst to ~5x the ruler, and each was reported
/// by the director rather than caught here. All three fail this test.
#[test]
fn panel_layout_is_governed_by_the_ruler() {
let (stats, t) = stats_with_a_latched_spike();
let (pill, _) = lay_out(&stats, t, false, 3);
let (width, ruler) = lay_out(&stats, t, true, 3);
let governing = ruler.max(pill);
assert!(
width <= governing + 1.0,
"the expanded panel is {width:.1} px against a {governing:.1} px bound \
(ruler {ruler:.1}, pill {pill:.1}) — something in it is refusing to wrap \
(TextWrapMode::Extend) or reaching for available_width"
);
}
/// And it must be genuinely governed, not accidentally narrow: the panel should actually
/// reach its bound, or the guard above is vacuous.
#[test]
fn the_expanded_panel_fills_its_bound() {
let (stats, t) = stats_with_a_latched_spike();
let (pill, _) = lay_out(&stats, t, false, 3);
let (width, ruler) = lay_out(&stats, t, true, 3);
let governing = ruler.max(pill);
assert!(
width >= governing - 1.0,
"the expanded panel is only {width:.1} px of a {governing:.1} px bound"
);
}
/// With no spike latched the pill is comfortably inside the ruler, so the ruler is what
/// governs in the ordinary case — the pill only takes over while a badge is up.
#[test]
fn the_ruler_governs_when_no_spike_is_latched() {
let mut stats = FrameStats::default();
let calm: Vec<_> = (0..400).map(|_| (16.6, 20.0, 5.0)).collect();
let t = stats.feed_frames(&calm, 0.0, 1.0 / 60.0);
assert!(stats.spike(t).is_none(), "the fixture must be quiet");
let (pill, ruler) = lay_out(&stats, t, false, 3);
assert!(
pill < ruler,
"the quiet pill is {pill:.1} px against a {ruler:.1} px ruler — the ruler should \
govern the ordinary panel, or its width tracks the badge and jitters"
);
let (width, _) = lay_out(&stats, t, true, 3);
assert!(
(width - ruler).abs() <= 1.0,
"the quiet panel is {width:.1} px, not the {ruler:.1} px ruler"
);
}
}
+134 -5
View File
@@ -58,6 +58,20 @@ const SPIKE_FLOOR_MIN_MS: f32 = 2.0;
/// has to outlive the event by long enough to glance at.
const LATCH_HOLD_SECS: f32 = 10.0;
/// Consecutive frames a [`SpikeKind::Worker`] burst must last before the badge will show it.
///
/// **Because `cpu` is a sum across threads, not a duration.** A frame in which six pool workers
/// happen to overlap carries six threads' milliseconds and trips the ratio test while costing the
/// frame nothing — the first live run of this HUD latched `▲31.3 work ×1` against `missed 0/300`,
/// which is the instrument reporting the scheduler rather than a regression. A real worker
/// regression is *sustained* (0610's burst is ~6 frames), so a few consecutive frames separates
/// the two cleanly.
///
/// [`SpikeKind::Main`] and [`SpikeKind::Stalled`] are deliberately **not** gated: one long
/// main-thread frame is itself the stutter, and `Stalled` already had to exceed the
/// missed-interval threshold to classify at all.
const WORKER_BURST_FRAMES: u32 = 3;
/// Baselines are re-derived this often, not every frame. A median moves slowly by construction, so
/// a one-second-stale baseline changes no verdict — and it keeps the collapsed pill's per-frame
/// path free of sorts (1370: the HUD is itself inside every campaign anchor).
@@ -129,6 +143,17 @@ impl Series {
(at(0.50), at(0.99), *v.last().unwrap())
}
/// The window's `q`-quantile. Sorts a copy, so like [`Self::percentiles`] it belongs off the
/// per-frame path — [`Baselines`] is where its caller reads the answer from.
fn quantile(&self, q: f32) -> f32 {
if self.samples.is_empty() {
return 0.0;
}
let mut v: Vec<f32> = self.samples.iter().copied().collect();
v.sort_by(f32::total_cmp);
v[(((v.len() - 1) as f32) * q).round() as usize]
}
/// The window's median alone — the baseline every spike test is relative to.
fn median(&self) -> f32 {
if self.samples.is_empty() {
@@ -210,12 +235,23 @@ pub(super) struct Spike {
pub(super) at: f32,
}
/// Is this burst worth a badge? See [`WORKER_BURST_FRAMES`] — the one kind that needs to prove
/// itself is the one measured by a cross-thread sum.
fn reportable(s: &Spike) -> bool {
s.kind != SpikeKind::Worker || s.frames >= WORKER_BURST_FRAMES
}
/// Medians re-derived once a second, off the per-frame path.
#[derive(Default)]
struct Baselines {
wall: f32,
cpu: f32,
main: f32,
/// The trend sparkline's y-scale: the trend window's p90, **not its max**. Derived here rather
/// than in the painter because it needs a sort and the pill draws every frame — and refreshed
/// on exactly the tick that appends the sample it summarises, so it can never lag by more
/// than one point.
trend_hi: f32,
at: f32,
}
@@ -278,6 +314,11 @@ impl FrameStats {
self.rail_ms() * DROPPED_FACTOR
}
/// The sparkline's y-scale — see [`Baselines::trend_hi`].
pub(super) fn trend_hi(&self) -> f32 {
self.baselines.trend_hi
}
/// Windowed mean frames per second. A mean, and labelled as one — it is the number that cannot
/// see a spike, which is why it is no longer the pill's headline.
pub(super) fn fps(&self) -> f32 {
@@ -292,6 +333,7 @@ impl FrameStats {
self.latched
.filter(|s| now - s.at < LATCH_HOLD_SECS)
.or(self.run)
.filter(reportable)
}
/// Bursts finished inside the hold window — "it happened three times" is a different report
@@ -362,10 +404,11 @@ impl FrameStats {
}
}
None => {
// The burst (if any) just ended: commit it. The latch keeps the WORST burst in the
// hold window rather than the most recent, so a small one cannot bury the big one
// you were meant to see.
if let Some(run) = self.run.take() {
// The burst (if any) just ended: commit it, unless it never earned a badge — a
// filtered burst is not a burst, or the tooltip would count three while the pill
// shows one. The latch keeps the WORST burst in the hold window rather than the
// most recent, so a small one cannot bury the big one you were meant to see.
if let Some(run) = self.run.take().filter(reportable) {
if now - self.recent_since >= LATCH_HOLD_SECS {
self.recent_bursts = 0;
self.recent_since = now;
@@ -392,6 +435,26 @@ impl FrameStats {
})
}
/// Drive the meters from a sibling module's test, on the same path [`sample_frame_time`] takes
/// minus the clocks. Each frame is `(wall_ms, cpu_ms, main_ms)`; returns the clock it left off
/// at. `hud`'s layout test needs a realistically-populated `FrameStats` — including a *latched
/// spike*, whose description is the longest string the panel can draw.
#[cfg(test)]
pub(super) fn feed_frames(&mut self, frames: &[(f32, f32, f32)], start_t: f32, dt: f32) -> f32 {
let mut t = start_t;
for &(wall, cpu, main) in frames {
self.wall.push(wall);
self.cpu.push(cpu);
self.main.push(main);
self.refresh_baselines(t);
if self.wall.len() > 1 && self.baselines.wall > 0.0 {
self.observe(t);
}
t += dt;
}
t
}
fn refresh_baselines(&mut self, now: f32) {
if now - self.baselines.at < BASELINE_REFRESH_SECS && self.baselines.at > 0.0 {
return;
@@ -400,12 +463,14 @@ impl FrameStats {
wall: self.wall.median(),
cpu: self.cpu.median(),
main: self.main.median(),
trend_hi: self.baselines.trend_hi,
at: now,
};
// The trend samples the same median the baseline does, so the two lanes cannot disagree
// about what "normal right now" is — the badge and the sparkline read one truth.
if !self.cpu.is_empty() {
self.trend.push(self.baselines.cpu);
self.baselines.trend_hi = self.trend.quantile(0.90);
}
}
}
@@ -538,13 +603,47 @@ mod tests {
let mut s = FrameStats::default();
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 60.0, 4.0)], t, 1.0 / 120.0);
let burst: Vec<_> = (0..WORKER_BURST_FRAMES).map(|_| (8.3, 60.0, 4.0)).collect();
t = feed(&mut s, &burst, t, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
let spike = s.spike(t).expect("a worker burst must latch");
assert_eq!(spike.kind, SpikeKind::Worker);
}
/// The noise floor the badge kept tripping on: `cpu` sums every thread, so one frame where
/// several pool workers overlap trips the ratio test while costing the frame nothing. It must
/// not reach the pill — nor the tooltip's burst count, or the two would disagree.
#[test]
fn a_one_frame_worker_blip_does_not_latch() {
let mut s = FrameStats::default();
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 60.0, 4.0)], t, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
assert!(
s.spike(t).is_none(),
"a single-frame cross-thread CPU blip is the scheduler, not a regression"
);
assert_eq!(s.recent_bursts(t), 0, "and it is not counted as a burst");
}
/// The gate is on `Worker` alone: a one-frame main-thread spike *is* the stutter and must
/// still latch immediately.
#[test]
fn a_one_frame_main_spike_is_not_gated() {
let mut s = FrameStats::default();
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 15.0, 30.0)], t, 1.0 / 120.0);
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
let spike = s.spike(t).expect("one long main-thread frame must latch");
assert_eq!(spike.kind, SpikeKind::Main);
assert_eq!(spike.frames, 1);
}
/// The 120 Hz calibration bug, pinned. A missed interval on a ProMotion panel is 16.7 ms; the
/// old fixed 25 ms threshold (1.5 × an assumed 60 Hz budget) could not see one.
#[test]
@@ -570,6 +669,36 @@ mod tests {
assert_eq!(old_n, 0, "the assumed-60 Hz threshold is blind to it");
}
/// The sparkline's y-scale must survive the launch spike. A loading second costs ~110 ms
/// against a settled ~8.5, and scaling to the window's MAX pressed a whole minute of real
/// signal into the bottom 7 % of the box — the lane read as a flat line on the director's
/// screen. p90 drops the outlier and keeps the shape.
#[test]
fn one_loading_second_does_not_flatten_the_trend_lane() {
let mut s = FrameStats::default();
let mut t = 0.0;
// A loading second, then a settled minute — one trend sample per second, so step the
// clock a full second between feeds.
t = feed(&mut s, &[(111.0, 111.0, 90.0)], t, 1.0);
for _ in 0..40 {
t = feed(&mut s, &[(16.6, 8.5, 1.7)], t, 1.0);
}
assert!(
s.trend.iter().any(|v| v > 100.0),
"the loading sample is still IN the window — this is about the scale, not a filter"
);
assert!(
s.trend_hi() < 20.0,
"p90 must ignore the launch outlier, got {}",
s.trend_hi()
);
// The settled level has to land in a readable part of the box, not squashed at the floor.
assert!(
8.5 / (s.trend_hi() * 1.15) > 0.5,
"the settled level must occupy the upper half of the lane"
);
}
/// The latch must keep the worst burst in the window, not the most recent — a small wobble
/// after a big spike must not bury it.
#[test]
@@ -648,3 +648,71 @@ fn fcf_select_dock_frame_selects_by_frame_and_raises_for_an_undocked_one() {
"an undocked window raises — benilla has no dock model for it (0288 §2)"
);
}
/// **The idle dock writes nothing** (decision 1396). `FCF_OnUpdate`'s apply block used to run
/// unconditionally — ~24 `getglobal`s, two table builds and ~20 `SetAlpha` calls every frame of
/// every session, writing the values that were already there, at 36 µs/frame (1395 measured it as
/// the largest single handler in the client).
///
/// The probe is a sentinel the applier can never write: it only ever writes `{0, 0.5, 1.0} × reveal`,
/// and `reveal` is pinned at 0 with the cursor away from the dock. If the gate is removed, the very
/// next tick overwrites 0.42 with 0 and this goes red.
#[test]
fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0); // far from the dock: no hover, so `reveal` stays 0
for _ in 0..8 {
s.tick(0.016); // let the label measurements land and the applier reach its resting write
s.resolve();
}
s.run("ChatFrame1Tab:SetAlpha(0.42)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
(alpha - 0.42).abs() < 1e-6,
"a settled dock must not rewrite its tab alpha — got {alpha}"
);
}
/// The control for the guard above: the gate must not cost the reveal it is gating. A stationary
/// cursor over the dock for longer than `CHAT_TAB_SHOW_DELAY` fades the selected tab to full.
#[test]
fn hovering_the_dock_still_reveals_the_tabs() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert!(
s.eval::<f64>("return ChatFrame1Tab:GetAlpha()").unwrap() < 1e-6,
"the dock starts concealed"
);
// The dock's own centre, asked of the layout rather than hardcoded.
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
s.mouse_move(x, y);
// 0.2 s stationary to arm the reveal, then the 0.15 s ramp — 25 frames covers both with room.
for _ in 0..25 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
(alpha - 1.0).abs() < 1e-6,
"a stationary hover reveals the selected tab at full alpha — got {alpha}"
);
}
+11
View File
@@ -218,6 +218,17 @@ impl Default for UiScaleCvar {
/// The shipped default (the reference's slider spans 0.64..1.0; 1.0 read oversized to the
/// director's eye — the taste call this dial exists for).
///
/// **It also happens to be what the reference itself lands on above ~853 px tall**, which we did
/// not know when it was chosen (wow-re `system/ui/scratch/modelframe-uiscale-law.md`, out of the
/// autocast-shine thread): `0x492f70` computes `max((H > 768) ? 768/H : 1.0, 0.9)` and is called
/// **both** on a mode set and from the `useUiScale`-OFF leg (`0x4908ad`) — and OFF is the shipped
/// default (`0x8430c0` = `"0"`). So the reference is 1.0 at 768 and below, 0.96 at 1280×800, and
/// **0.9 at 1080p and 1440p**. Our flat 0.9 therefore agrees with it on every modern window and
/// diverges only *below* ~853 px tall, where the reference is nearer 1.0. Left alone deliberately:
/// this constant is a director taste call, and the divergence is confined to window heights we
/// do not ship against. The reference's own law is written here so the next session inherits it
/// rather than re-deriving it.
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The app's `uiScale`: `WOW_UI_SCALE=` if set (clamped to the plausible dial range), else
@@ -822,6 +822,56 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Vertical Sync** (1394) — the Graphics page's third row, and the first option in this window
/// that reaches the *window* rather than a gameplay or UI knob. It is 1.12's own Video Options
/// checkbox 5 (`gxVSync`), which lived on the perf HUD as a dev checkbox until it turned out to be
/// unreachable in a player build (the HUD is `#[cfg(feature = "dev")]`) and to be a setting rather
/// than an instrument in the first place.
///
/// The row is deliberately **not** deferred, unlike the UI Scale slider above it: the reference's
/// row carries `gxRestart = 1` because its device could not swap the presentation interval live,
/// and wgpu can — so the click commits, like every other checkbox here.
#[test]
fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
let mut s = audio_harness();
s.set_cvar_host("gxVSync", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
// Read from the table, not from a restated default: the harness seeded it off.
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:GetChecked()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowVerticalSyncLabel:GetText()")
.unwrap(),
"Vertical Sync"
);
let _ = s.take_cvar_changes();
// A click commits immediately — nothing stages it behind Apply.
s.run("OptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gxVSync".to_string(), "1".to_string())],
"the checkbox writes the cvar on click, not on Apply"
);
// Defaults walks it back to the registered "1" — which `cvars::tests` welds to
// `VideoConfig::default()`, and `video::tests` welds in turn to the window's boot mode.
s.run("OptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:Click()")
.unwrap();
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:GetChecked()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The dropdown row (0992, the first): the capsule reads the CVar on select (text from the
/// row's own entries), the capsule button opens the shared list at the OWNER's effective scale
/// (the kit's 0992 uiScale correction — the window rides SetScale) with the current value's
@@ -1647,10 +1697,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 19 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's) + the Combat page's 14 saved-variable rows (1134) + the Interface
// page's 4 (3 from 1136, Buff Durations 1139) and the Action Bars page's 1 (1136).
assert_eq!(checked, 38, "every row but one carries a live 1.12 key");
// 20 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's) + the Combat page's 14
// saved-variable rows (1134) + the Interface page's 4 (3 from 1136, Buff Durations 1139) and
// the Action Bars page's 1 (1136).
assert_eq!(checked, 39, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1739,10 +1790,10 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 19 of the 20 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's), plus the Combat page's 14 saved-variable rows (1134), the Interface page's 4
// (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
assert_eq!(raised, 38, "every row but Auto Loot has a 1.12 description");
// 20 of the 21 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's), plus the Combat page's 14 saved-variable
// rows (1134), the Interface page's 4 (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
assert_eq!(raised, 39, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
+126
View File
@@ -0,0 +1,126 @@
//! Player-facing **video settings** — the knobs that reach the window and the presentation path.
//!
//! Today that is exactly one: **VSync** (`gxVSync`, the Graphics page's *Vertical Sync* row).
//!
//! **Why it is a player setting and not a dev toggle.** It briefly lived as a checkbox on the perf
//! HUD, where it existed to answer one instrument question — "is the GPU keeping up?" — because a
//! synced frame reads as the display's grant whether it needed 3 ms or 16 (0717). That is the wrong
//! home twice over: the HUD is `#[cfg(feature = "dev")]`, so a player build could never reach it,
//! and vsync is not a diagnostic in the first place. It is the same option 1.12 shipped —
//! `OptionsFrameCheckButtons["VERTICAL_SYNC"] = { index = 5, cvar = "gxVSync", gxRestart = 1 }`,
//! Video Options, checkbox 5 — and the same one every engine since has kept. The instrument
//! question keeps `$WOW_NOVSYNC=1`, which is where a measurement knob belongs.
//!
//! **We do not require the restart 1.12 did.** The reference's row carries `gxRestart = 1` because
//! its device could not swap the presentation interval live; wgpu reconfigures the surface on the
//! next frame, so the checkbox takes effect as you click it. A deliberate, stated departure.
//!
//! **`AutoNoVsync`, never `Immediate`.** On macOS/Metal, explicit `Immediate` both rails *and*
//! takes ~1 s `nextDrawable` stalls — measured, and pinned at [`crate::capture::probe_uncap_mode`].
use bevy::prelude::*;
use bevy::window::{PresentMode, PrimaryWindow};
/// `$WOW_NOVSYNC=1` — the session-only measurement override. It wins over the config for the run
/// and never reaches `config.toml` (registered in [`crate::cvars`]'s `env_overridden` set), so a
/// headless FPS-journal run can uncap without making the player's setting sticky.
pub(crate) fn novsync_env() -> bool {
std::env::var("WOW_NOVSYNC").as_deref() == Ok("1")
}
/// The video knobs a CVar write can land on. Default = what the client ships: synced, matching the
/// primary window's own `PresentMode::default()` (Fifo) — see `lib.rs`'s window literal.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
vsync: !novsync_env(),
}
}
}
/// The present mode a vsync setting means.
///
/// **On is `PresentMode::default()` — Bevy's default, deliberately, not `AutoVsync`.** 0294 recorded
/// running the engine default and that is `Fifo`: strict, never tears. `AutoVsync` is *not* a
/// synonym — it resolves to `FifoRelaxed` where available, which permits a tear on a late frame.
/// The dev HUD's old checkbox wrote `AutoVsync` on the way back on, so toggling it off and on
/// silently left the client on a different mode than it booted with; naming one function the single
/// mapping is what closes that.
pub(crate) fn present_mode(vsync: bool) -> PresentMode {
if vsync {
PresentMode::default()
} else {
PresentMode::AutoNoVsync
}
}
pub(crate) struct VideoPlugin;
impl Plugin for VideoPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<VideoConfig>()
.add_systems(Update, apply_present_mode);
}
}
/// Push the setting to the window **when the setting moves**, and only then.
///
/// The value compare is the `rescatter_clutter` pattern (0992), and it is load-bearing for two
/// separate reasons. `Res::is_changed()` over-fires: the cvar sync builds its `Knobs` bundle by
/// deref-mutting *every* knob resource, so any CVar write anywhere flags this one. And re-asserting
/// the setting every frame would fight the capture probes, which write `present_mode` on the window
/// directly mid-run (`capture::mod`, `probes::live_fps`) to uncap a measurement — their override
/// has to stick.
///
/// First sight deliberately does **not** just arm: `load_config` applies the saved value at
/// `Startup`, after the window already exists at its boot mode, so the first run is the one that
/// reconciles them.
fn apply_present_mode(
cfg: Res<VideoConfig>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut last: Local<Option<bool>>,
) {
if last.replace(cfg.vsync) == Some(cfg.vsync) {
return;
}
let want = present_mode(cfg.vsync);
let Ok(mut window) = windows.single_mut() else {
return;
};
// Guarded so an unchanged mode does not deref-mut `Window` — a spurious change there is a
// surface reconfigure.
if window.present_mode != want {
window.present_mode = want;
info!(
"video: vsync {} ({want:?})",
if cfg.vsync { "on" } else { "off" }
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped default is synced, and "synced" is the engine default 0294 chose — `Fifo`, not
/// `AutoVsync`. Written as an assertion because the two are easy to conflate and are not the
/// same: `AutoVsync` resolves to `FifoRelaxed` where available, which tears on a late frame.
#[test]
fn the_default_is_synced_like_the_window_literal() {
assert!(VideoConfig::default().vsync);
assert_eq!(present_mode(true), PresentMode::default());
assert_eq!(present_mode(true), PresentMode::Fifo);
}
/// Uncapped is `AutoNoVsync` and never `Immediate` — the Metal finding this module exists to
/// hold (`Immediate` rails and takes ~1 s `nextDrawable` stalls).
#[test]
fn uncapped_is_autonovsync_not_immediate() {
assert_eq!(present_mode(false), PresentMode::AutoNoVsync);
}
}
+6
View File
@@ -202,6 +202,12 @@ fn fire(
};
let wrapper = frame_wrapper(lua, id)?;
// The attribution seam (decision 1395). Tight around the call and *after* `frame_wrapper` —
// which documents that callers hold no model borrow here, so the profiler's own borrow cannot
// land in the middle of one. Off, it is a relaxed load and a not-taken branch; the guard closes
// the fire on the way out of this scope, including an unwind.
let _fire =
super::handler_prof::armed().then(|| super::handler_prof::Fire::open(lua, id, script));
invoke_with_globals(lua, wrapper, &func, event_name, extra)
}
@@ -0,0 +1,397 @@
//! Per-handler cost attribution (`WOW_UI_HANDLERS=<secs>`) — *which* FrameXML handler is spending
//! the frame. An `impl UiScript` block beside its concern, the `layout.rs` pattern.
//!
//! ## Why it exists (decision 1395)
//!
//! Three perf hunts in a row — 1383's autocast shine, 1385's cast bar, 1388's layout graph — each
//! opened with the same question and each answered it **by hand**: read the shipped Lua, guess the
//! busy handler, add a temporary print, re-run. `[ui-cost] tick=` is one aggregate over the whole
//! `OnUpdate` sweep, so it can say *that* the sweep got expensive and never *who*. This is the
//! missing decomposition, and it sits one layer below the one that landed beside it: 1389's HUD
//! measures the frame's cost and latches its tail (`cpu`/`main` ms, the spike badge), which is
//! **when** and **how much**; this is **who**, inside the UI's share of it.
//!
//! ## What it measures
//!
//! Every handler fired through [`super::event::fire`] — the single firing path for `OnUpdate`,
//! `OnEvent`, `OnShow`/`OnHide`, `OnSizeChanged`, and the whole widget set (`OnClick`, `OnEnter`,
//! `OnValueChanged`, …). Not just the tick's `OnUpdate` sweep, deliberately: an event storm (the
//! combat log at 40 lines a second) costs frames the same way a hot `OnUpdate` does, and a `by
//! script:` rollup on the report line is what tells the two apart at a glance.
//!
//! **Self and total, because handler firing nests.** A handler that calls `Show()` fires `OnShow`
//! inside itself, and a handler that resizes something fires `OnSizeChanged`; charging the parent
//! for its children would name the wrong frame, which is the one failure this instrument cannot
//! afford. A stack of per-level child nanos gives both: `total` is what the call cost, `self` is
//! what it cost *itself*. Sorted by `self`.
//!
//! Engine work a handler provokes synchronously **is** its self time, and that is the point — a
//! geometry getter inside an `OnUpdate` calls `settle()`, which is a whole layout resolve
//! (1388's opening finding: `OptionsScroll_Fit` does four per frame). That cost belongs to the
//! handler that asked for it.
//!
//! ## What it costs when off
//!
//! One relaxed atomic load and a not-taken branch per handler fire ([`armed`]). The state lives in
//! its own `app_data` slot rather than in [`super::Model`]: mlua keys `app_data` by `TypeId` with a
//! **`RefCell` per entry** (`mlua-0.11.6`, `types/app_data.rs`), so the profiler's borrow can never
//! collide with a model borrow held across a fire — a hazard that would otherwise be a panic in the
//! hottest path in the client, armed only in the sessions where we are already chasing something.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use mlua::Lua;
use super::UiScript;
/// How many rows the periodic report prints before rolling the rest into a `… N more` line.
const REPORT_ROWS: usize = 12;
/// The fast gate: is *any* VM in this process profiling handlers?
///
/// Process-wide and not per-VM on purpose — the check runs per handler fire, and a per-VM answer
/// would cost the `app_data` lookup this is here to avoid. A false positive (one VM armed, another
/// not) costs the unarmed VM a hash lookup that finds `on == false`, which is the only case where
/// the two answers differ and is not a case that ships.
static ARMED: AtomicBool = AtomicBool::new(false);
#[inline]
pub(super) fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// `WOW_UI_HANDLERS=<secs>` — the report period, read once. `WOW_UI_HANDLERS=3` reports every three
/// seconds; anything unparseable or non-positive leaves the instrument off, the `[ui-cost]` /
/// `[layout-prof]` posture.
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_HANDLERS")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|secs| *secs > 0.0)
})
}
/// One `(frame, script)` pair's accumulation over the current window.
struct Slot {
script: String,
calls: u32,
total_ns: u64,
self_ns: u64,
}
/// The profiler's state, one per VM, in its own `app_data` slot (see the module header).
///
/// Keyed by frame **id**, not by name: the name is a `String` clone and a two-map walk, so it is
/// resolved once at report time rather than on every fire. A frame destroyed before the report
/// reads back as `#<id>` — its cost still counted, which is what a churning pool of frames needs.
#[derive(Default)]
pub(crate) struct HandlerProf {
/// Is *this* VM recording? [`armed`] is the process-wide gate; this is the per-VM answer.
on: bool,
/// Seconds between reports; `0.0` records forever and never prints (what a test wants).
period: f32,
/// Wall seconds and frames since the last report — the window every printed rate divides by.
window: f32,
frames: u32,
/// Fires that had at least one nested fire inside them. Printed as the one-number answer to
/// "should I be reading `self` or `total` here?".
nested: u32,
/// Nanos already charged to *nested* fires, one entry per open fire. See the module header.
stack: Vec<u64>,
rows: HashMap<u32, Vec<Slot>>,
}
/// An open fire, closed by its `Drop` — the balance of the nesting stack is then a property of the
/// borrow checker rather than of the call site remembering to close what it opened. It also holds
/// under an unwind, which a hand-written enter/exit pair does not: a panic through a handler would
/// otherwise leak a stack level and silently inflate every parent attribution after it.
pub(super) struct Fire<'a> {
lua: &'a Lua,
id: u32,
script: &'a str,
/// `None` while this VM is not recording — the whole guard is then a no-op.
started: Option<Instant>,
}
impl<'a> Fire<'a> {
/// Open a fire on `(id, script)`. Cheap and inert unless this VM is recording.
pub(super) fn open(lua: &'a Lua, id: u32, script: &'a str) -> Fire<'a> {
let started = match lua.app_data_mut::<HandlerProf>() {
Some(mut prof) if prof.on => {
prof.stack.push(0);
Some(Instant::now())
}
_ => None,
};
Fire {
lua,
id,
script,
started,
}
}
}
impl Drop for Fire<'_> {
/// Charge `total` to `(id, script)`, `total - children` to its self time, and hand `total` up
/// to the enclosing fire as *its* child time.
fn drop(&mut self) {
let Some(started) = self.started else {
return;
};
let total = started.elapsed().as_nanos() as u64;
let Some(mut prof) = self.lua.app_data_mut::<HandlerProf>() else {
return;
};
let children = prof.stack.pop().unwrap_or(0);
if let Some(parent) = prof.stack.last_mut() {
*parent += total;
}
if children > 0 {
prof.nested += 1;
}
// `saturating_sub`: children are measured strictly inside this fire, so the subtraction
// cannot go negative by construction — but a construction argument is not a reason to hand
// a wrapped `u64` to a report if a clock ever disagrees.
let self_ns = total.saturating_sub(children);
let slots = prof.rows.entry(self.id).or_default();
match slots.iter_mut().find(|s| s.script == self.script) {
Some(slot) => {
slot.calls += 1;
slot.total_ns += total;
slot.self_ns += self_ns;
}
// The only allocation on this path, and only on a `(frame, script)` pair's first fire
// of the window — steady state is a linear scan over one frame's handful of slots.
None => slots.push(Slot {
script: self.script.to_string(),
calls: 1,
total_ns: total,
self_ns,
}),
}
}
}
/// One `(frame, script)` pair's cost over the profiler's current window.
#[derive(Clone, Debug, PartialEq)]
pub struct HandlerRow {
/// The frame's name, or `#<id>` for an anonymous or already-destroyed frame.
pub frame: String,
pub script: String,
pub calls: u32,
/// Microseconds spent in this handler *excluding* handlers it fired — what to sort by.
pub self_us: f64,
/// Microseconds spent in this handler *including* handlers it fired.
pub total_us: f64,
}
impl UiScript {
/// Arm or disarm handler attribution for this VM, reporting every `period_secs` (`0.0` records
/// with no periodic report — read it with [`UiScript::handler_profile`] instead).
///
/// Call it between ticks. An open [`Fire`] is inert either way (it decided once, at `open`),
/// but this clears the nesting stack, so a disarm from *inside* a handler would drop a level
/// its enclosing fires are still counting on.
pub fn profile_handlers(&self, on: bool, period_secs: f32) {
if on {
ARMED.store(true, Ordering::Relaxed);
}
let Some(mut prof) = self.lua.app_data_mut::<HandlerProf>() else {
return;
};
prof.on = on;
prof.period = period_secs;
prof.window = 0.0;
prof.frames = 0;
prof.nested = 0;
prof.stack.clear();
prof.rows.clear();
}
/// This VM's handler costs over the current window, heaviest **self** time first.
///
/// Window totals, not per-frame rates — the frame count is [`UiScript::handler_profile_frames`],
/// so a caller that wants a rate divides by the number it also has to report.
pub fn handler_profile(&self) -> Vec<HandlerRow> {
// Snapshot under the profiler's borrow and then let it go: labelling reaches into the model
// *and* back into Lua, neither of which may be touched with a borrow of anything open.
let raw: Vec<(u32, String, u32, u64, u64)> = match self.lua.app_data_ref::<HandlerProf>() {
Some(prof) => prof
.rows
.iter()
.flat_map(|(&id, slots)| {
slots.iter().map(move |slot| {
(
id,
slot.script.clone(),
slot.calls,
slot.self_ns,
slot.total_ns,
)
})
})
.collect(),
None => return Vec::new(),
};
let mut rows: Vec<HandlerRow> = raw
.into_iter()
.map(|(id, script, calls, self_ns, total_ns)| HandlerRow {
frame: self.handler_label(id, &script),
script,
calls,
self_us: self_ns as f64 / 1000.0,
total_us: total_ns as f64 / 1000.0,
})
.collect();
rows.sort_by(|a, b| {
b.self_us
.total_cmp(&a.self_us)
.then_with(|| a.frame.cmp(&b.frame))
.then_with(|| a.script.cmp(&b.script))
});
rows
}
/// Frames ticked since the last report — [`UiScript::handler_profile`]'s denominator.
pub fn handler_profile_frames(&self) -> u32 {
self.lua
.app_data_ref::<HandlerProf>()
.map_or(0, |prof| prof.frames)
}
/// Advance the profiler's window by one tick and print the report if it is due. Called at the
/// end of [`UiScript::tick`], so "a frame" means a ticked frame whatever else fired in it.
pub(super) fn report_handler_profile(&mut self, elapsed: f32) {
if !armed() {
return;
}
let due = match self.lua.app_data_mut::<HandlerProf>() {
Some(mut prof) if prof.on => {
prof.frames += 1;
prof.window += elapsed;
prof.period > 0.0 && prof.window >= prof.period
}
_ => false,
};
if !due {
return;
}
// The rows first, so the model borrow the label walk takes is released before the profiler
// is borrowed mutably to reset it.
let rows = self.handler_profile();
let mut prof = self
.lua
.app_data_mut::<HandlerProf>()
.expect("profiler app_data — checked above");
print_report(&rows, prof.window, prof.frames, prof.nested);
prof.window = 0.0;
prof.frames = 0;
prof.nested = 0;
prof.rows.clear();
}
}
impl UiScript {
/// A row's label: the frame's name, else **where the handler was written**, else `#<id>`.
///
/// The definition site is not a nicety. `CreateFrame("Frame")` + `OnUpdate` with no name is
/// *the* addon timer idiom, so the anonymous case is not the rare one — it is exactly the set
/// of handlers an attribution table exists to catch, and `#4127` is not something anyone can
/// act on. `Function::info()` hands back the chunk and line, and every addon chunk is named
/// `@Interface\AddOns\<Folder>\<File>` (see [`super::addon_chunk_name`]), so the label reads as
/// the addon and the line to open.
fn handler_label(&self, id: u32, script: &str) -> String {
let named = {
let model = self.model_ref();
model
.id_to_frame
.get(&id)
.and_then(|&h| model.arena.frame(h))
.and_then(|f| f.name.clone())
};
named
.or_else(|| self.handler_defined_at(id, script))
.unwrap_or_else(|| format!("#{id}"))
}
/// `<short_src>:<line>` for the function bound at `(id, script)`, or `None` if there is no such
/// binding or the VM cannot say (a C function, a stripped chunk).
fn handler_defined_at(&self, id: u32, script: &str) -> Option<String> {
// `raw_get` throughout: the registry's script tables are plain data, and a report must not
// be able to run a metamethod — that would re-enter Lua from inside an instrument.
let scripts: mlua::Table = self.lua.named_registry_value(super::REG_SCRIPTS).ok()?;
let per: mlua::Table = scripts.raw_get(id).ok()?;
let func: mlua::Function = per.raw_get(script).ok()?;
let info = func.info();
Some(format!("{}:{}", info.short_src?, info.line_defined?))
}
}
/// The `[ui-handlers]` block: the window's shape, the per-script rollup, then the heaviest
/// [`REPORT_ROWS`] handlers by self time with the remainder folded into one line.
///
/// Everything is **per frame**, because that is the unit every other cost in this client is quoted
/// in (`[ui-cost] tick=`, the HUD's `cpu ms`) and the whole use of this instrument is comparing
/// against those.
fn print_report(rows: &[HandlerRow], window: f32, frames: u32, nested: u32) {
let per_frame = f64::from(frames.max(1));
let total_self: f64 = rows.iter().map(|r| r.self_us).sum();
let mut by_script: Vec<(&str, f64)> = Vec::new();
for row in rows {
match by_script.iter_mut().find(|(s, _)| *s == row.script) {
Some((_, us)) => *us += row.self_us,
None => by_script.push((&row.script, row.self_us)),
}
}
by_script.sort_by(|a, b| b.1.total_cmp(&a.1));
let rollup: Vec<String> = by_script
.iter()
.map(|(script, us)| format!("{script} {:.1}", us / per_frame))
.collect();
eprintln!(
"[ui-handlers] {window:.2}s · {frames} frames · {} handlers · self {:.1} us/f · nested {nested}",
rows.len(),
total_self / per_frame,
);
eprintln!("[ui-handlers] by script: {}", rollup.join(" "));
eprintln!("[ui-handlers] self/f total/f calls/f handler");
for row in rows.iter().take(REPORT_ROWS) {
eprintln!(
"[ui-handlers] {:8.1} {:8.1} {:8.2} {}:{}",
row.self_us / per_frame,
row.total_us / per_frame,
f64::from(row.calls) / per_frame,
row.frame,
row.script,
);
}
if let Some(rest) = rows.len().checked_sub(REPORT_ROWS).filter(|n| *n > 0) {
let tail: f64 = rows.iter().skip(REPORT_ROWS).map(|r| r.self_us).sum();
eprintln!(
"[ui-handlers] {:8.1} … {rest} more",
tail / per_frame
);
}
}
/// Install the profiler's `app_data` slot, and arm it from the environment.
///
/// Unconditional (an empty `HandlerProf` is a few words) because mlua refuses to *insert* app data
/// while any app data is borrowed — so the slot has to exist before the VM starts running, or
/// [`UiScript::profile_handlers`] would carry a panic that only fires in the sessions using it.
pub(super) fn install(lua: &Lua) {
let period = env_period();
lua.set_app_data(HandlerProf {
on: period.is_some(),
period: period.unwrap_or_default(),
..HandlerProf::default()
});
if period.is_some() {
ARMED.store(true, Ordering::Relaxed);
}
}
+5
View File
@@ -70,6 +70,8 @@ pub(crate) mod font;
mod font_block;
mod gossip;
mod guild;
mod handler_prof;
pub use handler_prof::HandlerRow;
mod inspect;
mod item_stats;
mod item_text;
@@ -489,6 +491,9 @@ impl UiScript {
pub fn new() -> mlua::Result<UiScript> {
let lua = Lua::new();
lua.set_app_data(Model::new());
// Before anything can fire a handler, and while nothing holds an app-data borrow — the two
// conditions the profiler's slot has to be installed under (decision 1395).
handler_prof::install(&lua);
addon::install(&lua)?;
addon_message::install(&lua)?;
@@ -0,0 +1,209 @@
//! Per-handler cost attribution (decision 1395) — the instrument that answers *which* handler.
//!
//! These assert on **names, call counts, and the self/total relation**, never on a duration
//! (0735): "this handler cost 2 ms" is not a fact a test can own. The one relational assertion
//! (`self < total` under nesting) carries its own exact control — the same shape with nothing
//! nested inside it must report `self == total` to the last nanosecond, which is what proves the
//! difference came from the subtraction rather than from the clock.
use super::common::script;
use crate::script::{HandlerRow, ScriptValue, UiScript};
/// The row for `frame:script`, or `None`.
fn row(rows: &[HandlerRow], frame: &str, script: &str) -> Option<HandlerRow> {
rows.iter()
.find(|r| r.frame == frame && r.script == script)
.cloned()
}
/// Recording, with no periodic report — the tests read the window directly.
fn armed() -> UiScript {
let s = script();
s.profile_handlers(true, 0.0);
s
}
#[test]
fn every_fired_handler_is_attributed_to_its_own_frame_and_script() {
let mut s = armed();
s.run(
r#"
local a = CreateFrame("Frame", "Alpha")
a:SetScript("OnUpdate", function() end)
local b = CreateFrame("Frame", "Beta")
b:SetScript("OnUpdate", function() end)
b:RegisterEvent("UNIT_HEALTH")
b:SetScript("OnEvent", function() end)
local quiet = CreateFrame("Frame", "Quiet")
"#,
)
.unwrap();
s.tick(0.016);
s.tick(0.016);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
let rows = s.handler_profile();
assert_eq!(
s.handler_profile_frames(),
2,
"two ticks is two frames of denominator"
);
assert_eq!(
row(&rows, "Alpha", "OnUpdate").map(|r| r.calls),
Some(2),
"one OnUpdate call per tick: {rows:#?}"
);
assert_eq!(row(&rows, "Beta", "OnUpdate").map(|r| r.calls), Some(2));
assert_eq!(
row(&rows, "Beta", "OnEvent").map(|r| r.calls),
Some(1),
"the event fan-out is attributed too, and separately from the tick"
);
assert!(
rows.iter().all(|r| r.frame != "Quiet"),
"a frame with no handler must not appear at all: {rows:#?}"
);
}
#[test]
fn a_handler_with_nothing_nested_inside_it_is_all_self_time() {
// The control for the nesting test below: with no nested fire, the subtraction is by zero and
// the two numbers must be *identical*, not merely close.
let mut s = armed();
s.run(
r#"
local f = CreateFrame("Frame", "Flat")
f:SetScript("OnUpdate", function()
local n = 0
for i = 1, 20000 do n = n + i end
end)
"#,
)
.unwrap();
s.tick(0.016);
let flat =
row(&s.handler_profile(), "Flat", "OnUpdate").expect("the flat handler is attributed");
assert_eq!(
flat.self_us, flat.total_us,
"nothing nested ⇒ self is total, exactly"
);
}
#[test]
fn a_handler_that_fires_another_is_not_charged_for_its_child() {
let mut s = armed();
s.run(
r#"
inner = CreateFrame("Frame", "Inner")
inner:Hide()
inner:SetScript("OnShow", function()
local n = 0
for i = 1, 200000 do n = n + i end
end)
local outer = CreateFrame("Frame", "Outer")
outer:SetScript("OnUpdate", function()
inner:Hide()
inner:Show() -- fires Inner's OnShow from inside Outer's OnUpdate
end)
"#,
)
.unwrap();
s.tick(0.016);
assert!(s.errors().is_empty(), "{:?}", s.errors());
let rows = s.handler_profile();
let outer = row(&rows, "Outer", "OnUpdate").expect("the outer handler is attributed");
let inner = row(&rows, "Inner", "OnShow").expect("the nested handler gets its own row");
assert_eq!(inner.calls, 1);
assert!(
outer.self_us < outer.total_us,
"the child's cost must come off the parent's self time: {outer:#?}"
);
// And the nested row must be the one holding it — the whole point is naming Inner, not Outer.
assert!(
inner.self_us > 0.0,
"the child's own cost lands on the child: {inner:#?}"
);
// A second tick must behave like the first: a leaked stack level would start charging Outer's
// child time to a phantom parent and the relation above would quietly stop holding.
s.tick(0.016);
let rows = s.handler_profile();
let outer = row(&rows, "Outer", "OnUpdate").expect("outer");
assert_eq!(outer.calls, 2);
assert_eq!(row(&rows, "Inner", "OnShow").map(|r| r.calls), Some(2));
assert!(outer.self_us < outer.total_us, "{outer:#?}");
}
#[test]
fn an_anonymous_frames_handler_is_labelled_by_where_it_was_written() {
// `CreateFrame("Frame")` + OnUpdate with no name is the addon timer idiom, so this is the
// common case, not the exotic one — and `#4127` would be an unusable row.
let mut s = armed();
s.run(
r#"
local f = CreateFrame("Frame")
f:SetScript("OnUpdate", function() end)
"#,
)
.unwrap();
s.tick(0.016);
let rows = s.handler_profile();
let [only] = &rows[..] else {
panic!("exactly one handler fired: {rows:#?}");
};
assert!(
!only.frame.starts_with('#') && only.frame.contains(':'),
"an unnamed frame is labelled by its handler's chunk and line, not by its id: {only:#?}"
);
}
#[test]
fn an_unarmed_vm_records_nothing() {
// The off-state is the shipped state, so it gets a guard of its own: a VM that was never armed
// must report an empty profile even while another VM in this process is recording (the gate is
// process-wide, the state is per-VM — see `handler_prof::ARMED`).
let _recording = armed();
let mut s = script();
s.run(
r#"
local f = CreateFrame("Frame", "Silent")
f:SetScript("OnUpdate", function() end)
"#,
)
.unwrap();
s.tick(0.016);
assert!(
s.handler_profile().is_empty(),
"an unarmed VM records nothing: {:#?}",
s.handler_profile()
);
assert_eq!(s.handler_profile_frames(), 0);
}
#[test]
fn disarming_clears_the_window() {
let mut s = armed();
s.run(
r#"
local f = CreateFrame("Frame", "Cleared")
f:SetScript("OnUpdate", function() end)
"#,
)
.unwrap();
s.tick(0.016);
assert!(!s.handler_profile().is_empty());
s.profile_handlers(false, 0.0);
assert!(s.handler_profile().is_empty(), "disarming drops the window");
s.tick(0.016);
assert!(
s.handler_profile().is_empty(),
"and a disarmed VM keeps recording nothing"
);
}
@@ -16,6 +16,7 @@ mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod handler_prof;
mod input;
mod keyboard;
mod layout_gate;
+3
View File
@@ -170,5 +170,8 @@ impl UiScript {
// Advance fading tooltips (FadeOut's ramp + end-of-ramp hide) — engine behavior like the
// message fade above, decision 0274.
tooltip::tick_fades(&self.lua);
// `WOW_UI_HANDLERS=<secs>` — who spent the frame (decision 1395). Last, so a report covers
// everything this tick fired; a no-op unless the instrument is armed.
self.report_handler_profile(elapsed);
}
}