Files
benilla-octo/crates/benilla-world/src/dev_state.rs
T
gromandClaude Opus 5 280c2e886f Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique
projection I blamed it on in the last commit. What found them was looking
at the mirror's own render target instead of reasoning about the
composited frame — the water reads it through a fresnel weight, a rippled
UV, a plane-match band and an SSR march that paints over it, so from the
outside a broken mirror and a broken water shader look identical.
WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own
save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers
UnsupportedFormat for Rgba16Float, logging and writing nothing.)

- The oblique row's scale. Replacing the near plane with the water plane
  costs the FAR plane: `z >= 0` stops meaning "nearer than CAM_FAR" and
  becomes `depth >= a*d`, a plane through the eye tilted by a. Lengyel's
  own scale puts that plane exactly through the frustum's far corner, and
  the sky dome is pinned just inside the far plane, so it straddled the
  boundary and was sliced along its own tessellation. Scale by the
  supremum of d/depth over the frustum instead, with 2% of margin.
- The water surface lay ON its own clip plane. This was the starburst and
  it was fatal rather than untidy: depth now means "distance above the
  plane", so a fragment at d=0 reads z/w=1, the nearest value reverse-Z
  has, and beats every other surface in the frame. The mesh is
  wave-displaced, so its triangles straddled the plane instead of lying in
  it, and each was clipped to a sliver that kept the winning depth.
  SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the
  probed height answers for the surface under the eye, not this fragment.
- The mirror was stored upside down. A true reflection is what a camera on
  the far side sees with its up vector reflected too — an improper basis
  no Transform can hold. The honest right-handed camera sees the same
  surfaces through the same frustum and stores the image flipped, so the
  shader samples 1 - v; that costs no winding flip, so backface culling
  stays correct. It read as a lake reflecting the ridge the RIGHT way up.

Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the
Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first
lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its
own above High and the default stays 2.

Seated as a setting, per the standing rule that no graphics lane hides
behind an env var: waterQuality gains tier 3, the Ultra preset takes it,
and the Advanced Graphics dropdown gains a fourth entry in both locales.

With the lane off, both scenarios are byte-identical to the build before
it — which took two findings of its own:

- The system could not live in Update at all. `&mut Transform` on a camera
  is shared with everything that moves anything; declaring it put 59
  undeclared orders into Update and tripped the schedule ratchet, which is
  what kept this behind an env var. It belongs in PostUpdate with the
  skybox and the billboards, after propagation and before visibility,
  where it also reads the eye's settled pose instead of a frame-old one.
  (The comment blaming ResMut<Assets<Image>> for those 59 was wrong —
  dropping the asset handle changed the count by zero.)
- A system that runs and returns on its first line is still not free.
  Holding &mut Camera and &mut GlobalTransform for a frame is enough to
  change which undeclared orders the executor picks: the canal moved 0.75%
  of its pixels by up to 96/255 with the lane switched off. So the whole
  system sits behind a run condition rather than an early return — and the
  same effect killed a .before(UpdateDirectionalLightCascades) order that
  was correct in itself.

PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs
are structurally impossible: nothing else in the schedule names the mirror
camera); Update's ratchets down 4973 -> 4952.

Also restores scripts/gates.sh's `where` example, which had gone missing.
Its absence was silent — cargo's error went to /dev/null and `|| true`
made it read as "no install" — so the memo key and the player-tests gate
saw none even when WOW_DATA was set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-27 09:35:02 -06:00

163 lines
6.0 KiB
Rust

//! The dev state: the per-subsystem toggles the render and gameplay systems read, at
//! player-faithful defaults. The debug panel only edits it, so it stays in a player build.
use bevy::prelude::*;
/// Whether this is a deterministic capture run (`$WOW_CAPTURE`): wall-time variation freezes.
pub fn deterministic_run() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// Frames each still-frame input has read as changed since startup: `[camera transform,
/// DebugState, ViewDistance, ExteriorWindows, CameraInteriorClaim, any WmoPortalInstance, any
/// InheritedVisibility]`. The still-frame skips (visibility walk, billboards, emitter gates,
/// doodad hosts) engage only while all are still; `FPS_PROBE`'s `noisy=` prints them per window.
pub static STILL_INPUTS_CHANGED: [std::sync::atomic::AtomicU32; 7] = [
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
std::sync::atomic::AtomicU32::new(0),
];
/// `Last`: count this frame's changed still-frame inputs into [`STILL_INPUTS_CHANGED`].
pub(crate) fn count_still_inputs(
cam: Query<Ref<GlobalTransform>, With<crate::view::WorldCamera>>,
debug: Res<DebugState>,
view: Res<crate::view::ViewDistance>,
windows: Res<crate::wmo_portal::ExteriorWindows>,
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
portals: Query<(), Changed<crate::wmo_portal::WmoPortalInstance>>,
flips: Query<(), Changed<InheritedVisibility>>,
) {
let changed = [
cam.iter().any(|t| t.is_changed()),
debug.is_changed(),
view.is_changed(),
windows.is_changed(),
claim.is_changed(),
!portals.is_empty(),
!flips.is_empty(),
];
for (slot, c) in STILL_INPUTS_CHANGED.iter().zip(changed) {
if c {
slot.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
/// Root debug state, one section per subsystem.
#[derive(Resource)]
pub struct DebugState {
/// Panel visible; toggled with the dev chord + `D`. `$WOW_PANEL=1` starts it open, so a
/// headless capture can shoot it.
pub open: bool,
pub models: ModelDebug,
pub lighting: LightingDebug,
pub sound: SoundDebug,
pub weather: WeatherDebug,
}
impl Default for DebugState {
fn default() -> Self {
Self {
open: std::env::var_os("WOW_PANEL").is_some(),
models: default(),
lighting: default(),
sound: default(),
weather: default(),
}
}
}
/// Weather override through the wire's own `WeatherState::apply` path.
#[derive(Default)]
pub struct WeatherDebug {
/// Override armed: the scrub below replaces the wire, whose weather is consumed and ignored.
pub force: bool,
/// One-shot: set by the panel when the scrub changes, taken by `weather_tick`.
pub dirty: bool,
/// Wire weather type (0 fine / 1 rain / 2 snow / 3 sand).
pub kind: u32,
/// Wire grade (0..1).
pub grade: f32,
/// Apply instantly (the wire's `instant` flag) instead of the ramp.
pub instant: bool,
}
/// The sound kit probe, played through the real kit path (`sound::kit`); the sound settings are
/// `sound::SoundConfig`.
pub struct SoundDebug {
/// A `SoundEntries` kit id or name for the "Play kit" probe.
pub kit_query: String,
/// One-shot: play `kit_query` through the kit player.
pub play_kit: bool,
/// Copies of `kit_query` fired in one frame, the overlap case (many attackers, a group buff):
/// five of kit 3116 (`HolyProtection`) is a group Fortitude on a full party.
pub play_copies: u32,
}
impl Default for SoundDebug {
fn default() -> Self {
Self {
// A UI kit with several variations, to exercise the depleting weighted pick.
kit_query: "igMiniMapZoomIn".into(),
play_kit: false,
play_copies: 1,
}
}
}
/// Scene lighting controls: `Light.dbc` is sampled at the server game clock unless scrubbed.
pub struct LightingDebug {
/// Follow the live server game clock; when `false`, `manual_minute` sets the time.
pub follow_server_time: bool,
/// Minute of the game day (`0..1440`) while not following the server.
pub manual_minute: u32,
/// Disable the `Light.dbc` distance fog; the sky dome's horizon band is not fog and stays.
pub disable_fog: bool,
/// Hide the gradient sky dome.
pub disable_sky_dome: bool,
}
impl Default for LightingDebug {
fn default() -> Self {
// `WOW_CLOCK=<minute 0..1439>` arms the manual scrub, to match a reference shot's hour.
let clock = std::env::var("WOW_CLOCK")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.map(|m| m % 1440);
Self {
follow_server_time: clock.is_none(),
manual_minute: clock.unwrap_or(720), // noon, only used when not following the server
disable_fog: false,
// `WOW_NOSKYDOME=1` starts the dome and its cloud layer hidden, for a headless A/B
// that asks which surface a frame's artifact belongs to.
disable_sky_dome: std::env::var_os("WOW_NOSKYDOME").is_some(),
}
}
}
/// World-model render toggles: visibility per model kind and per blend layer.
pub struct ModelDebug {
/// Visible flags indexed by [`crate::model_render::kind_index`].
pub kind_visible: [bool; 4],
/// Visible flags indexed by [`crate::model_render::blend_index`].
pub blend_visible: [bool; 5],
/// WMO portal culling: on is the reference's per-group PVS, off draws every group.
pub portal_cull: bool,
}
impl Default for ModelDebug {
fn default() -> Self {
Self {
kind_visible: [true; 4],
blend_visible: [true; 5],
// `WOW_NOPORTALCULL=1` starts the cull off, for a headless A/B of one viewpoint.
portal_cull: std::env::var("WOW_NOPORTALCULL").is_err(),
}
}
}