the paper doll animates again, and the char-select plaza stops pulsing
- the paper doll and the character-select stage animate again: a staged rig is fenced off the world's frustum parker, which had been freezing every booth half a second after its bake - the char-select plaza stops pulsing dark and bright after a geared relog: the off-world lane now carries its authored batch order, so a ground decal and the street under it stop swapping on a sort tie re-broken every frame - the perf HUD goes on a diet: two small numbers and a bare arrow, drawn from a 4 Hz snapshot instead of the live meters - a missing file in a player's addon manifest logs WARN, not ERROR — a broken third-party package is the addon's defect, not the client's Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,8 @@ use benilla_world::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
|
||||
|
||||
use benilla_world::rig_anim::{AnimParked, RigPose};
|
||||
|
||||
use crate::portrait::StageRig;
|
||||
|
||||
/// Park only after the rig has been continuously out of frustum this long — a camera swing
|
||||
/// across the pack doesn't churn bone repoints. Waking is always instant.
|
||||
const PARK_AFTER_SECS: f32 = 0.5;
|
||||
@@ -100,16 +102,22 @@ pub(super) fn gate_rig_animation(
|
||||
// — at the LBRS pin, ~350 of the ~620 per-frame refreshes. Parking preserves their whole
|
||||
// observable surface: the event scanner requires `AnimDriver` (GOs never fired anim
|
||||
// events), `go_anim`'s state machine arms the player regardless of the marker, and the
|
||||
// wake samples the absolute clock. Booth/studio rigs are entity-joint-lane (no
|
||||
// `RigPose`), so this query can never park the character pane.
|
||||
// wake samples the absolute clock.
|
||||
//
|
||||
// `Without<DoodadAnimHost>` (decision 1365): placed doodads joined the collapsed lane,
|
||||
// and their draw gate (`doodad_anim::gate_doodad_anim`) owns their `AnimParked` marker —
|
||||
// it parks on the composed draw verdict + fade sphere, the doodad lane's own law, and
|
||||
// two writers to one marker would fight every frame the two policies disagree.
|
||||
//
|
||||
// `Without<StageRig>` (decision 1447): booth rigs joined the collapsed lane too (1443),
|
||||
// and a booth stage sits outside every world frustum by construction — unfiltered, this
|
||||
// gate froze every pane and glue scene `PARK_AFTER_SECS` after its bake, a marker the
|
||||
// booth camera gate (tracking only its own park edges) could not heal. Same one-writer
|
||||
// law as the doodads': the booth gate owns a staged rig's marker.
|
||||
(
|
||||
With<RigPose>,
|
||||
Without<benilla_world::doodad_anim::DoodadAnimHost>,
|
||||
Without<StageRig>,
|
||||
),
|
||||
>,
|
||||
self_hosts: Query<Has<Embodied>>,
|
||||
@@ -481,6 +489,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The 1447 regression: a staged rig (a booth doll — off every world frustum by
|
||||
/// construction) is never this gate's to park. Its `AnimParked` belongs to the booth camera
|
||||
/// gate alone; the night the 1443 collapse put `RigPose` on booth roots, this gate froze
|
||||
/// every paper doll and glue scene half a second after its bake.
|
||||
#[test]
|
||||
fn a_stage_rig_is_never_world_parked() {
|
||||
let mut app = app();
|
||||
spawn_camera(&mut app);
|
||||
let (stage, _) = spawn_rig(&mut app, Vec3::Z * 50.0); // behind the camera, like any stage
|
||||
app.world_mut()
|
||||
.entity_mut(stage)
|
||||
.insert(crate::portrait::StageRig);
|
||||
app.update();
|
||||
advance(&mut app, PARK_AFTER_SECS + 0.2, 4);
|
||||
assert!(
|
||||
!app.world().entity(stage).contains::<AnimParked>(),
|
||||
"an off-frustum stage rig stays live — its park is the booth gate's, not the world's"
|
||||
);
|
||||
}
|
||||
|
||||
/// A [`WmoGroupNav`] whose only meaningful fields are its portal-ref slice — the room-leg
|
||||
/// tests never touch flags/bounds.
|
||||
fn nav(ref_start: u16, ref_count: u16) -> WmoGroupNav {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
//! The standing dev HUD: a collapsed cost pill you can read while playing, and the full readout
|
||||
//! behind a click.
|
||||
//!
|
||||
//! **The collapsed pill covers three timescales, because a regression can arrive on any of them.**
|
||||
//! *Now* is the cost number. *The last few seconds* is the latched spike badge — a burst is ~250 ms
|
||||
//! (0610) and the director is looking at the game when it happens, so the evidence has to outlive
|
||||
//! the event. *The last minute* is the sparkline, which is the only lane that can see cost merely
|
||||
//! sitting higher than it used to.
|
||||
//! **The pill is two small numbers and a bare latch arrow** (1448 pared it back). fps dim — the
|
||||
//! familiar anchor, and by construction the number that cannot see cost (0717) — then process-CPU
|
||||
//! cost per frame, the meter vsync cannot rail; both under-size, because the pill sits over the
|
||||
//! game all session. The arrow is the spike latch: a burst is ~250 ms (0610) and the director is
|
||||
//! looking at the game when it happens, so the evidence has to outlive the event — and its
|
||||
//! appearance and colour are the *whole* signal (it once printed `▲261.9 main ×94`, a report
|
||||
//! where an alarm belongs; the numbers wait behind the click). What left the pill entirely: the
|
||||
//! minute-trend sparkline — a wiggle at 72 px; it is the expanded panel's full-width lane now,
|
||||
//! beside the line that names its step.
|
||||
//!
|
||||
//! **What is deliberately no longer the headline: fps.** 0717 established that while synced, wall
|
||||
//! frame time measures the display's present grant rather than our cost — but it only ever applied
|
||||
//! that to the expanded lines, and the pill went on reading framerate. On a 120 Hz-adaptive panel
|
||||
//! that hides a doubling: cost can go 3 → 6 ms with the grant unchanged and the number unmoved, and
|
||||
//! 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.
|
||||
//! **Drawn from a 4 Hz snapshot, not the live meters.** A label whose string changes every frame
|
||||
//! re-shapes its galley every frame — cost that scales with every widget the surface carries,
|
||||
//! booked into the very number the pill reports. Between snapshots every string is byte-identical,
|
||||
//! so egui's galley cache absorbs the text; the meters keep sampling every frame — only the *view*
|
||||
//! is quantized, and 250 ms is inside a human read of a number. (1448 *bounds* the price: the
|
||||
//! drawing cost, before and after, sat below the ~0.4 ms noise floor of the interleaved legs —
|
||||
//! 1370's "0.4–1.2 ms est" reads at its floor, and the diet is cheap insurance plus legibility,
|
||||
//! not a measured megawin.)
|
||||
//!
|
||||
//! **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
|
||||
@@ -42,8 +48,17 @@ const GREEN: egui::Color32 = egui::Color32::from_rgb(140, 220, 140);
|
||||
/// A latched spike is drawn amber until its peak is this many times its own baseline, then red.
|
||||
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 trend lane's height in the expanded panel (its width is the panel's).
|
||||
const TREND_HEIGHT: f32 = 13.0;
|
||||
|
||||
/// The pill's number size — under the 12 pt monospace default. The pill sits over the game all
|
||||
/// session; the director asked for the numbers at a glance-size, not a reading-size (1448).
|
||||
const PILL_NUM_SIZE: f32 = 10.0;
|
||||
|
||||
/// How often the drawn snapshot advances. Fast enough that the numbers read as live and a latched
|
||||
/// badge appears within a perceptual beat; slow enough that between refreshes every galley is
|
||||
/// byte-identical and egui's shape cache absorbs the whole text layout (see the module doc).
|
||||
const HUD_REFRESH_SECS: f32 = 0.25;
|
||||
|
||||
/// **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
|
||||
@@ -61,8 +76,10 @@ const TREND_SIZE: egui::Vec2 = egui::vec2(72.0, 13.0);
|
||||
/// 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.
|
||||
/// its full natural width and ignores `max_rect` entirely.** One long line therefore *defines*
|
||||
/// the window width, and `set_width` alone cannot stop it. (The line that shipped broken was
|
||||
/// `SpikeKind::describe`, ~110 characters; 1448 moved it to hover, but the `Wrap` below stays —
|
||||
/// it is the fence that stops the *next* long label, not a patch for that one.)
|
||||
/// - `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
|
||||
@@ -111,6 +128,14 @@ pub(crate) struct PerfHud {
|
||||
pub(crate) visible: bool,
|
||||
/// Full stats shown? Toggled by clicking the pill; the cost pill alone when `false`.
|
||||
expanded: bool,
|
||||
/// The snapshot the HUD draws from — a copy of the meters taken every [`HUD_REFRESH_SECS`],
|
||||
/// so the strings it formats hold still between refreshes (the module doc's cost argument).
|
||||
/// The live [`FrameStats`] keeps sampling every frame; this is only the view.
|
||||
snap: FrameStats,
|
||||
/// The clock `snap` was taken at. Spike ages and hold windows are computed against this, not
|
||||
/// the live clock, so they hold still with the rest of the view — and it doubles as the
|
||||
/// refresh timer.
|
||||
snap_at: f32,
|
||||
}
|
||||
|
||||
impl Default for PerfHud {
|
||||
@@ -118,6 +143,19 @@ impl Default for PerfHud {
|
||||
Self {
|
||||
visible: std::env::var("WOW_PERF_HUD").as_deref() != Ok("0"),
|
||||
expanded: false,
|
||||
snap: FrameStats::default(),
|
||||
// −∞, so the very first frame refreshes rather than drawing an empty snapshot.
|
||||
snap_at: f32::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PerfHud {
|
||||
/// Advance the snapshot if the current one is older than [`HUD_REFRESH_SECS`].
|
||||
fn maybe_refresh(&mut self, stats: &FrameStats, now: f32) {
|
||||
if now - self.snap_at >= HUD_REFRESH_SECS {
|
||||
self.snap = stats.clone();
|
||||
self.snap_at = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,15 +181,17 @@ pub(super) fn perf_hud_ui(
|
||||
return Ok(());
|
||||
}
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
let now = time.elapsed_secs();
|
||||
let synced = windows
|
||||
.single()
|
||||
.map(|w| synced_mode(w.present_mode))
|
||||
.unwrap_or(true);
|
||||
hud.maybe_refresh(&stats, time.elapsed_secs());
|
||||
|
||||
// A title-less, anchored window — minimal chrome (no title bar, no resize) but the stable
|
||||
// auto-sizing of a `Window`, so expanding to the full readout doesn't flash a mislaid first
|
||||
// frame the way a raw `Area` does.
|
||||
let hud = &mut *hud;
|
||||
let mut clicked = false;
|
||||
egui::Window::new("perf_hud")
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
@@ -165,10 +205,18 @@ pub(super) fn perf_hud_ui(
|
||||
.fill(OVERLAY_FILL),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
if draw_hud(ui, &stats, &diagnostics, synced, now, hud.expanded) {
|
||||
hud.expanded = !hud.expanded;
|
||||
}
|
||||
clicked = draw_hud(
|
||||
ui,
|
||||
&hud.snap,
|
||||
&diagnostics,
|
||||
synced,
|
||||
hud.snap_at,
|
||||
hud.expanded,
|
||||
);
|
||||
});
|
||||
if clicked {
|
||||
hud.expanded = !hud.expanded;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -210,9 +258,13 @@ pub(super) fn draw_hud(
|
||||
.clicked()
|
||||
}
|
||||
|
||||
/// The collapsed pill: fps (dim), the cost headline, the minute-long trend, and the latched spike.
|
||||
/// The collapsed pill: fps (dim), the cost headline — both small — and the bare latch arrow.
|
||||
/// Returns the row's rect; the caller makes the strip that spans it the toggle.
|
||||
///
|
||||
/// The arrow carries no numbers: the badge once printed `▲261.9 main ×94` over the game, which
|
||||
/// is a report, not an alarm (director, 1448). Its appearance and its colour are the whole
|
||||
/// signal; the numbers wait behind the click.
|
||||
///
|
||||
/// **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,
|
||||
@@ -222,12 +274,13 @@ fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Rect
|
||||
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.
|
||||
// fps, dimmed: the familiar anchor, and by construction the number that cannot see
|
||||
// any of this (0717).
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{:.0} fps", stats.fps()))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
.monospace()
|
||||
.size(PILL_NUM_SIZE),
|
||||
);
|
||||
|
||||
// The headline: process CPU per frame, the meter vsync cannot rail (0717).
|
||||
@@ -236,17 +289,17 @@ fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Rect
|
||||
egui::RichText::new(format!("{cpu:.1} ms"))
|
||||
.color(OVERLAY_TEXT)
|
||||
.monospace()
|
||||
.strong(),
|
||||
.strong()
|
||||
.size(PILL_NUM_SIZE),
|
||||
),
|
||||
None => ui.label(
|
||||
egui::RichText::new("-- ms")
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
.monospace()
|
||||
.size(PILL_NUM_SIZE),
|
||||
),
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -255,23 +308,22 @@ fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Rect
|
||||
} else {
|
||||
AMBER
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(format!("▲{:.1} {} ×{}", s.peak_ms, s.kind.tag(), s.frames))
|
||||
.color(col)
|
||||
.monospace()
|
||||
.strong(),
|
||||
);
|
||||
ui.label(egui::RichText::new("▲").color(col).strong());
|
||||
}
|
||||
})
|
||||
.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.
|
||||
/// The minute-long trend of per-second median CPU cost, at the expanded panel's full width (1448
|
||||
/// moved it off the pill, where 72 px showed a wiggle, not a shape). 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, hi: f32) {
|
||||
let (rect, _) = ui.allocate_exact_size(TREND_SIZE, egui::Sense::hover());
|
||||
let (rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width(), TREND_HEIGHT),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
if trend.len() < 2 {
|
||||
return;
|
||||
}
|
||||
@@ -417,8 +469,10 @@ fn expanded_readout(
|
||||
.on_hover_text(why);
|
||||
}
|
||||
|
||||
// The creep lane, in words — the sparkline on the pill shows the shape, this names the step.
|
||||
// The creep lane: the sparkline shows the shape, the line under it names the step.
|
||||
if let Some((first, last)) = stats.trend_ends() {
|
||||
ui.add_space(2.0);
|
||||
trend_sparkline(ui, &stats.trend, stats.trend_hi());
|
||||
let delta = last - first;
|
||||
ui.label(
|
||||
egui::RichText::new(format!("trend {first:.1} → {last:.1} ms ({delta:+.1}/min)"))
|
||||
@@ -445,38 +499,30 @@ fn expanded_readout(
|
||||
.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.label(
|
||||
egui::RichText::new(format!(
|
||||
"spike {:.2} ms over {:.2}, {}f, {:.0}s ago",
|
||||
s.peak_ms,
|
||||
s.baseline_ms,
|
||||
s.frames,
|
||||
(now - s.at).max(0.0)
|
||||
))
|
||||
.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 {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{bursts} bursts in the last 10 s — the worst is shown"
|
||||
))
|
||||
.color(OVERLAY_TEXT_DIM),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new("no spike in the last 10 s").color(OVERLAY_TEXT_DIM));
|
||||
// The latch in full — only when one is latched; a quiet panel says nothing about it (1448
|
||||
// dropped the "no spike" placeholder and the inline prose). The pill's arrow carries no
|
||||
// numbers, so this line is where the spike is actually read: kind, peak against baseline,
|
||||
// age. The frame count, the *meaning* of the kind, and the burst count wait on hover — the
|
||||
// panel stays numbers; the ~110-character `describe()` used to be the widest and least-read
|
||||
// thing on it.
|
||||
if let Some(s) = stats.spike(now) {
|
||||
let mut why = format!("{} frames — {}", s.frames, s.kind.describe());
|
||||
let bursts = stats.recent_bursts(now);
|
||||
if bursts > 1 {
|
||||
why = format!("{why} — {bursts} bursts in the last 10 s; the worst is shown");
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"spike {} {:.1} over {:.1}, {:.0}s ago",
|
||||
s.kind.tag(),
|
||||
s.peak_ms,
|
||||
s.baseline_ms,
|
||||
(now - s.at).max(0.0)
|
||||
))
|
||||
.color(AMBER)
|
||||
.monospace(),
|
||||
)
|
||||
.on_hover_text(why);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,4 +658,38 @@ mod tests {
|
||||
"the quiet panel is {width:.1} px, not the {ruler:.1} px ruler"
|
||||
);
|
||||
}
|
||||
|
||||
/// The view the HUD draws only advances on the refresh interval — between ticks it holds
|
||||
/// still, which is the entire cost argument (identical strings are what the galley cache can
|
||||
/// absorb) — and a refresh adopts the live meters wholesale.
|
||||
#[test]
|
||||
fn the_snapshot_advances_on_the_interval_not_per_frame() {
|
||||
let mut live = FrameStats::default();
|
||||
let frames: Vec<_> = (0..60).map(|_| (16.6, 20.0, 5.0)).collect();
|
||||
let t = live.feed_frames(&frames, 0.0, 1.0 / 60.0);
|
||||
|
||||
let mut hud = PerfHud::default();
|
||||
hud.maybe_refresh(&live, t);
|
||||
assert_eq!(
|
||||
hud.snap.wall.len(),
|
||||
60,
|
||||
"the first refresh adopts the meters"
|
||||
);
|
||||
assert_eq!(hud.snap_at, t);
|
||||
|
||||
let t2 = live.feed_frames(&[(40.0, 20.0, 5.0)], t, 1.0 / 60.0);
|
||||
hud.maybe_refresh(&live, t2);
|
||||
assert_eq!(
|
||||
hud.snap.wall.len(),
|
||||
60,
|
||||
"one frame later — inside the interval — the view holds still"
|
||||
);
|
||||
|
||||
hud.maybe_refresh(&live, t + HUD_REFRESH_SECS);
|
||||
assert_eq!(
|
||||
hud.snap.wall.len(),
|
||||
61,
|
||||
"past the interval the view catches up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
//! present grant, not our cost.** Only the CPU series measure work. That is why the pill's headline
|
||||
//! is `cpu ms` and not fps — on a 120 Hz-adaptive panel, cost can double with the granted interval
|
||||
//! unchanged and framerate unmoved, and the pill's old red threshold (fps < 58) sat ~5.7× above a
|
||||
//! healthy frame. fps is still there, dimmed, as the familiar anchor.
|
||||
//! healthy frame. fps stays on the pill — dim and small, the familiar anchor, never the thing
|
||||
//! being watched (1448).
|
||||
//!
|
||||
//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it** — but note that the
|
||||
//! *missed-interval* threshold is derived from the interval we actually observe, not from this
|
||||
|
||||
@@ -85,6 +85,7 @@ const BASELINE_REFRESH_SECS: f32 = 1.0;
|
||||
pub(super) const TREND_WINDOW_SECS: usize = 60;
|
||||
|
||||
/// 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,
|
||||
@@ -194,7 +195,7 @@ pub(super) enum SpikeKind {
|
||||
}
|
||||
|
||||
impl SpikeKind {
|
||||
/// The short tag the pill's badge carries.
|
||||
/// The short tag the expanded panel's spike line carries.
|
||||
pub(super) fn tag(self) -> &'static str {
|
||||
match self {
|
||||
SpikeKind::Main => "main",
|
||||
@@ -242,15 +243,15 @@ fn reportable(s: &Spike) -> bool {
|
||||
}
|
||||
|
||||
/// Medians re-derived once a second, off the per-frame path.
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Clone)]
|
||||
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.
|
||||
/// than in the painter because it needs a sort and the painter runs every frame the panel is
|
||||
/// open — 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,
|
||||
}
|
||||
@@ -260,7 +261,10 @@ struct Baselines {
|
||||
/// Three series, because they answer three different questions and 0717's law binds all of them:
|
||||
/// while synced, **wall frame time is the display's grant, not our cost**. Only the CPU series
|
||||
/// measure work.
|
||||
#[derive(Resource)]
|
||||
///
|
||||
/// `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,
|
||||
@@ -319,8 +323,8 @@ impl FrameStats {
|
||||
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.
|
||||
/// Windowed mean frames per second. A mean, and labelled as one — it is the number that
|
||||
/// cannot see a spike, 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,
|
||||
@@ -576,7 +580,7 @@ mod tests {
|
||||
"the badge reports the burst, not one frame"
|
||||
);
|
||||
assert!((spike.peak_ms - 12.0).abs() < 0.01);
|
||||
// The control: the number the pill used to show is still, by construction, unmoved.
|
||||
// The control: the pill's dim fps is still, by construction, unmoved.
|
||||
assert!(
|
||||
(s.fps() - fps_before).abs() < 1.0,
|
||||
"fps must be blind here — that is the whole reason the latch exists"
|
||||
|
||||
@@ -134,10 +134,12 @@ impl BoothRig {
|
||||
self.rig.is_some()
|
||||
}
|
||||
|
||||
/// Commit the pose buffer onto the booth root — after every consumer is seated.
|
||||
/// Commit the pose buffer onto the booth root — after every consumer is seated. `StageRig`
|
||||
/// rides with it (decision 1447): the pair exists together or not at all, so the world-view
|
||||
/// parker can never see a booth `RigPose` without the marker that exempts it.
|
||||
pub(super) fn finish(self, commands: &mut Commands) {
|
||||
if let Some(rig) = self.rig {
|
||||
commands.entity(self.root).insert(rig);
|
||||
commands.entity(self.root).insert((rig, super::StageRig));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +157,7 @@ pub(super) fn clear_booth_rig(commands: &mut Commands, root: Entity) {
|
||||
benilla_world::rig_anim::GlobalSeqDrive,
|
||||
benilla_world::rig_anim::RigPose,
|
||||
benilla_world::rig_anim::AnimParked,
|
||||
super::StageRig,
|
||||
benilla_world::rig_palette::RigSkin,
|
||||
)>();
|
||||
}
|
||||
|
||||
@@ -595,7 +595,13 @@ pub(super) fn sync_glue_scene(
|
||||
.map(|(pi, s)| {
|
||||
// The create scene is lit by its OWN authored M2 rig against its own buffer —
|
||||
// never the world's sun (decisions 0429/0435) — and its clouds and water scroll.
|
||||
let material = mats.off_world(s, s.texture.clone(), &light, true);
|
||||
//
|
||||
// The authored batch order (index + 1) rides with it (decision 1449): a scene's
|
||||
// batches all hang off the scene root, so they share one transparent sort distance
|
||||
// and only this bias tells them apart — without it `UI_Human`'s ground-shadow decal
|
||||
// and its street tied, and the tie re-broke every frame.
|
||||
let order = u16::try_from(pi + 1).unwrap_or(u16::MAX);
|
||||
let material = mats.off_world(s, s.texture.clone(), order, &light, true);
|
||||
BoothPart {
|
||||
skinned: skin_forms.get(pi).cloned(),
|
||||
static_mesh: stat_forms
|
||||
|
||||
@@ -449,6 +449,18 @@ struct Booth {
|
||||
/// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes.
|
||||
const BOOTH_SETTLE_FRAMES: u32 = 4;
|
||||
|
||||
/// This rig stands on a booth stage, not in the world: its `AnimParked` marker is owned by
|
||||
/// [`gate_booth_cameras`] alone, and the world-view parker (`creature_anim::lod`) filters it out
|
||||
/// (decision 1447). One marker, one writer — the same split the doodad draw gate holds for
|
||||
/// placed doodads (decision 1365). The hazard is structural: a booth stage sits outside every
|
||||
/// world frustum by construction, so an unfiltered view parker freezes every pane moments after
|
||||
/// its bake — while this gate, tracking only its own park edges, cannot heal the foreign marker.
|
||||
/// [`booth::BoothRig::finish`] inserts it beside the `RigPose`; [`booth::clear_booth_rig`]
|
||||
/// strips it with the rest. Policy, not machinery — which is why it lives here and not in
|
||||
/// `benilla_world::rig_anim` beside the marker it fences (the 1160 line).
|
||||
#[derive(bevy::prelude::Component)]
|
||||
pub(crate) struct StageRig;
|
||||
|
||||
/// How long a [`Booth::pending`] texture hold may keep the camera awake (wall secs) before it is
|
||||
/// declared never-landing and released ([`Booth::pending_since`]). Generous against a real load —
|
||||
/// an MPQ image lands in well under a second — because a premature release only costs a stale
|
||||
@@ -1699,6 +1711,11 @@ fn gate_booth_cameras(
|
||||
mut cams: Query<(&BoothCam, &mut Camera)>,
|
||||
rate: Res<PaneRate>,
|
||||
frames: Res<bevy::diagnostic::FrameCount>,
|
||||
// `WOW_BOOTH_LOG` only: the marker's REAL state beside this gate's `booth.parked`
|
||||
// bookkeeping. The two can desync exactly one way — a foreign writer — and that desync is
|
||||
// invisible in every capture (a woken rig snaps to the absolute clock), so it must be
|
||||
// loggable (decision 1447: the world parker froze every pane and no dump could show it).
|
||||
markers: Query<(), With<benilla_world::rig_anim::AnimParked>>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
) {
|
||||
let test = test_mode(&mut env_cache);
|
||||
@@ -1764,13 +1781,14 @@ fn gate_booth_cameras(
|
||||
&& (cam.is_active != render || active)
|
||||
{
|
||||
eprintln!(
|
||||
"[booth] t={:7.2} {} active={} render={} wake={} pending={}",
|
||||
"[booth] t={:7.2} {} active={} render={} wake={} pending={} marker={}",
|
||||
time.elapsed_secs(),
|
||||
token.as_str(),
|
||||
active,
|
||||
render,
|
||||
booth.wake,
|
||||
booth.pending.len(),
|
||||
markers.contains(booth.root),
|
||||
);
|
||||
}
|
||||
if cam.is_active != render {
|
||||
|
||||
@@ -123,6 +123,7 @@ fn bake_test(
|
||||
material: mats.off_world(
|
||||
s,
|
||||
s.texture.clone().or_else(|| skin.clone()),
|
||||
u16::try_from(pi + 1).unwrap_or(u16::MAX),
|
||||
light,
|
||||
false,
|
||||
),
|
||||
|
||||
@@ -186,7 +186,16 @@ impl Addon {
|
||||
let path = benilla_ui::loader::join_ref(self.prefix(), file);
|
||||
let Some(bytes) = self.read(&path) else {
|
||||
let e = format!("{}/{file}: not found", self.name);
|
||||
error!("ui_script: {e}");
|
||||
// Severity follows whose manifest lied. For the builtin that is us — a client
|
||||
// bug, and the boot tests assert none. For a player's addon it is the package
|
||||
// (the director's AtlasLoot copy lists `Bossnames\BossNames.xml`; no such folder
|
||||
// ships in it), and the reference client silently skips a missing toc entry — so
|
||||
// a broken addon must not read as a client ERROR, which is gate-fatal to
|
||||
// `smoke.sh`'s zero-ERROR count (1450).
|
||||
match self.source {
|
||||
Source::Builtin => error!("ui_script: {e}"),
|
||||
Source::Dir(_) => warn!("ui_script: {e}"),
|
||||
}
|
||||
failures.push(e);
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -177,15 +177,25 @@ impl M2BatchMaterials<'_> {
|
||||
/// eval plus the buffer's point table, decisions 0429/0435) over the sky lane, and plays the
|
||||
/// batch's authored UV animation: together, what a char-select scene is and a portrait bake
|
||||
/// is not (a booth freezes at t = 0 and is never ground-shaded).
|
||||
///
|
||||
/// `order` is the authored batch index + 1, exactly as [`Self::entity_variants`] and
|
||||
/// [`Self::skybox`] take it — the transparent sort bias that makes a model's coplanar batches
|
||||
/// draw in FILE order ([`super::BATCH_ORDER_SORT_EPS`]). This lane hardcoded `0` until 1449,
|
||||
/// and an off-world scene is the one place where that is not a harmless default: every batch
|
||||
/// of a glue scene hangs off the scene root, so they all share one transparent sort distance
|
||||
/// AND one zero bias — a total tie, broken by whatever order `check_visibility` happened to
|
||||
/// collect the view in that frame. `UI_Human`'s `groundshadow` decal and its `street` batch
|
||||
/// swapped sides several times a second, which is the char-select plaza "lighting up".
|
||||
pub fn off_world(
|
||||
&mut self,
|
||||
sub: &ModelSubmesh,
|
||||
texture: Option<Handle<Image>>,
|
||||
order: u16,
|
||||
light: &Buffer,
|
||||
rig: bool,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let shade = if rig { ShadeSel::Rig } else { ShadeSel::Lit };
|
||||
self.build(sub, texture, 0, shade, false, false, rig, light)
|
||||
self.build(sub, texture, order, shade, false, false, rig, light)
|
||||
}
|
||||
|
||||
/// The full variant set an **entity** part needs: every M2 entity — unit, player, GameObject,
|
||||
|
||||
Reference in New Issue
Block a user