From 23f23d36e09ae0cc78772821615fa135acafc78e Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Aug 2026 17:55:04 +0700 Subject: [PATCH] there are three water renderers, and WMO water gets its two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WMO water stops running the terrain-liquid program: the material key names WHICH renderer a pool takes, and the two WMO arms land — Blackfathom Deeps' interior pools take their authored per-vertex opacity and the material's own body colour (34, 38, 57 there — the dark blue-grey in the reference shot) instead of coming out opaque and daylit on the zone's river swatch - the exterior WMO arm bands at sub-17, not sub-16 — teal, not olive — which is what the Stormwind canals actually read as - a hole corner's authored height no longer counts as a height, and a corpus-wide instrument reports which WMO water arm every pool takes - the ADT/Felwood golden sweep is bit-identical: open-world water is provably untouched Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/minimap/interior.rs | 2 + crates/benilla-assets/src/materials.rs | 7 + crates/benilla-assets/src/shaders/liquid.wgsl | 210 ++++++++++++++++-- crates/benilla-assets/src/wmo.rs | 5 + .../examples/wmo_liquid_arms.rs | 160 +++++++++++++ crates/benilla-formats/src/liquid.rs | 7 + .../benilla-formats/src/models/wmo/group.rs | 202 +++++++++++------ crates/benilla-formats/src/models/wmo/root.rs | 22 ++ crates/benilla-formats/tests/wmo_liquid.rs | 147 ++++++++++++ crates/benilla-wmo/src/lib.rs | 47 +++- crates/benilla-world/src/liquid/mod.rs | 38 +++- crates/benilla-world/src/liquid/query.rs | 1 + crates/benilla-world/src/liquid/surface.rs | 171 ++++++++++---- .../src/terrain_stream/spawn/mod.rs | 2 + .../benilla-world/src/wmo_portal/audit/mod.rs | 1 + .../benilla-world/src/wmo_portal/interior.rs | 1 + crates/benilla-world/src/wmo_portal/mod.rs | 1 + 17 files changed, 883 insertions(+), 141 deletions(-) create mode 100644 crates/benilla-formats/examples/wmo_liquid_arms.rs diff --git a/crates/benilla-app/src/minimap/interior.rs b/crates/benilla-app/src/minimap/interior.rs index 5baecd0f..9e5205fc 100644 --- a/crates/benilla-app/src/minimap/interior.rs +++ b/crates/benilla-app/src/minimap/interior.rs @@ -237,6 +237,7 @@ mod tests { group_bounds: Vec::new(), group_footprints: Vec::new(), material_ground_type: Vec::new(), + material_diff_color: Vec::new(), group_footprint_bounds: Vec::new(), group_footprint_grids: Vec::new(), group_light_refs: Vec::new(), @@ -324,6 +325,7 @@ mod tests { group_bounds: Vec::new(), group_footprints: Vec::new(), material_ground_type: Vec::new(), + material_diff_color: Vec::new(), group_footprint_bounds: Vec::new(), group_footprint_grids: Vec::new(), group_light_refs: Vec::new(), diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index df26e6a2..5e59152e 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -514,6 +514,13 @@ pub struct LiquidExt { /// `secondary` Blinn term — water's own exponent, not the shared row-3 terrain shininess. #[uniform(102)] pub kind: Vec4, + /// - `x` = **which renderer** this surface belongs to (`liquid::surface::LiquidPath`): `0` = ADT + /// MCLQ (`ocean0_s.bls`), `1` = WMO exterior (`MapObjExtWater0.bls`), `2` = WMO interior + /// (fixed-function, unlit). The reference has three liquid renderers with genuinely different + /// combines, stage counts and opacity sources; this is which one `liquid.wgsl` runs. + /// - `y`/`z`/`w` reserved. + #[uniform(102)] + pub path: Vec4, /// `x` = reserved (frame 0), `y` = frame count, `z` = scroll flag, `w` = clock enable — /// the shader derives frame index and scroll from `globals.time` (liquid.wgsl `anim_time`); /// nothing mutates this uniform after build. diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index a4128193..08f49941 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -1,25 +1,74 @@ -// Liquid (lake/river/ocean) shader — a port of the reference's `ocean0_s.bls` path to WGSL. -// RE: docs/knowledge/terrain.md "Liquid" (WoW.exe + ocean0_s.bls + apitrace WoW.17 prog 159). +// Liquid shader — a port of the reference's **ADT** water path (`ocean0_s.bls`) to WGSL. // -// Ground truth from the live draw (apitrace WoW.17 program 159, ocean0_s.bls) + WoW.exe RE: +// ## SCOPE — this file implements ONE of the reference's three liquid renderers // -// result.rgb = primary*colorTex.rgb + detailTex.rgb + (secondary + 0.25)*detailTex.a -// result.a = colorTex.a +// This is the ADT MCLQ river/ocean path and nothing else. It is also, today, what benilla runs on +// WMO-embedded liquid, and that is **wrong** — see `liquid/surface.rs`'s `spawn_wmo_liquids` and the +// `wmo_liquid_arms` census. The reference has three: // -// where (VERIFIED from the trace's bound textures + uniforms + the binary's depth ramp): -// * colorTex (unit 0) is the 8×64 depth swatch built by WoW.exe `FUN_0068a830` — a plain **2-endpoint -// linear lerp** of the zone's dedicated `Light.dbc` water rows, RAW (no ×0.711): `water_shallow.rgb` -// = IntBand row 16 (river/lake) / 14 (ocean), `water_deep.rgb` = row 17 / 15. Golden-vector-matched -// to the apitrace swatch ≤1/255 over all 64 rows. (The earlier "reflected sky × 0.711 via -// `FUN_0068c250`" model fingered the WRONG builder — that fills a separate symmetric grey edge -// texture, tex 432, never bound on the water unit. Rows 14–17 were right all along.) -// * detailTex (unit 1) is the animated `lake_a`/`ocean_h` frame: RGB near-black (≈0.014), ALPHA = -// the ripple. So it adds a faint flat lift + an achromatic shimmer on the crests — NOT the body. -// * primary = the vertex's lit colour `clamp(ambient + N·L·sun)`. secondary = the specular sheen; P1 -// keeps it + the verified +0.25 constant. -// * alpha = swatch.a = `mix(water_shallow.w, water_deep.w, V)` over the SAME V as the colour (VERIFIED -// `WoW.exe FUN_0068a830` α = `127+2·row`, apitrace-confirmed). LightParams endpoints: river 0.5→1.0, -// ocean 0.75→1.0. Deeper water = more opaque. +// * **ADT MCLQ river/ocean** — passes `0x6851b0`/`0x685010`. Two texture stages: a static depth-ramp +// on stage 0, the animated sheet on stage 1, combined by the pixel program `ocean0_s.bls`. The +// only liquid path with a depth ramp, and the only one whose vertex carries NO colour. **This +// file.** +// * **WMO MLIQ water** — `0x6b62e0` category 0, split again on the group's `MOGP.flags & 0x48`: +// exterior `0x6b6630` (binds `MapObjExtWater0.bls`), interior `0x6b6420` (no shader, lighting +// forced off). One texture stage, no depth ramp at all — zero references to the ADT ramp globals +// `0xc7fbc0`/`0xc81768`/`0xc7fcd8` anywhere in `[0x6b0000, 0x6c4000)` — and alpha comes from a +// per-vertex authored byte, not from a depth swatch. 164 groups; 90% of them interior. +// * **magma/slime** — `0x6b68f0` (WMO) / `0x68dca0` (ADT), arm-blind, the sheet IS the body. +// +// ## The ADT combine (VERIFIED — the program is an asset, extracted and read) +// +// `Shaders\Pixel\ocean0_s.bls` out of `patch.MPQ`, verbatim ARB: +// +// PARAM c[1] = { { 0.25 } }; +// TEX R0, fragment.texcoord[0], texture[0], 2D; # colorTex = the depth ramp +// TEX R1, fragment.texcoord[1], texture[1], 2D; # detailTex = the animated sheet +// MAD R1.xyz, fragment.color.primary, R0, R1; +// ADD R0.xyz, fragment.color.secondary, c[0].x; +// MAD result.color.xyz, R0, R1.w, R1; +// MOV result.color.w, R0; # R0.w still holds colorTex.a +// +// ⇒ rgb = primary*colorTex.rgb + detailTex.rgb + (secondary + 0.25)*detailTex.a +// alpha = colorTex.a +// +// The `+0.25` is the program's own scalar `PARAM`, not an FFP env colour (`glTexEnvfv` has zero call +// sites image-wide) and not a material. **The formula this file has always carried is right, verbatim +// — but its provenance was fiction**: the header used to cite "apitrace WoW.17 program 159" and a +// `docs/knowledge/terrain.md`, neither of which exists, and wow-re had the program attributed to a +// *character* draw (`Model2.bls` ships 32 ARBfp permutations, none containing `0.25` or +// `fragment.color.secondary`). Corrected and recorded: wow-re `terrain/scratch/water-shading-law.md`. +// +// * colorTex (unit 0) is the depth swatch — a 2-endpoint linear lerp of the zone's dedicated +// `Light.dbc` water rows, RAW (no ×0.711): `water_shallow.rgb` = IntBand row 16 (river/lake) / 14 +// (ocean), `water_deep.rgb` = row 17 / 15. Rebuilt **per world frame**, not baked once: the dirty +// flags `[0xc8117c]`/`[0xc81b70]` clear at `0x680b90`/`0x680b97` and refill via `0x58acd0`, so the +// colour and the opacity track the zone and the clock. (The earlier "reflected sky × 0.711 via +// `FUN_0068c250`" model fingered the WRONG builder — a separate grey edge texture never bound on +// the water unit. Rows 14–17 were right all along.) +// * detailTex (unit 1) is the animated `lake_a`/`ocean_h` frame: RGB near-black, ALPHA = the ripple. +// MEASURED off the shipped BLPs (DXT3, 9 authored mips): lake_a RGB mean 0.0140 and achromatic to +// ±1 LSB, alpha mean 0.21 / p50 51 / p99 255. So it adds a faint flat lift + an achromatic shimmer +// on the crests — NOT the body. The authored mip chain deliberately kills the shimmer with +// distance (per-mip alpha max 255, 255, 255, 136, 68, then flat 51), which is why the sampler's +// mips and 16× aniso are load-bearing rather than a nicety. +// * primary = the vertex's lit colour `clamp(ambient + N·L·sun)`. The ADT liquid vertex has no +// colour element, so `glColor` is the device default `(1,1,1,1)` tracked into material +// ambient+diffuse by `glColorMaterial(FRONT_AND_BACK, AMBIENT_AND_DIFFUSE)`, and `GL_LIGHTING` is +// ON at both water draws (lava explicitly turns it off; water does not). +// * secondary = the specular sheen — see `sun_sheen` for what is verified in it and what is not. +// * alpha = swatch.a over the SAME V as the colour. LightParams endpoints: river 0.5→1.0, ocean +// 0.75→1.0. Deeper water = more opaque. **Open**: wow-re reads the byte-verified ADT water alpha +// as the `0xc7fbc0` LUT's `1.6·(i/63)^8` curve rather than the linear `127+2·row` this file +// applies — a much later-breaking ramp. Not changed here; it is a look change to every ADT water +// surface in the game and it belongs in the same A/B as the WMO arms. +// +// **Both `ocean0_s.bls` and `MapObjExtWater0.bls` are CVar-gated** — `specular` and `pixelShaders` +// (registered `0x6886a0`/`0x688712`) default to `"0"`, and with them off there is no program, no +// specular, the stage-1 combine is a plain ADD, and blend is never set so water draws OPAQUE. Our +// reference install's `Config.wtf` sets both to `"1"`, so every capture and every director comparison +// is against the shader leg — which is the leg we implement. The two do not compose: an active ARB +// program bypasses the texture environment entirely. // // A SINGLE swatch row (V) indexes both the colour and the alpha — they track together. V is `clamp(byte/42)` // for river/lake (VERIFIED `c81768`, saturates ~5 yd → the channel middle hits the deep teal row) and @@ -49,6 +98,9 @@ struct LiquidParams { // x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess. kind: vec4, + // x = WHICH RENDERER (see `LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior. + // y/z/w reserved. + path: vec4, // x = reserved (frame 0); y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7 // WMO magma/slime lane — the reference's animated stage-0 texture matrix; see // `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w = the clock enable (0 on a @@ -89,6 +141,10 @@ struct LiquidVsOut { // the Blinn highlight in its FFP vertex shader and interpolates it across the coarse water mesh). // The fragment shader uses this interpolated value directly. @location(4) secondary_vtx: vec3, + // The mesh's vertex COLOUR, which on a WMO INTERIOR pool carries its `MOMT.diffColor` body + // colour — the reference's own interior water vertex carries a colour dword for exactly this. + // White on every other lane (and on any mesh with no colour attribute), where nothing reads it. + @location(5) vcolor: vec4, } // Sun sheen (`secondary`): a Blinn highlight of the sun on the flat water surface — the glint that's @@ -98,10 +154,31 @@ struct LiquidVsOut { fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { let n = normalize(world_normal); let to_light = -normalize(wow_light.light_sun.xyz); + // LOCAL viewer, not the infinite-viewer constant: the reference sets + // `GL_LIGHT_MODEL_LOCAL_VIEWER = 1` at `0x59cf89`, so `H = normalize(L + normalize(eye − vertex))` + // and the eye vector is recomputed per vertex (VERIFIED wow-re `water-shading-law.md`). It is + // the difference between a highlight and a flood: with the infinite viewer `N·H` is very nearly + // CONSTANT over a flat water plane, so at high sun the whole sheet saturates at once instead of + // carrying a glint that moves with the camera. let to_view = normalize(view.world_position.xyz - world_pos); let half_v = normalize(to_light + to_view); let ndoth = max(dot(n, half_v), 0.0); - // Shininess is WATER's own (`w.kind.w`), not the shared row-3 terrain exponent. + // The reference's specular is additionally gated on `N·L > 0` (the fixed-function rule: a light + // behind the surface contributes no specular). **Deliberately not ported**, because it cannot + // fire here: `DayNight::SetDirection` holds the LIGHTING sun's azimuth constant and wobbles its + // elevation only between +20° and +37° — it is always above the horizon, night being the colours + // going dark rather than the sun setting (`lighting::daynight::sun_direction`, and the separate + // *visible* sun is the one that rises and sets). Against a liquid surface's flat up normal `N·L` + // is therefore positive at every minute of the day, so the gate is a branch that can only ever + // take one arm. Named rather than added: it is real, it is verified, and it is inapplicable. + // + // Shininess is WATER's own (`w.kind.w`), not the shared row-3 terrain exponent. Material + // specular is white (`SetRenderState(3, 0xffffffff)`) and the exponent is 6.0 (`[0x8102e8]`), + // both VERIFIED — so `light_spec.rgb` is the whole scale, and it is the one input here that is + // still INFERRED: wow-re pinned the mechanism (`CGLight+0x48` → `collector+0x6c` → `0x589d80(0)` + // → `glLightfv(GL_SPECULAR)`) but not the number, and reads it as a warm ≈(1.0, 0.91, 0.76) + // against the row-9 feed we use. Left on row 9 until that lands rather than swapped for an + // estimate. return wow_light.light_spec.rgb * pow(ndoth, max(w.kind.w, 1.0)); } @@ -184,6 +261,11 @@ fn vertex(in: Vertex) -> LiquidVsOut { 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; +#ifdef VERTEX_COLORS + out.vcolor = in.color; +#else + out.vcolor = vec4(1.0); +#endif // Per-vertex MCLQ depth (0..1) packed into UV1.x; drives the opacity ramp. out.depth = in.uv_b.x; // The faithful per-vertex sun sheen — interpolated across the coarse mesh by the fragment stage. @@ -240,6 +322,94 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { } let water_tint = mix(shallow.rgb, deep.rgb, depth); + // ---- The two WMO water arms ------------------------------------------------------------ + // + // Neither is the ADT combine below. `0x6b62e0`'s category 0 splits on the owning group's + // `MOGP.flags & 0x48`, and both halves bind ONE texture (the animated sheet) with no depth ramp + // anywhere — there is not a single reference to the ADT ramp globals `0xc7fbc0`/`0xc81768`/ + // `0xc7fcd8` in all of `[0x6b0000, 0x6c4000)`. Opacity on both is the per-vertex authored byte + // through the zone's linear alpha ramp, which `in.depth` carries and this lerp reproduces + // (`wmo_water_alpha_v`). VERIFIED wow-re `terrain/scratch/water-shading-law.md` §11. + let vtx_alpha = mix(shallow.w, deep.w, depth); + if (w.path.x > 1.5) { + // ---- WMO INTERIOR (`0x6b6420`) — 134 of the game's 164 water groups, Blackfathom included. + // + // Fixed-function, always: the kernel body contains no `mov ecx,0x3f` at all (a positive + // finding — the same scan on the exterior kernel finds two), and `[0xc9607c]`, the + // specular/pixelShaders gate, is never read on this path. So it ignores those CVars, runs + // with lighting OFF (`0x0e = 0`) and fog ON (`0x0f = 1`), and its whole output is the + // combine preset `(0x1f, 3)` over one texture stage: + // + // rgb = clamp(Cf + Ct) alpha = clamp(Af + At) + // + // `Cf` is the pool's `MOMT[materialId].diffColor` taken RAW — baked into the mesh's vertex + // colour, which is where the reference's own 6-float vertex carries it. NO sun term and no + // sheen: that vertex has no normal to compute one from. + // + // The ALPHA op is the part worth stating. Preset 3 is `GL_ADD` on both channels via + // `GL_COMBINE` (`COMBINE_ALPHA = GL_ADD` @`0x85c2fc`, operands left at the GL default), NOT + // the legacy `GL_TEXTURE_ENV_MODE = GL_ADD` whose alpha would be `Af · At`. The client takes + // the COMBINE path because `GL_ARB_texture_env_combine` is GL 1.3 core. So the ripple **adds** + // to the pool's opacity instead of multiplying it away — a pool is at its authored opacity in + // the troughs and saturates opaque on the crests, which is the opposite of what the legacy + // reading would have drawn. + let body = clamp(in.vcolor.rgb + detail.rgb, vec3(0.0), vec3(1.0)); + return vec4( + apply_fog(body, in.world_position.xyz), + clamp(vtx_alpha + detail.a, 0.0, 1.0), + ); + } + if (w.path.x > 0.5) { + // ---- WMO EXTERIOR (`0x6b6630`) — Stormwind's canals and fountains. + // + // This arm DOES bind a pixel program, `Shaders\Pixel\MapObjExtWater0.bls` (bound at + // `0x6b6654`, unbound `0x6b689c`), under the `[0xc9607c]` specular/pixelShaders gate. Both + // CVars default to "0", but our reference install's `Config.wtf` sets both to "1", so the + // shader leg is what every director comparison is against and it is the leg we implement. + // Decoded verbatim from the asset: + // + // rgb = primary.rgb + detail.rgb + secondary·detail.a alpha = primary.a + // + // **No `+0.25`.** That constant is the ADT program's own `PARAM` and has no counterpart + // here, so carrying it over — which is what we did — added a flat achromatic lift to every + // canal pixel at all times, sun or no sun. Against the sheet's real texel distribution that + // is ~3.5x the ripple contrast off the glint, and it is why the reference's canal shimmers + // only where the sun is while ours sparkled everywhere. + // + // `primary` is FFP-lit over a constant up-normal with the vertex colour tracked into + // ambient+diffuse (`glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)`), so the band + // is the MATERIAL colour and `primary = band · clamp(ambient + diffuse·max(N·L, 0))`. + // Lighting really is on here: neither this kernel nor its dispatch touches render-state id + // `0x0e`, and the control that such a call would be findable is the interior kernel, which + // does exactly that at `0x6b65bf`. + // + // The band is a SINGLE one — there is no bathymetry to lerp by — and it is the **deep** river + // row, `LightIntBand` sub-17, i.e. `water_river[1]`. Read as a hard immediate + // (`0x6b66be add edi, 0xec`), so nibbles 0, 4 and 8 all take it; exterior ocean cannot arise + // (category 2 falls to a bare epilogue with no draw). + // + // **Sub-17, not sub-16, and the distinction cost a round trip.** A DayNight band *slot* is not + // a `LightIntBand` *sub*: `0x6d64d0` displaces sub-8 out to record `+0x4c`, so `sub = slot + 1` + // across slots 8–16, and the kernel's slot 16 is sub-17. The shipped data says the same thing + // twice over — across all 367 LightParams rows carrying river bands, sub-16 is browns, olives + // and muddy yellows (G > B in 71%: the shallow colour of water over a riverbed) while sub-17 is + // blues and teals. At Stormwind sub-16 is `(79, 93, 20)`, which renders the canals olive-green; + // sub-17 is `(51, 82, 85)`. The ADT ramp runs sub-16 → sub-17 across its 64 texels; WMO + // exterior water takes the deep end alone, flat. + let n_ext = normalize(in.world_normal); + let to_light_ext = -normalize(wow_light.light_sun.xyz); + let primary_ext = clamp( + wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb + * max(dot(n_ext, to_light_ext), 0.0), + vec3(0.0), + vec3(1.0), + ) * deep.rgb; + let rgb_ext = primary_ext + detail.rgb + in.secondary_vtx * detail.a; + // `result.color.w = fragment.color.primary` — the vertex alpha ALONE. The bound program + // bypasses the texture environment entirely, so the interior arm's `+ At` does not apply here. + return vec4(apply_fog(rgb_ext, in.world_position.xyz), vtx_alpha); + } + // Body colour: lit vertex colour × the depth-lerped water-row swatch colour (`primary·colorTex`). let n = normalize(in.world_normal); let to_light = -normalize(wow_light.light_sun.xyz); diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 5ac38f2d..2a524585 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -173,6 +173,10 @@ pub struct WmoModel { /// `FootprintTris::mopy_material` resolves to. Shared by every group (MOMT lives on the root), /// and the tail of the footstep chain's WMO leg (decision 1161). pub material_ground_type: Vec, + /// Root MOMT `diffColor` per material, RGB 0..1 — the body colour an **interior** MLIQ pool + /// takes, indexed by its `LiquidMesh::material_id`. MOMT lives in the root, so a group file + /// cannot resolve its own pool's colour; the spawner does it from here. + pub material_diff_color: Vec<[f32; 3]>, /// Per-group AABB of the faces [`Self::group_footprints`] actually references (parallel; /// `None` = no footprint faces), computed at load from the triangles themselves — the broad /// phase for the footprint down-ray, the same exact-cull argument as @@ -729,6 +733,7 @@ impl AssetLoader for WmoModelLoader { doodad_groups, group_footprints, material_ground_type: root.material_ground_types(), + material_diff_color: root.material_diff_colors(), group_footprint_bounds, group_footprint_grids, group_light_refs, diff --git a/crates/benilla-formats/examples/wmo_liquid_arms.rs b/crates/benilla-formats/examples/wmo_liquid_arms.rs new file mode 100644 index 00000000..31d1bd95 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_liquid_arms.rs @@ -0,0 +1,160 @@ +//! **Which WMO water ARM each pool takes**, over the whole shipped corpus: +//! `cargo run -p benilla-formats --example wmo_liquid_arms`. +//! +//! The reference does not have *one* WMO water renderer. `0x6b62e0` dispatches the type nibble to a +//! category, and category 0 (river/water — nibbles 0/4/8) then splits again on the owning group's +//! `MOGP.flags & 0x48`: +//! +//! * `& 0x48 != 0` → **EXTERIOR**, kernel `0x6b6630`. A 9-float (`0x24`) vertex — position, an up +//! normal, a colour dword, `u,v` — and the kernel binds the pixel program +//! `Shaders\Pixel\MapObjExtWater0.bls` at `0x6b6654` (`GxRsSet(0x3f, …)`, gated on `[0xc9607c]`). +//! * `& 0x48 == 0` → **INTERIOR**, kernel `0x6b6420`. A 6-float (`0x18`) vertex — position, a colour +//! dword, `u,v` — with **no normal at all**, the colour coming from the map object's own light +//! records (`CMapObj+0x1d8`). +//! +//! The arm split is **category 0 only**. Magma and slime (nibbles 2/3/6/7) go to category 1 and one +//! shared kernel `0x6b68f0` whatever their group's flags say, so their rows below are context, not +//! two arms — they are reported because the same building often holds both, and because a reader +//! comparing group counts would otherwise assume the split is universal. +//! +//! Neither water arm is the **ADT** path (`0x6851b0`/`0x685010` → `ocean0_s.bls`), which is the only +//! one with a depth-ramp texture on stage 0 and the only one whose vertex carries no colour. +//! +//! benilla renders all three through one material and one shader — the ADT one. This census says how +//! much content each arm actually owns, so the divergence can be sized instead of guessed: the two +//! reported sites (B136 Blackfathom Deeps, and Stormwind's canals) land on opposite arms, and +//! neither is the path we implement. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use std::collections::BTreeMap; + +use benilla_formats::{wmo_group_header, wmo_group_liquid_mesh, Chain}; + +/// MOGP `flags & 0x48` — the reference's interior test (`0x6b3f90`, and the liquid dispatch's own +/// `[owner+0x10] & 0x48` at `0x6b62e0`). Zero ⇒ interior. +const EXTERIOR_MASK: u32 = 0x48; + +#[derive(Default, Clone, Copy)] +struct Tally { + groups: u32, + wet_tiles: u32, +} + +fn main() -> anyhow::Result<()> { + let data = benilla_formats::wow_data() + .ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?; + let reader = Chain::open(&data)?; + + // Group files are `_NNN.wmo`; the roots are what the chain lists, so walk the listing + // once and keep the group files directly — a root's group count is not knowable without parsing + // it, and the names are self-describing. + let mut by_arm: BTreeMap<(bool, String), Tally> = BTreeMap::new(); + // Per building, so a site can be looked up rather than re-derived. + let mut buildings: BTreeMap = BTreeMap::new(); + + for entry in reader.list()? { + let name = entry.name; + let lower = name.to_ascii_lowercase(); + if !lower.ends_with(".wmo") { + continue; + } + // A group file's stem ends `_NNN`; a root's does not. + let stem = lower.trim_end_matches(".wmo"); + let is_group = stem + .rsplit('_') + .next() + .is_some_and(|t| t.len() == 3 && t.bytes().all(|b| b.is_ascii_digit())); + if !is_group { + continue; + } + let Ok(bytes) = reader.read(&name) else { + continue; + }; + let Some(mesh) = wmo_group_liquid_mesh(&bytes) else { + continue; + }; + let Some(header) = wmo_group_header(&bytes) else { + continue; + }; + let interior = header.flags & EXTERIOR_MASK == 0; + let wet = mesh.wet.iter().filter(|w| **w).count() as u32; + + let t = by_arm + .entry((interior, format!("{:?}", mesh.kind))) + .or_default(); + t.groups += 1; + t.wet_tiles += wet; + + let root = stem + .rsplit_once('_') + .map_or(stem, |(r, _)| r) + .rsplit(['\\', '/']) + .next() + .unwrap_or(stem) + .to_string(); + if !mesh.kind.is_fullbright() { + let e = buildings.entry(root).or_default(); + if interior { + e.0 += 1; + } else { + e.1 += 1; + } + } + } + + println!("WMO liquid groups by ARM and kind (interior = MOGP.flags & 0x48 == 0)\n"); + println!( + "{:<10} {:<8} {:>8} {:>11} kernel", + "arm", "kind", "groups", "wet tiles" + ); + let (mut int_g, mut ext_g, mut int_t, mut ext_t) = (0, 0, 0, 0); + for ((interior, kind), t) in &by_arm { + // Only category 0 splits on the flag; the fullbright kinds share `0x6b68f0` either way. + let water = !matches!(kind.as_str(), "Magma" | "Slime"); + println!( + "{:<10} {:<8} {:>8} {:>11} {}", + if *interior { "INTERIOR" } else { "EXTERIOR" }, + kind, + t.groups, + t.wet_tiles, + match (water, interior) { + (true, true) => "0x6b6420 (interior water)", + (true, false) => "0x6b6630 (exterior water, MapObjExtWater0.bls)", + (false, _) => "0x6b68f0 (magma/slime — arm-blind)", + } + ); + if !water { + continue; // the totals below are the WATER arms; fullbright takes neither + } + if *interior { + int_g += t.groups; + int_t += t.wet_tiles; + } else { + ext_g += t.groups; + ext_t += t.wet_tiles; + } + } + println!( + "\nWATER arms — INTERIOR {int_g} groups / {int_t} wet tiles EXTERIOR {ext_g} groups / {ext_t} wet tiles" + ); + + // Buildings carrying WATER on both arms: a single-shader renderer cannot be right for the whole + // building. (Undercity holds both flags too, but its liquid is slime, which takes neither arm — + // hence the water-only filter.) + let mixed: Vec<_> = buildings + .iter() + .filter(|(_, (i, e))| *i > 0 && *e > 0) + .collect(); + println!("\nbuildings carrying WATER on BOTH arms: {}", mixed.len()); + for (root, (i, e)) in mixed.iter().take(20) { + println!(" {root:<40} interior {i:>3} exterior {e:>3}"); + } + + for site in ["stormwind", "blackfathom_instance"] { + if let Some((i, e)) = buildings.get(site) { + println!("\n{site} water groups: interior {i}, exterior {e}"); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs index 2911e568..14c913c2 100644 --- a/crates/benilla-formats/src/liquid.rs +++ b/crates/benilla-formats/src/liquid.rs @@ -168,6 +168,12 @@ pub struct LiquidMesh { /// beside `kind` because the render kind collapses the river speeds (nibbles 0 and 4 both /// draw `lake_a`) that the sound table splits (RiverStill 1111 vs RiverSlow 1112). pub sound_nibble: u8, + /// **WMO only** — the MLIQ header's `materialId`, i.e. this pool's index into the owning root's + /// MOMT array. The reference's INTERIOR water kernel takes its whole body colour from + /// `MOMT[materialId].diffColor` (see `benilla_wmo::Material::diff_color`), so this is how a pool + /// finds its own colour; the group file alone cannot resolve it, because MOMT lives in the root. + /// `None` on the ADT path, which has no such index and no such lookup. + pub material_id: Option, /// The texture set / render path this surface uses. pub kind: LiquidKind, } @@ -324,6 +330,7 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option< depths, indices, sound_nibble, + material_id: None, // ADT liquid has no MOMT to index kind, }) } diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index 6eb18336..a1db6b13 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -17,41 +17,59 @@ use crate::models::{remap_submesh, ModelBlend, RenderSubmesh, WmoBatchClass}; /// path). Either kernel places grid vertex `(i, j)` at `base + (i·STEP, j·STEP)`. const MLIQ_CELL_STEP: f32 = f32::from_bits(0x4085_5555); -/// Yards per water-texture repeat: the per-cell UV step is ¼, so one repeat spans `4·MLIQ_CELL_STEP` -/// ≈ 16.67 yd. We anchor each vertex's UV to its **model-space position** over this period so the -/// texture is one continuous field across all of a WMO's MLIQ surfaces — no per-surface seam. +/// Yards per water-texture repeat: **one repeat per grid cell**, `MLIQ_CELL_STEP` ≈ 4.167 yd. /// -/// **The mechanism this was built on has since been REFUTED, and the number is now known to be 4× -/// too large** (wow-re `liquid-uv-scroll-law.md`, a six-agent §5 that supersedes -/// `rf-mliq-liquid-uv-texgen.md`). That earlier note claimed the reference reaches seamlessness with -/// a world-anchored `GL_TEXTURE` matrix pushed at transform index 8. It does not: index 8 is the -/// **world/modelview** transform (`0x59ca60`'s first block feeds slots 10 and 8 to `0x5a0090` → -/// `glMatrixMode(GL_MODELVIEW)`; only 0–7 are texture stages), and the water arms push no texture -/// matrix at all — the sole `0x58b210` call in `[0x6b6000,0x6b7000)` is the magma/slime scroll. +/// The reference's water kernel `0x6b6630` writes `u = (float)i`, `v = (float)j` — the raw integer +/// tile indices, from loop counters that both start at a literal 0 (VERIFIED wow-re +/// `liquid-uv-scroll-law.md`, a six-agent §5). One repeat per cell means every surface boundary +/// lands on an exact repeat boundary, so a neighbour restarting at 0 is invisible: the seam-free +/// look is the *scale*. /// -/// What the reference actually does is simpler: `0x6b6630` writes `u = (float)i`, `v = (float)j`, -/// the **raw integer tile indices**, from loop counters that both start at a literal 0. One repeat -/// per grid cell means every surface boundary lands on an exact repeat boundary, so a neighbour -/// restarting at 0 is invisible — the seam-free look is the *scale*, not a matrix. Our `¼` is a -/// quarter of that, i.e. our WMO water texture is four times too big, and the model-space anchoring -/// is machinery we do not need. +/// **This used to be `4 ×` that**, i.e. every WMO water surface in the game carried a texture four +/// times too large, on a mechanism that was later refuted — the earlier note claimed seamlessness +/// came from a world-anchored `GL_TEXTURE` matrix at transform index 8, but index 8 is the +/// world/modelview transform and the water arms push no texture matrix at all. Decision 1271 carried +/// the correction and deliberately did not apply it, the look being the director's call; B136 and +/// the director's "Stormwind water seems too rough on the surface" are that call. The 4× cost more +/// than size: it is exactly **two mip levels**, so the surface sat on mip 0 across the whole near +/// field and never reached the authored chain that drops the sheet's peak ripple alpha 255 → 136 → +/// 68 → a flat 51. Measured at the Stormwind canal before the fix, near water and mid-distance water +/// had the same ripple contrast (σ 15.9 vs 15.2) — the ripple was not attenuating with distance at +/// all. /// -/// **NOT changed here on purpose.** Correcting it is a 4× visual change to every WMO water surface -/// in the game (Stormwind's canals and fountains, every dungeon pool) and the look is the director's -/// call, not a byte-fidelity autopilot's (`method.md` §9 / the contract §7). Decision 1271 carries the -/// evidence and the proposal; this constant stands until they have looked at the A/B. +/// We still anchor each vertex's UV to its **model-space position** rather than emitting the raw +/// `(i, j)`. At one repeat per cell the two agree wherever surfaces are cell-aligned (they differ by +/// a whole number of repeats, which `Repeat` wrapping erases), and where they are *not* aligned the +/// position anchor is the one that does not seam. /// -/// Magma/slime have a *separate* correction pending in the same record: the reference reads their -/// `st` from the MLIQ vertex's authored `int16` pair × `1/256` (`movsx`, `0x6b6993`), which we do not -/// parse at all and generate over this period instead. -const MLIQ_UV_PERIOD: f32 = 4.0 * MLIQ_CELL_STEP; +/// Magma/slime have a separate correction still pending: the reference reads their `st` from the +/// MLIQ vertex's authored `int16` pair × `1/256` (`movsx`, `0x6b6993`), which we do not parse at all +/// and generate over this period instead. +const MLIQ_UV_PERIOD: f32 = MLIQ_CELL_STEP; -/// The swatch-coord `V` fed to every WMO **water/ocean** vertex. WMO liquid verts carry flow bytes, -/// not a depth byte (unlike MCLQ), so there is no per-vertex bathymetry to ramp; the reference's WMO -/// water alpha comes from a separate shore LUT (`0xc7fbc0`, wow-re — not yet RE'd for this path). We -/// render the flat surface at the DEEP endpoint: opaque, deep-tinted canal water (the vanilla look), -/// which the zone's `Light.dbc` rows still colour per-zone. Tunable; the exact shore LUT is deferred. -const WMO_WATER_DEPTH_V: f32 = 1.0; +/// The **opacity ramp coordinate** fed to a WMO water vertex: its authored byte, normalised. +/// +/// WMO liquid carries no bathymetry, so there is nothing to ramp by depth the way ADT MCLQ water is +/// ramped — and the reference does not try. It indexes a 256-entry alpha ramp with the vertex's own +/// byte 0 (`0x6b64c6 movzx`), and that ramp is a plain **linear** lerp between the zone's river +/// shallow and deep alphas: `LUT[i] = (c0·256 + i·(c1 − c0)) >> 8`, rebuilt per frame, which for the +/// static-init endpoints (0.5, 1.0) runs 127 → 191 → 254. **Not** the ADT path's +/// `1.6·(i/63)^8` curve; the two systems do not even share a delivery mechanism (the ADT ramp is +/// uploaded as a GPU texture and never CPU-indexed). VERIFIED wow-re +/// `terrain/scratch/water-shading-law.md` §11 — including the refutation of a first reading that had +/// the step sign-extended as a byte and the ramp *decreasing*. +/// +/// So `byte / 255` is exactly the lerp coordinate our shader already applies to the shallow/deep +/// alpha pair off the shared light buffer, and passing it through the existing per-vertex channel +/// reproduces the reference's LUT without a second mechanism. +/// +/// This replaces a hard `1.0` — the deep, fully-opaque endpoint, pinned for every WMO pool in the +/// game because we believed those bytes were flow data. That pin is why Blackfathom's Pool of +/// Ask'ar rendered as a sheet you cannot see through where the real client shows 9–15 yd of bottom +/// (B136), and it was documented as "tunable; the exact shore LUT is deferred". +fn wmo_water_alpha_v(opacity_byte: u8) -> f32 { + f32::from(opacity_byte) / 255.0 +} /// MOGP `groupLiquid`'s sentinel: **this group holds no liquid of its own**. Any other value is the /// whole-group submersion override — see [`WmoGroupHeader::group_liquid`]. @@ -306,39 +324,17 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option Option Option Vec<[f32; 3]> { + match &self.parsed { + ParsedWmo::Root(r) => r + .materials + .iter() + .map(|m| { + let [red, green, blue] = m.diff_color; + [ + f32::from(red) / 255.0, + f32::from(green) / 255.0, + f32::from(blue) / 255.0, + ] + }) + .collect(), + _ => Vec::new(), + } + } + /// The root's portal graph (MOPV/MOPT/MOPR) — the data that drives per-group visibility culling /// (see [`WmoPortals`]). Empty for WMOs with no portals (most single-group props). pub fn portals(&self) -> &WmoPortals { diff --git a/crates/benilla-formats/tests/wmo_liquid.rs b/crates/benilla-formats/tests/wmo_liquid.rs index 66147dab..8515a89f 100644 --- a/crates/benilla-formats/tests/wmo_liquid.rs +++ b/crates/benilla-formats/tests/wmo_liquid.rs @@ -46,3 +46,150 @@ fn stormwind_canal_group_builds_still_water() { "a masonry group carries no liquid" ); } + +/// A hole corner's authored height is not a height, and it must not reach the mesh AABB. +/// +/// MLIQ carries a full `xverts × yverts` height array whether or not a tile is wet, and the shipped +/// files leave the hole interiors at a literal `0.0`. Emitted verbatim those are invisible in the +/// draw — no triangle references them — but they stretch the Bevy mesh AABB from the flat sheet all +/// the way to model z 0. Measured on the two files below: Blackfathom's Pool of Ask'ar had 186 of +/// 868 such vertices and an AABB spanning 58.28 yd around a sheet with **zero** vertical extent; +/// Stormwind's canal g099 had 36 of 108 and spanned 6.48 yd. The builder now fills every +/// undrawn vertex with the lowest drawn height, so both collapse to flat. +#[test] +fn hole_corner_heights_never_reach_the_mesh_bounds() { + let data = benilla_formats::wow_data_or_skip!(); + let reader = Chain::open(&data).expect("open vanilla patch chain"); + + for (path, surface_z) in [ + ( + "World\\wmo\\dungeon\\kl_blackfathom\\blackfathom_instance_007.wmo", + -58.283_04_f32, + ), + ( + "World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind_099.wmo", + -6.482_947_f32, + ), + ] { + let group = reader + .read(path) + .unwrap_or_else(|e| panic!("read {path}: {e}")); + let mesh = wmo_group_liquid_mesh(&group).unwrap_or_else(|| panic!("{path} carries water")); + + // Both pools are dead flat, so EVERY emitted vertex — drawn or not — must sit on the sheet. + let (lo, hi) = mesh + .positions + .iter() + .map(|p| p[2]) + .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), z| { + (lo.min(z), hi.max(z)) + }); + assert!( + (lo - surface_z).abs() < 1e-3 && (hi - surface_z).abs() < 1e-3, + "{path}: mesh z spans [{lo}..{hi}], expected the flat sheet at {surface_z}" + ); + + // …and the drawn geometry is unchanged by the substitution: the sheet is still the sheet. + for &i in &mesh.indices { + let z = mesh.positions[i as usize][2]; + assert!( + (z - surface_z).abs() < 1e-3, + "{path}: drawn vertex {i} at z {z}, expected {surface_z}" + ); + } + } +} + +/// The two reported sites take **different** water arms, and each carries the inputs its arm reads. +/// +/// `0x6b62e0`'s category 0 splits on the owning group's `MOGP.flags & 0x48`, and the two halves are +/// genuinely different renderers — the exterior one binds `MapObjExtWater0.bls` and lights a vertex +/// normal, the interior one is fixed-function, unlit, and takes its whole body colour from +/// `MOMT[materialId].diffColor`. B136 (Blackfathom) and the director's "Stormwind water seems too +/// rough" land on opposite arms, which is the whole reason one shader could not be right for both. +/// +/// Pins the two inputs a wrong offset would silently corrupt: the pool's `materialId` (so the body +/// colour is looked up in the right MOMT slot) and the per-vertex opacity byte (which we read as +/// nothing at all until 2026-08-20, rendering every WMO pool at a pinned, fully-opaque 1.0). +#[test] +fn the_two_water_arms_carry_their_own_inputs() { + let data = benilla_formats::wow_data_or_skip!(); + let reader = Chain::open(&data).expect("open vanilla patch chain"); + + // (group file, is the group interior?, the pool's MLIQ materialId) + for (path, interior, material_id) in [ + ( + "World\\wmo\\dungeon\\kl_blackfathom\\blackfathom_instance_007.wmo", + true, + 27_u16, + ), + ( + "World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind_099.wmo", + false, + 115_u16, + ), + ] { + let bytes = reader + .read(path) + .unwrap_or_else(|e| panic!("read {path}: {e}")); + let header = benilla_formats::wmo_group_header(&bytes) + .unwrap_or_else(|| panic!("{path} parses a MOGP header")); + assert_eq!( + header.flags & 0x48 == 0, + interior, + "{path}: MOGP flags {:#010x} put it on the wrong water arm", + header.flags + ); + + let mesh = wmo_group_liquid_mesh(&bytes).unwrap_or_else(|| panic!("{path} carries water")); + assert_eq!( + mesh.material_id, + Some(material_id), + "{path}: the pool must name its own MOMT slot — an interior arm reads its body colour \ + from exactly this index" + ); + + // The opacity channel is per-vertex and really varies: a constant here would mean we were + // reading a byte nothing authors (or the wrong byte). Blackfathom's pool spans 202 distinct + // values; Stormwind's canals are 91% a single one, so only the range is asserted in common. + let (lo, hi) = mesh + .depths + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v))); + assert!( + (0.0..=1.0).contains(&lo) && (0.0..=1.0).contains(&hi), + "{path}: opacity V out of range [{lo}..{hi}]" + ); + assert!( + hi > 0.0, + "{path}: every vertex opacity is zero — the byte is not being read" + ); + } +} + +/// One texture repeat per grid cell — the WMO water UV scale, and the fix for "too rough". +/// +/// The reference's `0x6b6630` writes `u = (float)i, v = (float)j`, raw tile indices from loop +/// counters that start at a literal 0, so one repeat spans one 4.167 yd cell. We had a quarter of +/// that: a texture 4x too large, which is exactly two mip levels, so the near field never reached +/// the authored chain that flattens the ripple with distance. +#[test] +fn wmo_water_repeats_once_per_grid_cell() { + let data = benilla_formats::wow_data_or_skip!(); + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let g099 = reader + .read("World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind_099.wmo") + .expect("read Stormwind_099.wmo"); + let mesh = wmo_group_liquid_mesh(&g099).expect("group 099 carries water"); + // Adjacent columns of the 12-wide grid are one cell apart, so exactly one repeat apart. + let du = mesh.uvs[1][0] - mesh.uvs[0][0]; + assert!( + (du - 1.0).abs() < 1e-4, + "one repeat per cell: adjacent vertices differ by {du} in u, expected 1.0" + ); + let dv = mesh.uvs[12][1] - mesh.uvs[0][1]; + assert!( + (dv - 1.0).abs() < 1e-4, + "one repeat per cell: adjacent rows differ by {dv} in v, expected 1.0" + ); +} diff --git a/crates/benilla-wmo/src/lib.rs b/crates/benilla-wmo/src/lib.rs index 1ba1e251..b46a9929 100644 --- a/crates/benilla-wmo/src/lib.rs +++ b/crates/benilla-wmo/src/lib.rs @@ -46,6 +46,18 @@ pub struct Material { /// EMISSION on lit batches (`flags & 0x10` — the windows-glow-at-night mechanism). Meaningless /// (usually zero) when the SIDN flag is clear. pub sidn_rgb: [u8; 3], + /// **MOMT+0x1C — `diffColor`**, the material's authored diffuse colour (BGRA on disk, RGB here). + /// + /// Read for exactly one consumer: it is the **body colour of an interior WMO liquid pool**. The + /// reference's interior water kernel `0x6b6420` runs with lighting forced off and no pixel + /// shader, and its whole RGB is this dword taken raw — `Cf = MOMT[MLIQ.materialId].diffColor`, + /// combined with the animated sheet as `clamp(Cf + Ct)`. The pool's material's own alpha at + /// `+0x1f` is discarded; opacity comes from the per-vertex LUT instead. + /// + /// The offset is anchored on both sides by fields this repo verified independently of water: + /// `+0x18` texture 2 and `+0x20` [`Self::ground_type`]. Stride 0x40, base `[CMapObj+0x1d8]` + /// (`0x6c3ace` + `0x6c3ad7 shr edx,6`). VERIFIED wow-re `terrain/scratch/water-shading-law.md`. + pub diff_color: [u8; 3], /// **MOMT+0x20 — the surface's `TerrainType.dbc` id**, i.e. what walking on this material /// sounds like. The footstep chain's WMO leg: the client's down-ray arbitrates a terrain probe /// against a WMO probe and the nearer surface supplies the terrain type, so a building's own @@ -124,8 +136,33 @@ pub struct WmoLiquid { /// selects its texture from the tile-flag type; kept for completeness). pub material_id: u16, /// Per-vertex absolute liquid height (WMO-local Z), row-major `j·xverts + i`, `xverts·yverts` long. - /// (For water verts the leading 4 bytes are flow data, not depth; the height is the last f32.) + /// The MLIQ vertex is 8 bytes and the height is its trailing `f32`; what the leading 4 bytes mean + /// is per liquid type — see [`Self::opacity`] for the water reading. pub heights: Vec, + /// Per-vertex **opacity index** — the water vertex's byte 0, row-major beside [`Self::heights`]. + /// + /// WMO liquid carries no bathymetry, so nothing here can be ramped by depth the way ADT MCLQ's + /// water is; this byte **is** the authored opacity. The reference indexes a 256-entry alpha ramp + /// (`[0xca7f10]`, rebuilt per frame from the zone's river shallow/deep alpha pair) with it and + /// uses the result as the vertex alpha — the water arms bind no depth-ramp texture at all + /// (VERIFIED wow-re `terrain/scratch/water-shading-law.md`; zero references to the ADT ramp + /// globals `0xc7fbc0`/`0xc81768`/`0xc7fcd8` anywhere in `[0x6b0000, 0x6c4000)`). + /// + /// It was parsed as nothing for a long time — the field it replaced was documented as "flow + /// data" — which is why every WMO pool in benilla rendered at one pinned opacity. + /// + /// On the **magma/slime** arm the same four bytes are an authored `int16` `(s, t)` UV pair + /// instead, so byte 0 there is half a texture coordinate and means nothing as an opacity. The + /// consumer picks by liquid type; this parser just carries the byte. + /// + /// That split is also what corroborates the reading against the shipped data, independently of + /// the disassembly. Censused over every drawn liquid vertex in the corpus: on the **water** arms + /// the byte is structured — Stormwind's canals are **91.2% a single value (86)** across 3492 + /// vertices with only 41 distinct values, exactly the shape of an authored per-surface opacity — + /// while on **magma/slime** it is near-uniform noise over all 256 values (each ≈0.7–1.4%), which + /// is what half of a texture coordinate should look like. A byte that is a constant on one arm + /// and white noise on the other is not being read at the wrong offset. + pub opacity: Vec, /// Per-tile flag bytes, row-major `j·xtiles + i`, `xtiles·ytiles` long. Low nibble = liquid type; /// `0xf` = hole (skip the tile). High bits are fishable (`0x40`) / shared (`0x80`). pub tile_flags: Vec, @@ -207,11 +244,13 @@ fn parse_root(b: &[u8]) -> Result { for m in p.chunks_exact(64) { // CImVector — BGRA bytes on disk, so the LE u32 reads B | G<<8 | R<<16 | A<<24. let sidn = m.u32_at(0x10).ok_or(Error::Truncated("MOMT"))?; + let diff = m.u32_at(0x1c).ok_or(Error::Truncated("MOMT"))?; root.materials.push(Material { flags: m.u32_at(0).ok_or(Error::Truncated("MOMT"))?, blend_mode: m.u32_at(8).ok_or(Error::Truncated("MOMT"))?, texture_1: m.u32_at(12).ok_or(Error::Truncated("MOMT"))?, sidn_rgb: [(sidn >> 16) as u8, (sidn >> 8) as u8, sidn as u8], + diff_color: [(diff >> 16) as u8, (diff >> 8) as u8, diff as u8], ground_type: m.u32_at(0x20).ok_or(Error::Truncated("MOMT"))?, }); } @@ -366,9 +405,12 @@ fn parse_mliq(s: &[u8]) -> Result> { if s.len() < tbase + ntiles { return Ok(None); // declared grid runs past the payload — treat as no liquid } - let heights = (0..nverts) + let heights: Vec = (0..nverts) .map(|k| s.f32_at(vbase + k * 8 + 4).unwrap_or(0.0)) .collect(); + // Byte 0 of the same 8-byte vertex — the water opacity index (see `WmoLiquid::opacity`). The + // bounds are already guaranteed by the `tbase` check above. + let opacity: Vec = (0..nverts).map(|k| s[vbase + k * 8]).collect(); let tile_flags = s[tbase..tbase + ntiles].to_vec(); Ok(Some(WmoLiquid { xverts, @@ -378,6 +420,7 @@ fn parse_mliq(s: &[u8]) -> Result> { base, material_id, heights, + opacity, tile_flags, })) } diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index 7658b781..7d7ea02f 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -3,24 +3,42 @@ //! by the terrain loader); this is the Bevy render glue — one shared [`LiquidMaterial`] per //! [`LiquidKind`], a `texture_2d_array` of its animated frames, and a 24 fps frame-index cycler. //! -//! Faithful model (RE'd from `WoW.exe` + `ocean0_s.bls` + apitrace WoW.17 program 159, all agree). -//! `ocean0_s.bls`: `rgb = primary·colorTex.rgb + detailTex.rgb + (secondary+0.25)·detailTex.a`, -//! `alpha = colorTex.a`. The body colour is **`primary · waterTint`**, where: +//! **This is the ADT path, and we render WMO liquid through it too — which is wrong.** The reference +//! has three liquid renderers, not one: ADT MCLQ (below), WMO MLIQ water (`0x6b62e0` category 0, +//! split again into exterior `0x6b6630` / interior `0x6b6420` on the group's `MOGP.flags & 0x48` — +//! one texture stage, no depth ramp, alpha from a per-vertex authored byte), and magma/slime. The +//! `wmo_liquid_arms` census sizes them: 30 exterior water groups (Stormwind's canals) against 134 +//! interior (every dungeon pool, Blackfathom's included). Neither is what this file implements. +//! Recorded in wow-re `terrain/scratch/water-shading-law.md`. +//! +//! Faithful ADT model — the combiner is an **asset**, extracted from `patch.MPQ` and read, not +//! inferred: `Shaders\Pixel\ocean0_s.bls` is `rgb = primary·colorTex.rgb + detailTex.rgb + +//! (secondary+0.25)·detailTex.a`, `alpha = colorTex.a`, with `0.25` its own scalar `PARAM`. The +//! formula this module has always carried is right verbatim; its **provenance was not** — the +//! citation here used to name an "apitrace WoW.17 program 159" and a `docs/knowledge/terrain.md`, +//! neither of which exists, and wow-re had the program attributed to a character draw. The body +//! colour is **`primary · waterTint`**, where: //! - **`waterTint`** is a plain **2-endpoint linear lerp** of the zone's dedicated `Light.dbc` water //! rows, RAW (no ×0.711): IntBand rows 16/17 (river/lake) or 14/15 (ocean), shallow→deep, by the //! per-vertex depth `V` (river/lake `V = clamp(byte/42)`, VERIFIED `c81768`/`FUN_0068d790`; saturates -//! ~5 yd so the channel middle reaches the deep/teal row). Swatch builder VERIFIED: WoW.exe -//! `FUN_0068a830`, golden-vector-matched to the apitrace swatch ≤1/255 over all 64 rows. (The earlier -//! "reflected sky × 0.711 via `FUN_0068c250`" model fingered the WRONG builder — a separate grey edge -//! texture never bound on the water unit; and `byte/255` was the wrong LUT → river never went teal.) +//! ~5 yd so the channel middle reaches the deep/teal row). The swatch is rebuilt **per world frame**, +//! not baked once at bootstrap: the dirty flags `[0xc8117c]`/`[0xc81b70]` clear at +//! `0x680b90`/`0x680b97` and refill via `0x58acd0`, so colour and opacity track the zone and the +//! clock. (The earlier "reflected sky × 0.711 via `FUN_0068c250`" model fingered the WRONG builder — +//! a separate grey edge texture never bound on the water unit; and `byte/255` was the wrong LUT → +//! river never went teal. The `FUN_0068a830` attribution this line used to carry was also wrong: +//! wow-re reads `0x68a830` as a sky-band row fill.) //! - **`primary`** is the lit vertex colour `clamp(ambient + N·L·sun)`. //! - the animated `lake_a`/`ocean_h` frame is the **`detailTex`** (near-black RGB + ripple alpha): a //! faint flat lift + an achromatic shimmer on crests — NOT the body colour. Mipped + 16× aniso so the //! ripple averages out at distance (near-field samples mip 0, so near sparkle is the term itself). //! - **opacity** = the SAME `V` indexes both colour and alpha (one swatch row → RGB + A): a ramp between -//! the LightParams shallow/deep alphas — river 0.5→1.0, ocean 0.75→1.0 (VERIFIED WoW.exe `FUN_0068a830` -//! α = `127+2·row`). The river channel reaches α=1.0 (opaque, deep teal) by byte 42 ≈ 5 yd; the shore -//! stays see-through (the pale edge band, faithful — the bottom shows). +//! the LightParams shallow/deep alphas — river 0.5→1.0, ocean 0.75→1.0. The river channel reaches +//! α=1.0 (opaque, deep teal) by byte 42 ≈ 5 yd; the shore stays see-through (the pale edge band — +//! the bottom shows). **OPEN:** we ramp it linearly (`127+2·row`), but wow-re reads the byte-verified +//! ADT water alpha as the `0xc7fbc0` LUT's `1.6·(i/63)^8` — a curve that stays near-transparent far +//! longer and then climbs hard. Unchanged so far: it is a look change to every ADT water surface in +//! the game, so it belongs in one A/B with the WMO arms rather than landing on its own. //! //! River/lake `V = clamp(byte/42)` (steep `c81768` LUT, `FUN_0068d790`) — NOT `byte/255` (the `c7fcd8` //! LUT, a different draw list the from-above river path doesn't use; it left the river middle stuck on diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs index c775c34a..4c71866b 100644 --- a/crates/benilla-world/src/liquid/query.rs +++ b/crates/benilla-world/src/liquid/query.rs @@ -1017,6 +1017,7 @@ mod tests { depths: vec![1.0; 4], indices: vec![0, 1, 2, 1, 3, 2], sound_nibble: 0, + material_id: None, kind: LiquidKind::Still, } } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 50afa14e..354ae497 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -1,7 +1,7 @@ //! **The Bevy render glue** — the animated liquid surfaces themselves. //! -//! One shared material per (kind, fog block) over a `texture_2d_array` of the kind's frames, the two -//! spawn paths (an ADT chunk's MCLQ and a WMO group's MLIQ), and the flat mesh build. The faithful +//! One shared material per (kind, [`LiquidPath`], scroll) over a `texture_2d_array` of the kind's +//! frames, the two spawn paths (an ADT chunk's MCLQ and a WMO group's MLIQ), and the flat mesh build. The faithful //! shading model is `super`'s header and `liquid.wgsl`'s; the *position* side — the grid each //! spawned surface publishes and the queries against it — is [`super::query`]. //! @@ -39,27 +39,90 @@ pub(crate) struct LiquidAssets { materials: HashMap, } -/// Which shared material a surface takes: its kind, and **which fog block it draws under**. +/// **Which of the reference's liquid renderers a surface belongs to.** /// -/// The second lane exists because the reference decides fog per *pass*, not per liquid type. A WMO -/// group's own pool is drawn by the WMO liquid pass, which re-submits the smoothed interior fog block -/// (`0x6b6323`–`0x6b6342`) under the same `[0xca7f00]` gate as the WMO *geometry* pass — so the pool -/// and the room's walls always fog alike. ADT liquid submits no fog and draws under the once-a-frame -/// scene block. (VERIFIED wow-re `fog-env-state` §5's complete 6-site submit census + -/// `liquid-render-state-sided` §5; decision 0691's open lane, now closed.) +/// The reference has three, and until now benilla had one. `0x6b62e0` sends the type nibble to a +/// category, and category 0 (river/water — nibbles 0/4/8) splits again on the owning group's +/// `MOGP.flags & 0x48`: /// -/// The third lane is the **lava/slime UV scroll** — see [`scrolls`]. It is a per-*nibble*, per-*path* -/// property that [`LiquidKind`] deliberately collapses (2 and 6 are both `Magma`), so it cannot be -/// derived from the kind and has to key the material. +/// * [`Adt`](LiquidPath::Adt) — the MCLQ queues `0x6851b0` (river) / `0x685010` (ocean). **Two** +/// texture stages: a static depth-ramp on stage 0 addressed by `tc0 = (0.5, LUT[depthByte])`, the +/// animated sheet on stage 1 at `tc1 = (col·¼, row·¼)`, combined by `ocean0_s.bls`. The only path +/// with a depth ramp, and its vertex carries no colour at all. +/// * [`WmoExterior`](LiquidPath::WmoExterior) — `0x6b6630`. **One** stage (the sheet), a 9-float +/// vertex carrying an up normal *and* a colour dword, and the pixel program `MapObjExtWater0.bls` +/// bound at `0x6b6654`. +/// * [`WmoInterior`](LiquidPath::WmoInterior) — `0x6b6420`. One stage, a 6-float vertex with **no +/// normal**, lighting forced OFF, and no pixel program at all. +/// +/// Magma and slime take none of these: they share `0x6b68f0` (WMO) / `0x68dca0` (ADT) whatever their +/// group's flags say, so for the fullbright kinds this enum records only which *spawner* made the +/// surface — which is still what picks the fog block below. +/// +/// This also subsumes the old `interior: bool` lane, which existed for the **fog block**: the +/// reference decides fog per *pass*, not per liquid type, and a WMO group's own pool is drawn by the +/// WMO liquid pass, which re-submits the smoothed interior fog block (`0x6b6323`–`0x6b6342`) under +/// the same `[0xca7f00]` gate as the WMO *geometry* pass — so a pool and the walls around it always +/// fog alike, while ADT liquid submits no fog and draws under the once-a-frame scene block. (VERIFIED +/// wow-re `fog-env-state` §5's 6-site submit census + `liquid-render-state-sided` §5; decision 0691.) +/// `WmoInterior` is exactly the old `interior == true`, so [`Self::interior_fog`] is the whole of +/// what that flag used to say — with the renderer identity now carried alongside it rather than +/// inferred from it. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum LiquidPath { + /// An ADT chunk's MCLQ surface. + Adt, + /// A WMO group's MLIQ surface, the group being exterior/exterior-lit (`MOGP.flags & 0x48 != 0`). + WmoExterior, + /// A WMO group's MLIQ surface, the group being a true interior (`MOGP.flags & 0x48 == 0`). + WmoInterior, +} + +impl LiquidPath { + /// A WMO group's own class, as the reference's `[owner+0x10] & 0x48` test reads it. + pub(crate) fn wmo(interior: bool) -> Self { + if interior { + Self::WmoInterior + } else { + Self::WmoExterior + } + } + + /// Does this surface fog with the **interior** block rather than the scene block? + fn interior_fog(self) -> bool { + matches!(self, Self::WmoInterior) + } + + /// The renderer selector `liquid.wgsl` branches on (`LiquidParams.path.x`). + fn shader_id(self) -> f32 { + match self { + Self::Adt => 0.0, + Self::WmoExterior => 1.0, + Self::WmoInterior => 2.0, + } + } + + /// Does a pool on this arm take its body colour from its **own MOMT `diffColor`**, baked into the + /// mesh's vertex colour? Only the interior arm: the exterior one is lit and reads a live + /// `Light.dbc` band instead, and ADT liquid has no MOMT to index at all. + fn takes_material_body_color(self) -> bool { + matches!(self, Self::WmoInterior) + } +} + +/// Which shared material a surface takes: its kind, its renderer ([`LiquidPath`]), and whether it +/// takes the lava/slime UV scroll. +/// +/// The scroll lane is a per-*nibble*, per-*path* property that [`LiquidKind`] deliberately collapses +/// (2 and 6 are both `Magma`), so it cannot be derived from the kind and has to key the material — +/// see [`scrolls`]. /// /// The variants share one decoded frame array — only the tiny uniform differs — so the extra /// materials cost a handful of bytes, not a second copy of 30 × 256² textures. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] struct LiquidKey { kind: LiquidKind, - /// This surface belongs to a WMO **interior** group (`MOGI & 0x48 == 0` — the same test - /// `wow_model.wgsl` fogs its interior lanes by, which is what keeps the two in step). - interior: bool, + path: LiquidPath, /// This surface takes the animated stage-0 texture matrix ([`scrolls`]). scroll: bool, } @@ -91,21 +154,17 @@ struct LiquidEntry { } impl LiquidAssets { - /// The shared material for a liquid kind on a given fog block, scrolling or not, if its frames + /// The shared material for a liquid kind on a given renderer, scrolling or not, if its frames /// loaded. `scroll` is [`scrolls`]'s verdict; a `true` for a kind that has no scrolling variant /// simply misses, which is the same "no material" path a failed frame load takes. pub(crate) fn material( &self, kind: LiquidKind, - interior: bool, + path: LiquidPath, scroll: bool, ) -> Option> { self.materials - .get(&LiquidKey { - kind, - interior, - scroll, - }) + .get(&LiquidKey { kind, path, scroll }) .map(|e| e.material.clone()) } } @@ -174,7 +233,7 @@ pub(crate) fn spawn_liquids<'a>( // ADT queue is a different kernel that hard-zeroes the v-translate ([`scrolls`]). Deriving // the flag from `lq.sound_nibble` here — the obvious-looking thing — would slide every // Searing Gorge and Burning Steppes lava pool in the game. - let Some(material) = liquid.material(lq.kind, false, false) else { + let Some(material) = liquid.material(lq.kind, LiquidPath::Adt, false) else { continue; // this kind's frames failed to load (warned at setup) }; // The world-space liquid grid (MCLQ positions are already absolute WoW, so the IDENTITY @@ -184,7 +243,7 @@ pub(crate) fn spawn_liquids<'a>( entities.push( commands .spawn(( - Mesh3d(meshes.add(liquid_bevy_mesh(lq))), + Mesh3d(meshes.add(liquid_bevy_mesh(lq, None))), MeshMaterial3d(material), Transform::IDENTITY, LiquidSurface, @@ -209,7 +268,7 @@ pub(crate) fn spawn_liquids<'a>( /// UVs, and the per-vertex swatch `V` packed into UV1.x for the shader's colour/opacity ramp. The /// caller decides the surface's world placement via the spawned entity's `Transform` (`IDENTITY` for /// absolute MCLQ water; the WMO placement transform for WMO liquid). -fn liquid_bevy_mesh(lq: &LiquidMesh) -> Mesh { +fn liquid_bevy_mesh(lq: &LiquidMesh, body_color: Option<[f32; 3]>) -> Mesh { let positions: Vec<[f32; 3]> = lq .positions .iter() @@ -228,6 +287,14 @@ fn liquid_bevy_mesh(lq: &LiquidMesh) -> Mesh { // UV1.x carries the per-vertex swatch depth (0..1) for the shader's opacity ramp. let uv1: Vec<[f32; 2]> = lq.depths.iter().map(|&d| [d, 0.0]).collect(); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, uv1); + // An INTERIOR WMO pool's body colour is its own `MOMT.diffColor`, and it rides the vertex colour + // because that is where the reference's interior water vertex carries it (a colour dword in its + // 6-float record). Baking it here keeps ONE shared material per lane: a per-surface uniform would + // have meant a material per pool. Absent on every other lane, which adds no attribute and takes + // the shader's `#else` white. + if let Some([red, green, blue]) = body_color { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[red, green, blue, 1.0]; n]); + } mesh.insert_indices(Indices::U32(lq.indices.clone())); mesh } @@ -264,11 +331,16 @@ pub(crate) fn spawn_wmo_liquids<'a>( transform: Transform, interior: bool, pool: WmoPool, + // The owning root's MOMT `diffColor` table (`WmoModel::material_diff_color`). An INTERIOR + // pool's whole body colour is its own entry; MOMT lives in the root, so the group file the + // `LiquidMesh` came from could not resolve it and the lookup happens here. + material_diff_color: &[[f32; 3]], entities: &mut Vec, ) { let Some(liquid) = liquid_assets else { return; }; + let path = LiquidPath::wmo(interior); for lq in liquids { // `interior` picks the fog block, not the look: an interior group's pool is drawn by the WMO // liquid pass, which re-submits the smoothed interior fog under the same `[0xca7f00]` gate as @@ -277,7 +349,17 @@ pub(crate) fn spawn_wmo_liquids<'a>( // Blackrock's lava (6) creeps, the Ironforge Great Forge's (2) does not, and both are // `LiquidKind::Magma` drawing the same sheet ([`scrolls`]). let scroll = scrolls(lq.sound_nibble); - let Some(material) = liquid.material(lq.kind, interior, scroll) else { + // The interior arm's body colour, resolved through the pool's own MLIQ `materialId`. A + // fullbright pool takes none (its sheet IS the body), and a missing/out-of-range index + // simply yields no colour rather than a guessed one. + let body_color = (path.takes_material_body_color() && !lq.kind.is_fullbright()) + .then(|| { + lq.material_id + .and_then(|id| material_diff_color.get(usize::from(id))) + .copied() + }) + .flatten(); + let Some(material) = liquid.material(lq.kind, path, scroll) else { continue; // this kind's frames failed to load (warned at setup) }; if scroll { @@ -290,7 +372,7 @@ pub(crate) fn spawn_wmo_liquids<'a>( } let surface = commands .spawn(( - Mesh3d(meshes.add(liquid_bevy_mesh(lq))), + Mesh3d(meshes.add(liquid_bevy_mesh(lq, body_color))), MeshMaterial3d(material), transform, LiquidSurface, @@ -382,11 +464,11 @@ pub(super) fn setup_liquid( } else { AlphaMode::Blend }; - // One material per (FOG BLOCK × SCROLL) combination the kind can actually take (see + // One material per (RENDERER × SCROLL) combination the kind can actually take (see // [`LiquidKey`]), all sharing the one decoded frame array (`frames` is a handle; the clone is // a refcount, not a second 30 × 256² decode). Every other input is identical, so this is the - // whole cost of letting an interior room's pool fog like the room instead of like the sky - // outside it — and of letting Blackrock's lava creep while Ironforge's sits still. + // whole cost of giving each of the reference's three liquid renderers its own material — and + // of letting Blackrock's lava creep while Ironforge's sits still. // // The scroll variant exists only for the fullbright kinds: [`scrolls`] can only ever answer // true for nibbles 6/7, which map to `Magma`/`Slime` and nothing else, so building one for @@ -396,9 +478,13 @@ pub(super) fn setup_liquid( } else { &[false] }; - for (interior, &scroll) in [false, true] - .into_iter() - .flat_map(|i| scroll_lanes.iter().map(move |s| (i, s))) + for (path, &scroll) in [ + LiquidPath::Adt, + LiquidPath::WmoExterior, + LiquidPath::WmoInterior, + ] + .into_iter() + .flat_map(|p| scroll_lanes.iter().map(move |s| (p, s))) { let material = materials.add(ExtendedMaterial { base: StandardMaterial { @@ -427,9 +513,11 @@ pub(super) fn setup_liquid( kind: Vec4::new( if kind.is_fullbright() { 1.0 } else { 0.0 }, if kind == LiquidKind::Ocean { 1.0 } else { 0.0 }, - if interior { 1.0 } else { 0.0 }, + if path.interior_fog() { 1.0 } else { 0.0 }, WATER_SHININESS, ), + // Which of the reference's three liquid renderers `liquid.wgsl` runs. + path: Vec4::new(path.shader_id(), 0.0, 0.0, 0.0), // x = reserved (frame 0; the shader derives the live index from its own // clock); y = frame count; z = the SCROLL FLAG (1 = this lane takes the // stage-0 v-scroll — [`scrolls`]' nibble-6/7 WMO lane); w = the clock @@ -448,22 +536,17 @@ pub(super) fn setup_liquid( light_buf: world_assets.shared_light.clone(), }, }); - assets.materials.insert( - LiquidKey { - kind, - interior, - scroll, - }, - LiquidEntry { material }, - ); + assets + .materials + .insert(LiquidKey { kind, path, scroll }, LiquidEntry { material }); } } - // Frame SETS, not materials — a set backs every fog-block/scroll variant of its kind. + // Frame SETS, not materials — a set backs every renderer/scroll variant of its kind. info!( "liquid: loaded {} water frame set(s)", FRAME_SETS .iter() - .filter(|(k, ..)| assets.material(*k, false, false).is_some()) + .filter(|(k, ..)| assets.material(*k, LiquidPath::Adt, false).is_some()) .count() ); commands.insert_resource(assets); diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 2e673ba5..b44c4ab0 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -506,6 +506,8 @@ pub(super) fn spawn_loaded_placements( &p.transform, m.group_bounds.get(gi), ), + // The root's MOMT diffColor table — an interior pool's body colour. + &m.material_diff_color, &mut ents, ); if let Some(instance) = p.portal_instance { diff --git a/crates/benilla-world/src/wmo_portal/audit/mod.rs b/crates/benilla-world/src/wmo_portal/audit/mod.rs index 51088c65..33501196 100644 --- a/crates/benilla-world/src/wmo_portal/audit/mod.rs +++ b/crates/benilla-world/src/wmo_portal/audit/mod.rs @@ -277,6 +277,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject { group_footprint_grids: benilla_assets::footprint_tri_grids(&group_footprints), group_footprints, material_ground_type: Vec::new(), + material_diff_color: Vec::new(), group_light_refs: Vec::new(), group_liquids, doodad_base: Vec::new(), diff --git a/crates/benilla-world/src/wmo_portal/interior.rs b/crates/benilla-world/src/wmo_portal/interior.rs index eb67284a..52aa00e2 100644 --- a/crates/benilla-world/src/wmo_portal/interior.rs +++ b/crates/benilla-world/src/wmo_portal/interior.rs @@ -809,6 +809,7 @@ mod tests { group_bounds: Vec::new(), group_footprints: Vec::new(), material_ground_type: Vec::new(), + material_diff_color: Vec::new(), group_footprint_bounds: Vec::new(), group_footprint_grids: Vec::new(), group_light_refs: Vec::new(), diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index 57ab1218..f844a630 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -1100,6 +1100,7 @@ mod tests { group_bounds: Vec::new(), group_footprints: Vec::new(), material_ground_type: Vec::new(), + material_diff_color: Vec::new(), group_footprint_bounds: Vec::new(), group_footprint_grids: Vec::new(), group_light_refs: Vec::new(),