underwater only when you're in the water, and druid forms work

- A pool belongs to a room now. Uldaman read as deep underwater because a
  mushroom cave's pool 186 yards up claimed every position beneath it forever;
  Undercity's NPCs swam on dry stone because Tirisfal's lake — 95 yards of rock
  overhead — still answered for them, and the camera took the whole screen
  underwater with it. Every liquid query (player, camera eye, every unit) is now
  scoped to the building that owns the water, verified against the real 1.12.1
  files at both reported spots.
- Druid forms work. A live display change — shapeshift, GM morph, revive — was
  only ever read at spawn; now the model swaps instantly and a size change eases
  over the real client's two seconds. A bear-form druid shows no weapon, exactly
  like the original: only a character body attaches held items.
- 126 NPCs across the world were bald and earless. An amulet's display row sits
  in their head slot, and its "tuck the hair under the helm" mask fired with no
  helm to tuck under. A mask now needs an actual helm model; the gnome suppliers
  get their pigtails, ears and earrings back.
- The pouring lava in Blackrock Mountain and the Great Forge came back. A prop
  that spans several rooms (a lava fall is named by up to 19) was culled with
  only the first room that names it, so it blinked in and out by camera angle.
  A prop now draws while any of its rooms is visible.
- The Undercity entrance lightshafts dim to their authored 10% instead of
  flooding the throne room with blinding white columns.
- An indoor pool fogs with its room now, instead of hazing with the outdoor
  scene while the wall beside it hazed with the room. Magma and slime take fog
  too — "fullbright" in the data meant unlit, never unfogged.
- Backing the camera out through the Deadmines entrance swirl no longer blanks
  the whole dungeon: the sealed pocket behind the portal has a detail-only
  floor the room-finder couldn't stand on, so it declared you outside. A third
  fallback leg now names the room from the camera-collision faces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-27 19:37:17 +07:00
co-authored by Claude Fable 5
parent 0d945c4fe8
commit 7823f6a47a
45 changed files with 3007 additions and 1202 deletions
+104 -19
View File
@@ -9,12 +9,14 @@
//! Same app-independent shape as the M2 loader (meshes + texture handles + metadata; materials at
//! spawn). WMOs don't size-fade, so there are no bounds.
use std::sync::Arc;
use benilla_formats::{
accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, parse_wmo_lights,
parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header,
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes, CollisionMesh, FootprintTris,
LiquidMesh, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupInfo, WmoLight, WmoPortalInfo,
WmoPortalRef,
accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision,
accumulate_wmo_group_collision, parse_wmo_lights, parse_wmo_root, wmo_group_doodad_refs,
wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh,
wmo_group_submeshes, CollisionMesh, FootprintTris, LiquidMesh, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef,
};
use bevy::asset::io::Reader;
use bevy::asset::{Asset, AssetLoader, LoadContext};
@@ -74,6 +76,13 @@ pub struct WmoModel {
/// BSP, mask `0x84`; an earlier render-face `|n.z|` floor heuristic mis-seeded doorways and slab
/// edges). Same triangles as [`Self::collision`], kept per group for group attribution.
pub group_collision_tris: Vec<Vec<[[f32; 3]; 3]>>,
/// Per-group **camera-only** triangles (parallel to [`Self::group_collision_tris`]): the faces
/// the camera gather keeps but the walking gather drops — DETAIL (`0x04`) set, NOCAMCOLLIDE
/// (`0x02`) clear. The down-ray's **camera-void fallback** (decision 0692) races this set only
/// after the faithful walking Leg A and portal Leg B both miss AND no terrain surface sits at or
/// below the eye: an eye held between camera-collidable surfaces (the Deadmines entrance pocket,
/// whose floor is all-DETAIL) then still names its room instead of blanking the building.
pub group_camera_only_tris: Vec<Vec<[[f32; 3]; 3]>>,
/// Per-group AABB of [`Self::group_collision_tris`] (parallel; `None` = no collision faces),
/// computed at load **from the triangles themselves** — the broad phase for the faces-only
/// down-ray (`area_down_ray`). Bounds derived from the faces can only summarize the geometry,
@@ -113,16 +122,24 @@ pub struct WmoModel {
/// attach path — a WMO MODD doodad never reaches it (create never calls SetMatrix; wow-re
/// `trace-forensics-abbey-interior-d3d` §1.1).
pub doodad_base: Vec<DoodadBase>,
/// Per-MODD **owning group** (the first group whose MODR refs name it), parallel to
/// [`Self::doodads`]; `None` for a MODD no group references.
/// Per-MODD **instantiating group** (the first group whose MODR refs name it), parallel to
/// [`Self::doodads`]; `None` for a MODD no group references. The reference creates a doodad
/// once, on the first visible-group walk that reaches it, and that create is what freezes its
/// lighting lane — so this is the LIGHTING key ([`Self::doodad_base`]), not the cull key.
pub doodad_owner: Vec<Option<u16>>,
/// Per-MODD **referencing groups** — every group whose MODR names it, parallel to
/// [`Self::doodads`]; empty for a MODD no group references.
///
/// This is the prop's portal-cull key. The reference commits a WMO's doodads **per visible
/// group** — `0x695aa0` loops the group's own MODR refs at `group+0xe8`/count `+0x144`, called
/// from the visible-group walk `0x698720` (wow-re `m2-interior-doodad-base-light.md` §453,
/// `trace-forensics-abbey-interior-d3d.md` §90) — so a group the portal flood culled draws none
/// of its furniture. Without this the props outlive their own building: cull every group of a
/// dungeon and its lanterns, crates and cobwebs hang in the void (decision 0689).
pub doodad_owner: Vec<Option<u16>>,
/// of its furniture, and a prop **any** of whose referrers is visible is drawn. Without the key
/// at all, props outlive their own building: cull every group of a dungeon and its lanterns,
/// crates and cobwebs hang in the void (decision 0689). With the key collapsed to one owner, a
/// prop that hangs through several rooms — a lava fall — blinks out on every camera angle whose
/// flood reaches a different one of its referrers.
pub doodad_groups: Vec<Arc<[u16]>>,
/// Per-group FOOTPRINT face set (render tris + baked MOCV + MOPY flags), indexed by absolute
/// group index; `None` for exterior groups / groups without MOCV. The **GameObject M2** light
/// lane samples it: the entity node's env-update attach down-rays the render mesh under the
@@ -288,9 +305,10 @@ pub fn floor168(c: [u8; 3]) -> [f32; 3] {
floor_raise(c, 168)
}
/// Invert MODR: MODD index → its owning group. First referencing group wins — a doodad named by two
/// groups gets one def *per group* in the reference; we instantiate it once, under its first owner.
/// `None` = referenced by no group at all (the reference never instantiates such a MODD).
/// Invert MODR: MODD index → its **instantiating** group. First referencing group wins — the
/// reference creates a doodad once, on the first visible-group walk that names it, and that create
/// is what freezes its lighting lane. `None` = referenced by no group at all (the reference never
/// instantiates such a MODD). This is the LIGHTING key only; the *cull* key is [`modr_refs`].
fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec<u16>]) -> Vec<Option<u16>> {
let mut owner: Vec<Option<u16>> = vec![None; doodad_count];
for (gi, refs) in group_doodad_refs.iter().enumerate() {
@@ -303,6 +321,28 @@ fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec<u16>]) -> Vec<Optio
owner
}
/// Invert MODR the other way: MODD index → **every** group whose refs name it. The draw loop is
/// per *visible* group over that group's own MODR (`0x695aa0` from the visible-group walk
/// `0x698720`), so a doodad named by N groups is reached by N independent chances to draw — it is
/// on screen when ANY of them is in the portal PVS, not only its first. Collapsing this to the
/// first owner is what made Blackrock's and the Great Forge's lava falls blink out by camera
/// angle: `BLACKROCKSTATUELAVAFLOW` is named by 9–19 groups apiece and `LAVAPOTS` by 13, so all
/// but one referrer's rooms hid a prop that the reference draws from every one of them.
fn modr_refs(doodad_count: usize, group_doodad_refs: &[Vec<u16>]) -> Vec<Arc<[u16]>> {
let mut refs: Vec<Vec<u16>> = vec![Vec::new(); doodad_count];
for (gi, group) in group_doodad_refs.iter().enumerate() {
for &di in group {
if let Some(slot) = refs.get_mut(di as usize) {
// MODR may name the same doodad twice in one group; the cull only needs the set.
if slot.last() != Some(&(gi as u16)) {
slot.push(gi as u16);
}
}
}
}
refs.into_iter().map(Arc::from).collect()
}
/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow
/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the
/// instantiate loop is per-group over MODR — never a spatial test), the owning group's
@@ -404,6 +444,10 @@ impl AssetLoader for WmoModelLoader {
// group index (the flat collider below is fed from the same gather).
let mut group_collision_tris: Vec<Vec<[[f32; 3]; 3]>> =
vec![Vec::new(); root.group_count() as usize];
// Per-group camera-only triangles (DETAIL set, NOCAMCOLLIDE clear) — the down-ray's
// camera-void fallback set (decision 0692), disjoint from the walking gather above.
let mut group_camera_only_tris: Vec<Vec<[[f32; 3]; 3]>> =
vec![Vec::new(); root.group_count() as usize];
// Collidable triangles flattened across every group (raw WMO-local coords), accumulated as we
// read each group's bytes — no second pass / second read.
let mut col_pos: Vec<[f32; 3]> = Vec::new();
@@ -456,6 +500,21 @@ impl AssetLoader for WmoModelLoader {
col_pos.extend_from_slice(&gpos);
col_idx.extend(gidx.iter().map(|i| i + base));
accumulate_wmo_group_camera_collision(&gbytes, &mut cam_pos, &mut cam_idx);
// Camera-only gather (the walking gather's DETAIL complement) — kept per group for the
// down-ray's camera-void fallback (decision 0692).
let (mut dpos, mut didx): (Vec<[f32; 3]>, Vec<u32>) = (Vec::new(), Vec::new());
accumulate_wmo_group_camera_only_collision(&gbytes, &mut dpos, &mut didx);
if let Some(tris) = group_camera_only_tris.get_mut(gi as usize) {
for t in didx.chunks_exact(3) {
if let (Some(&a), Some(&b), Some(&c)) = (
dpos.get(t[0] as usize),
dpos.get(t[1] as usize),
dpos.get(t[2] as usize),
) {
tris.push([a, b, c]);
}
}
}
let subs = wmo_group_submeshes(&gbytes, &root).map_err(to_io)?;
// This group's MODR (which MODD placements it owns/instantiates) + MOLR (which MOLT
// lights fold into its doodads' committed light) — the per-doodad base resolution below
@@ -513,9 +572,11 @@ impl AssetLoader for WmoModelLoader {
}
}
// Resolve every MODD placement's base once — placements, colours, MODR ownership, and MOLR
// light lists are all fixed in the root/groups, so nothing here is ever re-derived. The MODR
// inversion is shared with the portal-cull key below, so the two can't disagree.
// light lists are all fixed in the root/groups, so nothing here is ever re-derived. Both
// MODR inversions come off the same refs: the first-owner one freezes the lighting lane at
// create, the full-set one is the per-visible-group cull key.
let doodad_owner = modr_owners(root.doodads().len(), &group_doodad_refs);
let doodad_groups = modr_refs(root.doodads().len(), &group_doodad_refs);
let doodad_base = resolve_doodad_bases(
root.doodads(),
root.group_infos(),
@@ -545,6 +606,7 @@ impl AssetLoader for WmoModelLoader {
group_nav,
fogs: root.fogs().to_vec(),
group_collision_tris,
group_camera_only_tris,
group_collision_bounds,
collision_bounds,
collision,
@@ -555,6 +617,7 @@ impl AssetLoader for WmoModelLoader {
group_bounds: root.group_infos().to_vec(),
doodad_base,
doodad_owner,
doodad_groups,
group_footprints,
group_footprint_bounds,
group_light_refs,
@@ -671,11 +734,11 @@ mod doodad_base_tests {
}
/// A MODD named by several groups resolves to its **first** referencing group, and the mapping is
/// dense over the doodad list — the portal cull keys every prop off this (decision 0689), so a
/// shared prop is culled with one real room rather than never culled at all. Out-of-range MODR
/// entries (a malformed group) are ignored rather than panicking.
/// dense over the doodad list — the LIGHTING lane keys off this (the create that freezes the base
/// runs once, on the first visible-group walk to reach it). Out-of-range MODR entries (a
/// malformed group) are ignored rather than panicking.
#[test]
fn a_shared_modd_belongs_to_its_first_referencing_group() {
fn a_shared_modd_is_lit_by_its_first_referencing_group() {
// g0 names doodad 2; g1 names 0 and 2; g2 names 1 — plus a ref past the end of the list.
let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]];
assert_eq!(
@@ -686,4 +749,26 @@ mod doodad_base_tests {
// No groups at all ⇒ nothing is owned, and nothing is cullable.
assert_eq!(modr_owners(2, &[]), vec![None, None]);
}
/// The CULL key is the whole referrer set, not the first: the draw loop runs per *visible* group
/// over that group's MODR, so a prop several rooms name is drawn from every one of them. Keying
/// it to the first owner instead is what blinked Blackrock's and the Great Forge's lava falls
/// out by camera angle — real files name those props from 9–19 groups apiece.
#[test]
fn the_cull_key_is_every_referencing_group() {
// Same fixture as above: g0 names 2; g1 names 0 and 2; g2 names 1 + an out-of-range ref.
let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]];
let refs = modr_refs(3, &modr);
assert_eq!(&*refs[0], &[1], "doodad 0 is named by g1 alone");
assert_eq!(&*refs[1], &[2]);
assert_eq!(
&*refs[2],
&[0, 1],
"doodad 2 hangs in two rooms — either one draws it"
);
// A group naming the same doodad twice contributes it once; no groups ⇒ no key at all (the
// prop is uncullable, matching `modr_owners`' `None`).
assert_eq!(&*modr_refs(1, &[vec![0u16, 0u16]])[0], &[0]);
assert!(modr_refs(2, &[])[0].is_empty());
}
}
@@ -316,6 +316,18 @@ enum Command {
/// ADT tile radius around the center tile to scan (0 = just the containing tile).
tile_radius: u32,
},
/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and
/// its OWNING group(s) read from the group files' MODR lists — the relation the reference
/// instantiates from (a *visible* group's own refs, `0x695aa0`), so a prop referenced by NO
/// group is one the real client never creates at all (the divergence decision 0689 names,
/// which benilla still spawns). The "which props exist in this building, who owns them, and
/// which would the reference even draw" instrument.
Wmodoodads {
/// Internal path to the WMO **root** (forward or back slashes accepted).
internal_path: String,
/// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted.
filter: Option<String>,
},
/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose
/// model path contains a substring — position, Euler rotation (deg), scale, uniqueId. The
/// "point at a thing in the world, tell me exactly how it's placed" instrument: an
@@ -487,6 +499,10 @@ fn main() -> Result<()> {
let catalog = benilla_formats::LightCatalog::load(&mut chain)?;
catalog.debug_slots(map, [x, y, z], minute * 2);
}
Command::Wmodoodads {
internal_path,
filter,
} => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?,
Command::Placescan {
map,
center_x,
@@ -10,6 +10,149 @@ use benilla_formats::{Chain, M2AnimSummary, M2Light};
use crate::{model_key, yn};
/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and its
/// OWNING group(s) read from the group files' MODR lists — the relation the reference instantiates
/// from (`0x695aa0` loops a *visible* group's own refs, wow-re `m2-interior-doodad-base-light.md`
/// §453). A prop referenced by NO group is never created by the real client at all — the
/// divergence decision 0689 names and benilla still spawns. This answers "which props exist here,
/// who owns them, and which would the reference even draw" in one read (the B30/B32 question).
pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Result<()> {
let root_path = raw_path.replace('/', "\\").to_ascii_lowercase();
let bytes = chain
.read_file(&root_path)
.with_context(|| format!("reading WMO root '{root_path}'"))?;
let root = benilla_formats::parse_wmo_root(&bytes)
.with_context(|| format!("parsing WMO root '{root_path}'"))?;
let set_names = mods_set_names(&bytes);
// MODD index -> the groups whose MODR reference it (the ownership relation).
let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path);
let mut owners: BTreeMap<u16, Vec<u32>> = BTreeMap::new();
let mut groups_read = 0u32;
for gi in 0..root.group_count() {
let group_path = format!("{stem}_{gi:03}.wmo");
let Ok(gbytes) = chain.read_file(&group_path) else {
continue;
};
groups_read += 1;
for r in benilla_formats::wmo_group_doodad_refs(&gbytes) {
owners.entry(r).or_default().push(gi);
}
}
println!(
"{} doodad(s), {} set(s), {} group(s) ({} group file(s) read)",
root.doodads().len(),
root.doodad_sets().len(),
root.group_count(),
groups_read,
);
for (si, s) in root.doodad_sets().iter().enumerate() {
let name = set_names.get(si).map(String::as_str).unwrap_or("?");
println!(
"set {si:>2} [{:>5}..{:>5}) count {:>5} {name}",
s.start,
s.start + s.count,
s.count
);
}
let needle = filter.map(str::to_ascii_lowercase);
let infos = root.group_infos();
let mut shown = 0u32;
let mut orphans_total = 0u32;
let mut orphans_shown = 0u32;
for (i, d) in root.doodads().iter().enumerate() {
let orphan = !owners.contains_key(&(i as u16));
if orphan {
orphans_total += 1;
}
if let Some(n) = &needle {
if !model_key(&d.model).contains(n.as_str()) {
continue;
}
}
shown += 1;
if orphan {
orphans_shown += 1;
}
let sets: Vec<String> = root
.doodad_sets()
.iter()
.enumerate()
.filter(|(_, s)| (s.start..s.start + s.count).contains(&(i as u32)))
.map(|(si, _)| si.to_string())
.collect();
let owner_cell = match owners.get(&(i as u16)) {
Some(gs) => gs
.iter()
.map(|&g| {
let class = match infos.get(g as usize) {
Some(gi) if gi.interior => "INT",
Some(_) => "EXT",
None => "?",
};
format!("g{g}({class})")
})
.collect::<Vec<_>>()
.join(" "),
None => "ORPHAN".into(),
};
println!(
"modd {i:>5} pos ({:>8.2}, {:>8.2}, {:>8.2}) scale {:.3} color #{:02x}{:02x}{:02x}{:02x} sets [{}] {owner_cell} {}",
d.position[0], d.position[1], d.position[2],
d.scale,
d.color[0], d.color[1], d.color[2], d.color[3],
sets.join(","),
d.model,
);
}
match needle {
Some(_) => eprintln!(
"{shown} doodad(s) matched ({orphans_shown} ORPHAN); {orphans_total} orphan(s) among all {}",
root.doodads().len()
),
None => eprintln!(
"{orphans_total} of {} doodad(s) are ORPHANS (in no group's MODR — the reference never instantiates these)",
root.doodads().len()
),
}
Ok(())
}
/// The MODS set names (`char name[20]` per 32-byte record) — `WmoDoodadSet` keeps only the ranges,
/// so read the names off the raw root bytes here (top-level chunks are `[magic][size][data]`, magic
/// on disk reversed: MODS → `SDOM`).
fn mods_set_names(bytes: &[u8]) -> Vec<String> {
let mut off = 0usize;
while off + 8 <= bytes.len() {
let size = u32::from_le_bytes([
bytes[off + 4],
bytes[off + 5],
bytes[off + 6],
bytes[off + 7],
]) as usize;
let Some(data_end) = (off + 8).checked_add(size) else {
return Vec::new();
};
if data_end > bytes.len() {
return Vec::new();
}
if &bytes[off..off + 4] == b"SDOM" {
return bytes[off + 8..data_end]
.chunks_exact(32)
.map(|rec| {
let name = &rec[..20];
let end = name.iter().position(|&b| b == 0).unwrap_or(20);
String::from_utf8_lossy(&name[..end]).into_owned()
})
.collect();
}
off = data_end;
}
Vec::new()
}
/// Sweep every `.m2` (under `prefix`, if given) and classify every BILLBOARD batch by which way its
/// geometry faces — see the `Bbfacescan` command doc for why the sign decides visibility.
///
+28
View File
@@ -76,6 +76,34 @@ pub struct ItemDisplay {
pub spell_visual: u32,
}
impl ItemDisplay {
/// The `HelmetGeosetVisData` row pair (`[male, female]`) this display hides hair / facial hair /
/// ears with — **only when the display is actually a worn helm**, i.e. it names a head model.
///
/// The gate is the point. `helmet_vis` is authored on 1314 of the 29604 shipped rows, and **12
/// of those name no model at all** — jewellery-shaped rows (15676 is an
/// `INV_Jewelry_Amulet_01` icon with no mesh) that still carry a full hide mask. Nothing wears
/// them: with no `ModelName` there is no helm to attach and no helm to tuck hair under. But
/// `CreatureDisplayInfoExtra`'s head column points **126 character-model NPC displays** at
/// exactly those rows, and honouring the mask there strips the NPC's hairstyle to the bare
/// scalp (geoset 1), its ears to the tucked variant (701) and its earrings/beard to their
/// group bases — while 1.12.1 renders them in full (B93: Jubie Gadgetspring, display 7969 →
/// extra 5503 → head display **15676** → vis row **306** = `[446,478,510,222,238]`, every
/// column with the gnome bit `1<<7` set).
///
/// **The gate is `ModelName[0]` alone** — on-disk column 1, the LEFT slot — VERIFIED at the
/// bytes (wow-re RF-0085, `0x4799c1`): the head-slot handler `0x4799a0` loads
/// `[[cc+0x4a8] + 4]` and `cmp byte ptr [ecx],0`, a **string-emptiness** test, jumping straight
/// to the epilogue `0x479b33` past the whole geoset-vis tail. Column 2 (the right slot) is
/// never consulted, and neither is the helm M2's load result — `0x4798c0`'s return is clobbered
/// untested, so this is decided synchronously off the DBC row, never off whether the model
/// resolved. On the shipped table the two readings coincide (41 rows fill only the right slot;
/// **none** of them carries a vis pair), which the test asserts rather than assumes.
pub fn worn_helm_vis(&self) -> Option<[u32; 2]> {
self.model[0].is_some().then_some(self.helmet_vis)
}
}
/// `ItemDisplayInfo.dbc`, keyed by `displayId` (the id `ItemDisplayInfoID`/`UNIT_VIRTUAL_ITEM_SLOT_DISPLAY`
/// resolve into — decision 0072).
pub struct ItemDisplayCatalog {
+17 -17
View File
@@ -142,23 +142,23 @@ mod value_track;
pub use value_track::{TrackValue, ValueTrack};
mod models;
pub use models::{
accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses,
load_m2_animation_summary, load_m2_bounds, load_m2_collision_hull, load_m2_mesh,
load_m2_mesh_skinned, load_object_model, load_wmo, load_wmo_collision_tris,
m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_lookup,
parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
wmo_group_liquid_mesh, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent,
Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh,
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation,
ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone,
StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader,
WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision,
accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary,
load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model,
load_wmo, load_wmo_collision_tris, m2_ribbon_emitter_count, m2_texture_transform_count,
parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations,
parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_collision_hull,
parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights,
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris,
wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes,
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim,
Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
};
mod terrain;
pub use terrain::{
+17 -8
View File
@@ -27,12 +27,17 @@ pub enum LiquidKind {
Rapids,
/// Ocean — `XTextures\ocean\ocean_h.*` (30 frames). Coastal / sea tiles.
Ocean,
/// Magma / lava — `XTextures\lava\lava.*`. Opaque, fullbright (the animated texture IS the body,
/// no depth LUT; VERIFIED wow-re `rf-water-liquid-type-texture-material.md`, magma vert-fill
/// `0x68d890` = constant 1.0). WMO liquid only (nibbles `2`/`6`) — the Ironforge Great Forge.
/// Magma / lava — `XTextures\lava\lava.*`. Opaque and **unlit**: the animated texture IS the body,
/// with no depth LUT to modulate it by — but still **fogged**, like every liquid batch (VERIFIED
/// wow-re `liquid-render-state-sided.md` §3/§5, which corrected the earlier
/// `rf-water-liquid-type-texture-material.md` reading of the `0x68d890` vert-fill: its
/// `0x3f800000` is an up normal's Z, not a colour or alpha, and it is byte-identical in the river
/// and ocean fills). WMO liquid only (nibbles `2`/`6`) — the Ironforge Great Forge.
Magma,
/// Slime — `XTextures\slime\slime.*`. Its own texture on the magma render category (opaque,
/// fullbright). WMO liquid only (nibbles `3`/`7`) — Undercity, the Sludge Fields.
/// Slime — `XTextures\slime\slime.*`. Its own texture on the magma render category, and **code-path
/// identical to magma** on the WMO side: one handler, no type-2-vs-3 branch anywhere (VERIFIED
/// wow-re `liquid-render-state-sided.md` §5). WMO liquid only (nibbles `3`/`7`) — Undercity, the
/// Sludge Fields.
Slime,
}
@@ -52,9 +57,13 @@ impl LiquidKind {
}
}
/// Whether this kind renders as an **opaque, fullbright** surface (the animated texture is the
/// body colour, no water swatch / depth ramp / N·L darkening) — magma and slime. Water/ocean use
/// the depth-swatch `ocean0_s.bls` path instead.
/// Whether this kind renders as an **opaque, unlit** surface (the animated texture is the body
/// colour, no water swatch / depth ramp / N·L darkening) — magma and slime. Water/ocean use the
/// depth-swatch `ocean0_s.bls` path instead.
///
/// "Fullbright" here means **unlit, not unfogged**: every liquid batch in the reference draws with
/// GL_FOG enabled, magma and slime included (wow-re `liquid-render-state-sided` §3/§5). Reading it
/// as "no fog" is what made a submerged slime surface a flat unshaded sheet.
pub fn is_fullbright(self) -> bool {
matches!(self, LiquidKind::Magma | LiquidKind::Slime)
}
+28 -7
View File
@@ -139,7 +139,7 @@ pub fn accumulate_wmo_group_collision(
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
accumulate_wmo_group_faces(group_bytes, MOPY_DETAIL, positions, indices);
accumulate_wmo_group_faces(group_bytes, MOPY_DETAIL, 0, positions, indices);
}
/// Accumulate one WMO **group** file's *camera/LOS*-collidable triangles (raw WMO-local coords): the
@@ -151,16 +151,37 @@ pub fn accumulate_wmo_group_camera_collision(
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
accumulate_wmo_group_faces(group_bytes, MOPY_NOCAMCOLLIDE, positions, indices);
accumulate_wmo_group_faces(group_bytes, MOPY_NOCAMCOLLIDE, 0, positions, indices);
}
/// Shared WMO group-face gather: accumulate every triangle whose MOPY flags don't intersect `skip_mask`,
/// remapping verts to a compact subset *per group* (equal local indices in different groups are distinct
/// verts). A group that doesn't parse is skipped. The two collision audiences differ only in `skip_mask`
/// — `0x04` DETAIL for walking, `0x02` NOCAMCOLLIDE for the camera (see [`MOPY_DETAIL`]).
/// Accumulate one WMO **group** file's *camera-only* triangles — the faces the camera collides with
/// but the walking gather drops: DETAIL (`flags & 0x04`) set, NOCAMCOLLIDE (`flags & 0x02`) clear.
/// Exactly the camera gather minus the walking gather, kept as its own set so the down-ray's
/// camera-void fallback (decision 0692) can race it *after* the faithful walking leg has missed,
/// without double-counting the faces the walking leg already saw.
pub fn accumulate_wmo_group_camera_only_collision(
group_bytes: &[u8],
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
accumulate_wmo_group_faces(
group_bytes,
MOPY_NOCAMCOLLIDE,
MOPY_DETAIL,
positions,
indices,
);
}
/// Shared WMO group-face gather: accumulate every triangle whose MOPY flags don't intersect `skip_mask`
/// (and carry all of `require_mask`, when non-zero), remapping verts to a compact subset *per group*
/// (equal local indices in different groups are distinct verts). A group that doesn't parse is skipped.
/// The two collision audiences differ only in `skip_mask` — `0x04` DETAIL for walking, `0x02`
/// NOCAMCOLLIDE for the camera (see [`MOPY_DETAIL`]); the camera-only set requires DETAIL on top.
fn accumulate_wmo_group_faces(
group_bytes: &[u8],
skip_mask: u8,
require_mask: u8,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
@@ -173,7 +194,7 @@ fn accumulate_wmo_group_faces(
let Some(mopy) = group.material_info.get(t) else {
continue;
};
if mopy.flags & skip_mask != 0 {
if mopy.flags & skip_mask != 0 || mopy.flags & require_mask != require_mask {
continue;
}
for k in 0..3 {
@@ -0,0 +1,155 @@
//! B93 — a `HelmetGeosetVisData` hide mask only applies when the head display is a **worn helm**,
//! i.e. it names a model. Real-data difftest against the shipped 1.12.1 DBCs, end to end from the
//! NPC's `CreatureDisplayInfoExtra` row to the geosets her body renders. Skips when the client
//! isn't present.
//!
//! The reported case is **Jubie Gadgetspring** (vmangos creature 8678 → `CreatureDisplayInfo`
//! 7969 → extra **5503**): a gnome female whose head column names display **15676**, an
//! `INV_Jewelry_Amulet_01` row with **no model** and a full hide mask (`HelmVisFemale` **306** =
//! `[446, 478, 510, 222, 238]`, every column carrying the gnome bit `1 << 7`). 1.12.1 renders her
//! pigtails, her long ears and her earrings; honouring the mask strips all three.
use std::path::PathBuf;
use benilla_formats::{
load_creature_catalog, load_item_display_catalog, open_chain, CharacterGeosets, EquipGeosets,
};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// Jubie's `CreatureDisplayInfo` id — the chain's entry point.
const JUBIE_DISPLAY: u32 = 7969;
/// The amulet-shaped `ItemDisplayInfo` row her head column names.
const AMULET_DISPLAY: u32 = 15676;
#[test]
fn a_modelless_head_display_is_not_a_worn_helm() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
// 1. The defect only exists if the shipped row really is model-less AND masked.
let amulet = items.get(AMULET_DISPLAY).expect("display 15676 exists");
assert!(
amulet.model.iter().all(Option::is_none),
"display {AMULET_DISPLAY} names no model in either slot, got {:?}",
amulet.model
);
assert_eq!(
amulet.helmet_vis,
[248, 306],
"display {AMULET_DISPLAY} still carries a HelmetGeosetVisData pair"
);
// 2. …and we now refuse to read it as a helm.
assert_eq!(
amulet.worn_helm_vis(),
None,
"a model-less display is not a worn helm"
);
// 3. A real helm still hides what it should — the change must not disarm the mechanism.
let helm = items
.iter()
.find(|d| d.model[0].is_some() && d.helmet_vis != [0, 0])
.expect("some display is a modelled helm with a vis row");
assert_eq!(
helm.worn_helm_vis(),
Some(helm.helmet_vis),
"a modelled helm keeps its vis pair"
);
// 4. The blast radius, pinned: of the 1314 rows that author a vis pair, exactly 12 leave the
// LEFT model slot empty. If the shipped data ever changes, this test says so rather than the
// change silently growing.
let masked = items.iter().filter(|d| d.helmet_vis != [0, 0]).count();
let masked_modelless = items
.iter()
.filter(|d| d.helmet_vis != [0, 0] && d.model[0].is_none())
.count();
assert_eq!((masked, masked_modelless), (1314, 12));
// 5. The gate is `ModelName[0]` alone (wow-re RF-0085, `0x4799c1`), not "either slot". 41 rows
// fill only the RIGHT slot; none of them carries a vis pair, so the two readings coincide on
// the shipped table — assert that rather than assume it, because the day one diverges the
// reference's answer is the left slot's.
let right_only = items
.iter()
.filter(|d| d.model[0].is_none() && d.model[1].is_some())
.count();
let right_only_masked = items
.iter()
.filter(|d| d.model[0].is_none() && d.model[1].is_some() && d.helmet_vis != [0, 0])
.count();
assert_eq!((right_only, right_only_masked), (41, 0));
}
#[test]
fn jubie_keeps_her_hair_ears_and_earrings() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let creatures = load_creature_catalog(&mut chain).expect("creature catalog");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let geosets = CharacterGeosets::load(&mut chain).expect("customization tables");
let model = creatures
.model(JUBIE_DISPLAY)
.expect("display 7969 resolves a model");
let npc = model
.npc_appearance
.as_ref()
.expect("display 7969 has a CreatureDisplayInfoExtra row");
assert_eq!(
(npc.race, npc.sex, npc.hair_style, npc.facial_hair),
(7, 1, 1, 2),
"gnome female, hairstyle 1, facial-hair (earring) variation 2"
);
assert_eq!(
npc.equipment[0], AMULET_DISPLAY,
"her head column is the model-less amulet row"
);
let head = items.get(npc.equipment[0]).expect("head display row");
let eg = EquipGeosets {
helm_vis: head.worn_helm_vis(),
..EquipGeosets::default()
};
let set = geosets.visible_geosets(npc.race, npc.sex, npc.hair_style, npc.facial_hair, &eg);
// CharHairGeosets (7,1,1) → 3 · CharacterFacialHairStyles (7,1,2) geoset200 3 → 203 · the
// ear region keeps its 702 default. All three are what the reference shows.
for want in [3u16, 203, 702] {
assert!(set.contains(&want), "geoset {want} is on, got {set:?}");
}
for hidden in [1u16, 201, 701] {
assert!(
!set.contains(&hidden),
"geoset {hidden} (the helm-tucked variant) is off, got {set:?}"
);
}
// The counterfactual — the shipped mask, honoured — is exactly the reported picture: bare
// scalp, no earrings, tucked ears. This is what the bug looked like, asserted so a regression
// has to walk past it.
let broken = geosets.visible_geosets(
npc.race,
npc.sex,
npc.hair_style,
npc.facial_hair,
&EquipGeosets {
helm_vis: Some(head.helmet_vis),
..EquipGeosets::default()
},
);
for want in [1u16, 201, 701] {
assert!(broken.contains(&want), "the mask, honoured, forces {want}");
}
}
+100 -33
View File
@@ -27,9 +27,14 @@
// ×8 over-saturated; FLAT colour killed the gradient; sky×0.711 was the wrong builder; `byte/255` was the
// wrong LUT → river middle never went teal. Corrected to rows 14–17 raw lerp + the /42 V, 2026-05-31.)
//
// Two-sided + alpha-blended + depth-write-off comes from the material (AlphaMode::Blend, cull off) =
// the verified MCLQ water render state. Fog + gamma handling mirror terrain.wgsl (planar eye-Z
// GL_LINEAR fog in gamma space; raw gamma out — GAMMA LANE, 0161).
// Two-sided comes from the material (cull off) and is right for EVERY kind: all four reference liquid
// passes force GL_CULL_FACE off at pass entry against a cull-ON device baseline, and `glFrontFace` is
// not even imported (VERIFIED wow-re `liquid-render-state-sided` §6). Blending is per KIND, decided in
// liquid.rs: water/ocean blend with depth-write off; magma/slime are opaque and depth-write. Fog + gamma
// mirror terrain.wgsl (planar eye-Z GL_LINEAR fog in gamma space; raw gamma out — GAMMA LANE, 0161), fog
// applies to every kind — magma and slime included — and WHICH fog block a surface takes is per-surface:
// a WMO interior group's own pool fogs with the interior block, everything else with the scene block
// (see `apply_fog`). Light, fog and both water swatches all come off the ONE shared global-light buffer.
#import bevy_pbr::{
mesh_functions,
@@ -42,18 +47,33 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
struct LiquidParams {
light_ambient: vec4<f32>, // rgb = ambient; w unused
light_diffuse: vec4<f32>, // rgb = sun diffuse; w unused
light_sun: vec4<f32>, // xyz = sun TRAVEL dir (to-light = −xyz); w unused
light_spec: vec4<f32>, // rgb = sun specular colour; w = shininess (the `secondary` sheen term)
water_shallow: vec4<f32>, // rgb = shore tint (IntBand row 16 river / 14 ocean, raw); w = shallow alpha
water_deep: vec4<f32>, // rgb = deep tint (IntBand row 17 river / 15 ocean, raw); w = deep alpha (1.0)
fog_color: vec4<f32>, // rgb = fog colour (gamma 0..1); w = enable (>0.5)
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; zw reserved
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
kind: vec4<f32>,
anim: vec4<f32>, // x = current frame index; y = frame count; zw unused
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> w: LiquidParams;
// The shared global light (`lighting::global_light`) — the SAME buffer terrain and the models read,
// mirrored here as its canonical row prefix. Liquid used to carry its own copy of every one of these
// values, re-pushed per material by `apply_wow_lighting`; that copy is what left it with only the
// scene fog and no way to see the interior block (decision 0691).
struct WowLight {
light_ambient: vec4<f32>, // 0 rgb = ambient; w = Mod2x scale
light_diffuse: vec4<f32>, // 1 rgb = sun diffuse; w = clamp flag
light_sun: vec4<f32>, // 2 xyz = sun TRAVEL dir (to-light = −xyz)
light_spec: vec4<f32>, // 3 rgb = row-9 specular colour; w = TERRAIN shininess (liquid uses w.kind.w)
fog_color: vec4<f32>, // 4 rgb = scene fog (block 1, gamma 0..1); w = enable (>0.5)
fog_params: vec4<f32>, // 5 x = start yd; y = end yd; w = the farclip wall
_sh: array<vec4<f32>, 6>, // 6-11 model SH coeffs — unread here
_sh_c16: vec4<f32>, // 12
water_river: array<vec4<f32>, 2>, // 13-14 shallow/deep river-lake swatch (IntBand 16/17); w = alpha
water_ocean: array<vec4<f32>, 2>, // 15-16 shallow/deep ocean swatch (IntBand 14/15); w = alpha
_grade: vec4<f32>, // 17
wmo_fog_color: vec4<f32>, // 18 rgb = INTERIOR fog (block 2); w = enable
wmo_fog_params: vec4<f32>, // 19 x = start yd; y = end yd
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
struct LiquidVsOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_position: vec4<f32>,
@@ -72,11 +92,47 @@ struct LiquidVsOut {
// EVALUATION DOMAIN differs (interpolated vs evaluated per fragment).
fn sun_sheen(world_normal: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
let n = normalize(world_normal);
let to_light = -normalize(w.light_sun.xyz);
let to_light = -normalize(wow_light.light_sun.xyz);
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);
return w.light_spec.rgb * pow(ndoth, max(w.light_spec.w, 1.0));
// Shininess is WATER's own (`w.kind.w`), not the shared row-3 terrain exponent.
return wow_light.light_spec.rgb * pow(ndoth, max(w.kind.w, 1.0));
}
// Distance fog — planar eye-Z, GL_LINEAR, gamma space (mirrors terrain.wgsl). Applied to EVERY liquid
// kind, because the reference never disables fog for a liquid batch: the device default for GL_FOG is
// **ON** (`0x593bf0` writes state id `0x0f` = 1) and all 42 fog-enable setters in the binary are
// Push/Pop-scoped, so what a batch inherits at its draw is that default. The ADT lava pass sets only
// cull/lighting/blend (`0x6855ca`/`0x6855d6`/`0x6855e2`) and the WMO magma/slime arm sets only lighting
// (`0x6b6afe`) — neither touches fog — while the WMO *river* arm goes out of its way to re-assert
// `(0x0f, 1)`, which only makes sense in a world where fog-on is liquid's intended state. (VERIFIED
// wow-re `liquid-render-state-sided` §1–§3, §5.)
//
// WHICH fog is a per-surface choice, and it is the reference's own (VERIFIED wow-re `fog-env-state`
// §5, the complete 6-site submit census). The device holds two fog blocks: **block 1** (`+0x70/74/78`)
// is the scene fog, submitted once a frame from `WorldFrame::Render` (`0x66ff20`), and **block 2**
// (`+0x80/84/88`) is block 1 smoothed toward the MFOG/zone target over ~4 s (`0x6cf054`+) — the
// interior haze. Only two call sites in the whole binary re-submit block 2, and they are the WMO
// *geometry* pass (`0x6b51d9`/`0x6b51ea`) and the WMO *liquid* pass (`0x6b6323`–`0x6b6342`), both under
// the same `[0xca7f00]` gate. So an interior room's pool takes the room's fog, in lockstep with the
// walls around it; ADT liquid submits nothing and draws under the scene block. `w.kind.z` is that
// gate, resolved at spawn from the group's `MOGI & 0x48` — the same interior test `wow_model.wgsl`
// uses, which is what keeps the two in step.
fn apply_fog(rgb: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
var fog_color = wow_light.fog_color;
var fog_span = wow_light.fog_params.xy;
if (w.kind.z > 0.5) {
fog_color = wow_light.wmo_fog_color;
fog_span = wow_light.wmo_fog_params.xy;
}
if (fog_color.w <= 0.5) {
return rgb;
}
let eye_z = -(view.view_from_world * vec4<f32>(world_pos, 1.0)).z;
let denom = max(fog_span.y - fog_span.x, 0.001);
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
return mix(fog_color.xyz, rgb, factor);
}
@vertex
@@ -100,9 +156,9 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// HARD FAR-CLIP WALL (same as terrain/models, see terrain.wgsl): discard water beyond the
// projection far plane so lakes/rivers don't render past the wall. `fog_params.w` = farclip
// (0 ⇒ disabled).
if (w.fog_params.w > 0.0) {
if (wow_light.fog_params.w > 0.0) {
let clip_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
if (clip_z > w.fog_params.w) {
if (clip_z > wow_light.fog_params.w) {
discard;
}
}
@@ -111,11 +167,20 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// for magma/slime it is the OPAQUE BODY texture.
let detail = textureSample(frames, frames_samp, in.uv, i32(round(w.anim.x)));
// Magma / slime (anim.z > 0.5): the animated texture IS the opaque, fullbright body colour — no
// depth swatch, no N·L lighting, no fog (VERIFIED wow-re `rf-water-liquid-type-texture-material`:
// the magma vert-fill is a constant 1.0 with no LUT, on the emissive/no-darken GX render path).
if (w.anim.z > 0.5) {
return vec4<f32>(detail.rgb, 1.0);
// Magma / slime (kind.x > 0.5): the animated texture IS the opaque body colour — no depth swatch
// (the ADT liquid vertex format carries no colour element at all, and the WMO one is a hard
// `0xffffffff`, so there is nothing to modulate the sheet by) and no N·L (lighting state 0 on both
// paths). It IS fogged, like every other liquid batch.
//
// The earlier "emissive / no-darken / no fog" reading here was WRONG, and wrong twice over: it came
// from the ADT-lava row of `rf-water-liquid-type-texture-material`, which read GX state `0x37` as an
// emissive path when `0x37` is the per-stage TEXTURE-MATRIX enable pushing an identity — a texgen
// *reset*; and that row is the ADT queue, which never dispatches slime at all (Undercity's slime is
// WMO liquid). Skipping fog is what made a submerged slime surface a flat unshaded sheet at any
// depth instead of one that recedes into the murk. (VERIFIED wow-re `liquid-render-state-sided`
// §3/§3.1/§5, which corrects that row.)
if (w.kind.x > 0.5) {
return vec4<f32>(apply_fog(detail.rgb, in.world_position.xyz), 1.0);
}
// Per-vertex swatch coord V (in `in.depth`, computed CPU-side in wow-formats/liquid.rs): river/lake
@@ -125,14 +190,22 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// row: colour `shallow→deep` and opacity `shallow_α→deep_α` track together. (Earlier `×4` colour
// compression + the gentle `byte/255` V were band-aids for a wrong "V tops at 0.31" belief — removed.)
let depth = clamp(in.depth, 0.0, 1.0);
let water_tint = mix(w.water_shallow.rgb, w.water_deep.rgb, depth);
// The kind's swatch endpoints, off the shared light: ocean reads rows 15/16 (IntBand 14/15),
// river/lake rows 13/14 (IntBand 16/17). Both are packed every frame by `build_light_data`.
var shallow = wow_light.water_river[0];
var deep = wow_light.water_river[1];
if (w.kind.y > 0.5) {
shallow = wow_light.water_ocean[0];
deep = wow_light.water_ocean[1];
}
let water_tint = mix(shallow.rgb, deep.rgb, depth);
// Body colour: lit vertex colour × the depth-lerped water-row swatch colour (`primary·colorTex`).
let n = normalize(in.world_normal);
let to_light = -normalize(w.light_sun.xyz);
let to_light = -normalize(wow_light.light_sun.xyz);
let ndotl = max(dot(n, to_light), 0.0);
let primary = clamp(
w.light_ambient.rgb + w.light_diffuse.rgb * ndotl,
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -153,17 +226,11 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// colour. Deeper = more opaque, up to α=1.0 where V saturates (river/lake byte 42 ≈ 5 yd), so the
// channel middle is opaque + teal while the shore stays semi-transparent (V→0, α≈0.5) and the bottom
// shows through (faithful — the pale edge band). One steep V drives both colour and opacity together.
let alpha = mix(w.water_shallow.w, w.water_deep.w, depth);
// Distance fog — planar eye-Z, GL_LINEAR, gamma space (mirrors terrain.wgsl); the fog colour is
// also teal, so far water converges on the haze.
if (w.fog_color.w > 0.5) {
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
let denom = max(w.fog_params.y - w.fog_params.x, 0.001);
let factor = clamp((w.fog_params.y - eye_z) / denom, 0.0, 1.0);
rgb = mix(w.fog_color.xyz, rgb, factor);
}
let alpha = mix(shallow.w, deep.w, depth);
// Distance fog (see `apply_fog`) — the water fog colour is also teal, so far water converges on the
// haze.
rgb = apply_fog(rgb, in.world_position.xyz);
// GAMMA LANE (0161): raw gamma out; alpha blends in gamma like the reference's bytes.
return vec4<f32>(rgb, alpha);
+13 -4
View File
@@ -55,11 +55,20 @@ const WDL_OVERLAP: f32 = 33.0;
/// then be draw order). Multiplicative so it can never cross zero into the sky.
const WDL_DEPTH_PUSH: f32 = 0.999;
struct WdlParams {
fog_color: vec4<f32>, // rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z reserved; w = farclip wall (0 ⇒ off)
// The shared global light (`lighting::global_light`) — the same buffer terrain, the models and liquid
// bind, mirrored here as the row prefix WDL needs. Rows 4/5 are the SCENE fog (block 1) plus the
// farclip wall, and that is the right block by construction: the band IS the horizon, so it is never
// inside a WMO and never wants the interior block. (WDL used to hold its own copy of these two rows,
// re-pushed per material by `apply_wow_lighting`.)
struct WowLight {
_light_ambient: vec4<f32>, // 0
_light_diffuse: vec4<f32>, // 1
_light_sun: vec4<f32>, // 2
_light_spec: vec4<f32>, // 3
fog_color: vec4<f32>, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
fog_params: vec4<f32>, // 5 x = fog_start yd; y = fog_end yd; z unused; w = farclip wall (0 ⇒ off)
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> w: WdlParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> w: WowLight;
struct WdlVsOut {
@builtin(position) clip_position: vec4<f32>,
+13 -8
View File
@@ -119,8 +119,7 @@ pub(super) fn apply_model_visibility(
instances
.get(gv.instance)
.ok()
.and_then(|inst| inst.visible.get(gv.group as usize).copied())
.unwrap_or(true)
.is_none_or(|inst| gv.drawn_by(inst))
});
// The batch's animated material-alpha factor (decision 0130 phase 2): the sampled
@@ -149,10 +148,17 @@ pub(super) fn apply_model_visibility(
// already on the cutout material) cost nothing and don't re-batch every frame.
//
// The alpha field is written for `DoodadFade` holders (the fade composes `mat_factor` in)
// AND for a `MatAnim` that owns the channel outright (`drives_tag` — spell-effect parts,
// which have no fade). A lit interior prop's `MatAnim` sets neither (its tag is a packed
// colour the alpha field must never overwrite) — only its `mat_factor > 0` cull applies.
if fade.is_some() || mat_anim.is_some_and(|m| m.drives_tag) {
// AND for every other non-unit-lane `MatAnim`: the parts that own the channel outright
// (`drives_tag` — spell-effect parts, which have no fade) and the pinned no-fade lane —
// the lit interior props. The latter used to be skipped ("the tag is a packed colour"),
// which was true before the 0355 re-lane but stale after it: the probe-slot payload
// keeps bits 0..=15 as the alpha field precisely so `with_alpha` composes with the slot.
// Skipping them dropped a batch's authored dimming constant entirely — the Undercity
// throne room's LD_lightshaft01 (weights const 0.10/0.05, the reference's near-invisible
// haze) blasted at full brightness (bug B30). Only the unit lane stays out:
// `entities::apply_unit_mat_alpha` owns that compose, ordered against the interior
// classifier and the appear-fade.
if fade.is_some() || mat_anim.is_some_and(|m| !m.composes_unit_tag()) {
// Glow cards render at AUTHORED brightness (decision 0159 — the dimmer knob died
// with the faithful FFXGlow pass; the square-law is what keeps halos in check).
let alpha = fade_alpha * mat_factor;
@@ -188,8 +194,7 @@ pub(super) fn apply_model_visibility(
|| instances
.get(gv.instance)
.ok()
.and_then(|inst| inst.visible.get(gv.group as usize).copied())
.unwrap_or(true)
.is_none_or(|inst| gv.drawn_by(inst))
{
Visibility::Inherited
} else {
+4 -3
View File
@@ -289,9 +289,10 @@ pub(crate) struct MatAnim {
frozen: bool,
/// This instance's sampled value drives the render-alpha `MeshTag` field **by itself** (no
/// `DoodadFade` on the entity): the spell-effect parts (`entities::spell_fx`), whose alpha
/// channel has no other writer. `false` on the doodad lane — there the fade writer owns the
/// tag and composes [`Self::current`] in (and a lit interior prop's tag carries a packed
/// colour this flag must never overwrite) — and on the unit lane, whose own compose is
/// channel has no other writer. `false` on the doodad lane — there the visibility authority
/// owns the tag and composes [`Self::current`] in (for a fade holder multiplied with the
/// distance fade; for a lit interior prop written alone into the probe payload's alpha field,
/// bits 0..=15 since the 0355 re-lane) — and on the unit lane, whose own compose is
/// [`crate::entities::apply_unit_mat_alpha`].
pub(crate) drives_tag: bool,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
+10
View File
@@ -64,6 +64,12 @@ use equipment::{attach_held_items, refresh_player_looks, resolve_equipment};
pub(crate) mod mount;
use mount::refresh_mounts;
/// Live descriptor appearance (decision 0695): a `Values` delta moving the display id swaps the
/// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the
/// render scale (the reference's 2 s cosine smoothstep); both restamp the collision height.
mod live_display;
use live_display::{refresh_live_display, tick_scale_ease};
/// Terrain conform (decisions 0482/0486): every flagged model (`GlobalModelFlags & 3 ∈ {1,3}` —
/// mounts and wild quadrupeds alike) tilts to the ground under its unit, through the conform
/// node its root bones parent under.
@@ -356,6 +362,10 @@ impl Plugin for EntitiesPlugin {
// A mount transition does the same (decision 0441): the field diff tears the
// visual down, attach rebuilds it mounted (or dismounted) next frame(s).
refresh_mounts,
// A live display-id / scale change (decision 0695): the display swap is the
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
refresh_live_display,
tick_scale_ease,
)
.chain()
.in_set(EntityVisualsSet)
+54 -39
View File
@@ -61,45 +61,53 @@ pub(super) fn resolve_char_look(
entity: Entity,
stores: &Query<&ObjectStore>,
) -> Option<CharLook> {
match net.kind {
EntityKind::Player => {
// A player entity is always a character body. Race/sex come from `UNIT_FIELD_BYTES_0`; the
// `PLAYER_BYTES` / `PLAYER_BYTES_2` customization is *optional on the wire* — vmangos omits an
// all-zero field from the create mask, so a fully-default character (like our own "One") sends
// no `PLAYER_BYTES` at all. Per UpdateFields semantics an absent field means its default (0),
// not "no data": default each byte to 0 rather than gate the whole look on its presence (an
// over-strict `?` here left a default-customized avatar unskinned — the pre-0061 component
// path defaulted these implicitly).
let s = &stores.get(entity).ok()?.0;
Some(CharLook {
race: s.unit_race()?,
sex: s.unit_gender()?,
skin: s.player_skin().unwrap_or(0),
hair_style: s.player_hair_style().unwrap_or(0),
hair_color: s.player_hair_color().unwrap_or(0),
facial_hair: s.player_facial_hair().unwrap_or(0),
body: BodySkin::Composite {
face: s.player_face().unwrap_or(0),
},
})
}
EntityKind::Unit => {
let npc = dm?.npc_appearance.as_ref()?;
Some(CharLook {
race: npc.race,
sex: npc.sex,
skin: npc.skin,
hair_style: npc.hair_style,
hair_color: npc.hair_color,
facial_hair: npc.facial_hair,
body: match &npc.bake_name {
Some(name) => BodySkin::Baked(name.clone()),
None => BodySkin::Composite { face: npc.face },
},
})
}
_ => None,
// The look follows the DISPLAY, not the entity kind (decision 0695): with live display-id
// swaps a Player-kind entity can wear any display — a druid's bear form is a plain creature
// model (no look; Monster skins instead), and a GM-morphed player wearing a humanoid NPC
// display wears ITS CreatureDisplayInfoExtra appearance. The reference's own race/gender
// getters answer from the display's cached row with the descriptor as fallback (wow-re
// `w2d2.md`, the `0x60c690` getter family) — exactly this order: display appearance first,
// wire appearance only for a character body that carries none.
let d = dm?;
if let Some(npc) = d.npc_appearance.as_ref() {
// A character-model NPC display (whoever wears it): its CreatureDisplayInfoExtra
// appearance — a baked body when the row names one, else a live composite.
return Some(CharLook {
race: npc.race,
sex: npc.sex,
skin: npc.skin,
hair_style: npc.hair_style,
hair_color: npc.hair_color,
facial_hair: npc.facial_hair,
body: match &npc.bake_name {
Some(name) => BodySkin::Baked(name.clone()),
None => BodySkin::Composite { face: npc.face },
},
});
}
if net.kind == EntityKind::Player && d.is_character_body {
// A player wearing a character body. Race/sex come from `UNIT_FIELD_BYTES_0`; the
// `PLAYER_BYTES` / `PLAYER_BYTES_2` customization is *optional on the wire* — vmangos omits an
// all-zero field from the create mask, so a fully-default character (like our own "One") sends
// no `PLAYER_BYTES` at all. Per UpdateFields semantics an absent field means its default (0),
// not "no data": default each byte to 0 rather than gate the whole look on its presence (an
// over-strict `?` here left a default-customized avatar unskinned — the pre-0061 component
// path defaulted these implicitly).
let s = &stores.get(entity).ok()?.0;
return Some(CharLook {
race: s.unit_race()?,
sex: s.unit_gender()?,
skin: s.player_skin().unwrap_or(0),
hair_style: s.player_hair_style().unwrap_or(0),
hair_color: s.player_hair_color().unwrap_or(0),
facial_hair: s.player_facial_hair().unwrap_or(0),
body: BodySkin::Composite {
face: s.player_face().unwrap_or(0),
},
});
}
// A beast display (whoever wears it), a GameObject, a model-less display: no look.
None
}
/// The `mpq://` URL for a pre-baked NPC body atlas (a CreatureDisplayInfoExtra bake name) under
@@ -179,7 +187,14 @@ pub(in crate::entities) fn equip_geosets(
eg.cloak = d.catalog.get(cloak).map(|row| row.geoset_groups[0]);
}
if helm != 0 {
eg.helm_vis = d.catalog.get(helm).map(|row| row.helmet_vis);
// Only a display that names a head MODEL is a worn helm, and only a worn helm tucks
// hair/facial/ears away (B93: `CreatureDisplayInfoExtra`'s head column points 126
// character-model NPC displays at model-less jewellery rows that still carry a full
// hide mask — see [`benilla_formats::ItemDisplay::worn_helm_vis`]).
eg.helm_vis = d
.catalog
.get(helm)
.and_then(benilla_formats::ItemDisplay::worn_helm_vis);
}
}
eg
@@ -1018,6 +1018,11 @@ pub(super) fn attach_entity_visuals(
commands
.entity(entity)
.insert(VisualAttached)
// The display this visual was built with (decision 0695, the same pattern):
// `refresh_live_display` diffs it against the live descriptor and rebuilds on a
// change (druid form, GM morph). Stamped on the cube fallback too, so a model-less
// unit can never churn.
.insert(super::live_display::AppliedDisplay(net.display_id))
.remove::<super::equipment::Reattached>();
}
}
@@ -66,17 +66,11 @@ pub(crate) fn collision_height_for(
/// carrying none at all — is still stamped, at the ctor default, so no consumer has to tell
/// "not resolved yet" apart from "resolved to nothing".
///
/// **Stamped once, never restamped** — correct today because *neither* input can change on a live
/// entity. A live `SCALE_X` change is [`NetEntity::scale`]'s own standing deferral, and a live
/// `UNIT_FIELD_DISPLAYID` change isn't applied either: the wire field is read **only** on the
/// create path (`benilla_protocol`'s `display_id`, one call site, in the `ObjectCreate` arm), so a
/// morph or a druid form leaves both the drawn model and this height at the create-time body.
/// Verified live 2026-07-26 — `.modify morph 1564` on a swimming probe moved neither.
///
/// So restamping now would be strictly *worse*: it would give the collision box a body the renderer
/// isn't drawing, the exact disagreement this component exists to stop. When the live display-id
/// change lands (the shapeshift/morph gap, ledger F04), the model swap and this restamp are one
/// change, not two.
/// This system stamps only the **first** value. A live change to either input — a display-id swap
/// (druid form, GM morph) or a `SCALE_X` change — restamps through
/// [`super::live_display::refresh_live_display`] (decision 0695), in the same commit as the model
/// swap it rides with: the collision box and the drawn body can never disagree, which is exactly
/// why neither restamped alone before the swap existed (the old F04 deferral).
pub(super) fn stamp_collision_heights(
mut commands: Commands,
creatures: Option<Res<Creatures>>,
+12
View File
@@ -192,6 +192,13 @@ pub(super) struct DisplayModel {
/// §5): `1` = pitch to slope, `3` = pitch+roll, else level. Captured with `parts` on load;
/// `0` for WMO / model-less / still-loading displays.
pub(super) terrain_tilt: u8,
/// Whether this display resolves to a **character body** (a `Character\…` model path) — the
/// gate for the char-customization pipeline (geoset filter + skin composite). The look follows
/// the DISPLAY, not the entity kind (decision 0695): a druid in bear form is a Player-kind
/// entity wearing a plain creature model, and the reference's own race/gender getters answer
/// from the display's cached row, not the unit's descriptor (wow-re `w2d2.md`'s `0x60c690`
/// getter family).
pub(super) is_character_body: bool,
}
/// Resolve a creature display id to a [`DisplayModel`]: load its M2 (no skins — the slots are filled at
@@ -207,6 +214,10 @@ pub(super) fn new_creature_display(
dir: model_dir(&m.model_path).to_string(),
skins: m.textures,
npc_appearance: m.npc_appearance,
is_character_body: m
.model_path
.get(..10)
.is_some_and(|p| p.eq_ignore_ascii_case("character\\")),
..empty_shell()
},
_ => empty_display(),
@@ -261,6 +272,7 @@ pub(super) fn empty_shell() -> DisplayModel {
bbox_z_local: 0.0,
bake_center_local: Vec3::ZERO,
terrain_tilt: 0,
is_character_body: false,
}
}
+25 -3
View File
@@ -409,6 +409,21 @@ pub(super) fn resolve_equipment(
.or_else(|| driver.and_then(|d| d.sheath_state()))
.or_else(|| s.unit_sheath_state())
.unwrap_or(0);
// A player wearing a NON-character display (druid form, GM morph — decision 0695)
// attaches no equipment sub-models at all: the reference's held/helm/shoulder attach
// lives on the CCharacterComponent (`0x47a0c0`, wow-re charactermodel node), which only
// a character body builds — a bear-form druid shows no weapon by construction, not by a
// hide flag. [`Wielded`] (the anim-class pair) still resolves below: what's IN the hand
// is independent of whether its model is displayed. The creature virtual-item path (a
// naga's trident) is a different, unit-level mechanism and rides the Unit arms untouched.
// An unresolved display cache entry (the one-frame window after a live swap) reads as a
// character body — harmless: this diff re-runs every frame, and an attach needs the
// rebuilt body's `BoneAttach` first anyway.
let char_component = net_entity.kind != EntityKind::Player
|| net_entity
.display_id
.and_then(|d| creatures.as_deref()?.models.get(&d))
.is_none_or(|dm| dm.is_character_body);
let mut slots: [Option<HeldSlot>; ATTACH_SLOTS] = [None; ATTACH_SLOTS];
let mut wielded = Wielded::default();
let mut ranged_inv_type = None;
@@ -465,6 +480,9 @@ pub(super) fn resolve_equipment(
}
_ => {}
}
if !char_component {
continue; // wielded resolved; the model never attaches on a non-character body
}
let Some(attach) = placement(slot, inv_type, item_sheath, unit_sheath) else {
continue;
};
@@ -487,7 +505,11 @@ pub(super) fn resolve_equipment(
// display is written per shot from `SMSG_SPELL_START`, any caster; the attach follows the
// client's `$BWP`/`$BWR` keyframes through [`NockLatch`] (`drive_nock_latch`, decision
// 0408 — the arrow appears at the pull and leaves with the release).
if let (Some(ammo), Some(attach)) = (nocked, ammo_attach(ranged_inv_type, nock_latched)) {
if let (true, Some(ammo), Some(attach)) = (
char_component,
nocked,
ammo_attach(ranged_inv_type, nock_latched),
) {
ensure_item_model(
&mut held,
ammo.display_id,
@@ -507,7 +529,7 @@ pub(super) fn resolve_equipment(
// transform override; no cloak conflict). Self-only by construction, exactly like the
// client: bag slots are never replicated in 1.12, so a remote player's scan finds
// nothing (§H1 — a two-client capture would be the clean confirmation).
if net_entity.kind == EntityKind::Player && unit_sheath == 2 {
if net_entity.kind == EntityKind::Player && char_component && unit_sheath == 2 {
let mut quiver_display = None;
for bag in 19u8..23 {
let entry = s
@@ -539,7 +561,7 @@ pub(super) fn resolve_equipment(
// direct ItemDisplayInfo display ids, no template round-trip. A beast NPC (no appearance row)
// resolves nothing here, exactly as before.
let head_shoulder: Option<(u32, u32, u8, u8)> = match net_entity.kind {
EntityKind::Player => {
EntityKind::Player if char_component => {
let race = s.unit_race().unwrap_or(1);
let sex = s.unit_gender().unwrap_or(0).min(1);
let mut resolve = |slot: u8| {
+211
View File
@@ -0,0 +1,211 @@
//! Live descriptor **appearance** changes (decision 0695): a `Values` delta that moves
//! `UNIT_FIELD_DISPLAYID` / `GAMEOBJECT_DISPLAYID` swaps the entity's model in place, and one that
//! moves `OBJECT_FIELD_SCALE_X` eases its render scale — the druid-shapeshift / GM-morph gap
//! (ledger B69/F04) and `NetEntity::scale`'s old standing deferral, closed together because they
//! are one family: the create path interpreted both fields once and nothing ever re-read them.
//!
//! The reference watches both fields through its field-change registry. The DISPLAYID handler
//! reaches the model rebuild (`0x60abe0`), self-gated by "the display record actually changed"
//! (`0x60ae10` against the per-unit display cache `[unit+0xb34]`), re-resolves every model fact
//! from the new display (`0x60afb0 ResolveDisplayInfo`), and re-selects the stand/ride animation
//! (`0x60ce70`) — an **instant** swap, no morph transition (the ghost→alive revive swap rides the
//! same path; a shapeshift's green flash is the spell visual kit, a separate system). The SCALE_X
//! handler instead **eases the render scale over 2 s with a cosine smoothstep** (byte-verified,
//! `0x614bbf`). All wow-re: `questgiver-marker.md` §W6, `w2d2.md` §2.x, `object-layer.md`.
//!
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`] /
//! [`super::equipment::refresh_player_looks`]): the visual was BUILT with [`AppliedDisplay`], the
//! live truth is the descriptor store, and a difference tears the visual down for
//! `attach_entity_visuals` to rebuild — fade-skipped (a shapeshift isn't a spawn), waiting out the
//! new model's async load rather than flashing a cube. The collision height restamps **in the same
//! commit** as the swap — the 0645 rule that the collision box and the drawn body can never
//! disagree is exactly why neither restamped alone before this.
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::net::{Guid, NetEntity, ObjectStore};
use super::collision_height::{collision_height_for, CollisionHeight};
use super::{Creatures, VisualAttached};
/// The reference's scale-ease window: 2 s, cosine smoothstep (`0x614bbf`).
const SCALE_EASE_SECS: f32 = 2.0;
/// On the entity: the display id its current visual was BUILT with — [`refresh_live_display`]'s
/// diff key (the `AppliedEquipment` pattern). Stamped by the attach path on every (re)build,
/// cube fallback included (same read, no churn for a model-less unit); torn down with the visual.
#[derive(Component)]
pub(super) struct AppliedDisplay(pub(super) Option<u32>);
/// A live render-scale ease toward [`NetEntity::scale`]: the reference's 2 s cosine smoothstep
/// (`0x614bbf`), ticked by [`tick_scale_ease`] as absolute writes (a mid-ease visual rebuild's
/// snap is simply overwritten next frame, so the ease survives it).
#[derive(Component)]
pub(super) struct ScaleEase {
from: f32,
to: f32,
elapsed: f32,
}
/// The live descriptor's display id for this entity kind — the values-delta twin of the protocol's
/// create-time interpretation (`events/decode.rs` `display_id`): per-kind field, `0`/absent → `None`
/// (a real morph never zeroes it; the create block's absent-is-zero fold means `0` also reads
/// "never sent", so neither tears a visual down to a cube).
fn live_display_id(kind: EntityKind, store: &ObjectStore) -> Option<u32> {
let raw = match kind {
EntityKind::Unit | EntityKind::Player => store.0.unit_displayid(),
EntityKind::GameObject => store.0.gameobject_displayid(),
_ => None,
}?;
(raw > 0).then_some(raw as u32)
}
/// Diff each attached entity's live descriptor appearance against what its visual was built with,
/// and apply the change: a **display-id** move swaps the model (teardown → rebuild, the
/// [`super::mount::refresh_mounts`] shape) and a **scale** move arms the 2 s ease — both restamp
/// [`CollisionHeight`] in the same commit (its two inputs are exactly these two fields; decision
/// 0645's stamp-once rule was correct only while neither could change).
///
/// The self-avatar needs nothing special: it is the streamed entity (decision 0042), so the swap
/// rebuilds its body like any other unit and `player::mirror_self_collision_height` re-syncs the
/// swim lines from the restamp next frame. Mount children carry no [`ObjectStore`], so they can
/// never take this path (their display is the host's field, diffed by `refresh_mounts`).
#[allow(clippy::type_complexity)]
pub(super) fn refresh_live_display(
mut commands: Commands,
creatures: Option<Res<Creatures>>,
mut entities: Query<
(
Entity,
&Guid,
&mut NetEntity,
&ObjectStore,
&AppliedDisplay,
Option<&CollisionHeight>,
&Transform,
),
With<VisualAttached>,
>,
) {
for (entity, guid, mut net, store, applied, height, tf) in &mut entities {
let mut restamp = false;
// ── The display-id swap ──────────────────────────────────────────────────────────────
let live = live_display_id(net.kind, store);
if let Some(live) = live {
if applied.0 != Some(live) {
info!(
"display swap: guid {:016x} {:?} {:?} -> {} (instant, the 0x60abe0 rebuild)",
guid.0, net.kind, applied.0, live
);
net.display_id = Some(live);
restamp = true;
// The refresh_mounts teardown set + our own diff key: children (parts, joints,
// held roots, mount child) despawn, the per-instance visual components strip, and
// `attach_entity_visuals` rebuilds next frame(s) with the new display —
// fade-skipped via `Reattached` (a shapeshift isn't a spawn).
commands
.entity(entity)
.despawn_related::<Children>()
.remove::<(
VisualAttached,
AppliedDisplay,
super::equipment::AppliedEquipment,
super::mount::AppliedMount,
super::mount::MountChild,
AnimationPlayer,
bevy::animation::transition::AnimationTransitions,
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
.insert(super::equipment::Reattached);
}
}
// ── The scale ease ───────────────────────────────────────────────────────────────────
// The same kinds the create path scales (`events/decode.rs` `object_scale`): a kind whose
// create ignored the field must keep ignoring its deltas, or the first delta would "fix"
// a scale the create deliberately floored to 1.0.
let scaled_kind = matches!(
net.kind,
EntityKind::Unit | EntityKind::Player | EntityKind::GameObject
);
if let Some(live) = store.0.object_scale_x().filter(|s| *s > 0.0 && scaled_kind) {
if live != net.scale {
info!(
"scale change: guid {:016x} {} -> {} (2 s cosine ease, 0x614bbf)",
guid.0, net.scale, live
);
net.scale = live;
restamp = true;
commands.entity(entity).insert(ScaleEase {
from: tf.scale.x,
to: live,
elapsed: 0.0,
});
}
}
// One restamp per frame however many inputs moved: both CollisionHeight inputs live here.
// Snapped to the TARGET scale immediately (the swim/wade/splash lines move once) — easing
// a collision plane would drag the resolver through two seconds of intermediate depths.
if restamp {
let h = collision_height_for(creatures.as_deref(), net.display_id, net.scale);
if height != Some(&h) {
debug!(
"collision height restamp: guid {:016x} {:?} -> {:.3}",
guid.0,
height.map(|c| c.0),
h.0
);
}
commands.entity(entity).insert(h);
}
}
}
/// Tick every live [`ScaleEase`]: `scale(t) = from + (to − from) · (0.5 − 0.5·cos(π·t/2s))` — the
/// reference's cosine smoothstep (`0x614bbf`) — then land exactly on the target and retire.
pub(super) fn tick_scale_ease(
mut commands: Commands,
time: Res<Time>,
mut easing: Query<(Entity, &mut Transform, &mut ScaleEase)>,
) {
for (entity, mut tf, mut ease) in &mut easing {
ease.elapsed += time.delta_secs();
let t = (ease.elapsed / SCALE_EASE_SECS).min(1.0);
let w = 0.5 - 0.5 * (std::f32::consts::PI * t).cos();
tf.scale = Vec3::splat(ease.from + (ease.to - ease.from) * w);
if t >= 1.0 {
tf.scale = Vec3::splat(ease.to);
commands.entity(entity).remove::<ScaleEase>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The ease's shape is the reference's (`0x614bbf`): starts at `from`, cosine-smooth (half-way
/// in value at half-way in time), lands exactly on `to` at 2 s and holds.
#[test]
fn scale_ease_is_the_2s_cosine_smoothstep() {
let w = |elapsed: f32| {
let t = (elapsed / SCALE_EASE_SECS).min(1.0);
0.5 - 0.5 * (std::f32::consts::PI * t).cos()
};
assert_eq!(w(0.0), 0.0);
assert!((w(1.0) - 0.5).abs() < 1e-6); // cos(π/2) = 0 → half-way in value at 1 s
assert_eq!(w(2.0), 1.0);
assert_eq!(w(3.0), 1.0); // clamped past the window
// Smoothstep, not linear: the first quarter of the window covers less than a quarter
// of the value (the eased head), symmetric with the tail.
assert!(w(0.5) < 0.25);
assert!(w(1.5) > 0.75);
}
}
+7
View File
@@ -63,6 +63,13 @@ pub(crate) struct WmoResidency {
}
impl WmoResidency {
/// The change counter — bumped whenever the resident set actually changes. Read by the per-unit
/// room claim (`wmo_portal::track_unit_interiors`), whose re-test gate is otherwise movement
/// alone: a building streaming in under a STANDING unit must still re-claim it.
pub(crate) fn generation(&self) -> u32 {
self.generation
}
/// Replace the resident set, bumping the generation only when it actually changed
/// (order-independent, by asset id) — so the per-frame rebuild doesn't defeat the classifier's
/// movement gate.
-149
View File
@@ -1,149 +0,0 @@
//! Per-frame lighting APPLY for the materials NOT yet on the shared global-light buffer — **WDL +
//! liquid** — plus the sky `ClearColor`. Terrain and models read the one shared light storage buffer
//! ([`super::global_light`]) directly, so they're no longer touched here (that was the per-material
//! re-push that re-created every bind group). WDL (1 material) and liquid (≈3, already rewritten every
//! frame for its ripple-frame index) are negligible, so they keep the simple change-gated push.
//! Split from the resolve half in `super`.
use bevy::prelude::*;
use super::{WowLighting, WATER_SHININESS};
use crate::debug_panel::DebugState;
use crate::terrain::{LiquidMaterial, WdlMaterial};
/// Snapshot of the WoW-lighting inputs (colors + sun dir + material count + Step-5 fog + the
/// fog-disable toggle + farclip). The WDL/liquid push only fires when one of these changes.
// Tuple lanes: (ambient, diffuse, spec, sun_dir, material_count, fog_color_u32_bits,
// fog_params_u32_bits, disable_fog, farclip_bits). Floats stored as raw u32 bits = byte-equality
// without float NaN/-0.0 churn.
type TerrainLightSnap = (
[f32; 3],
[f32; 3],
[f32; 3],
Vec3,
usize,
[u32; 3],
[u32; 2],
bool,
u32,
);
/// Push the `Light.dbc` fog + (for liquid) light onto the **WDL + liquid** materials — the ones still
/// off the shared global-light buffer. Only writes when something changes (a new material appearing, a
/// light/fog transition, or the farclip slider moving). `light_sun.w = 1.0` (full directional); liquid
/// specular shininess is [`WATER_SHININESS`]. (Terrain + models read the shared buffer directly.)
#[allow(clippy::too_many_arguments)] // a Bevy system: each arg is a distinct resource/material store
pub(super) fn apply_wow_lighting(
lighting: Option<Res<WowLighting>>,
debug: Res<DebugState>,
view: Res<crate::view::ViewDistance>,
mut last: Local<Option<TerrainLightSnap>>,
mut wdl_materials: ResMut<Assets<WdlMaterial>>,
mut liquid_materials: ResMut<Assets<LiquidMaterial>>,
liquid_assets: Option<Res<crate::liquid::LiquidAssets>>,
) {
let Some(l) = lighting else {
return;
};
let t = &debug.lighting;
// Hard far-clip "wall" distance (yd) — the reference's projection far plane (`farclip`). Shares the
// `ViewDistance.farclip` with the per-object cull (one "view distance" lever); fed to shaders via
// fog_params.w so models discard fragments beyond it (per-pixel, closest-part-first reveal).
// (Terrain now reads farclip from the shared global-light buffer instead — `lighting::global_light`.)
let farclip = view.farclip;
let snap: TerrainLightSnap = (
l.ambient,
l.diffuse,
l.spec,
l.sun_dir,
wdl_materials.len() + liquid_materials.len(),
[
l.fog_color[0].to_bits(),
l.fog_color[1].to_bits(),
l.fog_color[2].to_bits(),
],
[l.fog_start.to_bits(), l.fog_end.to_bits()],
t.disable_fog,
farclip.to_bits(),
);
if *last == Some(snap) {
return;
}
*last = Some(snap);
// The shared fog + light uniforms (still pushed per-material for model/wdl/liquid until they're
// converted to the shared global-light buffer too — terrain already reads that buffer directly).
// `light_sun.w = 1.0` (full directional / MCSH on). The panel "disable distance fog" toggle forces
// fog off via `fog_color.w`. (The sky-dome horizon is unaffected — it reads the fog *colour*.)
let mut tu = l.terrain_uniforms(t.disable_fog);
// Live hard far-clip wall: terrain.wgsl + wow_model.wgsl read fog_params.w (from the shared buffer)
// and discard fragments beyond it; liquid.wgsl does the same. `wdl.wgsl` reads the SAME value from
// the other side — its band starts at `farclip − 33` and its depth is clamped behind the wall, so
// the horizon backdrop tracks the slider without a seam (decision 0684).
tu.fog_params.w = farclip;
// WDL distant terrain shares the terrain fog exactly (it's unlit white × this fog — the only
// lever it has). One shared material, so this is a single write on each light change.
for (_, m) in wdl_materials.iter_mut() {
m.extension.fog_color = tu.fog_color;
m.extension.fog_params = tu.fog_params;
}
// Water (lake/river/ocean): body colour = `primary · waterTint`, `primary` = the lit vertex colour
// (ambient + N·L·sun), `waterTint` = the per-vertex-depth lerp `shallow_rgb → deep_rgb` of the zone's
// dedicated `Light.dbc` water rows — IntBand 16/17 (river/lake) or 14/15 (ocean), RAW, a 2-endpoint
// swatch (VERIFIED `WoW.exe FUN_0068a830`). The from-above depth cue is this COLOUR gradient PLUS a
// shallow→deep alpha ramp; BOTH are indexed by the same per-vertex V (river/lake V = clamp(byte/42)).
// So we push the per-KIND shallow/deep colours + alphas here every light change — the materials are
// per kind, so we walk `LiquidAssets`' kind→handle map (not a blind material sweep). The `lake_a`
// frame is just the ripple detail; its index is driven separately by `liquid::animate_liquid`.
let water_spec = Vec4::new(
tu.light_spec.x,
tu.light_spec.y,
tu.light_spec.z,
WATER_SHININESS,
);
if let Some(assets) = &liquid_assets {
for (kind, handle) in assets.iter() {
let Some(m) = liquid_materials.get_mut(handle) else {
continue;
};
let (shallow, deep, shallow_a, deep_a) = l.water_colors(kind);
m.extension.light_ambient = tu.light_ambient;
m.extension.light_diffuse = tu.light_diffuse;
m.extension.light_sun = tu.light_sun;
m.extension.light_spec = water_spec;
m.extension.water_shallow = Vec4::new(shallow[0], shallow[1], shallow[2], shallow_a);
m.extension.water_deep = Vec4::new(deep[0], deep[1], deep[2], deep_a);
m.extension.fog_color = tu.fog_color;
m.extension.fog_params = tu.fog_params;
}
}
}
/// **Step 6** — drive the camera's `ClearColor` from the same Light.dbc row-7 fog colour the
/// terrain and model shaders blend in (Step 5). Sky / skybox proper is deferred; for now the
/// backdrop is just the fog colour, so a fully-fogged texel at the far plane lands on the same
/// gamma byte as the void behind it (no horizon seam). q6 RE: the reference client's sky pass
/// actually sets its own tiny 70→75 fog and draws in the fog colour, so the horizon converges on
/// row 7 the same way; we approximate that with `glClearColor` until the sky dome is built
/// (out-of-scope per the plan).
///
/// `Color::srgb` takes gamma-space 0..1 — that's exactly the form `WowLighting.fog_color` carries
/// (the DBC byte triple divided by 255). Bevy's clear pass writes the bytes to the sRGB surface
/// target directly, so the on-screen byte equals the DBC byte (same invariant as the shader output
/// decode). Only writes when the colour actually changes, so frame-to-frame churn stays at zero.
pub(super) fn apply_sky_backdrop(
lighting: Option<Res<WowLighting>>,
mut clear: ResMut<ClearColor>,
mut last: Local<Option<[f32; 3]>>,
) {
let Some(l) = lighting else {
return;
};
if *last == Some(l.fog_color) {
return;
}
*last = Some(l.fog_color);
// GAMMA LANE (0161): the buffer holds gamma bytes — the clear writes the authored DBC
// value RAW (`linear_rgb` = no conversion); the frame's one decode is the FFXGlow combine.
clear.0 = Color::linear_rgb(l.fog_color[0], l.fog_color[1], l.fog_color[2]);
}
+13 -77
View File
@@ -10,20 +10,18 @@ use bevy::prelude::*;
use crate::assets::AssetSet;
use benilla_formats::{LightCatalog, LiquidKind};
mod apply; // the per-frame material/ClearColor push (apply_wow_lighting, apply_sky_backdrop)
mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
use apply::{apply_sky_backdrop, apply_wow_lighting};
pub(crate) use global_light::{
commit_raw, light_blob_bytes, new_shared_light_buffer, pack_model_core_rows, SharedLightBuffer,
LIGHT_HEADER_ROWS,
};
pub(crate) use prop_probes::{prop_probe_region_offset, PropProbeSlot, PropProbes};
pub(crate) use resolve::fog_range;
use resolve::{setup_lighting, update_time_lighting};
use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting};
pub(crate) use sh::prop_probe_coeffs;
/// Scene lighting sampled from `Light.dbc` for the current time of day — fed into the terrain
@@ -162,64 +160,7 @@ pub(crate) struct WowLighting {
pub(crate) cloud_glow_track: f32,
}
/// The terrain/model lighting + fog uniforms resolved from the current [`WowLighting`] — the single
/// source of truth shared by [`apply_wow_lighting`] (the per-frame material push) and
/// the terrain streamer, which seeds a freshly-streamed tile with these so it renders with the
/// right light + fog on its FIRST frame instead of popping in un-fogged / placeholder-lit while it
/// waits a frame for `apply_wow_lighting`. The shared fields (ambient/diffuse/fog) are identical for
/// terrain and models; `light_sun.w` and the specular term differ per surface and are set at the call.
#[derive(Clone, Copy)]
pub(crate) struct TerrainLightUniforms {
pub(crate) light_ambient: Vec4,
pub(crate) light_diffuse: Vec4,
pub(crate) light_sun: Vec4,
pub(crate) light_spec: Vec4,
pub(crate) fog_color: Vec4,
pub(crate) fog_params: Vec4,
}
impl Default for TerrainLightUniforms {
/// Mirrors the streamer's historical placeholders (fog disabled, neutral light) — used only when
/// no [`WowLighting`] exists yet (DBCs absent); `apply_wow_lighting` is also inert then, so a
/// tile keeps these, exactly as before.
fn default() -> Self {
Self {
light_ambient: Vec4::new(0.4, 0.4, 0.4, 2.0),
light_diffuse: Vec4::new(1.0, 1.0, 1.0, 1.0),
light_sun: Vec4::new(0.0, -1.0, 0.0, 0.0),
light_spec: Vec4::ZERO,
fog_color: Vec4::ZERO,
fog_params: Vec4::new(125.0, 500.0, 0.0, 777.0),
}
}
}
impl WowLighting {
/// Resolve the terrain/model fog + light uniforms for the current light. `disable_fog` is the
/// debug-panel toggle (off = faithful, fog enabled). Terrain specular is row 9 + shininess 20
/// (Step 3b); `light_sun.w = 1.0` (full directional / MCSH on) — the model path overrides `.w`
/// with the SH-enable flag at its own call site.
pub(crate) fn terrain_uniforms(&self, disable_fog: bool) -> TerrainLightUniforms {
let fog_enable = if disable_fog { 0.0 } else { 1.0 };
TerrainLightUniforms {
light_ambient: Vec4::new(self.ambient[0], self.ambient[1], self.ambient[2], 1.0),
light_diffuse: Vec4::new(self.diffuse[0], self.diffuse[1], self.diffuse[2], 1.0),
light_sun: self.sun_dir.extend(1.0),
light_spec: Vec4::new(self.spec[0], self.spec[1], self.spec[2], 20.0),
fog_color: Vec4::new(
self.fog_color[0],
self.fog_color[1],
self.fog_color[2],
fog_enable,
),
// .w = the hard far-clip "wall" distance (yd): terrain.wgsl / wow_model.wgsl discard
// fragments with planar eye-Z beyond it, reproducing the reference's projection far plane
// (`farclip`, 777). `apply_wow_lighting` overrides this with the live `ViewDistance.farclip`
// each frame; the 777 seed clips a freshly-streamed tile correctly on its first frame.
fog_params: Vec4::new(self.fog_start, self.fog_end, 0.0, 777.0),
}
}
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. The
/// from-above depth swatch is a plain **2-endpoint linear lerp** of the zone's dedicated `Light.dbc`
/// water rows — IntBand 16/17 (river/lake) or 14/15 (ocean), **RAW** (no ×0.711) — by the per-vertex
@@ -232,6 +173,17 @@ impl WowLighting {
/// colour, not transparency) — VERIFIED vs the apitrace swatch alpha + user-confirmed in-game.
/// (The earlier "water reflects the sky × 0.711 via `FUN_0068c250`" derivation fingered the WRONG
/// builder; the dedicated rows 14-17 we'd originally used were right.)
///
/// The opacity ramp itself is indexed by the raw MCLQ depth byte (0 = shore → 255 = deep) with
/// **no scale** — VERIFIED `WoW.exe FUN_006b6b60` builds `ca7f10[d] = shallow + d·(deep−shallow)/256`
/// from `gWorldLight+0x114/+0x118`, corroborated by the apitrace swatch (`α = 127 + 2·row` ⇒
/// 0.5→1.0 for river/lake, 0.75→1.0 for ocean). The alpha and the colour ride the SAME per-vertex
/// `V`, and the river/lake `V` is the **steep `clamp(byte/42)`** (VERIFIED `c81768` LUT /
/// `FUN_0068d790`) — NOT `byte/255`: a river channel saturates to opaque deep teal by **byte 42 ≈
/// 5 yd** (ramp ≈8.5 byte/yd, VERIFIED `probe_water_depth`), leaving only the shore edge
/// see-through. Ocean uses a different non-LUT path (placeholder `/255`, pending its own RE+A/B).
/// (Earlier bugs: `×8 DEPTH_RAMP_SCALE` saturated at ~4 yd; then the gentle `byte/255` was the
/// WRONG LUT and the river middle never reached teal.)
pub(crate) fn water_colors(&self, kind: LiquidKind) -> ([f32; 3], [f32; 3], f32, f32) {
let (shallow_rgb, deep_rgb, alpha) = if kind == LiquidKind::Ocean {
(
@@ -250,22 +202,6 @@ impl WowLighting {
}
}
/// `Light.dbc` LightParams **water blend alphas** — the depth-ramp opacity endpoints, indexed by the
/// raw MCLQ depth byte (0 = shallow shore → 255 = deep), **no scale**. VERIFIED from `WoW.exe`
/// (`FUN_006b6b60` builds `ca7f10[d] = shallow + d·(deep−shallow)/256` from `gWorldLight+0x114/+0x118`,
/// LightParams 13 for Elwynn) and corroborated by the apitrace swatch (`α = 127 + 2·row` ⇒ 0.5→1.0).
/// River/lake shallow-α = 0.5, ocean = 0.75; both reach 1.0 (opaque) where the swatch coord `V`
/// saturates. The alpha and the colour ride the SAME per-vertex `V`, and the river/lake `V` is the
/// **steep `clamp(byte/42)`** (VERIFIED `WoW.exe c81768` LUT / `FUN_0068d790`) — NOT `byte/255`. So a
/// river channel saturates to opaque + deep-teal by **byte 42 ≈ 5 yd** (the river depth ramp is ≈8.5
/// byte/yd, VERIFIED `probe_water_depth`); only the shore edge stays semi-transparent (the brown bottom
/// shows). Ocean uses a different non-LUT path (placeholder `/255`, pending its own RE+A/B). Per-zone DBC
/// plumbing deferred; LightParams 13 covers Elwynn. (Earlier bugs: `×8 DEPTH_RAMP_SCALE` saturated at ~4
/// yd; then the gentle `byte/255` was the WRONG LUT and the river middle never reached teal.)
pub(crate) const RIVER_SHALLOW_ALPHA: f32 = 0.5;
pub(crate) const OCEAN_SHALLOW_ALPHA: f32 = 0.75;
pub(crate) const WATER_DEEP_ALPHA: f32 = 1.0;
/// Water surface **specular shininess** — the water material's Phong power for the sun sheen
/// (`ffp_material.shininess` at the traced water draw ≈ 6; terrain uses 20). Lower ⇒ a broader,
/// softer glint that spreads at grazing (sunrise/sunset) sun.
@@ -318,7 +254,7 @@ impl Plugin for LightingPlugin {
.add_systems(Startup, setup_lighting.after(AssetSet::Open))
.add_systems(
Update,
(update_time_lighting, apply_wow_lighting, apply_sky_backdrop)
(update_time_lighting, apply_sky_backdrop)
.chain()
// The storm blend reads this frame's weather densities (decision 0302).
.after(crate::weather::WeatherTick),
+29
View File
@@ -452,6 +452,35 @@ pub(super) fn update_time_lighting(
}
}
/// **Step 6** — drive the camera's `ClearColor` from the same Light.dbc row-7 fog colour the
/// terrain and model shaders blend in (Step 5). Sky / skybox proper is deferred; for now the
/// backdrop is just the fog colour, so a fully-fogged texel at the far plane lands on the same
/// gamma byte as the void behind it (no horizon seam). q6 RE: the reference client's sky pass
/// actually sets its own tiny 70→75 fog and draws in the fog colour, so the horizon converges on
/// row 7 the same way; we approximate that with `glClearColor` until the sky dome is built
/// (out-of-scope per the plan).
///
/// `Color::srgb` takes gamma-space 0..1 — that's exactly the form `WowLighting.fog_color` carries
/// (the DBC byte triple divided by 255). Bevy's clear pass writes the bytes to the sRGB surface
/// target directly, so the on-screen byte equals the DBC byte (same invariant as the shader output
/// decode). Only writes when the colour actually changes, so frame-to-frame churn stays at zero.
pub(super) fn apply_sky_backdrop(
lighting: Option<Res<WowLighting>>,
mut clear: ResMut<ClearColor>,
mut last: Local<Option<[f32; 3]>>,
) {
let Some(l) = lighting else {
return;
};
if *last == Some(l.fog_color) {
return;
}
*last = Some(l.fog_color);
// GAMMA LANE (0161): the buffer holds gamma bytes — the clear writes the authored DBC
// value RAW (`linear_rgb` = no conversion); the frame's one decode is the FFXGlow combine.
clear.0 = Color::linear_rgb(l.fog_color[0], l.fog_color[1], l.fog_color[2]);
}
#[cfg(test)]
mod tests {
use super::*;
+103
View File
@@ -0,0 +1,103 @@
//! Liquid (water) rendering: the animated lake/river/ocean surfaces the reference draws over MCLQ
//! geometry. The parse + flat mesh live in `benilla_formats::liquid` (built into each `ChunkMesh.liquid`
//! 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:
//! - **`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.)
//! - **`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).
//!
//! 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
//! the shallow green row). Ocean uses a non-LUT UV path → placeholder `/255` pending its own RE+A/B.
//! (Earlier cuts: ripple-as-colour → black; `×8` → "deep too early"; FLAT colour → "completely gone";
//! sky × 0.711 → wrong builder; `byte/255` → wrong LUT, no teal centre. Faithful = rows 14–17 raw lerp
//! + the /42 V. 2026-05-31.)
//!
//! Two-sided, alpha-blended, depth-write off (Bevy's transparent pass = the verified MCLQ water render
//! state).
//!
//! The frame-flip is the client's first render animation — a deliberate **one-off** (a frame-index
//! uniform off Bevy real `Time`), NOT a general animation system. Two clocks: animation =
//! wall-clock; day/night = server game-time.
//! ## Layout
//!
//! Two concerns, so two files behind this face:
//!
//! * [`query`] — **where the liquid is, and whether you are in it.** The world-space grid every
//! surface publishes ([`WaterChunkInfo`]), the position queries the swim/sound/foam/lighting
//! systems ask it ([`liquid_at`], [`water_surface_at`]), and the per-frame camera submersion
//! verdict ([`Underwater`]). Nothing here renders.
//! * [`surface`] — **the Bevy render glue.** The per-kind animated materials, the two spawn paths
//! (MCLQ and WMO MLIQ), the flat mesh build, and the 24 fps frame cycler.
//!
//! The one cross-feed runs query → lighting: `detect_submersion` publishes WHICH liquid the eye is
//! in, and `lighting::update_time_lighting` selects the whole submerged atmosphere from it.
use bevy::pbr::MaterialPlugin;
use bevy::prelude::*;
use crate::assets::AssetSet;
use crate::terrain::LiquidMaterial;
mod query;
#[cfg(test)]
mod real_data;
mod surface; // the against-real-client-files tests — they span both halves
// The submodules are private, so this list IS the subsystem's face: everything the rest of the
// client may name. `LiquidHit` and `LiquidSurface` are deliberately absent — they are returned and
// spawned, never named from outside; add them here the day something needs to.
pub(crate) use query::{
camera_claim, describe_at, liquid_at, player_claim, unit_claim, water_surface_at, FoamPatch,
LiquidClaim, LiquidSource, Underwater, WaterChunkInfo,
};
pub(crate) use surface::{spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource};
/// The water subsystem: load the per-kind frame arrays + shared materials at startup, then cycle the
/// animation frame each update. Spawning the per-chunk surfaces happens in the terrain streamer (via
/// [`spawn_liquids`], water lives *with* its tile), reading [`LiquidAssets`].
pub(crate) struct LiquidPlugin;
impl Plugin for LiquidPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<LiquidMaterial>::default())
.init_resource::<Underwater>()
.add_systems(Startup, surface::setup_liquid.after(AssetSet::Open))
.add_systems(
Update,
(
surface::animate_liquid,
// AFTER the portal pass, which publishes the camera eye's room this frame
// (`CameraInteriorClaim`): the submersion verdict is the eye's room applied to
// the eye's position, and a frame-old room would flash the wrong atmosphere for
// one frame on every doorway crossing — which, for a whole-screen filter, is
// exactly the artefact that reads worst.
query::detect_submersion.after(crate::wmo_portal::WmoPvsSet),
),
)
// The surface-render kill-switch (see [`hide_liquid_surfaces`]) — inert without the env
// var, so it costs nothing when it isn't being used.
.add_systems(
Update,
surface::hide_liquid_surfaces
.run_if(|| std::env::var_os("WOW_NO_LIQUID").is_some()),
);
}
}
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
//! The liquid subsystem against the **real 1.12.1 client files** — the tests that need actual ADT
//! and WMO bytes rather than a synthetic quad, so they live apart from either half's unit tests and
//! reach across both: they build surfaces the way `super::surface`'s spawn paths do and then ask
//! `super::query` the same questions the running client asks.
//!
//! Every one skips when the client isn't present (the repo never carries Blizzard data).
use bevy::prelude::*;
use super::query::{liquid_at, wet_footprint, LiquidClaim, LiquidSource, WaterChunkInfo};
use crate::wmo_portal::WmoRoom;
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh, LiquidMesh};
/// Every loaded liquid surface covering a position's tile neighbourhood, world-placed and
/// **owner-tagged** — the ADT's own MCLQ chunks and every WMO placement's MLIQ groups, i.e. the same
/// candidate set the running client's `WaterChunkInfo` query sees, scoped the same way.
///
/// Each WMO placement gets a synthetic instance id (its index here) standing in for the
/// `WmoPortalInstance` entity the app spawns; `containing_room` then answers the question the app's
/// interior down-ray answers at runtime — *which* placement the subject is standing in — so a test
/// can pose the real query with the real claim.
struct LiquidScene {
surfaces: Vec<WaterChunkInfo>,
/// Per placement: its synthetic instance id, model path, placement transform, and group
/// bounding boxes (WMO model space) — the containment test.
placements: Vec<Placement>,
}
struct Placement {
instance: Entity,
model: String,
transform: Transform,
group_boxes: Vec<([f32; 3], [f32; 3])>,
}
impl LiquidScene {
/// The room a world position stands in: the first placement one of whose group bounding boxes
/// contains it, and that group.
///
/// A coarser test than the app's down-ray (which races collision FACES and the terrain), and
/// deliberately so — a test that re-implemented the down-ray would be pinning the test's copy of
/// it. Group-box containment is enough to answer "which building is this position in", which is
/// the only thing these two sites turn on.
fn containing_room(&self, wow: [f32; 3]) -> Option<WmoRoom> {
self.placements.iter().find_map(|p| {
let local = bevy_to_wow(
p.transform
.compute_affine()
.inverse()
.transform_point3(wow_to_bevy(wow)),
);
let gi = p
.group_boxes
.iter()
.position(|(lo, hi)| (0..3).all(|i| local[i] >= lo[i] && local[i] <= hi[i]))?;
Some(WmoRoom {
instance: p.instance,
group: gi as u16,
})
})
}
/// Which model path a room's placement came from — so a test can state *which building* it
/// believes the player is in rather than trusting an index.
fn model_of(&self, room: WmoRoom) -> &str {
self.placements
.iter()
.find(|p| p.instance == room.instance)
.map_or("<none>", |p| p.model.as_str())
}
}
/// Build the [`LiquidScene`] around a position. Empty (`None`) when the client isn't present.
fn liquid_scene(map: &str, wow: [f32; 3]) -> Option<LiquidScene> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
return None;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let (cx, cy) = benilla_formats::world_to_tile(wow[0], wow[1]);
let mut scene = LiquidScene {
surfaces: Vec::new(),
placements: Vec::new(),
};
let mut seen_placements: Vec<u32> = Vec::new();
// A building as big as Blackrock is placed in every tile it straddles, so its MODF may sit
// in a neighbour of the tile the position falls in.
for dx in -1i32..=1 {
for dy in -1i32..=1 {
let (tx, ty) = ((cx as i32 + dx) as u32, (cy as i32 + dy) as u32);
let Ok(tile) = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty) else {
continue;
};
for lq in tile.chunks.iter().filter_map(|c| c.liquid.as_ref()) {
scene.surfaces.push(wet_footprint(
lq,
&Transform::IDENTITY,
LiquidSource::AdtChunk,
));
}
for w in &tile.wmos {
// A straddling building appears in each tile's MODF — spawn it once, as the
// streamer does (it dedups on the same `unique_id`).
if seen_placements.contains(&w.unique_id) {
continue;
}
seen_placements.push(w.unique_id);
let transform = Transform {
translation: wow_to_bevy(w.position),
rotation: placement_rotation(w.rotation),
scale: Vec3::ONE,
};
let root_path = w.model.to_ascii_lowercase();
let Ok(bytes) = chain.read_file(&root_path) else {
continue;
};
let Ok(root) = parse_wmo_root(&bytes) else {
continue;
};
let instance =
Entity::from_raw_u32(scene.placements.len() as u32).expect("valid entity id");
scene.placements.push(Placement {
instance,
model: root_path.clone(),
transform,
group_boxes: root
.group_infos()
.iter()
.map(|g| (g.bbox_min, g.bbox_max))
.collect(),
});
let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path);
for gi in 0..root.group_count() {
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
if let Some(lq) = wmo_group_liquid_mesh(&gb) {
scene.surfaces.push(wet_footprint(
lq_ref(&lq),
&transform,
LiquidSource::WmoGroup(Some(WmoRoom {
instance,
group: gi as u16,
})),
));
}
}
}
}
}
(!scene.surfaces.is_empty()).then_some(scene)
}
fn lq_ref(lq: &LiquidMesh) -> &LiquidMesh {
lq
}
/// The verdict at a position, and the highest wet vertex among the surfaces that claim it —
/// i.e. what the query answers now, beside what the chunk-maximum rule used to answer.
fn verdict(map: &str, wow: [f32; 3], claim: LiquidClaim) -> Option<(f32, f32)> {
let all = liquid_scene(map, wow)?.surfaces;
let hit = liquid_at(all.iter(), wow, claim)?;
let old = all
.iter()
.filter(|w| w.surface_z_at(wow[0], wow[1]).is_some())
.map(|w| w.chunk_max_z())
.fold(f32::MIN, f32::max);
Some((hit.surface_z, old))
}
/// **Blackrock Mountain's lava** at the director's `.go xyz -7531.21 -1123.64 172.58` (indoors,
/// `blackrock.wmo` group 038 — a 55×82 magma grid running 167.29 → 175.00 under a ~7° yaw
/// placement). The old chunk-maximum answered 175.00, i.e. 2.42 yd OVER the feet and well past
/// the 1.52 yd swim line, on a staircase whose lava is metres below.
#[test]
fn blackrock_lava_is_below_the_feet_not_above_it() {
let feet = [-7531.21_f32, -1123.64, 172.58];
// Blackrock's own placement owns the magma, and the player is in it.
let scene = liquid_scene("Azeroth", feet);
let claim = scene
.as_ref()
.and_then(|s| s.containing_room(feet))
.map_or(LiquidClaim::Unknown, LiquidClaim::Inside);
let Some((surface, old_max)) = verdict("Azeroth", feet, claim) else {
eprintln!("skipping: no WoW client data");
return;
};
assert!(
(old_max - 175.00).abs() < 0.05,
"the chunk maximum this replaces (got {old_max})"
);
assert!(
(surface - 168.45).abs() < 0.05,
"the lava under the feet is the cell's own height (got {surface})"
);
assert!(
surface < feet[2],
"surface {surface} must be UNDER the feet {} — standing on the stairs, not swimming",
feet[2]
);
}
/// **Felfire Hill's river** at the director's `.go xyz 1983.97 -2875.84 98.00` (outdoors, one
/// MCNK's 9×9 MCLQ falling 95.78 → 99.56 across the chunk). The old chunk-maximum answered
/// 99.56 — 1.56 yd over the feet, just past the 1.52 yd swim line — while the player stands on
/// the bank with the water at their soles.
#[test]
fn felfire_hill_river_does_not_swim_on_the_bank() {
let feet = [1983.97_f32, -2875.84, 98.00];
let Some((surface, old_max)) = verdict("Kalimdor", feet, LiquidClaim::Outdoors) else {
eprintln!("skipping: no WoW client data");
return;
};
assert!(
(old_max - 99.56).abs() < 0.05,
"the chunk maximum this replaces (got {old_max})"
);
assert!(
surface < feet[2],
"surface {surface} must be UNDER the feet {} — standing on the bank",
feet[2]
);
assert!(
(feet[2] - surface) < 1.0,
"…but only just: the water is at the player's soles (got {surface})"
);
}
/// **B85 — Uldaman reads as UNDERWATER** (director repro, `.go xyz -6152.73 -2969.59 213.73`; the
/// `/liquid` line read `VERDICT Still surface z 399.64 (+185.91 over feet)`, `WmoGroup … WET-CELL`).
///
/// The claiming pool is **another building's**. At these feet the player stands in
/// `kz_uldaman_a.wmo` (WMOAreaTable id 1218) group 22 — which carries no MLIQ over this XY at all —
/// while the surface answering `+185.91` is group 1 of a nearby `md_mushroomcave.wmo` placement,
/// whose every group box excludes the player: their feet sit at local z −191.15 under a pool that
/// runs local z −5.65…0.00, i.e. 186 yd overhead in a cave they have never entered.
///
/// A footprint has no floor, so before 0696 "indoors" (a bare bool) admitted every MLIQ surface on
/// the map and the lowest one won. The two arms below are the bug and the fix on the same bytes.
#[test]
fn uldaman_is_not_submerged_in_a_mushroom_caves_pool() {
let feet = [-6152.73_f32, -2969.59, 213.73];
let Some(scene) = liquid_scene("Azeroth", feet) else {
eprintln!("skipping: no WoW client data");
return;
};
// The bug, on the shipped files: with no owner scoping, a pool 185.91 yd up claims the player.
// (There is no ADT liquid over this XY, so `Unknown` here is exactly the pre-0696 "indoors".)
let unscoped = liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown)
.expect("the mushroom cave's pool is what B85 reported");
assert!(
(unscoped.surface_z - 399.64).abs() < 0.05,
"the surface B85 reported (got {})",
unscoped.surface_z
);
assert!(unscoped.surface_z - feet[2] > 185.0, "…and 186 yd overhead");
// The fix: the player's room is Uldaman's own placement, and Uldaman has no pool here.
let room = scene
.containing_room(feet)
.expect("the player stands inside a placement");
assert!(
scene.model_of(room).contains("uldaman"),
"the containing placement is Uldaman, not the cave (got {})",
scene.model_of(room)
);
assert!(
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Inside(room)).is_none(),
"in Uldaman, no liquid — the cave's pool belongs to a building the player is not in"
);
}
/// **B60 — Undercity's Rogues' Quarter under Tirisfal's water** (director repro,
/// `.go xyz 1414.08 53.00 -62.26`; the `/liquid` line read `AdtChunk Still WET-CELL surface z 32.93
/// (+95.19 over feet)` while the player's own VERDICT was already `none`).
///
/// The ADT chunk's lake covers the XY 95 yd overhead and the rooms are cut into the rock beneath it.
/// The player's query had delegated since 0634 and read dry; the **camera-eye** probe and the
/// **per-unit** swim marker had not, so the screen took the underwater filter and the NPCs swam on
/// dry stone. All three subjects run this one predicate now, so the assertion below is what each of
/// them asks.
#[test]
fn the_rogues_quarter_is_not_under_tirisfals_lake() {
let feet = [1414.08_f32, 53.00, -62.26];
let Some(scene) = liquid_scene("Azeroth", feet) else {
eprintln!("skipping: no WoW client data");
return;
};
// The control: the ADT surface really is there, really covers this XY, and really is 95 yd up —
// an un-delegated subject (the pre-0696 camera and creature marker) is submerged in it.
let unscoped = liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown)
.expect("Tirisfal's water covers this XY");
assert!(
(unscoped.surface_z - 32.93).abs() < 0.05
&& (unscoped.surface_z - feet[2] - 95.19).abs() < 0.05,
"the surface B60 reported (got {})",
unscoped.surface_z
);
// The fix: a subject standing in Undercity reads Undercity's own liquid — and there is none
// over this XY, in any of its 38 liquid groups.
let room = scene
.containing_room(feet)
.expect("the player stands inside a placement");
assert!(
scene.model_of(room).contains("undercity"),
"the containing placement is Undercity (got {})",
scene.model_of(room)
);
assert!(
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Inside(room)).is_none(),
"indoors, the ADT lake overhead is not this subject's liquid"
);
}
/// **The gradient the swim law is sized against** (decision 0644): Felwood's Felfire Hill
/// channel, along the run the live probe swam. A liquid surface is a heightfield, and *how far
/// from flat* is exactly what decides whether the swim latch's 1/36-yd hysteresis band can
/// absorb travelling along it — so the slope `player::swim`'s regression test drives is pinned
/// here against the shipped ADT instead of living as a constant someone can only take on faith.
#[test]
fn the_felfire_channel_falls_about_a_tenth_of_a_yard_per_yard() {
let (downstream, upstream) = ([1953.97_f32, -2866.84, 0.0], [2013.97_f32, -2866.84, 0.0]);
let Some(all) = liquid_scene("Kalimdor", downstream).map(|s| s.surfaces) else {
eprintln!("skipping: no WoW client data");
return;
};
let z = |at: [f32; 3]| {
liquid_at(all.iter(), at, LiquidClaim::Outdoors)
.unwrap_or_else(|| panic!("no river at {at:?}"))
.surface_z
};
let slope = (z(upstream) - z(downstream)) / (upstream[0] - downstream[0]);
assert!(
(slope - 0.099).abs() < 0.005,
"the channel's gradient over 60 yd (got {slope})"
);
}
+513
View File
@@ -0,0 +1,513 @@
//! **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), the flat mesh build, and the 24 fps
//! frame cycler. 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`].
//!
//! The frame-flip is the client's first render animation — a deliberate **one-off** (a frame-index
//! uniform off Bevy real `Time`), NOT a general animation system. Two clocks: animation =
//! wall-clock; day/night = server game-time.
use std::collections::HashMap;
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use super::query::{wet_footprint, FoamPatch, LiquidSource};
use crate::assets::LockRecover;
use crate::assets::{liquid_frame_array, RenderConfig, WorldAssets};
use crate::lighting::WATER_SHININESS;
use crate::terrain::{LiquidExt, LiquidMaterial};
use crate::wmo_portal::WmoRoom;
use benilla_assets::coords::wow_to_bevy;
use benilla_formats::{read_texture_mip_chain, BlpMipChain, LiquidKind, LiquidMesh};
/// Frame-flip rate — 30 frames over 1.25 s (VERIFIED `FUN_0068aac0`), i.e. 24 fps, real wall-clock.
const ANIM_FPS: f32 = 24.0;
/// The shared liquid materials, keyed by [`LiquidKey`], plus each one's animated frame count (for
/// the modulo in `animate_liquid`). Read by the terrain streamer (via [`spawn_liquids`] /
/// [`spawn_wmo_liquids`]) to material the per-chunk water meshes. Absent when the client has no data
/// (no `WorldAssets`).
#[derive(Resource, Default)]
pub(crate) struct LiquidAssets {
materials: HashMap<LiquidKey, LiquidEntry>,
}
/// Which shared material a surface takes: its kind, and **which fog block it draws under**.
///
/// 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 two 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,
}
struct LiquidEntry {
material: Handle<LiquidMaterial>,
frame_count: u32,
}
impl LiquidAssets {
/// The shared material for a liquid kind on a given fog block, if its frames loaded.
pub(crate) fn material(
&self,
kind: LiquidKind,
interior: bool,
) -> Option<Handle<LiquidMaterial>> {
self.materials
.get(&LiquidKey { kind, interior })
.map(|e| e.material.clone())
}
}
/// Marks a spawned water surface (one per liquid MCNK chunk), so it can be queried/culled as a group.
#[derive(Component)]
pub(crate) struct LiquidSurface;
/// Attribution kill-switch: `$WOW_NO_LIQUID=1` hides every liquid **surface**, and only the surface —
/// the swim grid, the submersion verdict, the foam and the ambient loops all stay live, because those
/// ride sibling components on the same entity ([`WaterChunkInfo`], [`FoamPatch`],
/// [`LiquidSoundSource`]) rather than its `Visibility`.
///
/// It exists because "is that thing occluding the NPC a liquid surface?" was otherwise unanswerable
/// from inside the game. A liquid surface is opaque where it is deep (`WATER_DEEP_ALPHA` = 1.0) and
/// fully opaque for the fullbright kinds, and it carries no silhouette of its own that reads as
/// *water* at a glancing angle — so a mis-placed one is indistinguishable by eye from a mis-placed
/// wall, and both look like a hard straight seam across the scene. One A/B now separates them; the
/// alternative was a screenshot argument. Same shape as `$WOW_NO_PARTICLES` / `$WOW_NO_FFX`.
///
/// Runs on `Added` so it costs one filtered query per frame and catches streamed-in surfaces too
/// (both spawn paths insert [`LiquidSurface`], MCLQ and WMO alike).
pub(super) fn hide_liquid_surfaces(mut surfaces: Query<&mut Visibility, Added<LiquidSurface>>) {
for mut vis in &mut surfaces {
*vis = Visibility::Hidden;
}
}
/// What the **above-water ambient-loop system** needs beyond the surface's geometry (wow-re
/// `liquid-ambience-loop.md`, decision 0506): the sound-class nibble the driver resolves through
/// `SoundWaterType.dbc`. Attached to **every** liquid surface, the fullbright kinds included (the
/// Ironforge lava rumble, Undercity slime).
///
/// It used to carry its own copy of the footprint — bounds + a surface height — because when 0506
/// wrote it, magma and slime carried no [`WaterChunkInfo`] to read them from. 0634 gave every kind
/// one (that is what made lava swimmable), so the copy became a third set of numbers describing the
/// same surface, and its height stayed the chunk maximum after the grid sample landed. The driver
/// queries `(&LiquidSoundSource, &WaterChunkInfo)` instead — the pairing both spawn paths already
/// guarantee — and reads the geometry from the one component that owns it.
#[derive(Component)]
pub(crate) struct LiquidSoundSource {
/// The surface's sound-class nibble (`class = n & 3`, `FluidSpeed = n & 0xc`).
pub(crate) nibble: u8,
}
/// Spawn a set of water surfaces — one flat mesh per [`LiquidMesh`], on its [`LiquidKind`]'s shared
/// animated material. Used by the `AdtTile` pipeline (`terrain_stream`). No-op when the client has no
/// data (`liquid_assets` absent) or a kind's frames didn't load. Spawned entities are pushed onto
/// `entities` so they despawn with their tile.
pub(crate) fn spawn_liquids<'a>(
commands: &mut Commands,
liquids: impl Iterator<Item = &'a LiquidMesh>,
liquid_assets: Option<&LiquidAssets>,
meshes: &mut Assets<Mesh>,
entities: &mut Vec<Entity>,
) {
let Some(liquid) = liquid_assets else {
return;
};
for lq in liquids {
// ADT liquid always takes the SCENE fog: the ADT liquid passes submit no fog block of their
// own, so they draw under the once-a-frame scene submit (wow-re `fog-env-state` §5).
let Some(material) = liquid.material(lq.kind, 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
// transform is a no-op round-trip).
let info = wet_footprint(lq, &Transform::IDENTITY, LiquidSource::AdtChunk);
let foam = !lq.kind.is_fullbright(); // white surf is a water thing
entities.push(
commands
.spawn((
Mesh3d(meshes.add(liquid_bevy_mesh(lq))),
MeshMaterial3d(material),
Transform::IDENTITY,
LiquidSurface,
info,
LiquidSoundSource {
nibble: lq.sound_nibble,
},
))
.id(),
);
// Foam is water-only; the cells it clips against ride `info`, not the marker.
if foam {
commands
.entity(*entities.last().expect("just pushed"))
.insert(FoamPatch);
}
}
}
/// Build the Bevy render mesh for one [`LiquidMesh`]: positions mapped WoW→Bevy (`lq.positions` are
/// raw WoW coords — absolute for MCLQ, WMO-model-local for WMO liquid), a flat up normal, the tiling
/// 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 {
let positions: Vec<[f32; 3]> = lq
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
let n = positions.len();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
// Flat surface: WoW up (0,0,1) → Bevy up (0,1,0). The shader lights against this (rotated into
// world by the entity transform) + the sun.
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, lq.uvs.clone());
// 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);
mesh.insert_indices(Indices::U32(lq.indices.clone()));
mesh
}
/// Spawn a WMO group's embedded liquid surfaces (Stormwind's canals + fountains, the Ironforge lava,
/// dungeon pools) at the building's placement `transform`, on the shared per-kind liquid material —
/// the same animated water render as MCLQ, but its geometry is WMO-model-local (built by
/// `benilla_formats::wmo_group_liquid_mesh`) so the placement transform lifts it into the world.
///
/// No-op when the client has no data (`liquid_assets` absent) or a kind's frames didn't load. Each
/// WATER surface also carries a world-space [`WaterChunkInfo`] + [`FoamPatch`] (both built by baking the
/// placement transform into the raw liquid coords, [`world_wow`]) so the whole water-interaction stack
/// sees WMO liquid exactly like MCLQ: swimming ([`crate::player::swim`]), the underwater murk
/// ([`detect_submersion`]), the wading splash/footstep sounds, AND the `CWater0Ripple` wade wake /
/// standing ring ([`crate::water_fx`], which builds each foam decal from the wet-cell lattice). The
/// foam's world-axis texgen + per-triangle overlap consume the transformed cells fine, so a rotated
/// canal's ring is still correctly world-oriented. Spawned entities are pushed onto `entities` so they
/// despawn with the placement.
///
/// `interior` is the owning group's `MOGI & 0x48 == 0` class, and it selects the **fog block** the
/// surface draws under — the Great Forge's lava hazes with the forge's own fog, Stormwind's open
/// canals with the sky's (see [`LiquidKey`]).
///
/// `owner` is the [`WmoRoom`] the pool belongs to — the query's **scope key** (decision 0696). A
/// liquid footprint has no floor, so a pool with no owner claims every position under its XY
/// anywhere on the map: the Uldaman entrance read as submerged under a mushroom cave's water 186 yd
/// overhead. `None` only for a placement that spawned no instance entity (see [`LiquidSource`]).
#[allow(clippy::too_many_arguments)] // one param per concern: assets, placement, fog block, owner
pub(crate) fn spawn_wmo_liquids<'a>(
commands: &mut Commands,
liquids: impl Iterator<Item = &'a LiquidMesh>,
liquid_assets: Option<&LiquidAssets>,
meshes: &mut Assets<Mesh>,
transform: Transform,
interior: bool,
owner: Option<WmoRoom>,
entities: &mut Vec<Entity>,
) {
let Some(liquid) = liquid_assets else {
return;
};
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
// the WMO geometry pass — so the pool fogs exactly like the walls around it (see [`LiquidKey`]).
let Some(material) = liquid.material(lq.kind, interior) else {
continue; // this kind's frames failed to load (warned at setup)
};
let surface = commands
.spawn((
Mesh3d(meshes.add(liquid_bevy_mesh(lq))),
MeshMaterial3d(material),
transform,
LiquidSurface,
// The ambient-loop source rides EVERY kind — the fullbright lava/slime hum too
// (0506). It reads its geometry off the `WaterChunkInfo` inserted below.
LiquidSoundSource {
nibble: lq.sound_nibble,
},
))
.id();
// The swim/submersion grid rides EVERY kind, magma and slime included — that is what
// makes Blackrock's lava and Undercity's slime swimmable instead of something you fall
// through (decision 0634, bugs B24/B25). It used to be gated on `!is_fullbright()` because
// `WaterChunkInfo` carried no kind, so tagging lava would have swum the player under a teal
// *water* murk with white foam. The component carries [`LiquidKind`] now and the
// water-flavoured consumers filter on it (`water_surface_at`, `detect_submersion`), so the
// exclusion is no longer what keeps lava from looking like a lake.
//
// Lava/slime **damage** is still not modelled — a named gap, not a reason to keep the
// geometry non-solid.
commands.entity(surface).insert(wet_footprint(
lq,
&transform,
LiquidSource::WmoGroup(owner),
));
// Foam stays water-only: it is white surf, and there is no such thing on magma.
if !lq.kind.is_fullbright() {
commands.entity(surface).insert(FoamPatch);
}
entities.push(surface);
}
}
/// Each kind's animated frame set: `(kind, XTextures subdir, file stem, frame count on disk)`.
/// Frames are `XTextures\<dir>\<stem>.<1..=count>.blp` (256² RGBA, RGB dark + alpha ripple).
const FRAME_SETS: &[(LiquidKind, &str, &str, u32)] = &[
(LiquidKind::Still, "river", "lake_a", 30),
(LiquidKind::Rapids, "river", "fast_a", 16),
(LiquidKind::Ocean, "ocean", "ocean_h", 30),
// WMO-liquid-only kinds (magma/slime carry no MCLQ data). Opaque + unlit + fogged: the animated
// texture IS the body colour, there being no vertex colour or depth LUT to modulate it by
// (VERIFIED wow-re `liquid-render-state-sided` §5).
(LiquidKind::Magma, "lava", "lava", 30),
(LiquidKind::Slime, "slime", "slime", 30),
];
pub(super) fn setup_liquid(
mut commands: Commands,
config: Option<Res<RenderConfig>>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
) {
let (Some(_config), Some(mut world_assets)) = (config, world_assets) else {
return; // no client data → no terrain, so no water either
};
// No light seed and no per-frame push: light, fog and both water swatches come off the shared
// global-light buffer (`lighting::global_light`), which `build_light_data` has already packed by
// the time anything draws — the same path terrain and the models take.
let mut assets = LiquidAssets::default();
for &(kind, dir, stem, count) in FRAME_SETS {
let Some((frames, frame_count)) =
load_frame_array(&mut world_assets, &mut images, dir, stem, count)
else {
warn!("liquid: no frames for {stem} — {kind:?} water will not render");
continue;
};
// Blend state is per KIND, and this is where it is decided (VERIFIED wow-re
// `liquid-render-state-sided` — the device render-state *defaults* at `0x593bf0` are the baseline
// a liquid batch draws under, because every setter in the reference is Push/Pop-scoped):
//
// * water / ocean — the reference sets EGxBlend 2 (SRC_ALPHA / INV_SRC_ALPHA) and depth-write
// OFF, which is exactly Bevy's `AlphaMode::Blend` transparent pass. Unchanged. (Both are
// gated on the fancy-water cvar `[0xc9a324]`, which we do not model — a separate lane.)
// * magma / slime — blend state stays at the default **0**, i.e. `glDisable(GL_BLEND)`, with
// the baseline depth test *and* depth write both ON (ids `0x10`/`0x12` = 1). The ADT lava
// pass says so explicitly (`0x6855e2 (0x07, 0)`) and the WMO magma/slime arm never touches
// blend at all. So they are genuinely opaque and belong in the OPAQUE pass: riding the
// transparent pass with depth-write off is how a lava sheet fails to occlude what is behind
// it. Foam already carries `depth_bias: 1.0` for the coplanar tie, so it still wins.
//
// Two-sided is universal — all four liquid passes force GL_CULL_FACE off at entry against a
// cull-ON baseline, and `glFrontFace` is never imported, so winding is moot (§6).
let alpha_mode = if kind.is_fullbright() {
AlphaMode::Opaque
} else {
AlphaMode::Blend
};
// Two materials per kind — one per FOG BLOCK (see [`LiquidKey`]) — 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.
for interior in [false, true] {
let material = materials.add(ExtendedMaterial {
base: StandardMaterial {
// We do our own (WoW) lighting in the shader.
unlit: true,
alpha_mode,
cull_mode: None,
double_sided: true,
..default()
},
extension: LiquidExt {
frames: frames.clone(),
// x = fullbright (magma/slime: the animated texture is the opaque body, skipping
// the swatch and N·L — but NOT the fog, which every liquid kind takes); y = read
// the ocean swatch rows rather than the river/lake ones; z = fog with the WMO
// INTERIOR block; w = water's own sun-sheen exponent.
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 },
WATER_SHININESS,
),
// x = frame 0 (index driven by `animate_liquid`); y = frame count; zw unused.
anim: Vec4::new(0.0, frame_count as f32, 0.0, 0.0),
light_buf: world_assets.shared_light.clone(),
},
});
assets.materials.insert(
LiquidKey { kind, interior },
LiquidEntry {
material,
frame_count,
},
);
}
}
// Frame SETS, not materials — each set backs both fog-block variants.
info!(
"liquid: loaded {} water frame set(s)",
assets.materials.len() / 2
);
commands.insert_resource(assets);
}
/// Decode frames `1..=count` for a kind — each with its BLP **authored mip chain** — into one
/// repeating, mipmapped + anisotropic `texture_2d_array` (`assets::liquid_frame_array`; mips are what
/// stop the ripple aliasing into sparkle at distance). Stops at the first missing/non-square/
/// size-mismatched frame (the on-disk sets are contiguous 256² runs). Returns the image handle + the
/// number of frames actually loaded, or `None` if none decoded.
fn load_frame_array(
world_assets: &mut WorldAssets,
images: &mut Assets<Image>,
dir: &str,
stem: &str,
count: u32,
) -> Option<(Handle<Image>, u32)> {
let mut frames: Vec<BlpMipChain> = Vec::new();
let mut size = 0u32;
for i in 1..=count {
let path = format!("XTextures\\{dir}\\{stem}.{i}.blp");
let Ok(chain) = read_texture_mip_chain(&mut world_assets.chain.lock_recover(), &path)
else {
break;
};
if chain.width != chain.height {
break; // water frames are square; bail rather than build a ragged array
}
if size == 0 {
size = chain.width;
} else if chain.width != size {
break; // a frame at a different resolution can't share the array
}
frames.push(chain);
}
if frames.is_empty() {
return None;
}
let loaded = frames.len() as u32;
Some((images.add(liquid_frame_array(frames)), loaded))
}
/// Advance every liquid material's frame index at [`ANIM_FPS`] off Bevy **real** `Time` (wall-clock,
/// mirroring the reference's `GetTickCount`-driven cycler — NOT the day/night game clock). Writes
/// only on the [`ANIM_FPS`] tick edge: `Assets::get_mut` alone marks the asset Modified and feeds
/// the respecialization pipeline (the mark-changed scan + `Changed<Mesh3d>` sweeps) every frame —
/// the 0353 demand-price law; between ticks the frame index cannot have changed.
pub(super) fn animate_liquid(
time: Res<Time>,
liquid: Option<Res<LiquidAssets>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
mut last_ticks: Local<Option<u32>>,
) {
let Some(liquid) = liquid else {
return;
};
// Captures pin the cycler to frame 0: the wall-clock at screenshot time varies with load
// times, so any framing with open water diffs differently run to run — the flake substrate's
// baseline redesign caught (MAE 3.97 → 0.009 pinned; decision 0600). One clause, one frame.
let ticks = if crate::capture::scenario_active() {
0
} else {
(time.elapsed_secs() * ANIM_FPS) as u32
};
if *last_ticks == Some(ticks) {
return;
}
*last_ticks = Some(ticks);
for entry in liquid.materials.values() {
if let Some(m) = materials.get_mut(&entry.material) {
m.extension.anim.x = (ticks % entry.frame_count.max(1)) as f32;
}
}
}
/// Which fog block a WMO pool takes, against the **real client data**. Skips when the 1.12.1 client
/// isn't present (the repo never carries Blizzard data).
#[cfg(test)]
mod real_data {
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh};
/// **Which fog block each liquid-bearing WMO group takes**, against the real client files.
///
/// This is decision 0691's follow-on lane. A WMO group's own pool is drawn by the pass that
/// re-submits the smoothed INTERIOR fog block (`0x6b6323`-`0x6b6342`), gated on the same
/// `[0xca7f00]` as the WMO *geometry* pass — so an indoor pool hazes with its room and an open-air
/// one must not. `spawn_wmo_liquids` decides that from the group's `MOGI & 0x48 == 0`, and an
/// inverted flag is invisible in any screenshot taken where the two blocks happen to agree (in dry
/// Undercity both clamp to 1 within ~58 yd). So pin it on the two buildings that straddle the line,
/// with the real numbers:
///
/// * **Undercity** — 38 liquid groups, and all but ONE are interior. Its slime canals are the
/// director's original report; the lone exterior one (group 7) is the granularity working, not
/// noise, and is what makes this a real two-sided test rather than a constant.
/// * **Stormwind** — 22 liquid groups (canals + fountains), every one EXTERIOR. Reading these as
/// interior would haze the open city's water with an indoor triple under an open sky.
///
/// Skips when the 1.12.1 client isn't present (the repo never carries Blizzard data).
#[test]
fn a_wmo_pools_fog_block_follows_its_groups_interior_class() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: no WoW client data");
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
// Which groups of a WMO carry liquid, and whether each is an interior group.
let liquid_groups = |chain: &mut benilla_formats::Chain, root_path: &str| {
let bytes = chain.read_file(root_path).expect("root readable");
let root = parse_wmo_root(&bytes).expect("parse root");
let stem = root_path
.strip_suffix(".wmo")
.unwrap_or(root_path)
.to_string();
(0..root.group_count() as usize)
.filter_map(|gi| {
let gb = chain.read_file(&format!("{stem}_{gi:03}.wmo")).ok()?;
wmo_group_liquid_mesh(&gb)?;
Some((gi, root.group_infos().get(gi).is_some_and(|g| g.interior)))
})
.collect::<Vec<_>>()
};
let uc = liquid_groups(&mut chain, "world\\wmo\\lorderon\\undercity\\undercity.wmo");
let exterior: Vec<usize> = uc.iter().filter(|(_, i)| !i).map(|(g, _)| *g).collect();
assert_eq!(uc.len(), 38, "Undercity's liquid group count moved: {uc:?}");
assert_eq!(
exterior,
vec![7],
"exactly one Undercity liquid group is exterior (the flag is per group, not per building)",
);
let sw = liquid_groups(
&mut chain,
"world\\wmo\\azeroth\\buildings\\stormwind\\stormwind.wmo",
);
assert_eq!(sw.len(), 22, "Stormwind's liquid group count moved: {sw:?}");
assert!(
sw.iter().all(|(_, interior)| !interior),
"Stormwind's canals and fountains are open to the sky — none takes the interior fog: {sw:?}",
);
}
}
+4
View File
@@ -223,6 +223,7 @@ mod tests {
],
fogs: Vec::new(),
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
collision_bounds: None,
collision: None,
@@ -237,6 +238,7 @@ mod tests {
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
};
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
@@ -303,6 +305,7 @@ mod tests {
group_nav: vec![nav(0.0, 3.0, 0, 1), nav(-303.0, -300.0, 1, 1)],
fogs: Vec::new(),
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
collision_bounds: None,
collision: None,
@@ -317,6 +320,7 @@ mod tests {
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
};
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
+5 -3
View File
@@ -130,9 +130,11 @@ pub(crate) struct NetEntity {
/// the DBC scale (`CreatureModelData.modelScale × CreatureDisplayInfo.scale`, or a per-spawn
/// override) into it, so the renderer ([`crate::entities`]) bakes this onto the transform *alone*,
/// never times its own DBC scale (that would double-apply). `1.0` is the default (no rescale).
/// Deferred: a *live* SCALE_X change (a values delta on an existing unit) isn't applied at all
/// yet — the reference eases the render scale to the new value over 2 s with a cosine smoothstep
/// (byte-verified, `0x614bbf`; the code once misread as a selection fade-in).
/// A *live* SCALE_X change (a values delta on an existing unit) updates this and eases the
/// render scale over 2 s with a cosine smoothstep — the reference's own transition
/// (byte-verified, `0x614bbf`; the code once misread as a selection fade-in) — through
/// `entities::live_display` (decision 0695), which likewise applies a live
/// `UNIT_FIELD_DISPLAYID` change (druid forms, GM morphs — the old F04 deferral).
pub(crate) scale: f32,
}
+18 -6
View File
@@ -11,10 +11,11 @@ use bevy::prelude::*;
use crate::collision::player_query_filter;
use crate::entities::CollisionHeight;
use crate::liquid::{water_surface_at, WaterChunkInfo};
use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::player::swim_enter_depth;
use super::super::NetEntity;
use crate::wmo_portal::UnitWmoRoom;
/// A server-dictated movement path (`SMSG_MONSTER_MOVE`): the unit traverses `points` at constant
/// speed over `duration` from `start`. All points are raw WoW coords; [`sample_splines`] interpolates
@@ -322,12 +323,14 @@ pub(in crate::net) fn ground_clamp_creatures(
}
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
/// (`None` only on a unit's first frame, before the stamp), and whether it is already marked.
/// (`None` only on a unit's first frame, before the stamp), its own WMO-room claim (whose liquid may
/// answer for it — decision 0696), and whether it is already marked.
type SwimMarkQuery = (
Entity,
&'static NetEntity,
&'static Transform,
Option<&'static CollisionHeight>,
Option<&'static UnitWmoRoom>,
Has<CreatureSwimming>,
);
@@ -360,14 +363,23 @@ pub(in crate::net) fn mark_swimming_creatures(
units: Query<SwimMarkQuery>,
water: Query<&WaterChunkInfo>,
) {
for (e, net, t, collision, marked) in &units {
for (e, net, t, collision, room, marked) in &units {
if net.kind != EntityKind::Unit {
continue; // players carry the real flag on the wire; GameObjects don't swim
}
// The unit's OWN room claim decides whose liquid answers (0696). It used to pass "no claim",
// so both sources answered: Undercity's NPCs read Tirisfal's ADT water 95 yd over their
// heads and swam on dry stone in rooms the player walked.
let claim = unit_claim(room);
// A unit the room tracker hasn't reached yet cannot ENTER swim. `Unknown` admits both
// sources — the very false positive this fix removes — so a freshly streamed NPC standing
// in an interior would flash the swim gait for the frame before its claim lands. It can
// still LEAVE: a stale mark must always be able to clear.
if !marked && claim == crate::liquid::LiquidClaim::Unknown {
continue;
}
let wow = bevy_to_wow(t.translation);
// `None`: no per-unit interior claim exists (CurrentWmoInterior is the local player's
// down-ray), so both sources answer here — the pre-0634 behaviour, named in `liquid_at`.
let depth = water_surface_at(water.iter(), wow, None).map_or(f32::MIN, |s| s - wow[2]);
let depth = water_surface_at(water.iter(), wow, claim).map_or(f32::MIN, |s| s - wow[2]);
let boundary = swim_enter_depth(collision.copied().unwrap_or_default().0);
let swimming = if marked {
depth >= boundary - CREATURE_SWIM_EXIT_BAND
+13
View File
@@ -64,6 +64,19 @@ impl MaterialExtension for WowParticleExt {
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
key: bevy::pbr::MaterialExtensionKey<Self>,
) -> std::result::Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
// `$WOW_PARTICLE_NODEPTH=1` — the occlusion A/B. Force the depth COMPARE to `Always` on
// every particle quad, so "this effect doesn't draw" splits cleanly into *nothing is
// emitted* (the census's job — `WOW_PARTICLE_CENSUS` counts live particles) and *it is
// emitted, rasterized, and the depth buffer eats it*. Those two look identical on screen
// and want completely different fixes. It found B16: the voidwalker's eye glow is two
// emitters mounted flush with the head skin (a ray from the eye bone crosses the body
// mesh at 3–6 mm), which our own opaque body pass then depth-rejects — under this switch
// both eyes appear exactly where the artist put them.
if std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.depth_compare = bevy::render::render_resource::CompareFunction::Always;
}
}
if key.bind_group_data.mod2x {
use bevy::render::render_resource::{
BlendComponent, BlendFactor, BlendOperation, BlendState,
+8 -7
View File
@@ -274,14 +274,15 @@ fn control(
),
>,
Query<&ChildOf>,
// The player's live WMO-interior claim — the liquid query's delegation key: inside a
// building only that building's own MLIQ answers, outdoors only the ADT's (decision 0634,
// the "swim in air" fix). Fourth slot here rather than a 17th top-level param.
Res<crate::wmo_portal::CurrentWmoInterior>,
// The player's live WMO-interior claim — the liquid query's delegation + scope key:
// inside a building only THAT PLACEMENT's own MLIQ answers, outdoors only the ADT's
// (decisions 0634 + 0696, the "swim in air" family). Fourth slot here rather than a 17th
// top-level param.
Res<crate::wmo_portal::PlayerWmoRoom>,
),
) {
let (water, transports, child_of) = (&world_q.0, &world_q.1, &world_q.2);
let indoors = world_q.3 .0.is_some();
let claim = crate::liquid::player_claim(&world_q.3);
let (left_click, right_click) = (&mut *click_test.0, &mut *click_test.1);
let Ok((mut cam_t, mut cam, camera)) = cameras.single_mut() else {
return;
@@ -715,7 +716,7 @@ fn control(
// Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`,
// the verified `0x6030c0` boundary — B7 resolved, decision 0226) so wading the line
// doesn't flicker between the two physics regimes.
let surface_y = swim::surface_over_feet(water, player.pos, indoors);
let surface_y = swim::surface_over_feet(water, player.pos, claim);
let swimming = swim::update_swimming(&mut player, surface_y, time.elapsed_secs());
if let Some(surface) = surface_y {
move_trace::swim(player.pos.y, surface, swimming, player.collision_height.0);
@@ -876,7 +877,7 @@ fn control(
capsule,
dir3 * dir_speed,
surface,
|feet| swim::surface_over_feet(water, feet, indoors),
|feet| swim::surface_over_feet(water, feet, claim),
);
// The surface redirect (decisions 0499+0505 — a NAMED DIVERGENCE, see
// `swim::cap_redirect`): when the rise capped at the rest line, the stroke went
+4 -5
View File
@@ -51,7 +51,7 @@ use avian3d::prelude::*;
use bevy::prelude::*;
use crate::collision::player_query_filter;
use crate::liquid::WaterChunkInfo;
use crate::liquid::{LiquidClaim, WaterChunkInfo};
use super::mover::Outcome;
use super::{Player, CAPSULE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, SKIN_WIDTH};
@@ -122,15 +122,14 @@ fn rest_cap(h: f32) -> f32 {
///
/// Uses [`liquid_at`], not the water-only wrapper: **you swim in lava and slime too** — Blackrock's
/// magma and Undercity's sludge are surfaces you enter, not ones you fall through (decision 0634).
/// `indoors` is the player's live WMO-interior claim, which picks whose liquid answers.
/// `claim` is the player's live WMO-interior claim — which ROOM's liquid answers (decision 0696).
pub(super) fn surface_over_feet(
water: &Query<&WaterChunkInfo>,
feet: Vec3,
indoors: bool,
claim: LiquidClaim,
) -> Option<f32> {
let wow = bevy_to_wow(feet);
crate::liquid::liquid_at(water.iter(), wow, Some(indoors))
.map(|hit| feet.y + (hit.surface_z - wow[2]))
crate::liquid::liquid_at(water.iter(), wow, claim).map(|hit| feet.y + (hit.surface_z - wow[2]))
}
/// Update [`Player::swimming`] from the water surface over the feet, with the verified enter/leave
+12 -6
View File
@@ -30,11 +30,12 @@ use benilla_formats::FootstepCatalog;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::creature_anim::{is_footstep, AnimSoundEvent};
use crate::entities::CollisionHeight;
use crate::liquid::{water_surface_at, WaterChunkInfo};
use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::net::NetEntity;
use crate::player::swim_enter_depth;
use crate::schedule::WorldStage;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
use crate::wmo_portal::UnitWmoRoom;
use super::creature::CreatureVoices;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
@@ -64,7 +65,12 @@ fn footstep_sounds(
// GlobalTransform: a mounted unit's steps are the MOUNT model's own tags, fired by the
// mount CHILD entity — whose local Transform is the seat-relative ~origin. World position
// is the only correct read for both parented and top-level sources (0441 fold-back).
units: Query<(&NetEntity, &GlobalTransform, Option<&CollisionHeight>)>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&CollisionHeight>,
Option<&UnitWmoRoom>,
)>,
footsteps: Option<Res<Footsteps>>,
voices: Option<Res<CreatureVoices>>,
streamer: Res<TerrainStreamer>,
@@ -89,7 +95,7 @@ fn footstep_sounds(
if !is_footstep(&ev.ident) {
continue;
}
let Ok((net, transform, collision)) = units.get(ev.entity) else {
let Ok((net, transform, collision, room)) = units.get(ev.entity) else {
continue;
};
// The unit's footstep class (module docs): the voice row's class verbatim; zero or no
@@ -100,9 +106,9 @@ fn footstep_sounds(
};
// Wading picks the splash slot; swimming (deeper than the wade ceiling) is silent.
let wow = bevy_to_wow(transform.translation());
// `None` — this walks every unit, and only the local player has an interior claim
// (`liquid_at`'s named gap).
let depth = water_surface_at(water.iter(), wow, None)
// The unit's own room claim (0696) — it used to pass "no claim", so every unit walking
// under an ADT lake picked the splash slot on dry indoor stone.
let depth = water_surface_at(water.iter(), wow, unit_claim(room))
.map(|s| s - wow[2])
.filter(|d| *d > 0.0);
let wade_max = swim_enter_depth(collision.copied().unwrap_or_default().0);
+16 -5
View File
@@ -25,9 +25,10 @@ use benilla_assets::coords::bevy_to_wow;
use crate::assets::WorldAssets;
use crate::entities::CollisionHeight;
use crate::liquid::{water_surface_at, WaterChunkInfo};
use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::net::NetEntity;
use crate::schedule::WorldStage;
use crate::wmo_portal::UnitWmoRoom;
use super::kit::{play_kit_ext, source_kit_playing, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -39,10 +40,19 @@ const SPLASH_KIT: u32 = 1096;
/// (`0x60314a`'s compare inside `0x6030c0`). Applied to the feet-referenced depth.
const SPLASH_DEPTH_FRAC: f32 = 0.4;
/// What [`water_splashes`] reads per unit: identity, pose, its own collision height, and its own
/// WMO-room claim (whose liquid may answer for it — decision 0696).
type SplashQuery = (
Entity,
&'static Transform,
Option<&'static CollisionHeight>,
Option<&'static UnitWmoRoom>,
);
/// Play the water splash on a unit's `0.4·h` depth-line crossing, either direction (module docs).
#[allow(clippy::too_many_arguments)] // the sound-play plumbing, one param per concern
fn water_splashes(
units: Query<(Entity, &Transform, Option<&CollisionHeight>), With<NetEntity>>,
units: Query<SplashQuery, With<NetEntity>>,
water: Query<&WaterChunkInfo>,
mut wet: Local<EntityHashMap<bool>>,
kits: Option<ResMut<SoundKits>>,
@@ -55,13 +65,14 @@ fn water_splashes(
return;
};
let listener = listener.pos;
for (entity, transform, collision) in &units {
for (entity, transform, collision, room) in &units {
let wow = bevy_to_wow(transform.translation);
// The unit's own collision height (0645). `None` only on a unit's very first frame, before
// the stamp runs — the ctor default covers it, as it does in the reference.
let h = collision.copied().unwrap_or_default().0;
// `None` — every unit, not just the local player; see `liquid_at`'s named gap.
let submerged = water_surface_at(water.iter(), wow, None)
// The unit's own room claim — every unit carries one since 0696 (it used to pass "no
// claim", so an ADT lake overhead splashed units standing dry underneath it).
let submerged = water_surface_at(water.iter(), wow, unit_claim(room))
.is_some_and(|s| s - wow[2] > SPLASH_DEPTH_FRAC * h);
let was = wet.insert(entity, submerged);
// The symmetric edge: any crossing splashes — wading in past the line AND surfacing back
+37 -42
View File
@@ -296,16 +296,16 @@ impl MaterialExtension for WowModelExt {
/// WoW.8 state).
pub type WdlMaterial = ExtendedMaterial<StandardMaterial, WdlExt>;
/// The two fog uniforms WDL needs — same semantics + values as the terrain/model fog (fed by
/// `apply_wow_lighting`). Packed on one binding like the others.
/// WDL reads the scene fog straight off the shared global light — it has no per-material input of
/// its own (the band is white geometry whose entire colour is the fog).
#[derive(Asset, AsBindGroup, Clone, TypePath)]
pub struct WdlExt {
/// `rgb` = Light.dbc IntBand row 7 fog color (gamma 0..1); `w` = enable (>0.5 ⇒ blend; 0 ⇒ skip).
#[uniform(100)]
pub fog_color: Vec4,
/// `x` = `fog_start` yd, `y` = `fog_end` yd; `zw` reserved. (q6 RE: GL_LINEAR, planar eye-Z.)
#[uniform(100)]
pub fog_params: Vec4,
/// **The shared global light** (`lighting::global_light`), the same buffer terrain and the models
/// bind. `wdl.wgsl` reads rows 4/5 only — the SCENE fog (block 1) plus the farclip wall, which is
/// right by construction: the band *is* the horizon, so it is never inside a WMO. Set once at
/// startup, never mutated (this replaces the per-material `apply_wow_lighting` push).
#[storage(90, read_only, buffer)]
pub light_buf: Buffer,
}
impl MaterialExtension for WdlExt {
@@ -325,9 +325,12 @@ impl MaterialExtension for WdlExt {
/// WoW state). P1 omits the unit-1 detail ripple + specular term.
pub type LiquidMaterial = ExtendedMaterial<StandardMaterial, LiquidExt>;
/// Liquid material inputs: the animated frame array + the shared WoW light/fog + the current
/// animation frame index. **All Vec4 uniforms merge onto one binding (102)** (same packing trick as
/// the other extensions — keeps the buffer count down). WGSL struct order must match the field order.
/// Liquid material inputs: the animated frame array, the per-material kind lanes, and the current
/// animation frame index. Everything that is *light* — ambient/diffuse/sun/spec, both water swatches
/// and both fog blocks — comes off the shared global-light buffer, exactly like terrain and the
/// models; this material carries only what genuinely varies per material. **The Vec4 uniforms merge
/// onto one binding (102)** (same packing trick as the other extensions). WGSL struct order must
/// match the field order.
#[derive(Asset, AsBindGroup, Clone, TypePath)]
pub struct LiquidExt {
/// The kind's animated frames (`lake_a`/`fast_a`/`ocean_h`), stacked as `2d_array` layers
@@ -335,41 +338,33 @@ pub struct LiquidExt {
#[texture(100, dimension = "2d_array", visibility(fragment))]
#[sampler(101, visibility(fragment))]
pub frames: Handle<Image>,
/// `rgb` = ambient color; `w` unused. Fed by `apply_wow_lighting` (same value as terrain).
/// The per-material constants that pick which *lanes* of the shared light this surface reads.
/// None of them is a light value; all four are fixed at material creation.
///
/// - `x` = **fullbright** (>0.5 ⇒ magma/slime): the animated texture IS the opaque body — skip
/// the depth swatch and the N·L term. Not "skip the fog"; see `liquid.wgsl` and decision 0691.
/// - `y` = **ocean** (>0.5): read the ocean water swatch (shared-light rows 15/16, `Light.dbc`
/// IntBand 14/15) instead of the river/lake one (rows 13/14, IntBand 16/17).
/// - `z` = **interior fog** (>0.5): this surface is a WMO *interior* group's own liquid, so it
/// fogs with the interior block (rows 18/19) like the walls around it, not the scene fog. The
/// reference gates the WMO liquid pass's fog submit (`0x6b6323`–`0x6b6342`) on exactly the same
/// `[0xca7f00]` as the WMO *geometry* pass (`0x6b51d9`/`0x6b51ea`) — one flag, so the pool and
/// the room can never disagree. ADT liquid is 0: the ADT pass submits no fog of its own and
/// draws under the once-a-frame scene submit.
/// - `w` = the sun-sheen **shininess** (`lighting::WATER_SHININESS`) for the `ocean0_s.bls`
/// `secondary` Blinn term — water's own exponent, not the shared row-3 terrain shininess.
#[uniform(102)]
pub light_ambient: Vec4,
/// `rgb` = directional (sun) diffuse color; `w` unused.
#[uniform(102)]
pub light_diffuse: Vec4,
/// `xyz` = world-space (Bevy) sun travel direction (lit where `N·(-sun) > 0`); `w` unused.
#[uniform(102)]
pub light_sun: Vec4,
/// **Sun sheen (specular)**: `rgb` = the sun specular colour (≈ row-9 near-white), `w` = shininess
/// (≈6, the water material's). The `ocean0_s.bls` `secondary` term: `liquid.wgsl` computes a
/// Blinn highlight `light_spec.rgb · pow(N·H, w)` and adds `(secondary + 0.25)·detail.a` — the
/// glinting sheen on the ripples, strongest at grazing (sunrise/sunset) sun. Fed by `apply_wow_lighting`.
#[uniform(102)]
pub light_spec: Vec4,
/// **Water shore tint + shallow alpha**: `rgb` = the shallow water-row tint (`Light.dbc` IntBand row
/// 16 river/lake / 14 ocean, RAW — the 2-endpoint depth swatch, VERIFIED `FUN_0068a830`); `w` =
/// `waterShallowAlpha` (river 0.5 / ocean 0.75). Fed per-kind by `WowLighting::water_colors`.
#[uniform(102)]
pub water_shallow: Vec4,
/// **Water deep tint + deep alpha**: `rgb` = the deep water-row tint (IntBand row 17 river / 15 ocean,
/// RAW); `w` = `waterDeepAlpha` (= 1.0, fully opaque). `liquid.wgsl` lerps the COLOUR `water_shallow.rgb
/// → water_deep.rgb` and the OPACITY `.w → .w` by the SAME depth V (river/lake `V = clamp(byte/42)`):
/// the from-above gradient (pale shore → opaque deep middle), colour and alpha tracking together.
#[uniform(102)]
pub water_deep: Vec4,
/// `rgb` = Light.dbc row 7 fog color (gamma 0..1); `w` = enable (>0.5 ⇒ blend). Same as terrain.
#[uniform(102)]
pub fog_color: Vec4,
/// `x` = `fog_start` yd, `y` = `fog_end` yd; `zw` reserved. Planar eye-Z linear fog, like terrain.
#[uniform(102)]
pub fog_params: Vec4,
pub kind: Vec4,
/// `x` = current frame index (set each frame by `animate_liquid`), `y` = frame count; `zw` unused.
#[uniform(102)]
pub anim: Vec4,
/// **The shared global light** (`lighting::global_light`): the one storage buffer terrain and the
/// models already read, now liquid's source too. `liquid.wgsl` reads rows 0-5 (light + scene fog +
/// farclip), 13-16 (the two water swatches) and 18/19 (the interior fog block). Read in BOTH
/// stages — the vertex stage evaluates the faithful per-vertex sun sheen. Set once at material
/// creation, never mutated.
#[storage(90, read_only, buffer, visibility(vertex, fragment))]
pub light_buf: Buffer,
}
impl MaterialExtension for LiquidExt {
+6 -4
View File
@@ -15,6 +15,7 @@
//! [`crate::clutter::ClutterPlugin`].
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
@@ -157,10 +158,11 @@ struct Placement {
struct WmoDoodadInst {
handle: Handle<M2Model>,
transform: Transform,
/// The prop's owning WMO group (its MODR referencer) — the portal-cull key, so a prop is hidden
/// with the room it furnishes. `None` for a MODD no group references (the reference never
/// instantiates one at all; we still show it, uncullable, rather than change what draws today).
group: Option<u16>,
/// Every WMO group whose MODR names this prop — the portal-cull key, so a prop is hidden with
/// the rooms it furnishes and drawn while any one of them is visible. Empty for a MODD no group
/// references (the reference never instantiates one at all; we still show it, uncullable,
/// rather than change what draws today).
groups: Arc<[u16]>,
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
@@ -275,9 +275,10 @@ pub(crate) fn spawn_model_entities(
// Animated material alpha (decision 0130 phase 2): the rare batch whose colour-alpha/weight
// tracks animate (fire flicker) or constantly dim gets its per-instance sampler; the
// visibility authority composes the value into the render-alpha tag + the A ≤ 0 cull.
// Captures freeze the clock at 0 (deterministic frames). NB a lit interior prop's tag
// carries its room colour, so its partial alpha can't show yet — only the 0-cull applies
// there (flagged in decision 0130; the population is a handful of instances per town).
// Captures freeze the clock at 0 (deterministic frames). A lit interior prop composes the
// same way since the 0355 re-lane gave the probe-slot payload its own alpha field (bits
// 0..=15) — the 0130-era "partial alpha can't show on the colour payload" deferral is
// collected (bug B30: the Undercity lightshaft's authored 0.10/0.05 dimming).
if let Some(anim) = &sub.alpha_anim {
commands
.entity(entity)
+43 -16
View File
@@ -10,6 +10,7 @@ pub(crate) use assemble::spawn_model_entities;
pub(crate) use fx::point_light;
use fx::{spawn_emitters_for, spawn_lights_for, spawn_ribbons_for, spawn_wmo_lights_for};
use std::sync::Arc;
use std::time::Instant;
use benilla_assets::coords::{wmo_doodad_local, wow_to_bevy};
@@ -26,7 +27,7 @@ use crate::liquid::{spawn_wmo_liquids, LiquidAssets};
use crate::model_render::{m2_url, ShadeSel};
use crate::particles::WowParticleMaterial;
use crate::terrain::WowModelMaterial;
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance};
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
use super::collider::{build_collider_task, placement_collider_data, PendingCollider};
use super::{
@@ -213,15 +214,27 @@ pub(super) fn spawn_loaded_placements(
})
.id();
if has_portals {
// One shared single-group key per group — geometry belongs to exactly
// one room, and sharing keeps the tag one allocation per group rather
// than per submesh (a city is ~100k submeshes).
let group_key: Vec<Arc<[u16]>> = (0..m.group_nav.len() as u16)
.map(|g| Arc::from([g].as_slice()))
.collect();
for (&entity, &group) in ents.iter().zip(&m.submesh_group) {
let Some(groups) = group_key.get(group as usize).cloned() else {
continue;
};
commands
.entity(entity)
.insert(WmoGroupVis { instance, group });
.insert(WmoGroupVis { instance, groups });
}
// The props spawn later — each waits on its own M2 — so they can't be
// tagged here; hold the instance for them (decision 0689).
p.portal_instance = Some(instance);
}
// The props spawn later — each waits on its own M2 — so they can't be
// tagged here; hold the instance for them (decision 0689). Held for EVERY
// instance, not just a portal-bearing one: since 0696 it is also the
// placement identity each embedded pool is scoped by, and a portal-less
// building's pool needs an owner exactly as much as a canal does.
p.portal_instance = Some(instance);
ents.push(instance); // despawns with the placement
}
// The building's embedded MLIQ liquid (Stormwind's canals + fountains, the
@@ -241,13 +254,24 @@ pub(super) fn spawn_loaded_placements(
liquid_assets.as_deref(),
&mut meshes,
p.transform,
// The group's own interior class (`MOGI & 0x48 == 0`) — which FOG BLOCK
// its pool draws under, so an indoor pool hazes with the room the way
// the walls beside it do (decision 0691's open lane).
m.group_bounds.get(gi).is_some_and(|g| g.interior),
// …and the ROOM the pool belongs to — the liquid query's scope key
// (0696): only a subject standing in this placement can be in it.
p.portal_instance.map(|instance| WmoRoom {
instance,
group: gi as u16,
}),
&mut ents,
);
if let Some(instance) = p.portal_instance {
let groups: Arc<[u16]> = Arc::from([gi as u16].as_slice());
for &e in &ents[first..] {
commands.entity(e).insert(WmoGroupVis {
instance,
group: gi as u16,
groups: groups.clone(),
});
}
}
@@ -396,15 +420,17 @@ pub(super) fn spawn_loaded_placements(
if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) {
commands.entity(first).insert(PropProbeSlot(slot));
}
// Portal-cull the prop with the group that owns it — the reference commits a WMO's
// Portal-cull the prop with the rooms that name it — the reference commits a WMO's
// doodads per VISIBLE group (`0x695aa0` over the group's MODR refs, from the
// visible-group walk `0x698720`), so furniture never outlives its room. Every submesh
// spawned above is this one prop, so they all take the same key (decision 0689).
if let (Some(instance), Some(group)) = (portal_instance, d.group) {
// visible-group walk `0x698720`), so furniture never outlives its room (decision 0689)
// and a prop several rooms name is drawn from ANY of them. Every submesh spawned above
// is this one prop, so they all share the one key.
if let (Some(instance), false) = (portal_instance, d.groups.is_empty()) {
for &entity in &ents {
commands
.entity(entity)
.insert(WmoGroupVis { instance, group });
commands.entity(entity).insert(WmoGroupVis {
instance,
groups: d.groups.clone(),
});
}
}
// Prop collider (avian): a static trimesh from the prop's collision hull at its world
@@ -523,9 +549,10 @@ fn resolve_wmo_doodads(
out.push(WmoDoodadInst {
handle: asset_server.load(m2_url(&d.model)),
transform: wmo_world.mul_transform(local),
// The MODR owner, resolved with the lighting base at load — the prop's portal-cull
// key, so a lantern is hidden with the room it hangs in (decision 0689).
group: wmo.doodad_owner.get(di).copied().flatten(),
// Every MODR referrer, inverted with the lighting base at load — the prop's
// portal-cull key, so a lantern is hidden with the room it hangs in (decision 0689)
// and a lava fall the rooms below and above both name survives either being culled.
groups: wmo.doodad_groups.get(di).cloned().unwrap_or_default(),
light,
spawned: false,
});
+13 -4
View File
@@ -104,6 +104,8 @@ pub(super) struct ChatProbes<'w, 's> {
clock: Option<Res<'w, crate::lighting::GameClock>>,
liquids: Query<'w, 's, &'static crate::liquid::WaterChunkInfo>,
interior: Res<'w, crate::wmo_portal::CurrentWmoInterior>,
player_room: Res<'w, crate::wmo_portal::PlayerWmoRoom>,
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
stores: Query<'w, 's, &'static crate::net::ObjectStore>,
self_store: Query<'w, 's, &'static crate::net::ObjectStore, With<SelfPlayer>>,
factions: Option<Res<'w, crate::target::Factions>>,
@@ -141,6 +143,8 @@ pub(super) fn drain_chat_input(
clock,
liquids,
interior,
player_room,
camera_claim,
stores,
self_store,
factions,
@@ -275,11 +279,12 @@ pub(super) fn drain_chat_input(
continue;
};
let wow = benilla_assets::coords::bevy_to_wow(feet);
let indoors = interior.0.is_some();
let verdict = crate::liquid::liquid_at(liquids.iter(), wow, Some(indoors));
let claim = crate::liquid::player_claim(player_room);
let eye = crate::liquid::camera_claim(camera_claim);
let verdict = crate::liquid::liquid_at(liquids.iter(), wow, claim);
let mut lines = vec![
format!(
"liquid: feet [{:.2}, {:.2}, {:.2}] · indoors {indoors} ({})",
"liquid: feet [{:.2}, {:.2}, {:.2}] · claim {claim:?} ({})",
wow[0],
wow[1],
wow[2],
@@ -291,6 +296,10 @@ pub(super) fn drain_chat_input(
None => "no WMO interior claim".into(),
}
),
// The camera EYE is a separate subject with its own claim (the reference's
// `[0xc7b748]`), and it is the one that decides the underwater filter. Printing
// only the player's is what let the two disagree unseen for a whole bug.
format!("liquid: EYE claim {eye:?}"),
match verdict {
Some(h) => format!(
"liquid: VERDICT {:?} surface z {:.2} ({:+.2} over feet)",
@@ -301,7 +310,7 @@ pub(super) fn drain_chat_input(
None => "liquid: VERDICT none — not in liquid".into(),
},
];
let candidates = crate::liquid::describe_at(liquids.iter(), wow);
let candidates = crate::liquid::describe_at(liquids.iter(), wow, claim);
if candidates.is_empty() {
lines.push("liquid: no footprint covers this XY at all".into());
}
+6 -19
View File
@@ -16,7 +16,6 @@ use bevy::prelude::*;
use crate::assets::LockRecover;
use crate::assets::{AssetSet, RenderConfig, WorldAssets};
use crate::lighting::WowLighting;
use crate::player::{Player, WorldCamera};
use crate::terrain::{WdlExt, WdlMaterial};
use crate::world_map::{CurrentMap, MapCatalogRes};
@@ -49,8 +48,8 @@ impl Plugin for WdlPlugin {
}
/// Streams the coarse WDL ring: the parsed file, the shared material every ring tile uses, and which
/// ring tiles are currently spawned. (The fog uniforms on the material are kept in sync by
/// `lighting::apply_wow_lighting`, same as terrain/model materials.)
/// ring tiles are currently spawned. (The material holds no fog of its own — it reads the shared
/// global-light buffer, same as terrain and the models.)
#[derive(Resource)]
pub(crate) struct WdlStreamer {
wdl: WdlFile,
@@ -80,7 +79,6 @@ fn setup_wdl(
mut commands: Commands,
config: Option<Res<RenderConfig>>,
world_assets: Option<ResMut<WorldAssets>>,
lighting: Option<Res<WowLighting>>,
mut materials: ResMut<Assets<WdlMaterial>>,
) {
let (Some(_config), Some(world_assets)) = (config, world_assets) else {
@@ -100,19 +98,9 @@ fn setup_wdl(
return;
}
};
// One shared material; seed its fog from the current light (or a sane default until frame 1's
// apply_wow_lighting). Opaque ⇒ depth-LEQUAL + depth-write, no blend (the verified WoW.8 state).
let (fog_color, fog_params) = lighting
.map(|l| {
let u = l.terrain_uniforms(false);
(u.fog_color, u.fog_params)
})
.unwrap_or((
// fog_params.w = farclip (the wall) so WDL near-clips correctly even before the first
// apply_wow_lighting push; matches the 777 seed terrain uses.
Vec4::new(0.323, 0.477, 0.516, 1.0),
Vec4::new(80.0, 481.0, 0.0, 777.0),
));
// One shared material, reading the shared global light like terrain does — no seed and no
// per-frame push: `build_light_data` has already packed the fog by the first draw. Opaque ⇒
// depth-LEQUAL + depth-write, no blend (the verified WoW.8 state).
let material = materials.add(ExtendedMaterial {
base: StandardMaterial {
base_color: Color::WHITE,
@@ -121,8 +109,7 @@ fn setup_wdl(
..default()
},
extension: WdlExt {
fog_color,
fog_params,
light_buf: world_assets.shared_light.clone(),
},
});
commands.insert_resource(WdlStreamer {
@@ -69,6 +69,16 @@ const FARGODEEP: Site = Site {
uid: 210351,
};
/// The Deadmines dungeon shell — decision 0692's repro site: the zone-in tunnel (g35) opens through
/// the swirl-portal doorway into a sealed pocket (g39) floored entirely with DETAIL faces, where the
/// faithful down-ray legs find nothing and the client blanks the building around its own camera.
const DEADMINES: Site = Site {
wmo: r"world\wmo\dungeon\az_deadmines\az_deadmines_b.wmo",
map: "DeadminesInstance",
tile: (32, 32),
uid: 170633,
};
/// The Goldshire blacksmith — the fire-lit forge floor (the 2026-07-13 per-step light-flash report).
const BLACKSMITH: Site = Site {
wmo: r"world\wmo\azeroth\buildings\goldshireblacksmith\goldshireblacksmith.wmo",
@@ -153,6 +163,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
})
.collect();
let mut group_collision_tris: Vec<Vec<[[f32; 3]; 3]>> = vec![Vec::new(); n];
let mut group_camera_only_tris: Vec<Vec<[[f32; 3]; 3]>> = vec![Vec::new(); n];
let mut group_footprints: Vec<Option<benilla_formats::FootprintTris>> =
(0..n).map(|_| None).collect();
let mut cam_pos = Vec::new();
@@ -191,6 +202,19 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
let base = walk_pos.len() as u32;
walk_pos.extend_from_slice(&cp);
walk_idx.extend(ci.iter().map(|i| i + base));
// Leg C's fallback set — the camera-only complement (DETAIL set, NOCAMCOLLIDE clear),
// exactly as the asset loader stores it (decision 0692).
let (mut dp, mut di): (Vec<[f32; 3]>, Vec<u32>) = (Vec::new(), Vec::new());
benilla_formats::accumulate_wmo_group_camera_only_collision(&gbytes, &mut dp, &mut di);
for t in di.chunks_exact(3) {
if let (Some(&a), Some(&b), Some(&c)) = (
dp.get(t[0] as usize),
dp.get(t[1] as usize),
dp.get(t[2] as usize),
) {
group_camera_only_tris[gi].push([a, b, c]);
}
}
names[gi] = root
.group_infos()
.get(gi)
@@ -210,6 +234,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
group_nav,
fogs: root.fogs().to_vec(),
group_collision_tris,
group_camera_only_tris,
group_collision_bounds,
collision_bounds,
collision: None,
@@ -224,6 +249,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
group_liquids: Vec::new(),
doodad_base: Vec::new(),
doodad_owner: Vec::new(),
doodad_groups: Vec::new(),
};
Subject {
model,
@@ -800,3 +826,48 @@ fn wmo_pvs_audit() {
hard.len()
);
}
/// Decision 0692's repro, on the shipped data: the camera pulled back from the Deadmines zone-in
/// crosses the swirl-portal doorway into the sealed g35/g39 pocket, whose floor is entirely DETAIL
/// faces — camera collision holds the eye there (every back-hemisphere ray hits a camera face within
/// ~16 yd) while Legs A+B find nothing, and the client's own verdict blanks the building around its
/// own camera. Leg C must name the pocket's room; the terrain race must still own the columns where
/// its answer is right (the hilltop lid ~272 yd above the tunnel).
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn wmo_camera_void_audit() {
let subject = load_subject(DEADMINES.wmo, Some(&DEADMINES));
// The zone-in head: areatrigger 78's destination (-14.5732, -385.475, 62.4561) + head height,
// mapped through the MODF placement (uid 170633) into B-local space.
let head = [268.56, -137.17, 34.61];
assert_eq!(
subject.seeds(head).in_group,
Some(35),
"the entrance tunnel must stay Leg A's answer — Leg C never touches a faithful verdict"
);
// Eyes along the swept camera path behind the head: level-back at 8/12/13.2 yd and the raised
// zoom arc. All sit past the last walking-gather floor; each must still name the pocket's room.
for eye in [
[260.6, -137.3, 34.6],
[256.6, -137.4, 34.6],
[255.4, -137.4, 34.6],
[256.0, -137.4, 41.9],
] {
let s = subject.seeds(eye);
let g = s.in_group.unwrap_or_else(|| {
panic!("camera-void eye {eye:?} read OUTSIDE — the building blanks around the camera")
});
assert!(
g == 35 || g == 39,
"camera-void eye {eye:?} named g{g}, not the entrance pocket (g35/g39)"
);
assert!(s.across.is_none(), "Leg C seeds a single root");
}
// Above the hilltop lid the terrain race owns the column: an eye over open ground must stay
// outside — the fallback's terrain gate keeps the divergence out of the client's right answers.
assert_eq!(
subject.seeds([255.4, -137.4, 280.0]).in_group,
None,
"an eye above the terrain lid must remain OUTSIDE"
);
}
+164 -3
View File
@@ -19,7 +19,7 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::{AdtTile, WmoModel};
use super::seed::{area_down_ray, down_ray_claim, down_ray_seeds, DownRayClaim};
use super::{WmoPortalInstance, WorldCamera, EXTERIOR, EXTERIOR_LIT};
use super::{WmoPortalInstance, WmoRoom, WorldCamera, EXTERIOR, EXTERIOR_LIT};
use crate::terrain_stream::{terrain_height_under, PropLobeLight, TerrainStreamer};
/// The WMO interior the camera eye is currently in — the `WMOAreaTable` join keys of the group
@@ -29,6 +29,44 @@ use crate::terrain_stream::{terrain_height_under, PropLobeLight, TerrainStreamer
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq)]
pub struct CurrentWmoInterior(pub Option<WmoInteriorKeys>);
/// The **room** the player stands in — the same down-ray claim [`CurrentWmoInterior`] names, but as
/// the placement identity ([`WmoRoom`]) rather than the `WMOAreaTable` join keys. Written by
/// [`track_current_interior`] in the same pass (no second ray).
///
/// Its consumer is the liquid query's scope key (decision 0696): a building's MLIQ pool belongs to
/// that building, so only a subject standing in *that placement* can be in it. Before this, "indoors"
/// was a bare `bool` and every WMO pool on the map answered — the Uldaman entrance read as submerged
/// under a mushroom cave's water 186 yd overhead, in a building the player was 191 yd below.
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq)]
pub struct PlayerWmoRoom(pub Option<WmoRoom>);
/// The WMO room **one remote unit** stands in — its own liquid claim, the per-unit twin of
/// [`PlayerWmoRoom`].
///
/// It exists because the liquid query's `None` ("no interior claim for this subject") arm was a
/// standing gap: every consumer that walks *units* rather than the local player — the creature swim
/// marker, the wade splash, the footstep splash slot, the remote-motion depth — passed it, so both
/// sources answered and an ADT lake claimed anything beneath it. In Undercity's Rogues' Quarter the
/// NPCs stood 95 yd under Tirisfal's water at z 32.93 and swam on dry stone, in the same rooms the
/// player (who *did* have a claim) walked normally (decision 0696).
///
/// Re-rayed only when the unit MOVES or a building streams in/out — a room changes at doorways, not
/// per frame — so a town full of standing NPCs costs one position compare each.
#[derive(Component, Clone, Copy, Default, PartialEq)]
pub struct UnitWmoRoom {
room: Option<WmoRoom>,
/// Where the claim was last sampled, and the WMO-residency generation then: the re-test gate.
at: Vec3,
generation: u32,
}
impl UnitWmoRoom {
/// The room, for the liquid query's scope key.
pub fn room(&self) -> Option<WmoRoom> {
self.room
}
}
/// `WMOAreaTable` lookup keys: `(WMOID, NameSetID, WMOGroupID)`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct WmoInteriorKeys {
@@ -131,14 +169,16 @@ pub(super) fn track_area_interior(
/// flicker, 2026-07-03). The camera is the fallback before login / detached free-fly.
/// Independent of the PVS compute (which early-outs on portal-less models — a portal-less hut
/// still has a floor to stand on; the *render* seed stays the camera, as the client's does).
#[allow(clippy::too_many_arguments)] // a Bevy system: each arg is a distinct resource/query
pub(super) fn track_current_interior(
wmos: Res<Assets<WmoModel>>,
player: Res<crate::player::Player>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
instances: Query<&WmoPortalInstance>,
instances: Query<(Entity, &WmoPortalInstance)>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
mut current: ResMut<CurrentWmoInterior>,
mut room: ResMut<PlayerWmoRoom>,
) {
let eye_world = if player.active && !player.detached {
player.pos + Vec3::Y * INTERIOR_PROBE_HEIGHT
@@ -152,7 +192,8 @@ pub(super) fn track_current_interior(
// player standing on the grass above it.
let terrain = terrain_height_under(&streamer, &adt_tiles, eye_world);
let mut found = None;
for inst in &instances {
let mut found_room = None;
for (entity, inst) in &instances {
let Some(model) = wmos.get(&inst.handle) else {
continue;
};
@@ -168,12 +209,130 @@ pub(super) fn track_current_interior(
name_set: u32::from(inst.name_set),
group_area_id: model.group_nav.get(gi).map_or(0, |g| g.area_table_id),
});
// The same claim as a placement identity — the liquid query's scope key (0696).
found_room = Some(WmoRoom {
instance: entity,
group: gi as u16,
});
break;
}
}
if current.0 != found {
current.0 = found;
}
if room.0 != found_room {
room.0 = found_room;
}
}
/// Squared distance (yd²) a unit must move before its room claim is re-rayed. A room changes at a
/// doorway, not continuously, so this is a travel gate rather than a precision one: the claim can
/// lag a transition by at most this distance, which for a swim/submersion verdict is well inside the
/// wade band. Deliberately coarser than `interior::RESAMPLE_DIST_SQ` (the light classifier's ~0,
/// which must not quantize a *continuous* MOCV field into steps — a room index has no such field).
const UNIT_ROOM_RESAMPLE_DIST_SQ: f32 = 0.25 * 0.25;
/// Maintain [`UnitWmoRoom`] on every net entity: the room its position stands in, from the same
/// faces-only down-ray the zone-text claim uses ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain
/// raced) cast from the unit's own position.
///
/// The leg is the **position-cast faces** one, not the render seed's lifted eye + portal race: the
/// client casts its position legs from the position itself (`zonetext-indoor-bit.md` (b)), and a unit
/// is a position, not a camera. Which leg the reference's *own* per-unit liquid depth uses — and
/// whether it scopes to the containing GROUP rather than the placement — is the open half of the
/// wow-re dispatch 0696 re-asked; this is the claim that makes the query *scoped at all*, and it is
/// re-pointable at one call site when that lands.
pub(super) fn track_unit_interiors(
mut commands: Commands,
wmos: Res<Assets<WmoModel>>,
instances: Query<(Entity, &WmoPortalInstance)>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
residency: Res<crate::interior::WmoResidency>,
mut units: Query<
(Entity, &GlobalTransform, Option<&mut UnitWmoRoom>),
With<crate::net::NetEntity>,
>,
) {
let generation = residency.generation();
for (entity, transform, claim) in &mut units {
// World position, not the local one: a mounted unit's `Transform` is seat-relative
// (0441), and its room is decided where it actually stands.
let pos = transform.translation();
// The re-test gate: a settled unit in an unchanged world keeps its room for free. A
// building streaming in UNDER a standing NPC must still re-claim it — that is the whole
// point of the generation leg, and without it a creature spawned before its own dungeon
// would hold an "outdoors" claim and read the lake overhead forever.
if let Some(claim) = claim.as_ref() {
if claim.generation == generation
&& pos.distance_squared(claim.at) < UNIT_ROOM_RESAMPLE_DIST_SQ
{
continue;
}
}
let room = room_at(&wmos, &instances, &streamer, &adt_tiles, pos);
let next = UnitWmoRoom {
room,
at: pos,
generation,
};
match claim {
Some(mut claim) => {
if *claim != next {
*claim = next;
}
}
// `try_insert`: a unit despawning this frame (the net teardown) applies before this
// command, and the insert must not panic at apply time.
None => {
commands.entity(entity).try_insert(next);
}
}
}
}
/// The WMO room a world position stands in — the placement + group of the nearest **face** under it,
/// faces-only ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain raced), first claim wins.
///
/// The shared body of the per-unit claim; the same shape as [`track_area_interior`]'s loop, returning
/// the placement identity instead of the `WMOAreaTable` keys.
fn room_at(
wmos: &Assets<WmoModel>,
instances: &Query<(Entity, &WmoPortalInstance)>,
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
feet_world: Vec3,
) -> Option<WmoRoom> {
let probe_world = feet_world + Vec3::Y * POSITION_PROBE_LIFT;
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
for (entity, inst) in instances {
let Some(model) = wmos.get(&inst.handle) else {
continue;
};
if model.wmo_id == 0 {
continue; // the same placement set the player's own claim walks
}
let local_from_world = inst.world_from_local.inverse();
let probe_local = bevy_to_wow(local_from_world.transform_point3(probe_world));
if !column_in_collision_bounds(model.collision_bounds, probe_local) {
continue; // no face of this building can own the probe column
}
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, probe_world, z));
if let Some(gi) = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_nav,
probe_local,
terrain_local,
EXTERIOR,
) {
return Some(WmoRoom {
instance: entity,
group: gi as u16,
});
}
}
None
}
/// Whether a world position stands INDOORS **for the unit LIGHT law** — the nearest surface under
@@ -426,6 +585,7 @@ mod tests {
group_nav: Vec::new(),
fogs: Vec::new(),
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
collision_bounds: None,
collision: None,
@@ -440,6 +600,7 @@ mod tests {
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
}
}
+94 -10
View File
@@ -27,6 +27,8 @@
//! `w`-clamp pair, the `0.1` near-parallel snap) are the client's own constants, read from the binary
//! — do not "fix" them to tolerances without re-deriving there first.
use std::sync::Arc;
mod fog;
mod interior;
mod probe;
@@ -37,8 +39,8 @@ pub use fog::{CameraWmoFog, WmoFogTarget};
pub(crate) use interior::{
indoor_verdict_at, indoors_at, terrain_z_local, IndoorVerdict, INTERIOR_PROBE_HEIGHT,
};
use interior::{track_area_interior, track_current_interior};
pub use interior::{CurrentAreaInterior, CurrentWmoInterior};
use interior::{track_area_interior, track_current_interior, track_unit_interiors};
pub use interior::{CurrentAreaInterior, CurrentWmoInterior, PlayerWmoRoom, UnitWmoRoom};
pub use probe::WmoCullProbe;
use probe::{TraceLog, PROBE_DUMP_PATH};
use seed::dominant_axes;
@@ -101,12 +103,29 @@ const MAX_ITERS: u32 = 1 << 16;
type Rect = [f32; 4];
const FULL_SCREEN: Rect = [-1.0, -1.0, 1.0, 1.0];
/// Static tag on every WMO **group** render submesh entity: which placement instance it belongs to and
/// its absolute group index, so the visibility authority can look up this group's per-frame PVS bit.
#[derive(Component, Clone, Copy)]
/// Static tag on every portal-culled WMO piece — a group render submesh, a group's MLIQ surface, a
/// MODD prop — naming which placement instance it belongs to and which absolute group indices can
/// draw it, so the visibility authority can look up their per-frame PVS bits.
///
/// Geometry and liquid belong to exactly one group. A **prop** can be named by many: the reference
/// draws doodads per *visible* group out of that group's own MODR, so every referrer is an
/// independent chance to be drawn (see [`Self::drawn_by`]). Built once per group/prop at spawn, so
/// the `Arc` is one allocation per key, not one per submesh.
#[derive(Component, Clone)]
pub struct WmoGroupVis {
pub instance: Entity,
pub group: u16,
pub groups: Arc<[u16]>,
}
impl WmoGroupVis {
/// The portal verdict: drawn iff **any** referencing group is in this frame's PVS. An index
/// past the instance's visible set (a stale or short set) reads as visible — the same fail-open
/// the rest of the cull takes, so a lookup miss can never blank a building.
pub fn drawn_by(&self, inst: &WmoPortalInstance) -> bool {
self.groups
.iter()
.any(|&g| inst.visible.get(g as usize).copied().unwrap_or(true))
}
}
/// One placed WMO building's portal-cull state. Spawned alongside the building's geometry; despawns
@@ -128,11 +147,31 @@ pub struct WmoPortalInstance {
pub visible: Vec<bool>,
}
/// One WMO **room**: a placed building's instance entity plus the absolute group index inside it.
///
/// This is the client's `[0xc7b748]` (the camera-containing placed map-object — wow-re
/// `models/scratch/wmo-current-group.md`) paired with the current group index its visible-group set
/// `0xc7cd88` carries. It is the identity every "which room is this subject in" answer resolves to,
/// and — since decision 0696 — the **scope key of the liquid query**: a building's pool belongs to a
/// room, so only a subject standing in that placement can be in it. A liquid footprint has no floor,
/// so without an owner a pool claims every position under its XY forever, in any building, at any
/// depth (Uldaman read as submerged under a *mushroom cave's* water 186 yd overhead).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct WmoRoom {
/// The placement's [`WmoPortalInstance`] entity.
pub instance: Entity,
/// Absolute group index within the building.
pub group: u16,
}
/// The camera's PVS-flood interior claim this frame — `None` with the camera over open world or
/// an EXTERIOR (`0x8`) group's floor; an EXTERIOR_LIT-only porch (`0x40`) still claims (the
/// `[0xc7b748]` writer's 0x8-only rejection, round-6 Q-H(c)). Written by [`compute_wmo_pvs`] off
/// the same down-ray seed the flood runs; consumed by the weather-visible gate
/// (`weather::precip`). When `Some`, `exterior_visible` reports whether this placement's flood
/// (`weather::precip`) and by the camera-eye submersion verdict (`liquid::detect_submersion` —
/// the reference's per-frame environment probe `0x6809c0` picks the WMO group's MLIQ over the ADT
/// liquid on exactly this global, wow-re `terrain/scratch/fog-env-state.md` §1).
/// When `Some`, `exterior_visible` reports whether this placement's flood
/// reached an EXTERIOR-flagged group (`flags & 0x148`) through a screen-rect-surviving portal
/// window — the carved pass-2 law of the weather flag `[0xca80c4]` (`0x6b42d9`, round-6 Q-H(a)):
/// rain stays visible — and falling — through a doorway the camera can SEE; face away and the
@@ -143,6 +182,8 @@ pub struct CameraInteriorClaim(pub Option<InteriorClaim>);
/// See [`CameraInteriorClaim`].
#[derive(Clone, Copy, PartialEq)]
pub struct InteriorClaim {
/// The room the camera EYE is in — the placement + its current group.
pub room: WmoRoom,
/// An EXTERIOR-flagged group (`flags & 0x148`) is in the claiming placement's PVS.
pub exterior_visible: bool,
}
@@ -157,13 +198,20 @@ pub struct WmoPortalPlugin;
impl Plugin for WmoPortalPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CurrentWmoInterior>()
.init_resource::<PlayerWmoRoom>()
.init_resource::<CurrentAreaInterior>()
.init_resource::<CameraWmoFog>()
.init_resource::<CameraInteriorClaim>()
.init_resource::<WmoCullProbe>()
.add_systems(
Update,
(compute_wmo_pvs, track_current_interior, track_area_interior).in_set(WmoPvsSet),
(
compute_wmo_pvs,
track_current_interior,
track_area_interior,
track_unit_interiors,
)
.in_set(WmoPvsSet),
);
}
}
@@ -178,7 +226,7 @@ const MAX_FLOOR_DROP: f32 = 1760.0;
fn compute_wmo_pvs(
wmos: Res<Assets<WmoModel>>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
mut instances: Query<&mut WmoPortalInstance>,
mut instances: Query<(Entity, &mut WmoPortalInstance)>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
mut probe: ResMut<WmoCullProbe>,
@@ -203,7 +251,7 @@ fn compute_wmo_pvs(
// group wins both.
let mut fog_target: Option<WmoFogTarget> = None;
let mut claim: Option<InteriorClaim> = None;
for mut inst in &mut instances {
for (entity, mut inst) in &mut instances {
let Some(model) = wmos.get(&inst.handle) else {
continue;
};
@@ -257,6 +305,10 @@ fn compute_wmo_pvs(
if let Some(nav) = model.group_nav.get(gi).filter(|n| n.flags & 0x8 == 0) {
fog_target = select_wmo_fog(&model.fogs, nav.fog_indices, eye_local);
claim = Some(InteriorClaim {
room: WmoRoom {
instance: entity,
group: gi as u16,
},
exterior_visible: model
.group_nav
.iter()
@@ -598,6 +650,36 @@ mod audit;
mod tests {
use super::*;
/// A prop is drawn while ANY room that names it is visible — the reference's per-visible-group
/// MODR walk. Blackrock's lava falls hang through up to 19 groups; keying them to one owner hid
/// the fall from every camera angle whose flood reached a different referrer. Geometry (a
/// single-group key) is unaffected, and an index past the visible set fails OPEN.
#[test]
fn a_prop_is_drawn_while_any_of_its_rooms_is_visible() {
let inst = WmoPortalInstance {
handle: Handle::default(),
world_from_local: Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, true, false],
};
let key = |groups: &[u16]| WmoGroupVis {
instance: Entity::PLACEHOLDER,
groups: Arc::from(groups),
};
assert!(key(&[1]).drawn_by(&inst), "its own room is visible");
assert!(!key(&[0]).drawn_by(&inst), "its own room is culled");
assert!(
key(&[0, 2, 1]).drawn_by(&inst),
"one visible referrer out of three draws the fall"
);
assert!(!key(&[0, 2]).drawn_by(&inst), "no referrer is visible");
assert!(
!key(&[]).drawn_by(&inst),
"no referrers at all: nothing ORs"
);
assert!(key(&[9]).drawn_by(&inst), "index past the set fails open");
}
#[test]
fn eye_on_portal_only_within_the_band_and_polygon() {
// A vertical doorway portal in the x=0 plane, spanning y ∈ [-2,2], z ∈ [0,4].
@@ -742,6 +824,7 @@ mod tests {
group_nav: Vec::new(),
fogs: Vec::new(),
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
collision_bounds: None,
collision: None,
@@ -756,6 +839,7 @@ mod tests {
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
}
}
}
+186 -7
View File
@@ -29,6 +29,17 @@
//! under it (decision 0258; it is what 0233 deferred). Terrain *above* the eye is not on the
//! down-segment at all, which is exactly what keeps a real tunnel reading INSIDE, and a column in an
//! MCNK hole has no terrain surface to hit, which is how a mine entrance stays walkable.
//!
//! - **Leg C — the camera-void fallback** (decision 0692; **ours**, not the client's): only when Legs
//! A and B both miss AND no terrain surface sits at or below the eye's column, the same race runs
//! once more over the **camera-only** faces (DETAIL set, NOCAMCOLLIDE clear — the faces that stop
//! the camera but never carried the walking BSP). The client returns "outside" here and blanks the
//! building — reproducible in 5875 at the Deadmines entrance, whose behind-the-portal pocket is
//! floored entirely with DETAIL faces: the camera is sealed in by its own collision yet the flood
//! loses it. An eye that camera-collision itself proves is between interior surfaces, under ground
//! level, names the room that owns the surface below it instead. The gates keep the divergence out
//! of every place the client's verdict is *right*: any terrain at/below the eye (doorsteps, streets,
//! open country) or any Leg A/B result leaves the faithful answer untouched.
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalInfo, WmoPortalRef};
pub(crate) use benilla_formats::triangle_z_at as floor_z_at;
@@ -57,6 +68,7 @@ pub(crate) fn down_ray_seeds(
) -> DownRaySeeds {
down_ray_pick(
&model.group_collision_tris,
&model.group_camera_only_tris,
&model.group_nav,
&model.portal_vertices,
&model.portal_infos,
@@ -72,8 +84,10 @@ pub(crate) fn down_ray_seeds(
/// space** (the down-ray's own frame), or `None` where there is no terrain surface to hit — off the
/// streamed tiles, or a hole cut through the ground into this very interior. It is the client's second
/// probe, and it wins the column whenever it is *strictly* nearer to the eye than the WMO's hit.
#[allow(clippy::too_many_arguments)] // the model's stored pieces, passed as parallel slices
pub(crate) fn down_ray_pick(
tris: &[Vec<[[f32; 3]; 3]>],
camera_only_tris: &[Vec<[[f32; 3]; 3]>],
nav: &[WmoGroupNav],
portal_vertices: &[[f32; 3]],
portal_infos: &[WmoPortalInfo],
@@ -167,7 +181,37 @@ pub(crate) fn down_ray_pick(
// Outside: nothing within the ray length, or the winning surface belongs to an exterior group
// (the client clears the instance for either — and then appends no seeds).
let Some(in_group) = best else {
return DownRaySeeds::default();
// Leg C — the camera-void fallback (decision 0692; ours, the module doc has the case).
// Fires only when BOTH faithful legs found nothing AND no terrain surface sits at or below
// the eye: an under-ground eye with a camera-collidable DETAIL surface below it is between
// interior surfaces the camera itself cannot leave, and blanking the building there is the
// client artifact the director rejected. Same race, same broad phase, same exterior and
// ray-length rules as Leg A; a single root, no `across` (no portal was crossed).
if terrain_z.is_some_and(|tz| tz <= eye[2]) {
return DownRaySeeds::default();
}
let mut fb_z = f32::NEG_INFINITY;
let mut fb: Option<usize> = None;
for (gi, g) in nav.iter().enumerate() {
if !in_column(g) {
continue;
}
for tri in camera_only_tris.get(gi).into_iter().flatten() {
if let Some(z) = floor_z_at(tri, eye[0], eye[1]) {
if z <= eye[2] && z > fb_z {
fb_z = z;
fb = Some(gi);
}
}
}
}
let named = fb.filter(|&g| {
eye[2] - fb_z <= MAX_FLOOR_DROP && nav.get(g).is_some_and(|n| n.flags & EXTERIOR == 0)
});
return DownRaySeeds {
in_group: named,
across: None,
};
};
if eye[2] - best_z > MAX_FLOOR_DROP || nav.get(in_group).is_none_or(|g| g.flags & EXTERIOR != 0)
{
@@ -363,7 +407,7 @@ mod tests {
nav: &[WmoGroupNav],
eye: [f32; 3],
) -> Option<usize> {
down_ray_pick(tris, nav, &[], &[], &[], eye, None).in_group
down_ray_pick(tris, &[], nav, &[], &[], &[], eye, None).in_group
}
/// `down_ray_pick` with a portal graph, no terrain — the pre-terrain-race fixtures.
@@ -377,6 +421,7 @@ mod tests {
) -> DownRaySeeds {
down_ray_pick(
tris,
&[],
nav,
portal_vertices,
portal_infos,
@@ -770,31 +815,165 @@ mod tests {
let tris = [quad(0.0, 20.0)];
// A camera 2 yd over the tunnel floor. With no terrain leg (or terrain far below), it is inside.
assert_eq!(
down_ray_pick(&tris, &[mine], &[], &[], &[], [0.0, 0.0, 2.0], None).in_group,
down_ray_pick(&tris, &[], &[mine], &[], &[], &[], [0.0, 0.0, 2.0], None).in_group,
Some(0)
);
// The hillside surface sits at z=8, between the eye (z=2) and... no: the eye is BELOW the
// surface, so the surface is above the eye and off the down-segment — a real tunnel under a
// hill still reads INSIDE.
assert_eq!(
down_ray_pick(&tris, &[mine], &[], &[], &[], [0.0, 0.0, 2.0], Some(8.0)).in_group,
down_ray_pick(
&tris,
&[],
&[mine],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(8.0)
)
.in_group,
Some(0)
);
// Now the camera is in open air 12 yd up, and the ground surface is at z=8 — below the eye and
// ABOVE the tunnel floor (z=0). The terrain wins the column: outside.
assert_eq!(
down_ray_pick(&tris, &[mine], &[], &[], &[], [0.0, 0.0, 12.0], Some(8.0)).in_group,
down_ray_pick(
&tris,
&[],
&[mine],
&[],
&[],
&[],
[0.0, 0.0, 12.0],
Some(8.0)
)
.in_group,
None
);
// A terrain surface exactly coincident with the WMO floor keeps the WMO (the client's strict
// `<`: equal is not "strictly nearer").
assert_eq!(
down_ray_pick(&tris, &[mine], &[], &[], &[], [0.0, 0.0, 2.0], Some(0.0)).in_group,
down_ray_pick(
&tris,
&[],
&[mine],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(0.0)
)
.in_group,
Some(0)
);
// A terrain surface just above the floor (z=0.1 vs floor 0) steals the column.
assert_eq!(
down_ray_pick(&tris, &[mine], &[], &[], &[], [0.0, 0.0, 2.0], Some(0.1)).in_group,
down_ray_pick(
&tris,
&[],
&[mine],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(0.1)
)
.in_group,
None
);
}
#[test]
fn camera_void_fallback_names_the_detail_floored_room() {
// The Deadmines-pocket shape (decision 0692): an interior group whose only floor is DETAIL
// faces — present in the camera-only set, absent from the walking set.
let pocket = nav(0, [-10.0, -10.0, 0.0], [10.0, 10.0, 12.0], 0, 0);
let walk: [Vec<[[f32; 3]; 3]>; 1] = [Vec::new()];
let detail = [quad(0.0, 10.0)];
// Both faithful legs miss, no terrain below ⇒ Leg C names the room, single root.
let s = down_ray_pick(
&walk,
&detail,
&[pocket],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None,
);
assert_eq!(s.in_group, Some(0));
assert_eq!(s.across, None);
// Terrain under a hill (surface above the eye) is off the down-segment: Leg C still fires.
assert_eq!(
down_ray_pick(
&walk,
&detail,
&[pocket],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(8.0)
)
.in_group,
Some(0)
);
}
#[test]
fn camera_void_fallback_defers_to_terrain_and_to_the_walking_leg() {
let pocket = nav(0, [-10.0, -10.0, 0.0], [10.0, 10.0, 12.0], 0, 0);
let detail = [quad(0.0, 10.0)];
// Any terrain surface at/below the eye (a doorstep decal over open ground) keeps the client's
// outside verdict — the fallback's gate.
assert_eq!(
down_ray_pick(
&[Vec::new()],
&detail,
&[pocket],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(1.9),
)
.in_group,
None
);
// A walking-leg answer anywhere (even a lower floor of another group) pre-empts Leg C: the
// faithful verdict is never touched.
let hall = nav(0, [-30.0, -30.0, -5.0], [30.0, 30.0, 12.0], 0, 0);
let walk = [Vec::new(), quad(-5.0, 30.0)];
let det2: [Vec<[[f32; 3]; 3]>; 2] = [quad(0.0, 10.0), Vec::new()];
assert_eq!(
down_ray_pick(
&walk,
&det2,
&[pocket, hall],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None,
)
.in_group,
Some(1)
);
// An EXTERIOR group's detail surface reads as outside, mirroring Leg A's rule.
let ext = nav(EXTERIOR, [-10.0, -10.0, 0.0], [10.0, 10.0, 12.0], 0, 0);
assert_eq!(
down_ray_pick(
&[Vec::new()],
&detail,
&[ext],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None,
)
.in_group,
None
);
}