there are three water renderers, and WMO water gets its two
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<f32>,
|
||||
// x = WHICH RENDERER (see `LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior.
|
||||
// y/z/w reserved.
|
||||
path: vec4<f32>,
|
||||
// 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<f32>,
|
||||
// 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<f32>,
|
||||
}
|
||||
|
||||
// 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<f32>, world_pos: vec3<f32>) -> vec3<f32> {
|
||||
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<f32>(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<f32> {
|
||||
}
|
||||
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<f32>(0.0), vec3<f32>(1.0));
|
||||
return vec4<f32>(
|
||||
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<f32>(0.0),
|
||||
vec3<f32>(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<f32>(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);
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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,
|
||||
|
||||
@@ -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 `<root stem>_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<String, (u32, u32)> = 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(())
|
||||
}
|
||||
@@ -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<u16>,
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<LiquidMesh
|
||||
first_wet
|
||||
};
|
||||
let kind = LiquidKind::from_nibble(type_nibble)?;
|
||||
let depth_v = if kind.is_fullbright() {
|
||||
0.0
|
||||
} else {
|
||||
WMO_WATER_DEPTH_V
|
||||
};
|
||||
// Fullbright kinds read no ramp at all (their sheet IS the body), and their leading four vertex
|
||||
// bytes are an authored int16 UV pair rather than an opacity — so the byte is meaningless there
|
||||
// and the channel stays 0, a statement rather than a value.
|
||||
let water = !kind.is_fullbright();
|
||||
|
||||
// Grid vertices (row-major `j·xverts + i`), WMO model space. Every vertex is emitted (dry-tile
|
||||
// corners included, unreferenced) so the tile indices line up; the AABB stays tight because WMO
|
||||
// liquid heights are real, flat values (no MCLQ FLT_MAX sentinel).
|
||||
let mut positions = Vec::with_capacity(xv * yv);
|
||||
let mut uvs = Vec::with_capacity(xv * yv);
|
||||
let mut depths = Vec::with_capacity(xv * yv);
|
||||
for j in 0..yv {
|
||||
for i in 0..xv {
|
||||
let mx = lq.base[0] + i as f32 * MLIQ_CELL_STEP;
|
||||
let my = lq.base[1] + j as f32 * MLIQ_CELL_STEP;
|
||||
let h = lq.heights[j * xv + i];
|
||||
positions.push([mx, my, if h.is_finite() { h } else { lq.base[2] }]);
|
||||
// UV from the vertex's MODEL-SPACE position, NOT its per-surface tile index. A per-surface
|
||||
// `(i·¼, j·¼)` resets to 0 at each surface's origin; a surface's tile count is arbitrary
|
||||
// (Stormwind g099 is 11 tiles = 2.75 repeats), so that reset lands mid-texture and SEAMS at
|
||||
// every surface boundary. Dividing the shared model-space position by the repeat period
|
||||
// ties all of a WMO's MLIQ surfaces to ONE continuous UV field, so grid-aligned neighbours
|
||||
// tile seamlessly. (Same per-cell ¼ step as before — `i·STEP/period = i·¼` — plus the
|
||||
// `base/period` offset that couples the surfaces.)
|
||||
uvs.push([mx / MLIQ_UV_PERIOD, my / MLIQ_UV_PERIOD]);
|
||||
depths.push(depth_v);
|
||||
}
|
||||
}
|
||||
|
||||
// 2 tris per wet tile (low nibble `!= 0xf`). Winding is irrelevant (drawn two-sided).
|
||||
// 2 tris per wet tile (low nibble `!= 0xf`). Winding is irrelevant (drawn two-sided). Built
|
||||
// BEFORE the vertices because which vertices the triangles actually reference is what decides
|
||||
// the substitution below.
|
||||
let mut indices = Vec::with_capacity(xt * yt * 6);
|
||||
let mut wet = vec![false; xt * yt];
|
||||
let mut drawn = vec![false; xv * yv];
|
||||
for ty in 0..yt {
|
||||
for tx in 0..xt {
|
||||
if lq.tile_flags[ty * xt + tx] & 0xf == 0xf {
|
||||
@@ -350,11 +346,69 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
|
||||
let bl = ((ty + 1) * xv + tx) as u32;
|
||||
let br = bl + 1;
|
||||
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
|
||||
for v in [tl, tr, bl, br] {
|
||||
drawn[v as usize] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return None; // grid present but every tile a hole
|
||||
}
|
||||
|
||||
// The height every **unreferenced** vertex is filled with — see the loop below. The lowest
|
||||
// drawn height, so the substitute can never push the AABB past the geometry that is really
|
||||
// there. `indices` is non-empty by the check above, so at least one drawn height exists.
|
||||
let fill_z = (0..xv * yv)
|
||||
.filter(|&n| drawn[n] && lq.heights[n].is_finite())
|
||||
.map(|n| lq.heights[n])
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let fill_z = if fill_z.is_finite() {
|
||||
fill_z
|
||||
} else {
|
||||
lq.base[2]
|
||||
};
|
||||
|
||||
// Grid vertices (row-major `j·xverts + i`), WMO model space. Every vertex is emitted — hole
|
||||
// corners included — so the tile indices line up.
|
||||
//
|
||||
// **A hole corner's authored height is not a height.** 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`: Blackfathom g007 has 186 of 868 (its water sits at model z −58.28) and even
|
||||
// Stormwind's canal g099 has 36 of 108 (water at −6.48). Emitting those verbatim is invisible in
|
||||
// the draw — no triangle references them — but it stretches the Bevy mesh AABB from the flat
|
||||
// sheet all the way to model z 0, i.e. 58 yd of phantom vertical extent on that one pool, and
|
||||
// every AABB consumer (frustum culling first) then reasons about a box that is mostly nothing.
|
||||
// (The comment that used to sit here claimed the opposite — "the AABB stays tight because WMO
|
||||
// liquid heights are real, flat values" — which is false on both files above. The MCLQ path has
|
||||
// always substituted for its own `FLT_MAX` sentinel for exactly this reason; WMO liquid needed
|
||||
// the same treatment against a different degenerate value, and the robust test is *is this
|
||||
// vertex drawn*, not *does its height look wrong*.)
|
||||
let mut positions = Vec::with_capacity(xv * yv);
|
||||
let mut uvs = Vec::with_capacity(xv * yv);
|
||||
let mut depths = Vec::with_capacity(xv * yv);
|
||||
for j in 0..yv {
|
||||
for i in 0..xv {
|
||||
let n = j * xv + i;
|
||||
let mx = lq.base[0] + i as f32 * MLIQ_CELL_STEP;
|
||||
let my = lq.base[1] + j as f32 * MLIQ_CELL_STEP;
|
||||
let h = lq.heights[n];
|
||||
let z = if drawn[n] && h.is_finite() { h } else { fill_z };
|
||||
positions.push([mx, my, z]);
|
||||
depths.push(if water {
|
||||
wmo_water_alpha_v(lq.opacity.get(n).copied().unwrap_or(0))
|
||||
} else {
|
||||
0.0
|
||||
});
|
||||
// UV from the vertex's MODEL-SPACE position, NOT its per-surface tile index. A per-surface
|
||||
// `(i·¼, j·¼)` resets to 0 at each surface's origin; a surface's tile count is arbitrary
|
||||
// (Stormwind g099 is 11 tiles = 2.75 repeats), so that reset lands mid-texture and SEAMS at
|
||||
// every surface boundary. Dividing the shared model-space position by the repeat period
|
||||
// ties all of a WMO's MLIQ surfaces to ONE continuous UV field, so grid-aligned neighbours
|
||||
// tile seamlessly. (Same per-cell ¼ step as before — `i·STEP/period = i·¼` — plus the
|
||||
// `base/period` offset that couples the surfaces.)
|
||||
uvs.push([mx / MLIQ_UV_PERIOD, my / MLIQ_UV_PERIOD]);
|
||||
}
|
||||
}
|
||||
Some(LiquidMesh {
|
||||
grid: [xv as u32, yv as u32],
|
||||
wet,
|
||||
@@ -365,6 +419,9 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
|
||||
// The `0x6ba970`-resolved nibble (groupLiquid override / first wet tile) IS the WMO
|
||||
// surface's sound class — the ambient-loop key (see `LiquidMesh::sound_nibble`).
|
||||
sound_nibble: type_nibble,
|
||||
// How an interior pool finds its body colour: MOMT lives in the ROOT, so the group file
|
||||
// cannot resolve it and the spawner does (see `liquid::surface::spawn_wmo_liquids`).
|
||||
material_id: Some(lq.material_id),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
@@ -753,6 +810,8 @@ mod tests {
|
||||
base: [100.0, 200.0, -3.0],
|
||||
material_id: 0,
|
||||
heights: vec![-3.0; 6],
|
||||
// Byte 0 per vertex — the authored opacity index (see `wmo_water_alpha_v`).
|
||||
opacity: vec![0, 51, 102, 153, 204, 255],
|
||||
tile_flags: vec![0x44, 0x0f], // wet (type 4 + fishable), hole
|
||||
};
|
||||
let m = build_wmo_liquid_mesh(&lq, 0xf).expect("a wet mesh");
|
||||
@@ -762,8 +821,15 @@ mod tests {
|
||||
// Grid vertex (i=1, j=0) sits at base + (1·STEP, 0, height).
|
||||
assert!((m.positions[1][0] - (100.0 + MLIQ_CELL_STEP)).abs() < 1e-3);
|
||||
assert_eq!(m.positions[0], [100.0, 200.0, -3.0]);
|
||||
// Water is rendered at the deep (opaque) V.
|
||||
assert!(m.depths.iter().all(|&v| v == WMO_WATER_DEPTH_V));
|
||||
// Opacity is the vertex's OWN authored byte, normalised — not a pinned deep endpoint.
|
||||
// Before this, every WMO pool in the game was rendered at a hard 1.0 (fully opaque).
|
||||
for (n, &v) in m.depths.iter().enumerate() {
|
||||
assert!(
|
||||
(v - f32::from(lq.opacity[n]) / 255.0).abs() < 1e-6,
|
||||
"vertex {n}: depth channel {v} should carry byte {}/255",
|
||||
lq.opacity[n]
|
||||
);
|
||||
}
|
||||
// The wet tile is the LEFT one (indices reference the left cell's 4 corners: 0,1,3,4).
|
||||
assert!(m.indices.iter().all(|&i| [0u32, 1, 3, 4].contains(&i)));
|
||||
}
|
||||
@@ -771,7 +837,8 @@ mod tests {
|
||||
/// Two grid-aligned WMO liquid surfaces (a canal split across groups) must share ONE continuous
|
||||
/// UV field, so the animated water texture doesn't seam at the boundary. Surface B begins exactly
|
||||
/// where A's last column sits (base offset by 2·STEP in X), so the vertex they share in model space
|
||||
/// must get the SAME UV in both — the property the old per-surface `(i·¼, j·¼)` reset broke (each
|
||||
/// must get the SAME UV in both — the property a per-surface `(i, j)` reset would break wherever
|
||||
/// surfaces are NOT cell-aligned (each
|
||||
/// surface restarted at UV 0, and an arbitrary tile count put that restart mid-texture → the seam).
|
||||
#[test]
|
||||
fn adjacent_surfaces_share_a_continuous_uv_field() {
|
||||
@@ -784,6 +851,7 @@ mod tests {
|
||||
base,
|
||||
material_id: 0,
|
||||
heights: vec![-3.0; 6],
|
||||
opacity: Vec::new(),
|
||||
tile_flags: vec![0x44, 0x44], // both tiles wet
|
||||
};
|
||||
// B's origin column coincides with A's rightmost column (one 2-tile span east).
|
||||
@@ -795,6 +863,7 @@ mod tests {
|
||||
base: [base[0] + 2.0 * MLIQ_CELL_STEP, base[1], base[2]],
|
||||
material_id: 0,
|
||||
heights: vec![-3.0; 6],
|
||||
opacity: Vec::new(),
|
||||
tile_flags: vec![0x44, 0x44],
|
||||
};
|
||||
let ma = build_wmo_liquid_mesh(&a, 0xf).unwrap();
|
||||
@@ -806,8 +875,10 @@ mod tests {
|
||||
(ua[0] - ub[0]).abs() < 1e-4 && (ua[1] - ub[1]).abs() < 1e-4,
|
||||
"seam: shared-vertex UVs differ A={ua:?} B={ub:?}"
|
||||
);
|
||||
// The field is position-derived: UV.x still steps exactly ¼ per cell (same ripple size as before).
|
||||
assert!((ma.uvs[1][0] - ma.uvs[0][0] - 0.25).abs() < 1e-4);
|
||||
// The field is position-derived, and it steps exactly ONE repeat per cell — the reference's
|
||||
// `u = (float)i` (`0x6b6630`). This used to assert ¼, i.e. a texture 4x too large; correcting
|
||||
// it is what took the ripple off mip 0 across the whole near field (`MLIQ_UV_PERIOD`).
|
||||
assert!((ma.uvs[1][0] - ma.uvs[0][0] - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -821,6 +892,7 @@ mod tests {
|
||||
base: [0.0, 0.0, 0.0],
|
||||
material_id: 0,
|
||||
heights: vec![0.0; 4],
|
||||
opacity: Vec::new(),
|
||||
tile_flags: vec![0x40], // nibble 0 (would be Still), but the override wins
|
||||
};
|
||||
let m = build_wmo_liquid_mesh(&lq, 2).expect("magma mesh");
|
||||
|
||||
@@ -120,6 +120,28 @@ impl WmoRoot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-material MOMT `diffColor` (`+0x1C`), RGB 0..1. Read for one consumer: an **interior**
|
||||
/// WMO liquid pool's body colour is `MOMT[MLIQ.materialId].diffColor`, taken raw, because the
|
||||
/// reference's interior water kernel runs unlit with no pixel shader (wow-re
|
||||
/// `terrain/scratch/water-shading-law.md`). Indexed by [`LiquidMesh::material_id`].
|
||||
pub fn material_diff_colors(&self) -> 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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<f32>,
|
||||
/// 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<u8>,
|
||||
/// 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<u8>,
|
||||
@@ -207,11 +244,13 @@ fn parse_root(b: &[u8]) -> Result<WmoRoot> {
|
||||
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<Option<WmoLiquid>> {
|
||||
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<f32> = (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<u8> = (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<Option<WmoLiquid>> {
|
||||
base,
|
||||
material_id,
|
||||
heights,
|
||||
opacity,
|
||||
tile_flags,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LiquidKey, LiquidEntry>,
|
||||
}
|
||||
|
||||
/// 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<Handle<LiquidMaterial>> {
|
||||
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<Entity>,
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user