- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps 7.0 ms 17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
184 lines
6.6 KiB
Rust
184 lines
6.6 KiB
Rust
//! The frame-cost meters the pill reads: a rolling window each of wall frame time and process
|
|
//! CPU per frame.
|
|
//!
|
|
//! **The law (0717): while synced, wall frame time measures the display's present grant, not our
|
|
//! cost.** Only the CPU series measures work — it is the pill's headline. `wall` exists for the
|
|
//! dim fps anchor and the hitch log below, nothing else.
|
|
//!
|
|
//! This file used to carry a per-series spike latch (0610's burst detector) and the
|
|
//! median baselines it tested against; both left with their last surface — the pill's arrow
|
|
//! (1455, after 1454 removed the expanded panel). The standing stall record is the
|
|
//! `frame hitch` warn below plus the self-sampler ([`super::stall`]); burst-shaped analysis
|
|
//! belongs to the instruments (the journal, the probes, Tracy).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::time::Real;
|
|
|
|
use super::clock::{main_thread_cpu_secs, process_cpu_secs};
|
|
|
|
/// Recent frames kept for the pill's windowed means (~5 s at 60 fps, ~2.5 s at 120).
|
|
pub(super) const SAMPLE_WINDOW: usize = 300;
|
|
|
|
/// Frame duration above which a frame is logged as a hitch (a stall the player feels). Far above
|
|
/// any present interval so it only fires on real stalls (load bursts), not normal jitter.
|
|
const HITCH_LOG_MS: f32 = 250.0;
|
|
|
|
/// A rolling window of one cost series, in milliseconds, capped at its own length.
|
|
#[derive(Clone)]
|
|
pub(super) struct Series {
|
|
samples: VecDeque<f32>,
|
|
cap: usize,
|
|
}
|
|
|
|
impl Series {
|
|
fn new(cap: usize) -> Self {
|
|
Self {
|
|
samples: VecDeque::with_capacity(cap),
|
|
cap,
|
|
}
|
|
}
|
|
|
|
fn push(&mut self, ms: f32) {
|
|
if self.samples.len() == self.cap {
|
|
self.samples.pop_front();
|
|
}
|
|
self.samples.push_back(ms);
|
|
}
|
|
|
|
pub(super) fn len(&self) -> usize {
|
|
self.samples.len()
|
|
}
|
|
|
|
/// Windowed mean. `None` on an empty window, so a caller can tell "no data" from "zero cost".
|
|
pub(super) fn mean(&self) -> Option<f32> {
|
|
(!self.samples.is_empty())
|
|
.then(|| self.samples.iter().sum::<f32>() / self.samples.len() as f32)
|
|
}
|
|
}
|
|
|
|
/// The per-frame cost meters.
|
|
///
|
|
/// `Clone` is for the HUD's 4 Hz snapshot (`PerfHud::maybe_refresh`) — a few hundred floats,
|
|
/// four times a second.
|
|
#[derive(Resource, Clone)]
|
|
pub(super) struct FrameStats {
|
|
/// Wall frame interval. The grant while synced; our real cost only when uncapped.
|
|
pub(super) wall: Series,
|
|
/// Process CPU per frame, user+system across every thread (`getrusage`) — the campaign's
|
|
/// currency (0736) and the meter the rail cannot fool (0717).
|
|
pub(super) cpu: Series,
|
|
prev_cpu_secs: Option<f64>,
|
|
/// The MAIN thread's CPU per frame — the part of the frame the player can feel: the wall
|
|
/// the schedule runs on. Sampled pinned (`sample_frame_time`'s `NonSendMarker`).
|
|
pub(super) main: Series,
|
|
prev_main_secs: Option<f64>,
|
|
}
|
|
|
|
impl Default for FrameStats {
|
|
fn default() -> Self {
|
|
Self {
|
|
wall: Series::new(SAMPLE_WINDOW),
|
|
cpu: Series::new(SAMPLE_WINDOW),
|
|
main: Series::new(SAMPLE_WINDOW),
|
|
prev_cpu_secs: None,
|
|
prev_main_secs: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FrameStats {
|
|
/// Windowed mean frames per second. A mean, and labelled as one — it is the number that
|
|
/// cannot see cost (0717), which is why the pill draws it dim and small, never as the
|
|
/// headline.
|
|
pub(super) fn fps(&self) -> f32 {
|
|
match self.wall.mean() {
|
|
Some(mean) if mean > 0.0 => 1000.0 / mean,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
/// 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)`; returns the clock it left off
|
|
/// at. `hud`'s snapshot test needs a realistically-populated `FrameStats`.
|
|
#[cfg(test)]
|
|
pub(super) fn feed_frames(&mut self, frames: &[(f32, f32)], start_t: f32, dt: f32) -> f32 {
|
|
let mut t = start_t;
|
|
for &(wall, cpu) in frames {
|
|
self.wall.push(wall);
|
|
self.cpu.push(cpu);
|
|
// The fixture's main-thread share: a fixed fraction of the sum, so a test can tell
|
|
// the two apart without a second column.
|
|
self.main.push(cpu * 0.5);
|
|
t += dt;
|
|
}
|
|
t
|
|
}
|
|
}
|
|
|
|
/// Sample both meters for this frame.
|
|
pub(super) fn sample_frame_time(
|
|
// Pinned to the main thread: `main_thread_cpu_secs` reads the CALLING thread's clock.
|
|
_pin: bevy::ecs::system::NonSendMarker,
|
|
time: Res<Time<Real>>,
|
|
mut stats: ResMut<FrameStats>,
|
|
) {
|
|
let wall_ms = time.delta_secs() * 1000.0;
|
|
stats.wall.push(wall_ms);
|
|
|
|
let cpu = process_cpu_secs();
|
|
if let (Some(prev), Some(n)) = (stats.prev_cpu_secs, cpu) {
|
|
stats.cpu.push(((n - prev) * 1000.0) as f32);
|
|
}
|
|
stats.prev_cpu_secs = cpu;
|
|
let main = main_thread_cpu_secs();
|
|
if let (Some(prev), Some(n)) = (stats.prev_main_secs, main) {
|
|
stats.main.push(((n - prev) * 1000.0) as f32);
|
|
}
|
|
stats.prev_main_secs = main;
|
|
|
|
// Log hard hitches so a load freeze is attributable from the log alone (one big stall vs many
|
|
// medium ones, and roughly when). The first frame's delta is the startup gap, not a hitch.
|
|
if wall_ms > HITCH_LOG_MS && stats.wall.len() > 1 {
|
|
warn!("frame hitch: {wall_ms:.0} ms (main thread blocked this long)");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The window is capped and the means are windowed: 300 quiet frames after 100 loud ones
|
|
/// must read quiet, and fps must be the reciprocal of the windowed wall mean.
|
|
#[test]
|
|
fn the_windows_roll_and_the_means_are_windowed() {
|
|
let mut s = FrameStats::default();
|
|
let loud: Vec<_> = (0..100).map(|_| (33.2, 30.0)).collect();
|
|
let calm: Vec<_> = (0..SAMPLE_WINDOW).map(|_| (16.6, 8.0)).collect();
|
|
let mut t = s.feed_frames(&loud, 0.0, 1.0 / 30.0);
|
|
t = s.feed_frames(&calm, t, 1.0 / 60.0);
|
|
let _ = t;
|
|
|
|
assert_eq!(s.wall.len(), SAMPLE_WINDOW, "the window is capped");
|
|
let cpu = s.cpu.mean().expect("cpu has samples");
|
|
assert!(
|
|
(cpu - 8.0).abs() < 0.01,
|
|
"the loud prefix must have rolled out, got {cpu}"
|
|
);
|
|
assert!(
|
|
(s.fps() - 1000.0 / 16.6).abs() < 0.5,
|
|
"fps is the windowed wall mean's reciprocal, got {}",
|
|
s.fps()
|
|
);
|
|
}
|
|
|
|
/// An empty cpu window is "no data", not "zero cost" — the pill prints `--` for it.
|
|
#[test]
|
|
fn no_data_is_not_zero_cost() {
|
|
let s = FrameStats::default();
|
|
assert!(s.cpu.mean().is_none());
|
|
assert_eq!(s.fps(), 0.0);
|
|
}
|
|
}
|