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:
@@ -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])),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
] {
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`]
|
||||
|
||||
Reference in New Issue
Block a user