lava creeps again, and sealed rooms empty of outdoor bodies

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:23:04 +07:00
co-authored by Claude Fable 5
parent d7f6a90bca
commit 404bbb0c6a
27 changed files with 1065 additions and 116 deletions
@@ -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(),
};
@@ -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])),
+1 -1
View File
@@ -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).
+205 -33
View File
@@ -54,31 +54,42 @@ pub(crate) fn footfall_side(ident: &[u8; 4]) -> Option<u8> {
.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<TrackSeek>;
/// 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<bevy::ecs::entity::EntityHashMap<(AnimationNodeIndex, f32)>>,
// 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<TrackMemory>,
// 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<bevy::ecs::entity::EntityHashMap<(AnimationNodeIndex, f32)>>,
mut last_overlay: Local<TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
anim_data: Option<Res<AnimData>>,
) {
@@ -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<f32> {
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::<bevy::ecs::message::Messages<AnimSoundEvent>>();
world.insert_resource(Window(clip.clone(), prev, cur));
world
.run_system_once(
|win: bevy::prelude::Res<Window>, 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::<bevy::ecs::message::Messages<AnimSoundEvent>>();
msgs.drain().map(|m| m.ident).collect()
}
}
+35 -8
View File
@@ -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<crate::transport::TransportAnchor>,
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<WireBody>,
viewers: Query<(
Entity,
Has<crate::net::SelfPlayer>,
Has<benilla_world::world_unit::ViewerUnit>,
)>,
) {
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);
@@ -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.
+4 -5
View File
@@ -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<GoAnim>>,
mut last: Local<
bevy::ecs::entity::EntityHashMap<(bevy::animation::graph::AnimationNodeIndex, f32)>,
>,
mut last: Local<crate::creature_anim::TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
) {
for (entity, anims, player) in &gos {
+2 -1
View File
@@ -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,
] {
+62
View File
@@ -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<N>`) 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)]);
+4 -1
View File
@@ -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))
}
@@ -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() {
+4
View File
@@ -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);
}
}
+20 -2
View File
@@ -49,7 +49,10 @@
struct LiquidParams {
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
kind: vec4<f32>,
anim: vec4<f32>, // x = current frame index; y = frame count; zw unused
// 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<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> w: LiquidParams;
@@ -100,6 +103,21 @@ fn sun_sheen(world_normal: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
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<f32>) -> vec2<f32> {
return vec2<f32>(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<f32> {
// 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
@@ -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<String, u32> = 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<String> = 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.
+23 -7
View File
@@ -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,
+26 -3
View File
@@ -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,
}
}
+25 -9
View File
@@ -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<u16>,
/// 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<Vec<RibbonEmitterDef>> {
.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<Vec<RibbonEmitterDef>> {
],
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)]
}),
+37
View File
@@ -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"
);
}
+229 -12
View File
@@ -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<ExteriorScene>,
Without<crate::model_render::ModelPart>,
Without<crate::wmo_portal::WmoGroupVis>,
// …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<crate::world_unit::WorldUnit>,
);
/// 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<ExteriorWindows>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
mut scene: Query<UnownedScene, UnownedSceneFilter>,
mut bodies: Query<ElectedBody>,
mut verdict: ResMut<ExteriorCullVerdict>,
) {
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<crate::wmo_portal::UnitWmoRoom>)],
) -> (Vec<Visibility>, ExteriorCullVerdict) {
let mut app = App::new();
app.init_resource::<ExteriorCullVerdict>()
.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<Entity> = 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::<Visibility>().unwrap())
.collect();
(vis, *app.world().resource::<ExteriorCullVerdict>())
}
/// 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<crate::wmo_portal::UnitWmoRoom> {
Some(crate::wmo_portal::UnitWmoRoom::default()) // rayed, hit nothing
}
fn in_a_room() -> Option<crate::wmo_portal::UnitWmoRoom> {
// 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<crate::wmo_portal::UnitWmoRoom> = 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));
}
}
+275 -23
View File
@@ -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<LiquidMaterial>,
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<Handle<LiquidMaterial>> {
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<Mesh3d>` 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<Mesh3d>` 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<Time>,
liquid: Option<Res<LiquidAssets>>,
@@ -453,25 +551,179 @@ pub(super) fn animate_liquid(
return;
}
*last_ticks = Some(ticks);
// The scroll's sawtooth, off the SAME quantised clock as the frame index so the two can never
// disagree about what time it is: `fmod(t, 10) · 0.1` ∈ [0, 1) repeats. Pinned to 0 in a
// deterministic run for exactly the reason the frame index is (0600).
let scroll = if crate::dev_state::deterministic_run() {
0.0
} else {
(ticks as f32 / ANIM_FPS) % SCROLL_PERIOD_SECS * SCROLL_REPEATS_PER_SEC
};
for entry in liquid.materials.values() {
// Gated per entry: a single-frame liquid (`frame_count` 1) never changes index, and the
// shared cache holds every kind ever built — re-marking them all Modified on each tick
// edge re-uploaded ~10 materials/frame on maps with no water at all.
let frame = (ticks % entry.frame_count.max(1)) as f32;
let offset = if entry.scroll { scroll } else { 0.0 };
benilla_assets::write_gated(
&mut materials,
&entry.material,
|m| m.extension.anim.x != frame,
|m| m.extension.anim.x = frame,
|m| m.extension.anim.x != frame || m.extension.anim.z != offset,
|m| {
m.extension.anim.x = frame;
m.extension.anim.z = offset;
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The scroll gate, nibble by nibble — every trap in [`scrolls`] as an assertion.
///
/// The failure this guards is not "lava doesn't move"; it is lava moving in the **wrong
/// places**, which is far harder to see and impossible to unsee once shipped.
#[test]
fn only_nibbles_six_and_seven_scroll() {
// The two that do: Blackrock's lava and Stratholme's slime.
assert!(scrolls(6), "magma variant 6 takes the animated matrix");
assert!(scrolls(7), "slime variant 7 takes the animated matrix");
// The two that reach the SAME kernel and draw the SAME sheet, and still do not scroll —
// the Ironforge Great Forge (2) and the Stratholme raid / Ahn'Qiraj slime (3).
assert!(!scrolls(2), "the Great Forge's lava is static");
assert!(!scrolls(3), "nibble-3 slime is static");
// The near-miss that a `nibble & 0xc == 4` test would wrongly admit: 4 satisfies it, but it
// is a *river* variant that never reaches the magma/slime kernel at all.
assert!(!scrolls(4), "nibble 4 is lake_a, not a hazard liquid");
// Everything else in the table, including the hole nibble.
for n in [0u8, 1, 5, 8, 0xf] {
assert!(!scrolls(n), "nibble {n} must not scroll");
}
}
/// A scrolling material exists for the fullbright kinds and **only** for them — a scroll lane
/// built for water would be a material no lookup can ever reach ([`setup_liquid`]).
#[test]
fn only_fullbright_kinds_can_take_a_scroll_lane() {
for &(kind, ..) in FRAME_SETS {
let wants_lane = kind.is_fullbright();
assert_eq!(
wants_lane,
[2u8, 3, 6, 7]
.iter()
.any(|&n| scrolls(n) && LiquidKind::from_nibble(n) == Some(kind)),
"{kind:?}: scroll lane and the nibbles that can ask for one must agree"
);
}
}
}
/// Which fog block a WMO pool takes, against the **real client data**. Skips when the 1.12.1 client
/// isn't present (the repo never carries Blizzard data).
#[cfg(test)]
mod real_data {
use super::LiquidKind;
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh};
use std::collections::HashMap;
/// **Which of the shipped hazard pools actually scroll** — [`super::scrolls`] against the real
/// data, so the gate is pinned to content rather than to a reading of the binary.
///
/// The whole reason this needs a test: "does lava scroll?" has no answer, only a per-pool one.
/// The nibble is authored per WMO group, `LiquidKind` collapses it (2 and 6 are both `Magma`,
/// drawing the same `lava.blp`), and the two behaviours sit **inside one building** — so a gate
/// that quietly regressed to "all magma" or "no magma" would look plausible everywhere and be
/// wrong nearly everywhere. These counts are the shipped 1.12.1 numbers, measured:
///
/// * **Ironforge is the two-sided case, and it is why the director's report needed a location.**
/// Eleven magma groups: **9 at nibble 2 (static)** and **2 at nibble 6 (scrolling)**, plus 2
/// still-water groups. Lava that moves and lava that does not, in the same forge.
/// * **Blackrock Mountain — the pin behind `blackrock_lava_is_below_the_feet_not_above_it`** —
/// is nibble 6 throughout, in the outer mountain and both instance WMOs. All of it creeps.
/// * **Slime splits the same way**: Undercity's 38 canals are nibble 3 and stand still;
/// Stratholme's are nibble 7 and flow.
#[test]
fn only_the_nibble_six_and_seven_pools_scroll_in_the_shipped_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
// Every liquid-bearing group of a WMO, as `(nibble, kind)` → count.
let mut census = |root_path: &str| -> HashMap<(u8, LiquidKind), usize> {
let bytes = chain.read_file(root_path).expect("root readable");
let root = parse_wmo_root(&bytes).expect("parse root");
let stem = root_path
.strip_suffix(".wmo")
.unwrap_or(root_path)
.to_string();
let mut tally = HashMap::new();
for gi in 0..root.group_count() as usize {
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
if let Some(m) = wmo_group_liquid_mesh(&gb) {
*tally.entry((m.sound_nibble, m.kind)).or_default() += 1;
}
}
tally
};
let ironforge = census("world\\wmo\\khazmodan\\cities\\ironforge\\ironforge.wmo");
assert_eq!(
ironforge,
HashMap::from([
((2, LiquidKind::Magma), 9),
((4, LiquidKind::Still), 2),
((6, LiquidKind::Magma), 2),
]),
"Ironforge's liquid census moved",
);
let blackrock = census("world\\wmo\\dungeon\\az_blackrock\\blackrock.wmo");
assert_eq!(
blackrock,
HashMap::from([((6, LiquidKind::Magma), 2)]),
"Blackrock Mountain's lava — the `.go xyz -7531.21 -1123.64 172.58` pin — is all nibble 6",
);
let undercity = census("world\\wmo\\lorderon\\undercity\\undercity.wmo");
assert_eq!(
undercity,
HashMap::from([((3, LiquidKind::Slime), 38)]),
"Undercity's slime is nibble 3 throughout",
);
let stratholme = census("world\\wmo\\dungeon\\ld_stratholme\\stratholme.wmo");
assert_eq!(
stratholme,
HashMap::from([((7, LiquidKind::Slime), 3)]),
"Stratholme's slime is nibble 7 throughout",
);
// And the gate's verdict over all of it — the same sheet, two behaviours.
assert_eq!(
ironforge
.iter()
.filter(|((n, _), _)| super::scrolls(*n))
.map(|(_, c)| c)
.sum::<usize>(),
2,
"exactly 2 of Ironforge's 11 magma pools scroll",
);
for (label, tally) in [("blackrock", &blackrock), ("stratholme", &stratholme)] {
assert!(
tally.keys().all(|(n, _)| super::scrolls(*n)),
"{label}: every pool scrolls",
);
}
assert!(
undercity.keys().all(|(n, _)| !super::scrolls(*n)),
"Undercity's slime stands still",
);
}
/// **Which fog block each liquid-bearing WMO group takes**, against the real client files.
///
+1
View File
@@ -1378,6 +1378,7 @@ mod inversion_tests {
wades: true,
scale: 1.0,
height: 2.0,
bound: None,
}
}
}
@@ -280,8 +280,13 @@ pub(super) fn apply_model_visibility(
///
/// - **`vis=Hidden`** — [`apply_model_visibility`] above said no: a toggle, the far-clip wall, a
/// fully-faded doodad, an `A ≤ 0` material track, the portal PVS or the exterior window gate.
/// - **`vis=Inherited view=false`** — *we* admitted it and **Bevy's frustum cull** dropped it,
/// which means the entity's `Aabb` does not describe what it draws.
/// - **`vis=Inherited inh=false`** — *this* part said yes and an **ancestor** said no. For a body
/// part that is the exterior-scene election (decision 1270), which is decided once on the net
/// root and inherits down; it is also how a transport hides its deck. Without this field the
/// line was indistinguishable from the frustum case below — the election arrived after the
/// trace did, and a body vanishing for the right reason would have read as a bound bug.
/// - **`vis=Inherited inh=true view=false`** — everything of ours admitted it and **Bevy's frustum
/// cull** dropped it, which means the entity's `Aabb` does not describe what it draws.
///
/// That second line is the whole of decisions 1259 and 1261 — an animated placement whose bound was
/// its bind pose, and then the same bound silently recomputed at the twin swap — and both were
@@ -324,6 +329,7 @@ type TracedModels<'w, 's> = Query<
&'static crate::interact::WorldObject,
&'static GlobalTransform,
&'static Visibility,
&'static InheritedVisibility,
&'static ViewVisibility,
Option<&'static Aabb>,
),
@@ -348,7 +354,7 @@ pub(super) fn trace_model_visibility(
let (cam_pos, cam_fwd) = cam_t.map_or((Vec3::ZERO, Vec3::Z), |t| {
(t.translation(), Vec3::from(t.forward()))
});
for (e, object, xf, vis, view, aabb) in &q {
for (e, object, xf, vis, inherited, view, aabb) in &q {
if !object.label.to_ascii_lowercase().contains(&trace.needle) {
continue;
}
@@ -364,10 +370,12 @@ pub(super) fn trace_model_visibility(
};
let depth = (centre - cam_pos).dot(cam_fwd);
println!(
"VIS_TRACE {e} {label} vis={vis:?} view={view} origin=[{ox:.1},{oy:.1},{oz:.1}] \
"VIS_TRACE {e} {label} vis={vis:?} inh={inh} view={view} \
origin=[{ox:.1},{oy:.1},{oz:.1}] \
bound=[{cx:.1},{cy:.1},{cz:.1}] r={radius:.1} depth={depth:.1} \
cam=[{px:.1},{py:.1},{pz:.1}]",
label = object.label,
inh = inherited.get(),
view = view.get(),
ox = xf.translation().x,
oy = xf.translation().y,
+12 -4
View File
@@ -51,7 +51,7 @@ pub struct EffectVertex {
}
/// The lane's blend variants (0733 §4) — a superset of the file-format enums it serves:
/// [`ParticleBlend`]'s three, plus the multiplicative pair the decal family and rain need.
/// [`ParticleBlend`]'s four, plus the multiplicative pair the decal family and rain need.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum EffectBlend {
/// `(SRC_ALPHA, ONE)` via premultiplied-alpha + the shader's gamma `rgb·a` fold (0160/0161).
@@ -60,6 +60,11 @@ pub enum EffectBlend {
Alpha,
/// No blend, depth-write ON — drawn in the transparent bracket at the owner rung (0719).
Opaque,
/// [`EffectBlend::Opaque`]'s state plus the fixed-function **alpha test** — the fragment is
/// discarded below `224/255` ([`benilla_assets`]'s `VANILLA_ALPHA_KEY_REF`, the same ref the
/// mesh path's cutouts take). EGxBlend 1, the debris/chip family; the byte chain that pins
/// blend-off + z-write-on + that ref is on [`ParticleBlend::AlphaKey`].
AlphaKey,
/// `dst · lerp(1, src, α)` — bevy's `AlphaMode::Multiply` state (`(Dst, 1−srcα)` + shader
/// premultiply, bevy_pbr mesh.rs:2486): the blob shadow's `GL_DST_COLOR/GL_ZERO`-with-fade,
/// and `ModelBlend::Mod` (0528) at α = 1.
@@ -73,6 +78,7 @@ impl From<ParticleBlend> for EffectBlend {
match blend {
ParticleBlend::Add => EffectBlend::Add,
ParticleBlend::Alpha => EffectBlend::Alpha,
ParticleBlend::AlphaKey => EffectBlend::AlphaKey,
ParticleBlend::Opaque => EffectBlend::Opaque,
}
}
@@ -80,9 +86,11 @@ impl From<ParticleBlend> for EffectBlend {
impl EffectBlend {
/// The ground-fx mapping from a part's authored blend — `model_render.rs`'s law with the
/// lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant here; the
/// groundscan census says flat `Spells\` quads are blend batches), and the part renders unlit
/// (spell fx are; a lit ground quad would differ — none observed).
/// lane's two named approximations: `AlphaTest` folds to `Alpha` (**deliberately not**
/// [`EffectBlend::AlphaKey`], which the particle side now takes — the groundscan census says
/// flat `Spells\` quads are blend batches, and a cutout ref on a soft-edged decal would bite
/// its fade), and the part renders unlit (spell fx are; a lit ground quad would differ — none
/// observed).
///
/// `additive` is a SECOND, non-optional input because [`ModelBlend`] cannot express additive:
/// M2 blend modes 3/4 fold into its `Blend` variant (see its own doc, "Alpha-blended /
@@ -225,6 +225,9 @@ impl SpecializedRenderPipeline for EffectPipeline {
),
EffectBlend::Alpha => (Some(BlendState::ALPHA_BLENDING), false, "BLEND_ALPHA"),
EffectBlend::Opaque => (None, true, "BLEND_OPAQUE"),
// EGxBlend 1: the same pipeline state as Opaque — the alpha TEST is the whole
// difference, and it lives in the fragment shader (wgpu has no `glAlphaFunc`).
EffectBlend::AlphaKey => (None, true, "BLEND_ALPHAKEY"),
EffectBlend::Multiply => (
Some(BlendState {
color: BlendComponent {
@@ -14,6 +14,8 @@
// - BLEND_ALPHA: straight (rgb, α) under standard alpha blending.
// - BLEND_OPAQUE: (rgb, 1) with blending off — the alpha-ignoring shape the material path's
// AlphaMode::Opaque produced.
// - BLEND_ALPHAKEY: BLEND_OPAQUE's output behind the fixed-function ALPHA TEST — `discard` below
// 224/255. wgpu has no `glAlphaFunc`, so the reference's EGxRs id 0x08 lands here.
// - BLEND_MULTIPLY: premultiplied (rgb·α, α) under (Dst, 1−srcα) — `dst·lerp(1, rgb, α)`, the
// blob shadow's modulate-with-fade (and ModelBlend::Mod at α = 1).
// - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst` — rain's state; reads no alpha.
@@ -132,6 +134,19 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// The gamma-space product — texture bytes × raw authored vertex colour, exactly what the
// reference multiplies.
let c = textureSample(effect_texture, effect_sampler, in.uv) * in.color;
#ifdef BLEND_ALPHAKEY
// THE FIXED-FUNCTION ALPHA TEST (EGxRs id 0x08 — `glAlphaFunc(GL_GEQUAL, ref/255)` +
// `glEnable(GL_ALPHA_TEST)`). `0x70c256` computes the ref from the RAW blend mode, so an
// AlphaKey emitter keeps `round(instanceAlpha × 224.0)` — 224 at full alpha — and `c.a` here
// is exactly the reference's fragment alpha: texture α × the over-life track α, GL_MODULATE.
// GEQUAL, so the test PASSES at the ref: discard strictly below it.
//
// Without it the debris family's transparent field paints solid — the Lesser Rock Elemental's
// chips drew as pale squares, PARTROCK.BLP's α = 0 surround at full opacity.
if (c.a < 224.0 / 255.0) {
discard;
}
#endif
var rgb = c.rgb;
#ifdef EFFECT_LIT
// SCENE LIGHTING (EGxRs id 0x0e), for the emitters that ask for it. A particle emitter owns
@@ -194,6 +209,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
#ifdef BLEND_OPAQUE
return vec4<f32>(rgb, 1.0);
#else
#ifdef BLEND_ALPHAKEY
// Blending is off (EGxBlend 1 → `glDisable(GL_BLEND)`); the surviving fragments are solid.
return vec4<f32>(rgb, 1.0);
#else
#ifdef BLEND_MULTIPLY
// The modulate family: premultiplied for bevy's Multiply state — `dst·lerp(1, rgb, α)`
// (the blob shadow's fade-riding darken; `ModelBlend::Mod` at α = 1).
@@ -209,4 +228,5 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
#endif
#endif
#endif
#endif
}
+6
View File
@@ -117,6 +117,10 @@ pub struct CullTerms {
pub tested: usize,
pub hidden: usize,
pub unbounded: usize,
/// The body leg, kept apart from `tested`/`hidden` — see the verdict's own note for why summing
/// a two-dozen audience into a tens-of-thousands one erases it.
pub bodies: usize,
pub bodies_hidden: usize,
}
impl WorldCensus<'_, '_> {
@@ -244,6 +248,8 @@ impl WorldCensus<'_, '_> {
tested: v.tested,
hidden: v.hidden,
unbounded: v.unbounded,
bodies: v.bodies,
bodies_hidden: v.bodies_hidden,
}),
mats: self.mats.len(),
meshes: self.meshes.len(),
+19
View File
@@ -61,6 +61,25 @@ pub struct WorldUnit {
/// component defaulted it to zero and the water-foam fixture caught it: 91 k pixels of wrong
/// ripple, in a lane none of the six scenery captures covers.
pub height: f32,
/// The box the world may **cull this body by**, in model space — or `None` for a body the world
/// must not decide.
///
/// The third of this component's size facts, beside [`scale`](Self::scale) and
/// [`height`](Self::height), and here for the same reason they are: one place answers "how big
/// is this body", and a second component for the culling half is the drift that ends with two
/// extents disagreeing.
///
/// It exists because standing in a sealed WMO room the reference never *submits* an outdoor
/// object at all (`crate::exterior_cull`, decision 1270) — so the cull needs one whole-object
/// AABB per body, on its root, which is the reference's own granularity. The game supplies the
/// armed idle's authored CAaBox (decision 0637 — a skinned body's bind-pose box is not where it
/// draws), unscaled: the root transform already carries the display scale.
///
/// `None` says **don't decide**, and it is a real answer, not an absence — a body whose display
/// has not resolved, and a transport, whose root `Visibility` its own tick owns every frame
/// (a second writer there is precisely the fight decision 0025 forbids). Either way the body
/// keeps drawing, which is what it did before this field existed.
pub bound: Option<bevy::camera::primitives::Aabb>,
}
/// …and this one is the **viewer's own** body. A marker rather than a bool on [`WorldUnit`]