From 7823f6a47a649b0b066fc39e018037191d594fb7 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 27 Jul 2026 19:37:17 +0700 Subject: [PATCH] underwater only when you're in the water, and druid forms work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-assets/src/wmo.rs | 123 +- .../src/bin/benilla-extract/main.rs | 16 + .../src/bin/benilla-extract/scan.rs | 143 +++ crates/benilla-formats/src/items.rs | 28 + crates/benilla-formats/src/lib.rs | 34 +- crates/benilla-formats/src/liquid.rs | 25 +- .../benilla-formats/src/models/collision.rs | 35 +- .../tests/helm_vis_wants_a_helm.rs | 155 +++ crates/benilla/assets/shaders/liquid.wgsl | 133 ++- crates/benilla/assets/shaders/wdl.wgsl | 17 +- crates/benilla/src/debug_panel/visibility.rs | 21 +- crates/benilla/src/doodad_anim.rs | 7 +- crates/benilla/src/entities.rs | 10 + .../benilla/src/entities/attach/char_skin.rs | 93 +- crates/benilla/src/entities/attach/mod.rs | 5 + .../benilla/src/entities/collision_height.rs | 16 +- crates/benilla/src/entities/display.rs | 12 + crates/benilla/src/entities/equipment.rs | 28 +- crates/benilla/src/entities/live_display.rs | 211 ++++ crates/benilla/src/interior.rs | 7 + crates/benilla/src/lighting/apply.rs | 149 --- crates/benilla/src/lighting/mod.rs | 90 +- crates/benilla/src/lighting/resolve.rs | 29 + crates/benilla/src/liquid/mod.rs | 103 ++ .../src/{liquid.rs => liquid/query.rs} | 1024 ++++++----------- crates/benilla/src/liquid/real_data.rs | 338 ++++++ crates/benilla/src/liquid/surface.rs | 513 +++++++++ crates/benilla/src/minimap/interior.rs | 4 + crates/benilla/src/net.rs | 8 +- crates/benilla/src/net/motion/spline.rs | 24 +- crates/benilla/src/particles/material.rs | 13 + crates/benilla/src/player.rs | 15 +- crates/benilla/src/player/swim.rs | 9 +- crates/benilla/src/sound/footsteps.rs | 18 +- crates/benilla/src/sound/water.rs | 21 +- crates/benilla/src/terrain.rs | 79 +- crates/benilla/src/terrain_stream.rs | 10 +- .../src/terrain_stream/spawn/assemble.rs | 7 +- .../benilla/src/terrain_stream/spawn/mod.rs | 59 +- crates/benilla/src/ui_chat/input.rs | 17 +- crates/benilla/src/wdl.rs | 25 +- crates/benilla/src/wmo_portal/audit/mod.rs | 71 ++ crates/benilla/src/wmo_portal/interior.rs | 167 ++- crates/benilla/src/wmo_portal/mod.rs | 104 +- crates/benilla/src/wmo_portal/seed.rs | 193 +++- 45 files changed, 3007 insertions(+), 1202 deletions(-) create mode 100644 crates/benilla-formats/tests/helm_vis_wants_a_helm.rs create mode 100644 crates/benilla/src/entities/live_display.rs delete mode 100644 crates/benilla/src/lighting/apply.rs create mode 100644 crates/benilla/src/liquid/mod.rs rename crates/benilla/src/{liquid.rs => liquid/query.rs} (53%) create mode 100644 crates/benilla/src/liquid/real_data.rs create mode 100644 crates/benilla/src/liquid/surface.rs diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index d5258624..eb0b7fa1 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -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>, + /// 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>, /// 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, - /// 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>, + /// 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>, + /// 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>, /// 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]) -> Vec> { let mut owner: Vec> = 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]) -> Vec]) -> Vec> { + let mut refs: Vec> = 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![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![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) = (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()); + } } diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 81e96afb..b2523290 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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, + }, /// 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, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 7ef3d110..87a65e31 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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> = 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 = 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::>() + .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 { + 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. /// diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index e9c84a83..bec3c766 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -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 { diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 12173cc5..354f8c4c 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs index 9559572b..c0e08fe4 100644 --- a/crates/benilla-formats/src/liquid.rs +++ b/crates/benilla-formats/src/liquid.rs @@ -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) } diff --git a/crates/benilla-formats/src/models/collision.rs b/crates/benilla-formats/src/models/collision.rs index b715c2a4..477d9df3 100644 --- a/crates/benilla-formats/src/models/collision.rs +++ b/crates/benilla-formats/src/models/collision.rs @@ -139,7 +139,7 @@ pub fn accumulate_wmo_group_collision( positions: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { - 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, ) { - 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, +) { + 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, ) { @@ -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 { diff --git a/crates/benilla-formats/tests/helm_vis_wants_a_helm.rs b/crates/benilla-formats/tests/helm_vis_wants_a_helm.rs new file mode 100644 index 00000000..0bc9da1e --- /dev/null +++ b/crates/benilla-formats/tests/helm_vis_wants_a_helm.rs @@ -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}"); + } +} diff --git a/crates/benilla/assets/shaders/liquid.wgsl b/crates/benilla/assets/shaders/liquid.wgsl index 2a0e8620..d25242e9 100644 --- a/crates/benilla/assets/shaders/liquid.wgsl +++ b/crates/benilla/assets/shaders/liquid.wgsl @@ -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, // rgb = ambient; w unused - light_diffuse: vec4, // rgb = sun diffuse; w unused - light_sun: vec4, // xyz = sun TRAVEL dir (to-light = −xyz); w unused - light_spec: vec4, // rgb = sun specular colour; w = shininess (the `secondary` sheen term) - water_shallow: vec4, // rgb = shore tint (IntBand row 16 river / 14 ocean, raw); w = shallow alpha - water_deep: vec4, // rgb = deep tint (IntBand row 17 river / 15 ocean, raw); w = deep alpha (1.0) - fog_color: vec4, // rgb = fog colour (gamma 0..1); w = enable (>0.5) - fog_params: vec4, // 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, anim: vec4, // x = current frame index; y = frame count; zw unused }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var 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, // 0 rgb = ambient; w = Mod2x scale + light_diffuse: vec4, // 1 rgb = sun diffuse; w = clamp flag + light_sun: vec4, // 2 xyz = sun TRAVEL dir (to-light = −xyz) + light_spec: vec4, // 3 rgb = row-9 specular colour; w = TERRAIN shininess (liquid uses w.kind.w) + fog_color: vec4, // 4 rgb = scene fog (block 1, gamma 0..1); w = enable (>0.5) + fog_params: vec4, // 5 x = start yd; y = end yd; w = the farclip wall + _sh: array, 6>, // 6-11 model SH coeffs — unread here + _sh_c16: vec4, // 12 + water_river: array, 2>, // 13-14 shallow/deep river-lake swatch (IntBand 16/17); w = alpha + water_ocean: array, 2>, // 15-16 shallow/deep ocean swatch (IntBand 14/15); w = alpha + _grade: vec4, // 17 + wmo_fog_color: vec4, // 18 rgb = INTERIOR fog (block 2); w = enable + wmo_fog_params: vec4, // 19 x = start yd; y = end yd +}; +@group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; + struct LiquidVsOut { @builtin(position) clip_position: vec4, @location(0) world_position: vec4, @@ -72,11 +92,47 @@ struct LiquidVsOut { // EVALUATION DOMAIN differs (interpolated vs evaluated per fragment). fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { 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, world_pos: vec3) -> vec3 { + 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(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 { // 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(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 { // 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(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(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 { // 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(0.0), vec3(1.0), ); @@ -153,17 +226,11 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // 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(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(rgb, alpha); diff --git a/crates/benilla/assets/shaders/wdl.wgsl b/crates/benilla/assets/shaders/wdl.wgsl index 03aed260..3ee90886 100644 --- a/crates/benilla/assets/shaders/wdl.wgsl +++ b/crates/benilla/assets/shaders/wdl.wgsl @@ -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, // rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend) - fog_params: vec4, // 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, // 0 + _light_diffuse: vec4, // 1 + _light_sun: vec4, // 2 + _light_spec: vec4, // 3 + fog_color: vec4, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend) + fog_params: vec4, // 5 x = fog_start yd; y = fog_end yd; z unused; w = farclip wall (0 ⇒ off) }; -@group(#{MATERIAL_BIND_GROUP}) @binding(100) var w: WdlParams; +@group(#{MATERIAL_BIND_GROUP}) @binding(90) var w: WowLight; struct WdlVsOut { @builtin(position) clip_position: vec4, diff --git a/crates/benilla/src/debug_panel/visibility.rs b/crates/benilla/src/debug_panel/visibility.rs index a43c3274..b2696c94 100644 --- a/crates/benilla/src/debug_panel/visibility.rs +++ b/crates/benilla/src/debug_panel/visibility.rs @@ -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 { diff --git a/crates/benilla/src/doodad_anim.rs b/crates/benilla/src/doodad_anim.rs index 7e3f5077..743ba698 100644 --- a/crates/benilla/src/doodad_anim.rs +++ b/crates/benilla/src/doodad_anim.rs @@ -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. diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index 53d8b21e..459e92e4 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -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) diff --git a/crates/benilla/src/entities/attach/char_skin.rs b/crates/benilla/src/entities/attach/char_skin.rs index f0174d50..9ea0e001 100644 --- a/crates/benilla/src/entities/attach/char_skin.rs +++ b/crates/benilla/src/entities/attach/char_skin.rs @@ -61,45 +61,53 @@ pub(super) fn resolve_char_look( entity: Entity, stores: &Query<&ObjectStore>, ) -> Option { - 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 diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index 69978e3b..3e719bc0 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -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::(); } } diff --git a/crates/benilla/src/entities/collision_height.rs b/crates/benilla/src/entities/collision_height.rs index 68c56a38..f52c3944 100644 --- a/crates/benilla/src/entities/collision_height.rs +++ b/crates/benilla/src/entities/collision_height.rs @@ -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>, diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index c2b891d4..412f7670 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -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, } } diff --git a/crates/benilla/src/entities/equipment.rs b/crates/benilla/src/entities/equipment.rs index 289bf236..2272f2fc 100644 --- a/crates/benilla/src/entities/equipment.rs +++ b/crates/benilla/src/entities/equipment.rs @@ -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; 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| { diff --git a/crates/benilla/src/entities/live_display.rs b/crates/benilla/src/entities/live_display.rs new file mode 100644 index 00000000..558ff68d --- /dev/null +++ b/crates/benilla/src/entities/live_display.rs @@ -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); + +/// 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 { + 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>, + mut entities: Query< + ( + Entity, + &Guid, + &mut NetEntity, + &ObjectStore, + &AppliedDisplay, + Option<&CollisionHeight>, + &Transform, + ), + With, + >, +) { + 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::() + .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