From e754af5db84f16774df4c878bbda466be08522c6 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:21:11 +0100
Subject: [PATCH 1/9] MONKEY (reviewfix): keep skybox clocks precise
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-world/src/skybox.rs | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index f14b946b..5c153c57 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -714,7 +714,9 @@ fn animate_skyboxes(
return;
}
let deterministic = crate::dev_state::deterministic_run();
- let now = time.elapsed_secs();
+ // MONKEY (reviewfix): retain sub-frame precision for skybox loops across long sessions; only
+ // narrow the wrapped sequence clock that the f32 track sampler consumes.
+ let now = time.elapsed_secs_f64();
let day = clock.minute.min(1439) as f32 / 1440.0;
clocks.clear();
for layer in want.0.iter().filter(|l| l.weight > 0.0) {
@@ -724,12 +726,17 @@ fn animate_skyboxes(
// A capture poses at `t = 0`, not the bind pose: a converted skybox places its layers with
// bone keys, and every row at `t = 0` is its seed.
let (band_t, gseq, live) = if layer.flags & SKYBOX_FULL_DAY != 0 {
- let g = if deterministic { 0.0 } else { f64::from(now) };
+ let g = if deterministic { 0.0 } else { now };
(rig.duration * day, g, true)
} else if deterministic {
(capture_t(), f64::from(capture_t()), true)
} else {
- (now, f64::from(now), true)
+ let band_t = if rig.duration > 0.0 {
+ (now % f64::from(rig.duration)) as f32
+ } else {
+ 0.0
+ };
+ (band_t, now, true)
};
let pose = if live && rig.animates() {
rig.pose(rig.band_time(band_t), gseq)
From 51d7de6a4e184abd62fdea3cfd1b62dbf1bdced5 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:22:02 +0100
Subject: [PATCH 2/9] MONKEY (reviewfix): document Classic skybox fidelity
Co-Authored-By: GPT-5.6 Sol (Codex)
---
LIGHTING.md | 5 +++++
crates/benilla-world/src/skybox.rs | 10 +++++++++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index ff447c35..59d58ea2 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -38,6 +38,11 @@ behind each constant.
| 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.
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index 5c153c57..ba807803 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -24,6 +24,13 @@
//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon;
//! a celestial model draws as a layer under its main model. Every batch animates
//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks.
+//!
+//! MONKEY (reviewfix): those M2-fidelity repairs deliberately also apply when `zoneSkyboxes` is
+//! off. The reference WMO and ghost slots animate their authored colour/alpha and texture tracks,
+//! use non-white M2 colours, and draw batches in authored order; deterministic captures pose those
+//! same tracks at their pinned time rather than substituting the bind pose. The main-order offset
+//! only leaves room for a celestial under-layer and the final fog cone. The cvar gates the added
+//! zone slot, not fixes to the two 1.12 slots.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -48,7 +55,8 @@ use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
const SHOW_SKYBOX: u32 = 0x40000;
/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so
-/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last.
+/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last. MONKEY
+/// (reviewfix): this ordering also protects the reference WMO/ghost slots when zone skyboxes are off.
const MAIN_ORDER_BASE: u16 = 24;
/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step.
const FOG_CONE_ORDER: u16 = 56;
From 0eb9e1c0228268933c1e7c586d323d88fe3c2cd7 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:23:43 +0100
Subject: [PATCH 3/9] MONKEY (reviewfix): preserve missing stage-one UV
transforms
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-formats/src/models/m2_batches.rs | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs
index 6cb13f03..eca42d5f 100644
--- a/crates/benilla-formats/src/models/m2_batches.rs
+++ b/crates/benilla-formats/src/models/m2_batches.rs
@@ -492,16 +492,20 @@ pub fn parse_m2_render_submeshes(
.get(batch.texture_coord_combo_index as usize + 1)
.copied()
.unwrap_or(0);
- let combo = batch.texture_transform_combo_index.wrapping_add(1);
+ // MONKEY (reviewfix): 0xffff is the no-transform sentinel. Advancing stage 1 must
+ // keep it absent rather than wrap it onto stage 0's transform at index zero.
+ let combo = batch.texture_transform_combo_index.checked_add(1);
Some((
resolve_texture(rec, dir, skins).0,
(rec.wrap_x, rec.wrap_y),
set == 1,
batch.shader_id & 0xf == 4,
- tex_anim::bake_uv_anim(model, combo, seq0_slot),
- tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots)
+ combo.and_then(|combo| tex_anim::bake_uv_anim(model, combo, seq0_slot)),
+ combo
+ .and_then(|combo| tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots))
.and_then(|s| s.seq(None).cloned()),
- tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots)
+ combo
+ .and_then(|combo| tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots))
.and_then(|s| s.seq(None).cloned()),
))
})
From 4b692177aef1f7002e7bb06d5c347066ac2671d5 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:26:09 +0100
Subject: [PATCH 4/9] MONKEY (reviewfix): make skybox path lookup deterministic
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-formats/src/light.rs | 5 +++
crates/benilla-formats/src/light/skybox.rs | 37 ++++++++++++++++++++--
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs
index 6ffd1159..c9a016f5 100644
--- a/crates/benilla-formats/src/light.rs
+++ b/crates/benilla-formats/src/light.rs
@@ -181,6 +181,9 @@ pub struct LightCatalog {
/// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along
/// ([`SkyboxDef`]), read from either table layout.
skyboxes: HashMap,
+ /// MONKEY (reviewfix): normalised model path в†’ lowest matching `LightSkybox` id. Some patched
+ /// tables alias a model with different flags; path lookup must not depend on `HashMap` order.
+ skybox_by_path: HashMap,
}
fn light_schema() -> Schema {
@@ -328,6 +331,7 @@ impl LightCatalog {
};
// MONKEY (skybox): both layouts, keyed by the header's field count.
let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?;
+ let skybox_by_path = skybox::index_paths(&skyboxes);
Ok(LightCatalog {
lights,
int_bands,
@@ -337,6 +341,7 @@ impl LightCatalog {
light_params_water_alpha,
light_params_skybox,
skyboxes,
+ skybox_by_path,
})
}
diff --git a/crates/benilla-formats/src/light/skybox.rs b/crates/benilla-formats/src/light/skybox.rs
index 70322687..ab6c319a 100644
--- a/crates/benilla-formats/src/light/skybox.rs
+++ b/crates/benilla-formats/src/light/skybox.rs
@@ -94,6 +94,19 @@ pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result) -> HashMap {
+ let mut by_path = HashMap::with_capacity(skyboxes.len());
+ for (&id, def) in skyboxes {
+ by_path
+ .entry(def.path.to_ascii_lowercase())
+ .and_modify(|old: &mut u32| *old = (*old).min(id))
+ .or_insert(id);
+ }
+ by_path
+}
+
/// The modern collector's step: dedupe by model with the larger alpha, then scale every other
/// entry by `1 − alpha` (`addSkyBox` steps 1 and 4).
pub(super) fn collect(list: &mut Vec, id: u32, alpha: f32) {
@@ -126,9 +139,10 @@ impl LightCatalog {
/// The row whose model is `path`, for a MOSB skybox the table also names.
pub fn skybox_def_by_path(&self, path: &str) -> Option<&SkyboxDef> {
- self.skyboxes
- .values()
- .find(|d| d.path.eq_ignore_ascii_case(path))
+ let path = crate::models::model_path(path).to_ascii_lowercase();
+ self.skybox_by_path
+ .get(&path)
+ .and_then(|id| self.skyboxes.get(id))
}
/// The skybox the slot's param names; a weather slot without one falls back to the clear
@@ -234,6 +248,23 @@ mod tests {
assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]);
}
+ #[test]
+ fn duplicate_paths_resolve_to_the_lowest_id() {
+ let def = |path: &str| SkyboxDef {
+ path: path.into(),
+ flags: 0,
+ celestial: None,
+ };
+ let skyboxes = HashMap::from([
+ (17, def(r"environments\stars\shared.m2")),
+ (3, def(r"ENVIRONMENTS\STARS\SHARED.M2")),
+ (9, def(r"environments\stars\other.m2")),
+ ]);
+ let paths = index_paths(&skyboxes);
+ assert_eq!(paths.get(r"environments\stars\shared.m2"), Some(&3));
+ assert_eq!(paths.get(r"environments\stars\other.m2"), Some(&9));
+ }
+
/// The installed chain loads in either layout.
#[test]
fn the_installed_table_loads() {
From 9a88ea2de5349ddb485b2f2c52b3fc9c30a4ac5a Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:28:28 +0100
Subject: [PATCH 5/9] MONKEY (reviewfix): isolate animated sky materials
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-world/src/skybox.rs | 4 ++-
crates/benilla-world/src/skybox_anim.rs | 39 ++++++++++++++++++-------
2 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index ba807803..36c94b76 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -542,14 +542,16 @@ fn build_skybox(
}
}
}
+ let mut lane_materials = [pair.steady.clone(), pair.fade_blend.clone()];
let lane = SkyMatLane::register(
sub,
uv,
tint,
&mut table,
mats.materials(),
- &[pair.steady.id(), pair.fade_blend.id()],
+ &mut lane_materials,
);
+ [pair.steady, pair.fade_blend] = lane_materials;
let mesh = meshes.add(mesh);
let pose = match sole_bone(sub) {
Some(b) if rig.bone_moves(b) => PartPose::Rigid(b),
diff --git a/crates/benilla-world/src/skybox_anim.rs b/crates/benilla-world/src/skybox_anim.rs
index 652fa163..408e4534 100644
--- a/crates/benilla-world/src/skybox_anim.rs
+++ b/crates/benilla-world/src/skybox_anim.rs
@@ -333,21 +333,38 @@ impl SkyMatLane {
tint: Option>,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut Assets,
- mats: &[AssetId],
+ mats: &mut [Handle],
) -> Self {
- let mut lane = SkyMatLane::default();
- let write = |materials: &mut Assets,
- f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
- for id in mats {
- crate::model_render::lazy::with_material_mut(materials, *id, |m| f(m));
+ // MONKEY (reviewfix): `model_material` is a cache. Animation slots are per sky batch, so
+ // detach its handles before writing any slot (notably affine-only `.z`) into the material.
+ let originals = mats.to_vec();
+ for i in 0..mats.len() {
+ if let Some(prior) = (0..i).find(|&j| originals[j].id() == originals[i].id()) {
+ mats[i] = mats[prior].clone();
+ continue;
}
- };
+ if let Some(material) =
+ crate::model_render::lazy::with_material_mut(materials, originals[i].id(), |m| {
+ m.clone()
+ })
+ {
+ mats[i] = materials.add(material);
+ }
+ }
+ let mut lane = SkyMatLane::default();
+ let write =
+ |materials: &mut Assets,
+ f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
+ for id in mats.iter() {
+ crate::model_render::lazy::with_material_mut(materials, id.id(), |m| f(m));
+ }
+ };
if let Some(uv) = uv.filter(|a| a.period > 0.0) {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
- .and_then(|id| {
- crate::model_render::lazy::with_material_mut(materials, *id, |m| {
+ .and_then(|handle| {
+ crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
[m.extension.sun_scale.z, m.extension.sun_scale.w]
})
})
@@ -376,8 +393,8 @@ impl SkyMatLane {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
- .and_then(|id| {
- crate::model_render::lazy::with_material_mut(materials, *id, |m| {
+ .and_then(|handle| {
+ crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
})
})
From 6107bdc7a43f8d835b63a3241db62dbd04fe207a Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:32:00 +0100
Subject: [PATCH 6/9] MONKEY (reviewfix): separate skybox sort bands
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-world/src/skybox.rs | 64 +++++++++++++++++++++---------
1 file changed, 46 insertions(+), 18 deletions(-)
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index 36c94b76..225471bf 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -28,11 +28,11 @@
//! MONKEY (reviewfix): those M2-fidelity repairs deliberately also apply when `zoneSkyboxes` is
//! off. The reference WMO and ghost slots animate their authored colour/alpha and texture tracks,
//! use non-white M2 colours, and draw batches in authored order; deterministic captures pose those
-//! same tracks at their pinned time rather than substituting the bind pose. The main-order offset
-//! only leaves room for a celestial under-layer and the final fog cone. The cvar gates the added
+//! same tracks at their pinned time rather than substituting the bind pose. The batch-order layout
+//! leaves room for a celestial under-layer and the final fog cone. The cvar gates the added
//! zone slot, not fixes to the two 1.12 slots.
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
use std::sync::Arc;
use bevy::asset::RenderAssetUsages;
@@ -54,12 +54,9 @@ use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
/// `WmoGroupNav::flags`.
const SHOW_SKYBOX: u32 = 0x40000;
-/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so
-/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last. MONKEY
-/// (reviewfix): this ordering also protects the reference WMO/ghost slots when zone skyboxes are off.
-const MAIN_ORDER_BASE: u16 = 24;
-/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step.
-const FOG_CONE_ORDER: u16 = 56;
+/// MONKEY (reviewfix): the fog cone owns the skybox band's final distinct rung. Orders above this
+/// clamp onto it in [`crate::model_render::skybox_sort_bias`], so model batches stop one rung below.
+const FOG_CONE_ORDER: u16 = 58;
/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox
/// from `LightParams`. Off leaves the reference's two slots only.
@@ -155,9 +152,10 @@ enum PartPose {
#[derive(Component, Default)]
struct SkyboxLocal(Affine3A);
-/// Skybox paths built this session, failed loads included, so a failure is not retried per frame.
+/// Skybox paths built this session в†’ their batch counts. Failed loads use zero, so a failure is not
+/// retried per frame; successful celestial counts set the main model's dynamic order base.
#[derive(Resource, Default)]
-struct BuiltSkyboxes(HashSet);
+struct BuiltSkyboxes(HashMap);
/// MONKEY (skybox): each built model's rig, by path.
#[derive(Resource, Default)]
@@ -228,6 +226,15 @@ fn norm(path: &str) -> String {
}
}
+/// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately
+/// after the largest active celestial model. Oversized art compresses onto the last model rung but
+/// can never collide with the fog cone.
+fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) -> u16 {
+ let base = if celestial { 0 } else { celestial_batches };
+ base.saturating_add(u16::try_from(batch.saturating_add(1)).unwrap_or(u16::MAX))
+ .min(FOG_CONE_ORDER - 1)
+}
+
/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the
/// larger weight and every other layer is scaled by `1 − weight`.
fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) {
@@ -431,9 +438,13 @@ fn build_skybox(
if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) {
build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats);
}
- for layer in &want.0 {
+ // MONKEY (reviewfix): build celestial models first so main bases derive from their real batch
+ // counts instead of a fixed split that aliases sufficiently large models.
+ let mut pending: Vec<_> = want.0.iter().collect();
+ pending.sort_by_key(|layer| !layer.celestial);
+ for layer in pending {
let path = layer.path.as_str();
- if built.0.contains(path) {
+ if built.0.contains_key(path) {
continue;
}
// MONKEY (skybox): the rig, off the same bytes the batches come from.
@@ -453,16 +464,22 @@ fn build_skybox(
Ok(subs) if !subs.is_empty() => subs,
Ok(_) => {
warn!("skybox '{path}' has no render batches — keeping the gradient dome");
- built.0.insert(path.to_string());
+ built.0.insert(path.to_string(), 0);
continue;
}
Err(e) => {
warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
- built.0.insert(path.to_string());
+ built.0.insert(path.to_string(), 0);
continue;
}
};
- let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE };
+ let celestial_batches = want
+ .0
+ .iter()
+ .filter(|layer| layer.celestial)
+ .filter_map(|layer| built.0.get(&layer.path).copied())
+ .max()
+ .unwrap_or(0);
for (i, sub) in subs.iter().enumerate() {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
@@ -506,7 +523,7 @@ fn build_skybox(
let tint = sub.rgb_anim.clone().map(Arc::new);
// The authored batch order after the layer's base (0 is unordered): every batch shares
// one sort distance.
- let order = base + u16::try_from(i + 1).unwrap_or(0);
+ let order = skybox_batch_order(layer.celestial, celestial_batches, i);
let Some(mut pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref())
else {
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
@@ -591,7 +608,9 @@ fn build_skybox(
}
}
rigs.0.insert(path.to_string(), rig);
- built.0.insert(path.to_string());
+ built
+ .0
+ .insert(path.to_string(), u16::try_from(subs.len()).unwrap_or(u16::MAX));
}
}
@@ -971,6 +990,15 @@ mod tests {
}
}
+ #[test]
+ fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() {
+ assert_eq!(skybox_batch_order(true, 0, 23), 24);
+ assert_eq!(skybox_batch_order(false, 24, 0), 25);
+ assert_eq!(skybox_batch_order(false, 0, 31), 32);
+ assert_eq!(skybox_batch_order(false, 40, usize::MAX), FOG_CONE_ORDER - 1);
+ assert_eq!(FOG_CONE_ORDER, 58);
+ }
+
#[test]
fn a_wmo_sky_crossfades_the_zone_sky() {
let mut layers = vec![layer("a.m2", 1.0)];
From cc42b2f9fbfed5398181ce9a310d75df98923c9e Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:36:16 +0100
Subject: [PATCH 7/9] MONKEY (reviewfix): stitch displaced water LOD edges
Co-Authored-By: GPT-5.6 Sol (Codex)
---
.../src/shaders/enhanced_water.wgsl | 20 ++++++-
crates/benilla-assets/src/shaders/liquid.wgsl | 2 +-
crates/benilla-world/src/liquid/lod.rs | 60 ++++++++++++++++++-
3 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index faf60bd9..5cd1bd25 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -132,9 +132,25 @@ fn water_time() -> f32 {
// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else).
// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract.
-fn water_swell(xz: vec2, authored_depth: f32) -> vec3 {
+fn water_swell(xz: vec2, authored_depth: f32, stitch: f32) -> vec3 {
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
- return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
+ let now = water_time();
+ let shore = swell_shore_fade(authored_depth);
+ // MONKEY (reviewfix): a tagged fine boundary vertex follows the straight edge between the
+ // two displaced coarse endpoints. That closes the T-junction against a coarse neighbour.
+ if stitch >= 1.0 {
+ let lane = u32(floor(stitch));
+ let along = fract(stitch);
+ var direction = vec2(1.0, 0.0);
+ if lane == 2u { direction = vec2(-1.0, 0.0); }
+ if lane == 3u { direction = vec2(0.0, 1.0); }
+ if lane == 4u { direction = vec2(0.0, -1.0); }
+ let lo = xz - direction * along * 4.1666665;
+ let hi = lo + direction * 4.1666665;
+ return mix(water_gerstner(lo, now, shore).xyz,
+ water_gerstner(hi, now, shore).xyz, along);
+ }
+ return water_gerstner(xz, now, shore).xyz;
}
return vec3(0.0);
}
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index c414754b..53027ebd 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -148,7 +148,7 @@ fn vertex(in: Vertex) -> LiquidVsOut {
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
// MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
- out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x), 0.0);
+ out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x, in.uv_b.y), 0.0);
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
diff --git a/crates/benilla-world/src/liquid/lod.rs b/crates/benilla-world/src/liquid/lod.rs
index 6ac8ad6d..e7c5e817 100644
--- a/crates/benilla-world/src/liquid/lod.rs
+++ b/crates/benilla-world/src/liquid/lod.rs
@@ -2,8 +2,10 @@
//!
//! Wgpu has no tessellation stage. The coarse grid therefore remains the streamed source of
//! truth, while a 4x bilinear copy is built only when the camera enters the surface's near ring.
-//! Every original edge vertex survives bit-for-bit, so a fine chunk and its coarse neighbour share
-//! the same boundary. Hysteresis avoids rebuilding while the camera hovers at the threshold.
+//! Every original edge vertex survives bit-for-bit. Non-corner fine vertices on an outer edge carry
+//! a compact stitch tag in UV1.y, so the vertex shader lerps the displaced coarse endpoints instead
+//! of evaluating a new Gerstner point that can leave the neighbour's straight edge. Hysteresis
+//! avoids rebuilding while the camera hovers at the threshold.
//! A full MCLQ copy is 1,089 vertices + 6,144 indices (about 68 KiB without allocator overhead);
//! the 64 yd ring bounds ordinary terrain residency to roughly 3.5 MiB, and a per-surface ceiling
//! refuses pathological MLIQ grids rather than allowing one pool to consume an unbounded copy.
@@ -135,6 +137,46 @@ fn bilerp(
out
}
+/// MONKEY (reviewfix): encode an outer-edge fine vertex as `axis/sign + coarse-edge fraction`.
+/// UV1.y is otherwise zero. Coarse corners and interior fine vertices need no stitch.
+fn stitch_tag(
+ positions: &[[f32; 3]],
+ cols: usize,
+ rows: usize,
+ fine_cols: usize,
+ fine_rows: usize,
+ fx: usize,
+ fy: usize,
+ sx: usize,
+ sy: usize,
+ tx: f32,
+ ty: f32,
+) -> f32 {
+ let (a, b, t) = if (fy == 0 || fy + 1 == fine_rows) && fx % SUBDIVISIONS != 0 {
+ let y = if fy == 0 { 0 } else { rows - 1 };
+ (y * cols + sx, y * cols + sx + 1, tx)
+ } else if (fx == 0 || fx + 1 == fine_cols) && fy % SUBDIVISIONS != 0 {
+ let x = if fx == 0 { 0 } else { cols - 1 };
+ (sy * cols + x, (sy + 1) * cols + x, ty)
+ } else {
+ return 0.0;
+ };
+ let dx = positions[b][0] - positions[a][0];
+ let dz = positions[b][2] - positions[a][2];
+ let lane = if dx.abs() >= dz.abs() {
+ if dx >= 0.0 {
+ 1.0
+ } else {
+ 2.0
+ }
+ } else if dz >= 0.0 {
+ 3.0
+ } else {
+ 4.0
+ };
+ lane + t
+}
+
/// Four-way subdivision of every source cell. The output is still one indexed regular grid: there
/// are no duplicated cell-edge vertices, and dry source cells emit no triangles.
fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option {
@@ -185,7 +227,11 @@ fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option {
let tx = (fx - sx * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
fine_positions.push(bilerp(positions, cols, sx, sy, tx, ty));
fine_uv0.push(bilerp(uv0, cols, sx, sy, tx, ty));
- fine_uv1.push(bilerp(uv1, cols, sx, sy, tx, ty));
+ let mut uv1 = bilerp(uv1, cols, sx, sy, tx, ty);
+ uv1[1] = stitch_tag(
+ positions, cols, rows, fine_cols, fine_rows, fx, fy, sx, sy, tx, ty,
+ );
+ fine_uv1.push(uv1);
if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
output.push(bilerp(source, cols, sx, sy, tx, ty));
}
@@ -265,6 +311,14 @@ mod tests {
assert_eq!(positions[20], [0.0, 0.0, 1.0]);
assert_eq!(positions[24], [1.0, 1.0, 1.0]);
assert_eq!(positions[12], [0.5, 0.25, 0.5]);
+ let Some(VertexAttributeValues::Float32x2(uv1)) = fine.attribute(Mesh::ATTRIBUTE_UV_1)
+ else {
+ panic!("uv1");
+ };
+ assert_eq!(uv1[0][1], 0.0, "coarse corner");
+ assert_eq!(uv1[2][1], 1.5, "top-edge midpoint, +x at t=0.5");
+ assert_eq!(uv1[10][1], 3.5, "left-edge midpoint, +z at t=0.5");
+ assert_eq!(uv1[12][1], 0.0, "interior fine vertex");
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
}
From 405e9b534f41b64f52d00be04f406ed9afd1473f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:37:11 +0100
Subject: [PATCH 8/9] MONKEY (reviewfix): shade water in its source wave domain
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-assets/src/shaders/enhanced_water.wgsl | 4 +++-
crates/benilla-assets/src/shaders/liquid.wgsl | 8 ++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index 5cd1bd25..a8906e63 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -91,6 +91,8 @@ fn water_rain_open(world_pos: vec3) -> f32 {
struct WaterFragment {
clip_position: vec4,
world_position: vec4,
+ // MONKEY (reviewfix): world XZ before `water_swell`, so fragment waves share its source domain.
+ source_xz: vec2,
// The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0.
depth: f32,
// MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes.
@@ -555,7 +557,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec
}
let t = water_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase.
let fog = water_fog(in.world_position.xyz, in.room_fog);
- let p = in.world_position.xz;
+ let p = in.source_xz;
let energy = clamp(water.mode.y, 0.0, 1.0);
let ocean_mesh = water.lane.y > 0.5 && water.lane.x < 0.5;
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index 53027ebd..93f32ba6 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -85,6 +85,8 @@ struct LiquidVsOut {
@location(5) vcolor: vec4,
// `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`.
@location(6) @interpolate(flat) room_fog: u32,
+ // MONKEY (reviewfix): the analytic water domain before the ocean's vertex displacement.
+ @location(7) source_xz: vec2,
}
// Sun sheen (`secondary`): the Blinn highlight `light_spec.rgb · (N·H)^shininess`.
@@ -147,8 +149,9 @@ fn vertex(in: Vertex) -> LiquidVsOut {
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
+ out.source_xz = out.world_position.xz;
// MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
- out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x, in.uv_b.y), 0.0);
+ out.world_position += vec4(water_swell(out.source_xz, in.uv_b.x, in.uv_b.y), 0.0);
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
@@ -221,7 +224,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 {
return enhanced_water(
// MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried
// by the reference fragment interface; exterior/ADT vertices supply white.
- WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog),
+ WaterFragment(in.clip_position, in.world_position, in.source_xz, in.depth, in.vcolor,
+ in.room_fog),
shallow_enhanced, deep_enhanced);
}
From 8c894bf9f7e773dd1868915ed9b7133eeaab1064 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:43:56 +0100
Subject: [PATCH 9/9] MONKEY (reviewfix): pin shared shader buffer literals
Co-Authored-By: GPT-5.6 Sol (Codex)
---
.../src/lighting/global_light.rs | 58 +++++++++++++++----
1 file changed, 46 insertions(+), 12 deletions(-)
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index c67291c7..258fe899 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -67,7 +67,7 @@ struct LightStd430 {
rows: [[f32; 4]; LIGHT_HEADER_ROWS],
points: [[f32; 4]; 2 * MAX_POINT_LIGHTS],
// MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier
- // offset moves (8528 -> 8784 B); packed by [`pack_monkey_frame`] from [`super::MonkeyFrame`].
+ // offset moves (the per-frame prefix is now 8784 B); packed by [`pack_monkey_frame`].
monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
}
@@ -123,8 +123,8 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
/// interior set; [`build_light_data`] packs the nearest-to-camera first when over capacity.
///
/// This is the BUFFER shape (`LightStd430` and [`RoomClaimTable`] size against it) and it must not
-/// move: the blob is 8528 B and is mirrored by three shaders plus the portrait booth. How many of
-/// those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
+/// move: the per-frame blob is 8784 B and is mirrored by the shaders plus the portrait booth. How
+/// many of those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
pub(super) const MAX_POINT_LIGHTS: usize = 256;
/// MONKEY (ext light k8): how many of [`MAX_POINT_LIGHTS`] slots may hold a LIVE entry — **255**,
@@ -140,7 +140,7 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256;
///
/// So the pack truncates one entry earlier. The lights are sorted nearest-camera-first before the
/// truncation, so the one this drops is the farthest of a 256-strong set — sub-pixel and usually
-/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8528 B
+/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8784 B
/// blob or the room-claim table changes.
pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1;
@@ -478,7 +478,7 @@ impl Default for FireLightGain {
}
/// MONKEY (post): live `bloom` tier. The packer adds it to `light_diffuse.w`, whose old value 1
-/// was an unread clamp marker, so the 8528-byte shared buffer and every existing decode stay put.
+/// was an unread clamp marker, so the 8784-byte per-frame prefix and every existing decode stay put.
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)]
pub struct EmissiveTier(pub u8);
@@ -897,7 +897,7 @@ pub(super) struct ShadowLanes<'w> {
/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
/// directional-shadow weights.
///
-/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
+/// **There is still no free f32** ([`LightStd430`] is 8784 B, pinned by tests and mirrored by three
/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
@@ -931,7 +931,7 @@ fn unpack_shadow_lane(w: f32) -> (f32, f32) {
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
///
-/// **There was no free f32 left.** [`LightStd430`] is 8528 B, mirrored by three shaders plus the
+/// **There was no free f32 left.** [`LightStd430`] is 8784 B, mirrored by three shaders plus the
/// portrait booth, and must not grow; every `.w` in rows 0..=20 is spoken for (the Mod2x/clamp/SH
/// enables, terrain shininess, fog enable, farclip, the ambient DC lanes, `sh_c13_*.w` which the
/// SH `dot(row, quad)` reads as a real band, the world-shadow flag, the shadow distance, the
@@ -963,7 +963,7 @@ pub const DAYLIGHT_LANE_SCALE: f32 = 0.49;
/// MONKEY (bake floor): how [`DynamicInteriors::bake_floor`] rides to the shader — as the
/// FRACTIONAL part of the packed `sh_c16.w` lane, `w = world_shadow_flag + bake × this`.
///
-/// **There is still no free f32.** [`LightStd430`] is 8528 B and mirrored by three shaders plus the
+/// **There is still no free f32.** [`LightStd430`] is 8784 B and mirrored by three shaders plus the
/// portrait booth (see [`DAYLIGHT_LANE_SCALE`] for the full accounting of why it must not grow).
/// `wmo_fog_params.w` — the lane the daylight floor rides — is now spent: its integer part is
/// `1 + interiorDebug` and its fraction is `interiorDaylight × 0.49`, and two fractions cannot
@@ -1360,7 +1360,7 @@ fn build_light_data(
// MONKEY (darkness gains): `nightGain` — one live dim over everything the EXTERIOR law lights
// (terrain, models, WMO exteriors, and the ext-class night blend, all of which derive from the
// ambient/diffuse/specular rows below). Folded CPU-side into those packed rows rather than
- // added as a shader uniform because `LightStd430` has no free lane left (8528 B, mirrored by
+ // added as a shader uniform because `LightStd430` has no free lane left (8784 B, mirrored by
// three WGSL structs) — and a pack-time fold costs the GPU exactly nothing anyway.
//
// The ramp is the DUSK CLOCK every other night feature already fades on: `sun_w` is
@@ -1437,7 +1437,7 @@ fn build_light_data(
// the dim and the shadow fade can never drift onto two different dusk clocks.
// MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
// The body may have changed abruptly this frame; the shared state holds zero until the new
- // basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
+ // basis has propagated and then ramps. One signed float still keeps the prefix at 8784 bytes.
rows[5][2] = shadow.handover.weight;
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
@@ -2717,14 +2717,48 @@ mod tests {
assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders");
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
// 21 header rows + 2 x 256 point rows, 16 B each.
- // MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block.
+ // MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block.
assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size");
}
+ /// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads
+ /// the shared buffer as raw rows, so pin those literals to the Rust layout constants.
+ #[test]
+ fn programme_and_shelter_shader_literals_match_the_rust_layout() {
+ const WATER: &str = include_str!("../../../benilla-assets/src/shaders/enhanced_water.wgsl");
+ const TERRAIN: &str = include_str!("../../../benilla-assets/src/shaders/terrain.wgsl");
+ const MODEL: &str = include_str!("../../../benilla-assets/src/shaders/wow_model.wgsl");
+ const STATIC_GX: &str = include_str!("../shaders/static_gx.wgsl");
+
+ let monkey_row = LIGHT_HEADER_ROWS + 2 * MAX_POINT_LIGHTS;
+ let shelter_row = monkey_row + super::super::monkey_frame::MONKEY_FRAME_ROWS;
+ assert!(
+ WATER.contains(&format!("const MONKEY_ROW: u32 = {monkey_row}u;")),
+ "enhanced_water.wgsl MONKEY_ROW must follow the header and point table"
+ );
+ assert!(
+ WATER.contains(&format!("const SHELTER_ROW: u32 = {shelter_row}u;")),
+ "enhanced_water.wgsl SHELTER_ROW must follow MonkeyFrame"
+ );
+
+ let shelter_cells = crate::weather::shelter::GRID * crate::weather::shelter::GRID;
+ let declaration = format!("array");
+ for (name, shader) in [
+ ("terrain.wgsl", TERRAIN),
+ ("wow_model.wgsl", MODEL),
+ ("static_gx.wgsl", STATIC_GX),
+ ] {
+ assert!(
+ shader.contains(&declaration),
+ "{name} shelter length must equal shelter::GRID squared"
+ );
+ }
+ }
+
/// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's
/// on/off word, and every existing decode of that word must be blind to it.
///
- /// This is the test the feature stands on: there was no free `f32` left in an 8528-byte layout
+ /// This is the test the feature stands on: there was no free `f32` left in the 8784-byte layout
/// three shaders mirror, so the value shares a lane with two other facts. If the fraction ever
/// grew past 0.5 — a wider `DAYLIGHT_LANE_SCALE`, a `daylight` that escaped its clamp — the
/// debug decode `u32(max(w - 1, 0) + 0.5)` would round UP and every building in the frame would