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:
Sam
2026-08-20 19:04:09 +07:00
co-authored by Claude Fable 5
parent 67f9310364
commit 00c3a49a53
10 changed files with 247 additions and 87 deletions
+30 -2
View File
@@ -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 {
+149 -69
View File
@@ -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"
);
}
}
+2 -1
View File
@@ -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
+13 -9
View File
@@ -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"
+5 -2
View File
@@ -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
+19 -1
View File
@@ -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,
),
+10 -1
View File
@@ -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;
};
+11 -1
View File
@@ -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,