From 404bbb0c6a6cb5979fc65c9c87733dc7ca82bdd6 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 19:23:04 +0700 Subject: [PATCH] lava creeps again, and sealed rooms empty of outdoor bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lava and slime surfaces creep on the building path — the two liquid types the reference animates, and only those - particle blend 1 is AlphaKey: the rock elemental throws tumbling chips instead of flat pale squares - standing in a sealed room, outdoor creatures are never submitted to the scene at all — the reference's own election, not a hide - an animation clip's arm frame fires no event keys, matching the client's walker rule frame for frame Co-Authored-By: Claude Fable 5 --- .../src/capture/probes/live_fps.rs | 5 +- crates/benilla-app/src/capture/waterfx.rs | 3 + crates/benilla-app/src/creature_anim.rs | 2 +- .../benilla-app/src/creature_anim/events.rs | 238 ++++++++++++-- crates/benilla-app/src/entities.rs | 43 ++- crates/benilla-app/src/entities/attach/mod.rs | 9 + crates/benilla-app/src/go_anim.rs | 9 +- crates/benilla-app/src/pipe_warm/mod.rs | 3 +- crates/benilla-app/src/preflight.rs | 62 ++++ crates/benilla-app/src/probe_shield.rs | 5 +- crates/benilla-app/src/sound/liquid_loop.rs | 8 + crates/benilla-app/src/ui_mirror.rs | 4 + crates/benilla-assets/src/shaders/liquid.wgsl | 22 +- .../src/bin/benilla-extract/scan/particles.rs | 18 +- .../benilla-formats/src/models/wmo/group.rs | 30 +- crates/benilla-formats/src/particles.rs | 29 +- crates/benilla-formats/src/ribbons.rs | 34 +- crates/benilla-formats/tests/particles.rs | 37 +++ crates/benilla-world/src/exterior_cull.rs | 241 +++++++++++++- crates/benilla-world/src/liquid/surface.rs | 298 ++++++++++++++++-- crates/benilla-world/src/model_fade.rs | 1 + .../src/model_render/visibility.rs | 16 +- crates/benilla-world/src/particles/buffer.rs | 16 +- crates/benilla-world/src/particles/render.rs | 3 + .../benilla-world/src/shaders/wow_effect.wgsl | 20 ++ crates/benilla-world/src/world_census.rs | 6 + crates/benilla-world/src/world_unit.rs | 19 ++ 27 files changed, 1065 insertions(+), 116 deletions(-) diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 19105c0d..9b0ce627 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -255,8 +255,9 @@ fn drive_live_fps( }; let culled = match seen.cull.as_ref() { Some(v) => format!( - " cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} cull_unbounded={}", - v.windows, v.frusta, v.tested, v.hidden, v.unbounded + " cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} \ + cull_unbounded={} cull_bodies={} cull_bodies_hidden={}", + v.windows, v.frusta, v.tested, v.hidden, v.unbounded, v.bodies, v.bodies_hidden ), None => String::new(), }; diff --git a/crates/benilla-app/src/capture/waterfx.rs b/crates/benilla-app/src/capture/waterfx.rs index a655faa7..e429a473 100644 --- a/crates/benilla-app/src/capture/waterfx.rs +++ b/crates/benilla-app/src/capture/waterfx.rs @@ -133,6 +133,9 @@ pub(crate) fn spawn( wades: true, scale: 1.0, height: crate::entities::CollisionHeight::default().0, + // The fixture is a foam rig, not a scene body: it has no model box and the capture is + // outdoors, where the exterior cull stands down anyway. `None` = don't decide. + bound: None, }, crate::entities::VisualAttached, Transform::from_translation(wow_to_bevy([start_x, cy, surf - view.depth])), diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index b3eb4b58..5132256d 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -664,7 +664,7 @@ use driver::{drive_animations, drive_hand_grip}; mod events; use events::fire_anim_events; pub(crate) use events::{ - advance_track, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent, + advance_track, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent, TrackMemory, }; /// The `$BTH` breath puffs — a unit's visible cold vapour in a snow zone (B233, decision 1149). diff --git a/crates/benilla-app/src/creature_anim/events.rs b/crates/benilla-app/src/creature_anim/events.rs index b8239715..268bfaf3 100644 --- a/crates/benilla-app/src/creature_anim/events.rs +++ b/crates/benilla-app/src/creature_anim/events.rs @@ -54,31 +54,42 @@ pub(crate) fn footfall_side(ident: &[u8; 4]) -> Option { .then_some(ident[2]) } -/// How far (seconds) into a just-switched clip the playhead may be for [`fire_anim_events`] to -/// count it as *watched from the start* and fire its head window (`t = 0` keyframes included). -/// Comfortably above a frame (even a hitchy one), comfortably below the corpse settle's -/// `seek_to(duration)` (every Death clip is > 1 s). +/// How far (seconds) into a just-armed clip the playhead may have been *at the arm* for +/// [`advance_track`] to open the next frame's window at the clip's head (`t = 0` keyframes +/// included). Comfortably above a frame (even a hitchy one), comfortably below the corpse +/// settle's `seek_to(duration)` (every Death clip is > 1 s). const FRESH_CLIP_HEAD: f32 = 0.25; +/// One scanned track's memory: the graph node playing, the seek last seen on it, and whether that +/// seek is an **arm stamp** — the frame the clip was armed, which fires nothing (see +/// [`advance_track`]). +#[derive(Clone, Copy)] +pub(crate) struct TrackSeek { + node: AnimationNodeIndex, + seek: f32, + armed: bool, +} + +/// A scanner's per-entity track memory — the `Local` every [`advance_track`] caller owns. +pub(crate) type TrackMemory = bevy::ecs::entity::EntityHashMap; + /// Fire the event keyframes the current clip crossed since last frame. Runs after /// [`super::driver::drive_animations`] so the clip/seek state is this frame's. Per unit we -/// remember `(anim_id, seek_time)`; a loop wrap fires the tail `(prev, duration]` then the head -/// `[0, cur]`. +/// remember the playing node and its seek ([`TrackSeek`]); a loop wrap fires the tail +/// `(prev, duration]` then the head `[0, cur]` — a `t = 0` key really does re-fire on every wrap, +/// in the reference too (wow-re `m2-event-track-walker.md` §2), which is why a held turn-in-place +/// lays a footprint pair twice a second there as well. /// -/// Arming: **first sight of a unit arms silently** (it streamed in mid-clip — never back-fire a -/// window we didn't watch). A **clip change on a tracked unit fires the head window `[0, cur]`** -/// when the playhead is near the start ([`FRESH_CLIP_HEAD`]) — we watched this clip begin, and -/// keyframes at exactly `t = 0` are real (the emote voices: EmoteLaugh/Cry/Chicken carry their -/// `$CSD` at `0.000`, probe-verified — the old arm-only rule silenced /laugh while mid-clip -/// /applaud clapped). A clip change that *starts* deep in the timeline (the corpse settle's -/// `seek_to(duration)`) stays silent — that window wasn't played. +/// Arming is [`advance_track`]'s: an arm frame fires nothing, the frame after it opens the clip's +/// head window (so `t = 0` keyframes are real), and a clip that doesn't survive its arm frame +/// fires nothing at all — the reference's own walker rule, byte-cited there. pub(super) fn fire_anim_events( units: Query<(Entity, &ModelAnimations, &AnimationPlayer, &AnimDriver)>, - mut last: Local>, - // The **masked overlay** track's own `(node, seek)` memory (decision 0087): a swing/emote routed to - // the SpineLow overlay plays *beside* the base, so its events (a swing's `$CSS`, an emote's `$CSD`) + mut last: Local, + // The **masked overlay** track's own memory (decision 0087): a swing/emote routed to the + // SpineLow overlay plays *beside* the base, so its events (a swing's `$CSS`, an emote's `$CSD`) // are scanned on their own node — the base scan above never sees them. - mut last_overlay: Local>, + mut last_overlay: Local, mut out: MessageWriter, anim_data: Option>, ) { @@ -105,8 +116,9 @@ pub(super) fn fire_anim_events( } // Masked overlay track: events fire from whichever track plays the clip. The overlay knows // its exact node — match it back to its clip (a variation's `upper_node`, decision 0114). - // A freshly-started overlay (seek ≈ 0) fires its head window via [`advance_track`], so an - // emote's `t = 0` `$CSD` voice still rings; a swing's mid-clip `$CSS` fires as normal. + // A freshly-started overlay fires its head window one frame after the arm via + // [`advance_track`], so an emote's `t = 0` `$CSD` voice still rings; a swing's mid-clip + // `$CSS` fires as normal. if let Some(ov) = drv.overlay { let id = resolved_id(anims, ov.id, catalog); let clip = anims @@ -126,25 +138,48 @@ pub(super) fn fire_anim_events( } } -/// Advance a per-track `(node, seek)` memory and return the `prev` seek to scan events from, or `None` -/// to only arm this frame. Keyed by the playing **graph node** (not the semantic id): two variations -/// of the same id are different timelines with different event tracks (decision 0114), and a node -/// switch is a clip change like any other. **First sight of a unit arms silently** (it may have -/// streamed in mid-clip — never back-fire a window we didn't watch). A **clip change caught near the -/// start** ([`FRESH_CLIP_HEAD`]) returns `-1.0` so the head window `[0, cur]` fires — we watched this -/// clip begin, and `t = 0` keyframes are real (the emote voices carry `$CSD` at `0.000`). A clip -/// change that *starts* deep in its timeline (the corpse settle's `seek_to(duration)`) stays silent. +/// Advance a per-track memory and return the `prev` seek to scan events from, or `None` to only +/// arm this frame. Keyed by the playing **graph node** (not the semantic id): two variations of +/// the same id are different timelines with different event tracks (decision 0114), and a node +/// switch is a clip change like any other. +/// +/// **The arm frame fires nothing** — the reference's own rule, and the reason this is not simply +/// "fire the head window when you see a new clip" (decision 1273). The client's animation arm +/// `0x7121a0` bakes the block's window start `+0xa8 = now` (`0x712758`), so on that frame the +/// walker computes `prev == cur` and `0x719518 jae` abandons the block before reading a single +/// key. The window only opens on the **next** frame, and it opens at the arm stamp — local +/// `t = 0` — with the fire test `prevL <= t < curL` (`0x7196d5`–`0x7196d9`), so `t = 0` keyframes +/// are real (the emote voices carry `$CSD` at `0.000`) but only for a clip that is **still armed a +/// frame later**. A clip armed and abandoned inside one frame fires nothing at all, which is what +/// keeps a flickering Shuffle↔Stand churn silent in the reference — and is exactly what our +/// fire-on-the-arm-frame rule turned into a footprint carpet under a stuttering mouse-turn. +/// +/// So: a clip change (and first sight of a unit — it may have streamed in mid-clip) records the +/// arm and returns `None`; the frame after an arm returns `-1.0`, opening the head window +/// `[0, cur]`. An arm that *starts* deep in its timeline (the corpse settle's `seek_to(duration)`, +/// past [`FRESH_CLIP_HEAD`]) opens at its own stamp instead, so a settled corpse never replays its +/// collapse's keys. pub(crate) fn advance_track( - last: &mut bevy::ecs::entity::EntityHashMap<(AnimationNodeIndex, f32)>, + last: &mut TrackMemory, entity: Entity, node: AnimationNodeIndex, cur: f32, ) -> Option { - match last.insert(entity, (node, cur)) { - Some((pnode, p)) if pnode == node => Some(p), - Some(_) if cur <= FRESH_CLIP_HEAD => Some(-1.0), - _ => None, - } + let was = last.get(&entity).copied().filter(|t| t.node == node); + last.insert( + entity, + TrackSeek { + node, + seek: cur, + armed: was.is_none(), + }, + ); + let was = was?; // the arm frame itself: recorded, scanned never + Some(if was.armed && was.seek <= FRESH_CLIP_HEAD { + -1.0 + } else { + was.seek + }) } /// Fire the event keyframes `clip` crossed on `(prev, cur]`. A loop wrap (`cur < prev`) fires the @@ -212,4 +247,141 @@ mod tests { fn lossy(t: &[u8; 4]) -> String { String::from_utf8_lossy(t).into_owned() } + + fn track() -> (TrackMemory, Entity, AnimationNodeIndex, AnimationNodeIndex) { + ( + TrackMemory::default(), + Entity::from_raw_u32(1).expect("valid entity id"), + AnimationNodeIndex::new(7), // ShuffleLeft, say + AnimationNodeIndex::new(11), // Stand + ) + } + + /// **The arm frame scans nothing, and the frame after it opens the clip's head.** The + /// reference's arm (`0x7121a0`) stamps the block's window start at `now`, so the walker's + /// `prev == cur` abandons the block that frame (`0x719518`); the next frame's window opens at + /// that stamp — local `t = 0`, lower-inclusive — which is what makes an emote's `t = 0.000` + /// `$CSD` real without making it fire a frame early. + #[test] + fn an_arm_frame_is_silent_and_the_next_frame_opens_the_head() { + let (mut last, unit, shuffle, _) = track(); + assert_eq!( + advance_track(&mut last, unit, shuffle, 0.0), + None, + "the arm frame itself scans nothing" + ); + assert_eq!( + advance_track(&mut last, unit, shuffle, 0.016), + Some(-1.0), + "the frame after the arm opens the head window [0, cur]" + ); + assert_eq!( + advance_track(&mut last, unit, shuffle, 0.032), + Some(0.016), + "…and steady frames scan (prev, cur] as before" + ); + } + + /// **A clip armed and abandoned inside one frame fires nothing** — the report this rule was + /// written for (decision 1273). A stuttering mouse-turn flickers the gait Shuffle↔Stand at + /// input cadence, and HumanMale's ShuffleLeft keys `$SL0`+`$SR0` at `t = 0.000`: firing on the + /// arm frame laid a footprint pair per flicker, where the reference lays none. + #[test] + fn a_clip_armed_for_one_frame_never_fires() { + let (mut last, unit, shuffle, stand) = track(); + for _ in 0..8 { + assert_eq!(advance_track(&mut last, unit, shuffle, 0.0), None); + assert_eq!(advance_track(&mut last, unit, stand, 0.0), None); + } + } + + /// An arm that *starts* deep in its timeline — the corpse settle's `seek_to(duration)` — + /// opens at its own stamp, never at the head: a body that streamed in dead must not replay + /// the collapse's `$DTH` keys. + #[test] + fn an_arm_deep_in_the_timeline_never_opens_the_head() { + let (mut last, unit, death, _) = track(); + assert_eq!(advance_track(&mut last, unit, death, 2.0), None); + assert_eq!( + advance_track(&mut last, unit, death, 2.0), + Some(2.0), + "the window opens at the settle stamp — (2.0, 2.0] is empty" + ); + } + + /// The turn-in-place cadence, which this change deliberately leaves alone: HumanMale's + /// ShuffleLeft (anim 11) is a **0.500 s loop** whose only keys are `$SL0` and `$SR0`, both at + /// `t = 0.000`, and the reference re-fires a `t = 0` key on **every wrap** (wow-re + /// `m2-event-track-walker.md` §2, byte-traced) — so a held turn lays a print pair twice a + /// second in the real client too. + #[test] + fn a_loop_wrap_refires_the_head_keys() { + let clip = shuffle_clip(); + assert_eq!(fired(&clip, -1.0, 0.016), vec![*b"$SL0", *b"$SR0"], "head"); + assert!( + fired(&clip, 0.016, 0.4).is_empty(), + "mid-lap: nothing keyed" + ); + assert_eq!( + fired(&clip, 0.48, 0.01), + vec![*b"$SL0", *b"$SR0"], + "the wrap fires the tail (empty here) then the head — both feet again" + ); + } + + /// HumanMale ShuffleLeft's real shape (`benilla-extract m2events`): 0.500 s, looping, keys + /// `$SL0` and `$SR0` both at 0.000. + fn shuffle_clip() -> AnimClip { + AnimClip { + anim_id: 11, + seq_index: 38, + node: AnimationNodeIndex::new(7), + looping: true, + duration: 0.5, + move_speed: 0.0, + blend_time: 0.15, + bounds_center: bevy::prelude::Vec3::ZERO, + bounds_radius: 0.0, + bounds_min: bevy::prelude::Vec3::ZERO, + bounds_max: bevy::prelude::Vec3::ZERO, + events: vec![ + benilla_formats::AnimEvent { + time: 0.0, + ident: *b"$SL0", + data: 0, + }, + benilla_formats::AnimEvent { + time: 0.0, + ident: *b"$SR0", + data: 0, + }, + ] + .into(), + arm_nodes: None, + upper_node: None, + frequency: 0, + replay: (0, 0), + poses_bones: true, + } + } + + /// The idents [`scan_events`] fires over one `(prev, cur]` window, in order. + fn fired(clip: &AnimClip, prev: f32, cur: f32) -> Vec<[u8; 4]> { + use bevy::ecs::system::RunSystemOnce; + #[derive(bevy::prelude::Resource)] + struct Window(AnimClip, f32, f32); + let mut world = bevy::prelude::World::new(); + world.init_resource::>(); + world.insert_resource(Window(clip.clone(), prev, cur)); + world + .run_system_once( + |win: bevy::prelude::Res, mut out: MessageWriter<_>| { + let unit = Entity::from_raw_u32(1).expect("valid entity id"); + scan_events(&win.0, unit, win.1, win.2, &mut out); + }, + ) + .expect("run_system_once"); + let mut msgs = world.resource_mut::>(); + msgs.drain().map(|m| m.ident).collect() + } } diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 52b153d9..bc33c26e 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -413,6 +413,28 @@ fn scope_entity_art( scope.apply(&mut composites.0, benilla_world::art_scope::ArtSlot::Skins); } +/// **The armed idle's authored CAaBox for a built body**, model space — recorded by +/// [`attach_entity_visuals`] where the display model is read, and restated onto +/// [`WorldUnit::bound`](benilla_world::world_unit::WorldUnit::bound) by [`publish_world_units`]. +/// +/// Split from the field it feeds for the same reason `CollisionHeight` is: the attach knows the +/// number, the reconciler owns the component, and one writer per component is decision 0025. Absent +/// until a body's model resolves — and on a body that never gets one, absent for good. +#[derive(Component, Clone, Copy)] +pub(crate) struct ModelBound(pub(crate) bevy::camera::primitives::Aabb); + +/// Everything [`publish_world_units`] reads to restate one body: its wire record, the two +/// game-side components whose numbers it folds in, whether its `Visibility` is the transport +/// tick's, and what it currently says. +type WireBody = ( + Entity, + &'static NetEntity, + Option<&'static collision_height::CollisionHeight>, + Option<&'static ModelBound>, + Has, + Option<&'static benilla_world::world_unit::WorldUnit>, +); + /// **Restate every wire body as a [`WorldUnit`](benilla_world::world_unit::WorldUnit)** — the game's half /// of the unit inversion (see that module). /// @@ -423,19 +445,14 @@ fn scope_entity_art( /// visible to the world this frame. fn publish_world_units( mut commands: Commands, - bodies: Query<( - Entity, - &NetEntity, - Option<&collision_height::CollisionHeight>, - Option<&benilla_world::world_unit::WorldUnit>, - )>, + bodies: Query, viewers: Query<( Entity, Has, Has, )>, ) { - for (entity, net, height, current) in &bodies { + for (entity, net, height, bound, anchored, current) in &bodies { let want = benilla_world::world_unit::WorldUnit { // The wire kind is answered HERE and never handed over (1177): the engine asks "does // this body displace water", and translating its own vocabulary into that answer is @@ -448,11 +465,21 @@ fn publish_world_units( // swims on dry land". A body whose display has not resolved yet must read as a // default-sized body, which is what the foam site did before this component existed. height: height.copied().unwrap_or_default().0, + // The box the exterior cull may elect this body by (decision 1270). **A transport + // answers `None` on purpose**: `transport::tick_transports` writes that root's + // `Visibility` every frame off its own timetable, and a second writer there is the + // fight decision 0025 exists to prevent — so the world is told not to decide, rather + // than told a box and left to race. Every other body offers its model's box, once it + // has one. + bound: (!anchored).then_some(bound).flatten().map(|b| b.0), }; // Only write on a real change: the component is change-detected downstream, and a // per-frame rewrite would mark every body dirty for every reader every frame. let same = current.is_some_and(|c| { - c.wades == want.wades && c.scale == want.scale && c.height == want.height + c.wades == want.wades + && c.scale == want.scale + && c.height == want.height + && c.bound == want.bound }); if !same { commands.entity(entity).insert(want); diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 56d6da51..16b21511 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -644,6 +644,15 @@ pub(super) fn attach_entity_visuals( bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max) }) }); + // …and the same box is this body's **election bound** (decision 1270): standing in a + // sealed WMO room the reference never submits an outdoor object at all, so the cull + // needs one whole-object AABB per body, on its root. Recorded here and restated onto + // `WorldUnit::bound` by `publish_world_units` — the one reconciler that answers every + // "what is this body to the world" question, exactly as it already does for the + // collision height. A body with no authored idle box records none and keeps drawing. + if let Some(aabb) = idle_aabb { + commands.entity(entity).insert(super::ModelBound(aabb)); + } // Everything one part's spawn reads from this unit, gathered once (`attach::dress`) — // the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at // stream-in and a geoset that appears when a belt is swapped are dressed by one law. diff --git a/crates/benilla-app/src/go_anim.rs b/crates/benilla-app/src/go_anim.rs index 4a7aa2ee..50bddfb2 100644 --- a/crates/benilla-app/src/go_anim.rs +++ b/crates/benilla-app/src/go_anim.rs @@ -680,13 +680,12 @@ fn drive_go_collision( /// The playing clip is found as the creature scanner finds a variation track: of the clips with /// a live [`AnimationPlayer`] play, the smallest seek is the newest arm (a cross-fade's fading /// source is older by construction). The shared [`advance_track`]/[`scan_events`] helpers give -/// the same arming rules — first sight silent, a watched clip-start fires its `t = 0` head, a -/// loop wrap fires tail-then-head — and a frozen rate-0 leg never advances, so it never fires. +/// the same arming rules — an arm frame fires nothing, the frame after it fires the clip's `t = 0` +/// head, a loop wrap fires tail-then-head — and a frozen rate-0 leg never advances, so it never +/// fires. fn fire_go_anim_events( gos: Query<(Entity, &ModelAnimations, &AnimationPlayer), With>, - mut last: Local< - bevy::ecs::entity::EntityHashMap<(bevy::animation::graph::AnimationNodeIndex, f32)>, - >, + mut last: Local, mut out: MessageWriter, ) { for (entity, anims, player) in &gos { diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index a17a213b..12fc4e9b 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -527,7 +527,7 @@ fn despawn_rigs(commands: &mut Commands, rigs: &Query<(Entity, Option<&ChildOf>) /// `MaterialPlugin` one, so no menagerie *entity* can reach it: its pipelines exist only when a /// draw record sits in the shared stream at queue time. So while the pass runs, this pushes one /// degenerate draw per reachable [`EffectPipelineKey`] — the full blend × raster-bias cross -/// ({Add, Alpha, Opaque, Multiply, Mod2x} × {0, ground-decal, blob-shadow}; the key's own doc +/// ({Add, Alpha, Opaque, AlphaKey, Multiply, Mod2x} × {0, ground-decal, blob-shadow}; the key's own doc /// pins the closed bias set) — through the PRODUCTION stream (`EffectQuads` → extract → queue → /// specialize), once per view class: the world camera (samples=N) and the twin booth (samples=1). /// The queue path specializes per matching view regardless of coverage, so a 4-vertex sliver at @@ -568,6 +568,7 @@ fn warm_effect_lane( EffectBlend::Add, EffectBlend::Alpha, EffectBlend::Opaque, + EffectBlend::AlphaKey, EffectBlend::Multiply, EffectBlend::Mod2x, ] { diff --git a/crates/benilla-app/src/preflight.rs b/crates/benilla-app/src/preflight.rs index 47d77221..c36f5649 100644 --- a/crates/benilla-app/src/preflight.rs +++ b/crates/benilla-app/src/preflight.rs @@ -91,6 +91,16 @@ const GM_FACTION_TEMPLATE: u32 = 35; /// and a coordinate triple do not tell a reader at a glance that this character is nowhere useful. const ZONE_WAIT_SECS: f32 = 4.0; +/// How long we let an in-flight `.gm off` settle before banner-ing the GM flag anyway. +/// +/// `WOW_GM=off` is a **round trip** and it loses the race with the descriptor: the shield sends +/// `.gm off` at world entry and the server's answer lands ~0.9 s later, while the self descriptor +/// is here in ~0.3 s. Without this grace the banner shouts "GM MODE IS ON — any damage or drowning +/// reading taken now is wrong" at precisely the run that asked for GM off, which reads as *discard +/// this measurement* and is exactly backwards (a 2026-08-13 lava probe hit it). Wait, then report +/// what actually held — a `.gm off` that never lands is still warned about, one grace later. +const GM_OFF_WAIT_SECS: f32 = 3.0; + /// The hard backstop: report whatever we have this long after world entry. Only reached when /// something is badly wrong with the stream, and reporting late beats never reporting. const DESCRIPTOR_WAIT_SECS: f32 = 8.0; @@ -186,6 +196,16 @@ fn report_session( if zone.is_none() && time.elapsed_secs() - armed_at < ZONE_WAIT_SECS { return; // the tile under us hasn't streamed yet — give the zone name its grace } + // The other in-flight state the banner would otherwise misreport: a requested `.gm off` that + // the server has not answered yet (see [`GM_OFF_WAIT_SECS`]). + if hold_for_gm_off( + store.0.player_flags() & PLAYER_FLAGS_GM != 0, + crate::probe_shield::wants_gm_off(), + shield.report(), + time.elapsed_secs() - armed_at, + ) { + return; + } state.armed_at = None; let name = self_guid @@ -228,6 +248,20 @@ fn report_session( } } +/// Whether to hold the banner for a `.gm off` that has gone out but not been answered +/// ([`GM_OFF_WAIT_SECS`] is the why). Pure, so the race it guards is testable without an app. +/// +/// All four clauses matter. Only a run that *asked* for GM off waits; only a body the shield +/// actually commands (`probe`) can have a `.gm off` in flight at all — on the director's own +/// account `WOW_GM` is inert (0677), so waiting there would just delay a true warning; and the +/// grace expires, so a `.gm off` that never lands is still reported, one grace later. +fn hold_for_gm_off(gm_flag_set: bool, wants_off: bool, shield: ShieldReport, waited: f32) -> bool { + gm_flag_set + && wants_off + && matches!(shield, ShieldReport::Arming | ShieldReport::Armed) + && waited < GM_OFF_WAIT_SECS +} + /// Everything about this avatar that will quietly invalidate a session's work, worst first. Pure /// over the descriptor (plus the one piece of state that rides no descriptor field — the probe /// shield, decision 0677) so the whole ladder is unit-testable. @@ -346,6 +380,34 @@ mod tests { const UNIT_FLAGS: u16 = 46; const PLAYER_FLAGS: u16 = 190; + /// The banner must not accuse a `WOW_GM=off` run of measuring through GM mode while the + /// `.gm off` it just sent is still in flight — the race [`GM_OFF_WAIT_SECS`] exists for, and + /// the one a lava probe walked into on 2026-08-13. + #[test] + fn the_banner_waits_out_a_gm_off_round_trip_but_not_forever() { + // The race itself: flag still set, we asked for off, shield is ours, answer not back yet. + assert!(hold_for_gm_off(true, true, ShieldReport::Arming, 0.4)); + assert!(hold_for_gm_off(true, true, ShieldReport::Armed, 0.4)); + + // …but the grace expires, so a `.gm off` that never lands is still reported. + assert!(!hold_for_gm_off( + true, + true, + ShieldReport::Armed, + GM_OFF_WAIT_SECS + 0.1 + )); + + // Nothing else ever waits. GM mode is the DEFAULT (0679): a run that did not ask for it off + // must be warned immediately, not three seconds late… + assert!(!hold_for_gm_off(true, false, ShieldReport::Armed, 0.4)); + // …a body the shield does not command (the director's own account) can have no `.gm off` + // in flight at all, so waiting there would only delay a true warning (0677)… + assert!(!hold_for_gm_off(true, true, ShieldReport::NotOurs, 0.4)); + assert!(!hold_for_gm_off(true, true, ShieldReport::Disabled, 0.4)); + // …and with the flag already clear there is nothing to wait for. + assert!(!hold_for_gm_off(false, true, ShieldReport::Armed, 0.4)); + } + #[test] fn a_healthy_avatar_reports_nothing() { let f = player(&[(HEALTH, 60), (MAXHEALTH, 60)]); diff --git a/crates/benilla-app/src/probe_shield.rs b/crates/benilla-app/src/probe_shield.rs index 6041e1b8..7d3559e1 100644 --- a/crates/benilla-app/src/probe_shield.rs +++ b/crates/benilla-app/src/probe_shield.rs @@ -173,7 +173,10 @@ fn is_probe_account(user: &str) -> bool { /// factions, hostility, colours, threat, damage or timers. An explicit ask always beats the /// default, and the rig's token is an explicit ask: two modules must not send `.gm` at each other, /// so the one with the spec in front of it wins (0651's rig owns its own `gm:` step). -fn wants_gm_off() -> bool { +/// +/// [`crate::preflight`] reads it too — the banner has to know a `.gm off` is on its way out before +/// it accuses the run of measuring through GM mode. +pub(crate) fn wants_gm_off() -> bool { std::env::var("WOW_GM").is_ok_and(|v| v.eq_ignore_ascii_case("off")) || std::env::var("WOW_RIG").is_ok_and(|spec| rig_asks_for_gm_off(&spec)) } diff --git a/crates/benilla-app/src/sound/liquid_loop.rs b/crates/benilla-app/src/sound/liquid_loop.rs index 7bace3bd..919c68a5 100644 --- a/crates/benilla-app/src/sound/liquid_loop.rs +++ b/crates/benilla-app/src/sound/liquid_loop.rs @@ -107,6 +107,14 @@ fn drive_liquid_loops( let (Some(water_sounds), Some(mut kits), Some(assets)) = (water_sounds, kits, assets) else { return; }; + // Running silent — no audio device (`WOW_NOSOUND=1`, CI, an unattended probe). A loop that + // fails to start is never *held*, so this system re-attempts it every frame and every attempt + // warns: a 75 s lava probe logged 3975 identical `no audio device` lines, 89% of the file, and + // paid a failed kit resolve per frame for them. The device is a startup fact that nothing here + // can recover, and `sound::plugin` already warns about it once (see [`SoundOutput`]). + if out.mixer.is_none() { + return; + } // The submerge HARD stop (no fade) + the resurface instant-restart edge. if world.submersion().is_water() { diff --git a/crates/benilla-app/src/ui_mirror.rs b/crates/benilla-app/src/ui_mirror.rs index 7096106e..72158d21 100644 --- a/crates/benilla-app/src/ui_mirror.rs +++ b/crates/benilla-app/src/ui_mirror.rs @@ -154,6 +154,10 @@ fn feed_mirror_timers( ), MirrorTimerEdge::Stop { .. } => ("MIRROR_TIMER_STOP", vec![name]), }; + // One line per edge. The bars live inside the script VM, so from outside it a mirror + // timer is otherwise unobservable — and "did the server start a breath timer *here*?" is + // the first question of any drowning / fatigue / liquid-hazard probe. + debug!("net: mirror timer {event} {edge:?}"); script.fire_event(event, args); } } diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index d25242e9..c3c5da5e 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -49,7 +49,10 @@ struct LiquidParams { // 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 + // x = current frame index; y = frame count; z = the v-scroll offset in texture repeats + // (nibble-6/7 WMO magma/slime only — the reference's animated stage-0 texture matrix; see + // `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w unused. + anim: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams; @@ -100,6 +103,21 @@ fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { return wow_light.light_spec.rgb * pow(ndoth, max(w.kind.w, 1.0)); } +// The lava/slime **surface scroll**: the reference's animated stage-0 texture matrix, which for +// liquid-type nibbles 6 and 7 is the identity with element 13 — the **v translate** — set to +// `fmod(uptime_s, 10.0) · 0.1` (VERIFIED wow-re `liquid-uv-scroll-law.md`, six-agent §5, matrix +// built at `0x6b68f0` and pushed at stage 0 by `0x6b6ae3`). A texture matrix times `(s, t, 0, 1)` +// with only element 13 non-identity is exactly `t += phase`, so the whole mechanism is this add — +// no matrix, no second sampler, no cost on the paths that do not scroll (`anim.z` is a hard 0 +// there, which the CPU side guarantees rather than the shader branching on it). +// +// A full repeat every 10 s, so `REPEAT` wrapping makes the sawtooth's reset invisible. Only the +// rate and the period are reproducible — the reference's phase comes off `GetTickCount`, i.e. the +// machine's uptime, so its absolute value is not a thing to match. +fn apply_scroll(uv: vec2) -> vec2 { + return vec2(uv.x, uv.y + w.anim.z); +} + // 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 @@ -165,7 +183,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple); // for magma/slime it is the OPAQUE BODY texture. - let detail = textureSample(frames, frames_samp, in.uv, i32(round(w.anim.x))); + let detail = textureSample(frames, frames_samp, apply_scroll(in.uv), i32(round(w.anim.x))); // 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 diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs index f43bfa0a..276b8a6b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs @@ -23,6 +23,7 @@ use crate::model_key; pub fn ribbonscan(chain: &mut Chain) -> Result<()> { let names = super::m2_names(chain, None)?; let (mut scanned, mut hits, mut gated, mut mid_seq) = (0u32, 0u32, 0u32, 0u32); + let mut blends: BTreeMap = BTreeMap::new(); for name in names { let Ok(bytes) = chain.read_file(&name) else { continue; @@ -54,6 +55,18 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { per.sort(); notes.push(format!(" r{i} {}", per.join(" "))); } + // The BLEND census beside the gate one. A ribbon takes its blend from a real M2 material + // (`materialIndices[0]` → render-flags `blend`), so unlike a particle emitter it can in + // principle author any mode — and the two the parse folds (`Opaque`, covering file modes + // 0/1/5/6) render with no alpha test at all, which is exactly what painted the particle + // side's AlphaKey debris as solid squares. Tallied here so the fold stays a MEASURED + // approximation instead of an assumed-empty one. + for d in &defs { + let raw = d.blend_mode.map_or("?".into(), |m| m.to_string()); + *blends + .entry(format!("{:?}({raw})", d.blend)) + .or_insert(0u32) += 1; + } gated += u32::from(any_gate); mid_seq += u32::from(any_mid); let tag = match (any_gate, any_mid) { @@ -66,10 +79,12 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { println!("{line}"); } } + let blend_tally: Vec = blends.iter().map(|(b, n)| format!("{b} {n}")).collect(); eprintln!( "{scanned} models scanned, {hits} with ribbons — {gated} carry an enable gate, \ {mid_seq} of those key it MID-SEQUENCE (a band-start-only read misses their whole \ - ON window)" + ON window). Blends: {}", + blend_tally.join(" · ") ); Ok(()) } @@ -305,6 +320,7 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { match d.blend { benilla_formats::ParticleBlend::Add => hit("blend:add"), benilla_formats::ParticleBlend::Alpha => hit("blend:alpha"), + benilla_formats::ParticleBlend::AlphaKey => hit("blend:alphakey"), benilla_formats::ParticleBlend::Opaque => hit("blend:opaque"), } // The raw blend byte disambiguates what the parsed enum folds: 5 = Mod, 6 = Mod2x. diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index 2e6066b0..6eb18336 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -21,13 +21,29 @@ const MLIQ_CELL_STEP: f32 = f32::from_bits(0x4085_5555); /// ≈ 16.67 yd. We anchor each vertex's UV to its **model-space position** over this period so the /// texture is one continuous field across all of a WMO's MLIQ surfaces — no per-surface seam. /// -/// VERIFIED mechanism (wow-re `rf-mliq-liquid-uv-texgen.md`): the reference's per-vertex `st` DOES -/// reset per surface (raw `(i, j)`) — seamlessness comes from a shared **world-anchored** `GL_TEXTURE` -/// matrix that translates by the liquid batch's common bbox-min origin. Anchoring off a shared origin -/// is the fix; model space is our equivalent realization (identical within a single WMO placement, -/// which Stormwind is). DEFERRED to the exact form (a world-space texture matrix): the matrix's scale -/// — the true yards-per-repeat — is still INFERRED upstream (`[owner+0x94]`, unlocated), so `4·STEP` -/// stands as the size that preserves the prior ripple; fold the verified scale in when it lands. +/// **The mechanism this was built on has since been REFUTED, and the number is now known to be 4× +/// too large** (wow-re `liquid-uv-scroll-law.md`, a six-agent §5 that supersedes +/// `rf-mliq-liquid-uv-texgen.md`). That earlier note claimed the reference reaches seamlessness with +/// a world-anchored `GL_TEXTURE` matrix pushed at transform index 8. It does not: index 8 is the +/// **world/modelview** transform (`0x59ca60`'s first block feeds slots 10 and 8 to `0x5a0090` → +/// `glMatrixMode(GL_MODELVIEW)`; only 0–7 are texture stages), and the water arms push no texture +/// matrix at all — the sole `0x58b210` call in `[0x6b6000,0x6b7000)` is the magma/slime scroll. +/// +/// What the reference actually does is simpler: `0x6b6630` writes `u = (float)i`, `v = (float)j`, +/// the **raw integer tile indices**, from loop counters that both start at a literal 0. One repeat +/// per grid cell means every surface boundary lands on an exact repeat boundary, so a neighbour +/// restarting at 0 is invisible — the seam-free look is the *scale*, not a matrix. Our `¼` is a +/// quarter of that, i.e. our WMO water texture is four times too big, and the model-space anchoring +/// is machinery we do not need. +/// +/// **NOT changed here on purpose.** Correcting it is a 4× visual change to every WMO water surface +/// in the game (Stormwind's canals and fountains, every dungeon pool) and the look is the director's +/// call, not a byte-fidelity autopilot's (`method.md` §9 / the contract §7). Decision 1271 carries the +/// evidence and the proposal; this constant stands until they have looked at the A/B. +/// +/// Magma/slime have a *separate* correction pending in the same record: the reference reads their +/// `st` from the MLIQ vertex's authored `int16` pair × `1/256` (`movsx`, `0x6b6993`), which we do not +/// parse at all and generate over this period instead. const MLIQ_UV_PERIOD: f32 = 4.0 * MLIQ_CELL_STEP; /// The swatch-coord `V` fed to every WMO **water/ocean** vertex. WMO liquid verts carry flow bytes, diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index f8786a53..2035b24e 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -54,15 +54,37 @@ pub enum ParticleShape { } /// How a particle batch blends (file `blendingType` @ +0x28 = the M2/EGxBlend enum). Additive is the -/// flame/glow case (the campfire is `4`); the rest fold to alpha for now (mod/mod2x are rare for the -/// props we render and will get their own path when a model needs them). +/// flame/glow case (the campfire is `4`); mod/mod2x still fold to alpha (rare for the props we +/// render, and they will get their own path when a model needs them). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ParticleBlend { /// `3` NoAlphaAdd / `4` Add — `(SRC_ALPHA, ONE)`. Flames, glows, embers. No depth write/sort. Add, /// `2` Alpha — `(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)`. Smoke. Alpha, - /// `0` Opaque / `1` AlphaKey — rare for particles. + /// `1` AlphaKey — blending **OFF**, depth-write ON, and the fixed-function **alpha test** + /// `GEQUAL 224/255` carving the texture's silhouette out of the quad. The chip/debris family: + /// solid little rocks, bone shards, ice splinters, each authored as a shape on a + /// fully-transparent field. + /// + /// Every step is byte-verified in wow-re (see [`ParticleBlend::Opaque`] for the shared half): + /// the emitter's own blend field decides its list at `0x7085db` (`cmp word[rec+0x28],1; ja` — + /// mode ≤ 1 **and** instance alpha ≥ 0.99999 lands in the `+0x4c` **opaque** list, i.e. pass 0, + /// `part-flush-emitter-depth.md` §1), pass 0 takes promotion-table row 0 + /// `0x811fe0 = {0,1,2,10,3,4,5}` so mode 1 stays **EGxBlend 1** + /// (`m2-blend-promotion-zfill.md` §1), EGxBlend 0/1 → `glDisable(GL_BLEND)` + /// (`0x59d563`, `egxrs-depth-blend-states.md`), and `0x70c256` sets the ref from the RAW mode — + /// `round(instanceAlpha × 224.0)`, `[0x812034] = 224.0f` — never from the pass + /// (`m2-blend-promotion-zfill.md` §2). Depth-write stays ON because the synthetic material only + /// sets its z-write-off bit for `blendMode > 1` (`0x70d8f1`, `part-scene-multipliers.md` §0). + /// + /// Folding this into `Opaque` — no discard at all — paints the transparent field solid: the + /// Lesser Rock Elemental's debris (`PARTROCK.BLP`, 31 % of its texels at α = 0) came out as + /// flat pale squares instead of tumbling chips. + AlphaKey, + /// `0` Opaque — blending OFF, depth-write ON, and **no** alpha test (`0x70c237`'s mode-0 arm + /// sets ref 0, which `0x59d5b9` turns into `glDisable(GL_ALPHA_TEST)`). Genuinely rare for + /// particles; the fallback for an out-of-range blend byte too. Opaque, } @@ -611,6 +633,7 @@ fn blend_of(v: u8) -> ParticleBlend { match v { 3 | 4 => ParticleBlend::Add, 2 | 5 | 6 => ParticleBlend::Alpha, // 5/6 (mod/mod2x) fold to alpha until a model needs them + 1 => ParticleBlend::AlphaKey, _ => ParticleBlend::Opaque, } } diff --git a/crates/benilla-formats/src/ribbons.rs b/crates/benilla-formats/src/ribbons.rs index 9b93d307..947d9d14 100644 --- a/crates/benilla-formats/src/ribbons.rs +++ b/crates/benilla-formats/src/ribbons.rs @@ -109,6 +109,10 @@ pub struct RibbonEmitterDef { /// Blend mode from `materialIndices[0]` → the M2 render-flags entry's blend u16 (the same /// EGxBlend folding as particle/batch blends). pub blend: ParticleBlend, + /// The RAW authored blend u16 behind [`Self::blend`] (`None` when the material index does not + /// resolve). The fold is lossy, so a census cannot say *which* mode a folded ribbon authored + /// without it — and re-walking the material array downstream is how the two readings drift. + pub blend_mode: Option, /// Trail tint (colorTrack, RGB 0..1, keyed on the clip clock; constant white when unkeyed). pub color: ValueTrack<[f32; 3]>, /// Trail opacity (alphaTrack, fixed16/32767, keyed — the slash's fade-out; constant 1.0 when @@ -280,18 +284,29 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result> { .and_then(|ti| textures.get(ti).cloned().flatten()) }; // materialIndices[0] → render-flags entry → blend u16. - let blend = { + let blend_mode = { let n = le_u32(bytes, e + 0x1c); let ofs = le_u32(bytes, e + 0x20) as usize; - let mat = (n > 0 && ofs + 2 <= bytes.len()) + (n > 0 && ofs + 2 <= bytes.len()) .then(|| le_u16(bytes, ofs) as usize) - .filter(|&m| m < rf_count && rf_base + m * 4 + 4 <= bytes.len()); - match mat.map(|m| le_u16(bytes, rf_base + m * 4 + 2)) { - Some(3 | 4) => ParticleBlend::Add, - Some(2) => ParticleBlend::Alpha, - Some(_) => ParticleBlend::Opaque, - None => ParticleBlend::Add, // unresolved material: trails are near-always additive - } + .filter(|&m| m < rf_count && rf_base + m * 4 + 4 <= bytes.len()) + .map(|m| le_u16(bytes, rf_base + m * 4 + 2)) + }; + // The fold, MEASURED against the corpus by `benilla-extract ribbonscan` (176 models, 590 + // ribbons): Add(4) 527 · Alpha(2) 51 · Mod2x(6) 12. Nothing authors 0, 1 or 5, so the + // AlphaKey arm below is the law rather than a live case — but it is the law, and the + // particle side proved what folding it into `Opaque` costs. + // + // KNOWN GAP, deliberately not fixed here: those 12 Mod2x ribbons fall into `Opaque` and + // render with no blend at all, while a Mod2x PARTICLE folds the other way (to `Alpha`). + // `ParticleBlend` cannot express Mod/Mod2x, so straightening it means widening that enum + // and re-verifying both lanes — its own change, not a rider on the AlphaKey fix. + let blend = match blend_mode { + Some(3 | 4) => ParticleBlend::Add, + Some(2) => ParticleBlend::Alpha, + Some(1) => ParticleBlend::AlphaKey, + Some(_) => ParticleBlend::Opaque, + None => ParticleBlend::Add, // unresolved material: trails are near-always additive }; out.push(RibbonEmitterDef { bone: le_u16(bytes, e + 0x04), @@ -302,6 +317,7 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result> { ], texture, blend, + blend_mode, color: track_keys_with(bytes, e + 0x24, [1.0; 3], band, 12, |b, o| { [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)] }), diff --git a/crates/benilla-formats/tests/particles.rs b/crates/benilla-formats/tests/particles.rs index 9f9851dc..7b407590 100644 --- a/crates/benilla-formats/tests/particles.rs +++ b/crates/benilla-formats/tests/particles.rs @@ -457,3 +457,40 @@ fn barrel_explode_emitters_are_off_at_rest_and_fire_in_their_clips() { .any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01, 0.0))); assert!(destroy_fires, "the Destroy clip plays the explosion"); } + +/// The debris family's blend: file `blendingType` **1 is AlphaKey**, not Opaque — the fold that +/// painted the Lesser Rock Elemental's rock chips as flat pale squares. +/// +/// Pinned on the elemental because it is the one CREATURE in the corpus that authors mode 1 (a +/// `partcensus` sweep finds 26 such emitters in 8 models, the other 7 all `Spells\`). The +/// distinction is load-bearing: mode 1 draws with blending OFF but under `glAlphaFunc(GEQUAL, +/// 224/255)`, which carves `PARTROCK.BLP`'s silhouette out of the quad; mode 0 has no alpha test +/// at all, and nothing in 1.12.1 authors it. +#[test] +fn earth_elemental_debris_is_alphakey_not_opaque() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("Creature\\ElementalEarth\\ElementalEarth.m2") + .expect("read ElementalEarth.m2"); + + let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters"); + assert_eq!(emitters.len(), 13, "the elemental authors 13 emitters"); + + // Emitters 3 and 12 are the rock chips; 11 is the third PARTROCK emitter, authored mode 2. + let rock = |e: &benilla_formats::ParticleEmitterDef| { + e.texture + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("CREATURE\\ELEMENTALEARTH\\PARTROCK.BLP")) + }; + assert!(rock(&emitters[3]) && rock(&emitters[11]) && rock(&emitters[12])); + assert_eq!(emitters[3].blend, ParticleBlend::AlphaKey); + assert_eq!(emitters[12].blend, ParticleBlend::AlphaKey); + assert_eq!(emitters[11].blend, ParticleBlend::Alpha); + + // Nothing on this model — nor anywhere in the shipped corpus — is mode 0. + assert!( + !emitters.iter().any(|e| e.blend == ParticleBlend::Opaque), + "no emitter here folds to Opaque" + ); +} diff --git a/crates/benilla-world/src/exterior_cull.rs b/crates/benilla-world/src/exterior_cull.rs index 08e27562..187db592 100644 --- a/crates/benilla-world/src/exterior_cull.rs +++ b/crates/benilla-world/src/exterior_cull.rs @@ -24,10 +24,30 @@ //! liquid layers (`0x683ab0`) and the far band (`0x683040`) — and each drain unlinks what it walks. So //! "no window" really does mean "no exterior content", with no second path to leak through. //! -//! **Units are exempt and must stay exempt.** A CGUnit rides ClntObjMgr → `0x481540` → `0x607da0` → -//! `0x710b90` into the M2Scene worklist, drained unconditionally at `0x483460`+`0x48368a`; that -//! function contains zero references to `[0xc7b748]`/`[0xcbe320]`. The reference submits outdoor mobs -//! from a sealed room and lets the building's own geometry z-reject them. +//! ## Bodies ride the same walk — the correction 0774 needed (decision 1270) +//! +//! 0774 read this lane one stage too late and wrote the opposite: *"units are exempt and must stay +//! exempt … the reference submits outdoor mobs from a sealed room and lets the building's own +//! geometry z-reject them."* The **drain** fact it checked is true — `0x483460`/`0x48368a` walk the +//! M2Scene worklist with zero references to `[0xc7b748]`/`[0xcbe320]` — but the decision is taken +//! *before* the worklist, in the driver that does read them. +//! +//! Every frame `0x683dd0` (sole caller `0x6812c5`, inside `0x681070`) elects each scene object into +//! one of two passes: `[record+0xb4]` is `0x481540` and nothing else (that dword occurs once +//! image-wide, at `0x613ece`), and `CGObject::ShouldRender` is literally `0x6146c6: and eax,1`. +//! Pass 2 takes `0x48161f je` → `0x48174e call 0x710c50`, which writes `[model+0x50] = 0`: the model +//! never enters `[scene+0x24]`, the only list the draw loop `0x707680`/`0x707882` walks — and walk 2 +//! also skips `0x710b90`, so a pass-2 object is neither drawn nor ticked. +//! +//! **List A's only outdoor producer is `0x683340`, whose only caller is `0x682fe2` — inside +//! `0x682fa0`**, the per-window exterior walk above. A sealed room never runs it, so `0x680390` +//! sweeps every outdoor object in the world into pass 2. Nothing is submitted, so there is nothing +//! for the building's geometry to z-reject: 0774's second claim fails in both halves. An object +//! standing in a WMO group rides the sibling `0x6834e0` instead, which is why a creature in the +//! camera's own room still draws. +//! +//! So a body is exterior scene exactly when it is not in a WMO room, and the windows gate it like +//! the ground it stands on — [`WorldUnit::bound`]. //! //! ## The clip geometry //! @@ -49,9 +69,10 @@ //! //! ## What is gated so far, and what is knowingly not //! -//! **Gated:** ADT terrain (`0x683bf0`), ADT doodad placements (`0x683700`) and the WDL far band -//! (`0x683040`) — the buckets that produce the reported symptom, and the ones whose entities have no -//! other `Visibility` writer, so this is their sole authority (decision 0025). +//! **Gated:** ADT terrain (`0x683bf0`), ADT doodad placements (`0x683700`), the WDL far band +//! (`0x683040`) and now the net **bodies** on `0x683340` ([`WorldUnit::bound`]) — the buckets that produce +//! the reported symptoms, and the ones whose entities have no other `Visibility` writer, so this is +//! their sole authority (decision 0025). //! //! **The unit is the drawn object, and for terrain that is the 33.333 yd MCNK cell, not the 533 yd //! tile** (decision 0780). This is a property of the *spawner*, not of anything here — but the cull is @@ -70,6 +91,7 @@ //! no root entity in our graph, so we tag and test each **submesh**. Their union is the object, so the //! only divergence is at a doorway's edge — a submesh entirely outside the window is dropped where the //! reference would draw the doodad whole. Visible only as a partially-clipped prop in a doorway. +//! A body does not take that deviation: it has a root, so it is tested once, whole ([`ElectedBody`]). use bevy::camera::primitives::{Aabb, Frustum}; use bevy::camera::visibility::VisibilitySystems; @@ -87,9 +109,11 @@ const MIN_WINDOW_NDC: f32 = 0.02; /// liquid. Tag the object that is *drawn*: this is tested per entity, so tagging a container whose box /// spans far more than any one draw admits the lot (decision 0780). /// -/// **Never on units, players, or anything parented to a WMO group**: the former are exempt by the -/// carved law above, and the latter are already culled by the portal PVS -/// ([`crate::wmo_portal::WmoGroupVis`]) — double-gating them would blank building interiors. +/// **Never on anything parented to a WMO group** — those are already culled by the portal PVS +/// ([`crate::wmo_portal::WmoGroupVis`]), and double-gating them would blank building interiors. +/// **Never on a net body either**, but for the opposite reason to the one this line used to give: +/// a body IS elected (see the module header), it is simply elected once at its **root** off +/// [`WorldUnit::bound`], rather than per submesh. #[derive(Component)] pub struct ExteriorScene; @@ -115,6 +139,13 @@ pub struct ExteriorCullVerdict { /// Non-zero is not automatically a defect (a tagged non-drawing entity has no bound and draws /// nothing), but it is the one number that says how much of the scene the cull is not deciding. pub(crate) unbounded: usize, + /// Net bodies the election reached ([`WorldUnit::bound`] set), and how many the windows + /// rejected — counted apart from + /// `tested`/`hidden` because they answer a different question. The world buckets are a + /// *residency* count in the tens of thousands; bodies are the server's visibility stream, a + /// couple of dozen. Summed together, a body leg that reaches nothing at all would be invisible. + pub(crate) bodies: usize, + pub(crate) bodies_hidden: usize, } pub(crate) struct ExteriorCullPlugin; @@ -244,18 +275,46 @@ type UnownedSceneFilter = ( With, Without, Without, + // …and disjoint from the body leg below, which also writes `Visibility`. The two audiences + // never overlap by construction (a body is elected at its root, which is not scene content), + // but Bevy needs that **proved**, not intended: without this the system panics B0001 at the + // first run. Stating it here rather than in the body query keeps the exclusion next to the + // `ModelPart`/`WmoGroupVis` ones, which are the same kind of claim. + Without, +); + +/// What the body leg reads per net body — one whole-object test on its root, which is the +/// reference's own granularity (`0x682f40`), and which takes the whole visual with it: body +/// geosets, worn gear under their joints, the mount and its rider (`mount::spawn_mount_child` +/// parents the mount to its host), and the entity-lane billboard cards, which are world roots that +/// mirror their owner's `InheritedVisibility` ([`crate::billboard::face_billboards`]). None of that +/// composes from a per-submesh tag. +/// +/// [`crate::wmo_portal::UnitWmoRoom`] is the reference's own fork between its two producers: a room +/// claim means the object rides `0x6834e0` with its building, no claim means it rides `0x683340` +/// inside the window walk. `Option` because a body that spawned this frame may not have been rayed +/// yet, and an unknown room is a **timing gap, not a verdict** — the same law the missing-`Aabb` +/// arm above takes, and for the same reason: deciding on absent information flickers the world. +type ElectedBody = ( + &'static GlobalTransform, + &'static crate::world_unit::WorldUnit, + Option<&'static crate::wmo_portal::UnitWmoRoom>, + &'static mut Visibility, ); /// Hide every [`ExteriorScene`] object that no window admits — **only those with no other /// `Visibility` owner**. A model submesh (`ModelPart`) and a WMO group piece (`WmoGroupVis`) are /// written by [`crate::debug_panel`]'s model-visibility authority, which composes the toggles, the /// far-clip wall, the distance fade and the portal PVS; a second writer here does not "also cull" -/// them, it *overwrites* all of that every frame (decision 0784). So this system now owns exactly -/// what it is the sole authority for: terrain cells and the WDL far band. +/// them, it *overwrites* all of that every frame (decision 0784). So this system owns exactly what +/// it is the sole authority for: terrain cells, the WDL far band, and — in the second query below, +/// a different audience under the same law rather than a second system — the elected net bodies +/// ([`ElectedBody`], decision 1270), whose roots nothing else writes. fn apply_exterior_cull( windows: Res, cam: Query<(&GlobalTransform, &Projection), With>, mut scene: Query, + mut bodies: Query, mut verdict: ResMut, ) { let set = |vis: &mut Visibility, target: Visibility| { @@ -279,6 +338,28 @@ fn apply_exterior_cull( }, ); } + // The body leg: one whole-object test per net body (see [`ElectedBody`]), serial because the + // audience is the server's visibility stream — dozens, not the tens of thousands above. + let (mut body_n, mut body_hidden) = (0usize, 0usize); + for (gt, body, room, mut vis) in &mut bodies { + let Some(bound) = body.bound.as_ref() else { + continue; // the game says this body is not ours to decide — `WorldUnit::bound` + }; + body_n += 1; + // In a WMO room ⇒ its building's own walk submits it, not the exterior one. No claim yet ⇒ + // not decided. Either way the gate stands down and the body keeps drawing. + let exempt = room.is_none_or(|r| r.room().is_some()); + let admitted = exempt || gate.admits(gt, Some(bound)); + body_hidden += usize::from(!admitted); + set( + &mut vis, + if admitted { + Visibility::Inherited + } else { + Visibility::Hidden + }, + ); + } *verdict = ExteriorCullVerdict { windows: match &*windows { ExteriorWindows::Unrestricted => None, @@ -288,6 +369,8 @@ fn apply_exterior_cull( tested, hidden, unbounded, + bodies: body_n, + bodies_hidden: body_hidden, }; } @@ -386,4 +469,138 @@ mod tests { window_frustum([0.0, -1.0, MIN_WINDOW_NDC + 0.001, 1.0], &clip_from_world()).is_some() ); } + + /// One `apply_exterior_cull` run over a scene built by the caller, returning each body's + /// verdict in spawn order plus the counters. + fn run_bodies( + windows: ExteriorWindows, + bodies: &[(Vec3, Option)], + ) -> (Vec, ExteriorCullVerdict) { + let mut app = App::new(); + app.init_resource::() + .insert_resource(windows) + .add_systems(Update, apply_exterior_cull); + // The same camera the pure-frustum tests use: origin, down −Z, 90° fov. + app.world_mut().spawn(( + WorldCamera, + GlobalTransform::IDENTITY, + Projection::Perspective(PerspectiveProjection { + fov: std::f32::consts::FRAC_PI_2, + aspect_ratio: 1.0, + near: 0.1, + far: 1000.0, + ..default() + }), + )); + let ids: Vec = bodies + .iter() + .map(|(at, room)| { + // A yard-ish box on the body's own origin — a creature's authored idle CAaBox. + let mut e = app.world_mut().spawn(( + GlobalTransform::from_translation(*at), + crate::world_unit::WorldUnit { + wades: true, + scale: 1.0, + height: 2.0, + bound: Some(Aabb::from_min_max(Vec3::splat(-0.5), Vec3::splat(0.5))), + }, + Visibility::Inherited, + )); + if let Some(room) = *room { + e.insert(room); + } + e.id() + }) + .collect(); + app.update(); + let vis = ids + .iter() + .map(|e| *app.world().entity(*e).get::().unwrap()) + .collect(); + (vis, *app.world().resource::()) + } + + /// The three states a body's room claim can be in — kept distinct because the middle and the + /// last decide opposite ways and are one `Option` layer apart. + fn outdoors() -> Option { + Some(crate::wmo_portal::UnitWmoRoom::default()) // rayed, hit nothing + } + fn in_a_room() -> Option { + // The room's *identity* is nothing to this system — only whether there is one. + Some(crate::wmo_portal::UnitWmoRoom::claimed( + crate::wmo_portal::WmoRoom { + instance: Entity::from_raw_u32(1).expect("a valid entity id"), + group: 0, + }, + )) + } + const NOT_RAYED_YET: Option = None; + + /// **The Caverns of Time report, as a test** (decision 1270). Sealed room ⇒ no windows ⇒ the + /// exterior walk never runs, so an outdoor body is not submitted at all — while a body standing + /// in a WMO room rides the sibling producer `0x6834e0` and still draws. Both bodies sit at the + /// same spot dead ahead of the camera: the *only* thing separating them is the room claim, + /// which is the reference's own fork between its two producers. + #[test] + fn a_sealed_room_hides_the_outdoor_body_and_keeps_the_one_in_a_room() { + let ahead = Vec3::new(0.0, 0.0, -50.0); + let (vis, verdict) = run_bodies( + ExteriorWindows::Windows(Vec::new()), + &[(ahead, outdoors()), (ahead, in_a_room())], + ); + assert_eq!( + vis, + vec![Visibility::Hidden, Visibility::Inherited], + "outdoors is elected to pass 2; a body in a room is submitted with its building" + ); + assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 1)); + } + + /// A body whose room has not been rayed yet is **not decided** — an unknown room is a timing + /// gap, not a verdict, the same law the missing-`Aabb` arm takes. Asserted in the sealed room, + /// which is the only state where the two answers differ. + #[test] + fn an_unrayed_body_is_not_decided() { + let (vis, _) = run_bodies( + ExteriorWindows::Windows(Vec::new()), + &[(Vec3::new(0.0, 0.0, -50.0), NOT_RAYED_YET)], + ); + assert_eq!(vis, vec![Visibility::Inherited]); + } + + /// Outdoors the driver takes its `{0,0,1,1}` leg and every body is submitted as before — the + /// arm that must never narrow, since it is the state the client spends most of its life in. + #[test] + fn outdoors_every_body_is_submitted() { + let (vis, verdict) = run_bodies( + ExteriorWindows::Unrestricted, + &[ + (Vec3::new(0.0, 0.0, -50.0), outdoors()), + // Behind the camera, and still submitted: `Unrestricted` is a stand-down, not a + // frustum test. Bevy's own cull is what drops this one, one system later. + (Vec3::new(0.0, 0.0, 50.0), outdoors()), + ], + ); + assert_eq!(vis, vec![Visibility::Inherited; 2]); + assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 0)); + } + + /// A doorway on the right admits the body on the right and rejects the one on the left — the + /// same law as the terrain test above, now on a whole object rather than a chunk of ground. + #[test] + fn a_doorway_admits_only_its_own_side() { + let (vis, verdict) = run_bodies( + ExteriorWindows::Windows(vec![[0.1, -1.0, 1.0, 1.0]]), + &[ + (Vec3::new(40.0, 0.0, -50.0), outdoors()), + (Vec3::new(-40.0, 0.0, -50.0), outdoors()), + ], + ); + assert_eq!( + vis, + vec![Visibility::Inherited, Visibility::Hidden], + "the window is on the right" + ); + assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 1)); + } } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 78782fa1..de694733 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -45,7 +45,11 @@ pub(crate) struct LiquidAssets { /// scene block. (VERIFIED wow-re `fog-env-state` §5's complete 6-site submit census + /// `liquid-render-state-sided` §5; decision 0691's open lane, now closed.) /// -/// The two variants share one decoded frame array — only the tiny uniform differs — so the extra +/// The third lane is the **lava/slime UV scroll** — see [`scrolls`]. It is a per-*nibble*, per-*path* +/// property that [`LiquidKind`] deliberately collapses (2 and 6 are both `Magma`), so it cannot be +/// derived from the kind and has to key the material. +/// +/// The variants share one decoded frame array — only the tiny uniform differs — so the extra /// materials cost a handful of bytes, not a second copy of 30 × 256² textures. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] struct LiquidKey { @@ -53,22 +57,65 @@ struct LiquidKey { /// This surface belongs to a WMO **interior** group (`MOGI & 0x48 == 0` — the same test /// `wow_model.wgsl` fogs its interior lanes by, which is what keeps the two in step). interior: bool, + /// This surface takes the animated stage-0 texture matrix ([`scrolls`]). + scroll: bool, +} + +/// **Texture repeats per second** the reference slides a scrolling liquid sheet along **v** — +/// `[0x801620]` = 0.1 (VERIFIED wow-re `liquid-uv-scroll-law.md`, six-agent §5). +const SCROLL_REPEATS_PER_SEC: f32 = 0.1; + +/// The scroll's wrap period in seconds — the `fmod` divisor `[0x80e5a0]` = 10.0. With +/// [`SCROLL_REPEATS_PER_SEC`] that makes the offset a **sawtooth over exactly one repeat**, so the +/// wrap is invisible on a `REPEAT`-sampled texture and the phase never grows without bound. +const SCROLL_PERIOD_SECS: f32 = 10.0; + +/// Does this surface take the reference's animated stage-0 texture matrix — the lava/slime scroll? +/// +/// **Only WMO MLIQ surfaces whose type nibble is 6 or 7** (VERIFIED wow-re `liquid-uv-scroll-law.md`: +/// the WMO magma/slime kernel `0x6b68f0` gates on `and esi,0xc; cmp esi,4` over the `0x6ba970` +/// nibble, and builds a matrix that is identity except element 13 = the v-translate). Three +/// consequences that are easy to get wrong, all of them load-bearing: +/// +/// * **Nibbles 2 and 3 do NOT scroll**, though they reach the same kernel and draw the same +/// `lava.blp`/`slime.blp`. In the shipped data that is the difference between Blackrock's lava +/// (nibble 6, scrolls) and the **Ironforge Great Forge's** (nibble 2, static) — the same texture, +/// two behaviours, and the reason "lava scrolls" is not a statement you can make about lava. +/// * **ADT liquid never scrolls, magma included** — the open-world Searing Gorge / Burning Steppes +/// lava is nibble 6 too, but its queue is a *different* kernel (`0x68dca0`) which pushes a +/// byte-exact identity with element 13 hard-zeroed at `0x68dda1`. So the gate is the nibble AND +/// the path; a nibble-only test would slide every lava pool in the open world. +/// * The phase is driven off the reference's uptime clock, so only its **rate and period** are +/// reproducible — never its absolute value. We drive ours off the same wall clock the frame +/// cycler uses. +fn scrolls(nibble: u8) -> bool { + matches!(nibble, 6 | 7) } struct LiquidEntry { material: Handle, frame_count: u32, + /// Mirrors [`LiquidKey::scroll`], so the cycler can drive the v-offset without re-deriving the + /// key it is already iterating the values of. + scroll: bool, } impl LiquidAssets { - /// The shared material for a liquid kind on a given fog block, if its frames loaded. + /// The shared material for a liquid kind on a given fog block, scrolling or not, if its frames + /// loaded. `scroll` is [`scrolls`]'s verdict; a `true` for a kind that has no scrolling variant + /// simply misses, which is the same "no material" path a failed frame load takes. pub(crate) fn material( &self, kind: LiquidKind, interior: bool, + scroll: bool, ) -> Option> { self.materials - .get(&LiquidKey { kind, interior }) + .get(&LiquidKey { + kind, + interior, + scroll, + }) .map(|e| e.material.clone()) } } @@ -131,7 +178,13 @@ pub(crate) fn spawn_liquids<'a>( for lq in liquids { // ADT liquid always takes the SCENE fog: the ADT liquid passes submit no fog block of their // own, so they draw under the once-a-frame scene submit (wow-re `fog-env-state` §5). - let Some(material) = liquid.material(lq.kind, false) else { + // + // And it NEVER scrolls — `scroll: false` here is not a default, it is the finding. Shipped + // ADT magma is nibble 6 throughout, the very nibble that scrolls on the WMO path, but the + // ADT queue is a different kernel that hard-zeroes the v-translate ([`scrolls`]). Deriving + // the flag from `lq.sound_nibble` here — the obvious-looking thing — would slide every + // Searing Gorge and Burning Steppes lava pool in the game. + let Some(material) = liquid.material(lq.kind, false, false) else { continue; // this kind's frames failed to load (warned at setup) }; // The world-space liquid grid (MCLQ positions are already absolute WoW, so the IDENTITY @@ -230,9 +283,21 @@ pub(crate) fn spawn_wmo_liquids<'a>( // `interior` picks the fog block, not the look: an interior group's pool is drawn by the WMO // liquid pass, which re-submits the smoothed interior fog under the same `[0xca7f00]` gate as // the WMO geometry pass — so the pool fogs exactly like the walls around it (see [`LiquidKey`]). - let Some(material) = liquid.material(lq.kind, interior) else { + // This is the ONE path that can scroll, and the nibble — not the kind — is what decides it: + // Blackrock's lava (6) creeps, the Ironforge Great Forge's (2) does not, and both are + // `LiquidKind::Magma` drawing the same sheet ([`scrolls`]). + let scroll = scrolls(lq.sound_nibble); + let Some(material) = liquid.material(lq.kind, interior, scroll) else { continue; // this kind's frames failed to load (warned at setup) }; + if scroll { + // Rare (only nibble 6/7 pools reach it) and the only outside view of a decision that is + // otherwise invisible until you stand over the pool and watch it for ten seconds. + debug!( + "liquid: {:?} nibble {} takes the scroll lane", + lq.kind, lq.sound_nibble + ); + } let surface = commands .spawn(( Mesh3d(meshes.add(liquid_bevy_mesh(lq))), @@ -275,9 +340,12 @@ const FRAME_SETS: &[(LiquidKind, &str, &str, u32)] = &[ (LiquidKind::Still, "river", "lake_a", 30), (LiquidKind::Rapids, "river", "fast_a", 16), (LiquidKind::Ocean, "ocean", "ocean_h", 30), - // WMO-liquid-only kinds (magma/slime carry no MCLQ data). Opaque + unlit + fogged: the animated - // texture IS the body colour, there being no vertex colour or depth LUT to modulate it by - // (VERIFIED wow-re `liquid-render-state-sided` §5). + // The fullbright kinds: opaque + unlit + fogged, the animated texture IS the body colour, there + // being no vertex colour or depth LUT to modulate it by (VERIFIED wow-re + // `liquid-render-state-sided` §5). **Magma reaches here from BOTH liquid systems** — the WMO + // MLIQ pools *and* the ADT MCLQ magma queue (B21: the 128 open-world lava chunks, Burning + // Steppes/Searing Gorge/Un'Goro); only slime is WMO-only, the reference having no ADT queue for + // it at all. See [`benilla_formats::LiquidKind`]. (LiquidKind::Magma, "lava", "lava", 30), (LiquidKind::Slime, "slime", "slime", 30), ]; @@ -324,11 +392,24 @@ pub(super) fn setup_liquid( } else { AlphaMode::Blend }; - // Two materials per kind — one per FOG BLOCK (see [`LiquidKey`]) — sharing the one decoded - // frame array (`frames` is a handle; the clone is a refcount, not a second 30 × 256² decode). - // Every other input is identical, so this is the whole cost of letting an interior room's pool - // fog like the room instead of like the sky outside it. - for interior in [false, true] { + // One material per (FOG BLOCK × SCROLL) combination the kind can actually take (see + // [`LiquidKey`]), all sharing the one decoded frame array (`frames` is a handle; the clone is + // a refcount, not a second 30 × 256² decode). Every other input is identical, so this is the + // whole cost of letting an interior room's pool fog like the room instead of like the sky + // outside it — and of letting Blackrock's lava creep while Ironforge's sits still. + // + // The scroll variant exists only for the fullbright kinds: [`scrolls`] can only ever answer + // true for nibbles 6/7, which map to `Magma`/`Slime` and nothing else, so building one for + // water would be a material nothing can ever look up. + let scroll_lanes: &[bool] = if kind.is_fullbright() { + &[false, true] + } else { + &[false] + }; + for (interior, &scroll) in [false, true] + .into_iter() + .flat_map(|i| scroll_lanes.iter().map(move |s| (i, s))) + { let material = materials.add(ExtendedMaterial { base: StandardMaterial { // We do our own (WoW) lighting in the shader. @@ -359,24 +440,34 @@ pub(super) fn setup_liquid( if interior { 1.0 } else { 0.0 }, WATER_SHININESS, ), - // x = frame 0 (index driven by `animate_liquid`); y = frame count; zw unused. + // x = frame 0 (index driven by `animate_liquid`); y = frame count; z = the + // v-scroll offset in texture repeats (also `animate_liquid`'s, and left at 0 + // forever on a non-scrolling material); w unused. anim: Vec4::new(0.0, frame_count as f32, 0.0, 0.0), light_buf: world_assets.shared_light.clone(), }, }); assets.materials.insert( - LiquidKey { kind, interior }, + LiquidKey { + kind, + interior, + scroll, + }, LiquidEntry { material, frame_count, + scroll, }, ); } } - // Frame SETS, not materials — each set backs both fog-block variants. + // Frame SETS, not materials — a set backs every fog-block/scroll variant of its kind. info!( "liquid: loaded {} water frame set(s)", - assets.materials.len() / 2 + FRAME_SETS + .iter() + .filter(|(k, ..)| assets.material(*k, false, false).is_some()) + .count() ); commands.insert_resource(assets); } @@ -419,10 +510,17 @@ fn load_frame_array( } /// Advance every liquid material's frame index at [`ANIM_FPS`] off Bevy **real** `Time` (wall-clock, -/// mirroring the reference's `GetTickCount`-driven cycler — NOT the day/night game clock). Writes -/// only on the [`ANIM_FPS`] tick edge: `Assets::get_mut` alone marks the asset Modified and feeds -/// the respecialization pipeline (the mark-changed scan + `Changed` sweeps) every frame — -/// the 0353 demand-price law; between ticks the frame index cannot have changed. +/// mirroring the reference's `GetTickCount`-driven cycler — NOT the day/night game clock), and the +/// **v-scroll offset** on the materials that take one ([`scrolls`]). Writes only on the +/// [`ANIM_FPS`] tick edge: `Assets::get_mut` alone marks the asset Modified and feeds the +/// respecialization pipeline (the mark-changed scan + `Changed` sweeps) every frame — the +/// 0353 demand-price law; between ticks the frame index cannot have changed. +/// +/// The scroll rides the same 24 Hz edge rather than getting its own per-frame write. It is a +/// continuous quantity, so that quantises it — but to 1/24 s, i.e. **1/240th of a repeat** per step +/// on a 10 s sawtooth, which is far below what a 256² sheet can show, and it keeps the material +/// upload budget exactly where 0353 put it. (The reference is quantised too, just differently: it +/// rebuilds the matrix per draw off a millisecond clock.) pub(super) fn animate_liquid( time: Res