diff --git a/crates/benilla-app/src/creature_anim/lod.rs b/crates/benilla-app/src/creature_anim/lod.rs index 23ce576e..59e00008 100644 --- a/crates/benilla-app/src/creature_anim/lod.rs +++ b/crates/benilla-app/src/creature_anim/lod.rs @@ -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` (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` (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, Without, + Without, ), >, self_hosts: Query>, @@ -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::(), + "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 { diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index fbd9d423..5c51b248 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -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" + ); + } } diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index 06793af8..660f545f 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -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 diff --git a/crates/benilla-app/src/perf/stats.rs b/crates/benilla-app/src/perf/stats.rs index 599ae0d0..4219421c 100644 --- a/crates/benilla-app/src/perf/stats.rs +++ b/crates/benilla-app/src/perf/stats.rs @@ -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, 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" diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index cfa1d543..adbe5853 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -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, )>(); } diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 5eee8e36..35348ca4 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -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 diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 853e7e73..a12c505d 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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, frames: Res, + // `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>, mut env_cache: Local>, ) { 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 { diff --git a/crates/benilla-app/src/portrait/test_bake.rs b/crates/benilla-app/src/portrait/test_bake.rs index 248dd651..62b81e21 100644 --- a/crates/benilla-app/src/portrait/test_bake.rs +++ b/crates/benilla-app/src/portrait/test_bake.rs @@ -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, ), diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 1ba72853..0291c4d9 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -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; }; diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 457e8967..0f354bb5 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -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>, + order: u16, light: &Buffer, rig: bool, ) -> Handle { 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,