diff --git a/LIGHTING.md b/LIGHTING.md index ff447c35..2b40ac08 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -31,13 +31,18 @@ behind each constant. | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | | Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` | | Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` | -| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | +| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND` (dev build + `WOW_CAPTURE` only); field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | | Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning | | Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` | | City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` | | Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | +`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are +the reference 1.12 slots, so their fidelity fixes remain active in Classic: authored colour and +alpha tracks affect the layer, non-white M2 colours reach the mesh, deterministic captures pose at +their pinned time, and the batch-order base reserves the lower celestial band and final fog cone. + Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / Bright presets. @@ -76,6 +81,16 @@ and High volumetric fog. No rung decides `shadowDistance` or `interiorShadowSoft column at startup (`Cvars::seed_graphics_preset`) on every row the file does not carry; the rows' registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and tests, which never seed, keep them. The Advanced page's Defaults button lands on High too. +The seeded boot value that leaves the reference (`farclip` 777 against 350) is declared in +`cvars::SEEDED_DEVIATIONS`, and a test walks the whole High column against the reference rows. +Session-owned rows (env levers such as `WOW_FARCLIP`) are skipped when both labels are derived, so +an env override never persists `Custom` (MONKEY (reviewfix-a)). + +**Capture fixtures** (MONKEY (reviewfix-a)). A capture reads CVars only from +`WOW_CAPTURE_CVARS=` (read-only; nothing is saved), never from `BENILLA_HOME`. +`WOW_FOLIAGE_WIND` is honoured only in a dev build under `WOW_CAPTURE`. A capture has no avatar, +so zone grading reads `terrain_stream::CaptureCameraArea` (the area under the camera, written only +under `WOW_CAPTURE`) and the grade's day/night weight follows the rendered `GameClock` minute. **Saved presets and new rows** (MONKEY (followups)). On load, `Cvars::reapply_saved_presets` writes the saved `graphicsQuality` rung (and the saved `lightingQuality` rung, else the Graphics @@ -163,6 +178,8 @@ is its own module, documented in `WATER.md`. by `cargo check`. - Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the cube-map occlusion is evaluated for the nearest three. +- Portal bleed fixtures (`update_bleed_fixtures`, `daylight.rs`) are re-evaluated at 10 Hz, not per + frame; a new doorway or a dial change runs it at once. Sources are bucketed per placement. ## Build @@ -173,6 +190,13 @@ cargo build --release -p benilla Debug tracing: `WOW_TORCH_TRACE=1`, `WOW_POINTS_DUMP=1`, `WOW_SHADOW_TRACE=1`, and the cvar `interiorDebug` 1..4. +Performance: measure a `play`/release build, never `dev`. `WOW_CAPTURE= +WOW_FPS_PROBE=600 WOW_FPS_JOURNAL= WOW_GPU_PASSES=1 WOW_WIN=2560x1440` prints frame, CPU and +per-pass GPU times; `BENILLA_HOME=` supplies the `config.toml` (a capture never seeds the +Graphics Preset, so write the rows out). `WOW_PERF_CROWD=` stands n players at the look point. +Night fixtures: `perf-northshire-night`, `perf-abbey-night`. Per-system CPU: build with +`--features benilla-app/trace_chrome`. + ## Licence Same as upstream: MIT OR Apache-2.0. Code and techniques taken from other projects (WarcraftXL, diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 629f08ec..7011afc4 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -31,8 +31,9 @@ where they stand. Do not remove either. | `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) | | `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) | | `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) | -| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.*`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported from the GPL-3.0-or-later source implementation, Copyright (C) 2026 WarcraftXL) | +| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.cpp`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported; used with the author's permission, attribution required) | | `crates/benilla-world/src/shaders/sky_fx.wgsl` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | Domain-warped billow fbm (quintic value noise, 0.6 cotton blend, 0.55 octave gain) for the cloud detail | +| `crates/benilla-world/src/sky_fx.rs` | `wxl-retail-clouds` | `Clouds.cpp`, `Clouds.hpp` | The High cloud-detail tier and the sun-march inputs it feeds the sky and cloud materials (ported) | | `crates/benilla-world/src/shaders/cloud.wgsl` | `wxl-retail-clouds` | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining | | `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | `wxl-retail-clouds` | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) | | `crates/benilla-world/src/wind/mod.rs` | `wxl-experimental-wind` | `field/Wind.hpp`, `field/Wind.cpp` | Stateless three-sine gust and veer field, default profile, and weather gain (ported) | diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 756b8968..ec1dbd0c 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1834,3 +1834,76 @@ fn seed_equipped_bags( } } } + +/// MONKEY (perf): `WOW_PERF_CROWD=` stands `n` naked human players (display 49) in a grid +/// around the scenario's look point, on the ground — a start zone's crowd for the FPS probe, so the +/// per-unit costs (animation, the shadow lanes' CPU skinning) are in the measurement. Off unset. +pub(super) fn seed_perf_crowd( + mut commands: Commands, + ctx: Res, + progress: Res, + spatial: avian3d::prelude::SpatialQuery, + mut seeded: Local, +) { + let Some(n) = std::env::var("WOW_PERF_CROWD").ok().and_then(|v| v.parse::().ok()) else { + return; + }; + let Some(scenario) = ctx.scenario else { + return; + }; + if *seeded || !(progress.total > 0 && progress.ready == progress.total) { + return; + } + *seeded = true; + use benilla_protocol::messages::ObjectFields; + let per_row = 6u32; + for i in 0..n { + let (row, col) = ((i / per_row) as f32, (i % per_row) as f32); + let wow = [ + scenario.look[0] + (row - 1.0) * 2.5, + scenario.look[1] + (col - per_row as f32 * 0.5) * 2.5, + scenario.look[2] + 40.0, + ]; + let mut pos = wow_to_bevy(wow); + if let Some(hit) = spatial.cast_ray( + pos, + Dir3::NEG_Y, + 200.0, + true, + &benilla_world::collision::WorldCollision::body_filter(), + ) { + pos.y -= hit.distance; + } + commands.spawn(( + crate::net::Guid(0x5100 + u64::from(i)), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Player, + display_id: Some(49), + scale: 1.0, + }, + crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (22, 100), + (28, 100), + (34, 12), + (35, 1), + (36, 1 | 1 << 8), + ])), + crate::entities::Equipment { + settled: true, + ..default() + }, + benilla_world::world_unit::WorldUnit { + wades: true, + scale: 1.0, + height: crate::entities::CollisionHeight::default().0, + bound: None, + }, + Transform { + translation: pos, + rotation: Quat::from_rotation_y(i as f32 * 0.7), + ..default() + }, + Visibility::default(), + )); + } +} diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 226cc85d..7d3bc400 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -119,7 +119,7 @@ mod probes; mod scenarios; use crate::run_mode::CaptureMode; pub(crate) use depth_probe::DepthProbePlugin; -use fixtures::{seed_ui_fixture, seed_wind_player}; +use fixtures::{seed_perf_crowd, seed_ui_fixture, seed_wind_player}; pub(crate) use live_shot::LiveShotPlugin; pub(crate) use phase_probe::PhaseProbePlugin; pub(crate) use pick_probe::PickProbePlugin; @@ -885,6 +885,14 @@ impl Plugin for CapturePlugin { // constant 16.67 ms for any scene, however slow. app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT)) .add_systems(Startup, hold_clock); + // MONKEY (perf): a probe window opens unfocused, and `WinitSettings::game()` runs an + // unfocused window at reactive 1/60 s — every probe read 16.7 ms whatever the scene cost. + if probe_frames > 0 { + app.insert_resource(bevy::winit::WinitSettings { + focused_mode: bevy::winit::UpdateMode::Continuous, + unfocused_mode: bevy::winit::UpdateMode::Continuous, + }); + } app.insert_resource(CaptureMode) .init_resource::() .insert_resource(CaptureCtx { @@ -905,6 +913,8 @@ impl Plugin for CapturePlugin { }) .add_systems(Update, pin_scene.in_set(WorldStage::Present)) .add_systems(Update, seed_wind_player) + // MONKEY (perf): the probe crowd (`WOW_PERF_CROWD`). + .add_systems(Update, seed_perf_crowd) // Before the UnitFeed pass: the seed stands in for wire data that in live play // filled the app caches on EARLIER frames, so the same frame's feeds (item-template // / player-req pushes, then the merchant paint) must all see it. Unordered, the diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d1ba890c..8aa90ad1 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1779,6 +1779,35 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, ui: None, }, + // MONKEY (integration): the Westfall sea from 30 yd up, aimed ~72 yd out, so the enhanced + // water's 64-80 yd fine/coarse LOD ring crosses the frame centre (review #7, WOW_WATER=2). + Scenario { + name: "water-ocean-lodring", + map: Some(MAP_AZEROTH), + eye: [-10500.0, 2112.0, 30.0], + look: [-10449.0, 2163.0, 0.0], + minute: 720, + ui: None, + }, + // MONKEY (perf): Northshire at midnight from the ground — where the owner's night FPS drop is + // reported (torch lanes, lamp fog, bleed fixtures all live). Probe fixtures, not look fixtures. + Scenario { + name: "perf-northshire-night", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 0, + ui: None, + }, + // MONKEY (perf): the abbey front at midnight, close to its lamps and candelabra. + Scenario { + name: "perf-abbey-night", + map: Some(MAP_AZEROTH), + eye: [-8935.0, -145.0, 86.0], + look: [-8905.0, -160.0, 85.0], + minute: 0, + ui: None, + }, ]; /// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 2e31b997..8e6286cb 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -955,7 +955,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours( "skyQuality", "0", - "benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \n 2 High (+ detailed sun-lit clouds)", + "benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \ + 2 High (+ detailed sun-lit clouds)", ), // MONKEY (wind): one tier controls the grass-only and grass-plus-tree receivers. ours( @@ -984,7 +985,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours( "zoneSkyboxes", "0", - "benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the reference draws a DBC skybox only for the ghost", + "benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the \ + reference draws a DBC skybox only for the ghost", ), ours( "waterQuality", @@ -1133,13 +1135,15 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours( "exteriorShadows", "1", - "benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day", + "benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at \ + night; no effect by day", ), // MONKEY (daylight: terrain torch casters): the ground as a torch caster. ours( "torchTerrainShadows", "0", - "benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows", + "benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's \ + light); needs exteriorShadows", ), // MONKEY (static torch cache): residency and per-frame work have separate live budgets. ours( @@ -2334,8 +2338,11 @@ fn same_value(a: &str, b: &str) -> bool { /// allowed to say, which is what keeps the row from going stale behind a `/console` write. pub(crate) fn derive_lighting_quality(cvars: &Cvars) -> &'static str { for (name, members) in LIGHTING_PRESETS { + // MONKEY (reviewfix-a): a session-owned row (an env lever) is not the player's choice and + // must not turn the saved label Custom; it is skipped, as the seed skips it. let matched = members .iter() + .filter(|(k, _)| !cvars.is_session_owned(k)) .all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v))); if matched { return name; @@ -2379,6 +2386,21 @@ pub(crate) const GRAPHICS_PRESET_NAMES: [&str; 5] = ["Classic", "Low", "Medium", /// The rung a player with no saved preset boots into ([`Cvars::seed_graphics_preset`]). pub(crate) const GRAPHICS_DEFAULT: &str = "High"; +/// MONKEY (reviewfix-a): **the rows where the first-boot seed leaves the reference** — the +/// [`GRAPHICS_DEFAULT`] column value a new player actually boots with, against the reference's +/// own default. The registered row keeps its `same(...)` (the registry default IS the +/// reference's; a capture or a test, which never seeds, runs at it), so this list is where the +/// deviation of the *seeded* boot value is declared, `Deviates`-style: `(row, reference, why)`. +/// `seeded_column_deviates_only_where_declared` walks the whole column against the reference +/// rows, both ways. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) const SEEDED_DEVIATIONS: &[(&str, &str, &str)] = &[( + "farclip", + "350", + "owner decision: the High preset is the first-boot default, and High's view distance is the \ + reference's own slider maximum, 777 yd; Classic keeps 350", +)]; + /// **The one place the Graphics Preset is written down**: one line per governed row, one column /// per rung of [`GRAPHICS_PRESET_NAMES`]. `lightingQuality` is itself a row — writing its rung /// writes [`LIGHTING_PRESETS`]' members — so every lighting, water, fog, post and wind row the @@ -2421,8 +2443,10 @@ fn graphics_column(name: &str) -> Option { /// ladders show). Reads `lightingQuality`'s DERIVED label, so run it after that is mirrored. pub(crate) fn derive_graphics_quality(cvars: &Cvars) -> &'static str { for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { + // MONKEY (reviewfix-a): session-owned rows are skipped, as in the lighting ladder above. let matched = GRAPHICS_PRESETS .iter() + .filter(|(k, _)| !cvars.is_session_owned(k)) .all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v[col]))); if matched { return name; @@ -2636,14 +2660,14 @@ fn stored_config() -> StoredConfig { } } -/// MONKEY (integration): a capture may opt into one explicit, read-only CVar fixture through -/// `BENILLA_HOME`. All other local state stays hermetic and [`save_config`] still sees no path, -/// so deterministic A/B homes can select graphics tiers without a capture writing anything back. +/// MONKEY (integration): a capture may opt into one explicit, read-only CVar fixture. +/// MONKEY (reviewfix-a): through its own variable, `WOW_CAPTURE_CVARS=`, not +/// `BENILLA_HOME` (the general local-state override a player shell may carry, which would load +/// that player's settings into a baseline). Without it a capture reads no config at all. All +/// other local state stays hermetic and [`save_config`] still sees no path. fn config_read_path() -> Option { if std::env::var_os("WOW_CAPTURE").is_some() { - return std::env::var_os("BENILLA_HOME") - .map(std::path::PathBuf::from) - .map(|home| home.join("config.toml")); + return std::env::var_os("WOW_CAPTURE_CVARS").map(std::path::PathBuf::from); } crate::local_state::config_path() } @@ -3740,19 +3764,36 @@ mod tests { /// row's rung values do too. #[test] fn graphics_rows_pass_their_observers_unclamped() { + // MONKEY (reviewfix-a): every governed row, every rung — not only `farclip`. Each value + // is driven through the real setter and read back off the resource its renderer reads. let mut app = cvar_app(); for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { - let farclip = GRAPHICS_PRESETS - .iter() - .find(|(k, _)| *k == "farclip") - .unwrap() - .1[col]; - apply(&mut app, "farclip", farclip); - assert_eq!( - res::(&app).farclip, - farclip.parse::().unwrap(), - "{name}" - ); + for (k, values) in GRAPHICS_PRESETS { + if *k == "lightingQuality" { + continue; // its members are welded by the lighting-preset census test + } + let value = values[col]; + apply(&mut app, k, "-1"); + apply(&mut app, k, value); + let video = res::(&app); + let flag = |b: bool| if b { 1.0 } else { 0.0 }; + let applied = match *k { + "farclip" => res::(&app).farclip, + "skyQuality" => video.sky_quality as f32, + "skyDither" => flag(res::(&app).0), + "fogModel" => { + flag(res::(&app).modern()) + } + "rainSurfaces" => flag(res::(&app).0), + "torchTerrainShadows" => flag(video.torch_terrain_shadows), + "daylightWindowSplit" => flag(video.daylight_window_split), + "ambientOcclusion" => video.ambient_occlusion as f32, + "zoneSkyboxes" => flag(res::(&app).0), + "lampFog" => video.lamp_fog as f32, + _ => panic!("add the observer readback for {k}"), + }; + assert_eq!(applied, value.parse::().unwrap(), "{name}/{k}"); + } } } @@ -4382,6 +4423,12 @@ mod tests { app.init_resource::(); // MONKEY (wet) app.add_observer(crate::monkey_gfx::on_cvar); }, + // MONKEY (reviewfix-a): the zone-skybox bridge, so every governed row has its observer. + |app| { + app.insert_resource(crate::zone_skybox::ZoneSkyboxOverride(None)); + app.init_resource::(); + app.add_observer(crate::zone_skybox::on_cvar); + }, |app| { app.add_observer(crate::player::camera::on_cvar); }, @@ -4957,6 +5004,8 @@ mod tests { ) .unwrap(); let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + // MONKEY (reviewfix-a): the fixture is named by its own variable, not BENILLA_HOME. + let _x = EnvGuard::set("WOW_CAPTURE_CVARS", tmp.join("config.toml").to_str().unwrap()); let _c = EnvGuard::set("WOW_CAPTURE", "post-lava-searing"); assert_eq!(boot_cvar("bloom").as_deref(), Some("0")); @@ -5073,4 +5122,110 @@ mod tests { ] ); } + + // ─── MONKEY (reviewfix-a) ──────────────────────────────────────────────────────────────── + + /// The value the reference boots a row at, as its [`Reference`] column records it; `None` + /// for benilla's own rows (nothing to match). + fn reference_boot_value(row: &Registered) -> Option<&'static str> { + match &row.reference { + Reference::Same(v) => Some(v), + // The reference's own boot code lands where our default does. + Reference::Overridden { .. } => Some(row.default), + Reference::Deviates { value, .. } => Some(value), + Reference::Ours(_) => None, + } + } + + /// **The seeded High column against every reference row** (review #6): a governed row a new + /// player boots off the reference's value must be declared in [`SEEDED_DEVIATIONS`] with + /// that reference value and a reason, and a declared row must really deviate. + #[test] + fn seeded_column_deviates_only_where_declared() { + let col = graphics_column(GRAPHICS_DEFAULT).unwrap(); + for (k, values) in GRAPHICS_PRESETS { + if k.eq_ignore_ascii_case("lightingQuality") { + continue; // High on that ladder IS the registered defaults (its own test) + } + let row = REGISTERED.iter().find(|r| r.name == *k).expect("governed row registered"); + let declared = SEEDED_DEVIATIONS.iter().find(|(n, _, _)| n == k); + match reference_boot_value(row) { + Some(reference) if !same_value(values[col], reference) => { + let (_, value, why) = declared.unwrap_or_else(|| { + panic!( + "{k}: the seeded {GRAPHICS_DEFAULT} value {} leaves the reference's \ + {reference}; declare it in SEEDED_DEVIATIONS", + values[col] + ) + }); + assert!(same_value(value, reference), "{k}: declared reference is stale"); + assert!(!why.trim().is_empty(), "{k}: a deviation owes a reason"); + } + _ => assert!( + declared.is_none(), + "{k}: declared in SEEDED_DEVIATIONS but the seed does not deviate" + ), + } + } + for (n, _, _) in SEEDED_DEVIATIONS { + assert!( + GRAPHICS_PRESETS.iter().any(|(k, _)| k == n), + "{n}: declared but not governed" + ); + } + } + + /// Review #14: an env lever owns a governed row for the session; the label must not derive + /// (and so persist) `Custom` because of it. + #[test] + fn a_session_owned_row_does_not_derive_custom() { + let mut cvars = fresh_registry(); + apply_graphics_preset(&mut cvars, "High"); + lighting_quality(&mut cvars); + assert_eq!(derive_graphics_quality(&cvars), "High"); + cvars.own_for_session("farclip", Some("500")); + assert_eq!(cvars.get("farclip"), Some("500")); + assert_eq!(derive_graphics_quality(&cvars), "High"); + // A lighting member owned by the session is skipped the same way. + cvars.own_for_session("waterQuality", Some("0")); + assert_eq!(derive_lighting_quality(&cvars), "High"); + } + + /// Review #15: help strings are one line — a lost `\` continuation leaves a literal `\n` or a + /// run of indentation spaces in the `/console` text. + #[test] + fn help_strings_have_no_broken_continuations() { + for row in REGISTERED { + let why = match &row.reference { + Reference::Ours(why) + | Reference::Deviates { why, .. } + | Reference::Overridden { why, .. } => *why, + Reference::Same(_) => continue, + }; + assert!( + !why.contains('\n') && !why.contains("\\n") && !why.contains(" "), + "{}: broken line continuation in {why:?}", + row.name + ); + } + } + + /// Review #13: a capture reads CVars only from the dedicated `WOW_CAPTURE_CVARS` fixture, + /// never from a `BENILLA_HOME` the shell happens to carry. + #[test] + fn a_capture_reads_cvars_only_from_its_fixture() { + use crate::local_state::test_env::{EnvGuard, ENV_LOCK}; + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = EnvGuard::set("WOW_CAPTURE", "overlook-noon"); + let _h = EnvGuard::set("BENILLA_HOME", "player-home"); + let _x = EnvGuard::unset("WOW_CAPTURE_CVARS"); + assert_eq!(config_read_path(), None); + let _x = EnvGuard::set("WOW_CAPTURE_CVARS", "fixture/config.toml"); + assert_eq!( + config_read_path(), + Some(std::path::PathBuf::from("fixture/config.toml")) + ); + } } diff --git a/crates/benilla-app/src/monkey_gfx.rs b/crates/benilla-app/src/monkey_gfx.rs index 4ba49e2a..8b40b233 100644 --- a/crates/benilla-app/src/monkey_gfx.rs +++ b/crates/benilla-app/src/monkey_gfx.rs @@ -33,12 +33,9 @@ pub(crate) fn on_cvar( } // MONKEY (wind): numeric tier, live; 0 is the exact no-displacement path. "foliagewind" => { - // Capture-only A/B override. Normal runs have no such environment variable and obey - // the live CVar exactly; the instrument can force Off without rewriting Config.wtf. - let requested = std::env::var("WOW_FOLIAGE_WIND") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| ev.num()); + // Capture-only A/B pin (MONKEY reviewfix-a: gated like `capture_daylight` — dev + // build AND `WOW_CAPTURE`). Player runs obey the live CVar exactly. + let requested = foliage_wind_pin().map(f32::from).unwrap_or_else(|| ev.num()); let want = requested.clamp(0.0, 2.0) as u8; if foliage_wind.0 != want { foliage_wind.0 = want; @@ -57,6 +54,14 @@ pub(crate) fn on_cvar( } } +/// MONKEY (reviewfix-a): the `WOW_FOLIAGE_WIND` capture pin, only in a dev build under `WOW_CAPTURE`. +fn foliage_wind_pin() -> Option { + if !crate::run_mode::dev_affordances() { + return None; + } + FoliageWind::capture_override() +} + /// Registers the programme's cvar bridges. Must be added before `CvarPlugin` so the saved values /// applied at Startup reach it (the edge `game_plugins.rs` documents for every knob plugin). pub(crate) struct MonkeyGfxPlugin; @@ -64,7 +69,11 @@ pub(crate) struct MonkeyGfxPlugin; impl Plugin for MonkeyGfxPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .init_resource::() + // MONKEY (reviewfix-a): the resource is seeded here so a player build never takes the + // capture pin (the world plugin's `init_resource` keeps whatever is already present). + .insert_resource(FoliageWind( + foliage_wind_pin().unwrap_or(FoliageWind::REGISTERED), + )) .init_resource::() .init_resource::() // MONKEY (wet) .add_observer(on_cvar); diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs index 5a4dc42a..82048096 100644 --- a/crates/benilla-app/src/post/grading.rs +++ b/crates/benilla-app/src/post/grading.rs @@ -1,7 +1,7 @@ //! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture. //! -//! Ported from WarcraftXL's `wxl-retail-grading` (Copyright (C) 2026 WarcraftXL, GPL-3.0-or-later). -//! Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear +//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-grading, Grading.cpp, shaders/Grading.ps.hlsl. +//! Used with the author's permission; attribution required. Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear //! lookup after upload to a 3D texture. See `THIRD-PARTY.md`. use super::bloom::BloomLabel; @@ -25,7 +25,7 @@ use bevy::{ renderer::{RenderContext, RenderDevice, RenderQueue}, texture::GpuImage, view::ViewTarget, - RenderApp, RenderStartup, + Render, RenderApp, RenderStartup, RenderSystems, }, }; use std::collections::{HashMap, HashSet}; @@ -43,10 +43,66 @@ struct GradeCatalog { struct GradeView { day: Handle, night: Handle, - // x = authored strength, y = night blend. + /// MONKEY (polish): the pair being faded out (identity once the fade is done). + prev_day: Handle, + prev_night: Handle, + // x = authored strength, y = night blend, z = crossfade weight of the current pair (1 = done), + // w = the previous pair's strength. control: Vec4, } +/// MONKEY (polish): the zone crossfade's length; a border crossing no longer snaps the grade. +const GRADE_FADE_S: f32 = 2.0; + +/// One zone's LUT pair at its authored strength; a zone with no row grades at strength 0. +#[derive(Clone, PartialEq, Debug)] +struct GradePair { + day: Handle, + night: Handle, + strength: f32, +} + +/// MONKEY (polish): the crossfade from the previous zone's pair to the current one. +#[derive(Resource, Default)] +struct GradeBlend { + current: Option, + previous: Option, + /// The current pair's weight, 0..1. + fade: f32, + primed: bool, +} + +impl GradeBlend { + /// Steps toward `target`. The first graded zone after login (or re-enabling) and a disabled + /// grade snap; a return to the + /// pair still fading out reverses the fade instead of restarting it. + fn step(&mut self, target: Option, dt: f32, snap: bool) { + if snap || !self.primed { + *self = Self { + primed: !snap && target.is_some(), + current: target, + previous: None, + fade: 1.0, + }; + return; + } + if target != self.current { + if self.fade < 1.0 && target == self.previous { + std::mem::swap(&mut self.current, &mut self.previous); + self.fade = 1.0 - self.fade; + } else { + self.previous = self.current.take(); + self.current = target; + self.fade = 0.0; + } + } + self.fade = (self.fade + dt / GRADE_FADE_S).min(1.0); + if self.fade >= 1.0 { + self.previous = None; + } + } +} + #[derive(Clone, Copy, ShaderType)] struct GradeUniform { control: Vec4, @@ -55,6 +111,7 @@ struct GradeUniform { impl Plugin for GradingPlugin { fn build(&self, app: &mut App) { app.add_plugins(ExtractComponentPlugin::::default()) + .init_resource::() .add_systems(Last, update_views); if !app.is_plugin_added::() { return; @@ -73,6 +130,10 @@ impl Plugin for GradingPlugin { render_app .insert_resource(GradeShader(shader)) .add_systems(RenderStartup, init_pipeline) + .add_systems( + Render, + prepare_uniforms.in_set(RenderSystems::PrepareResources), + ) .add_render_graph_node::>(Core3d, GradeLabel) .add_render_graph_edges( Core3d, @@ -215,17 +276,25 @@ fn volume_image(cube: Vec) -> Image { ) } +#[allow(clippy::too_many_arguments)] fn update_views( mut commands: Commands, video: Res, catalog: Option>, area: Res, + // MONKEY (reviewfix-a): a capture has no avatar; it grades by the area under the camera. + capture_area: Option>, areas: Option>, time: Res, + // MONKEY (reviewfix-a): the minute the lighting actually rendered (server, or the manual / + // capture clock); `WorldTime` alone is the server's and stays at its noon default offline. + rendered: Option>, + clock: Res