Shadowfang's far rooms lose their cyan, and the street lamps hum

- Shadowfang Keep's far doorways no longer fill with flat blue-cyan at distance: it was never a hole — the room behind the arches is in the visible set at every camera distance — it was the building's own MFOG record (rgb 40,68,79 at end 106.9 yd, ~95% saturated at 70 yd) painted over a room the reference shows under the scene fog, because benilla picked the fog lane STATICALLY per batch (`flags & 0x48`) where the client's `[0xca7f00]` gate decides it per frame per group along the portal walk; the flood publishes that bit now and all three lanes (entity, retained, WMO liquid) read it, and the wow-re carve confirmed the predicate verbatim and added the conjunct we lacked — the MFOG target exists only when one of the camera's own containing groups is a true interior, so in a courtyard the interior triple decays to the scene triple, which is what a unit standing in the keep reads
- every world-placed M2 was silent — the street lamp's hum, the campfire, the waterfall, the windmill: no doodad ever wrote into the anim-event stream (three producers, none a terrain placement), and the sound clock was gated on the RIG, which is rightly never built for a model whose idle is the static mesh — `KalidarStreetLamp01.m2` is one looping 3.3 s Stand keying no bone at all, carrying one `$DSL`; the census (`benilla-extract soundeventscan`) counts 273 carrier models, 108 on tiers no rig gate arms; a sound-carrying placement now gets a clock-only host, `$DSL` is a sustained emitter, and the RE foldback settled three laws: `$DSL` ALWAYS loops and consults no flag (the kit's 0x200 bit is a lane select and 0x400 is random pitch — a neat four-row correlation of ours refuted), `$DSE` is the stop token, and a doodad holds ONE registration slot, which is why `bellows.m2`'s two loops alternate rather than drone
- the reference voices sound IDS, not doodads: `$DSL` registers the doodad's position as one record in a 32-entry pool keyed by SoundEntries id (`0x461d80`), each entry runs one channel at whichever of its records is nearest the listener — moved, not restarted (`0x7a5b10`), so one hum follows you down a row of lamps — the pump admits the first four entries by claim order and fades the rest over 3.0 s; the per-id duplicate suppressors turn out to be the one-shot lane's and were being applied to every lane (a three-second dropout every time a loop came back under the cap); three 76 yd hops between two Darnassus lamps now produce ONE play, and Darnassus runs 12 entitled ambiences with 8 withheld by the cap
- the selection ring joins the decal band too, and it draws UNDER the shadow: the RE found the ring emitted from inside PHASE 1's own M2 node drain (`[node+0xb4] = 0x481540` → the `+0x38` tick → `0x608e00`), the same iteration that then draws that node's blob shadow — the reverse of the tie-break this ladder had asserted since both rungs existed, a rule invented here and never read anywhere; `WOW_EFFECT_TRACE` names every rung of the band and prints its index in the sorted transparent phase, which is the ladder read straight off a live frame

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:00:01 +07:00
co-authored by Claude Fable 5.1
parent 0e83531e8c
commit d961d0bc06
36 changed files with 2027 additions and 143 deletions
+5 -1
View File
@@ -11,7 +11,11 @@
//! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only).
//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). So the shadow
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). `0x683dd0` is
//! the **M2 node drain**, and the same loop body ticks each node's object first
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and gates its shadow second
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never
//! the other way round (wow-re `decal-frame-slot.md`). So the shadow
//! lands after terrain and WMO and **before** everything else: the footprint decals
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
+6
View File
@@ -36,6 +36,12 @@
//! quiescence, or waiting for pixel stability cannot help. It is a renderer defect that the harness
//! correctly *reports*; do not try to gate it away here. Read 0815 before touching this file's phases.
//!
//! **`creature-indoor-front` is one of them** (named 2026-09-01, while A/B-ing 1787's fog gate): two
//! runs of ONE unchanged build land in the two states at MAE 4.518, the whole difference confined to
//! the subject's own brightness with the room around it pixel-identical, and the settle counts differ
//! (235 vs 211 frames). If a change you are A/B-ing shows exactly 4.518 here, re-run the baseline
//! before believing it — the number is the flake's, not yours.
//!
//! ## Running one capture by hand
//! **Run through Cargo — never the built binary directly:**
//! ```text
@@ -597,6 +597,7 @@ mod tests {
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, false],
interior_fog: vec![false, false],
liquid_visited: vec![false, false],
flooded: vec![None, None],
})
+71
View File
@@ -0,0 +1,71 @@
//! The **placed-doodad** animation-event scanner — the third and last producer feeding the
//! [`AnimSoundEvent`] stream, beside the creature scanner ([`crate::creature_anim`]) and the
//! GameObject one ([`crate::go_anim`]).
//!
//! Bug B345 ("every world-placed M2 doodad is silent") was this hole: a lamp's hum, a campfire's
//! crackle, a waterfall, a windmill and a gnome machine are all authored as `$DSL`/`$DSO`/`$SND`
//! keys on the model's own idle sequence — and **nothing in benilla ever wrote a doodad's events
//! into the stream**. The routing end ([`crate::sound::anim_events`]) has handled those tags since
//! decision 0070 slice 3; it simply never received one from a doodad, because the only two
//! scanners gate on `AnimDriver` (units) and `GoAnim` (server-sent GameObjects), neither of which a
//! terrain-stream placement ever gets.
//!
//! **Why it is not enough to scan the hosts that already exist.** The corpus says 273 models author
//! a doodad sound marker (`benilla-extract soundeventscan`); only 165 of them are the `FirstSeq`
//! tier that carries an `AnimationPlayer`. The other 108 — including the reported lamp,
//! `KalidarStreetLamp01.m2`, one looping 3.333 s Stand that keys no bone at all and carries exactly
//! one `$DSL` → `NightElfStreetLampLoop` — are bind-posed, and decision 0130's content gate
//! deliberately builds them no rig. That gate is right about pixels and was never a claim about the
//! clock: the reference arms **every** placed doodad and cycles it (wow-re `doodad-anim-host.md`
//! §1/§5). So `doodad_anim::spawn_anim_host` now gives a sound-carrying model a clock-only host —
//! the arm bookkeeping, no player — and this scanner reads that arm.
//!
//! **The clock is the shared one, never the player** ([`DoodadAnimHost::arm_clock`]): the reference
//! gates the cycle on *linkage* (residency), not on the draw, so a campfire behind you keeps
//! crackling. Reading the draw-gated `AnimationPlayer` would silence it the moment you turned away.
use bevy::prelude::*;
use benilla_world::doodad_anim::DoodadAnimHost;
use benilla_world::schedule::WorldStage;
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent, TrackMemory};
use benilla_assets::ModelAnimations;
/// Fire the event keyframes each placed doodad's armed clip crossed this frame.
///
/// The arming rules are the shared [`advance_track`]/[`scan_events`] ones the other two scanners
/// use — an arm frame fires nothing, the frame after it opens the clip's `t = 0` head (which is
/// where essentially every doodad sound key sits), and a loop wrap fires tail-then-head. A
/// variation re-roll ([`benilla_world::doodad_anim`]'s self-sustaining re-arm) changes the node, so
/// [`advance_track`] sees a fresh arm and never scans across the seam.
fn fire_doodad_anim_events(
time: Res<Time>,
hosts: Query<(Entity, &DoodadAnimHost, &ModelAnimations)>,
mut last: Local<TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
) {
let now = time.elapsed_secs();
for (entity, host, anims) in &hosts {
let Some((node, cur)) = host.arm_clock(now) else {
continue; // gseq-only host with no sound arm: nothing is armed, so nothing fires
};
let Some(clip) = anims.clips.iter().find(|c| c.node == node) else {
continue;
};
if let Some(prev) = advance_track(&mut last, entity, node, cur) {
scan_events(clip, entity, prev, cur, &mut out);
}
}
// Drop memory for hosts that no longer exist. The other two scanners leave their `Local` to
// grow — a unit or GameObject population churns with the session — but placements do not:
// crossing a continent streams tens of thousands of them in and out, and an unreaped entry per
// placement is an unbounded leak on the one lane where it actually bites. `Query::contains` is
// a lookup, so this is one pass over a map sized by the live sound-host count. It lives here
// rather than in its own system because a `Local` belongs to exactly one system.
last.retain(|e, _| hosts.contains(*e));
}
pub(crate) fn plugin(app: &mut App) {
app.add_systems(Update, fire_doodad_anim_events.in_set(WorldStage::Present));
}
+2
View File
@@ -60,6 +60,7 @@ mod debug_panel;
/// **The dev/player seam** (decisions 0026/1173, built in 1174) — the group, and the boundary
/// rule, in one file. Always compiled; what it *holds* is not.
mod dev;
mod doodad_events;
mod entities;
mod fishing_line;
mod footprints;
@@ -523,6 +524,7 @@ pub fn run(build: BuildId) -> AppExit {
// GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence
// on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above.
.add_plugins(go_anim::plugin)
.add_plugins(doodad_events::plugin)
// Avatar + camera + input.
.add_plugins(PlayerPlugin)
// Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the
+1 -1
View File
@@ -615,7 +615,7 @@ fn warm_effect_lane(
] {
for raster_bias in [
0,
benilla_world::sky_order::Rung::GROUND_FX as i32,
benilla_world::sky_order::Rung::DECAL_RASTER,
benilla_world::sky_order::Rung::SHADOW_RASTER,
] {
// `lit` is a pipeline-key axis (a shader def), so BOTH arms are warmed — a hole
+74 -9
View File
@@ -1,8 +1,9 @@
//! Route M2 animation event tags (`crate::creature_anim::AnimSoundEvent`) to audio — the
//! anim-driven trigger surface (decision 0070 slice 3).
//!
//! Routed here: `$SND`/`$DSO` (one-shot kit `data` at the model), `$DSL` (doodad loop kit —
//! played as a one-shot for now; looping-emitter lifecycle comes with doodad anim support),
//! Routed here: `$SND`/`$DSO` (one-shot kit `data` at the model), `$DSL`/`$DSE` (a placed
//! doodad's ambient loop — **registered into, and released from, the emitter pool**
//! [`super::doodad_pool`], which owns everything about how it sounds),
//! `$CSD` — the **character emote clips' embedded voice** (HumanMale EmoteLaugh 70 carries
//! `$CSD 6923` = the SoundEntries kit literally named `HumanMaleEmoteLaugh`; Cry 77 → 6921,
//! Chicken 78 → 6919, Applaud 80 → 4× `ClapSounds` 6576 — probe-verified on the real 5875 M2 +
@@ -38,12 +39,13 @@ use crate::net::ObjectStore;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::doodad_pool::DoodadEmitterPool;
use super::emote::EmoteSounds;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
#[allow(clippy::too_many_arguments)] // the standard sound-route param set + the two resolvers
fn route_anim_events(
pub(super) fn route_anim_events(
mut events: MessageReader<AnimSoundEvent>,
// GlobalTransform: `$SND` tags can fire from parented visuals (a mount child's model),
// whose local Transform is not a world position (0441 fold-back).
@@ -57,6 +59,16 @@ fn route_anim_events(
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
// Kit ids already complained about. A failing kit here is a PER-EVENT failure on a stream that
// fires at doodad rates, so warning every time is a log flood, not a diagnostic: one live run
// past Darnassus produced 420 identical lines for `NightElfLantern01`'s `$DSL(33764)`, an id
// that is simply not in 5875's `SoundEntries` (32401 is the corpus's only other one). The
// reference does nothing audible for an id it cannot resolve, so this is data, not an error —
// but it is still worth saying once, because a kit that goes missing for any OTHER reason is a
// real bug and silence would hide it.
mut complained: Local<std::collections::HashSet<u32>>,
// The ambient emitter pool — `$DSL`/`$DSE`'s whole destination.
mut pool: ResMut<DoodadEmitterPool>,
) {
if events.is_empty() {
return;
@@ -65,7 +77,11 @@ fn route_anim_events(
return;
};
let listener = listener.pos;
let ring = |kits: &mut SoundKits, out: &mut SoundOutput, kit: u32, entity: Entity| {
let ring = |kits: &mut SoundKits,
out: &mut SoundOutput,
kit: u32,
entity: Entity,
complained: &mut std::collections::HashSet<u32>| {
let pos = transforms.get(entity).map(|t| t.translation()).ok();
if let Err(e) = play_kit(
kits,
@@ -77,13 +93,62 @@ fn route_anim_events(
pos,
SoundCategory::Sfx,
) {
warn!("anim event kit {kit}: {e:#}");
if complained.insert(kit) {
warn!("anim event kit {kit}: {e:#} (further reports for this kit suppressed)");
}
}
};
for ev in events.read() {
match &ev.ident {
b"$SND" | b"$DSO" | b"$DSL" | b"$CSD" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev.entity);
// `$DSL` — the DOODAD SOUND **LOOP** (`0x69521d`), VERIFIED (wow-re
// `sound/scratch/doodad-sound-emitters.md`, §5). A persistent handle with a lifecycle,
// one per doodad (`[CMapDoodadDef+0x168]`): crossing the marker again **repositions**
// the existing registration (`0x462000`) and never restarts it; a DIFFERENT id
// releases the old one (`0x461f80`) and registers the new (`0x461d80`). So there is no
// wrap retrigger at all — which the shipped audio already implied, since
// `NightElfStreetLampLoop` is 4.000 s on a 3.333 s sequence and `CampFireSmallLoop` is
// 2.967 s on the same, mismatched in both directions.
//
// **It always loops, and consults NO flag.** Looping is an entry-point constant on the
// reference's registration pool: `0x7a54d0` builds mode `0x1002` (`HW3D|LOOP_NORMAL`)
// and calls `SetLoopCount(stream, -1)`, against `0x7a5490`'s `0x1000` for the one-shot
// path. This corrects the interim that shipped with the first half of B345: the kit's
// 0x200 bit has exactly ONE reader image-wide (`0x458840`), whose two callers are the
// GameObject display-slot lane select and the spell-visual lane — it reaches neither
// mode word and is a LANE SELECT, not a loop flag. (0x400, which correlated perfectly
// with the four non-sustaining `$DSL` kits in the shipped data, is `random pitch`; the
// correlation was authoring practice — you do not detune a sustained hum.) `force_loop`
// here is therefore the faithful shape, not a workaround: 25 of the 60 kits a `$DSL`
// names omit 0x200 and every one of them loops in the real client.
// `$DSL` — the DOODAD SOUND **LOOP** (`0x69521d`), VERIFIED (wow-re
// `sound/scratch/doodad-sound-emitters.md`). It does not start a sound. It
// **registers this doodad's position** as one emitter of its SoundEntries id in the
// pool at `0xb06dd8` (`0x461d80`), and re-crossing the marker only *repositions* that
// registration (`0x462000`) — which is why there is no wrap retrigger, and why
// `NightElfStreetLampLoop` (4.000 s of sample on a 3.333 s sequence) is not chopped
// every cycle. Whether anything is audible, from where, and how many at once are the
// pool pump's questions, not this scanner's: see [`super::doodad_pool`].
b"$DSL" if ev.data != 0 => {
if let Ok(t) = transforms.get(ev.entity) {
super::doodad_pool::register(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
);
}
}
// `$DSE` — the doodad sound **STOP** token (`0x45534424`), VERIFIED in the same note:
// it releases the doodad's registration (`0x461f80`), and its `data` is 0 on all 16
// shipped models. Without it a `$DSL` started at a keyframe never ends — which is
// exactly the elevator and machinery family (`GnomereganElevatorLoop`, `SubwayLoop`,
// the Undercity and Thunder Bluff lifts, the zeppelin), where the loop is authored to
// run for one leg of the animation and stop. Releasing a record is not stopping a
// sound: the id keeps sounding while any *other* doodad still names it.
b"$DSE" => super::doodad_pool::release(&mut pool, ev.entity),
b"$SND" | b"$DSO" | b"$CSD" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev.entity, &mut complained);
}
b"$ESD" => {
let Some(emotes) = emotes.as_deref() else {
@@ -98,7 +163,7 @@ fn route_anim_events(
.then(|| emotes.state_event_sound(state))
.flatten()
{
ring(&mut kits, &mut out, kit, ev.entity);
ring(&mut kits, &mut out, kit, ev.entity, &mut complained);
}
}
b"$TRD" => {
@@ -108,7 +173,7 @@ fn route_anim_events(
let hold = units.get(ev.entity).ok().and_then(|(_, h)| h);
let kit = hold.and_then(|h| held_strike_sound(spells, &visuals.0, h.spell_id));
if let Some(kit) = kit {
ring(&mut kits, &mut out, kit, ev.entity);
ring(&mut kits, &mut out, kit, ev.entity, &mut complained);
}
}
other => {
+714
View File
@@ -0,0 +1,714 @@
//! The **placed-doodad ambient emitter pool** — the reference's registration table at `0xb06dd8`
//! and its per-frame pump `0x461990` (wow-re `sound/scratch/doodad-sound-emitters.md`, §7/§8/§17).
//!
//! B345's fix gave every world-placed doodad a clock and routed its `$DSL` key to audio, which is
//! what made a lamp hum at all. It played that hum the obvious way — one channel per doodad, tagged
//! to the doodad — and that is **not the shape the reference has**. The client does not voice
//! doodads; it voices **sound ids**, and a doodad is only a *position* it can put one at:
//!
//! - `$DSL` registers the doodad's position as one **record** inside the pool entry that holds its
//! SoundEntries id (`0x461d80`, handle `((entry+1) << 16) | record` kept in
//! `[CMapDoodadDef+0x168]`). Every doodad naming the same id shares one entry.
//! - Each entry runs **exactly one channel**, positioned at whichever of its records is **nearest
//! the listener** (`0x461ca0`'s 256-wide argmin), repositioned as that changes and **never
//! restarted** (`0x7a5b10`). One hum follows you down a row of thirty lamps.
//! - The pump walks the 32 entries and lets the first **four** hold a channel (`0x4619c0 cmp
//! edi,4`). Arbitration is by **ascending entry index — claim order, not distance**: a fifth
//! distinct ambience is faded out over 3.0 s and does *not* take over when you walk toward it.
//!
//! So the three limits are structural, not a budget bolted on: **32 distinct ids**, **256 emitters
//! per id**, **4 sounding at once**.
//!
//! **What we had instead**, measured rather than assumed (`benilla-extract soundeventscan`): a
//! channel per doodad, parked on its own model and restarted every time you crossed its
//! `DistanceCutoff`. The *count* was held down by two gates that were never about ambience —
//! **45 of the 60** resolvable `$DSL` kits carry the reference's own `NO_DUPLICATES` bit (0x20),
//! which benilla applied on every lane, so three-quarters of doodad ambience already played
//! exactly one channel; the other 15 got up to two from `SAME_KIT_MAX`. One channel, yes — but at
//! whichever doodad happened to fire first rather than the nearest, retriggering at every cutoff
//! crossing, and with **nothing whatever** bounding the number of *distinct* ambiences, so a
//! torch-and-campfire cluster could take a third of the 12-voice ceiling away from the sounds a
//! player is actually listening for. Applying 0x20 here was itself wrong: it is the **one-shot**
//! lane's suppressor (§15), and this lane is now exempt from it
//! ([`super::kit::PlayExtras::dedupe_exempt`]).
//!
//! **What the pool is not.** It is not a cache and not an optimisation — it is the mechanism that
//! decides *where* a doodad ambience comes from and *which* ones you hear. Sharing a channel is
//! what makes the emitter track the nearest lamp; the cap is what makes the fifth one silent.
//!
//! **Deliberate divergences**, both documented at their site: a failed kit is flagged in place
//! rather than by negating the entry's id, and a channel the frame pump culls past the kit's own
//! `DistanceCutoff` is stopped rather than left running at zero gain. Neither changes what is
//! audible; see the notes on [`Entry::failed`] and [`pump_doodad_emitters`].
use std::collections::HashSet;
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::kit::{
self, kit_name, play_kit_ext, set_source_kit_gain, source_kit_playing, stop_source, KitRef,
PlayExtras, SoundCategory, SoundKits,
};
use super::{math, AudioListener, SoundConfig, SoundOutput};
/// `0x461eac cmp ecx,0x1c200` over the entry stride `0xe10` — **32** entries, one per distinct
/// SoundEntries id. A 33rd distinct id finds no match and no free entry, `0x461d80` returns handle
/// 0, and that doodad is simply untracked until an entry frees (§17).
const POOL_ENTRIES: usize = 32;
/// `[entry+0xC00..+0xCFF]` — **256** emitter records per entry.
const RECORDS_PER_ENTRY: usize = 256;
/// `0x4619c0 cmp edi,4` — at most **four** entries hold a channel at once.
const PLAYING_CAP: usize = 4;
/// `0x7a5a10(3.0f)` — the fade a released or cap-evicted channel gets, in seconds.
const FADE_SECS: f32 = 3.0;
/// The entity a pool entry's one channel rides. Its `Transform` is what [`kit::pump_channels`]'s
/// tracked-follow reads, so moving it *is* the reference's reposition (`0x7a5b10`) — the same
/// trick the liquid ambience loops use for their slewed emitter.
#[derive(Component)]
struct PoolEmitter;
/// One registered emitter: the reference's `[entry + 12·rec]` position, with presence in the list
/// standing in for its `[+0xC00+rec]` active byte.
struct Record {
doodad: Entity,
pos: Vec3,
}
/// One pool entry — a SoundEntries id, its emitters, and the single channel they share.
#[derive(Default)]
struct Entry {
/// `[+0xE00]` — the SoundEntries id this entry holds; 0 = free.
id: u32,
/// **Divergence.** The reference marks a kit that failed to resolve by *negating* the id
/// (`[+0xE00] = -id`), which makes the entry stop matching its own id: the doodad's next
/// `$DSL` then releases and re-registers into a fresh entry, every cycle, for ever. A separate
/// flag keeps the doodad where it is. Both hold exactly one entry and both are silent, so
/// nothing observable turns on it — but ours does not churn.
failed: bool,
/// `[+0xC00]`'s active set, in claim order (which is what the 257th-record eviction rule
/// below reads as "first").
records: Vec<Record>,
/// `[+0xE04]`'s companion — the entity carrying this entry's channel, `None` when none is
/// live.
voice: Option<Entity>,
}
impl Entry {
/// `0x461b40` steps 1–3: is there a channel coming to this entry at all? (Occupied, its kit
/// resolvable, and at least one emitter registered.) Entitlement, not audibility — see
/// [`admitted`].
fn entitled(&self) -> bool {
self.id != 0 && !self.failed && !self.records.is_empty()
}
/// `0x461ca0` — the position of the **nearest** active record, ties keeping the first
/// (`0x461cf6` discards a candidate that is not *strictly* closer).
fn nearest(&self, listener: Vec3) -> Option<Vec3> {
self.records
.iter()
.map(|r| (math::dist_sq(listener, r.pos), r.pos))
.reduce(|best, cand| if cand.0 < best.0 { cand } else { best })
.map(|(_, pos)| pos)
}
}
/// A channel the pool has let go of and is fading out over [`FADE_SECS`].
///
/// It is an **orphan on purpose**: the reference starts the fade and clears `[+0xE04]` in the same
/// breath (`0x461d20`, `0x4619c5`), so the entry is free to be reclaimed by another id — or to
/// start a fresh channel of its own — while the old sound is still dying. Holding the fade on the
/// entry would serialise those.
struct Fading {
emitter: Entity,
kit: u32,
gain: f32,
}
/// The pool itself.
#[derive(Resource)]
pub(super) struct DoodadEmitterPool {
entries: [Entry; POOL_ENTRIES],
/// Which entry each doodad is registered in — the reference's per-doodad handle
/// `[CMapDoodadDef+0x168]`, minus the record half (a record *is* its doodad here).
handles: EntityHashMap<usize>,
fading: Vec<Fading>,
/// Kit ids already warned about. A `$DSL` re-fires every animation cycle, so an unresolvable
/// id is a per-cycle failure on a per-doodad stream: the same log flood B345's routing half
/// had to learn to avoid.
complained: HashSet<u32>,
/// The last logged census — `(kit id, is a channel actually live)` per admitted entry — so
/// the line below reports **edges**, not frames. Liveness is in the key on purpose: a channel
/// culled past its cutoff and restarted is exactly the churn a broken nearest-follow would
/// produce, and the census is where that has to be visible.
last_census: Vec<(u32, bool)>,
}
impl Default for DoodadEmitterPool {
fn default() -> Self {
Self {
entries: std::array::from_fn(|_| Entry::default()),
handles: EntityHashMap::default(),
fading: Vec::new(),
complained: HashSet::new(),
last_census: Vec::new(),
}
}
}
impl DoodadEmitterPool {
/// `$DSL`'s registration step — `0x69521d`'s compare followed by `0x462000` (reposition) or
/// `0x461f80`+`0x461d80` (re-register).
///
/// Crossing the marker again with the **same** id only moves the record; that is the whole
/// reason there is no wrap retrigger, and why `NightElfStreetLampLoop` (4.000 s of sample on a
/// 3.333 s sequence) is not chopped every cycle. A **different** id releases the old
/// registration and takes a new one, which is what makes `bellows.m2`'s `$DSL` pair —
/// `BellowOut` at t=0.000, `BellowIN` at t=1.100 on one looping 2 s sequence — *alternate*
/// through one slot instead of droning together.
fn register(&mut self, doodad: Entity, id: u32, pos: Vec3, listener: Vec3) {
if let Some(&e) = self.handles.get(&doodad) {
if self.entries[e].id == id {
if let Some(r) = self.entries[e]
.records
.iter_mut()
.find(|r| r.doodad == doodad)
{
r.pos = pos;
}
return;
}
self.release(doodad);
}
// `0x461e60`: the entry already holding this id, else the first free one.
let Some(e) = self
.entries
.iter()
.position(|x| x.id == id)
.or_else(|| self.entries.iter().position(|x| x.id == 0))
else {
return; // all 32 entries hold other ids — untracked until one frees (§17)
};
if self.entries[e].id == 0 {
self.entries[e].id = id;
self.entries[e].failed = false;
}
if self.entries[e].records.len() >= RECORDS_PER_ENTRY {
// `0x461a60` — the sound ledger's `first_slot_farther_than_query`: evict the FIRST
// record farther from the listener than the newcomer, and reject the newcomer when
// none is. Not an LRU and not an argmax: first-farther, in claim order.
let d = math::dist_sq(listener, pos);
let Some(victim) = self.entries[e]
.records
.iter()
.position(|r| math::dist_sq(listener, r.pos) > d)
else {
return;
};
let gone = self.entries[e].records.remove(victim);
self.handles.remove(&gone.doodad);
}
self.entries[e].records.push(Record { doodad, pos });
self.handles.insert(doodad, e);
}
/// `0x461f80` → `0x461d20` — release `doodad`'s record. When it was the entry's **last**, the
/// id is cleared and the channel starts its 3.0 s fade as an orphan.
///
/// Reached from `$DSE` (the authored stop token — the elevator and machinery family, where the
/// loop runs for one leg of the animation) and from the host's despawn (the doodad's own
/// teardown, §9). There is deliberately **no** map-change reset: the reference has none either
/// (`0x461a20`'s only caller is the process-shutdown chain), and a streamed-out tile releases
/// its doodads one by one, which is the same thing done honestly.
fn release(&mut self, doodad: Entity) {
let Some(e) = self.handles.remove(&doodad) else {
return;
};
let entry = &mut self.entries[e];
entry.records.retain(|r| r.doodad != doodad);
if !entry.records.is_empty() {
return;
}
let (kit, voice) = (entry.id, entry.voice);
*entry = Entry::default();
if let Some(emitter) = voice {
self.fading.push(Fading {
emitter,
kit,
gain: 1.0,
});
}
}
/// Hand this entry's channel to the fade list and leave the entry otherwise intact — the
/// cap's eviction (`0x4619c5`), which keeps the id and its records so the entry can start a
/// fresh channel the moment it is back under the cap.
fn retire(&mut self, e: usize) {
let (kit, voice) = {
let entry = &mut self.entries[e];
(entry.id, entry.voice.take())
};
if let Some(emitter) = voice {
self.fading.push(Fading {
emitter,
kit,
gain: 1.0,
});
}
}
}
/// The entries that get a channel this frame: the first [`PLAYING_CAP`] entitled ones **by
/// ascending index** (`0x4619c0`).
///
/// Index is claim order, so this is first-come — emphatically **not** nearest. That is the cap's
/// one surprising property and the reference's own falsifiable prediction: standing where five
/// distinct doodad ambiences are in range, exactly four sound, and walking toward the fifth does
/// not make it take over.
///
/// Entitlement is deliberately not audibility. An entry whose nearest emitter is past its kit's
/// `DistanceCutoff` still holds its slot, because in the reference its FMOD channel is still
/// *playing* — merely attenuated to nothing — and `0x461b40` counts it. A far ambience therefore
/// keeps a near one silent, which is exactly what the cap does in the real client.
fn admitted(entries: &[Entry]) -> Vec<usize> {
entries
.iter()
.enumerate()
.filter(|(_, x)| x.entitled())
.map(|(i, _)| i)
.take(PLAYING_CAP)
.collect()
}
/// The pump (`0x461990`): fade the orphans, mark unresolvable kits, then service the admitted
/// entries — start a channel where there is none, and otherwise only *move* the one there is.
#[allow(clippy::too_many_arguments)] // the standard sound-driver param set
fn pump_doodad_emitters(
mut pool: ResMut<DoodadEmitterPool>,
mut emitters: Query<&mut Transform, With<PoolEmitter>>,
time: Res<Time>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
mut commands: Commands,
) {
let (Some(mut kits), Some(assets)) = (kits, assets) else {
return;
};
// Running silent (`WOW_NOSOUND=1`, CI, an unattended probe): a start that cannot succeed must
// not be retried per frame per entry, and must not warn — the same lesson the liquid loops
// learned at 3975 identical lines in a 75 s run.
if out.mixer.is_none() {
return;
}
let listener_pos = listener.pos;
let step = if FADE_SECS > 0.0 {
time.delta_secs() / FADE_SECS
} else {
1.0
};
let pool = &mut *pool;
// The orphans' fade-out.
pool.fading.retain_mut(|f| {
f.gain -= step;
// Faded out, or nothing left to fade (the frame pump culled the channel past its cutoff,
// or the voice ceiling stole it): let the entity go now rather than hold it for 3 s of
// silence.
if f.gain <= 0.0 || !source_kit_playing(&out, f.emitter, f.kit) {
stop_source(&mut out, f.emitter);
commands.entity(f.emitter).despawn();
return false;
}
set_source_kit_gain(&mut out, f.emitter, f.kit, f.gain);
true
});
// `0x45cda0(id)` returning null → `[+0xE00] = -id`, permanently. Done before the arbitration
// so a dead id never costs a live one its slot, not even for a frame. `NightElfLantern01`'s
// `$DSL(33764)` is the shipped case: 5875's `SoundEntries` simply has no such row.
for entry in pool.entries.iter_mut() {
if entry.id != 0 && !entry.failed && kit_name(&kits, entry.id).is_none() {
entry.failed = true;
if pool.complained.insert(entry.id) {
warn!(
"doodad emitter kit {}: no SoundEntries row — entry silenced permanently \
(further reports for this kit suppressed)",
entry.id
);
}
}
}
let admitted = admitted(&pool.entries);
let mut census: Vec<(u32, bool)> = Vec::new();
for e in 0..POOL_ENTRIES {
if !admitted.contains(&e) {
// Free, failed, emitter-less — or over the cap, which is the case that matters: the
// channel fades out and the entry keeps everything else.
pool.retire(e);
continue;
}
let id = pool.entries[e].id;
let Some(nearest) = pool.entries[e].nearest(listener_pos) else {
continue; // `entitled()` guarantees a record; belt and braces
};
// Put the voice entity at the nearest record. For a live channel this *is* the reference's
// reposition — the frame pump's tracked-follow ships the new position and the sound never
// restarts, which is the difference between one hum that follows you down a row of lamps
// and thirty hums that each retrigger as you pass.
let emitter = match pool.entries[e].voice {
Some(em) => {
match emitters.get_mut(em) {
Ok(mut tf) => tf.translation = nearest,
// Ours, spawned on an earlier frame, so this cannot happen — except through a
// query-filter mistake, which would silently freeze every ambience at the
// first emitter it ever found and leave the whole nearest-follow inert. That
// failure is invisible in the audio (a hum is a hum), so it has to be loud
// here.
Err(err) => {
if pool.complained.insert(id) {
warn!(
"doodad emitter {em}: transform unreachable ({err}) — kit {id} is \
stuck at its first emitter"
);
}
}
}
em
}
None => {
let em = commands
.spawn((PoolEmitter, Transform::from_translation(nearest)))
.id();
pool.entries[e].voice = Some(em);
em
}
};
if source_kit_playing(&out, emitter, id) {
census.push((id, true));
continue;
}
// No channel: either this entry has never sounded, or the frame pump culled ours past the
// kit's `DistanceCutoff`. **Divergence:** the reference's pool lane has no distance
// pre-check and leaves the channel running at zero gain past the cutoff, where ours stops
// and restarts it. For a loop of ambience that is inaudible — restarting a hum you could
// not hear costs nothing but a phase — and it hands the voice back while it is silent.
// The *cap* is unaffected, because [`admitted`] counts entitlement, not liveness.
if let Err(err) = play_kit_ext(
&mut kits,
&assets,
&mut out,
&config,
listener_pos,
KitRef::Id(id),
Some(nearest),
SoundCategory::Sfx,
PlayExtras {
source: Some(emitter),
// Looping is an entry-point constant on this lane, not a flag: `0x7a54d0` builds
// mode `0x1002` (`HW3D|LOOP_NORMAL`) and calls `SetLoopCount(-1)`, against
// `0x7a5490`'s `0x1000` for the one-shot path. Every `$DSL` loops; no `$DSO` does;
// `SoundEntries.Flags` is consulted for neither (§16).
force_loop: true,
// The per-id suppressors are the one-shot lane's; this lane dedupes structurally
// (one entry per id) and must not be blocked by its own fading predecessor.
dedupe_exempt: true,
..default()
},
) {
if pool.complained.insert(id) {
warn!("doodad emitter kit {id}: {err:#} (further reports for this kit suppressed)");
}
}
// Whether the start actually took: `play_kit_ext` succeeds *without playing* when the kit
// is past its cutoff, held by the loading cover, or refused by the voice ceiling. Reading
// the channel back is the only honest answer, and the difference between "admitted" and
// "audible" is the whole point of this line.
census.push((id, source_kit_playing(&out, emitter, id)));
}
// The pool's one observable. "Which doodad ambiences are sounding, and how many are being held
// back by the cap" is invisible in the audio itself — five ids in range and four sounding is
// indistinguishable from four in range unless something says so — and it is precisely the
// question a retest of the cap has to answer. Logged on the **edge**, so a parked camera
// prints one line, not one per frame.
if census != pool.last_census {
let entitled = pool.entries.iter().filter(|x| x.entitled()).count();
let named: Vec<String> = census
.iter()
.map(|(id, live)| {
let name = kit_name(&kits, *id).unwrap_or("?");
// A kit past its own `DistanceCutoff` is admitted, holds its cap slot, and is
// inaudible. Saying "sounding" of it would be the observable lying about the one
// thing it exists to report.
format!(
"{id} ({name}){}",
if *live { "" } else { " [out of range]" }
)
})
.collect();
debug!(
"doodad pool: admitted [{}] — {} sounding, {entitled} entitled entr{}, {} withheld by \
the cap, {} fading",
named.join(", "),
census.iter().filter(|(_, live)| *live).count(),
if entitled == 1 { "y" } else { "ies" },
entitled.saturating_sub(census.len()),
pool.fading.len(),
);
pool.last_census = census;
}
}
/// Register `doodad`'s emitter for kit `id` at `pos` — `$DSL`'s handler, called from
/// [`super::anim_events`].
pub(super) fn register(
pool: &mut DoodadEmitterPool,
doodad: Entity,
id: u32,
pos: Vec3,
listener: Vec3,
) {
pool.register(doodad, id, pos, listener);
}
/// Release `doodad`'s emitter — `$DSE`'s handler.
pub(super) fn release(pool: &mut DoodadEmitterPool, doodad: Entity) {
pool.release(doodad);
}
/// Release the emitters of doodads whose host has gone (`0x7133a0`'s teardown leg, §9).
///
/// This replaces B345's channel-scoped reaper. A doodad no longer *owns* a channel, so stopping
/// "its" channel is the wrong verb: what a despawn retires is one **record**, and the sound only
/// stops when the last emitter of that id in the world is gone. Streaming out one of thirty lamps
/// must not silence the other twenty-nine.
fn release_doodad_emitters_on_despawn(
mut gone: RemovedComponents<benilla_world::doodad_anim::DoodadAnimHost>,
mut pool: ResMut<DoodadEmitterPool>,
) {
for entity in gone.read() {
pool.release(entity);
}
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<DoodadEmitterPool>().add_systems(
Update,
(
// Registration (`$DSL`/`$DSE` in `anim_events`) and the despawn release both write the
// pool; the pump reads it and starts channels; the frame pump then applies this
// frame's positions and gains. Ordered explicitly rather than left to the scheduler,
// because an arbitrary order costs a frame of latency on a stop.
release_doodad_emitters_on_despawn.before(pump_doodad_emitters),
pump_doodad_emitters
.after(super::anim_events::route_anim_events)
.before(kit::pump_channels),
)
.in_set(WorldStage::Present),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// Entities are only identities here — the pool never looks one up.
fn doodads(n: u32) -> Vec<Entity> {
(0..n)
.map(Entity::from_raw_u32)
.map(Option::unwrap)
.collect()
}
fn pool_with(entries: &[(u32, usize)]) -> DoodadEmitterPool {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(entries.len() as u32 * 2);
for (i, (id, records)) in entries.iter().enumerate() {
for r in 0..*records {
pool.entries[i].records.push(Record {
doodad: ds[r % ds.len()],
pos: Vec3::ZERO,
});
}
pool.entries[i].id = *id;
}
pool
}
/// The structural dedupe: N doodads naming one kit share ONE entry, which is what makes the
/// pump run one channel for the lot.
#[test]
fn every_doodad_naming_one_kit_shares_a_single_entry() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(30);
for (i, d) in ds.iter().enumerate() {
pool.register(*d, 3378, Vec3::new(i as f32, 0.0, 0.0), Vec3::ZERO);
}
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
assert_eq!(pool.entries[0].records.len(), 30);
// …and its channel goes to the nearest of the thirty, not to whichever fired last.
assert_eq!(
pool.entries[0]
.nearest(Vec3::new(29.0, 0.0, 0.0))
.unwrap()
.x,
29.0
);
assert_eq!(pool.entries[0].nearest(Vec3::ZERO).unwrap().x, 0.0);
}
/// `0x461cf6` discards a candidate that is not *strictly* closer.
#[test]
fn a_tie_for_nearest_keeps_the_first_record() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(2);
pool.register(ds[0], 7, Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO);
pool.register(ds[1], 7, Vec3::new(0.0, 0.0, -5.0), Vec3::ZERO);
assert_eq!(pool.entries[0].nearest(Vec3::ZERO).unwrap().z, 5.0);
}
/// Re-crossing the marker with the same id REPOSITIONS (`0x462000`); it never takes a second
/// record and never releases. This is the no-wrap-retrigger property, in the data.
#[test]
fn re_firing_the_same_id_only_moves_the_record() {
let mut pool = DoodadEmitterPool::default();
let d = doodads(1)[0];
pool.register(d, 3378, Vec3::ZERO, Vec3::ZERO);
pool.register(d, 3378, Vec3::new(1.0, 2.0, 3.0), Vec3::ZERO);
assert_eq!(pool.entries[0].records.len(), 1);
assert_eq!(pool.entries[0].records[0].pos, Vec3::new(1.0, 2.0, 3.0));
}
/// `bellows.m2`: two `$DSL` keys on one looping sequence alternate through the doodad's single
/// handle, rather than droning together.
#[test]
fn a_different_id_moves_the_doodads_one_registration() {
let mut pool = DoodadEmitterPool::default();
let d = doodads(1)[0];
pool.register(d, 1000, Vec3::ZERO, Vec3::ZERO);
pool.register(d, 2000, Vec3::ZERO, Vec3::ZERO);
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
assert_eq!(pool.entries.iter().find(|e| e.id != 0).unwrap().id, 2000);
}
/// Releasing the last record frees the entry (`0x461d20`) so another id can claim it.
#[test]
fn the_last_release_frees_the_entry() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(2);
pool.register(ds[0], 42, Vec3::ZERO, Vec3::ZERO);
pool.register(ds[1], 42, Vec3::ZERO, Vec3::ZERO);
pool.release(ds[0]);
assert_eq!(pool.entries[0].id, 42, "one emitter left: the id stays");
pool.release(ds[1]);
assert_eq!(pool.entries[0].id, 0);
assert!(pool.handles.is_empty());
}
/// §17: a 33rd distinct id is UNTRACKED — the registration fails outright rather than evicting
/// an incumbent. `$DSL` re-fires every cycle, so it takes an entry the moment one frees.
#[test]
fn a_thirty_third_distinct_id_is_untracked_until_an_entry_frees() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(POOL_ENTRIES as u32 + 1);
for (i, d) in ds.iter().enumerate() {
pool.register(*d, 100 + i as u32, Vec3::ZERO, Vec3::ZERO);
}
assert_eq!(pool.handles.len(), POOL_ENTRIES);
let late = ds[POOL_ENTRIES];
assert!(!pool.handles.contains_key(&late));
pool.release(ds[0]);
pool.register(late, 100 + POOL_ENTRIES as u32, Vec3::ZERO, Vec3::ZERO);
assert_eq!(pool.entries[0].id, 100 + POOL_ENTRIES as u32);
}
/// `0x461a60` — first-farther-than-the-newcomer, in claim order. Not an LRU, and not the
/// farthest: the FIRST record farther than the incoming one goes.
#[test]
fn the_two_hundred_and_fifty_seventh_record_evicts_the_first_farther_one() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(RECORDS_PER_ENTRY as u32 + 1);
// Records 0..255 at x = 255 down to 0: record 0 is the farthest, record 255 the nearest.
for (i, d) in ds.iter().take(RECORDS_PER_ENTRY).enumerate() {
let x = (RECORDS_PER_ENTRY - 1 - i) as f32;
pool.register(*d, 9, Vec3::new(x, 0.0, 0.0), Vec3::ZERO);
}
assert_eq!(pool.entries[0].records.len(), RECORDS_PER_ENTRY);
// A newcomer at x = 10 is nearer than record 0 (x = 255), so record 0 — the first one
// farther than it — is the victim, and the newcomer takes the tail slot.
let late = ds[RECORDS_PER_ENTRY];
pool.register(late, 9, Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO);
assert_eq!(pool.entries[0].records.len(), RECORDS_PER_ENTRY);
assert!(
!pool.handles.contains_key(&ds[0]),
"the evicted one loses its handle"
);
assert_eq!(
pool.entries[0].records[0].pos.x,
(RECORDS_PER_ENTRY - 2) as f32
);
assert_eq!(pool.entries[0].records.last().unwrap().pos.x, 10.0);
}
/// …and one farther than every incumbent is REJECTED, not swapped in.
#[test]
fn a_record_farther_than_all_of_them_is_rejected() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(RECORDS_PER_ENTRY as u32 + 1);
for d in ds.iter().take(RECORDS_PER_ENTRY) {
pool.register(*d, 9, Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO);
}
let late = ds[RECORDS_PER_ENTRY];
pool.register(late, 9, Vec3::new(500.0, 0.0, 0.0), Vec3::ZERO);
assert_eq!(pool.entries[0].records.len(), RECORDS_PER_ENTRY);
assert!(!pool.handles.contains_key(&late));
}
/// The cap picks by ENTRY INDEX — claim order — and stops at four.
#[test]
fn the_cap_admits_the_first_four_entitled_entries_by_index() {
let pool = pool_with(&[(10, 1), (20, 1), (30, 1), (40, 1), (50, 1), (60, 1)]);
assert_eq!(admitted(&pool.entries), vec![0, 1, 2, 3]);
}
/// Free, emitter-less and failed entries are not entitled and cost the cap nothing — the
/// entry after them moves up.
#[test]
fn unentitled_entries_do_not_consume_a_cap_slot() {
let mut pool = pool_with(&[(10, 1), (0, 0), (30, 0), (40, 1), (50, 1), (60, 1), (70, 1)]);
pool.entries[3].failed = true;
assert_eq!(admitted(&pool.entries), vec![0, 4, 5, 6]);
}
/// The falsifiable half: distance does NOT arbitrate. A fifth id right on top of the listener
/// stays silent behind four claimed earlier and far away.
#[test]
fn distance_never_promotes_a_fifth_entry_over_an_earlier_one() {
let mut pool = DoodadEmitterPool::default();
let ds = doodads(5);
for (i, d) in ds.iter().enumerate().take(4) {
pool.register(*d, 100 + i as u32, Vec3::new(400.0, 0.0, 0.0), Vec3::ZERO);
}
pool.register(ds[4], 999, Vec3::ZERO, Vec3::ZERO);
let admitted = admitted(&pool.entries);
assert_eq!(admitted, vec![0, 1, 2, 3]);
assert!(
!admitted.contains(&4),
"the near fifth is held back by claim order"
);
}
}
+78 -5
View File
@@ -308,6 +308,21 @@ const BUS_CAP: [u32; 13] = [0x7fff_ffff, 1, 2, 2, 1, 1, 2, 2, 1, 6, 4, 1, 2];
/// for why this is ours to add rather than a fidelity port.
const SAME_KIT_MAX: usize = 2;
/// The reference's own per-id suppressor: kit flag `0x20` set AND a same-kit instance still
/// audible (`0x458f40` lifts `SoundEntries +0x7c` bit 0x20 into the FMOD flags word, consumed by
/// the pre-play gate `0x7a66a0`). Named rather than inlined so the tests read the *gate*, not a
/// copy of it.
fn no_duplicates_blocks(dedupe_exempt: bool, flags: u32, live_same_kit: usize) -> bool {
!dedupe_exempt && flags & sound_kit_flags::NO_DUPLICATES != 0 && live_same_kit > 0
}
/// [`SAME_KIT_MAX`], decision 1560's looser fallback for the 1 847 rows the reference left
/// ungated. Both suppressors belong to the **one-shot lane**; see [`PlayExtras::dedupe_exempt`]
/// for the caller that is exempt from them and why.
fn same_kit_cap_blocks(dedupe_exempt: bool, live_same_kit: usize) -> bool {
!dedupe_exempt && live_same_kit >= SAME_KIT_MAX
}
/// The reference's **global voice ceiling** — the number this whole hunt came down to.
///
/// `FSOUND_Init(44100, 12, 0x82)` (wow-re `sound/scratch/voice-cap-and-headroom.md` §5, VERIFIED
@@ -467,6 +482,19 @@ pub(super) struct PlayExtras {
/// Loop regardless of the kit's own 0x200 flag, for the drivers whose *column* is the loop
/// authority (the creature body-loop; see [`play_kit_ext`]'s own note).
pub(super) force_loop: bool,
/// **Skip the per-id duplicate suppressors** — the kit's own `NO_DUPLICATES` flag and
/// [`SAME_KIT_MAX`]. Those are the **one-shot lane's** gate (`0x458f40` lifting `SoundEntries`
/// bit 0x20 into the FMOD flags word, consumed by `0x7a66a0`), and a caller that already
/// guarantees one channel per kit by construction must not be held to it a second time. The
/// ambient emitter pool ([`super::doodad_pool`]) is that caller: it dedupes **structurally**,
/// one entry per SoundEntries id (wow-re `doodad-sound-emitters.md` §15), and its opens go
/// through `0x7a5680` → `0x7a54d0`, which never reaches that gate at all.
///
/// It is not a nicety. `NightElfStreetLampLoop` is Flags **0x220**, so with the gate applied a
/// pool entry could not replace its own 3.0 s fade-out — the lamp's hum would drop out for
/// three seconds every time its entry was re-admitted past the cap, and 2 776 of the 4 623
/// shipped rows carry the bit.
pub(super) dedupe_exempt: bool,
/// Which per-unit latch this play takes, if any ([`Latch`]). Defaults to [`Latch::None`] —
/// tagging a `source` is about ownership, and taking a latch has to be asked for.
pub(super) latch: Latch,
@@ -553,6 +581,7 @@ pub(super) fn play_kit_ext(
variant,
source,
force_loop,
dedupe_exempt,
latch,
bus,
volume_mult: Volume(mult),
@@ -609,7 +638,8 @@ pub(super) fn play_kit_ext(
// still audible (0x458f40 lifts SoundEntries +0x7c bit 0x20 into the FMOD flags word). The
// gate's OTHER arm — an always-on per-category concurrent cap (count[0xcf553c] >=
// limit[0x87ce60], 13 categories) — stays a deferral until that limit table is read out.
if flags & sound_kit_flags::NO_DUPLICATES != 0 && out.channels.iter().any(|c| c.kit == id) {
let live_same_kit = out.channels.iter().filter(|c| c.kit == id).count();
if no_duplicates_blocks(dedupe_exempt, flags, live_same_kit) {
return Ok(());
}
@@ -630,7 +660,7 @@ pub(super) fn play_kit_ext(
// reference's own idea applied, more loosely, where the data left a hole. Two rather than one
// deliberately: it keeps some sense that several things happened (and cannot silence two
// genuinely distinct mobs' impacts), while removing the coherent stack that does the damage.
if out.channels.iter().filter(|c| c.kit == id).count() >= SAME_KIT_MAX {
if same_kit_cap_blocks(dedupe_exempt, live_same_kit) {
out.copies_dropped += 1;
return Ok(());
}
@@ -806,6 +836,13 @@ pub(super) fn source_kit_playing(out: &SoundOutput, source: Entity, kit_id: u32)
.any(|c| c.source == Some(source) && c.kit == kit_id)
}
/// The kit's catalog name, or `None` when the id resolves to no `SoundEntries` row — the
/// reference's `0x45cda0(id)` null test, which is what permanently fails a doodad emitter pool
/// entry (`[+0xE00] = -id`). The pool is outside this module, and the catalog is not.
pub(super) fn kit_name(kits: &SoundKits, id: u32) -> Option<&str> {
kits.catalog.get(id).map(|k| k.name.as_str())
}
/// Whether kit `id` is a LOOPING kit (`SoundEntries` flag 0x200) — the client's `0x458830` test
/// that splits tracked-loop playback from fire-and-forget one-shots (decision 0107).
pub(super) fn kit_looping(kits: &SoundKits, id: u32) -> bool {
@@ -842,7 +879,11 @@ pub(super) fn stop_source_kit(out: &mut SoundOutput, source: Entity, kit_id: u32
/// Force-stop every channel tagged with `source` — the unit-teardown stop (`0x5fbb6c`: the client
/// stops + clears the greeting handle when the unit's record is torn down). Called on despawn.
pub(super) fn stop_source(out: &mut SoundOutput, source: Entity) {
/// Returns **how many channels it actually stopped** — the caller's only way to tell "this source
/// held a loop and we reaped it" from "this source was silent", which is the pair a streaming
/// retest has to distinguish (the doodad reaper in [`super::anim_events`]).
pub(super) fn stop_source(out: &mut SoundOutput, source: Entity) -> usize {
let before = out.channels.len();
out.channels.retain_mut(|c| {
if c.source == Some(source) {
c.handle.stop(mixer::declick());
@@ -851,6 +892,7 @@ pub(super) fn stop_source(out: &mut SoundOutput, source: Entity) {
true
}
});
before - out.channels.len()
}
/// The `PlaySoundFile` path — a raw file play, no kit: no audibility/duplicate gates, no
@@ -1442,8 +1484,10 @@ mod tests {
/// [`SAME_KIT_MAX`] however many targets the buff had.
#[test]
fn a_mass_buff_collapses_to_the_same_kit_cap() {
// What the gate sees: N already-live copies of kit 3116, asked for one more.
let live_copies = |n: usize| n >= SAME_KIT_MAX;
// What the gate sees: N already-live copies of kit 3116, asked for one more. The real
// predicate, not a restatement of it — a mirrored copy here would keep passing after the
// gate itself changed.
let live_copies = |n: usize| same_kit_cap_blocks(false, n);
assert!(!live_copies(0), "the first copy always plays");
assert!(
!live_copies(1),
@@ -1458,6 +1502,35 @@ mod tests {
}
}
/// **The lane split.** Both suppressors are the ONE-SHOT lane's (`0x458f40` → `0x7a66a0`);
/// the ambient emitter pool's opens go through `0x7a5680` → `0x7a54d0` and never reach them
/// (wow-re `doodad-sound-emitters.md` §15), because that lane already guarantees one channel
/// per SoundEntries id structurally.
///
/// `NightElfStreetLampLoop` is the case that proves it matters rather than tidies: Flags
/// **0x220**, so an un-exempt pool entry could not replace its own 3.0 s fade-out — the lamp's
/// hum would drop out for three seconds every time its entry came back under the cap.
#[test]
fn the_emitter_pool_lane_is_exempt_from_the_one_shot_suppressors() {
const LAMP: u32 = 0x220;
assert!(
no_duplicates_blocks(false, LAMP, 1),
"the one-shot lane still honours the reference's own 0x20"
);
assert!(
!no_duplicates_blocks(true, LAMP, 1),
"a pool entry must be able to replace its own fading channel"
);
assert!(
!same_kit_cap_blocks(true, 8),
"…and the coherent-copy fallback is the same lane's, so it is exempt too"
);
assert!(
same_kit_cap_blocks(false, SAME_KIT_MAX),
"the exemption is scoped to the caller that asks for it, not global"
);
}
/// The looser cap only exists for rows the reference left ungated. A row carrying flag 0x20 is
/// still capped at **one** by the byte-verified gate above it, and this must not loosen that.
#[test]
+3 -1
View File
@@ -61,7 +61,9 @@ fn route_missile_sounds(
}
}
// The missile carries only this one channel — stop everything tagged to it.
MissileSound::Stop { entity } => stop_source(&mut out, entity),
MissileSound::Stop { entity } => {
stop_source(&mut out, entity);
}
}
}
}
+2
View File
@@ -18,6 +18,7 @@ mod anim_events;
mod cinematic;
mod combat;
mod creature;
mod doodad_pool;
mod emote;
pub(crate) mod footsteps;
mod gameobject;
@@ -458,6 +459,7 @@ impl Plugin for SoundPlugin {
cinematic::plugin(app);
gameobject::plugin(app);
anim_events::plugin(app);
doodad_pool::plugin(app);
spell::plugin(app);
missile::plugin(app);
creature::plugin(app);
+8 -7
View File
@@ -46,12 +46,6 @@ const RETICLE_DEFAULT_RADIUS: f32 = 1.388_889;
/// The projection box's vertical slab: ± 2.0 around the picked point (`0x4837b0` — fixed, not
/// radius-scaled like the ring's).
const RETICLE_VERT: f32 = 2.0;
/// Same coplanarity treatment as the ring (`sky_order::Rung::GROUND_FX` rationale) — the
/// *rasterizer* half only. The reticle's sort rung is `Rung::RETICLE`, in the pre-water decal
/// band: the reference draws this pass at `0x4836c5` with flags `0x200122`, before the water
/// (B347). Its liquid-receiver twin at `0x483727` is the half that draws after the water, and it
/// has no counterpart here until liquid becomes a receiving surface.
const RETICLE_RASTER_BIAS: i32 = 8192;
/// The two state textures (the render-side residency gate withholds the draw until loaded).
#[derive(Resource)]
@@ -197,7 +191,14 @@ pub(super) fn push_reticle(
let mut batch = draw
.batch(cam, texture)
.anchored(state.key.center)
.rung(benilla_world::sky_order::Rung::RETICLE, RETICLE_RASTER_BIAS);
// Sort rung from the pre-water decal band — the reference draws this pass at `0x4836c5`
// with flags `0x200122`, before the water (B347); its liquid-receiver twin at `0x483727`
// is the half that draws after it, and has no counterpart here until liquid becomes a
// receiving surface. Raster margin from the family's shared coplanarity constant.
.rung(
benilla_world::sky_order::Rung::RETICLE,
benilla_world::sky_order::Rung::DECAL_RASTER,
);
batch.extend(state.verts.iter().map(|v| EffectVertex {
pos: v.pos,
uv: v.uv,
+1 -1
View File
@@ -508,7 +508,7 @@ pub(super) fn push_ring(
.anchored(state.key.feet)
.rung(
benilla_world::sky_order::Rung::RING,
benilla_world::sky_order::Rung::RING as i32,
benilla_world::sky_order::Rung::DECAL_RASTER,
);
batch.extend(state.verts.iter().map(|v| EffectVertex {
pos: v.pos,
+19 -9
View File
@@ -145,6 +145,12 @@ struct LiquidVsOut {
// colour — the reference's own interior water vertex carries a colour dword for exactly this.
// White on every other lane (and on any mesh with no colour attribute), where nothing reads it.
@location(5) vcolor: vec4<f32>,
// The per-frame INTERIOR FOG lane for this surface's own room (decision 1787): the client's
// `[0xca7f00]`, which gates the WMO liquid pass's block-2 submit (`0x6b6323`–`0x6b6342`)
// exactly as it gates the geometry pass — so a pool and the walls around it can never
// disagree about which fog they wear. Carried on `MeshTag` bit 30, read once in the vertex
// stage and flat-interpolated (the whole surface is one instance).
@location(6) @interpolate(flat) room_fog: u32,
}
// Sun sheen (`secondary`): a Blinn highlight of the sun on the flat water surface — the glint that's
@@ -233,13 +239,14 @@ fn apply_scroll(uv: vec2<f32>) -> vec2<f32> {
// interior haze. Only two call sites in the whole binary re-submit block 2, and they are the WMO
// *geometry* pass (`0x6b51d9`/`0x6b51ea`) and the WMO *liquid* pass (`0x6b6323`–`0x6b6342`), both under
// the same `[0xca7f00]` gate. So an interior room's pool takes the room's fog, in lockstep with the
// walls around it; ADT liquid submits nothing and draws under the scene block. `w.kind.z` is that
// gate, resolved at spawn from the group's `MOGI & 0x48` — the same interior test `wow_model.wgsl`
// uses, which is what keeps the two in step.
fn apply_fog(rgb: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
// walls around it; ADT liquid submits nothing and draws under the scene block. `w.kind.z` is the STATIC
// half of that gate, resolved at spawn from the group's `MOGI & 0x48`; the per-frame half is the
// room's own `[0xca7f00]` bit on `MeshTag` bit 30 (decision 1787 — the flood decides it, and the
// WMO geometry lane reads the same answer, which is what keeps the two in step).
fn apply_fog(rgb: vec3<f32>, world_pos: vec3<f32>, room_fog: u32) -> vec3<f32> {
var fog_color = wow_light.fog_color;
var fog_span = wow_light.fog_params.xy;
if (w.kind.z > 0.5) {
if (w.kind.z > 0.5 && room_fog != 0u) {
fog_color = wow_light.wmo_fog_color;
fog_span = wow_light.wmo_fog_params.xy;
}
@@ -270,6 +277,9 @@ fn vertex(in: Vertex) -> LiquidVsOut {
out.depth = in.uv_b.x;
// The faithful per-vertex sun sheen — interpolated across the coarse mesh by the fragment stage.
out.secondary_vtx = sun_sheen(out.world_normal, out.world_position.xyz);
// `MeshTag` bit 30 — see `LiquidVsOut::room_fog`. ADT surfaces carry no tag (0 ⇒ scene fog,
// which is their only lane anyway).
out.room_fog = mesh_functions::get_tag(in.instance_index) & 0x40000000u;
return out;
}
@@ -309,7 +319,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// depth instead of one that recedes into the murk. (VERIFIED wow-re `liquid-render-state-sided`
// §3/§3.1/§5, which corrects that row.)
if (w.kind.x > 0.5) {
return vec4<f32>(apply_fog(detail.rgb, in.world_position.xyz), 1.0);
return vec4<f32>(apply_fog(detail.rgb, in.world_position.xyz, in.room_fog), 1.0);
}
// Per-vertex swatch coord V (in `in.depth`, computed CPU-side in wow-formats/liquid.rs): river/lake
@@ -362,7 +372,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// reading would have drawn.
let body = clamp(in.vcolor.rgb + detail.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
return vec4<f32>(
apply_fog(body, in.world_position.xyz),
apply_fog(body, in.world_position.xyz, in.room_fog),
clamp(vtx_alpha + detail.a, 0.0, 1.0),
);
}
@@ -414,7 +424,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
let rgb_ext = primary_ext + detail.rgb + in.secondary_vtx * detail.a;
// `result.color.w = fragment.color.primary` — the vertex alpha ALONE. The bound program
// bypasses the texture environment entirely, so the interior arm's `+ At` does not apply here.
return vec4<f32>(apply_fog(rgb_ext, in.world_position.xyz), vtx_alpha);
return vec4<f32>(apply_fog(rgb_ext, in.world_position.xyz, in.room_fog), vtx_alpha);
}
// Body colour: lit vertex colour × the depth-lerped water-row swatch colour (`primary·colorTex`).
@@ -447,7 +457,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// Distance fog (see `apply_fog`) — the water fog colour is also teal, so far water converges on the
// haze.
rgb = apply_fog(rgb, in.world_position.xyz);
rgb = apply_fog(rgb, in.world_position.xyz, in.room_fog);
// GAMMA LANE (0161): raw gamma out; alpha blends in gamma like the reference's bytes.
return vec4<f32>(rgb, alpha);
@@ -1088,16 +1088,21 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// — the level-up fix), WHITE for Mod, GREY for Mod2x; policy 4 (render flag 0x02) = unfogged.
// Encoded in clutter_fade.z bits 4-6.
// Interior lanes fog with the INTERIOR triple — the room keeps its warm MFOG haze while the
// storm's veil stays on everything seen through the door. Two routes in: the MATERIAL flag
// (WMO interior-group batches and their doodad props, m.model_flags.z — round-6 Q-I:
// `0x6b5190`/`0x6b62e0` push DNState+0x80/84/88) and the per-INSTANCE tag bit 30 (an entity
// M2 whose model stands in a WMO interior — the reference stages a unit's fog by the unit's
// own light-node classification, `0x71c110`/`[node+0xc]`, wow-re m2-unit-interior-fog.md;
// at camera-out the triples are equal, so the bit only diverges inside a fogged WMO).
// Every other lane inherits the scene fog.
// storm's veil stays on everything seen through the door. ONE route in, the per-INSTANCE tag
// bit 30, written by two disjoint owners for the two mechanisms the reference has:
// * room-bound WMO content (group geometry, its doodad props) takes the client's per-group
// `[0xca7f00]` gate on the two interior-fog pushes `0x6b5190`/`0x6b62e0` (round-6 Q-I),
// resolved per frame by the portal flood (`wmo_portal::GroupPvs::interior_fog`);
// * an entity M2 takes its OWN light-node classification (`0x71c110`/`[node+0xc]`, wow-re
// m2-unit-interior-fog.md), which is a different gate on the same triple.
// The material's own `model_flags.z` is NOT that test: it is static per batch, so before
// decision 1787 every true-interior group in a building wore the building's MFOG the moment
// the camera stood anywhere inside it — B335, where the Shadowfang room two exterior-lit
// courtyards away read as a flat teal wash at 70 yd. At camera-out the two triples are equal,
// so the bit only ever diverges inside a fogged WMO. Every other lane inherits the scene fog.
var fog_color = wow_light.fog_color;
var fog_span = wow_light.fog_params.xy;
if (m.model_flags.z > 0.5 || interior_fogged) {
if (interior_fogged) {
fog_color = wow_light.wmo_fog_color;
fog_span = wow_light.wmo_fog_params.xy;
}
@@ -486,6 +486,17 @@ enum Command {
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose
/// sequences carry a `$DSL` (doodad sound loop) / `$DSO` (doodad sound one-shot) / `$SND`
/// (generic one-shot) marker, the `SoundEntries` kit each names with its 3D parameters, and —
/// the column this exists for — whether the carrying sequence is REST-posed, i.e. one the
/// render content gate (decision 0130) never builds a rig for. A placed lamp's hum is a single
/// `$DSL` on a sequence that keys no bone at all, so the whole class is unreachable through an
/// `AnimationPlayer`: the population instrument for "every world doodad is silent" (B345).
Soundeventscan {
/// Internal-path prefix to limit the sweep (e.g. `world`); all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE
/// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the
/// population instrument for particle-flag mechanisms (which content actually authors
@@ -852,6 +863,7 @@ fn main() -> Result<()> {
Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?,
Command::Shardcensus { prefix } => scan::shardcensus(&mut chain, prefix.as_deref())?,
Command::Idleslotscan { prefix } => scan::idleslotscan(&mut chain, prefix.as_deref())?,
Command::Soundeventscan { prefix } => scan::soundeventscan(&mut chain, prefix.as_deref())?,
Command::Partscan { mask, prefix } => {
let mask = parse_u32_maybe_hex(&mask)
.with_context(|| format!("parsing flag mask '{mask}'"))?;
@@ -11,7 +11,8 @@
//! the batches whose UV/tint loop differs between sequence slots.
//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors.
//! - [`skeleton`] — the bone tree and the attachment table that addresses it.
//! - [`sequence`] — which sequence an arm plays, and what breaks when it is the wrong one.
//! - [`sequence`] — which sequence an arm plays, what breaks when it is the wrong one, and
//! which of its event markers an arm can reach at all.
//!
//! Split out of a single 3.9k-line `scan.rs` once it had grown ~30 independent sweeps: the file
//! was one *kind* of thing, but not one concern, and nothing in it was shared across families
@@ -34,7 +35,7 @@ pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uv
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
};
pub use sequence::{fxlifescan, goanimscan, idleslotscan, seqclockscan};
pub use sequence::{fxlifescan, goanimscan, idleslotscan, seqclockscan, soundeventscan};
pub use skeleton::{attachscan, bonescan};
pub use world::{doodadscan, placescan, skyboxscan, wmodoodads};
@@ -4,6 +4,10 @@
//! The GameObject substate arm (`goanimscan`), the effect-model Stand/Hold/Decay lifecycle
//! (`fxlifescan`), the loader-idle slot the per-sequence bakes degrade away from (`idleslotscan`),
//! and the models with no keyed bone at all, so no clock ever advances (`seqclockscan`).
//!
//! Plus the arm's *reach*: which models author a sound-event marker the rig gate never arms
//! (`soundeventscan`) — the same failure one level down, where the sequence is right and nothing
//! runs its clock.
use std::collections::{BTreeMap, HashSet};
@@ -597,3 +601,147 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
);
Ok(())
}
/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose
/// sequences carry a `$DSL` (doodad sound loop) / `$DSO` (doodad sound one-shot) / `$SND` (generic
/// one-shot) event marker, the `SoundEntries` kit each names, and — the column this exists for —
/// whether the carrying sequence is **rest-posed**.
///
/// Why that column decides everything: `benilla_assets`' render content gate
/// (`idle_pose_differs` → [`benilla_formats::ModelAnimation::is_rest_pose`], decision 0130) skips
/// building a rig for a sequence that would render as the static mesh, and the whole point of
/// that gate is that it is a question **about pixels only**. A placed lamp's Stand band keys no
/// bone at all — it is pure rest pose — yet it carries the one `$DSL` marker that is its hum. So
/// every model in the `REST` column is one whose sound the arm can never reach through a rig: its
/// events need a clock that does not depend on there being anything to animate.
///
/// Reports the per-model rows, then a tag/kit histogram and the `REST`-gated share.
pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
const SOUND_TAGS: [&[u8; 4]; 3] = [b"$DSL", b"$DSO", b"$SND"];
let kits = benilla_formats::load_sound_kit_catalog(chain).ok();
let names = super::m2_names(chain, prefix)?;
let (mut scanned, mut carriers, mut rest_gated, mut hostless) = (0u32, 0u32, 0u32, 0u32);
let mut per_tier: BTreeMap<&str, u32> = BTreeMap::new();
let mut per_tag: BTreeMap<String, (u32, u32)> = BTreeMap::new(); // tag -> (markers, rest-gated)
let mut per_kit: BTreeMap<u32, u32> = BTreeMap::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let anims = benilla_formats::parse_m2_animations(&bytes);
if anims.is_empty() {
continue;
}
scanned += 1;
// The spawn tier `benilla_world::doodad_anim::classify` would give this model, from the
// same three inputs it reads: a boneless model is `Static` outright (the 0035 guard);
// otherwise the 0130 content gate picks `FirstSeq` when the loader-idle clip's pose
// differs from bind, else `GlobalSeqOnly` when free-running channels exist, else
// `Static`. `is_rest_pose` is the gate's own predicate (decision 0936 pushed it down
// beside the parse so this census cannot drift from it); the tier matters here because
// only the two animated tiers get an anim-root ENTITY at all — a `Static` carrier has
// nothing but loose submeshes to hang an emitter on.
let bones = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes))
.map_or(0, |f| f.model().bones.len());
let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes)
.unwrap_or_default()
.first()
.map_or(0, |p| p.resolved_id);
let gseq = !benilla_formats::parse_m2_global_sequence_bones(&bytes).is_empty();
let tier = if bones == 0 {
"Static"
} else if anims
.iter()
.any(|a| a.anim_id == idle_id && !a.is_rest_pose())
{
"FirstSeq"
} else if gseq {
"GlobalSeq"
} else {
"Static"
};
let mut rows = Vec::new();
let mut model_rest_gated = false;
for a in &anims {
let marks: Vec<_> = a
.events
.iter()
.filter(|e| SOUND_TAGS.contains(&&e.ident))
.collect();
if marks.is_empty() {
continue;
}
// The gate is asked of the sequence that CARRIES the marker: that is the one whose
// clock has to run for the marker to be reached at all.
let rest = a.is_rest_pose();
model_rest_gated |= rest;
for e in &marks {
let tag = String::from_utf8_lossy(&e.ident).into_owned();
let slot = per_tag.entry(tag).or_default();
slot.0 += 1;
slot.1 += u32::from(rest);
*per_kit.entry(e.data).or_default() += 1;
}
let list: Vec<String> = marks
.iter()
.map(|e| {
let tag = String::from_utf8_lossy(&e.ident);
let kit = kits
.as_ref()
.and_then(|c| c.get(e.data))
.map_or_else(|| "?".to_string(), |k| k.name.clone());
format!("{:.3}s {tag}({}) {kit}", e.time, e.data)
})
.collect();
rows.push(format!(
" seq {:>2} anim {:>3} {:<5} {:>6.3}s {} {}",
a.seq_index,
a.anim_id,
if a.looping { "loop" } else { "clamp" },
a.duration,
if rest { "REST" } else { "rig " },
list.join(", "),
));
}
if rows.is_empty() {
continue;
}
carriers += 1;
rest_gated += u32::from(model_rest_gated);
*per_tier.entry(tier).or_default() += 1;
if tier == "Static" {
hostless += 1;
}
println!("{name} [{tier}]");
for r in rows {
println!("{r}");
}
}
println!("\n=== tag histogram (markers, and how many sit on a REST-posed sequence) ===");
for (tag, (n, rest)) in &per_tag {
println!(" {tag} {n:>5} marker(s), {rest:>5} on a REST-posed sequence");
}
println!("\n=== distinct sound kits named ({}) ===", per_kit.len());
for (id, n) in &per_kit {
let k = kits.as_ref().and_then(|c| c.get(*id));
println!(
" {id:>6} x{n:<4} {:<40} vol {:>4.2} minDist {:>7.2} cutoff {:>8.2} flags 0x{:03x}",
k.map_or("MISSING", |k| k.name.as_str()),
k.map_or(0.0, |k| k.volume),
k.map_or(0.0, |k| k.min_distance),
k.map_or(0.0, |k| k.distance_cutoff),
k.map_or(0, |k| k.flags),
);
}
println!("\n=== spawn tier of the carriers ===");
for (t, n) in &per_tier {
println!(" {t:<10} {n:>4} model(s)");
}
eprintln!(
"{scanned} models with sequences scanned; {carriers} carry a $DSL/$DSO/$SND marker, \
{hostless} of them on the Static tier (no anim-root entity exists to hang an emitter \
on today); {rest_gated} carry the marker on a REST-posed sequence — the class benilla's rig gate \
(decision 0130) never arms, so the marker is unreachable through an AnimationPlayer."
);
Ok(())
}
+87
View File
@@ -1422,3 +1422,90 @@ mod tests {
);
}
}
#[cfg(test)]
mod doodad_sound_tests {
/// **The humming lamp, off the real 5875 chain** — bug B345's diagnosis, pinned as data.
///
/// `KalidarStreetLamp01.m2` is the shape the whole doodad-ambience class takes, and every
/// clause here is a claim the fix rests on:
///
/// * it has **one** sequence, `AnimationData` id 0 (Stand), **looping** — so the marker on it
/// is crossed once per cycle, for ever;
/// * that sequence is **rest-posed** — it keys no bone at all, so decision 0130's render
/// content gate builds it no rig, which is correct about pixels and was never a statement
/// about the clock. Gating the *event* track on that gate is what made the lamp silent;
/// * it carries exactly **one** event, `$DSL` at t = 0.000 with payload **3378** — the
/// `SoundEntries` id of `NightElfStreetLampLoop`.
///
/// If any of these stops holding, the fix's premise has moved and the test should fail loudly
/// rather than let the lamp go quiet again.
#[test]
fn the_lamps_hum_is_one_rest_posed_looping_dsl_key() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("World\\Generic\\NightElf\\Passive Doodads\\Lamps\\KalidarStreetLamp01.m2")
.expect("the lamp is in the chain");
let seqs = super::parse_m2_animations(&bytes);
assert_eq!(seqs.len(), 1, "one authored sequence");
let stand = &seqs[0];
assert_eq!(stand.anim_id, 0, "Stand");
assert!(stand.looping, "the hum's carrier loops");
assert!(
stand.is_rest_pose(),
"keys no bone — the rig gate skips it, and that must not silence it"
);
assert_eq!(stand.events.len(), 1, "exactly one event key");
let ev = &stand.events[0];
assert_eq!(&ev.ident, b"$DSL", "a doodad sound LOOP tag");
assert_eq!(ev.data, 3378, "SoundEntries NightElfStreetLampLoop");
assert!(ev.time.abs() < 1e-6, "keyed at t = 0");
}
/// **The pair that pins the one-handle-per-doodad slot.** `bellows.m2` keys TWO `$DSL` at
/// DIFFERENT times on one looping 2 s sequence — `BellowOut` at t = 0.000 and `BellowIN` at
/// t = 1.100, each about 1.18 s of audio.
///
/// This looked like proof that `$DSL` could not mean "loop" — a pair of one-shots timed to
/// alternate. The RE says otherwise, and the real mechanism is better: a doodad holds exactly
/// ONE registration (`[CMapDoodadDef+0x168]`), and crossing a marker with a DIFFERENT id
/// releases the held one before registering the new (`0x69521d` → `0x461f80`). So both ARE
/// ordinary loops, and the slot is what makes them alternate — the pumping bellows. Every
/// `$DSL` loops and no flag is consulted (wow-re `sound/scratch/doodad-sound-emitters.md`).
///
/// The guard is therefore on the eviction in `sound::anim_events`: without it both start and
/// both drone.
#[test]
fn a_dsl_pair_on_one_cycle_alternates_through_one_slot() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("World\\Generic\\Human\\Passive Doodads\\Bellows\\bellows.m2")
.expect("the bellows is in the chain");
let seqs = super::parse_m2_animations(&bytes);
let carrier = seqs
.iter()
.find(|s| s.events.iter().any(|e| &e.ident == b"$DSL"))
.expect("a sequence carries the pair");
assert!(carrier.looping, "and it loops");
let mut keys: Vec<_> = carrier
.events
.iter()
.filter(|e| &e.ident == b"$DSL")
.map(|e| (e.data, e.time))
.collect();
keys.sort_by(|a, b| a.1.total_cmp(&b.1));
assert_eq!(keys.len(), 2, "a pair sharing one registration slot");
assert!(keys[0].1.abs() < 1e-6, "the first is keyed at t = 0");
assert!(
keys[1].1 > 1.0,
"the second is keyed a second later ({:.3}s) — the eviction is what makes it a pump",
keys[1].1
);
assert_ne!(keys[0].0, keys[1].0, "and they are different kits");
}
}
+131 -2
View File
@@ -100,6 +100,43 @@ pub enum DoodadAnimTier<'a> {
FirstSeq(&'a AnimClip),
}
/// The animation-event tags that make a placed doodad SOUND: `$DSL` (the looping emitter), `$DSO`
/// (a positioned one-shot), `$DSE` (the stop token that releases a `$DSL` — `data` is 0 on all 16
/// shipped models) and the generic `$SND`. Routed by `benilla_app::sound::anim_events`; listed here
/// because their presence is what earns a bind-posed model a clock it would otherwise be denied
/// (see [`arms_for_sound`]). Byte law: wow-re `sound/scratch/doodad-sound-emitters.md`.
pub(crate) const SOUND_EVENT_TAGS: [&[u8; 4]; 4] = [b"$DSL", b"$DSO", b"$DSE", b"$SND"];
/// Does this model's **arm chain** — the loader-idle seed and its variations, the only sequences a
/// placed doodad ever plays — carry a sound-event marker?
///
/// This is deliberately NOT part of [`classify`]: the tier answers "what does this model *render*",
/// and 0130's whole point is that a bind-posed sequence renders as the static mesh. That stays true.
/// What it never meant is that the sequence does not *run* — the reference arms every placed doodad
/// and cycles it on residency (module docs, §1/§5), and the event track rides that clock. A humming
/// lamp is the proof: `KalidarStreetLamp01.m2` is one looping 3.333 s Stand that keys no bone at all
/// and carries exactly one event, `$DSL` → `NightElfStreetLampLoop`. Gating its clock on the *rig*
/// is what made every world doodad silent (bug B345), and the corpus says the class is not marginal
/// — `benilla-extract soundeventscan`: 273 carrier models, 108 of which no rig gate would ever arm.
///
/// Same shape as [`ModelAnimations::idle_clip`]'s own note about emitters and material loops: arming
/// the idle seed is what gives a consumer a running clock instead of a frozen slot-0 read. Sound is
/// simply the third consumer.
pub(crate) fn arms_for_sound(anims: &ModelAnimations) -> bool {
let Some(idle) = anims.idle_clip() else {
return false;
};
anims
.clips
.iter()
.filter(|c| c.anim_id == idle.anim_id)
.any(|c| {
c.events
.iter()
.any(|e| SOUND_EVENT_TAGS.contains(&&e.ident))
})
}
/// Classify a placed model for the spawn site. Boneless models are `Static` regardless of parsed
/// tracks — their skinned twin carries joint attributes with no joints to index (the 0035 guard).
pub fn classify<'a>(
@@ -211,10 +248,21 @@ pub fn spawn_anim_host(
return None;
}
let tier = classify(&m.skeleton, m.animations.as_ref());
if matches!(tier, DoodadAnimTier::Static) {
// The SOUND arm (bug B345), orthogonal to the tier: a model whose pose is bind renders as the
// static mesh — 0130's gate is right about pixels — but its event track still has to run, and
// the reference runs it for every placed doodad. So a `Static`/`GlobalSeqOnly` model that
// authors `$DSL`/`$DSO`/`$SND` on its arm chain still gets a HOST here: `ModelAnimations` and
// the arm bookkeeping (`clip`/`anim_id`, which is what [`reroll_doodad_variation`] maintains and
// the event scanner reads), and deliberately **no `AnimationPlayer`** — there is no pose to
// evaluate, and paying for a rig to run a clock is exactly what 0130 exists to avoid.
let sound_arm = m.animations.as_ref().is_some_and(arms_for_sound);
if matches!(tier, DoodadAnimTier::Static) && !sound_arm {
return None;
}
let anims = m.animations.as_ref().expect("animated tier ⇒ animations");
let anims = m
.animations
.as_ref()
.expect("animated tier or sound arm ⇒ animations");
// Chain-only visibility (`crate::vis_chain`): the host root renders nothing — its parts
// draw as world roots — and ~1.4k hosts sat in the per-camera sweep at the Goldshire pin.
let root = commands
@@ -237,6 +285,7 @@ pub fn spawn_anim_host(
let mut armed_seq = None;
let mut arm_id = None;
if let DoodadAnimTier::FirstSeq(head) = tier {
// (the rigged arm — player + graph + variation chain)
// The **loader's** seed only — `0x70ebd0`'s var-0 arm on the head of the chain. The real
// pick is the holder setup's second op4 call (`0x695100`, `variationIdx = -1`), which lands
// *after* it and is the effective arm (wow-re §4a); here that second arm is
@@ -257,6 +306,20 @@ pub fn spawn_anim_host(
clip_info = Some((head.node, head.duration));
armed_seq = Some(head.seq_index);
arm_id = Some(head.anim_id);
} else if sound_arm {
// The clock-only arm: same loader seed, same self-sustaining re-roll — `ModelAnimations` is
// what [`reroll_doodad_variation`] needs, and without it the host would never be matched by
// that query and its window would never advance. No player, no graph handle: nothing here
// poses anything, and `gate_doodad_anim` already treats both as optional.
let head = anims.idle_clip().expect("sound arm ⇒ an idle clip");
clip_info = Some((head.node, head.duration));
arm_id = Some(head.anim_id);
commands.entity(root).insert(anims.clone());
// `armed_seq` stays `None` ON PURPOSE. It is the *only* input to `PlacementHost::arm`,
// which is what puts this placement's emitters on `EmitClock::Host` — a clock that reads
// the host's `AnimationPlayer`. This host has none, so claiming the arm would move a
// previously-`Pinned` emitter onto a player that does not exist. The sound clock is read
// from `clip`/`armed_at` instead, which needs no player and no slot number.
}
if let Some(drive) = GlobalSeqDrive::new_rig(&anims.global_bones, m.skeleton.joints.len()) {
commands.entity(root).insert(drive);
@@ -309,6 +372,31 @@ pub struct DoodadAnimHost {
pub(crate) parked_at: f32,
}
impl DoodadAnimHost {
/// Where the **armed clip's own clock** stands right now: `(node, seek)`, the seek being
/// `(now − armed_at) mod duration`. `None` when nothing is armed (the gseq-only tier without a
/// sound arm — free-running channels have no window at all).
///
/// The **shared clock, never the `AnimationPlayer`**, and that is the point rather than a
/// convenience. The player is paused by [`gate_doodad_anim`] whenever none of the placement's
/// submeshes is drawn; the reference's animation cycle is gated on **linkage — residency, not
/// the draw** (module docs §5, the same reasoning [`reroll_doodad_variation`] runs over every
/// host for). A campfire behind you keeps crackling in the real client, and reading the paused
/// player here would stop it the moment you turned around. It also gives the two arms one
/// answer: a clock-only host has no player to read, and a rigged one's resume seeks to exactly
/// this value, so the two never disagree while drawn.
pub fn arm_clock(&self, now: f32) -> Option<(AnimationNodeIndex, f32)> {
let (node, duration) = self.clip?;
// A zero-length clip has no phase to compute; it sits at its own t = 0.
let seek = if duration > 0.0 {
(now - self.armed_at).rem_euclid(duration)
} else {
0.0
};
Some((node, seek))
}
}
/// The doodad's self-sustaining re-arm (decision 0768, wow-re `doodad-anim-host.md` §5): when the
/// armed play-window ends, roll a fresh frequency-weighted variation of the same animation id, snap
/// to it, and write the next window. This is the whole of bug B63's residual — the Blasted Lands
@@ -1101,4 +1189,45 @@ mod tests {
active.seek_time()
);
}
/// [`DoodadAnimHost::arm_clock`] — the sound lane's phase, which must be the SHARED clock's,
/// never the (draw-gated) player's. Three claims: it wraps with the clip, it is unaffected by
/// the host being parked, and an unarmed host has no phase at all.
#[test]
fn arm_clock_wraps_on_the_shared_clock() {
let mut host = DoodadAnimHost {
meshes: Vec::new(),
fade: (0.0, Vec3::ZERO),
clip: Some((AnimationNodeIndex::new(1), 3.333)),
armed_at: 10.0,
window_hi: f32::NEG_INFINITY,
anim_id: Some(0),
// PARKED — the whole point: a campfire behind you keeps crackling, because the
// reference gates the cycle on residency, not on the draw.
active: false,
parked_at: 0.0,
};
let (_, seek) = host.arm_clock(10.0).expect("armed");
assert!(seek.abs() < 1e-6, "at the arm it sits at t = 0");
let (_, seek) = host.arm_clock(12.0).expect("armed");
assert!((seek - 2.0).abs() < 1e-4, "2 s in, {seek}");
// Past one full cycle it wraps rather than running off the end.
let (_, seek) = host.arm_clock(14.0).expect("armed");
assert!((seek - (4.0 - 3.333)).abs() < 1e-4, "wrapped to {seek}");
// A zero-length clip has no phase to compute and must not divide by it.
host.clip = Some((AnimationNodeIndex::new(1), 0.0));
let (_, seek) = host.arm_clock(99.0).expect("armed");
assert!(
seek.abs() < 1e-6,
"a zero-length clip sits at its own t = 0"
);
// Nothing armed (the gseq-only tier without a sound arm) ⇒ no phase, so nothing fires.
host.clip = None;
assert!(host.arm_clock(12.0).is_none());
}
}
@@ -856,6 +856,7 @@ mod tests {
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, false],
interior_fog: vec![false, false],
liquid_visited: vec![false, false],
flooded: vec![None, None],
})
@@ -408,6 +408,11 @@ pub(crate) fn spawn_wmo_liquids<'a>(
MeshMaterial3d(material),
transform,
LiquidSurface,
// The per-frame interior-fog lane rides `MeshTag` bit 30, written by the one
// `Visibility` authority off this pool's own room (decision 1787; `liquid.wgsl`'s
// `room_fog`). Spawned clear: a pool wears the room's fog only once the flood has
// said the room is on the lane.
bevy::mesh::MeshTag(0),
// The ambient-loop source rides EVERY kind — the fullbright lava/slime hum too
// (0506). It reads its geometry off the `WaterChunkInfo` inserted below.
LiquidSoundSource {
+19 -1
View File
@@ -77,7 +77,13 @@
//! laws in a single frame the instant the ramp latched.
//! 3. `debug_panel::apply_model_visibility` drives the small-prop distance fade (`DoodadFade`
//! holders) + glow-card dimming; `DoodadFade` is **never attached** to a lit interior prop
//! (spawn-time exclusion in `terrain_stream`), so 2 and 3 are disjoint.
//! (spawn-time exclusion in `terrain_stream`), so 2 and 3 are disjoint. The same system also
//! owns [`INTERIOR_FOG_BIT`] on **room-bound WMO content** — anything carrying a
//! `WmoGroupVis`: a building's group geometry, its doodad props, and the merged blobs of
//! either. That is the client's per-group `[0xca7f00]` gate, whose population is disjoint from
//! 2's by construction (a WMO part is never classified — it holds no `InteriorLit`) and which
//! writes through [`with_interior_fog`], so the probe slot a prop was spawned with survives
//! the room turning its fog on and off.
//! 4. `player::apply_self_model_fade` (the zoom-to-first-person feather) runs **after** 1–3 and wins
//! on the self body submeshes' alpha while feathering; on the frame it ends it restores the
//! alpha field itself and hands the material back to the part's law (it cannot lean on 2 to
@@ -217,6 +223,18 @@ pub fn alpha_bits(alpha: f32) -> u32 {
}
}
/// Set or clear [`INTERIOR_FOG_BIT`] alone, preserving the whole payload and the highlight bit —
/// the room-bound writer's read-modify-write. The payload is untouched on purpose: a WMO interior
/// prop's probe slot says how it is LIT, and the client's `[0xca7f00]` decides only which fog
/// TRIPLE its batch is pushed with. The `0` sentinel survives too, because the shader masks bits
/// 30/31 off before testing it.
pub(crate) fn with_interior_fog(tag: u32, on: bool) -> u32 {
match on {
true => tag | INTERIOR_FOG_BIT,
false => tag & !INTERIOR_FOG_BIT,
}
}
/// Write the alpha field of a tag, preserving every other field (shade or probe slot, rig, both
/// flag bits) — the fade writers' read-modify-write (an appear-fade on a unit standing in MCSH
/// shadow must not flash it lit, and a feathering skinned part must keep its palette).
@@ -81,6 +81,7 @@ pub(super) fn apply_model_visibility(
&GlobalTransform,
Option<&Aabb>,
Has<crate::exterior_cull::ExteriorScene>,
Option<&mut MeshTag>,
),
Without<ModelPart>,
>,
@@ -175,6 +176,18 @@ pub(super) fn apply_model_visibility(
.is_none_or(|inst| gv.drawn_by(inst))
});
// …and, off the SAME per-frame flood, which fog triple this piece's batch is pushed
// with: the client's per-group `[0xca7f00]` (wow-re round-6 Q-I — the group drawer
// `0x6b5190` and the group-doodad drawer `0x6b62e0` push the interior triple only under
// it; everything else inherits `push_fog`'s scene triple). `None` for every non-WMO
// entity, which leaves `INTERIOR_FOG_BIT` to the entity classifier — a unit's fog is
// staged by the unit's OWN classification, not by the room's gate.
let room_fog = group_vis.map(|gv| {
instances
.get(gv.instance)
.is_ok_and(|inst| gv.interior_fogged_by(inst))
});
// The batch's animated material-alpha factor (decision 0130 phase 2): the sampled
// colour-alpha × transparency-weight loop, `1.0` for the untracked majority. Multiplied
// into the tag below, and into the cull here — the real client skips a batch whose
@@ -244,18 +257,25 @@ pub(super) fn apply_model_visibility(
// haze) blasted at full brightness (bug B30). Only the unit lane stays out:
// `entities::apply_unit_mat_alpha` owns that compose, ordered against the interior
// classifier and the appear-fade.
if fade.is_some() || mat_anim.is_some_and(|m| !m.composes_unit_tag()) {
// Glow cards render at AUTHORED brightness (decision 0159 — the dimmer knob died
// with the faithful FFXGlow pass; the square-law is what keeps halos in check).
let alpha = fade_alpha * mat_factor;
// `with_alpha` handles the `MeshTag == 0` opaque-sentinel (a *visible* glow card dimmed
// to exactly 0 must not flip to full bright); a distance-faded doodad (`fade_alpha == 0`)
// is Hidden anyway, so ≈0 bits there are equally fine. Conventions: `crate::mesh_tag`.
if let Some(mut tag) = tag {
let bits = crate::mesh_tag::with_alpha(tag.0, alpha);
if tag.0 != bits {
tag.0 = bits;
}
// The two tag fields this system owns, written in ONE read-modify-write so they
// compose instead of racing: the fade alpha, and the room's interior-fog bit.
if let Some(mut tag) = tag {
let mut bits = tag.0;
if fade.is_some() || mat_anim.is_some_and(|m| !m.composes_unit_tag()) {
// Glow cards render at AUTHORED brightness (decision 0159 — the dimmer knob died
// with the faithful FFXGlow pass; the square-law is what keeps halos in check).
let alpha = fade_alpha * mat_factor;
// `with_alpha` handles the `MeshTag == 0` opaque-sentinel (a *visible* glow card
// dimmed to exactly 0 must not flip to full bright); a distance-faded doodad
// (`fade_alpha == 0`) is Hidden anyway, so ≈0 bits there are equally fine.
// Conventions: `crate::mesh_tag`.
bits = crate::mesh_tag::with_alpha(bits, alpha);
}
if let Some(on) = room_fog {
bits = crate::mesh_tag::with_interior_fog(bits, on);
}
if tag.0 != bits {
tag.0 = bits;
}
}
if let Some(f) = fade {
@@ -292,7 +312,7 @@ pub(super) fn apply_model_visibility(
// liquid every frame with no portal re-check for the rest of the world session — the Great
// Forge's walkway-level pool stays put when its group drops out of the flood (B65). An index
// past the latch fails OPEN (portal-less props never latch and must always draw).
for (gv, mut vis, xf, aabb, exterior) in &mut group_only {
for (gv, mut vis, xf, aabb, exterior, tag) in &mut group_only {
let portal_ok = !m.portal_cull
|| instances.get(gv.instance).ok().is_none_or(|inst| {
gv.groups
@@ -321,6 +341,22 @@ pub(super) fn apply_model_visibility(
if *vis != want {
*vis = want;
}
// …and the pool's fog lane, off the same per-frame flood as the room's walls (decision
// 1787). The reference's WMO liquid pass re-submits the interior fog block under the SAME
// `[0xca7f00]` as the geometry pass (wow-re `fog-env-state` §5's 6-site census), so a room
// and its water can never disagree; `liquid.wgsl` ANDs this with the surface's own static
// interior class. Note the term is the FLOOD's bit, not the ever-visited latch above: a
// pool the latch keeps drawing after its room left the PVS wears the scene fog, which is
// what the client's per-group toggle does with a group it is not currently walking.
if let Some(mut tag) = tag {
let on = instances
.get(gv.instance)
.is_ok_and(|inst| gv.interior_fogged_by(inst));
let bits = crate::mesh_tag::with_interior_fog(tag.0, on);
if tag.0 != bits {
tag.0 = bits;
}
}
}
}
+1
View File
@@ -1106,6 +1106,7 @@ pub(crate) mod tests {
liquid_visited: vec![false; 3],
flooded: vec![None; 3],
visible: vec![true, false, false],
interior_fog: vec![false; 3],
};
let fade = |groups: Option<&[u16]>| EmitterFade {
radius: 2.0,
+27 -10
View File
@@ -367,6 +367,21 @@ fn extract_effects(
}));
}
/// `$WOW_EFFECT_TRACE`'s lane names: which rung of the pre-water surface-decal band a draw's sort
/// bias is, or `None` for everything else on the stream (particles, ribbons, foam, precipitation).
/// Keyed on the sort rung — the only field that is unique per decal lane (`sky_order::Rung`).
fn decal_lane(bias: f32) -> Option<&'static str> {
use crate::sky_order::Rung;
match bias {
b if b == Rung::RING => Some("ring"),
b if b == Rung::SHADOW_SORT => Some("shadow"),
b if b == Rung::FOOTPRINT => Some("footprint"),
b if b == Rung::RETICLE => Some("reticle"),
b if b == Rung::GROUND_FX => Some("ground-fx"),
_ => None,
}
}
/// Add one [`Transparent3d`] item per draw record to the matching camera's phase.
fn queue_effects(
effect_pipeline: Res<EffectPipeline>,
@@ -493,11 +508,16 @@ fn prepare_effects(
// advances past the absorbed items (`render_phase/mod.rs:1487`).
let effect_fn = draw_functions.read().id::<DrawEffects>();
// `$WOW_EFFECT_TRACE` — the lane's own phase probe (the WOW_PHASE shape, decision 0665,
// asked of effect items): during the merge walk, record each BLOB-SHADOW item (Tris
// topology + the shadow's own sort rung — its RASTER bias is shared with the footprint
// lane, which used to label prints as shadows here) with its sort distance and appended index
// range, then log the phase totals. Splits "never pushed / never queued / merged away /
// submitted but the GPU state ate it" — the gap no pixel reading can see into.
// asked of effect items): during the merge walk, record each **surface-decal** item — every
// rung of the pre-water band, named by [`decal_lane`] — with its **index in the sorted
// transparent phase**, its sort distance, and its appended index range, then log the phase
// totals. Splits "never pushed / never queued / merged away / submitted but the GPU state ate
// it" — the gap no pixel reading can see into — and, since 1789, reads the draw-order ladder
// straight off a live frame: `item 4` vs `item 81` is what B347 was, measured.
//
// The lane is keyed on the SORT rung because that is the one field unique per lane: the raster
// bias is shared (the footprint lane rides `SHADOW_RASTER`, and prints were being labelled
// shadows here for exactly that reason).
let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some();
let mut trace_lines: Vec<String> = Vec::new();
meta.indices.clear();
@@ -557,12 +577,9 @@ fn prepare_effects(
}
}
let index_end = meta.indices.len() as u32;
if trace
&& draw.topology == EffectTopology::Tris
&& draw.bias == crate::sky_order::Rung::SHADOW_SORT
{
if let (true, Some(lane)) = (trace, decal_lane(draw.bias)) {
trace_lines.push(format!(
" item {i}: shadow anchor {:.1?}, dist {:.1}, verts {}, indices \
" item {i}: {lane} anchor {:.1?}, dist {:.1}, verts {}, indices \
{index_start}..{index_end}, tex {:?}",
draw.anchor,
item.distance,
@@ -68,7 +68,9 @@ struct GxCell {
@group(1) @binding(0) var<uniform> cell: GxCell;
// The per-ITEM record table, indexed by the vertex word's low bits:
// x = texture-array layer, y = the WMO authored batch order (the clip-z nudge; 0 on cells —
// an exact no-op), z = the MOMT SIDN colour packed r|g<<8|b<<16 (gamma bytes), w = free.
// an exact no-op), z = the MOMT SIDN colour packed r|g<<8|b<<16 (gamma bytes), w = flags:
// bit 0 the exile kill bit (B2), bits 1..=13 the interior-prop probe slot (B4), bit 14 the
// per-frame INTERIOR FOG lane (1787 — the client's per-group `[0xca7f00]`).
@group(1) @binding(1) var<storage, read> recs: array<vec4<u32>>;
@group(1) @binding(2) var tex_array: texture_2d_array<f32>;
// Repeat and clamp variants of the BLP model-albedo sampler (linear tri-filtered, aniso 8 —
@@ -437,9 +439,15 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// Black/White/Grey families belong to blends this lane excludes by admission). Interior
// WMO surfaces fog with the INTERIOR triple — the room keeps its warm MFOG haze
// (round-6 Q-I: one flag gates walls, pools and props alike).
// The interior fog lane is the per-frame record bit, NOT the baked `WORD_INTERIOR`: the
// client pushes the interior triple per GROUP under `[0xca7f00]` (`0x6b5190` for surfaces,
// `0x6b62e0` for the group's doodads — wow-re round-6 Q-I), and the flood decides it each
// frame (decision 1787, `wow_model.wgsl` carries the same law on its tag bit 30). Keyed on
// the batch's static interior flag instead, every true-interior group of a building wore the
// building's MFOG the moment the camera stood anywhere in it — B335.
var fog_color = wow_light.fog_color;
var fog_span = wow_light.fog_params.xy;
if ((in.word & WORD_INTERIOR) != 0u) {
if ((recs[in.word & 0xffffu].w & 16384u) != 0u) {
fog_color = wow_light.wmo_fog_color;
fog_span = wow_light.wmo_fog_params.xy;
}
+46 -26
View File
@@ -164,21 +164,37 @@ impl Rung {
/// near-side default is after the water. Everything with a *known* slot moved to the
/// pre-water band below (B347).
pub const GROUND_FX: f32 = 8192.0;
/// The **selection ring**, the same number in both roles and the same coplanarity rationale.
/// **Not in the pre-water band — not yet, and not by argument:** the ring's draw hangs off the
/// CGObject per-frame slot (`0x614ada` inside `0x614a90` = vtable +0x38), not off a call the
/// frame driver's own row names, so which side of the water pass it lands on is unread. An RE
/// is out for it (B347); until it answers the ring keeps the rung it has shipped on rather
/// than moving on a guess.
pub const RING: f32 = 8192.0;
/// **The pre-water decal band, rung 1: the unit blob shadow.**
/// The **rasterizer** constant the decal family shares — [`GROUND_FX`](Rung::GROUND_FX)'s
/// number in its second role, named so the lanes whose *sort* rung left the +8192 neighbourhood
/// (the ring, the reticle) still reach the coplanarity margin rather than a copy of it. The
/// blob shadow is the one lane that needs more: [`SHADOW_RASTER`](Rung::SHADOW_RASTER), B131.
pub const DECAL_RASTER: i32 = 8192;
/// **The pre-water decal band, rung 1: the selection ring** (and, when they are built, the
/// corpse marker and the click-to-move marker — the same `+0x30` slot and the same call site).
///
/// This rung shipped at **+8192** for months and was moved on a guess's opposite: 1785 left it
/// alone because the ring's frame slot was *unread*, and the RE it dispatched came back with a
/// slot earlier than anything guessed. `[node+0xb4] = 0x481540` → `0x4815d0` →
/// `0x48160c call [obj vt+0x38]` → `0x614ada call [obj vt+0x30]` → `0x608e00`: the ring is
/// emitted **from inside PHASE 1's own M2 node drain** (`0x6812c5 call 0x683dd0`), the same
/// loop body that then calls `0x6d78f0` for that node's blob shadow — so the family is not
/// merely before the water, it is before the footprints and before the M2 opaque pass too.
///
/// **And the ring draws UNDER the shadow, not over it.** Per node the `+0x38` tick runs first
/// (`0x48160c`) and the shadow gate second (`0x683ec3`), so the additive ring goes down and the
/// modulate shadow darkens it. This ladder asserted the opposite for as long as both rungs have
/// existed — "where both decals stack the ring draws later deterministically" was a tie-break
/// invented here, not a fact read anywhere, and it was backwards.
pub const RING: f32 = -5.4e4;
/// **The pre-water decal band, rung 2: the unit blob shadow.**
///
/// The reference draws its ground decals *before* the water, and B347 is what it costs not
/// to. The frame driver `0x483460` emits this one inside PHASE 1's opaque drain row
/// (`0x6812c5 call 0x683dd0` → `0x6d78f0` → `0x6d7920`; wow-re `unit-blob-shadow.md` Q1) —
/// before the footprints, before the M2 opaque pass (`0x4836a6`), and long before the water
/// surfaces, which draw in phase 3 *between* the two M2 transparent passes
/// (`water-frame-straddle.md` §1). Every transparent in the world paints over a shadow there.
/// in the same node-drain loop body as the ring above and immediately after it, before the
/// footprints, before the M2 opaque pass (`0x4836a6`), and long before the water surfaces,
/// which draw in phase 3 *between* the two M2 transparent passes (`water-frame-straddle.md`
/// §1). Every transparent in the world paints over a shadow there.
///
/// Here it did the exact opposite. At the old **+4096** the shadow's key (`view-z + bias`)
/// beat every world transparent — [`WATER_BIAS`], [`FAR_SIDE_BIAS`], and the unbiased
@@ -190,21 +206,22 @@ impl Rung {
/// WMO-skybox band above it, each by more than a world view-z can travel (`WORLD_VIEW_Z_FLOOR`
/// — two rungs are only separated if their gap exceeds it). The deepest world transparent
/// sorts at `FAR_SIDE_BIAS − 8 − far ≈ −4.3e4`; the shallowest skybox batch at
/// `WMO_SKYBOX_BIAS − 0.09 ≈ −6.0e4` — a window of `(−5.7e4, −4.3e4)`, which the three rungs
/// take centred, 2e3 apart, with ~5e3 of margin at each end. The assert block below is the
/// `WMO_SKYBOX_BIAS − 0.09 ≈ −6.0e4` — a window of `(−5.7e4, −4.3e4)`, which the four rungs
/// take 2e3 apart, clearing the skybox by 3e3 below and the world band by ~5e3 above. The
/// assert block below is the
/// check, and it is arithmetic on the neighbours rather than a blanket 1e4: the window is only
/// 1.4e4 wide, so the ladder's usual margin does not fit and pretending it does would be the
/// lie. **The depth of the rung costs nothing at the rasterizer** — unlike [`WATER_BIAS`] and
/// the two rungs above, the effect stream carries sort and raster as separate fields
/// ([`SHADOW_RASTER`](Rung::SHADOW_RASTER) is this lane's other half).
pub const SHADOW_SORT: f32 = -5.2e4;
/// **The pre-water band, rung 2: footprints** — the reference draws them at
/// **The pre-water band, rung 3: footprints** — the reference draws them at
/// `0x483654` (`0x670240` → `0x69a3e0`), one slot AFTER the shadow pass and still before the
/// M2 opaque pass, so a print paints over a shadow rather than under it. That was inverted
/// here: the print rode +2048 *below* the shadow's +4096, on a comment calling the reference's
/// frame order an open RE item — `water-frame-straddle.md` §1 had already closed it.
pub const FOOTPRINT: f32 = -5.0e4;
/// **The pre-water band, rung 3: the ground-target reticle** — the reference's solid-receiver
/// **The pre-water band, rung 4: the ground-target reticle** — the reference's solid-receiver
/// pass (`0x4836c5`, flags `0x200122`) is the last decal before the water. Its *liquid* pass
/// (`0x483727`, flags `0xf0000`) is the family's one genuinely post-water draw, and it has no
/// counterpart here: liquid is not a `GroundDecalSurface` yet, so nothing we project can land
@@ -254,15 +271,17 @@ const _: () = {
assert!(Rung::GROUND_FX - CLOUDS_BIAS > 1.0e4);
assert!(GLARE_BIAS - Rung::GROUND_FX > 1.0e4);
assert!(Rung::NAMEPLATE - GLARE_BIAS > 1.0e4);
// The ring still draws after the shadow where the two stack, and the shadow's raster margin
// is its own axis (B131) — not comparable to the sort rungs, only to zero.
assert!(Rung::RING > Rung::SHADOW_SORT && Rung::SHADOW_RASTER > 0);
// The two raster margins are their own axis (B131) — not comparable to the sort rungs above,
// only to zero and to each other: the shadow's is the raised one.
assert!(Rung::SHADOW_RASTER > Rung::DECAL_RASTER && Rung::DECAL_RASTER > 0);
// ─── The pre-water decal band (B347) ────────────────────────────────────────────────────
// Internally ordered as the reference's frame emits them — shadow (PHASE 1, `0x6812c5`) →
// footprints (`0x483654`) → the reticle's solid pass (`0x4836c5`) — with a step far wider
// than the view-z two *stacked* decals can differ by (they share a patch of ground, so their
// anchors are yards apart, not kilometres).
// ─── The pre-water decal band (B347, 1785/1789) ─────────────────────────────────────────
// Internally ordered as the reference's frame emits them — ring then shadow, both from
// PHASE 1's node drain (`0x6812c5 call 0x683dd0`: the `+0x38` tick at `0x48160c`, then the
// shadow gate at `0x683ec3`) → footprints (`0x483654`) → the reticle's solid pass
// (`0x4836c5`) — with a step far wider than the view-z two *stacked* decals can differ by
// (they share a patch of ground, so their anchors are yards apart, not kilometres).
assert!(Rung::SHADOW_SORT - Rung::RING > 1.0e3);
assert!(Rung::FOOTPRINT - Rung::SHADOW_SORT > 1.0e3);
assert!(Rung::RETICLE - Rung::FOOTPRINT > 1.0e3);
// And below EVERY world transparent whatever its distance — the deepest being a far-side
@@ -271,9 +290,10 @@ const _: () = {
// was the one thing in the scene the water did not attenuate.
assert!(FAR_SIDE_BIAS - 8.0 + WORLD_VIEW_Z_FLOOR - Rung::RETICLE > 1.0e3);
// ...and above the WMO-skybox band whatever the decal's distance, so a building's painted sky
// can never sort over a shadow (it is depth-forced to the far plane as well, but the ladder
// does not get to lean on a second mechanism to state its own order).
assert!(Rung::SHADOW_SORT + WORLD_VIEW_Z_FLOOR - WMO_SKYBOX_BIAS > 1.0e3);
// can never sort over a decal (it is depth-forced to the far plane as well, but the ladder
// does not get to lean on a second mechanism to state its own order). Measured at the band's
// FLOOR, which is the ring's rung.
assert!(Rung::RING + WORLD_VIEW_Z_FLOOR - WMO_SKYBOX_BIAS > 1.0e3);
};
/// The depth law (module doc) is a property of the **shaders**, so it is checked there: every sky
+34 -9
View File
@@ -418,12 +418,26 @@ pub(super) fn cull_cells(
let Ok(inst) = instances.get(entity) else {
continue;
};
let mut sel = vec![false; draw.sets.len()];
let mut sel = crate::static_gx::render::GxSel {
drawn: vec![false; draw.sets.len()],
fog: vec![false; draw.sets.len()],
};
let mut any = false;
for (set, aabb) in &draw.groups {
let Some(rooms) = draw.sets.get(usize::from(*set)) else {
continue;
};
// The interior-fog lane is decided per SET whether or not the set draws — the
// record sync reads the whole vector, and a set the frustum drops this frame is
// back next frame with the same rooms. `set_admitted`'s fail-OPEN read is wrong
// here: `interior_fogged_by`'s fail-closed one is the fog rule (a missing bit
// must not paint a room's MFOG onto a prop).
sel.fog[usize::from(*set)] = rooms.iter().any(|&g| {
inst.interior_fog
.get(usize::from(g))
.copied()
.unwrap_or(false)
});
if m.portal_cull && !set_admitted(&inst.visible, rooms) {
continue;
}
@@ -445,7 +459,7 @@ pub(super) fn cull_cells(
{
continue;
}
sel[usize::from(*set)] = true;
sel.drawn[usize::from(*set)] = true;
any = true;
}
if any {
@@ -462,7 +476,7 @@ pub(super) fn cull_cells(
// The WMO regions — the dev WMO toggle wholesale, then per-group admission; regions
// likewise ordered near-first (B3).
if m.kind_visible[crate::model_render::kind_index(crate::model_render::ModelKind::Wmo)] {
let mut admitted: Vec<(u32, (Entity, Vec<bool>))> = Vec::new();
let mut admitted: Vec<(u32, (Entity, crate::static_gx::render::GxSel))> = Vec::new();
for (&entity, draw) in &world.wmos {
// A region whose instance can't answer this frame (spawn-command latency) keeps
// last frame's absence — one frame of the entity path's own arrival class.
@@ -470,9 +484,19 @@ pub(super) fn cull_cells(
continue;
};
let max_group = draw.groups.iter().map(|(g, _)| *g).max().unwrap_or(0);
let mut sel = vec![false; usize::from(max_group) + 1];
let mut sel = crate::static_gx::render::GxSel {
drawn: vec![false; usize::from(max_group) + 1],
fog: vec![false; usize::from(max_group) + 1],
};
let mut any = false;
for (group, aabb) in &draw.groups {
// The fog lane first, for every group of the region and not only the drawn ones —
// see the prop site above for why.
sel.fog[usize::from(*group)] = inst
.interior_fog
.get(usize::from(*group))
.copied()
.unwrap_or(false);
// The portal PVS bit — the SAME fail-open read `WmoGroupVis::drawn_by` takes,
// behind the same panel switch.
if m.portal_cull
@@ -502,7 +526,7 @@ pub(super) fn cull_cells(
{
continue;
}
sel[usize::from(*group)] = true;
sel.drawn[usize::from(*group)] = true;
any = true;
}
if any {
@@ -541,7 +565,7 @@ pub(super) fn cull_cells(
.iter()
.filter(|i| {
i.group.is_some_and(|s| {
sel.get(usize::from(s)).copied().unwrap_or(false)
sel.drawn.get(usize::from(s)).copied().unwrap_or(false)
})
})
.count();
@@ -556,12 +580,13 @@ pub(super) fn cull_cells(
.draws
.iter()
.filter(|i| {
i.group
.is_some_and(|g| sel.get(usize::from(g)).copied().unwrap_or(false))
i.group.is_some_and(|g| {
sel.drawn.get(usize::from(g)).copied().unwrap_or(false)
})
})
.count();
}
wmo_groups += sel.iter().filter(|b| **b).count();
wmo_groups += sel.drawn.iter().filter(|b| **b).count();
}
let (mut fs, mut fx, mut fg) = (0usize, 0usize, 0usize);
for cell in gx.cells.values() {
+1 -1
View File
@@ -612,7 +612,7 @@ impl StaticGx {
.world
.visible_wmos
.iter()
.map(|(_, bits)| bits.iter().filter(|b| **b).count())
.map(|(_, bits)| bits.drawn.iter().filter(|b| **b).count())
.sum();
(
self.world.visible.len(),
+10 -8
View File
@@ -56,10 +56,9 @@ impl PickSource for StaticGx {
continue;
};
gather(region, origin, dir, &mut candidates, |region, i| {
region.items[i]
.prop
.as_ref()
.is_some_and(|p| sets.get(usize::from(p.set)).copied().unwrap_or(false))
region.items[i].prop.as_ref().is_some_and(|p| {
sets.drawn.get(usize::from(p.set)).copied().unwrap_or(false)
})
});
}
}
@@ -69,10 +68,13 @@ impl PickSource for StaticGx {
continue;
};
gather(region, origin, dir, &mut candidates, |region, i| {
region.items[i]
.wmo
.as_ref()
.is_some_and(|w| groups.get(usize::from(w.group)).copied().unwrap_or(false))
region.items[i].wmo.as_ref().is_some_and(|w| {
groups
.drawn
.get(usize::from(w.group))
.copied()
.unwrap_or(false)
})
});
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
+63 -5
View File
@@ -118,8 +118,21 @@ fn mark_world_camera(
#[derive(Clone)]
pub(crate) enum GxDoodadVis {
Cell((i32, i32)),
/// A prop region + this frame's per-referrer-SET admission bits.
Prop(Entity, Vec<bool>),
/// A prop region + this frame's per-referrer-SET verdicts.
Prop(Entity, GxSel),
}
/// One region's per-selection-grain verdicts for this frame — a WMO region's grain is the GROUP,
/// a prop region's the referrer-SET index, and both index these vectors the same way.
#[derive(Clone, Default)]
pub(crate) struct GxSel {
/// Drawn this frame: PVS ∧ frustum ∧ farclip ∧ the exterior window gate.
pub drawn: Vec<bool>,
/// On the interior fog lane — the client's per-group `[0xca7f00]`, resolved by the portal
/// flood ([`crate::wmo_portal::GroupPvs::interior_fog`]). Rides beside `drawn` because it is
/// the same walk's answer at the same grain, and because the node syncs it into the record
/// table exactly where it already syncs the kill column.
pub fog: Vec<bool>,
}
/// The published half the render world clones each frame. The baked regions sit behind `Arc`
@@ -141,11 +154,16 @@ pub(crate) struct GxWorld {
/// This frame's per-group admission per region (indexed by absolute group index): the
/// portal flood's verdict collapsed to CPU range selection — the node draws exactly the
/// runs whose group bit is set.
pub visible_wmos: Vec<(Entity, Vec<bool>)>,
pub visible_wmos: Vec<(Entity, GxSel)>,
}
use super::pool::GxTexturePool;
/// Record column `w`, bit 14 — the per-item **interior fog** lane (decision 1787), written per
/// frame by the fog sync and read by `static_gx.wgsl`'s fog select. Bit 0 is the exile kill bit
/// and bits 1..=13 the interior-prop probe slot, so 14 is the first free bit.
const RECORD_FOG_BIT: u32 = 1 << 14;
/// One coalesced draw run: adjacent live bake items sharing (bind-group slot, pipeline
/// bucket, group). Killed items are SKIPPED at build time (B3): a run never carries an exiled
/// or gone item, so a far cell of fully-faded faders submits no vertex work at all (the WGSL
@@ -180,6 +198,11 @@ struct GxCellGpu {
records: Vec<[u32; 4]>,
/// The `killed_rev` this table last uploaded.
killed_applied: u32,
/// The per-grain interior-fog verdict this table's records were last written from
/// ([`GxSel::fog`]) — empty until the first sync. Compared rather than revisioned: it is
/// tens of bools, it changes only when the camera changes rooms, and unlike the kill bitmap
/// it is produced by the per-frame cull rather than owned by the region.
fog_applied: Vec<bool>,
}
#[derive(Resource, Default)]
@@ -504,6 +527,39 @@ fn prepare_static_gx(
gpu.runs = build_runs(&draw.draws, &gpu.item_slot, &draw.killed);
gpu.killed_applied = draw.killed_rev;
}
// The interior-fog sync (decision 1787): the client's per-group `[0xca7f00]` decides which
// fog triple a WMO group's surfaces — and its doodad props — are pushed with, so it is a
// per-frame property of the SELECTION, not of the bake. It rides the record table's w column
// (bit `RECORD_FOG_BIT`) beside the kill bit, written per item from its own selection grain.
// Rewritten only when the verdict actually moves — which is when the camera changes rooms —
// and it never touches run membership, so no coalesce is owed.
let sync_fog = |gpu: &mut GxCellGpu, draw: &GxCellDraw, sel: &GxSel| {
if gpu.fog_applied == sel.fog {
return;
}
for (rec, item) in gpu.records.iter_mut().zip(&draw.draws) {
let on = item
.group
.is_some_and(|g| sel.fog.get(usize::from(g)).copied().unwrap_or(false));
rec[3] = (rec[3] & !RECORD_FOG_BIT) | (u32::from(on) * RECORD_FOG_BIT);
}
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
gpu.fog_applied.clone_from(&sel.fog);
};
for (entity, sel) in &gx.visible_wmos {
if let (Some(gpu), Some(draw)) = (cache.wmos.get_mut(entity), gx.wmos.get(entity)) {
sync_fog(gpu, draw, sel);
}
}
for vis in &gx.visible {
let GxDoodadVis::Prop(entity, sel) = vis else {
continue; // cell items are never on the interior lane
};
if let (Some(gpu), Some(draw)) = (cache.props.get_mut(entity), gx.props.get(entity)) {
sync_fog(gpu, draw, sel);
}
}
}
/// Assemble one region's GPU state against the shared pool: pool slots for its textures, the
@@ -601,6 +657,8 @@ fn assemble_region(
runs,
records,
killed_applied: draw.killed_rev,
// Empty ⇒ the first fog sync always fires (the baked records carry no lane bit).
fog_applied: Vec::new(),
})
}
@@ -714,7 +772,7 @@ impl ViewNode for StaticGxNode {
// (B3/B4): the real client's own drain order (1429's byte-true anchor: terrain →
// WMO → … → doodad), and the buildings are the frame's best early-z occluders for
// the doodads behind them.
let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&Vec<bool>>)> = Vec::new();
let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&GxSel>)> = Vec::new();
for (entity, sel) in &gx.visible_wmos {
if let (Some(gpu), Some(draw)) = (cache.wmos.get(entity), gx.wmos.get(entity)) {
resolved.push((gpu, draw, Some(sel)));
@@ -783,7 +841,7 @@ impl ViewNode for StaticGxNode {
// The PVS range selection (1429's collapse): a WMO run draws iff its group's
// admission bit is set this frame; a cell run always draws.
if let (Some(sel), Some(group)) = (sel, run.group) {
if !sel.get(usize::from(group)).copied().unwrap_or(false) {
if !sel.drawn.get(usize::from(group)).copied().unwrap_or(false) {
continue;
}
}
@@ -338,6 +338,9 @@ pub(super) fn spawn_loaded_placements(
world_from_local: p.transform.compute_affine(),
name_set: p.name_set,
visible: vec![true; m.group_nav.len()],
// Nothing wears the building's own fog until a flood says
// it is on the chain (`WmoPortalInstance::interior_fog`).
interior_fog: vec![false; m.group_nav.len()],
liquid_visited: vec![false; m.group_nav.len()],
// The MOGP `groupLiquid` override, resolved once at spawn: 13
// groups in the whole archive declare "this room is wholly
@@ -44,6 +44,7 @@ mod pin;
/// A building at its real spot on a real map: the model, plus the MODF placement to find (by its
/// `uniqueId`) in `tile`, whose 3×3 tile block supplies the terrain the down-ray races.
#[derive(Clone, Copy)]
struct Site {
wmo: &'static str,
map: &'static str,
@@ -112,6 +113,17 @@ const IRONFORGE: Site = Site {
uid: 7706,
};
/// Shadowfang Keep — B335's site: the courtyard whose far doorways read as a flat blue-cyan wash.
/// Its two courtyard groups (g38, g72) are EXTERIOR_LIT (`0x40`) while the rooms behind their
/// arches (g61, g60) are true interiors, which is the shape the interior-fog chain gate turns on.
/// `.go xyz -214.30 2211.84 79.78 33` looks at the arches; `-213.90 2236.15 79.78` is inside g61.
const SHADOWFANG: Site = Site {
wmo: r"world\wmo\dungeon\ld_shadowfang\ld_shadowfanginterior.wmo",
map: "Shadowfang",
tile: (27, 32),
uid: 218202,
};
/// Eye height above a floor point for both the standing player and the seated camera samples.
const EYE_HEIGHT: f32 = 1.7;
@@ -7,27 +7,58 @@
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use bevy::math::{Mat4, Vec3};
use super::{load_subject, Site, EXTERIOR, UNDERCITY};
use super::{
load_subject, Site, BLACKSMITH, DEADMINES, EXTERIOR, FARGODEEP, GOLDSHIRE, IRONFORGE,
SHADOWFANG, UNDERCITY,
};
/// The pin family's shared env targeting (`WOW_PIN_WMO`/`_MAP`/`_TILE`/`_UID`, default
/// [`UNDERCITY`]).
/// The named subjects `WOW_PIN_SITE` selects — every [`Site`] the harness carries, so a report at a
/// building we have already stood in is one word rather than four env vars (whose `WOW_PIN_WMO`
/// backslash quoting is its own trap). Unknown names list themselves.
const NAMED_SITES: &[(&str, Site)] = &[
("goldshire", GOLDSHIRE),
("fargodeep", FARGODEEP),
("deadmines", DEADMINES),
("blacksmith", BLACKSMITH),
("undercity", UNDERCITY),
("ironforge", IRONFORGE),
("shadowfang", SHADOWFANG),
];
/// The pin family's shared env targeting: `WOW_PIN_SITE=<name>` picks a [`NAMED_SITES`] subject
/// (default [`UNDERCITY`]), and `WOW_PIN_WMO`/`_MAP`/`_TILE`/`_UID` each override one field of it —
/// so an unnamed building still works, and a named one needs no quoting.
fn site_from_env() -> Site {
let base = match std::env::var("WOW_PIN_SITE") {
Ok(name) => {
let key = name.trim().to_ascii_lowercase();
*NAMED_SITES
.iter()
.find(|(n, _)| *n == key)
.map(|(_, s)| s)
.unwrap_or_else(|| {
let known: Vec<&str> = NAMED_SITES.iter().map(|(n, _)| *n).collect();
panic!("WOW_PIN_SITE={name:?} is not one of {known:?}")
})
}
Err(_) => UNDERCITY,
};
Site {
wmo: Box::leak(
std::env::var("WOW_PIN_WMO")
.unwrap_or_else(|_| UNDERCITY.wmo.to_string())
.unwrap_or_else(|_| base.wmo.to_string())
.into_boxed_str(),
),
map: Box::leak(
std::env::var("WOW_PIN_MAP")
.unwrap_or_else(|_| UNDERCITY.map.to_string())
.unwrap_or_else(|_| base.map.to_string())
.into_boxed_str(),
),
tile: std::env::var("WOW_PIN_TILE").map_or(UNDERCITY.tile, |s| {
tile: std::env::var("WOW_PIN_TILE").map_or(base.tile, |s| {
let (a, b) = s.split_once(',').expect("WOW_PIN_TILE wants tx,ty");
(a.trim().parse().unwrap(), b.trim().parse().unwrap())
}),
uid: std::env::var("WOW_PIN_UID").map_or(UNDERCITY.uid, |s| s.trim().parse().unwrap()),
uid: std::env::var("WOW_PIN_UID").map_or(base.uid, |s| s.trim().parse().unwrap()),
}
}
@@ -67,6 +98,27 @@ fn wmo_group_census() {
site.wmo,
subject.model.group_nav.len()
);
// The root's MFOG table, once. Every group line below names the ≤4 record indices it points
// at, and the fog a room ends up wearing is one of these — which is the whole diagnosis when a
// far room reads as a flat wash of one colour (B335: SFK's `0xff28444f` at end 106.9 yd). The
// colour is decoded exactly as [`super::super::fog`] decodes it (`0xAARRGGBB`).
for (i, f) in subject.model.fogs.iter().enumerate() {
println!(
"mfog f{i}: flags {:#x} pos ({:.1},{:.1},{:.1}) r[{:.2},{:.2}] end {:.1} start×{:.3} = {:.1} rgb({},{},{})",
f.flags,
f.pos[0],
f.pos[1],
f.pos[2],
f.radius_inner,
f.radius_outer,
f.fog_end,
f.fog_start_scalar,
f.fog_end * f.fog_start_scalar,
(f.color >> 16) & 0xff,
(f.color >> 8) & 0xff,
f.color & 0xff,
);
}
// `WOW_PIN_COL=<world-x,world-y>`: every walking-collision face crossing that column, per group,
// as world z — the down-ray's Leg A candidate list for one spot, without running the flood.
if let Some((cx, cy)) = std::env::var("WOW_PIN_COL").ok().and_then(|v| {
@@ -211,6 +263,17 @@ fn wmo_group_census() {
.map(|(lo, hi)| format!(" LIQUID local z[{lo:.1},{hi:.1}]"))
.unwrap_or_default(),
);
// Which MFOG records this group's MOGP header points at, and whether the group is on the
// INTERIOR fog lane at all (`flags & 0x48 == 0` — the drawer router's own test).
println!(
" fog {:?}{}",
g.fog_indices,
if g.flags & 0x48 == 0 {
" INTERIOR-LANE"
} else {
" scene-lane (0x48 set)"
}
);
// The group's portal edges, each placed in WoW world space. The group lines above answer
// "which room is that"; without this the doorway between two of them is a local-space
// vertex span nobody can point at in game, so a `.go xyz` report can't name the portal it
@@ -353,12 +416,28 @@ fn wmo_pin_probe() {
}
let vis: Vec<usize> = pvs
.visible
.iter()
.enumerate()
.filter(|(_, &v)| v)
.map(|(i, _)| i)
.collect();
println!("visible: {} of {} groups {vis:?}", vis.len(), pvs.len());
println!(
"visible: {} of {} groups {vis:?}",
vis.len(),
pvs.visible.len()
);
// The interior-fog gate (`[0xca7f00]`, [`super::super::GroupPvs`]): which of those groups wear
// the building's own MFOG triple, and which inherit the scene fog. B335 is read straight off
// this line — the room behind the courtyard's arches must NOT be on it.
let fogged: Vec<usize> = pvs
.interior_fog
.iter()
.enumerate()
.filter(|(_, &v)| v)
.map(|(i, _)| i)
.collect();
println!("interior-fog lane: {fogged:?}");
// The portal graph of every group the flood reached. A flood that stops has either run out of
// edges or had them all rejected, and only the edge list tells the two apart.
@@ -435,8 +514,118 @@ fn wmo_pin_probe() {
if g.flags & EXTERIOR != 0 { " EXT" } else { "" },
g.ref_start,
g.ref_count,
if pvs[gi] { "VISIBLE" } else { "CULLED" }
match (pvs.visible[gi], pvs.interior_fog[gi]) {
(true, true) => "VISIBLE · interior fog",
(true, false) => "VISIBLE · scene fog",
(false, _) => "CULLED",
}
);
}
}
}
/// **B335's fixture** — the Shadowfang courtyard's far doorways, as an invariant instead of a
/// screenshot. The reported spot (`.go xyz -224.01 2168.63 79.79 33`, third-person camera behind)
/// stands in the open courtyard **g38**, whose MOGP flags carry `0x40` EXTERIOR_LIT; the two arches
/// at the back of it open (through the second courtyard **g72**, also `0x40`) into room **g61**, a
/// true interior. The report was that g61 fills with a flat blue-cyan at distance: that colour is
/// this building's own MFOG record — `rgb(40,68,79)`, end 106.9 yd, start 10.7 — which at 70 yd is
/// nearly saturated, while the reference shows the room under the scene fog (map 33 noon: end 333,
/// start 83 ⇒ no fog at all at that range).
///
/// So the assertion is not about visibility — the flood reaches g61 from anywhere in the courtyard,
/// and always did. It is about which fog lane it lands on: the seed's own group takes the interior
/// lane, and the chain breaks below the exterior-lit courtyards, so **g61 must be on the scene
/// lane**. The control is the seed itself, which must stay on the interior lane.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() {
let site = SHADOWFANG;
let subject = load_subject(site.wmo, Some(&site));
let placed = subject
.placed
.as_ref()
.expect("the fixture needs a placement");
let to_local =
|wow: [f32; 3]| bevy_to_wow(placed.local_from_world.transform_point3(wow_to_bevy(wow)));
// The reported camera: third-person behind the player at the fountain, aimed at the p50 arches.
let eye_world = [-227.1_f32, 2157.0, 83.7];
let look_world = [-212.5_f32, 2232.3, 84.2];
let eye = to_local(eye_world);
let eye_bevy = placed.world_from_local.transform_point3(wow_to_bevy(eye));
let look_bevy = placed
.world_from_local
.transform_point3(wow_to_bevy(to_local(look_world)));
let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0)
* Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y);
let pvs = crate::wmo_portal::compute_pvs_traced(
&subject.model,
eye,
subject.terrain_z(eye),
&clip,
&placed.world_from_local,
&mut (),
);
// The flags the whole verdict turns on, asserted so a data misread can't pass silently.
assert_eq!(
subject.model.group_nav[38].flags & 0x48,
0x40,
"g38 is the EXTERIOR_LIT courtyard"
);
assert_eq!(
subject.model.group_nav[72].flags & 0x48,
0x40,
"g72 is the second EXTERIOR_LIT courtyard"
);
assert_eq!(
subject.model.group_nav[61].flags & 0x48,
0,
"g61 is a true interior room"
);
assert!(
pvs.visible[61],
"the flood reaches the room behind the arches"
);
assert!(
!pvs.interior_fog[61],
"B335: the room two exterior-lit courtyards away must inherit the SCENE fog, not this \
building's MFOG teal"
);
assert!(
!pvs.interior_fog[72],
"the chain is already broken at the second courtyard"
);
assert!(
!pvs.interior_fog[38],
"the courtyard the camera stands in is exterior-LIT: the exterior drawer pushes no fog, \
so its own surfaces are on the scene lane too"
);
assert!(
pvs.interior_fog.iter().all(|f| !f),
"from this courtyard NOTHING wears the building's MFOG — which is what the reference \
screenshot shows: not one navy pixel in the frame"
);
// The control, from a true interior: standing in g61 itself, the room DOES take the lane —
// otherwise this fixture would pass on a gate that is simply always off.
let inside = to_local([-213.90, 2236.15, 81.5]);
let inside_bevy = placed
.world_from_local
.transform_point3(wow_to_bevy(inside));
let inside_clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0)
* Mat4::look_at_rh(inside_bevy, eye_bevy, Vec3::Y);
let inside_pvs = crate::wmo_portal::compute_pvs_traced(
&subject.model,
inside,
subject.terrain_z(inside),
&inside_clip,
&placed.world_from_local,
&mut (),
);
assert!(
inside_pvs.interior_fog[61],
"control: the room seeds its own flood, so it wears the building's MFOG from inside"
);
}
+169 -13
View File
@@ -153,6 +153,18 @@ impl WmoGroupVis {
.iter()
.any(|&g| inst.visible.get(g as usize).copied().unwrap_or(true))
}
/// The interior-fog verdict: this piece takes the building's MFOG triple iff **any** naming
/// group does ([`WmoPortalInstance::interior_fog`], the client's `[0xca7f00]`). ORed for the
/// same reason `drawn_by` ORs — a prop several rooms name is drawn from any of them, so it
/// wears the fog of any of them. A lookup miss reads FALSE, the opposite of `drawn_by`'s
/// fail-open: a missing bit here must not paint a room's fog onto the open world, and the scene
/// fog is what every un-gated surface already wears.
pub fn interior_fogged_by(&self, inst: &WmoPortalInstance) -> bool {
self.groups
.iter()
.any(|&g| inst.interior_fog.get(g as usize).copied().unwrap_or(false))
}
}
/// **Is a room-bound RIDER drawn this frame?** — [`WmoGroupVis::drawn_by`] resolved for the lanes
@@ -202,6 +214,12 @@ pub struct WmoPortalInstance {
/// PVS. Seeded all-`true` (everything shows until the first compute, and forever for portal-less
/// props).
pub visible: Vec<bool>,
/// Per-group **interior-fog** gate for this frame (the client's `[0xca7f00]`): `true` = this
/// group's surfaces and its doodads draw with the building's MFOG triple, `false` = they
/// inherit the scene fog. Computed by the same flood as `visible` — see [`GroupPvs`] for the
/// rule and the byte cites. Seeded all-`false`: nothing wears a room's fog until a flood says
/// so, and a portal-less prop (which never floods) never does.
pub interior_fog: Vec<bool>,
/// Per-group **ever-visited** latch — the client's render-record persistence (wow-re
/// `wmo-record-persistence.md`, landed wow-re main @`00a766f6`): the visit callback `0x685d70`
/// creates a per-(instance,group) record the first time the flood visits a group, the record
@@ -371,8 +389,18 @@ fn compute_wmo_pvs(
// precondition) before it was born.
if model.portal_refs.is_empty() || model.portal_infos.is_empty() {
let held = inst.bypass_change_detection();
// …and never on the interior fog lane: a portal-less prop runs no flood, so there is
// no chain to be on (the same reading the fog resolve takes — see [`fog`]'s module doc).
let mut changed = false;
if held.visible.len() != groups || held.visible.iter().any(|v| !*v) {
held.visible = vec![true; groups];
changed = true;
}
if held.interior_fog.len() != groups || held.interior_fog.iter().any(|v| *v) {
held.interior_fog = vec![false; groups];
changed = true;
}
if changed {
inst.set_changed();
}
continue;
@@ -403,8 +431,12 @@ fn compute_wmo_pvs(
&world_from_local,
&mut (&mut tap, &mut log),
);
if inst.bypass_change_detection().visible != fresh {
inst.bypass_change_detection().visible = fresh;
if inst.bypass_change_detection().visible != fresh.visible {
inst.bypass_change_detection().visible = fresh.visible;
inst.set_changed();
}
if inst.bypass_change_detection().interior_fog != fresh.interior_fog {
inst.bypass_change_detection().interior_fog = fresh.interior_fog;
inst.set_changed();
}
if let (Some(text), Some(log)) = (&mut dump_text, &log) {
@@ -419,8 +451,37 @@ fn compute_wmo_pvs(
// fires only when the doorway's screen rect survives, the carved pass-1 law).
if claim.is_none() {
if let Some(gi) = tap.in_group {
if let Some(nav) = model.group_nav.get(gi).filter(|n| n.flags & EXTERIOR == 0) {
fog_target = select_wmo_fog(&model.fogs, nav.fog_indices, eye_local);
if model
.group_nav
.get(gi)
.is_some_and(|n| n.flags & EXTERIOR == 0)
{
// **The MFOG engagement conjunct** (wow-re `models/scratch/wmo-interior-fog-gate.md`,
// §5 VERIFIED; the byteOut loop `0x69de5f`–`0x69dea0` over the camera's containing
// set `0xc7cd88`): the interior fog target exists only when one of the camera's
// ≤2 CONTAINING groups — the down-ray's in-group and its across-portal partner,
// which is exactly that set — is a TRUE interior, `flags & 0x48 == 0`. In an
// exterior-LIT courtyard the client's `t` decays to 0 and the interior triple
// equals the scene triple; no surface of the building is on the lane either
// (decision 1787), so what this conjunct actually decides is the fog of an
// interior-classified UNIT standing in the building, which reads the triple by
// its own classification and would otherwise wear a courtyard's MFOG.
//
// The candidate walk reads THAT group's own MOGP fog indices (`grp+0x34`), not
// the containing group's unconditionally — a courtyard's indices never engage.
// The interior CLAIM below keeps the `0x8`-alone test it has always had: the
// `[0xc7b748]` writer rejects on EXTERIOR only (round-6 Q-H(c)), and the weather
// gate and the submersion probe both read the claim, not this.
fog_target = [Some(gi), tap.across]
.into_iter()
.flatten()
.find_map(|g| {
model
.group_nav
.get(g)
.filter(|n| n.flags & (EXTERIOR | EXTERIOR_LIT) == 0)
})
.and_then(|n| select_wmo_fog(&model.fogs, n.fog_indices, eye_local));
claim = Some(InteriorClaim {
room: WmoRoom {
instance: entity,
@@ -496,12 +557,18 @@ fn compute_wmo_pvs(
#[derive(Default)]
struct SeedTap {
in_group: Option<usize>,
/// The down-ray's ACROSS-portal seed. With [`Self::in_group`] this is the client's
/// `0xc7cd88` — the camera's ≤2 *containing* groups, which the MFOG engagement conjunct
/// tests (and which is NOT the flood's visible set, a confusion wow-re corrected in
/// `models/scratch/wmo-interior-fog-gate.md`).
across: Option<usize>,
/// `(destination group, rect it was entered on)` for every accepted portal step.
entered: Vec<(usize, Rect)>,
}
impl FloodTrace for SeedTap {
fn seed(&mut self, seeds: DownRaySeeds) {
self.in_group = seeds.in_group;
self.across = seeds.across;
}
fn entered(&mut self, _from: usize, _portal: u16, to: usize, rect: Rect, _on_plane: bool) {
self.entered.push((to, rect));
@@ -610,6 +677,30 @@ fn compute_pvs(
world_from_local,
&mut (),
)
.visible
}
/// What one flood publishes per group: this frame's PVS bit, and whether the group draws with the
/// building's **interior fog** triple rather than the scene fog.
///
/// The second is the client's per-group `[0xca7f00]` — the gate on BOTH interior-fog pushes, the
/// group drawer `0x6b5190` and the group-doodad drawer `0x6b62e0` (wow-re
/// `lighting/scratch/rf-weather-emission-timeline.md` round-6 Q-I, `terrain/scratch/fog-env-state.md`
/// §block 2). Decision 0347 modelled the LANE (interior triple on shared-light rows 18-19, selected
/// by the batch's own `flags & 0x48` interior bit) and recorded this gate as *"not modelled"*; B335
/// is that gap seen from the Shadowfang courtyard, where the room two exterior-lit courtyards away
/// wore the building's MFOG teal at 70 yd while the reference showed it under the scene fog.
///
/// The rule here: the seed's own group takes the lane (`0x6b3b4c`/`0x6b3df5` force it), and the walk
/// carries it to a neighbour only while no group on the path has `flags & 0x48` — the interior-chain
/// anchor breaking at an exterior/exterior-lit group (`0x6b424f` → `0x6b42c6`, QL-1). A `0x48` group
/// is drawn by the exterior drawer `0x6b4f10`, which pushes no fog at all, so its own bit is inert;
/// what the break decides is the rooms BEYOND it.
pub(crate) struct GroupPvs {
/// Per absolute group index: in this frame's portal PVS.
pub(crate) visible: Vec<bool>,
/// Per absolute group index: draws with the interior fog triple (the `[0xca7f00]` equivalent).
pub(crate) interior_fog: Vec<bool>,
}
/// [`compute_pvs`] with a [`FloodTrace`] tap on every decision — the same single flood; the probe dump
@@ -621,9 +712,10 @@ fn compute_pvs_traced<T: FloodTrace>(
clip_from_world: &Mat4,
world_from_local: &Affine3A,
trace: &mut T,
) -> Vec<bool> {
) -> GroupPvs {
let nav = &model.group_nav;
let mut visible = vec![false; nav.len()];
let mut interior_fog = vec![false; nav.len()];
// Seed: the down-ray's set — the camera's current group AND, when the nearest crossing was a
// portal, the group on its other side. Each is an independent flood ROOT with the full screen
@@ -632,21 +724,23 @@ fn compute_pvs_traced<T: FloodTrace>(
// under-floods at exactly the near-portal moment the second root exists to cover). Over no surface
// (open air) or an exterior one ⇒ the outside leg, seeding each EXTERIOR group full-screen (Bevy
// then frustum-culls the genuinely off-screen ones by their Aabb). Stack item: (group, came_from,
// working screen rect, depth).
let mut stack: Vec<(usize, usize, Rect, u32)> = Vec::new();
// working screen rect, depth, interior-chain intact).
let mut stack: Vec<(usize, usize, Rect, u32, bool)> = Vec::new();
let seeds = down_ray_seeds(model, eye_local, terrain_z);
trace.seed(seeds);
match seeds.in_group {
Some(c) => {
stack.push((c, usize::MAX, FULL_SCREEN, 0));
stack.push((c, usize::MAX, FULL_SCREEN, 0, true));
if let Some(a) = seeds.across {
stack.push((a, usize::MAX, FULL_SCREEN, 0));
stack.push((a, usize::MAX, FULL_SCREEN, 0, true));
}
}
None => {
// The outside leg — no containing map object, so nothing takes the interior fog lane
// (the client's selector bails on `[0xc7b748] == 0` ahead of all of this, round 5 Q-G).
for (gi, g) in nav.iter().enumerate() {
if g.flags & EXTERIOR != 0 {
stack.push((gi, usize::MAX, FULL_SCREEN, 0));
stack.push((gi, usize::MAX, FULL_SCREEN, 0, false));
}
}
}
@@ -656,16 +750,32 @@ fn compute_pvs_traced<T: FloodTrace>(
// (`wow-5875-re` `wmo-insideleg-phase3.md`): only then is the exterior shell drawn (below).
let mut deferred_exterior = false;
let mut iters = 0u32;
while let Some((g, came, rect, depth)) = stack.pop() {
while let Some((g, came, rect, depth, chain)) = stack.pop() {
iters += 1;
if iters > MAX_ITERS || g >= nav.len() {
continue;
}
visible[g] = true;
// The interior-fog gate ([`GroupPvs::interior_fog`]): this group draws with the building's
// own MFOG triple iff the walk that reached it kept an unbroken interior chain from the
// seed AND the group is itself a true interior. ORed over paths — a group two walks reach
// takes the lane if EITHER kept the chain.
//
// The second term is the DRAWER ROUTER folded in (`0x6b3fe8` picks interior-vs-exterior on
// the same `flags & 0x48`): an exterior/exterior-lit group is drawn by `0x6b4f10`, which
// pushes no fog at all, so `[0xca7f00]` cannot reach its surfaces however the walk got
// there — and that is true of the SEED's own group too when the camera stands in a
// courtyard. Folding it here rather than ANDing it in each of the three consuming lanes
// keeps one definition of "which fog does this room wear".
interior_fog[g] |= chain && nav[g].flags & (EXTERIOR | EXTERIOR_LIT) == 0;
if depth >= DEPTH_CAP {
continue;
}
let nav_g = &nav[g];
// The chain breaks BELOW an exterior/exterior-lit group, never at the seed: the seed forces
// the lane on for its own group (`0x6b3b4c`/`0x6b3df5`), and a `flags & 0x48` group is the
// anchor the walk's chain breaks at (`0x6b424f` → `0x6b42c6`).
let chain_on = chain && nav_g.flags & (EXTERIOR | EXTERIOR_LIT) == 0;
let start = nav_g.ref_start as usize;
let end = (start + nav_g.ref_count as usize).min(model.portal_refs.len());
for r in &model.portal_refs[start..end] {
@@ -719,7 +829,7 @@ fn compute_pvs_traced<T: FloodTrace>(
if nav.get(neighbour).is_some_and(|n| n.flags & EXTERIOR != 0) {
deferred_exterior = true;
}
stack.push((neighbour, g, inter, depth + 1));
stack.push((neighbour, g, inter, depth + 1, chain_on));
}
}
@@ -738,7 +848,10 @@ fn compute_pvs_traced<T: FloodTrace>(
}
}
}
visible
GroupPvs {
visible,
interior_fog,
}
}
/// The portal's polygon vertices (WoW model space), or `None` if the span is out of range or
@@ -890,6 +1003,7 @@ mod tests {
world_from_local: Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, true, false],
interior_fog: vec![false; 3],
liquid_visited: vec![false; 3],
flooded: vec![None; 3],
};
@@ -911,6 +1025,48 @@ mod tests {
assert!(key(&[9]).drawn_by(&inst), "index past the set fails open");
}
/// The interior-fog key ORs like `drawn_by` — a prop several rooms name wears the fog of any
/// of them — but a lookup miss reads FALSE where `drawn_by`'s reads true. The asymmetry is
/// deliberate: a missing PVS bit must not blank a building, and a missing fog bit must not
/// paint a room's MFOG onto something the flood never placed.
#[test]
fn the_interior_fog_key_ors_over_rooms_and_fails_closed() {
let inst = WmoPortalInstance {
handle: Handle::default(),
world_from_local: Affine3A::IDENTITY,
name_set: 0,
visible: vec![true; 3],
interior_fog: vec![false, true, false],
liquid_visited: vec![false; 3],
flooded: vec![None; 3],
};
let key = |groups: &[u16]| WmoGroupVis {
instance: Entity::PLACEHOLDER,
groups: Arc::from(groups),
};
assert!(key(&[1]).interior_fogged_by(&inst), "its own room is on it");
assert!(
!key(&[0]).interior_fogged_by(&inst),
"its own room is off it"
);
assert!(
key(&[0, 2, 1]).interior_fogged_by(&inst),
"one referrer on the lane is enough"
);
assert!(
!key(&[0, 2]).interior_fogged_by(&inst),
"no referrer is on it"
);
assert!(
!key(&[]).interior_fogged_by(&inst),
"no referrers: nothing ORs"
);
assert!(
!key(&[9]).interior_fogged_by(&inst),
"index past the set fails CLOSED — the opposite of drawn_by"
);
}
/// **Only `0x8` opens a window onto the open world** (`0x6b44f8`), and the flag values here are
/// Stormwind's own, read off a live cull trace at the director's `.go xyz -8798.70 478.64 95.79`
/// (decision 1148). The city is 306 groups of which **exactly one** — `g46`, `0x08809` — carries