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