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