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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user