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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user