From 71d167720556270cab2af5e623cf033b8ca83efe Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 Sep 2026 16:07:44 +0700 Subject: [PATCH] the fire layer sounds, heartbeats blend, and freezes refuse the flinch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the burning city was silent: the director, at a Stratholme pin (map 329, `3471.91 -3366.89 136.85`), reported the burning-building flame layer missing — on the reference it swells as you approach a building — and that is the ambient emitter pool's signature exactly, one channel per sound id positioned at whichever of its emitters is nearest the listener (1867): 88 placements of `StratholmeFireSmokeEmberM/S.m2`, WMO props of `stratholme_b.wmo` with zero render batches, each authoring one `$DSL(6437)` StratholmeFireSmokeEmberSLoop; measured at the pin, the pool's four channels were held by UndeadCampfireSmall / CauldronLoop / TorchLoop / SlimeWaterfall, every one past its own DistanceCutoff, 0 sounding, the fire the fifth entitled entry and withheld by the cap, on a frame reporting 1,004 parked anim hosts each still holding a registration; two independent defects behind one line: (1) `doodad_events::fire_doodad_anim_events` scanned every resident host — no distance, frustum or portal gate — while the four channels are arbitrated by ascending entry index, claim order, so a surplus registration is not harmless but silence; the reference's event scan `0x719370` is reached only from `0x7074b0`'s walk of `[CM2Scene+0x20]`, a worklist emptied every frame (`0x707680`) and refilled by `0x710b90(model, 1)` from the terrain drain `0x683f80`, which walks only the bucket the scene-walk cull `0x683700` appended to — frustum, horizon occlusion, and the radius-tiered horizontal fade whose `alpha ≤ 0` is a hard cutoff — so a culled doodad's `$DSL` never fires and it never takes one of the pool's 32 entries; our prose — at four sites — had read wow-re's "linkage" as "loaded", and the scanner now honours `DoodadAnimHost::active`, benilla's one spelling of that membership; which made the pin MORE silent, because `gate_doodad_anim`'s meshless branch built its own `EmitterFade { instance: None, room: None }` on the reasoning that a particles-only model is not a building's prop — Stratholme's fire is 88 of them — so it fell to `ExteriorGate::admits_sphere`, and a sealed room is `Windows([])`, which admits nothing; a placement now has ONE draw-set gate, built once by `spawn::fx::emitter_fade` and handed to its emitters, ribbons, glow lights and anim host alike (a transport's props pass `None` and get `EmitterFade::sphere`, the single spelling of the bare form), so a meshless WMO prop's bones, particles and sound freeze and resume together under its building's portal PVS — which wow-re then CONFIRMED: a WMO `MODD` prop takes the same drain as an ADT `MDDF` prop (`0x684155`), behind a per-frame portal gate (`0x6838f0` runs only for the groups the traversal reaches); (2) the RE round that fix dispatched came back with a correction: we counted the cap on ENTITLEMENT, an out-of-range entry keeping its slot because FMOD was supposedly still playing it attenuated to nothing, and the binary says otherwise — `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`, and `0x7a5870` is bit `0x100000` of `[ch+0x38]`, the DistanceCutoff verdict maintained by a second system the pump never names: `0x7a5ca0` registers the channel into a cull list only when the cutoff is non-zero, and `0x7a5000`, run immediately before `FSOUND_Update`, stops it out of range and re-issues `FSOUND_Stream_PlayEx` back in — so a far, inaudible ambience holds none of the four, neither does one whose start failed, and a cutoff of 0 is never range-culled at all; `admitted()` becomes `cap_step()`, one step of the reference's own fold over ascending entry index — Skip / Service / Retire — with the counter advanced only by a channel read back live from the mixer (`source_kit_playing`), and the pump may service up to all 32 entries looking for four live ones, which is what keeps an out-of-range ambience positioned to start the instant it comes into range; the census names WHICH ids the cap withheld, an edge like any other — "1 withheld" was a count, and *which* is the only question a missing-layer report asks — and reports three states, `serviced [out of range]` costing the cap nothing versus `withheld`, never reached; and the event scan forgets its cursor across a park, since `0x719370`'s window is one frame wide off a clock that never stops and has no persisted cursor, so a culled doodad's missed markers are lost, never replayed — carrying the memory would have burst every `$DSO`/`$SND` the park skipped; retested at the pin: five entitled entries, four out of range, 6437 SOUNDING, nothing withheld, hosts in the animate set 28 → 184, and the log shows the entry claimed by a distant fire coming into view then following to the nearest record 150 ms later — the swell the director described; the Goldshire forge control is unchanged, BellowIN/BellowOut alternating through one entry, 1 sounding; `$DSO`/`$SND` one-shots from culled doodads stop too, equally faithful; and the variation re-roll stays ungated on purpose — gating it would re-roll a whole field's lightning on the frame you turned back, a look for the director to weigh (2059, 2065) - a poured particle is born already aged: the spawn kernels' third argument is the SUBSTEP dt, not the lifespan — the burst loop pushes a literal 0 (`0x7b5600`), both continuous loops push dt (`0x7b5779` interpolated, `0x7b57e2` plain), and the kernels store `particle.age = U01 · w` (`0x7b88c4`/`0x7b88c7`, byte-identical across the plane, sphere and spline kernels) — and benilla birthed everything at 0.0, so the births sharing one frame arrived in lockstep; `birth_age` now seeds a poured particle at a random fraction of the frame it was born in, which is why a steady stream does not pulse at the frame rate, while a burst's N particles keep one clock and age as one body; read as the LIFESPAN instead, `w` would scatter a burst over `[0, lifespan)` — half the integrated brightness, half the mean radius, half every steady pool's population — and an audit here proposed exactly that reading, which wow-re's own measured count (N = rate × lifespan = 300, confirmed) had already ruled out and the bytes refute, so `a_burst_births_every_particle_at_age_zero` pins it; one named approximation: the reference substeps at a hard 0.1 s (`[0x81d828]`), bounding its dt by construction, and benilla does not substep, so the frame delta is clamped at 0.1 s in `birth_age` instead — identical below 0.1 s, which is every frame that is not a hitch - the wound-flinch gate is the freeze node, and the swing report stays open: the director's report of a warrior in a many-mob brawl whose swings read as more suppressed and cut short by incoming impacts than the reference's had survived a full re-read of the victim side with every mechanism matching, and one reference gate benilla did not model had turned up on the way — the wound trigger `0x60ea70` refuses the flinch while any CEffect node on the victim's `+0xb4` list carries `+0x2c & 0x4` (`0x60eab6`); wow-re's cross-check pinned the bit's one setter image-wide, `0x6201d0`, the node the kit CharProc applier `0x60d7c0` creates for CharProcType 11: it saves the owner's playback rates (mount root, body key-bone 4, body root), overrides them with `CharParamZero` via `0x712910` and ORs the bit in, and the teardown `0x6203e0` restores them — the freeze node 0889 already models as `AuraNodes::head_anim_rate`, seen from the other end; while it lives every flinch is refused — melee, spell, missile, `$HIT`; in shipped data 17 kits carry a proc 11, 15 at rate 0.0, all referenced only as state kits — Freezing Trap, Ice Block, Petrify, Entangle, web wrap, the frozen-in-place family, and not the stuns (kits 349 / 33 carry none) — and the gate is the node's presence, kit 3071's rate of 1.0 closing it exactly like 0.0; a level-60 warrior brawl spawns none of them — Bloodrage, Berserker Rage, Enrage, Death Wish, Battle Shout, Demoralizing Shout, Sunder Armor, Heroic Strike, Rend, an incoming Renew or Power Word: Shield, the creature Strike/Cleave/Thunderclap family, a mage's Frost Nova (state kit 285) — so this gate does not explain the report; the lead is dead, verified, and the report stays open with the move trace (`WOW_MOVE_TRACE_TAGS=fct,anim`) now carrying every wound trigger, evict and expire beside every swing play and fast-path, so the per-swing flinch count and timing can be read off one run instead of guessed at; the driver refuses the wound while `AuraNodes::head_anim_rate()` is `Some` — the node's presence, not its rate — traced as `wound trigger id= REFUSED (a proc-11 rate node is attached)`; `KitPlay::wound` runs the kit tail's `[8,10]` branch for a discrete play and not for the impact hand-off's state stage (`0x60f383`: a state kit runs the base recompute only); 2058's `0x6ea280` hostility-classifier decode is CONFIRMED (`Targets & 0x100 → 1`, `& 0x80 → 2`, else the per-effect implicit-target tables `0x6ea338`/`0x6ea378`); tests: a rate-0 node and a rate-1.0 node both refuse the flinch and no node flinches, an instant harmful `SMSG_SPELL_GO` lays exactly one wound per hit target with a state kit naming CombatWound adding nothing, a helpful one lays none, and a missile arrival wounds regardless of hostility; named and not done: an effect model's own `$HIT` event flinching the unit it is attached to (`0x61f6f0`), and the reflect impact on the caster (`0x6e8cb0`) (2063) - tag `0x26` is the teleport's, not the heartbeat's: wow-re's cross-check of the relay family — one client handler `0x603bb0` → `OnUnitMoveEvent 0x601580`, thirty opcodes — confirmed 2061's teleport claim and corrected an attribution this client had rested on since 0601: the queued move-event node's tag that `0x619030` (facing interp) and `0x619090` (position reconcile) both bail on (`cmp [eax+0xc], 0x26` at `0x61904b`) belongs to `MSG_MOVE_TELEPORT`, and the heartbeat's is `0x25`; benilla had excluded the heartbeat from both blends for two years and blended the teleport — which it never parsed, so nothing contradicted it; checked on this side before acting: `push 0x26` occurs at exactly two addresses in the movement region, `0x6186bd` and `0x618736`, inside functions whose only callers are `0x602f90` and `0x602fb0`, and `0x602fb0`'s other branch pushes `0xc7` = `MSG_MOVE_TELEPORT_ACK`; it was most wrong exactly where it mattered most — the heartbeat is the ~500 ms pulse a moving player sends, and a mover running straight sends nothing else carrying position (0617), so a watched player running in a straight line had their position snapped at 2 Hz instead of blended, a second cause, of our own, for the "2 Hz height staircase" 0626/1545 were built around (the ground clamp stays: our floor and the server's genuinely differ, and no blend fixes a missing collider); `reconciles()` is now `verb != Teleport`, and the three opcode-derived booleans this was heading toward are one closed `RelayVerb` enum — `Pose` / `Heartbeat` / `Teleport` / `Root(bool)` — so every receiver has to say what it does with each — nothing had forced a reader to enumerate what an opcode could mean, which is how the tag mistake lived as long as it did; two more corrections from the same cross-check: `MSG_MOVE_ROOT`/`MSG_MOVE_UNROOT` are opcode-authoritative — after the merge the client runs `SetRoot 0x7c7340` (`or 0x1000`, then the one-shot motion wipe `& 0xffe07f00`) or `ClearRoot 0x7c7370` unconditionally, the flags word getting no vote; vmangos always sends a word that already agrees (`MovementHandler.cpp:1070`), so it changes nothing today and stops the root depending on the server continuing to be careful — and a relayed flags word is MERGED, not assigned (`new = old ^ ((old ^ wire) & 0x75a07dff)` at `0x618de7`), which the self lane already did and the remote lane did not; still consequence-free, since nothing reads the one client-owned bit, ON_TRANSPORT, off `RemoteMotion::flags`, but lifted to `move_flags::merge_server_authored` so both lanes share one implementation; pinned both ways — the teleport skips, Heartbeat / Pose / Root all arm, and a deliberately contradictory `MSG_MOVE_ROOT` whose word says running roots the mover and wipes the direction bits anyway; open from the same round: the transport re-anchor (`0x618b30 → 0x7c6900`), which benilla reaches by a different route and is worth proving equivalent, and `MovementInfo +0x04`, which the reference neither parses nor zeroes and whose readers were not censused (2064) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/aura_visual.rs | 10 + .../benilla-app/src/creature_anim/driver.rs | 41 ++- .../src/creature_anim/driver/tests.rs | 69 +++++ .../benilla-app/src/creature_anim/select.rs | 13 + .../src/creature_anim/spell_visual.rs | 14 +- .../src/creature_anim/spell_visual/tests.rs | 111 ++++++++ crates/benilla-app/src/doodad_events.rs | 70 ++++- crates/benilla-app/src/entities/wmo_props.rs | 5 + crates/benilla-app/src/net/apply.rs | 6 +- crates/benilla-app/src/net/motion/relay.rs | 33 +-- crates/benilla-app/src/net/motion/remote.rs | 72 +++-- crates/benilla-app/src/net/motion/tests.rs | 94 ++++--- crates/benilla-app/src/player/wire_in.rs | 2 +- crates/benilla-app/src/sound/emitter_pool.rs | 213 +++++++++++---- crates/benilla-protocol/src/events.rs | 16 +- crates/benilla-protocol/src/events/decode.rs | 8 +- crates/benilla-protocol/src/lib.rs | 6 +- crates/benilla-protocol/src/messages/mod.rs | 4 +- .../benilla-protocol/src/messages/movement.rs | 61 +++++ crates/benilla-protocol/tests/movement.rs | 41 +-- crates/benilla-world/src/doodad_anim/lazy.rs | 2 +- crates/benilla-world/src/doodad_anim/mod.rs | 245 +++++++++++++----- crates/benilla-world/src/particles.rs | 13 + crates/benilla-world/src/particles/sim.rs | 73 +++++- .../src/terrain_stream/spawn/assemble.rs | 60 ++++- .../src/terrain_stream/spawn/fx.rs | 3 +- .../src/terrain_stream/spawn/mod.rs | 36 ++- 27 files changed, 1037 insertions(+), 284 deletions(-) diff --git a/crates/benilla-app/src/aura_visual.rs b/crates/benilla-app/src/aura_visual.rs index f7e402c1..f67a42cf 100644 --- a/crates/benilla-app/src/aura_visual.rs +++ b/crates/benilla-app/src/aura_visual.rs @@ -233,6 +233,16 @@ impl AuraNodes { pub(crate) fn head_anim_rate(&self) -> Option { self.rate.first().map(|(_, r)| *r) } + + /// A node list holding one proc-11 rate node — the state a freeze aura leaves on a unit, for + /// the driver's wound-refusal tenant (decision 2063) without running the aura-slot watcher. + #[cfg(test)] + pub(crate) fn with_rate_node_for_tests(spell_id: u32, rate: f32) -> Self { + Self { + rate: vec![(spell_id, rate)], + ..Default::default() + } + } } /// Publish every rig's body tint to the per-instance channel ([`benilla_world::instance_tint`], decision diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index 71c71d7a..967e960c 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -298,6 +298,10 @@ pub(super) fn drive_animations( // hover, walk-mode, swim. `unify` folds them into the flags word the selector reads, so // a creature the server has rooted or put in walk mode is animated as one. Option<&crate::net::UnitMoveModes>, + // The unit's aura CharProc nodes — read for ONE thing here: whether a proc-11 + // animation-rate node (the freeze auras' `0x6201d0` node, decision 0889) is on the + // unit, which is the client's `+0x2c & 0x4` wound-flinch refusal (decision 2063). + Option<&crate::aura_visual::AuraNodes>, ), )>, // A mount child's movement view is its HOST's (decision 0441): the same @@ -458,6 +462,7 @@ pub(super) fn drive_animations( nock_latched, transform, move_modes, + aura_nodes, ), ) in &mut units { @@ -1376,16 +1381,32 @@ pub(super) fn drive_animations( WoundEdge::Melee(hit_info) => select::wound_anim(hit_info, engaged), WoundEdge::Spell => select::wound_anim(0, engaged), }; - wound_trigger( - &mut drv, - &mut player, - anims, - catalog, - &mut rng, - id, - &mv, - mounted, - ); + // The trigger's fourth entry gate (`0x60eaac`–`0x60eac8`, wow-re + // `charproc-rate-override-wound-gate.md`, decision 2063): a CharProc-11 rate-override + // node on the unit's effect list — the freeze auras' node, Freezing Trap / Ice Block / + // petrify / web wrap — refuses EVERY flinch, melee and spell alike, for as long as it + // lives. The gate is the node's presence, not its rate (kit 3071's 1.0 gates too), so + // it reads the node list, not the pause the rate applies. + let rate_node = aura_nodes.is_some_and(|n| n.head_anim_rate().is_some()); + if rate_node { + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!("wound trigger id={id} REFUSED (a proc-11 rate node is attached)"), + ); + } + } else { + wound_trigger( + &mut drv, + &mut player, + anims, + catalog, + &mut rng, + id, + &mv, + mounted, + ); + } } // ── The per-animation sheath reconcile (decision 0080 structure 3 — the client's diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index 860a01f7..0aa49bc8 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -638,6 +638,75 @@ fn spell_flinch_picks_the_wound_by_engagement() { assert_eq!(node(idle), Some(2), "unengaged: StandWound(8)"); } +/// The wound trigger's fourth entry gate (`0x60eaac`–`0x60eac8`, wow-re +/// `charproc-rate-override-wound-gate.md`; decision 2063): a CharProc-11 rate-override node on +/// the unit — what a freeze aura (Ice Block, Freezing Trap, petrify, web wrap) leaves attached — +/// refuses every flinch for its life. The gate is the node's *presence*: kit 3071's rate of 1.0 +/// (no freeze at all) closes it exactly like the family's 0.0. A unit without the node flinches. +#[test] +fn a_rate_override_node_refuses_the_wound() { + fn model() -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: vec![clip(0, 1, true), clip(8, 2, false)], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } + let mut app = app(); + let spawn = |app: &mut App, nodes: Option| { + let mut e = app.world_mut().spawn(( + model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + )); + if let Some(n) = nodes { + e.insert(n); + } + e.id() + }; + let frozen = spawn( + &mut app, + Some(crate::aura_visual::AuraNodes::with_rate_node_for_tests( + 11958, 0.0, + )), + ); + let held_at_one = spawn( + &mut app, + Some(crate::aura_visual::AuraNodes::with_rate_node_for_tests( + 3071, 1.0, + )), + ); + let free = spawn(&mut app, None); + app.update(); + for e in [frozen, held_at_one, free] { + app.world_mut().write_message(WoundAnim { entity: e }); + } + app.update(); + let wounded = |e: Entity| { + app.world() + .entity(e) + .get::() + .unwrap() + .wound + .is_some() + }; + assert!( + !wounded(frozen), + "Ice Block's rate-0 node refuses the flinch" + ); + assert!( + !wounded(held_at_one), + "the gate is the node, not the rate: a 1.0 node refuses too" + ); + assert!(wounded(free), "no node: the flinch lays as before"); +} + /// The whiff slow-down touches SWING anims only (decision 0279's scoping): a spell kit's /// full-body special (Special1H 57) rides the same `Mode::Swing` slot, and a concurrent /// auto-attack miss must not drag it to half speed — the director's "the Eviscerate spin diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index fe920b4e..05d23388 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -191,6 +191,19 @@ pub(crate) mod move_flags { /// turn bits, walk mode, root, [`FALLING`]/[`FALLING_FAR`], swim, water-walk — is inside. pub const SERVER_AUTHORED: u32 = 0x75a0_7dff; + /// Merge a server-authored packet's `MOVEMENTFLAGS` into a mover's own — the reference's masked + /// merge (`0x618c30 @0x618de7-df3`: `new = old ^ ((old ^ wire) & 0x75a07dff)`), **not** an + /// assignment. One law, both movers: our own (`player::wire_in`'s self-addressed pose) and every + /// watched one (`net::motion::remote::apply_move`), because the reference runs this merge inside + /// the one scheduler both go through and the mask is arm-invariant across all thirty relay + /// opcodes (wow-re `collision/scratch/movement-relay-family-map.md`, decision 2064). + /// + /// The omission that bites is pinned by test on the self lane: [`ON_TRANSPORT`] sits **outside** + /// the mask, so a server-authored pose can relocate a rider but never board or deboard them. + pub const fn merge_server_authored(local: u32, wire: u32) -> u32 { + (local & !SERVER_AUTHORED) | (wire & SERVER_AUTHORED) + } + /// Any horizontal-movement direction bit (forward/back/strafe) — the client's `[9e8] & 0xf` gate. pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT; diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 7052fa39..52d61aae 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -556,6 +556,13 @@ struct KitPlay { sound: bool, /// The stage this play is, for the instances' animation lifecycle ([`FxStage`]). stage: FxStage, + /// Whether the kit tail's anim branch may lay a wound for an `AnimID ∈ [8,10]` — the + /// client's `0x60f383`: a **stage-2** (state) play runs the base recompute only and never + /// reaches the `[8,10]` test, so a state kit naming a wound anim wounds nobody (wow-re + /// `charproc-rate-override-wound-gate.md` §7 (A), decision 2063). The other exclusion there — + /// stage 4 with a zero cast time — never reaches this branch in benilla: a precast kit's anim + /// becomes the [`CastHold`], not a `play_kit` call. + wound: bool, } impl KitPlay { @@ -566,6 +573,7 @@ impl KitPlay { effects: true, sound: true, stage: FxStage::OneShot, + wound: true, }; } @@ -581,7 +589,10 @@ fn play_kit( if let Some(anim_id) = kit.anim_id { if (8..=10).contains(&anim_id) { // `0x60f3b8: push 0; call 0x60ea70` — severity 0, the kit's own id is NOT what plays. - out.wounds.write(WoundAnim { entity }); + // A state-stage play never gets here in the client ([`KitPlay::wound`]). + if play.wound { + out.wounds.write(WoundAnim { entity }); + } } else { out.oneshots.write(EmoteAnim { entity, @@ -686,6 +697,7 @@ fn play_impact( |s: &VisualStages| s.state, KitPlay { effects: false, + wound: false, ..KitPlay::DISCRETE }, ), diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index 62080aa1..25bebf0f 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -607,6 +607,117 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { /// cast kit plays a body animation emits its [`MissileSpawn`] deferred (`awaits_release`) — /// the launch waits for the animation's release keyframe — while a cast kit with no animation /// (or none at all) launches at GO. +/// The spell impact body twitch (decision 2058, sharpened by 2063): an instant harmful spell's GO +/// lays ONE severity-0 wound on each hit target (`0x6e8bf0` @ `0x6e8c89`), a helpful one lays none; +/// a state kit naming a wound anim adds nothing on either (the client's stage-2 play never reaches +/// the `[8,10]` test, `0x60f383`); and a missile ARRIVAL wounds unconditionally (`0x61dc50` @ +/// `0x61dc74`) — even for the helpful spell. +#[test] +fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() { + const HARMFUL: u32 = 7386; // Sunder Armor's shape: instant, enemy-targeted + const HELPFUL: u32 = 139; // Renew's shape: instant, ally-targeted + const VISUAL_H: u32 = 406; + const VISUAL_F: u32 = 280; + const IMPACT_KIT: u32 = 556; // no anim + const STATE_KIT: u32 = 436; // names CombatWound(9) — must add nothing + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::(); + let stages = VisualStages { + impact: IMPACT_KIT, + state: STATE_KIT, + ..Default::default() + }; + app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( + HashMap::from([(VISUAL_H, stages), (VISUAL_F, stages)]), + HashMap::from([ + ( + IMPACT_KIT, + VisualKit { + anim_id: None, + ..Default::default() + }, + ), + ( + STATE_KIT, + VisualKit { + anim_id: Some(9), + ..Default::default() + }, + ), + ]), + ))); + app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([ + ( + HARMFUL, + SpellDisplay { + visual: VISUAL_H, + targets: 0x80, + ..Default::default() + }, + ), + ( + HELPFUL, + SpellDisplay { + visual: VISUAL_F, + targets: 0x100, + ..Default::default() + }, + ), + ])), + ..crate::ui_action::Spells::empty_for_tests() + }); + app.add_systems(Update, route_cast_visuals); + + let caster = app.world_mut().spawn_empty().id(); + let target = app.world_mut().spawn_empty().id(); + let wounds = |app: &mut App| -> Vec { + app.world_mut() + .resource_mut::>() + .drain() + .map(|w| w.entity) + .collect() + }; + for (spell_id, expected) in [(HARMFUL, vec![target]), (HELPFUL, Vec::new())] { + app.world_mut() + .write_message(cast_event(caster, spell_id, CastEventKind::Go)); + app.world_mut().write_message(SpellGoTargets { + caster, + spell_id, + hits: vec![target], + misses: Vec::new(), + dest: None, + ammo_display_id: None, + seq: 1, + }); + app.update(); + assert_eq!(wounds(&mut app), expected, "instant GO of spell {spell_id}"); + } + // A missile landing wounds whoever it lands on, hostility untested. + app.world_mut().write_message(cast_event( + target, + HELPFUL, + CastEventKind::Impact { + weapon_visual: None, + }, + )); + app.update(); + assert_eq!(wounds(&mut app), vec![target], "missile arrival"); +} + #[test] fn missile_spawn_defers_iff_the_cast_kit_animates() { const ANIMATED: u32 = 133; // Fireball's shape: cast kit with anim 53 diff --git a/crates/benilla-app/src/doodad_events.rs b/crates/benilla-app/src/doodad_events.rs index f9d3b463..ea8c4bd3 100644 --- a/crates/benilla-app/src/doodad_events.rs +++ b/crates/benilla-app/src/doodad_events.rs @@ -16,13 +16,58 @@ //! `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 — +//! clock: the reference arms **every** placed doodad it creates and cycles it whenever the doodad is +//! in the frame's animate set (wow-re `doodad-anim-host.md` §1/§4a/§5b — see the gate below). So +//! `doodad_anim::spawn_anim_host` 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. +//! **The scan is gated on the frame's ANIMATE SET, and that gate is the whole mechanism** — not a +//! budget, and not an optimisation (decision 2059). The reference's event scan `0x719370` is +//! reached from exactly one place: `0x7074b0`'s walk of the per-frame M2 scene worklist +//! `[CM2Scene+0x20]`, which is **emptied every frame** and refilled only by `0x710b90(model, 1)` +//! from the terrain doodad drain `0x683f80` — and the drain walks only the bucket the scene-walk +//! cull `0x683700` appended to, i.e. a doodad that passed frustum + horizon-occlusion **and** came +//! out of the radius-tiered horizontal distance fade with `alpha > 0`. A doodad that fails any of +//! those is not in the worklist, so its event track is never scanned, so **its `$DSL` never +//! fires and it never takes one of the emitter pool's 32 entries** (wow-re +//! `terrain/scratch/doodad-emitter-drawset-gate.md` §1b/§1c/§2b, `animation/scratch/ +//! doodad-anim-host.md` §5b, `object-layer/scratch/unit-anim-visibility-gate.md` §4). +//! +//! Ungated, this scanner registered **every resident doodad in the streaming radius**, and with the +//! pool's four channels arbitrated by claim order (`super::sound::emitter_pool`) that is not a +//! harmless surplus — it is silence. Measured at the director's Stratholme pin +//! (`.go xyz 3471.91 -3366.89 136.85 329`, 2026-09-07): all four channels held by +//! `UndeadCampfireSmall` / `CauldronLoop` / `TorchLoop` / `SlimeWaterfall`, **every one of them +//! past its own `DistanceCutoff` and therefore inaudible**, while the fifth entitled entry — +//! `StratholmeFireSmokeEmberSLoop`, the burning-building layer the director reported missing, with +//! 88 emitters right there — was withheld by the cap. `0 sounding`, on the same frame that reported +//! `parked=1004`: a thousand hosts outside the animate set, each still holding its registration. +//! +//! **"A campfire behind you keeps crackling" is still true, and this gate does not break it** — but +//! by the other mechanism. A registration is released by `$DSE`, by a `$DSL` naming a different id, +//! or by the doodad's own teardown, and by nothing else (`sound/scratch/doodad-sound-emitters.md` +//! §9): turning away stops the *marker firing*, never the entry. The prose this module and +//! [`DoodadAnimHost::arm_clock`] used to carry — "the reference gates the cycle on residency, not +//! the draw" — read `doodad-anim-host.md` §5b's *linkage* as "loaded" when the note means spliced +//! into `[scene+0x20]`, which **is** the drawn/faded set ("a doodad culled out of the drain stops +//! advancing and resumes on re-link"). +//! +//! The clock stays [`DoodadAnimHost::arm_clock`]'s shared one rather than the `AnimationPlayer`'s: +//! while a host is in the animate set the two agree by construction (the gate's resume seeks the +//! player to exactly this value), and the clock-only tier has no player to read at all. +//! +//! **Across a park the memory is DROPPED, not carried** — because the reference's scan window is +//! one frame wide and it never replays. `0x719370` computes `prev = max(windowLo, now − dt)` and +//! `cur = now` (`0x71950f`, for a looping sequence), **both off the absolute scene clock**, which +//! `0x7074c2` advances every frame whether or not the model is linked; a write census over the +//! whole function finds no persisted cursor. So the markers a culled doodad crossed are **lost**, +//! and what actually fires on re-link is the completion watchdog re-arming at `startOffset = 0` +//! (wow-re `doodad-sound-emitters.md` §3, corrected 2026-09-07 by the §5 round decision 2065 +//! records). Keeping [`advance_track`]'s entry across the park would hand the resume frame a +//! `prev` from seconds ago and fire the whole span at once — harmless for a `$DSL` (its re-reach +//! only repositions) and a burst of one-shots for `$DSO`/`$SND`. Dropping it makes the resume an +//! **arm frame**, which fires nothing, and the frame after opens the head window `[0, cur]` — the +//! re-arm, in the shape this scanner already has. use bevy::prelude::*; @@ -58,6 +103,21 @@ fn fire_doodad_anim_events( ) { let now = time.elapsed_secs(); for (entity, host, anims, world, pose) in &hosts { + // The worklist gate (module docs): a host outside this frame's animate set is not in + // `[CM2Scene+0x20]`, so the reference never scans its event track. `active` is benilla's + // one spelling of that membership — `gate_doodad_anim` writes it from the composed + // far-clip + distance-fade + portal verdict on the placement's submeshes, or, for a + // meshless (particles-only) model, from the same fade-sphere + frustum law its emitters + // gate on. It is a `PostUpdate` write and this reads it in `Update`, so the verdict is + // one frame old — the same accepted lag the interior and billboard laws carry, and + // nothing here can tell a 16 ms-late ambience from an on-time one. + if !host.active { + // …and forget where its clip stood. See the module docs: the reference's window is one + // frame wide off a clock that never stops, so a resume must re-arm rather than replay + // everything the park skipped. + last.remove(&entity); + continue; + } let Some((node, cur)) = host.arm_clock(now) else { continue; // gseq-only host with no sound arm: nothing is armed, so nothing fires }; diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index acc0055a..00d11bbe 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -336,6 +336,11 @@ pub(super) fn spawn_wmo_gameobject_props( &object, shade, interior_slot, + // No draw-set gate: a transport's props ride a MOVING parent, and an `EmitterFade` + // measures from a baked world point that a mover has none of — the same reason + // their emitters carry none (module docs above). The assembler builds the bare + // sphere its mesh lane needs from `radius`/`center` instead. + None, radius, center, anim_bound, diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index e640462e..8e70c6ba 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -731,8 +731,7 @@ pub(crate) fn apply_net_updates( flags, pitch, time, - heartbeat, - teleport, + verb, fall_time, jump, transport, @@ -752,8 +751,7 @@ pub(crate) fn apply_net_updates( fall_time, jump, transport, - heartbeat, - teleport, + verb, }, now_ms, &mut commands, diff --git a/crates/benilla-app/src/net/motion/relay.rs b/crates/benilla-app/src/net/motion/relay.rs index 20303cb1..553b9087 100644 --- a/crates/benilla-app/src/net/motion/relay.rs +++ b/crates/benilla-app/src/net/motion/relay.rs @@ -26,7 +26,7 @@ //! idle+empty gate `@0x618ce4`/`@0x618cf3`, re-base `@0x618cfc-d41`, clamps `@0x618d0d`/`@0x618d49`, //! fire store `@0x618dcc`, due test `@0x618dd2`) · `0x618b50` (the ring) · `0x615c30` (the drain). -use benilla_protocol::{JumpInfo, TransportPose}; +use benilla_protocol::{JumpInfo, RelayVerb, TransportPose}; /// One relayed move exactly as it came off the wire — the payload of /// [`benilla_protocol::SessionEvent::UnitMove`], before [`RelayChain`] decides *when* it applies. @@ -44,31 +44,24 @@ pub(crate) struct RelayMove { pub(crate) fall_time: u32, pub(crate) jump: Option, pub(crate) transport: Option, - /// `MSG_MOVE_HEARTBEAT` — excluded from the pre-fire reconcile lerp (the reference's `0x619090` - /// skips tag `0x26`); it applies as an outright snap. - pub(crate) heartbeat: bool, - /// `MSG_MOVE_TELEPORT` — somebody else blinked (decision 2061). Excluded from the pre-fire - /// reconcile for the opposite reason to a heartbeat's: not "the dead-reckon has already - /// converged" but "there is nothing to converge to". The pose is a **discontinuity**; lerping - /// a mover toward it would drag them across the gap the teleport exists to skip, sweeping the - /// capsule through every wall in between. - pub(crate) teleport: bool, + /// **What this packet's opcode means on top of the pose** (decision 2064) — the receiver's + /// switch, and the only thing about a relay that is not in its `MovementInfo`. + pub(crate) verb: RelayVerb, } impl RelayMove { /// **Does this queued move arm the pre-fire reconcile?** The two blends - /// ([`super::remote::facing_lerp`] and the position lerp) exist to make the pose a queued move - /// carries land *smoothly* at its fire-time, so both are skipped for the two kinds of move that - /// smoothing cannot help — for opposite reasons: + /// ([`super::remote::facing_lerp`] and the position lerp) exist to land a queued move's pose + /// *smoothly* at its fire-time, and the reference arms them for every relay but **one**: the + /// queued node's tag `0x26`, which `0x619030` (facing) and `0x619090` (position) both skip. /// - /// - a **heartbeat** needs no blend: the scheduled dead-reckon has structurally converged on it - /// by the fire-time, and the reference skips it explicitly (tag `0x26` at `0x619030 - /// @0x61904b` / `0x619090 @0x6190bb`; decisions 0601/0603); - /// - a **teleport** has nothing to blend *to*: its position is a discontinuity, and lerping the - /// mover into it would walk them — swept capsule and all — across the gap the teleport exists - /// to skip (decision 2061). + /// **That tag is the TELEPORT's, and this client believed for two years it was the + /// heartbeat's** (decision 2064, correcting 0601/0603). A teleport is the one move smoothing + /// cannot help — its position is a discontinuity, so blending toward it walks the mover, swept + /// capsule and all, across the gap the teleport exists to skip. A heartbeat has no such + /// problem and the reference blends it like anything else. pub(crate) fn reconciles(&self) -> bool { - !self.heartbeat && !self.teleport + self.verb != RelayVerb::Teleport } } diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 1c84cb5b..0608ea4e 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -127,15 +127,14 @@ pub(in crate::net) fn trace_relay( return; } let lead = chain.lead_ms(now_ms); - // `tp` is its own tag, not folded into `tr`: a mover that appears in the wrong place is the - // one report where "was there a teleport?" is the whole question, and the trace has to answer - // it without a second run (decision 2061). - let kind = if mv.heartbeat { - "hb" - } else if mv.teleport { - "tp" - } else { - "tr" + // One tag per verb, `tp` and `rt` included: a mover that appears in the wrong place, or keeps + // sliding through a root, is the report where "did that packet arrive?" is the whole question, + // and the trace has to answer it without a second run (decisions 2061/2064). + let kind = match mv.verb { + benilla_protocol::RelayVerb::Heartbeat => "hb", + benilla_protocol::RelayVerb::Teleport => "tp", + benilla_protocol::RelayVerb::Root(_) => "rt", + benilla_protocol::RelayVerb::Pose => "tr", }; benilla_assets::trace::line( "rly", @@ -214,9 +213,11 @@ fn rendered_pitch(rm: &RemoteMotion) -> f32 { /// - **`dz`** — the height the mover gained or lost **this frame**, *excluding* the packet applied /// this frame (the drain runs first, so the anchor already carries it). A grounded mover riding the /// surface moves in Z on every sloped frame; before 0626 the only thing that could move it was the -/// pre-fire reconcile lerp, which skips heartbeats — so a mover running *straight* read `+0.000` -/// sample after sample and took its height as a 2 Hz snap, which is the "delayed terrain snap" -/// report. Read it against `age`: a nonzero `dz` at a large `age` is the ground, not the server. +/// pre-fire reconcile lerp, which *this client* then wrongly skipped for heartbeats (decision +/// 2064: tag `0x26` is the teleport's, not the heartbeat's) — so a mover running *straight* read +/// `+0.000` sample after sample and took its height as a 2 Hz snap, which is the "delayed terrain +/// snap" report. Read it against `age`: a nonzero `dz` at a large `age` is the ground, not the +/// server. /// - **`held`** — how much of this frame's intended horizontal travel the world took away. Sustained /// nonzero is a mover being *held* against a wall by our colliders (right); a flat zero for a mover /// whose own client is stopped dead is the dead-reckon marching into the geometry (wrong), and it @@ -393,7 +394,24 @@ pub(in crate::net) fn apply_move( } rm.wow_pos = ev.position; rm.orientation = ev.orientation; - rm.flags = ev.flags; + // **A merge, not an assignment** (decision 2064): the reference folds a relayed word through + // `0x75a07dff` at `0x618de7`, keeping the client-owned bits outside it — `ON_TRANSPORT` above + // all, which is why a relayed pose relocates a rider without deboarding them. Today every bit + // benilla holds on a watched mover comes from the wire and lands inside the mask, so this + // changes no behaviour; it stops the next bit added from silently being the one that does. + rm.flags = move_flags::merge_server_authored(rm.flags, ev.flags); + // …and then the ONE verb whose opcode outranks the word it arrived with. `SetRoot 0x7c7340` + // sets `0x1000` and wipes the motion bits once (`& 0xffe07f00` — this is what stops a rooted + // body coasting); `ClearRoot 0x7c7370` only clears the bit and invents no movement. Both run + // unconditionally after the merge, so a root holds whether or not the server was careful to + // send an already-wiped word. + match ev.verb { + benilla_protocol::RelayVerb::Root(true) => { + rm.flags = (rm.flags | move_flags::ROOT) & super::modes::ROOT_APPLY_WIPE; + } + benilla_protocol::RelayVerb::Root(false) => rm.flags &= !move_flags::ROOT, + _ => {} + } rm.pitch = ev.pitch; rm.vertical_velocity = vertical_velocity; rm.jump_xy_vel = jump_xy_vel; @@ -454,10 +472,13 @@ pub(in crate::net) fn drain_pending_moves( /// Without it the extrapolation ran in a vacuum, and the two symptoms that follow are the ones the /// director reported. **Height:** [`RemoteMotion::advance`] never moves a grounded mover's Z, so Z /// changed only where a *packet* put it — at the apply, or dragged there by the pre-fire reconcile -/// lerp. The lerp skips heartbeats, and a mover running **straight** sends nothing else that carries -/// position (0617's frame-cadence stream is `SET_FACING`, sent only while turning) — so a straight run -/// gave a 2 Hz height staircase under a continuous XY, which sinks a watched player into ground that -/// rises under them and floats them over ground that falls away, at the same rate. **Geometry:** a +/// lerp. The lerp skipped heartbeats *in this client* — a mis-attribution corrected by 2064, where +/// the reference skips the teleport instead — and a mover running **straight** sends nothing else +/// that carries position (0617's frame-cadence stream is `SET_FACING`, sent only while turning), so +/// a straight run gave a 2 Hz height staircase under a continuous XY, which sinks a watched player +/// into ground that rises under them and floats them over ground that falls away, at the same rate. +/// (The clamp is still the answer — our floor and the server's differ, which no blend can fix — but +/// that particular staircase had a second cause, and it was ours.) **Geometry:** a /// mover held against a wall by its own client keeps reporting `FORWARD` with an unchanged position — /// there is no "I am blocked" bit on the wire, and there needn't be, because the watching client is /// meant to be stopped by the same wall. Ours wasn't: it advanced into the geometry for the whole @@ -673,19 +694,14 @@ pub(in crate::net) fn extrapolate_remote_units( // - **Position** (the 0x619090 arm + 0x6191c0 lerp): while a NON-heartbeat event waits and // the prediction at its fire-time would miss its position by ≥ the 0.0278-yd tolerance, // blend the SIMULATED pose so it lands on the event position at the fire-time. - // A heartbeat is excluded from both arms (`0x619030 @0x61904b` / `0x619090 @0x6190bb` - // skip tag 0x26) — it snaps at fire, and by then the scheduled dead-reckon has - // structurally converged. **A teleport is excluded too** (decision 2061), for the - // opposite reason: not "already converged" but "nothing to converge to". Both blends are - // built to make a *continuous* pose land smoothly; a blink is a discontinuity, and - // blending toward it walks the mover — swept capsule and all — across the very gap the - // teleport skips. + // **Exactly ONE relay is excluded from both arms, and it is the TELEPORT** — the queued + // node's tag `0x26` that `0x619030 @0x61904b` and `0x619090 @0x6190bb` bail on. Decision + // 2064 corrects 0601/0603, which had that tag down as the heartbeat's: a blink is the one + // pose smoothing cannot help, because blending toward a discontinuity walks the mover — + // swept capsule and all — across the gap the teleport exists to skip. A deferred heartbeat + // blends like anything else. if let Some(ev) = rm.pending.front() { let remaining_s = ((ev.fire_ms - now_ms) / 1000.0) as f32; - // A heartbeat is excluded from BOTH pre-fire blends — the reference's facing arm - // `0x619030` skips tag 0x26 exactly as the position arm `0x619090` does (wow-re - // `remote-air-facing.md`, decision 0603) — so it applies as an outright snap at - // fire; the smoothed facings are the transition/SET_FACING family's. if remaining_s > 0.0 && ev.mv.reconciles() { orientation = facing_lerp(orientation, ev.mv.orientation, dt, remaining_s); // Predict from the pre-frame state to the fire-time (this frame's dt + what's left). diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index 79b212ed..4fdeb2b8 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -7,6 +7,8 @@ use std::time::{Duration, Instant}; use benilla_protocol::{JumpInfo, MonsterMoveFacing, MoveSpeeds}; use bevy::prelude::Quat; +use benilla_protocol::RelayVerb; + use crate::creature_anim::move_flags; use crate::player::{GRAVITY, TERMINAL_VELOCITY}; @@ -99,8 +101,7 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() { fall_time: 0, jump: None, transport: None, - heartbeat: false, - teleport: false, + verb: benilla_protocol::RelayVerb::Pose, }; // Through the real arrival path, not a hand-set field: `apply_move` is what the relay and // the queue drain both go through, so this is the seam that would drop the pitch. @@ -891,8 +892,7 @@ fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec, u32) { fall_time: id as u32, // the packet's identity, carried through the queue jump: None, transport: None, - heartbeat: false, - teleport: false, + verb: benilla_protocol::RelayVerb::Pose, }; let (live, empty) = (rm.flags, rm.pending.is_empty()); let fire_ms = rm.relay.schedule(wire_ms, now, live, empty); @@ -1197,8 +1197,9 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() { // ── The observer leg of the movement-mode family (decision 2061) ────────────────────────────── /// Build the `RelayMove` the six observer opcodes decode to — an ordinary relay carrying whatever -/// flags word the server wrote, since apply/unapply rides that word and not the opcode. -fn observed(flags: u32, position: [f32; 3], teleport: bool) -> super::relay::RelayMove { +/// flags word the server wrote (apply/unapply rides that word for five of the six) plus the +/// opcode's verb, which matters for root and teleport alone. +fn observed(flags: u32, position: [f32; 3], verb: RelayVerb) -> super::relay::RelayMove { super::relay::RelayMove { wire_ms: 1000, position, @@ -1208,8 +1209,7 @@ fn observed(flags: u32, position: [f32; 3], teleport: bool) -> super::relay::Rel fall_time: 0, jump: None, transport: None, - heartbeat: false, - teleport, + verb, } } @@ -1253,7 +1253,10 @@ fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() { ); // What vmangos actually broadcasts: ROOT set, the direction bits gone. - let rooted = apply_observed(walking, &observed(move_flags::ROOT, [0.0; 3], false)); + let rooted = apply_observed( + walking, + &observed(move_flags::ROOT, [0.0; 3], RelayVerb::Root(true)), + ); assert_eq!( rooted.flags & move_flags::INTEGRATED, @@ -1276,40 +1279,47 @@ fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() { #[test] fn an_observed_levitate_lands_all_three_granted_bits() { let trio = move_flags::HOVER | move_flags::SAFE_FALL | move_flags::WATER_WALKING; - let floating = apply_observed(motion(0, 0.0), &observed(trio, [0.0; 3], false)); + let floating = apply_observed(motion(0, 0.0), &observed(trio, [0.0; 3], RelayVerb::Pose)); assert_eq!( floating.flags, trio, "hover + feather fall + water walk, from one broadcast word" ); // …and the un-levitate is the same word with the bits gone — no opcode says "unapply". - let landed = apply_observed(floating, &observed(0, [0.0; 3], false)); + let landed = apply_observed(floating, &observed(0, [0.0; 3], RelayVerb::Pose)); assert_eq!(landed.flags, 0, "the revoke is the absence of the bits"); } -/// **A blink is a discontinuity, and the pre-fire reconcile must not blend into it** (decision -/// 2061). Both blends exist to land a *continuous* pose smoothly; armed on a teleport they would -/// drag the mover across the 20 yards the Blink exists to skip — sweeping the shared capsule -/// through every wall in between — which is the same "he slid there" symptom, wearing a new hat. +/// **The teleport is the ONE relay the pre-fire reconcile skips — and the heartbeat is not** +/// (decision 2064, correcting 0601/0603). /// -/// The pose itself still applies in full: a teleport is excluded from the *blend*, never from the -/// apply. +/// The queued node's tag `0x26`, which `0x619030` (facing) and `0x619090` (position) both bail on, +/// is the teleport's: `push 0x26` occurs at exactly two addresses in the movement region +/// (`0x6186bd`, `0x618736`), both inside functions reached only from the teleport arms — and +/// `0x602fb0`, one of the two callers, sends `push 0xc7` (`MSG_MOVE_TELEPORT_ACK`) on its other +/// branch. This client had it attributed to the heartbeat since 0601 and blended the wrong one. +/// +/// Both halves are asserted, because getting either backwards is a distinct visible bug: blending +/// toward a blink drags the mover — swept capsule and all — across the 20 yards it exists to skip, +/// and *not* blending a heartbeat leaves a watched player's straight run snapping at 2 Hz, which is +/// the whole reason the blends exist. And a teleport is excluded from the *blend*, never the apply. #[test] -fn a_teleport_is_excluded_from_the_pre_fire_reconcile_but_not_from_the_apply() { +fn the_teleport_is_the_only_relay_the_reconcile_skips() { let dest = [20.0, 5.0, 3.0]; - let tp = observed(move_flags::FORWARD, dest, true); - let hb = super::relay::RelayMove { - heartbeat: true, - ..observed(move_flags::FORWARD, dest, false) - }; - let walk = observed(move_flags::FORWARD, dest, false); + let tp = observed(move_flags::FORWARD, dest, RelayVerb::Teleport); - assert!(!tp.reconciles(), "a blink is not blended toward"); - assert!(!hb.reconciles(), "nor is a heartbeat (decision 0601)"); - assert!( - walk.reconciles(), - "…but an ordinary transition still is — the smoothing this gate protects" - ); + assert!(!tp.reconciles(), "a blink is never blended toward"); + for armed in [ + RelayVerb::Heartbeat, + RelayVerb::Pose, + RelayVerb::Root(true), + RelayVerb::Root(false), + ] { + assert!( + observed(move_flags::FORWARD, dest, armed).reconciles(), + "{armed:?} arms both blends — tag 0x26 is the teleport's alone" + ); + } let there = apply_observed(motion(move_flags::FORWARD, 0.0), &tp); assert_eq!( @@ -1317,3 +1327,27 @@ fn a_teleport_is_excluded_from_the_pre_fire_reconcile_but_not_from_the_apply() { "the teleport pose lands outright: excluded from the blend, never from the apply" ); } + +/// **The root opcode outranks the flags word it arrived with** (decision 2064). After the masked +/// merge the client runs `SetRoot 0x7c7340` — `or 0x1000`, then the one-shot motion wipe +/// `& 0xffe07f00` — unconditionally, so a `MSG_MOVE_ROOT` roots the mover even if the word it +/// carried still had direction bits in it. vmangos always sends an already-wiped word, which is +/// exactly why this is worth pinning: nothing in a live run would catch it going wrong. +#[test] +fn the_root_opcode_wins_over_a_word_that_disagrees_with_it() { + // A deliberately contradictory packet: the opcode says root, the word says "running forward, + // not rooted". The reference roots them anyway. + let liar = observed(move_flags::FORWARD, [0.0; 3], RelayVerb::Root(true)); + let rooted = apply_observed(motion(move_flags::FORWARD, 0.0), &liar); + assert_ne!(rooted.flags & move_flags::ROOT, 0, "the opcode set the bit"); + assert_eq!( + rooted.flags & move_flags::INTEGRATED, + 0, + "and the one-shot wipe took the direction bits with it — this is what stops the slide" + ); + + // The unroot clears the bit and invents no movement: `ClearRoot 0x7c7370` is `and ~0x1000`, + // not the wipe run backwards. + let freed = apply_observed(rooted, &observed(0, [0.0; 3], RelayVerb::Root(false))); + assert_eq!(freed.flags, 0, "cleared, and still not moving"); +} diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index 4258ef8f..a4e5f2fe 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -529,7 +529,7 @@ pub(super) fn apply_server_moves( /// a server-authored pose can relocate a rider but never board or deboard them. See /// [`move_flags::SERVER_AUTHORED`]. pub(super) fn merge_server_flags(local: u32, wire: u32) -> u32 { - (local & !move_flags::SERVER_AUTHORED) | (wire & move_flags::SERVER_AUTHORED) + move_flags::merge_server_authored(local, wire) } /// Apply one bare self-addressed `MSG_MOVE_*` — a pose the *server* wrote for our own mover, with diff --git a/crates/benilla-app/src/sound/emitter_pool.rs b/crates/benilla-app/src/sound/emitter_pool.rs index e50c3ed5..f004b21c 100644 --- a/crates/benilla-app/src/sound/emitter_pool.rs +++ b/crates/benilla-app/src/sound/emitter_pool.rs @@ -114,8 +114,9 @@ struct Entry { 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`]. + /// resolvable, and at least one emitter registered.) Entitlement, not audibility — it decides + /// whether the cap walk *services* the entry, never whether it *counts*; the composite return + /// of steps 4–6 decides that (see [`cap_step`], decision 2065). fn entitled(&self) -> bool { self.id != 0 && !self.failed && !self.records.is_empty() } @@ -163,6 +164,10 @@ pub(super) struct AmbientEmitterPool { /// 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)>, + /// The last logged **withheld** set — the entitled ids past the cap. Part of the edge key for + /// the same reason liveness is: *which* ambience the cap took away is the whole question a + /// report of a missing layer asks, and the census could only ever say how many (2059). + last_withheld: Vec, } impl Default for AmbientEmitterPool { @@ -173,6 +178,7 @@ impl Default for AmbientEmitterPool { fading: Vec::new(), complained: HashSet::new(), last_census: Vec::new(), + last_withheld: Vec::new(), } } } @@ -312,26 +318,53 @@ impl AmbientEmitterPool { } } -/// The entries that get a channel this frame: the first [`PLAYING_CAP`] entitled ones **by -/// ascending index** (`0x4619c0`). +/// What the cap walk does with one entry, folded over ascending index +/// (`0x4619c0`–`0x4619f9`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CapStep { + /// Not entitled — free, failed, or emitter-less. Costs the cap nothing and gets no service. + Skip, + /// Under the cap: service it (start or reposition), then let the channel say whether it + /// counts. + Service, + /// Four are already sounding — fade this entry's channel out over [`FADE_SECS`] and leave + /// everything else on the entry (`0x4619c5`). + Retire, +} + +/// One step of the cap walk. **`sounding_so_far` counts entries that came back LIVE AND IN RANGE, +/// not entitled ones** — the correction of 2026-09-07 (decision 2065). /// /// 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. +/// one surprising property: standing where five distinct doodad ambiences are *audible*, 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 { - entries - .iter() - .enumerate() - .filter(|(_, x)| x.entitled()) - .map(|(i, _)| i) - .take(PLAYING_CAP) - .collect() +/// **What changed, and why the old reading mattered.** This used to count *entitlement* — id +/// present, kit resolvable, at least one emitter registered — on the reasoning that a channel past +/// its `DistanceCutoff` is still "playing" in FMOD, merely attenuated to nothing. The binary says +/// otherwise. `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`, and `0x7a5870` +/// is bit `0x100000` of `[ch+0x38]` — **the `DistanceCutoff` verdict**, maintained by a second +/// system the pump never mentions (`0x7a5ca0` registers the channel into the cull list +/// `[0xcf5580]`, and `0x7a5000`, run immediately before `FSOUND_Update`, sets the bit and *stops* +/// the channel out of range, clearing it and re-issuing `FSOUND_Stream_PlayEx` back in range). So a +/// far, inaudible ambience does **not** hold one of the four, and neither does one whose start +/// failed. The one exception is byte-derived too: a kit whose `DistanceCutoff` is **0** is never +/// registered into the cull list (`0x7a5cca jnp`), so it holds a slot at any distance — which our +/// `play_kit_ext` reproduces, since its own cutoff gate is `cutoff > 0.0 && !audible`. +/// (wow-re `sound/scratch/doodad-sound-emitters.md` §8, §5 round + byte arbitration.) +/// +/// This is not a detail. At the director's Stratholme pin all four channels were held by +/// `UndeadCampfireSmall` / `CauldronLoop` / `TorchLoop` / `SlimeWaterfall`, **every one past its +/// own cutoff** — nothing audible at all — while the burning-building loop right there was +/// withheld. Under the reference's rule not one of the four would have counted. +const fn cap_step(entitled: bool, sounding_so_far: usize) -> CapStep { + if !entitled { + CapStep::Skip + } else if sounding_so_far >= PLAYING_CAP { + CapStep::Retire + } else { + CapStep::Service + } } /// The pump (`0x461990`): fade the orphans, mark unresolvable kits, then service the admitted @@ -396,14 +429,27 @@ fn pump_emitters( } } - let admitted = admitted(&pool.entries); + // The cap walk (`0x461990`), ascending entry index. An entry is SERVICED while fewer than four + // are sounding, and it counts only if servicing produced a live, in-range channel + // ([`cap_step`]) — so an out-of-range ambience is kept up to date and re-starts the moment it + // comes into range, but never costs a near one its slot. + let mut sounding = 0usize; let mut census: Vec<(u32, bool)> = Vec::new(); + let mut withheld: Vec = 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; + match cap_step(pool.entries[e].entitled(), sounding) { + CapStep::Skip => { + // Free, failed or emitter-less: nothing to hold, nothing to fade. + pool.retire(e); + continue; + } + CapStep::Retire => { + // Over the cap: the channel fades out and the entry keeps everything else. + withheld.push(pool.entries[e].id); + pool.retire(e); + continue; + } + CapStep::Service => {} } let id = pool.entries[e].id; let Some(nearest) = pool.entries[e].nearest(listener_pos) else { @@ -443,6 +489,7 @@ fn pump_emitters( } }; if source_kit_playing(&out, emitter, id) { + sounding += 1; census.push((id, true)); continue; } @@ -480,41 +527,56 @@ fn pump_emitters( } // 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 channel back is the only honest answer — and since 2065 it is also the cap's own + // counter (`0x461b40`'s composite return, [`cap_step`]), which is why this is read rather + // than predicted. + let live = source_kit_playing(&out, emitter, id); + if live { + sounding += 1; + } + census.push((id, live)); } - // 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 { + // The pool's one observable, in the three states the cap actually has. "Which doodad ambiences + // are sounding, and which one went quiet" 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. + // + // **Naming the withheld entries is the half the census was missing** (2059): "1 withheld by + // the cap" cannot say *which* ambience went silent, and that is the only question a report of + // a missing sound layer asks. Since 2065 the split is three ways rather than two, because the + // cap's own counter is liveness: `serviced [out of range]` is an entry the walk reached, kept + // positioned and could not sound — costing the cap **nothing** — while `withheld` is one the + // walk never reached because four were already sounding. Those were the same word before, and + // conflating them is exactly the reading the binary refuted. + if census != pool.last_census || withheld != pool.last_withheld { let entitled = pool.entries.iter().filter(|x| x.entitled()).count(); let named: Vec = 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(); + let held: Vec = withheld + .iter() + .map(|id| format!("{id} ({})", kit_name(&kits, *id).unwrap_or("?"))) + .collect(); debug!( - "emitter pool: admitted [{}] — {} sounding, {entitled} entitled entr{}, {} withheld by \ - the cap, {} fading", + "emitter pool: serviced [{}] — {} 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()), + held.join(", "), pool.fading.len(), ); pool.last_census = census; + pool.last_withheld = withheld; } } @@ -777,11 +839,52 @@ mod tests { assert!(!pool.handles.contains_key(&late)); } - /// The cap picks by ENTRY INDEX — claim order — and stops at four. + /// Run the cap walk over a pool, given each entry's liveness verdict for this frame — + /// [`cap_step`] folded exactly as [`pump_emitters`] folds it, without a mixer. Returns + /// `(sounding indices, withheld indices)`. + fn walk(pool: &AmbientEmitterPool, live: impl Fn(usize) -> bool) -> (Vec, Vec) { + let (mut sounding, mut withheld) = (Vec::new(), Vec::new()); + for e in 0..POOL_ENTRIES { + match cap_step(pool.entries[e].entitled(), sounding.len()) { + CapStep::Skip => {} + CapStep::Retire => withheld.push(e), + CapStep::Service => { + if live(e) { + sounding.push(e); + } + } + } + } + (sounding, withheld) + } + + /// The cap picks by ENTRY INDEX — claim order — and stops at four SOUNDING entries. #[test] - fn the_cap_admits_the_first_four_entitled_entries_by_index() { + fn the_cap_admits_the_first_four_sounding_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]); + let (sounding, withheld) = walk(&pool, |_| true); + assert_eq!(sounding, vec![0, 1, 2, 3]); + assert_eq!(withheld, vec![4, 5]); + } + + /// **The correction of 2065, and the reason Stratholme went silent.** An entry the walk + /// services but which comes back with no live channel — past its own `DistanceCutoff`, or a + /// start that failed — consumes **nothing**. `0x461b40` returns false for it (`0x7a5870`, the + /// cutoff bit), so `0x4619c0`'s counter never advances and the walk keeps going. + /// + /// The four here are the real Stratholme pin: `UndeadCampfireSmall`, `CauldronLoop`, + /// `TorchLoop` and `SlimeWaterfall`, every one out of range, in front of the burning-building + /// loop with 88 emitters on top of the listener. Under the old entitlement count they held all + /// four slots and it was withheld; under the binary's rule they hold none. + #[test] + fn a_serviced_but_silent_entry_does_not_consume_a_cap_slot() { + let pool = pool_with(&[(3255, 1), (3379, 1), (4694, 1), (4855, 1), (6437, 1)]); + let (sounding, withheld) = walk(&pool, |e| e == 4); // only the fire is in range + assert_eq!(sounding, vec![4], "the audible fifth sounds"); + assert!( + withheld.is_empty(), + "nothing was ever withheld: {withheld:?}" + ); } /// Free, emitter-less and failed entries are not entitled and cost the cap nothing — the @@ -790,24 +893,30 @@ mod tests { 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]); + let (sounding, _) = walk(&pool, |_| true); + assert_eq!(sounding, 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. + /// The falsifiable half: distance does NOT arbitrate **among entries that are all audible**. + /// A fifth id right on top of the listener stays silent behind four claimed earlier — but + /// only while those four are inside their own cutoffs, which is the qualifier 2065 added + /// (wow-re rewrote its own live A/B for the same reason: with far incumbents the prediction + /// is the opposite, and reading it without the qualifier would have torn up a correct + /// mechanism). #[test] - fn distance_never_promotes_a_fifth_entry_over_an_earlier_one() { + fn distance_never_promotes_a_fifth_entry_over_an_earlier_audible_one() { let mut pool = AmbientEmitterPool::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" + let (sounding, withheld) = walk(&pool, |_| true); // all four incumbents audible + assert_eq!(sounding, vec![0, 1, 2, 3]); + assert_eq!( + withheld, + vec![4], + "the near fifth is held back by claim order, not distance" ); } } diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 2649d6b3..c4a141c9 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -329,17 +329,11 @@ pub enum SessionEvent { /// *deltas* between consecutive stamps — replay paced by the sender's own cadence, wow-re /// `remote-apply-timing.md`; the app mirrors that per unit (decisions 0601/0615). time: u32, - /// True for `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. The reference's reconcile - /// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a - /// heartbeat applies as an outright snap (decision 0601). - heartbeat: bool, - /// True for `MSG_MOVE_TELEPORT` — somebody else **blinked** (decision 2061): the observer - /// leg of the near-teleport, broadcast by `MovementPacketSender::SendTeleportToObservers` - /// once the mover acked its own `MSG_MOVE_TELEPORT_ACK`. Same body as any relay, but - /// `position` is a **discontinuity, not a step** — so it applies as an outright snap and is - /// excluded from the pre-fire reconcile, which exists to make a *continuous* pose land - /// smoothly and would otherwise glide a blinking mage across the intervening 20 yards. - teleport: bool, + /// **What this packet's opcode means on top of the pose** — heartbeat, teleport, root, or + /// nothing at all ([`crate::messages::RelayVerb`], decision 2064). Twenty-three opcodes + /// share this one body and one client handler; three of them are more than narration, and + /// the enum is exactly those three. + verb: crate::messages::RelayVerb, fall_time: u32, jump: Option, transport: Option, diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 95059fa1..ccba68a5 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -702,8 +702,7 @@ pub fn decode(packet: ServerPacket) -> Vec { flags, pitch, time, - heartbeat: opcode == crate::messages::opcode::MSG_MOVE_HEARTBEAT, - teleport: opcode == crate::messages::opcode::MSG_MOVE_TELEPORT, + verb: crate::messages::RelayVerb::of(opcode), fall_time, jump, transport, @@ -973,8 +972,9 @@ pub fn decode(packet: ServerPacket) -> Vec { flags, pitch, time, - heartbeat: false, - teleport: false, + // A speed change carries a fresh pose, and nothing more: the opcode's meaning is + // the speed, which rides its own event beside this one. + verb: crate::messages::RelayVerb::Pose, fall_time, jump, transport, diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index fd3ca37b..b7966698 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -19,9 +19,9 @@ pub use events::{ }; pub use messages::{ AttackSwingError, CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo, - JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, ServerPacket, - SpeedKind, SplineMode, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, - CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, + JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, RelayVerb, + ServerPacket, SpeedKind, SplineMode, TransportPose, CHARACTER_FLAG_GHOST, + CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, }; pub use world::{ WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 99f2dcf5..ad428c3b 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -178,7 +178,9 @@ pub use mirror_timer::{ read_pause_mirror_timer, read_start_mirror_timer, read_stop_mirror_timer, MirrorTimerKind, MirrorTimerStart, }; -pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, SplineMode, TransportPose}; +pub use movement::{ + JumpInfo, MoveMode, MovementInfo, RelayVerb, SpeedKind, SplineMode, TransportPose, +}; pub use opcode_names::opcode_name; pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket}; pub use page_text::page_text_query; diff --git a/crates/benilla-protocol/src/messages/movement.rs b/crates/benilla-protocol/src/messages/movement.rs index 0be3d6fc..caddfe01 100644 --- a/crates/benilla-protocol/src/messages/movement.rs +++ b/crates/benilla-protocol/src/messages/movement.rs @@ -233,6 +233,67 @@ impl SplineMode { } } +/// **What the opcode of a relayed move means**, beyond the pose every one of them carries — the +/// receiver's switch, modelled once (decision 2064). +/// +/// The `[packed guid][MovementInfo]` relay family is 23 opcodes on ONE client handler (`0x603bb0` → +/// `OnUnitMoveEvent 0x601580`), and for most of them the opcode is pure narration: the flags word in +/// the body already says what changed, and `0x601580`'s parse runs *before* it even loads the opcode +/// (`0x47eba0` at `0x6015ce`). Three cases are not narration, and this enum is exactly those three — +/// VERIFIED against the binary in wow-re's `collision/scratch/movement-relay-family-map.md`, which +/// maps all thirty rows. +/// +/// Deliberately one field rather than a bag of booleans: they are disjoint (one opcode per packet) +/// and the set is closed, so an enum forces every receiver to say what it does with each — which is +/// how the mistake below was possible for as long as it was. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RelayVerb { + /// The ordinary pose stream: start/stop/strafe/jump/turn/pitch/swim/fall-land/set-facing, + /// walk-vs-run mode, the observer's hover / feather-fall / water-walk, and the knockback relay. + /// The opcode adds nothing the flags word does not already carry. + #[default] + Pose, + /// `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. + /// + /// **It is NOT excluded from the pre-fire reconcile, and believing otherwise was a two-year + /// error** (decision 2064, correcting 0601/0603). The queued move-event node's tag `0x26`, which + /// `0x619030` (facing interp) and `0x619090` (position reconcile) both skip, is the + /// **teleport's**, not the heartbeat's — `push 0x26` appears at exactly two addresses in the + /// movement region, `0x6186bd` and `0x618736`, both inside functions reached only from the + /// teleport arms (`0x602fb0`, whose other branch sends `push 0xc7` = `MSG_MOVE_TELEPORT_ACK`). + /// A deferred heartbeat *is* eligible for both blends. The variant survives the correction + /// because the distinction is still worth tracing. + Heartbeat, + /// `MSG_MOVE_TELEPORT` — the observer's near-teleport (a Blink, a `.tele`). **The one opcode of + /// the thirty the client never smooths toward**: tag `0x26`, skipped by both blends. Every relay + /// snaps the pose (`0x7c6420` writes the wire pose into the live position *and* the integrator + /// base); this one additionally re-bases the mover, zeroes the interp cells `[cmov+0x148]`/ + /// `[cmov+0x14c]` via `0x617e90`, and past 30.0 yd forces a world re-anchor. + Teleport, + /// `MSG_MOVE_ROOT` (`true`) / `MSG_MOVE_UNROOT` (`false`) — the one place in this family where + /// **the opcode decides and the flags word does not get a vote**. After the masked merge, the + /// client runs `SetRoot 0x7c7340` (`or 0x1000`, then the one-shot motion wipe `& 0xffe07f00`) or + /// `ClearRoot 0x7c7370` (`and ~0x1000`) unconditionally. vmangos happens to send a word that + /// already agrees — it forces `MOVEFLAG_ROOT` back on for a rooted mover + /// (`MovementHandler.cpp:1070`) — so honouring the opcode changes nothing today and makes the + /// root independent of the server continuing to be careful. + Root(bool), +} + +impl RelayVerb { + /// Which verb a relay opcode carries. Anything not named here is [`Self::Pose`]. + pub fn of(opcode: u16) -> Self { + use super::opcode as op; + match opcode { + op::MSG_MOVE_HEARTBEAT => Self::Heartbeat, + op::MSG_MOVE_TELEPORT => Self::Teleport, + op::MSG_MOVE_ROOT => Self::Root(true), + op::MSG_MOVE_UNROOT => Self::Root(false), + _ => Self::Pose, + } + } +} + /// One jump's ballistic launch parameters — the conditional `MovementInfo` tail present iff the /// `MOVEFLAG_JUMPING` (0x2000) flag is set. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` /// (build 1.12.1): wire order is `zspeed, cosAngle, sinAngle, xyspeed` (note cos *before* sin). The diff --git a/crates/benilla-protocol/tests/movement.rs b/crates/benilla-protocol/tests/movement.rs index 2ade07dc..ac802da7 100644 --- a/crates/benilla-protocol/tests/movement.rs +++ b/crates/benilla-protocol/tests/movement.rs @@ -6,7 +6,7 @@ mod common; use benilla_protocol::events::{decode, SessionEvent}; -use benilla_protocol::messages::{self, MovementInfo, TransportPose}; +use benilla_protocol::messages::{self, MovementInfo, RelayVerb, TransportPose}; use benilla_protocol::wire::{write_packed_guid, Vector3d}; use benilla_protocol::ServerPacket; use common::hx; @@ -758,25 +758,34 @@ fn observer_move_mode_family_parses_golden() { const FEATHER_FALL: u32 = 0x2000_0000; const HOVER: u32 = 0x4000_0000; - // (opcode, the flags word the server would have written into it, is-a-teleport) + // (opcode, the flags word the server would have written into it, the opcode's own verb) let expected = [ - (messages::opcode::MSG_MOVE_ROOT, ROOT, false), - (messages::opcode::MSG_MOVE_UNROOT, 0, false), - (messages::opcode::MSG_MOVE_WATER_WALK, WATER_WALK, false), - // The same opcode, the other direction — the bit is simply absent. - (messages::opcode::MSG_MOVE_WATER_WALK, 0, false), - (messages::opcode::MSG_MOVE_FEATHER_FALL, FEATHER_FALL, false), - (messages::opcode::MSG_MOVE_HOVER, HOVER, false), + (messages::opcode::MSG_MOVE_ROOT, ROOT, RelayVerb::Root(true)), + (messages::opcode::MSG_MOVE_UNROOT, 0, RelayVerb::Root(false)), + ( + messages::opcode::MSG_MOVE_WATER_WALK, + WATER_WALK, + RelayVerb::Pose, + ), + // The same opcode, the other direction — the bit is simply absent, and the verb is still + // `Pose`: for these three the word is the whole message. + (messages::opcode::MSG_MOVE_WATER_WALK, 0, RelayVerb::Pose), + ( + messages::opcode::MSG_MOVE_FEATHER_FALL, + FEATHER_FALL, + RelayVerb::Pose, + ), + (messages::opcode::MSG_MOVE_HOVER, HOVER, RelayVerb::Pose), // Levitate grants all three at once (decision 1706) — one word, three bits. ( messages::opcode::MSG_MOVE_HOVER, HOVER | FEATHER_FALL | WATER_WALK, - false, + RelayVerb::Pose, ), - (messages::opcode::MSG_MOVE_TELEPORT, 0, true), + (messages::opcode::MSG_MOVE_TELEPORT, 0, RelayVerb::Teleport), ]; - for (op, flags, is_teleport) in expected { + for (op, flags, expected_verb) in expected { let body = info(flags); assert_eq!(body.len(), 30, "the relay shape, with no counter dword"); let packet = messages::parse_server(op, &body) @@ -807,16 +816,14 @@ fn observer_move_mode_family_parses_golden() { [SessionEvent::UnitMove { guid: 0xAA, flags: f, - teleport, - heartbeat, + verb, .. }] => { assert_eq!(*f, flags); assert_eq!( - *teleport, is_teleport, - "only MSG_MOVE_TELEPORT is a discontinuity ({op:#06x})" + *verb, expected_verb, + "the opcode's verb, and only for the three that have one ({op:#06x})" ); - assert!(!*heartbeat, "none of these is the periodic pulse"); } other => panic!("expected one UnitMove event for {op:#06x}, got {other:?}"), } diff --git a/crates/benilla-world/src/doodad_anim/lazy.rs b/crates/benilla-world/src/doodad_anim/lazy.rs index 0ff14e89..d6b6d38d 100644 --- a/crates/benilla-world/src/doodad_anim/lazy.rs +++ b/crates/benilla-world/src/doodad_anim/lazy.rs @@ -250,7 +250,7 @@ mod tests { .spawn(( DoodadAnimHost { meshes: vec![part], - fade: (1.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(1.0, Vec3::ZERO), clip: Some((AnimationNodeIndex::new(1), 2.0)), armed_at: 0.0, window_hi: f32::INFINITY, diff --git a/crates/benilla-world/src/doodad_anim/mod.rs b/crates/benilla-world/src/doodad_anim/mod.rs index 9bcfa62d..27978875 100644 --- a/crates/benilla-world/src/doodad_anim/mod.rs +++ b/crates/benilla-world/src/doodad_anim/mod.rs @@ -113,7 +113,8 @@ pub(crate) const SOUND_EVENT_TAGS: [&[u8; 4]; 4] = [b"$DSL", b"$DSO", b"$DSE", b /// 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 +/// it creates (`0x695100` → `0x7121a0`, module docs §1/§4a) and cycles it whenever the doodad is in +/// the frame's animate set, 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 @@ -343,12 +344,21 @@ pub struct DoodadAnimHost { /// The placement's skinned submesh entities — animation runs iff ANY of them is drawn (their /// `Visibility` is the composed far-clip + distance-fade + portal-cull verdict). pub(crate) meshes: Vec, - /// The placement's fade sphere (radius, WORLD center) — the draw-set gate for a MESHLESS - /// host (a particles-only model like the InstancePortal swirl: 0 render batches, so no - /// submesh carries a `Visibility` verdict). Same law the placement's emitters gate on - /// (decision 0171: fade alpha > 0 + frustum sphere), so joints and particle pools - /// freeze/resume in lockstep. - pub(crate) fade: (f32, Vec3), + /// The placement's **draw-set gate** — the draw-set answer for a MESHLESS host (a + /// particles-only model like the InstancePortal swirl or Stratholme's burning-building fire: + /// 0 render batches, so no submesh carries a `Visibility` verdict). Not a fade sphere any + /// more but the whole [`crate::particles::EmitterFade`], **the same value the placement's + /// emitters, ribbons and glow lights were built with** (`terrain_stream::spawn::emitter_fade`, + /// one expression at one call site), so every rider of a placement freezes and resumes + /// together. + /// + /// It used to be `(radius, world_center)`, and [`gate_doodad_anim`] rebuilt the rest as + /// `instance: None, room: None` on the reasoning that a particles-only model is not a + /// building's prop. Stratholme's fire is 88 particles-only props of `stratholme_b.wmo`, and + /// with no instance the exterior-window term asks `ExteriorGate::admits_sphere` — which, + /// standing in a sealed room, is `Windows([])` and admits **nothing**. Every meshless prop of + /// the building the camera stood in was parked (decision 2059). + pub(crate) fade: crate::particles::EmitterFade, /// The looping first-sequence graph node + its duration (secs); `None` on the gseq-only tier. pub(crate) clip: Option<(AnimationNodeIndex, f32)>, /// `Time::elapsed_secs` at the current **arm** — the player's clock origin: the variation @@ -377,14 +387,17 @@ impl DoodadAnimHost { /// `(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. + /// The **shared clock, never the `AnimationPlayer`**, because it is the one answer both arms + /// can give: a clock-only host has no player to read at all, and a rigged one's resume seeks + /// the player to exactly this value, so the two never disagree while the host is drawn. + /// + /// It is **not** a claim that the cycle runs while the host is parked. This doc used to say the + /// reference gates the cycle on "linkage — residency, not the draw", and that read + /// `doodad-anim-host.md` §5b's *linkage* as "loaded": the note means spliced into the per-frame + /// scene worklist `[CM2Scene+0x20]`, which **is** the drawn/faded set — "a doodad culled out of + /// the drain stops advancing and resumes on re-link". Consumers that must honour that read + /// [`DoodadAnimHost::active`] beside this clock; the placed-doodad sound scanner + /// (`benilla_app::doodad_events`) does, and decision 2059 is what it cost not to. 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. @@ -407,12 +420,18 @@ impl DoodadAnimHost { /// Two details that are easy to get wrong, both byte-pinned: /// - **The re-arm is a snap, not a blend** (`blendFlag = 0` at `0x6951c8`) — hence `stop_all` before /// the play rather than a cross-fade. -/// - **The window advances on linkage, not on the draw.** The reference's per-frame walk covers -/// every model spliced into the scene list, which the doodad drain does per *in-range* doodad, so -/// a doodad behind the camera keeps cycling. This system therefore runs over ALL hosts and never -/// consults [`DoodadAnimHost::active`] — gating it on the draw instead would freeze the whole -/// field while you looked away and then re-roll all 31 placements on the same frame you turned -/// back, a burst of simultaneous strikes that the reference cannot produce. +/// - **This system runs over ALL hosts and never consults [`DoodadAnimHost::active`] — a +/// DELIBERATE divergence, not a reading of the bytes.** The reference's per-frame walk covers +/// exactly the models spliced into `[CM2Scene+0x20]`, and the doodad drain splices only what +/// survived frustum + occlusion + the radius-tiered fade cutoff — so in the real client a doodad +/// behind you *stops* advancing and, on re-link, finds `now ≥ windowHi` and re-arms at once +/// (wow-re `animation/scratch/doodad-anim-host.md` §5b, +/// `terrain/scratch/doodad-emitter-drawset-gate.md` §1c/§2b). Gating this here would therefore be +/// the faithful shape, and it is left ungated on purpose: it would freeze the whole field while +/// you looked away and re-roll all 31 of the Tainted Scar's placements on the frame you turned +/// back, and that burst is an approved *look* to weigh with the director rather than a silent +/// change (decision 2059 names it as the open follow-up). The sound lane is gated; this one is +/// not, and the difference is recorded rather than smoothed over. /// /// A host that is not currently drawn still re-rolls; it just updates [`DoodadAnimHost::clip`] and /// leaves the (stopped) player alone, so [`gate_doodad_anim`]'s resume arms whatever the latest @@ -490,13 +509,13 @@ fn gate_doodad_anim( // camera stood still, every host's verdict is last frame's (decision 1979's floor: ~1.5 k // hosts × their submesh lookups on every still frame). changed_vis: Query<(), (Changed, With)>, - // The far-clip wall — the meshless host's draw-set gate needs the same depth bound the - // emitters and the doodad meshes use… - view: Res, - // …and the same exterior-window term, for the same reason: a meshless prop OUTSIDE, seen from a - // WMO interior, is not in the frame's worklist and must not tick its bones either (0786). - exterior_windows: Res, - camera_claim: Res, + // The frame's draw-set inputs, as ONE bundle: the far-clip wall, the exterior-window gate + // (a meshless prop OUTSIDE, seen from a WMO interior, is not in the frame's worklist and must + // not tick its bones either — 0786), the room the camera stands in, and the portal instances + // a prop's own rooms resolve through (0689/1289). The same `SystemParam` the particle and + // ribbon sims read, so the three riders of one placement cannot answer the draw-set question + // differently (2059). + scene: crate::particles::sim::SceneGates, // The lazy-rig lane's wake half (decision 0863, [`lazy`]): a drawn host without a slot // promotes here — allocation, row seed, part swap. mut palettes: ResMut, @@ -514,16 +533,11 @@ fn gate_doodad_anim( } let now = time.elapsed_secs(); let world_cam = cam.single().ok(); - let exterior_gate = crate::exterior_cull::ExteriorGate::build( - &exterior_windows, - world_cam.as_ref().map(|(tf, _, proj, _)| (&**tf, &**proj)), - ); - let camera_instance = camera_claim.0.map(|c| c.room.instance); + let (farclip, exterior_gate, camera_instance) = + scene.scene(world_cam.as_ref().map(|(tf, _, proj, _)| (&**tf, &**proj))); let verdicts_still = world_cam.as_ref().is_some_and(|(tf, _, proj, local)| { !tf.is_changed() && !proj.is_changed() && !local.as_ref().is_some_and(|l| l.is_changed()) - }) && !exterior_windows.is_changed() - && !camera_claim.is_changed() - && !view.is_changed() + }) && !scene.changed() && changed_vis.is_empty(); for (entity, mut host, lazy, pose, has_rig, player, drive) in &mut hosts { // A host born this frame has no verdict to reuse (`active` starts false): a meshless one @@ -536,36 +550,26 @@ fn gate_doodad_anim( // Meshless (particles-only) host: the emitters' own draw-set law (see the `fade` // field doc) — the reference ticks animation for any model in the draw set, and a // 0-batch model is admitted on its fade sphere exactly like its emitters are. - // "Exactly like" is now literal: this calls `EmitterFade::in_draw_set`, the single - // spelling of the rule, rather than a second copy of it. The copy is how the far-clip - // term went missing here as well as in the emitters (decision 0678 / bug B39) — a - // meshless fire prop kept animating its bones at any distance past the wall. - let (radius, center) = host.fade; + // "Exactly like" is literal: this asks the placement's OWN `EmitterFade` — the very + // value its emitters and ribbons were handed — through `in_draw_set`, the single + // spelling of the rule. A second copy of it is how the far-clip term went missing + // here as well as in the emitters (0678/B39), and how a particles-only WMO prop lost + // its building identity and was culled from inside its own room (2059). + let fade = &host.fade; world_cam.as_ref().is_some_and(|(cam_tf, frustum, _, _)| { - let cam_pos = cam_tf.translation(); - // A meshless host is a placement's own particles-only model; it carries no building - // instance of its own, so the honest answer here is "test my sphere". - let fade = crate::particles::EmitterFade { - radius, - center, - instance: None, - // …and no rooms either, for the same reason: a placement's particles-only - // model is not a building's prop, so nothing gates it on a portal PVS. - room: None, - }; fade.in_draw_set( - cam_pos, + cam_tf.translation(), Vec3::from(cam_tf.forward()), - view.farclip, + farclip, frustum.intersects_sphere( &bevy::camera::primitives::Sphere { - center: center.into(), - radius, + center: fade.center.into(), + radius: fade.radius, }, false, ), fade.exterior_admitted(&exterior_gate, camera_instance), - fade.room_admitted(None), // no rooms ⇒ admitted; the single spelling, not `true` + scene.room_admits(fade), ) }) } else { @@ -960,7 +964,7 @@ mod tests { .spawn(( DoodadAnimHost { meshes: Vec::new(), - fade: (1.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(1.0, Vec3::ZERO), clip: None, armed_at: 0.0, window_hi: f32::NEG_INFINITY, @@ -1048,7 +1052,7 @@ mod tests { .spawn(( DoodadAnimHost { meshes: Vec::new(), - fade: (1.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(1.0, Vec3::ZERO), clip: None, armed_at: 0.0, window_hi: f32::NEG_INFINITY, @@ -1156,7 +1160,7 @@ mod tests { .spawn(( DoodadAnimHost { meshes: vec![mesh], - fade: (1.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(1.0, Vec3::ZERO), clip: Some((node, 2.0)), armed_at: 0.0, // Far future: this test exercises the DRAW gate, so no window may expire @@ -1218,6 +1222,118 @@ mod tests { /// [`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. + /// **A meshless prop of the building the camera stands in keeps animating** — decision 2059's + /// wiring, pinned where it broke. + /// + /// `EmitterFade`'s own tests already pin the LAW (`particles`: + /// `a_sealed_room_keeps_its_own_props_burning_and_stops_everything_else`). What was wrong here + /// was the wiring: this gate built its meshless host a fresh `EmitterFade` with + /// `instance: None, room: None`, so the exterior-window term fell through to + /// `ExteriorGate::admits_sphere` — and a sealed room is `Windows([])`, which "admits nothing". + /// Every particles-only WMO prop of the camera's OWN building was therefore parked, which is + /// Stratholme's 88 burning-building fires: 0 render batches each, so nothing but this branch + /// ever judged them, and their `$DSL` never fired. + /// + /// The control is the second host: same sphere, same place, no building — an ADT map doodad, + /// which a sealed room really must stop. + #[test] + fn a_sealed_room_keeps_its_own_meshless_props_animating() { + use crate::wmo_portal::{ + CameraInteriorClaim, ExteriorWindows, InteriorClaim, WmoGroupVis, WmoPortalInstance, + WmoRoom, + }; + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.init_resource::(); + app.init_resource::(); + app.init_asset::(); + app.add_systems(Update, gate_doodad_anim); + + // The camera stands inside a sealed room of `building` — no exterior window at all. + let building = app + .world_mut() + .spawn(WmoPortalInstance { + handle: Handle::default(), + world_from_local: bevy::math::Affine3A::IDENTITY, + name_set: 0, + visible: vec![true], + interior_fog: vec![false], + liquid_visited: vec![false], + flooded: vec![None], + }) + .id(); + app.insert_resource(ExteriorWindows::Windows(Vec::new())); + app.insert_resource(CameraInteriorClaim(Some(InteriorClaim { + room: WmoRoom { + instance: building, + group: 0, + }, + exterior_visible: false, + }))); + + // Looking straight at both hosts, 20 yd out and well inside the wall. + let seat = + Transform::from_xyz(0.0, 0.0, 0.0).looking_at(Vec3::new(0.0, 0.0, -20.0), Vec3::Y); + let projection = bevy::camera::Projection::from(bevy::camera::PerspectiveProjection { + far: 5000.0, + ..default() + }); + let frustum = bevy::camera::primitives::Frustum::from_clip_from_world( + &(projection.get_clip_from_view() * GlobalTransform::from(seat).affine().inverse()), + ); + app.world_mut().spawn(( + crate::view::WorldCamera, + GlobalTransform::from(seat), + seat, + frustum, + projection, + )); + + let node = AnimationNodeIndex::new(1); + let mut meshless = |fade: crate::particles::EmitterFade| { + app.world_mut() + .spawn(DoodadAnimHost { + meshes: Vec::new(), // 0 render batches — the whole point + fade, + clip: Some((node, 2.0)), + armed_at: 0.0, + window_hi: f32::INFINITY, + anim_id: Some(0), + active: false, + parked_at: 0.0, + }) + .id() + }; + let center = Vec3::new(0.0, 0.0, -20.0); + let prop = meshless(crate::particles::EmitterFade { + instance: Some(building), + room: Some(WmoGroupVis { + instance: building, + groups: [0u16].as_slice().into(), + }), + ..crate::particles::EmitterFade::sphere(2.0, center) + }); + let map_doodad = meshless(crate::particles::EmitterFade::sphere(2.0, center)); + + app.update(); + let active = |app: &App, e: Entity| { + app.world() + .entity(e) + .get::() + .unwrap() + .active + }; + assert!( + active(&app, prop), + "a prop of the camera's OWN building is not exterior to it — it keeps animating, and \ + its $DSL keeps firing" + ); + assert!( + !active(&app, map_doodad), + "the control: a map doodad carries no building, so the sealed room stops it" + ); + } + /// The teleport frame (2026-09-04): this system runs before transform propagation, so the /// camera's `GlobalTransform` still reads still on the frame its `Transform` was re-seated, /// and the verdict reuse carried every host's pre-snap verdict across the snap — a lamppost @@ -1270,7 +1386,7 @@ mod tests { .spawn(( DoodadAnimHost { meshes: Vec::new(), - fade: (1.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(1.0, Vec3::ZERO), clip: Some((node, 2.0)), armed_at: 0.0, window_hi: f32::INFINITY, @@ -1326,13 +1442,14 @@ mod tests { fn arm_clock_wraps_on_the_shared_clock() { let mut host = DoodadAnimHost { meshes: Vec::new(), - fade: (0.0, Vec3::ZERO), + fade: crate::particles::EmitterFade::sphere(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. + // PARKED — and the clock still answers, which is the point: `arm_clock` is a pure + // function of the shared clock, so the phase is defined whether or not the host is in + // the animate set. Honouring that membership is the CALLER's job (decision 2059). active: false, parked_at: 0.0, }; diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index adcfb9c6..e8766745 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -552,6 +552,19 @@ pub struct EmitterFade { } impl EmitterFade { + /// A bare fade **sphere** — the gate of a placement that is nobody's furniture: an ADT map + /// doodad, a WMO root's own geometry, a prop on a moving transport. `center` is WORLD space, + /// already placed. The building-prop form goes through + /// [`crate::terrain_stream::emitter_fade`], which composes this with the instance and rooms. + pub(crate) fn sphere(radius: f32, center: Vec3) -> Self { + Self { + radius, + center, + instance: None, + room: None, + } + } + /// This owner's distance-fade ALPHA (not the cutoff): the reference writes it into the /// doodad's `CM2Model+0x180`, so it multiplies that model's particles exactly as it does its /// batches (`FUN_00683f80` → `+0x180` → `+0x19c` → `emitter+0x1a8`, decision 0827). The diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index 53e75176..f39879dd 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -184,7 +184,7 @@ fn drive_child( child.particles.push(Particle { pos: p.pos + fold(local), vel, - age: 0.0, + age: birth_age(child.def.burst(), dt, &mut child.rng), life: now.lifespan, phase, fresh: true, @@ -195,6 +195,27 @@ fn drive_child( } } +/// A particle's **birth age** — the reference's third spawn-kernel argument, `U01 · w` +/// (`0x7b88c4`/`0x7b88c7` in the plane kernel, byte-identical in sphere and spline; wow-re +/// `part-birth-age-and-quad-edge.md` §1.3/§1.4). +/// +/// `w` is **not** the lifespan, which is the reading that would halve every pool's steady-state +/// population: the burst loop pushes a literal `0` (`0x7b5600`) and both continuous loops push the +/// **substep dt** (`0x7b5779` interpolated, `0x7b57e2` plain). So a burst's N particles are all born +/// at age 0 together, while a poured one is born already aged by a random fraction of the frame it +/// was born in — which de-synchronises the births that share a frame, and is why a steady stream +/// does not visibly pulse at the frame rate. +/// +/// **Named approximation:** the reference substeps at a hard `0.1 s` (`[0x81d828]`, `0x7b58e7`), so +/// its `w` is bounded by construction; we do not substep, so the frame delta is clamped here +/// instead. Identical below 0.1 s, which is every frame that is not a hitch. +fn birth_age(is_burst: bool, dt: f32, rng: &mut u32) -> f32 { + if is_burst { + return 0.0; + } + rand01(rng) * dt.min(0.1) +} + /// The **draw-set gate's** scene inputs, bundled: the far-clip wall the gate bounds emitters at /// (0678), the exterior-window test a WMO interior applies to everything outside it (0786) with the /// camera's own room as its one exemption, and the per-frame portal PVS the rooms *inside* a @@ -1181,7 +1202,7 @@ pub(super) fn simulate_particles( particles.push(Particle { pos, vel, - age: 0.0, + age: birth_age(def.burst(), dt, rng), life: now.lifespan, phase, fresh: true, @@ -1487,12 +1508,54 @@ fn fold_committed_light( #[cfg(test)] mod tests { use super::{ - booth_frozen, fold_committed_light, follow_fraction, inherit_trigger, integrate_particle, - is_above, scene_frozen, ChildEmitter, EffectLighting, EffectVertex, Particle, StepEnv, - Vec3, + birth_age, booth_frozen, fold_committed_light, follow_fraction, inherit_trigger, + integrate_particle, is_above, scene_frozen, ChildEmitter, EffectLighting, EffectVertex, + Particle, StepEnv, Vec3, }; use bevy::prelude::{Quat, Transform}; + /// **A burst's particles are all born at age 0** — the burst spawn loop pushes a literal `0` + /// as the kernels' `w` (`0x7b5600`), so the whole puff shares one clock and ages as one body. + /// Reading `w` as the LIFESPAN instead — the shape an audit proposed here — would scatter the + /// puff over `[0, lifespan)`, halve its integrated brightness and halve every steady pool's + /// population, which is the arithmetic wow-re's own measured C1 (300 = rate x lifespan) + /// already ruled out. Pinned so that reading cannot come back. + #[test] + fn a_burst_births_every_particle_at_age_zero() { + let mut rng = 0x1234_5678; + for _ in 0..64 { + assert_eq!(birth_age(true, 0.016, &mut rng), 0.0); + } + // Not a function of dt: a hitch does not age a burst either. + assert_eq!(birth_age(true, 5.0, &mut rng), 0.0); + } + + /// A POURED particle is born aged by a random fraction of its own frame (`U01 . dt`), which is + /// what de-synchronises the births sharing one frame. + #[test] + fn a_poured_particle_is_born_aged_within_its_own_frame() { + let mut rng = 0x9e37_79b9; + let dt = 0.016; + let mut saw_nonzero = false; + for _ in 0..256 { + let age = birth_age(false, dt, &mut rng); + assert!((0.0..dt).contains(&age), "age {age} outside [0, {dt})"); + saw_nonzero |= age > 0.0; + } + assert!(saw_nonzero, "the seed is a real draw, not a constant 0"); + } + + /// The reference substeps at a hard 0.1 s, so its `w` is bounded by construction; we do not + /// substep, so a hitch frame is clamped here instead (the named approximation in `birth_age`). + #[test] + fn a_hitch_frame_cannot_birth_a_particle_older_than_the_substep_cap() { + let mut rng = 0xdead_beef; + for _ in 0..256 { + let age = birth_age(false, 5.0, &mut rng); + assert!((0.0..0.1).contains(&age), "age {age} outside [0, 0.1)"); + } + } + fn one_quad() -> Vec { vec![ EffectVertex { diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index 9aa2773d..2c65c6ff 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -113,6 +113,22 @@ pub fn spawn_model_entities( // (decision 0778). `None` everywhere else (exterior props fade; WMO groups use their own // per-submesh interior flag + batch class). interior_slot: Option, + // The placement's **draw-set gate** — its fade sphere plus, for a WMO prop, the building + // instance and the rooms that name it. `Some` on the world-static lanes, where the caller + // builds it ONCE ([`super::fx::emitter_fade`]) and hands the same value to every rider of the + // placement: the emitters, the ribbon trails, the glow lights — and, since 2059, the anim + // host, which used to fabricate its own with `instance: None, room: None` and so judged a + // particles-only WMO prop as exterior scene. Standing inside a sealed room that is + // `ExteriorGate::Windows([])`, which "admits nothing": every meshless prop of the building the + // camera was standing in was parked, its event track unscanned, and Stratholme's + // burning-building `$DSL` never fired. + // + // `None` on the entity-hosted lane (a transport's props), which carries no `EmitterFade` at + // all on purpose — a mover has no baked world point for one to measure from + // (`benilla_app::entities::wmo_props`). The bare sphere below is what its mesh lane needs, and + // `radius` is read for exactly that: in the `Some` arm the gate carries its own and this + // parameter is never consulted again. + fade: Option<&crate::particles::EmitterFade>, radius: f32, local_center: Vec3, // The model's authored **all-animation** bound in Bevy model-local space @@ -176,6 +192,16 @@ pub fn spawn_model_entities( // its host bone off (0130 phase 4), and the FILE sequence slot its variation roll landed // on, which the emitters' rate/gate tracks must be sampled against (decision 0760). ) -> SpawnedModel { + // One gate for this placement, from here down: the caller's when it has one, else the bare + // sphere (which is the same value `emitter_fade` would build from the same inputs — the + // constructor is shared, so the two arms cannot drift). Every `radius`/world-centre read below + // goes through it. + let fade = match fade { + Some(f) => f.clone(), + None => { + crate::particles::EmitterFade::sphere(radius, transform.transform_point(local_center)) + } + }; let kind = object.kind; let is_wmo = kind == ModelKind::Wmo; let mut out = Vec::with_capacity(submeshes.len()); @@ -372,7 +398,7 @@ pub fn spawn_model_entities( interior_prop: interior_probe, order_free: matches!(sub.blend, ModelBlend::Opaque | ModelBlend::AlphaTest) && !sub.additive, - never_fade: radius > crate::model_fade::NEVER_FADE_RADIUS, + never_fade: fade.radius > crate::model_fade::NEVER_FADE_RADIUS, }; // A merged FADER blob lives permanently in the reference's own fading render state — // the blend twin (blend on, cutout ref on unfaded alpha, depth-write forced on) — with @@ -391,7 +417,7 @@ pub fn spawn_model_entities( let merge_sphere = if steady_interior_prop { Vec4::new(0.0, 0.0, 0.0, f32::INFINITY) } else { - Vec4::from((transform.transform_point(local_center), radius)) + Vec4::from((fade.center, fade.radius)) }; if crate::static_merge::census_enabled() { let key = merge @@ -451,19 +477,24 @@ pub fn spawn_model_entities( crate::static_gx::GxSite::Doodad { owner } if !is_wmo && class.merges() && !class.interior_prop => { - let fade = (!class.never_fade && !crate::static_gx::fade_lane_disabled()) - .then(|| crate::static_gx::GxFadeSeed { - radius, - local_center, - stat_mesh: stat_mesh.clone(), - aabb: *stat_aabb, - cutout: cutout.clone(), - blend: blend.clone(), - }); + // `fade_seed`, not `fade`: the placement's draw-set gate is in scope + // here under that name, and shadowing it with the retained pass's + // per-batch seed reads as the same thing twice. + let fade_seed = (!class.never_fade + && !crate::static_gx::fade_lane_disabled()) + .then(|| crate::static_gx::GxFadeSeed { + radius: fade.radius, + local_center, + stat_mesh: stat_mesh.clone(), + aabb: *stat_aabb, + cutout: cutout.clone(), + blend: blend.clone(), + }); // Never-fade admits bare; a fader admits only WITH its seed (the // fade lane lever empties the seed, sending faders back to the // entity path). - (class.never_fade || fade.is_some()).then_some((*owner, None, None, fade)) + (class.never_fade || fade_seed.is_some()) + .then_some((*owner, None, None, fade_seed)) } crate::static_gx::GxSite::Wmo { instance, groups } if is_wmo && class.merges() => @@ -797,7 +828,7 @@ pub fn spawn_model_entities( // which is steady indoors). Adding `DoodadFade` is what makes the fade system drive the tag. if !steady_interior_prop { commands.entity(entity).insert(DoodadFade { - radius, + radius: fade.radius, local_center: fade_center, cutout, blend, @@ -830,7 +861,8 @@ pub fn spawn_model_entities( } commands.entity(h.root).insert(DoodadAnimHost { meshes: skinned_meshes, - fade: (radius, transform.transform_point(local_center)), + // The caller's one gate, not a second construction of it (2059). + fade: fade.clone(), clip: h.clip, armed_at: now, // Born already expired, so the first frame runs the holder setup's `variationIdx = -1` diff --git a/crates/benilla-world/src/terrain_stream/spawn/fx.rs b/crates/benilla-world/src/terrain_stream/spawn/fx.rs index 75614a42..a4e40dae 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/fx.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/fx.rs @@ -163,13 +163,12 @@ pub(super) fn emitter_fade( ) -> particles::EmitterFade { let room = instance.zip(groups.filter(|g| !g.is_empty())); particles::EmitterFade { - radius: fade.0, - center: transform.transform_point(fade.1), instance, room: room.map(|(instance, groups)| crate::wmo_portal::WmoGroupVis { instance, groups: groups.clone(), }), + ..particles::EmitterFade::sphere(fade.0, transform.transform_point(fade.1)) } } diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 33509719..2b9d0d57 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -190,6 +190,11 @@ pub(super) fn spawn_loaded_placements( }; let (radius, center) = m2_fade(&m.bounds, p.transform.scale.x); let anim_bound = m2_anim_bound(&m.bounds); + // The placement's ONE draw-set gate, built before the spawn because the + // assembler now takes it too (2059): the mesh lane, the emitters, the ribbons + // and the anim host are admitted or refused together, from one expression. A + // map doodad is nobody's furniture, so it carries no instance and no rooms. + let fade = emitter_fade(p.transform, (radius, center), None, None); // The placement's identity, built BEFORE the spawn: every lane that takes // a batch — entity, merge blob, retained cell — carries this same Arc, so // whichever one draws it, the pick names the placement (decision 1534). @@ -217,6 +222,7 @@ pub(super) fn spawn_loaded_placements( &object, shade, None, // map doodad: exterior sky lighting (no interior probe) + Some(&fade), radius, center, anim_bound, @@ -282,7 +288,6 @@ pub(super) fn spawn_loaded_placements( // One gate for both emitter families. `None`/empty: an ADT map doodad // belongs to no building, so neither the window exemption nor the room // term has anything to key on. - let fade = emitter_fade(p.transform, (radius, center), None, None); spawn_emitters_for( &mut commands, &m.emitters, @@ -335,6 +340,10 @@ pub(super) fn spawn_loaded_placements( // Hoisted above the spawn: the merge site needs it (a portal-gated building's // blobs take `WmoGroupVis`), and the instance logic below reuses it. let has_portals = !m.portal_refs.is_empty() && !m.portal_infos.is_empty(); + // A building carries no authored M2 bounds, so its gate is the never-fade + // sphere the `f32::INFINITY` radius below always meant. `m2: None` on this + // lane ⇒ no anim host, so only the mesh half of the gate is ever read. + let fade = emitter_fade(p.transform, (f32::INFINITY, Vec3::ZERO), None, None); // The building's identity, shared by every lane that takes one of its batches // (decision 1534) — see the doodad site above. let object = Arc::new(WorldObject { @@ -398,6 +407,7 @@ pub(super) fn spawn_loaded_placements( &object, ShadeSel::Matte, // WMO lights on the FFP N·L path — the selector is unread None, // WMO groups carry their own per-submesh interior flag + batch class + Some(&fade), f32::INFINITY, Vec3::ZERO, None, // …and no authored M2 box: group geometry never animates @@ -687,6 +697,19 @@ pub(super) fn spawn_loaded_placements( }; let (radius, center) = m2_fade(&m.bounds, d.transform.scale.x); let anim_bound = m2_anim_bound(&m.bounds); + // One gate for every rider of this prop: the prop rides its building's + // exterior-window exemption AND the portal PVS of the rooms that name it — the same + // `WmoGroupVis` the submeshes are culled by, so a prop's mesh, its flames, its + // streamers and (since 2059) its ANIM HOST are admitted or refused together + // (decisions 0786 / 0689 / 1289). Hoisted above the spawn because the assembler takes + // it now; a particles-only prop has no submesh to carry a verdict, so this is the only + // thing that tells its host it is furniture rather than exterior scene. + let fade = emitter_fade( + d.transform, + (radius, center), + portal_instance, + Some(&d.groups), + ); // An interior prop's committed light, folded ONCE into its SH probe (the reference folds // at doodad create + per-frame light commit; everything here is static, so once): the // MODD-colour ambient + the diffuse lobe on the fixed interior axis + the owning group's @@ -743,6 +766,7 @@ pub(super) fn spawn_loaded_placements( &object, shade, interior_slot, // interior props light off their folded probe, not the sky + Some(&fade), radius, center, anim_bound, @@ -854,16 +878,6 @@ pub(super) fn spawn_loaded_placements( welds.add_prop(unique_id, verts, tris); } } - // One gate for both emitter families: the prop rides its building's exterior-window - // exemption AND the portal PVS of the rooms that name it — the same `WmoGroupVis` the - // submeshes above are culled by, so a prop's mesh, its flames and its streamers are - // admitted or refused together (decisions 0786 / 0689 / 1289). - let fade = emitter_fade( - d.transform, - (radius, center), - portal_instance, - Some(&d.groups), - ); spawn_emitters_for( &mut commands, &m.emitters,