the fire layer sounds, heartbeats blend, and freezes refuse the flinch

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:07:44 +07:00
co-authored by Claude Fable 5.1
parent 0d5ff18fa2
commit 71d1677205
27 changed files with 1037 additions and 284 deletions
+10
View File
@@ -233,6 +233,16 @@ impl AuraNodes {
pub(crate) fn head_anim_rate(&self) -> Option<f32> {
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
+31 -10
View File
@@ -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
@@ -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<crate::aura_visual::AuraNodes>| {
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::<AnimDriver>()
.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
@@ -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;
@@ -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
},
),
@@ -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::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<KitPush>()
.add_message::<EmoteAnim>()
.add_message::<WoundAnim>()
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
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<Entity> {
app.world_mut()
.resource_mut::<Messages<WoundAnim>>()
.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
+65 -5
View File
@@ -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
};
@@ -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,
+2 -4
View File
@@ -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,
+13 -20
View File
@@ -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<JumpInfo>,
pub(crate) transport: Option<TransportPose>,
/// `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
}
}
+44 -28
View File
@@ -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).
+64 -30
View File
@@ -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>, 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");
}
+1 -1
View File
@@ -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
+161 -52
View File
@@ -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<u32>,
}
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<usize> {
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<u32> = 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<String> = census
.iter()
.map(|(id, live)| {
let name = kit_name(&kits, *id).unwrap_or("?");
// A kit past its own `DistanceCutoff` is admitted, holds its cap slot, and is
// inaudible. Saying "sounding" of it would be the observable lying about the one
// thing it exists to report.
format!(
"{id} ({name}){}",
if *live { "" } else { " [out of range]" }
)
})
.collect();
let held: Vec<String> = 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<usize>, Vec<usize>) {
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"
);
}
}
+5 -11
View File
@@ -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<JumpInfo>,
transport: Option<TransportPose>,
+4 -4
View File
@@ -702,8 +702,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
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<SessionEvent> {
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,
+3 -3
View File
@@ -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,
+3 -1
View File
@@ -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;
@@ -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
+24 -17
View File
@@ -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:?}"),
}
+1 -1
View File
@@ -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,
+181 -64
View File
@@ -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<Entity>,
/// 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<Visibility>, With<crate::model_render::ModelPart>)>,
// 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<crate::view::ViewDistance>,
// …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<crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
// 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<crate::rig_palette::RigPalettes>,
@@ -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::<crate::view::ViewDistance>();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_asset::<SkinnedMeshInverseBindposes>();
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::<DoodadAnimHost>()
.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,
};
+13
View File
@@ -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
+68 -5
View File
@@ -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<EffectVertex> {
vec![
EffectVertex {
@@ -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<u16>,
// 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`
@@ -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))
}
}
@@ -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,