- a Vanish, a stealth or a unit leaving range clears your target at once, not two seconds later, and TAB reaches a mob behind you (2345) - a fully painted terrain layer covers fully: roads and rock no longer let about 6% of the grass underneath show through (2345) - companion pets show their names, the saved camera pitch survives a login, and a missed resurrect offer no longer blocks later ones (2345) - ESC on a quest giver closes it, browsing the auction house by category searches the right items, and abandon removes the quest you chose (2345) - a profession window open at logout no longer reopens for the next character (2345) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1881 lines
99 KiB
Rust
1881 lines
99 KiB
Rust
//! GameObject open/close animation (decision 0242; chest lid folded in by 2271) — a **client-side**
|
||
//! `GAMEOBJECT_STATE` drives a skeletal M2 sequence, so a **door** swings, a **button** depresses, and a
|
||
//! **chest lid** opens/closes on its §243 state machine.
|
||
//!
|
||
//! **The model (2271, §5-VERIFIED):** the real client keeps *one* stored state per GameObject (the
|
||
//! binary's `go+0x27c`) and *one* `SetGoState` that all callers funnel through; a change of that state
|
||
//! plays the §243 transition. benilla mirrors that exactly — [`GoAnim::state`] is the single source of
|
||
//! truth, written by the **three callers** the RE census pinned:
|
||
//!
|
||
//! 1. **the wire** ([`sync_wire_go_state`]) — a `GAMEOBJECT_STATE` UpdateField change. This is the
|
||
//! door/button driver (the server flips their state over the wire) and the first-sight rest-pose seed.
|
||
//! 2. **the open-lock spell-go** ([`open_go_lid`]) — `SMSG_SPELL_GO` for an open-lock cast targeting the
|
||
//! GO opens it (`SetGoState(ACTIVE)`). A chest's lid pops when the *Opening* cast goes off, not on the
|
||
//! click — the faithful timing. The server never flips a chest's wire state (its `Use(CHEST)` runs
|
||
//! scripts only; loot is spell-driven), so this — not the wire — is what opens a chest.
|
||
//! 3. **the loot-release** ([`close_go_lid`]) — the loot window closing (`CMSG_LOOT_RELEASE`) drops the
|
||
//! state to READY, closing the lid, with no server round-trip (the client's loot-frame close handler).
|
||
//!
|
||
//! This supersedes 0244's "chests aren't animated": 0244 was right that the *wire* state never changes on
|
||
//! loot, but wrong to conclude the chest is off the machine — it runs §243 identically, just fed from
|
||
//! loot events instead of the wire. Wiring a chest to the *wire* watch alone gave it a state that never
|
||
//! changed — the "instant open" glitch; feeding it from the loot events is the fix.
|
||
//!
|
||
//! The mechanism (wow-re `object-layer.md` §243 + `scratch/go-anim-state-machine.md`, §5-VERIFIED): the
|
||
//! `GAMEOBJECT_STATE` value maps — **with no inversion**, the same polarity the sound path uses — to a
|
||
//! held rest pose, and a *change* of state plays a one-shot transition motion that settles onto the new
|
||
//! rest pose. The client keys the played sequence by its **AnimationData.dbc id** (the door-machine's
|
||
//! internal index and the debug state-name strings at `0x860850` are both stale/off-by-one — the RE's
|
||
//! central trap; we key by id):
|
||
//!
|
||
//! | wire state | rest pose (held) | entered by motion (one-shot) |
|
||
//! |------------|-----------------------|------------------------------|
|
||
//! | 1 READY | `0x93` Closed | `0x92` Close (from open) |
|
||
//! | 0 ACTIVE | `0x95` Opened | `0x94` Open (from closed) |
|
||
//! | 2 ALT | `0x97` Destroyed | `0x96` Destroy / `0x98` Rebuild |
|
||
//!
|
||
//! benilla reuses the creature skeletal path wholesale: an animated GameObject is instanced with the same
|
||
//! joints + `AnimationPlayer` + graph + [`ModelAnimations`] a creature gets ([`crate::entities::attach`]),
|
||
//! but tagged with [`GoAnim`] instead of `AnimDriver`, so this driver — not `creature_anim` — owns it.
|
||
//! Clips are keyed by AnimationData.dbc id, so a resolved id becomes a clip by a scan of
|
||
//! [`ModelAnimations::clips`], exactly as `creature_anim` does.
|
||
//!
|
||
//! **A transition motion is a TRANSIENT substate, and the settle is explicit** (decision 1151,
|
||
//! wow-re `gameobject-anim-arm.md` §2d/§3). The kernel's `flags` bit 0 says nothing about how long
|
||
//! a transition lasts: bit 0 clear means the *pose* wraps its band for ever, and the whole
|
||
//! door family (`G_Crate01`, every `World\Goober\` prop, the books) authors Close/Open/Destroy
|
||
//! that way. What ends a swing is the **object layer**: the model's completion callback fires once
|
||
//! at the arm's baked window end — span × replay, bit 0 ignored — and slot 14 `0x5f4120` advances
|
||
//! the machine off the motion substate onto its rest one (2 Open → 3 Opened, 4 Close → 1 Closed,
|
||
//! 5 Destroy → 6 Destroyed, 7 Rebuild → 1 Closed), arming that pose over the motion. So benilla
|
||
//! arms a motion for exactly ONE window ([`RepeatAnimation::Never`]) and
|
||
//! [`retire_transient_anim`] re-runs the state machine at its end — the same endpoint, and the
|
||
//! same shape the Custom channel already uses (decisions 1099/1100). Honouring the loop bit
|
||
//! instead is a chest lid that springs open and slams shut ~1.5×/s for ever, which is the report
|
||
//! this record closes.
|
||
//!
|
||
//! The §243 **missing-sequence fallback** is no longer deferred: [`remap_missing`] implements the
|
||
//! four-way remap, including the two legs that freeze a *motion* clip at frame 0 to stand in for an
|
||
//! absent rest pose. It is not a corner case — the Ahn'Qiraj gate's roots (`AHN_QIRAJ_DOORROOTS`,
|
||
//! Stand + Open only) took the "play nothing" path and rendered its 42-bone tangle at bind pose.
|
||
//!
|
||
//! Still deferred (noted, not this slice): the **ANIMPROGRESS** half — the field selects the *motion*
|
||
//! substate rather than the rest one at spawn, and seeks the clip to `duration × progress / 100`
|
||
//! (wow-re `gameobject-anim-arm.md` §2b, `go-anim-state-machine.md`'s seek section); benilla reads
|
||
//! only `GAMEOBJECT_STATE` and always plays from frame 0. And the mid-flight **reverse blend**
|
||
//! (interrupting a half-open door).
|
||
|
||
use avian3d::prelude::{Collider, ColliderDisabled};
|
||
use benilla_assets::ModelAnimations;
|
||
use bevy::animation::transition::AnimationTransitions;
|
||
use bevy::animation::RepeatAnimation;
|
||
use bevy::prelude::*;
|
||
use std::time::Duration;
|
||
|
||
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent};
|
||
use crate::net::{GuidIndex, ObjectStore};
|
||
use benilla_world::schedule::WorldStage;
|
||
|
||
/// `GO_STATE_ACTIVE` (vmangos `GOState`) — the **open** state (door swung, chest lid up). Passable.
|
||
const GO_STATE_ACTIVE: u32 = 0;
|
||
/// `GO_STATE_READY` (vmangos `GOState`) — the **closed / solid** state. A door/button blocks movement
|
||
/// only in this state; `GO_STATE_ACTIVE` (0, open) and `GO_STATE_ACTIVE_ALTERNATIVE` (2) are passable.
|
||
const GO_STATE_READY: u32 = 1;
|
||
|
||
/// `AnimationData.dbc` **157 Despawn** — the id the one-shot channel's code 6 resolves to
|
||
/// (wow-re `gameobject-anim-arm.md` §2c: `0x80b0e0[6]` = substate 12, `0x8607e4[12]` = 157). The
|
||
/// object plays this once and *then* goes away; see [`DespawnAnimAnnounced`].
|
||
const ANIM_DESPAWN: u16 = 157;
|
||
|
||
/// Marker + client-side state for an animated GameObject (decisions 0242/2271). Instanced by
|
||
/// [`crate::entities::attach`] on an animatable GO type whose model authors sequences; driven by
|
||
/// [`drive_go_anim`]. Distinct from creatures' `AnimDriver` so the two drivers never touch one entity.
|
||
#[derive(Component, Default)]
|
||
pub(crate) struct GoAnim {
|
||
/// Client-authoritative `GAMEOBJECT_STATE` (the binary's stored `go+0x27c`) — the single source of
|
||
/// truth for the §243 animation + collision. Written by the three "SetGoState callers": the wire
|
||
/// sync, the open-lock spell-go, and the loot-release. `None` until first sight.
|
||
state: Option<u32>,
|
||
/// The state we last *animated* to — resolves which transition motion to play next. Distinct from
|
||
/// `state` so a caller can change the target while [`drive_go_anim`] still knows the pose we're
|
||
/// leaving (first sight settles the resting pose **silently** — a door that streams in already open
|
||
/// must not replay its swing).
|
||
shown: Option<u32>,
|
||
/// A pending **one-shot** play, as an `AnimationData.dbc` id — the second, disjoint arm
|
||
/// channel of wow-re `gameobject-anim-arm.md` §2c, never the §243 lid family. The reference
|
||
/// has ONE such slot fed by ONE entry point (`0x5f8c50(GO, code)` → slot 15), whose 7-entry
|
||
/// code table `0x80b0e0` is the whole channel:
|
||
///
|
||
/// | code | substate | id | producer |
|
||
/// |------|----------|----|----------|
|
||
/// | 1 | 0 | 145 Spawn | object CREATE on wire update-type 3 (**not built**) |
|
||
/// | 2..5 | 8..11 | 153..156 Custom0-3 | opcode `0xb3` (`anim_id >= 4` rejected) |
|
||
/// | 6 | 12 | 157 Despawn | opcode `0x215` — [`DespawnAnimAnnounced`] |
|
||
///
|
||
/// Written by [`queue_custom_anim`] / [`arm_despawn_anim`], consumed by [`drive_go_anim`]
|
||
/// AFTER the state arm, so the bobber's same-frame pair (the forced `READY → ACTIVE` flip +
|
||
/// the splash) resolves with the splash on top (decision 1086). Slot 15 pre-gates on the model
|
||
/// OWNING the resolved id, so this channel takes no §2c remap (decisions 1086/1404).
|
||
one_shot: Option<u16>,
|
||
/// The clip armed for the current **TRANSIENT substate**, if any — the completion-retire's
|
||
/// watch (decisions 1100/1151, wow-re `gameobject-anim-arm.md` §2d + `go-display-sound-events.md`
|
||
/// §6-8). The reference keeps exactly one current substate in `[handler+0x10]`, and its
|
||
/// per-model completion callback fires ONCE at the arm's baked window end (span × replay, the
|
||
/// loop bit ignored); slot 14 `0x5f4120` then advances a transient substate onto its rest one
|
||
/// and arms that pose over the transient clip. **Two families reach it, and they share this
|
||
/// one slot exactly as the reference's does**: the §243 transition motions (2 Open / 4 Close /
|
||
/// 5 Destroy / 7 Rebuild) and the Custom0..3 block (8..11). [`retire_transient_anim`] models
|
||
/// the advance for both; without it a bit-0-clear clip runs for ever — the bobber's splash
|
||
/// looping ~1.3 s (1100's 2-3 audible splashes) and the crate lid never settling shut (1151).
|
||
transient: Option<Transient>,
|
||
/// The **rest** pose's armed clip node, when that pose is on the re-arm cycle below. Distinct
|
||
/// from [`Self::transient`], which is the reference's ONE transient-substate slot
|
||
/// (`[handler+0x10]`) and drives the §2d *advance*: a rest pose never advances, it re-arms
|
||
/// **itself**. `None` while a transient owns the model, on the rate-0 frozen leg (which never
|
||
/// completes), and once the §2c already-playing check has refused the re-arm and the pose has
|
||
/// settled for good.
|
||
rest_window: Option<AnimationNodeIndex>,
|
||
/// The state the machine last **dispatched** — the reference's `0x5f3cb0(old, new)` entry,
|
||
/// distinct from [`Self::shown`] because the completion retire clears `shown` to force a
|
||
/// re-resolve and that re-resolve is emphatically *not* a dispatch (slot 14 routes a resting
|
||
/// substate through `0x5f4167` → `0x5f3930`, straight past it). Only a genuine change of
|
||
/// [`Self::state`] — and the completion of a Spawn/Custom substate, slot 14's `0x5f4190` leg —
|
||
/// re-enters the dispatch, whose first act is `0x5f3cc8 call 0x5f40c0`: drop the object's
|
||
/// display-sound loop registration ([`GoStateDispatch`]).
|
||
dispatched: Option<u32>,
|
||
/// The **requested** animation id currently armed — the reference's `[block0+0xf8]`, written by
|
||
/// op4 (`0x71252f`) and read back by `0x712090(model, -1)`. The §2c *remap* legs consult it
|
||
/// (`0x5f39fa: cmp esi,eax; je 0x5f3b32`) and refuse an arm that would request what is already
|
||
/// playing; the model-owns-it leg (`0x5f396c jne`) and the collapse-to-Stand leg
|
||
/// (`0x5f3a54 jmp 0x5f3a0b`, past the check) do not. That asymmetry is the whole difference
|
||
/// between a prop that **settles** and one that **cycles** — see [`drive_go_anim`].
|
||
armed_id: Option<u16>,
|
||
}
|
||
|
||
/// The GameObject **state-machine dispatch** ran on this entity — the reference's `0x5f3cb0`,
|
||
/// whose first act is `0x5f3cc8 call 0x5f40c0`: **release whatever display-sound loop the object
|
||
/// holds** (wow-re `go-display-sound-events.md` §2/§6c). Consumed by
|
||
/// [`crate::sound::gameobject`], which owns that registration; the animation half of the dispatch
|
||
/// is [`drive_go_anim`]'s own work and needs no message.
|
||
///
|
||
/// Emitted on a genuine `SetGoState` and on the completion of a **Spawn or Custom** substate
|
||
/// (slot 14's `0x5f4190` leg re-enters the dispatch with `old == new`) — and deliberately *not*
|
||
/// on a resting substate's own re-arm, which slot 14 sends to `0x5f4167` instead, past the
|
||
/// dispatch entirely. That distinction is the whole reason a brazier's `TorchLoop` hums
|
||
/// continuously instead of restarting every band pass.
|
||
#[derive(Message)]
|
||
pub(crate) struct GoStateDispatch(pub(crate) Entity);
|
||
|
||
/// Which completed substates re-enter the state dispatch — slot 14's table
|
||
/// `[0]=0x5f4190 … [8..11]=0x5f4190`, i.e. **Spawn (145) and Custom0..3 (153..156)**, as
|
||
/// `AnimationData.dbc` ids. Every other completed substate is either a transition motion (which
|
||
/// advances to its rest pose through `0x5f413d`/`0x5f414b`/`0x5f4159`) or a rest pose re-arming
|
||
/// itself (`0x5f4167`); neither touches the display-sound loop.
|
||
fn completion_redispatches(id: u16) -> bool {
|
||
matches!(id, 145 | 153..=156)
|
||
}
|
||
|
||
/// The **armed** transient clip — its `AnimationData.dbc` id *and* the graph node the arm actually
|
||
/// landed on. The id alone was enough while every arm took a model's **head** variation; it stops
|
||
/// being enough now that the GameObject arm rolls one (`variationIdx = -1`, see [`drive_go_anim`]),
|
||
/// because two sequences sharing an id are two different nodes and only one of them is playing.
|
||
/// [`retire_transient_anim`] must watch the node that was armed, or it retires against a sibling
|
||
/// variation's (idle) player state and ends the substate on the wrong frame — immediately, for a
|
||
/// variation that was never played.
|
||
#[derive(Clone, Copy)]
|
||
struct Transient {
|
||
/// The `AnimationData.dbc` id — the reference's `[handler+0x10]` substate identity, what the
|
||
/// inspector and the despawn pin compare against.
|
||
id: u16,
|
||
/// The graph node of the clip actually armed: this id's *rolled* variation, not its head.
|
||
node: AnimationNodeIndex,
|
||
}
|
||
|
||
/// The GameObject's **stored** state — the binary's `go+0x27c`, which is what every consumer reads
|
||
/// (the §243 animation, `usable`'s state pre-gate, and the lock chain's per-slot Action gate,
|
||
/// decision 0752). [`GoAnim::state`] when the object is on the animation machine (it carries the
|
||
/// client-side predictions the wire never sends — a chest's lid), else the wire field, else the
|
||
/// wire default `0` = ACTIVE for a field vmangos omitted because it was zero.
|
||
pub(crate) fn go_state(anim: Option<&GoAnim>, store: &ObjectStore) -> u32 {
|
||
anim.and_then(|a| a.state)
|
||
.or_else(|| store.0.gameobject_state())
|
||
.unwrap_or(GO_STATE_ACTIVE)
|
||
}
|
||
|
||
/// The inspector's GameObject **animation** readout (decision 1151): which sequence the §243 arm
|
||
/// is actually playing on the object under the cursor — its `AnimationData` id, whether it is the
|
||
/// state's held **rest** pose or a **transient** one (a transition motion, or a Custom block, which
|
||
/// [`retire_transient_anim`]'s §2d advance ends at its window end), and the repeat the player is
|
||
/// running it under. `None` when the object renders as a static mesh, or nothing is armed yet.
|
||
///
|
||
/// The line that closes the loop on this whole class of report. "The crate is stuck open/closing"
|
||
/// and "the lid settles shut" are indistinguishable from every card line we had: the GO line said
|
||
/// `state 1 closed(READY)`, which was **true**, while a `Forever` Close motion swung underneath
|
||
/// it. `anim Close(146) · transition · loops` is that bug stated in one hover; `anim Closed(147) ·
|
||
/// rest` is the fix, read the same way.
|
||
///
|
||
/// The newest arm is the **smallest seek** — a cross-fade's fading source is older by construction
|
||
/// — the same pick [`fire_go_anim_events`] makes for the event scan.
|
||
pub(crate) fn armed_anim(
|
||
go: &GoAnim,
|
||
player: &AnimationPlayer,
|
||
anims: &ModelAnimations,
|
||
) -> Option<(u16, bool, RepeatAnimation)> {
|
||
let (clip, active) = anims
|
||
.clips
|
||
.iter()
|
||
.filter_map(|c| player.animation(c.node).map(|a| (c, a)))
|
||
.min_by(|a, b| a.1.seek_time().total_cmp(&b.1.seek_time()))?;
|
||
Some((
|
||
clip.anim_id,
|
||
go.transient.is_some_and(|t| t.id == clip.anim_id),
|
||
active.repeat_mode(),
|
||
))
|
||
}
|
||
|
||
/// Which GameObject types get the state-driven **animated** instance (skinned lid/door + §243
|
||
/// sequences) — **the byte-verified type census**, not a guess (wow-re `gameobject-anim-arm.md` §2f).
|
||
///
|
||
/// `CGGameObject::LoadBaseObject` dispatches on the wire TYPE_ID through the 31-entry jump table at
|
||
/// `0x5f76cc` (`cmp ecx,0x1e` + *unsigned* `ja`, so 0..30) and allocates a per-type strategy handler
|
||
/// into `[GO+0x210]`. The handlers fall into exactly two families, and the split is legible in the
|
||
/// **allocation size**: every type whose handler is `0x1c` bytes gets a 36-slot vtable carrying the
|
||
/// real `0x5f3c30`/`0x5f3b50` arm; every other size gets a 34-slot vtable whose corresponding slots
|
||
/// are the abstract base's do-nothing bodies — and whose `+0x88` doesn't even exist (reading it walks
|
||
/// into the next vtable, which is what made an earlier census report plausible nonsense).
|
||
///
|
||
/// So the machine is **20 of the 31 types**, not the three we had:
|
||
///
|
||
/// | on the machine | off it |
|
||
/// |----------------|--------|
|
||
/// | 0 DOOR · 1 BUTTON · 2 QUESTGIVER · 3 CHEST · 6 TRAP · 8 SPELL_FOCUS · 9 TEXT · 10 GOOBER · 12 AREADAMAGE · 16 DUELFLAG · 17 FISHINGNODE · 18 SUMMONING_RITUAL · 19 MAILBOX · 23 MEETINGSTONE · 24 FLAGSTAND · 26 FLAGDROP · 27 MINI_GAME · 28 LOTTERY_KIOSK · 29 CAPTURE_POINT · 30 | 4 BINDER · 5 GENERIC · 7 CHAIR · 11 TRANSPORT · 13 CAMERA · 14 MAP_OBJECT · 15 MO_TRANSPORT · 20 AUCTIONHOUSE · 21 GUARDPOST · 22 SPELLCASTER · 25 FISHINGHOLE |
|
||
///
|
||
/// Type **30** is past vmangos's enum but has a real family-A arm in the table, so it is included for
|
||
/// completeness; type **21 GUARDPOST** has no `case` in the source at all and shares the out-of-range
|
||
/// default, which points `[GO+0x210]` at a static placeholder and logs `"BADBASEGAMEOBJECT|%d"`.
|
||
///
|
||
/// The old `0 | 1 | 3` was wrong in both directions — far too narrow, and its stated reason for
|
||
/// excluding GOOBER(10) ("also fires custom-anim + spells, so its path is unverified") was not a
|
||
/// reason the binary recognises: the custom-anim opcode drives a *disjoint* substate family (8..11),
|
||
/// never the lid. Measured against live world data, the narrowing left **1427 spawns** — books,
|
||
/// traps, goobers, questgivers — rendering the loader seed's pose instead of their state's.
|
||
///
|
||
/// A model that doesn't author a skeleton/sequences still falls back to a static mesh (the attach
|
||
/// gate also checks the joints/animations exist), so a lid-less chest simply stays static.
|
||
pub(crate) fn go_animates(type_id: i32) -> bool {
|
||
matches!(
|
||
type_id,
|
||
0 | 1 | 2 | 3 | 6 | 8 | 9 | 10 | 12 | 16 | 17 | 18 | 19 | 23 | 24 | 26 | 27 | 28 | 29 | 30
|
||
)
|
||
}
|
||
|
||
/// `GAMEOBJECT_TYPE_DOOR` — type id `0`, the one type a ghost's mover trace drops
|
||
/// ([`publish_ghost_door_exclusions`]). Also the wire default: vmangos omits zero-valued fields, so
|
||
/// an absent `GAMEOBJECT_TYPE_ID` means DOOR.
|
||
const GO_TYPE_DOOR: i32 = 0;
|
||
|
||
/// Which types drop their collision when open (decision 0249): **DOOR(0) / BUTTON(1)** only. A door's
|
||
/// static hull can't swing with the mesh, so an open door is made walkable by disabling the collider —
|
||
/// keyed off the server's wire state, which *is* the door's real passability. A **CHEST(3)** keeps its
|
||
/// collider in every state (you don't walk through an "open" chest), so it is deliberately excluded even
|
||
/// though it now animates.
|
||
fn collision_follows_state(type_id: i32) -> bool {
|
||
matches!(type_id, 0 | 1)
|
||
}
|
||
|
||
/// Whether a door/button's collider is solid, from its **wire** `GAMEOBJECT_STATE` (decision 0757).
|
||
///
|
||
/// The load-bearing case is `None`. An absent field is not "unknown" — it is the wire default `0`
|
||
/// = `GO_STATE_ACTIVE` = **open**, because vmangos omits zero-valued fields from the create block
|
||
/// (the same law `go_templates` already documents for an absent `GAMEOBJECT_TYPE_ID` meaning
|
||
/// DOOR). Treating it as unknown and bailing left the collider enabled on **every door that spawns
|
||
/// open**, which is the whole of "GameObjects that block passage while visually open": Zul'Gurub's
|
||
/// Forcefield (180497) and Stratholme's `Doodad_SmallPortcullis03/04/08/09` are all `type 0`,
|
||
/// `startOpen = 1`, spawned at `state = 0`. It also explains the GM workaround the reporter found
|
||
/// — toggling the object twice un-sticks it — because the first toggle sends a *non-zero* state,
|
||
/// so the field finally exists on the wire, and the trip back to `0` then reads as a real value.
|
||
fn collider_is_solid(wire_state: Option<u32>) -> bool {
|
||
wire_state.unwrap_or(GO_STATE_ACTIVE) == GO_STATE_READY
|
||
}
|
||
|
||
/// A cast launched at a GameObject (`SMSG_SPELL_GO` carrying a `TARGET_FLAG_GAMEOBJECT`), bridged from the
|
||
/// net apply layer to this module (decision 2271). [`open_go_lid`] opens the target's lid/door iff the
|
||
/// spell carries an open-lock effect and the GO is an animated type — the client's `Spell_C` open path.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct GoLidOpen {
|
||
pub(crate) go_guid: u64,
|
||
pub(crate) spell_id: u32,
|
||
}
|
||
|
||
/// A GameObject's one-shot Custom animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision 1086), bridged
|
||
/// from the net apply layer. The wire's `anim_id` is the Custom index (0..3 → AnimationData ids
|
||
/// 153..156); the reference rejects `anim_id >= 4` in the handler and this side keeps that gate.
|
||
/// The load-bearing sender is the fishing bobber's bite (`anim_id 0` — the splash, arriving beside
|
||
/// the forced state flip and the server's own `SMSG_PLAY_OBJECT_SOUND` splash kit).
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct GoCustomAnim {
|
||
pub(crate) go_guid: u64,
|
||
pub(crate) anim_id: u32,
|
||
}
|
||
|
||
/// What to play for the current `GAMEOBJECT_STATE`: a held **rest** pose (first sight, or a state with no
|
||
/// transition), or a one-shot transition **motion** (the swing) that lands on the new rest pose.
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Play {
|
||
/// Snap to a held pose — no swing (first sight / stream-in).
|
||
Rest(u16),
|
||
/// Play a transition motion once, holding its end frame (= the destination rest pose).
|
||
Motion(u16),
|
||
}
|
||
|
||
impl Play {
|
||
fn anim_id(self) -> u16 {
|
||
match self {
|
||
Play::Rest(id) | Play::Motion(id) => id,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The §2c **missing-sequence remap** — what the arm actually requests when the model doesn't author
|
||
/// the state's animation id. Byte-verified twice over (wow-re `gameobject-anim-arm.md` §2c's
|
||
/// `0x5f3972` jump table `0x5f3b40`, and `go-anim-state-machine.md`'s independent read of the same
|
||
/// switch), it is a four-way table keyed on the id, consulted only after the ownership test
|
||
/// [`ModelAnimations::owns`] (the reference's `0x711960`) says no:
|
||
///
|
||
/// | missing | condition | requests instead |
|
||
/// |------------|-------------------|----------------------------|
|
||
/// | 146 Close | owns Open | 146 (op4 resolves onward) |
|
||
/// | | else | 147 Closed |
|
||
/// | 147 Closed | owns Close | 147 (op4 resolves onward) |
|
||
/// | | else owns Open | **148 Open, frozen** |
|
||
/// | | else | 0 Stand |
|
||
/// | 148 Open | owns Close | 148 (op4 resolves onward) |
|
||
/// | | else owns Destroy | 150 Destroy |
|
||
/// | | else | 149 Opened |
|
||
/// | 149 Opened | owns Open | 149 (op4 resolves onward) |
|
||
/// | | else owns Close | **146 Close, frozen** |
|
||
/// | | else | 151 Destroyed |
|
||
///
|
||
/// The returned flag marks the two **frozen** legs, where the reference stands a *motion* clip in for
|
||
/// a missing *rest* pose by arming it at playback rate `0`. That reads correctly because a motion's
|
||
/// frame 0 IS the pose it departs from: Open's first frame is closed, Close's first frame is open —
|
||
/// confirmed in the asset, `G_BookOpenMediumBrown`'s bone-0 rotation being identity at Open's first
|
||
/// key and the full open angle at Close's. Ids outside the door group get no remap at all
|
||
/// (`lea eax,[esi-0x92]; cmp eax,3; ja`).
|
||
///
|
||
/// Before this existed an unowned id simply played **nothing**: the `AnimationPlayer` was never armed
|
||
/// and the skinned mesh sat at bind pose. `AHN_QIRAJ_DOORROOTS` (the Ahn'Qiraj gate's roots, GO type
|
||
/// 1 BUTTON) authors only Stand and Open — no Closed — so it took exactly that path.
|
||
fn remap_missing(anims: &ModelAnimations, id: u16) -> (u16, bool) {
|
||
if anims.owns(id) {
|
||
return (id, false);
|
||
}
|
||
match id {
|
||
146 if anims.owns(148) => (146, false),
|
||
146 => (147, false),
|
||
147 if anims.owns(146) => (147, false),
|
||
147 if anims.owns(148) => (148, true),
|
||
147 => (0, false),
|
||
148 if anims.owns(146) => (148, false),
|
||
148 if anims.owns(150) => (150, false),
|
||
148 => (149, false),
|
||
149 if anims.owns(148) => (149, false),
|
||
149 if anims.owns(146) => (146, true),
|
||
149 => (151, false),
|
||
other => (other, false),
|
||
}
|
||
}
|
||
|
||
/// The held rest-pose animation-id for a wire `GAMEOBJECT_STATE` (§243). `None` for an unmapped state.
|
||
fn rest_anim(state: u32) -> Option<u16> {
|
||
match state {
|
||
0 => Some(0x95), // ACTIVE → Opened (held open)
|
||
1 => Some(0x93), // READY → Closed (held closed)
|
||
2 => Some(0x97), // ALT → Destroyed
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The transition-motion animation-id for a `prev → cur` state change (§243), i.e. the swing. `None`
|
||
/// when the pair has no distinct motion (falls back to snapping the rest pose).
|
||
///
|
||
/// The reference (`0x5f3cb0`, byte-verified in wow-re `go-anim-state-machine.md`) dispatches on the
|
||
/// **NEW** state and lets OLD pick transient-vs-rest: NEW 0 takes the Open motion from OLD 1 (else
|
||
/// rests Opened), NEW 1 takes Close from OLD 0 / Rebuild from OLD 2 (else rests Closed), NEW 2 takes
|
||
/// Destroy from OLD 1 (else rests Destroyed). Our `(_, 2)` wildcard got that last row wrong: an
|
||
/// **open** object going destructible (`0 → 2`) rests straight on Destroyed rather than playing the
|
||
/// Destroy swing, because the reference's condition is `OLD == 1`, not "any OLD".
|
||
///
|
||
/// (The reference's conditions are each `OLD == x` **or** `ANIMPROGRESS < 100`. benilla does not read
|
||
/// ANIMPROGRESS yet — see the module doc's deferral — so this is the settled column only, which is
|
||
/// what every state-change we can observe today resolves to.)
|
||
fn motion_anim(prev: u32, cur: u32) -> Option<u16> {
|
||
match (prev, cur) {
|
||
(1, 0) => Some(0x94), // closed → open : Open motion (else NEW 0 rests Opened)
|
||
(0, 1) => Some(0x92), // open → closed : Close motion
|
||
(2, 1) => Some(0x98), // rebuilt → closed : Rebuild
|
||
(1, 2) => Some(0x96), // closed → destroyed : Destroy (else NEW 2 rests Destroyed)
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Resolve the play for a state observation: first sight (`prev` None) snaps the rest pose; a change plays
|
||
/// the transition motion if one exists, else snaps the new rest pose.
|
||
fn resolve(prev: Option<u32>, cur: u32) -> Option<Play> {
|
||
match prev {
|
||
None => rest_anim(cur).map(Play::Rest),
|
||
Some(p) => motion_anim(p, cur)
|
||
.map(Play::Motion)
|
||
.or_else(|| rest_anim(cur).map(Play::Rest)),
|
||
}
|
||
}
|
||
|
||
/// Caller 1 (the wire, §243): track each animated GO's `GAMEOBJECT_STATE` from the wire, acting only on a
|
||
/// *genuine* wire change. This is the door/button driver (the server flips their state over the wire) and
|
||
/// the first-sight rest-pose seed for every animated GO (a chest streams in closed). Two inputs, both
|
||
/// exact (decision 2297): the seed (`Added<GoAnim>`, when attach tags the entity — read off the store),
|
||
/// and the field EDGE on `GAMEOBJECT_STATE` for any later delta. An unrelated field change (position,
|
||
/// dyn-flags) is not an edge on this dword, so a chest whose wire state is constant is never re-closed by
|
||
/// one — its lid is owned by the loot callers below. That used to need a `last_wire` shadow on the
|
||
/// component; the edge carries the old value itself.
|
||
#[allow(clippy::type_complexity)]
|
||
fn sync_wire_go_state(
|
||
mut edges: MessageReader<crate::net::FieldChanged>,
|
||
// The seed leg and the edge leg both write `GoAnim`; one `ParamSet`, two disjoint views.
|
||
mut gos: ParamSet<(
|
||
Query<(&ObjectStore, &mut GoAnim), Added<GoAnim>>,
|
||
Query<&mut GoAnim>,
|
||
)>,
|
||
) {
|
||
for (store, mut anim) in &mut gos.p0() {
|
||
// Absent ⇒ the wire default `0` = ACTIVE (decision 0757) — vmangos omits zero fields, so a
|
||
// door that spawns OPEN sends none. Reading that as "unknown" left `state` at `None`, and
|
||
// the object rested at its loader pose instead of Opened.
|
||
anim.state = Some(store.0.gameobject_state().unwrap_or(GO_STATE_ACTIVE));
|
||
}
|
||
let mut live = gos.p1();
|
||
for e in edges.read() {
|
||
if e.kind != benilla_protocol::messages::ObjectType::GameObject
|
||
|| e.index != benilla_protocol::field::FIELD_GAMEOBJECT_STATE
|
||
{
|
||
continue;
|
||
}
|
||
if let Ok(mut anim) = live.get_mut(e.entity) {
|
||
anim.state = Some(e.new);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Caller 2 (the open-lock spell-go): open a chest lid / locked door when a cast with an open-lock effect
|
||
/// launches at it (`SMSG_SPELL_GO` → [`GoLidOpen`]). Gated on the spell's open-lock effect (the client's
|
||
/// `[spell+0xf4] ∈ {OPEN_LOCK, OPEN_LOCK_ITEM}` test) so a plain unit spell that merely names a GO can't
|
||
/// open it; observer-safe (another player's cast opens the chest you can see, since it resolves the GO
|
||
/// guid from the packet, not our own pending cast). Sets the client state to ACTIVE(0).
|
||
fn open_go_lid(
|
||
mut opens: MessageReader<GoLidOpen>,
|
||
spells: Option<Res<crate::ui_action::Spells>>,
|
||
index: Res<GuidIndex>,
|
||
mut gos: Query<&mut GoAnim>,
|
||
) {
|
||
for GoLidOpen { go_guid, spell_id } in opens.read().copied() {
|
||
let is_open_lock = spells
|
||
.as_deref()
|
||
.and_then(|s| s.catalog.get(spell_id))
|
||
.is_some_and(|d| d.open_lock.is_some());
|
||
if !is_open_lock {
|
||
continue;
|
||
}
|
||
let Some(&e) = index.0.get(&go_guid) else {
|
||
continue;
|
||
};
|
||
if let Ok(mut anim) = gos.get_mut(e) {
|
||
anim.state = Some(GO_STATE_ACTIVE);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Caller 4 (the custom-anim opcode, decision 1086): queue the one-shot Custom play. This is the
|
||
/// **disjoint** arm channel of wow-re `gameobject-anim-arm.md` §step 8 — it never touches
|
||
/// [`GoAnim::state`] (the lid family), rejects `anim_id >= 4` exactly as the reference handler
|
||
/// does, and maps the index to its AnimationData id (`153 + n`, Custom0..3). Ownership is judged
|
||
/// at play time by [`drive_go_anim`] (the model components live there); a guid with no [`GoAnim`]
|
||
/// — a non-animated GO type, or one still streaming in — drops the play, matching
|
||
/// [`open_go_lid`]'s posture.
|
||
fn queue_custom_anim(
|
||
mut plays: MessageReader<GoCustomAnim>,
|
||
index: Res<GuidIndex>,
|
||
mut gos: Query<&mut GoAnim>,
|
||
) {
|
||
for GoCustomAnim { go_guid, anim_id } in plays.read().copied() {
|
||
let Some(id) = custom_anim_id(anim_id) else {
|
||
continue; // the reference handler's own reject (step 8)
|
||
};
|
||
let Some(&e) = index.0.get(&go_guid) else {
|
||
continue;
|
||
};
|
||
if let Ok(mut anim) = gos.get_mut(e) {
|
||
anim.one_shot = Some(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The wire Custom index → its AnimationData id (`153 + n`, Custom0..3), or `None` for the
|
||
/// reference handler's reject (`anim_id >= 4` — wow-re `gameobject-anim-arm.md` step 8's
|
||
/// "opcode `0xb3` byte `b` (reject `b >= 4`), substate `8+b`").
|
||
fn custom_anim_id(anim_id: u32) -> Option<u16> {
|
||
(anim_id < 4).then(|| 153 + anim_id as u16)
|
||
}
|
||
|
||
/// The server announced this object's **despawn animation** (`SMSG_GAMEOBJECT_DESPAWN_ANIM`,
|
||
/// opcode `0x215`) — the one-shot channel's code 6, [`ANIM_DESPAWN`]. Inserted by the net apply
|
||
/// layer at packet time so it is on the entity *before* the `SMSG_DESTROY_OBJECT` that vmangos
|
||
/// sends in the same server tick is processed: that ordering is the whole mechanism, and it is
|
||
/// why this is a component rather than a message (a message is not read until later in the frame,
|
||
/// by which time the destroy has already freed the entity).
|
||
///
|
||
/// It is **not** GameObject-only — `WorldObject::SendObjectDeSpawnAnim` also fires for a totem's
|
||
/// death and a DynamicObject's expiry, and two vmangos boss scripts send it for objects that keep
|
||
/// living. So this marker asserts nothing on its own: it arms [`GoAnim::one_shot`] if there is a
|
||
/// [`GoAnim`] to arm, and it only defers a destroy that actually arrives ([`PendingDestroy`]).
|
||
#[derive(Component)]
|
||
pub(crate) struct DespawnAnimAnnounced;
|
||
|
||
/// The reference's **pending-destroy mark** — `[GO+0xe4]` bit `0x10`, set by the object-manager
|
||
/// destroy `0x464920` when it finds the object still pinned (wow-re
|
||
/// `go-display-sound-events.md` §6d, §5-VERIFIED). The arm takes the pin whenever the armed
|
||
/// substate is not a resting one (`0x5f3b27 call 0x4683e0` → refcount `[obj+0xe8]`), slot 14's
|
||
/// footer releases it at the window end (`0x468410`), and only then does the deferred destroy run
|
||
/// (`0x46844a call 0x464920`) — which is how an object gets to finish its own despawn animation
|
||
/// after the server has already told the client it is gone.
|
||
///
|
||
/// benilla takes the pin on the ONE substate that produces an observable — 12 / [`ANIM_DESPAWN`],
|
||
/// where the whole play happens after the destroy — and [`release_despawn_pin`] is the release.
|
||
/// A GO destroyed mid-transition (a door frozen half-open by a despawn) is the reference's other
|
||
/// pinned case and still pops instantly here; named, not built (decision 1404).
|
||
///
|
||
/// One deliberate divergence: the reference's `0x464920` returns *before* removing the object from
|
||
/// the manager, so a pinned object stays addressable by guid for the length of its play. benilla
|
||
/// drops the guid from the index at destroy time and keeps only the entity, because a respawn that
|
||
/// reused the guid would otherwise refresh an object already condemned to despawn. Nothing
|
||
/// observable rides on it — the object still draws, and the server has stopped answering for that
|
||
/// guid anyway.
|
||
#[derive(Component)]
|
||
pub(crate) struct PendingDestroy;
|
||
|
||
/// Caller 5 (the despawn-anim opcode): arm the one-shot [`ANIM_DESPAWN`] play on an object the
|
||
/// server has announced as despawning — one arm per announcement, exactly as the reference's
|
||
/// single `0x5f8c50(GO, 6)` call is one arm per packet.
|
||
///
|
||
/// `Changed`, not `Added`, and the difference is not pedantry: re-inserting a component that is
|
||
/// already present marks it changed but **not** added, so an object announced a second time
|
||
/// without having died in between (`SendObjectDeSpawnAnim` is a `WorldObject` method — two vmangos
|
||
/// boss scripts call it on objects that go on living) would silently never arm again.
|
||
///
|
||
/// A marked entity with no [`GoAnim`] (a totem, a DynamicObject, a GO type off the machine) simply
|
||
/// isn't matched, and [`release_despawn_pin`] then pops it on its first pass — the ordinary instant
|
||
/// destroy. Model ownership is judged at play time by [`drive_go_anim`], as on the Custom channel.
|
||
fn arm_despawn_anim(mut gos: Query<&mut GoAnim, Changed<DespawnAnimAnnounced>>) {
|
||
for mut go in &mut gos {
|
||
go.one_shot = Some(ANIM_DESPAWN);
|
||
}
|
||
}
|
||
|
||
/// Release the pin: an object whose destroy was deferred ([`PendingDestroy`]) goes away as soon as
|
||
/// it is no longer *playing* its despawn animation — the reference's `0x468410` decrement reaching
|
||
/// zero with the pending-destroy bit set, which runs the deferred `0x464920`.
|
||
///
|
||
/// Runs AFTER [`drive_go_anim`], and that ordering is load-bearing in both directions. On the
|
||
/// arming frame the drive has just set [`GoAnim::transient`], so the object is held; on the frame
|
||
/// [`retire_transient_anim`] clears it (the window ended) nothing re-arms 157, so the release runs.
|
||
/// An entity that never armed anything — no [`GoAnim`], a model that doesn't author 157 — fails the
|
||
/// test on its very first pass and is released the same frame, which is the case for everything
|
||
/// that isn't an egg.
|
||
///
|
||
/// **"Released" is a fade, not a pop** (decision 2198). What the deferred `0x464920` runs into is
|
||
/// the base OnDeactivate `0x6145e0`, which hands the object's model to the `SWModelFadeout`
|
||
/// scheduler `0x672df0` on its way out — so the object stops existing while its model keeps
|
||
/// drawing and ramps to zero. A looted chest whose static model authors no `Despawn` sequence is
|
||
/// exactly this path with no animation in front of it, and popping it was the report that found
|
||
/// the missing hop. [`DespawnFade`](benilla_world::model_fade::DespawnFade) is that hand-off; it
|
||
/// arms once and drives itself, so the pin comes off with it and this query stops matching. The
|
||
/// hand-off is [`crate::net::tear_down`], the same one both wire routes take, so the object's
|
||
/// identity ends here too — a pinned object is still an object while it plays, and stops being
|
||
/// one when the pin drops.
|
||
fn release_despawn_pin(
|
||
mut commands: Commands,
|
||
pinned: Query<(Entity, Option<&GoAnim>), With<PendingDestroy>>,
|
||
) {
|
||
for (e, go) in &pinned {
|
||
if go.is_some_and(|g| g.transient.is_some_and(|t| t.id == ANIM_DESPAWN)) {
|
||
continue;
|
||
}
|
||
commands
|
||
.entity(e)
|
||
.try_remove::<PendingDestroy>()
|
||
.queue_silenced(crate::net::tear_down);
|
||
}
|
||
}
|
||
|
||
/// Caller 3 (the loot-release): close a chest lid when its loot window closes. The client sends
|
||
/// `CMSG_LOOT_RELEASE` and immediately drops the state to READY(1) — no server round-trip. We watch the
|
||
/// open loot source guid change (any close path: the player's close, or the server's release when the last
|
||
/// item is looted) and close the lid iff the guid that just closed is an animated GO — a looted corpse or
|
||
/// creature resolves to an entity without [`GoAnim`], so `get_mut` misses it and nothing happens.
|
||
fn close_go_lid(
|
||
loot: Res<crate::ui_loot::LootState>,
|
||
index: Res<GuidIndex>,
|
||
mut gos: Query<&mut GoAnim>,
|
||
mut last_source: Local<Option<u64>>,
|
||
) {
|
||
let current = loot.source();
|
||
if *last_source == current {
|
||
return;
|
||
}
|
||
if let Some(closed) = *last_source {
|
||
if let Some(&e) = index.0.get(&closed) {
|
||
if let Ok(mut anim) = gos.get_mut(e) {
|
||
anim.state = Some(GO_STATE_READY);
|
||
}
|
||
}
|
||
}
|
||
*last_source = current;
|
||
}
|
||
|
||
/// The **completion-driven retire** of the current TRANSIENT substate — the §2d advance
|
||
/// (decisions 1100/1151; wow-re `gameobject-anim-arm.md` §2d/§3 + `go-display-sound-events.md`
|
||
/// §6-8, §5-verified). The reference registers a per-model completion callback at GO model attach
|
||
/// (`0x5f7d43` → `[M2+0x70]`) which the driver `0x719370` fires ONCE when the armed sequence
|
||
/// reaches its baked window end — span × replay-count, the **loop bit ignored** — and slot 14
|
||
/// `0x5f4120` dispatches on the current substate:
|
||
///
|
||
/// | completed substate | advances to | i.e. |
|
||
/// |--------------------|-------------|------|
|
||
/// | 2 Open | 3 Opened | the swing settles open |
|
||
/// | 4 Close | 1 Closed | the lid settles shut |
|
||
/// | 5 Destroy | 6 Destroyed | |
|
||
/// | 7 Rebuild | 1 Closed | |
|
||
/// | 0 Spawn, 8..11 Custom0-3 | re-run at the current state (`0x5f4190`) | |
|
||
/// | 1/3/6 (a rest pose) | **itself** (`0x5f4167`) | the held pose re-arms, with a fresh variation roll |
|
||
///
|
||
/// **This — not the animation kernel — is what turns a transition clip into a resting state**, and
|
||
/// it is why the kernel's loop bit is not the transition's duration: `G_Crate01`'s Close is
|
||
/// `flags` bit 0 clear, so the *pose* would wrap for ever, and only this advance ends it. For the
|
||
/// bobber's Custom0 the re-run resolves 149 → 151 → the lookup's all-fallback rows → **Stand**,
|
||
/// landing one frame after the 1333 ms window — before the looping kernel's second `$GC0` crossing
|
||
/// at 1533 ms. Net law: one splash per 0xB3, one swing per state change, then the state pose.
|
||
///
|
||
/// **The last row is not "nothing", and 1151 read it as such.** `0x5f4167` is byte-for-byte slot
|
||
/// 34's own selection and calls `0x5f3930` **directly**, bypassing slot 34's change guard on an
|
||
/// unchanged `[handler+0x10]` — so a resting substate re-issues its own op4, and with it a fresh
|
||
/// `variationIdx = -1` roll, every `span × R` ms for ever (wow-re `gameobject-anim-arm.md` §6c/§6d,
|
||
/// §5-arbitrated). The completion that drives it ignores the loop bit (`0x719503` tests it only
|
||
/// after the notify block), so a bit-0-CLEAR pose completes too. That is why Onyxia's lava traps
|
||
/// spurt *continuously* rather than once at stream-in, and it is the same law `loop-replay-fidget.md`
|
||
/// §7 and `doodad-anim-host.md` §5 already carried for units and placed doodads — the GameObject
|
||
/// note was the outlier.
|
||
///
|
||
/// Benilla therefore arms EVERY clip `Never`-repeat (one window, the same endpoint), so "the
|
||
/// window ended" is the player's finished flag; the retire then clears `shown`, which makes the
|
||
/// state arm of [`drive_go_anim`] re-resolve the current state as a fresh rest pose — our
|
||
/// state-machine re-run, and (for a motion) exactly the table above, since `rest_anim(state)` is
|
||
/// the destination row of whichever motion that state's change armed. Runs before
|
||
/// [`drive_go_anim`] in the chain so the re-arm lands the same frame. Reads never deref-mut, so a
|
||
/// quiet GO stays out of the Changed stream.
|
||
fn retire_transient_anim(
|
||
mut gos: Query<(Entity, &mut GoAnim, &AnimationPlayer)>,
|
||
mut dispatch: MessageWriter<GoStateDispatch>,
|
||
) {
|
||
for (entity, mut go, player) in &mut gos {
|
||
// The ARMED node, not `find(id)`'s head variation — see [`Transient::node`].
|
||
let done = |n| {
|
||
player
|
||
.animation(n)
|
||
.is_none_or(bevy::animation::ActiveAnimation::is_finished)
|
||
};
|
||
if let Some(t) = go.transient {
|
||
if done(t.node) {
|
||
go.transient = None;
|
||
// The completion's state-machine re-run: forget the shown pose so the state arm
|
||
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
|
||
// reference's re-arm over the finished transient block.
|
||
go.shown = None;
|
||
// A Spawn/Custom completion re-enters `0x5f3cb0` with `old == new`, and that
|
||
// dispatch drops the object's display-sound loop — which is what ends the
|
||
// GnomeMachine's Custom0 hum at the end of its clip rather than leaving it
|
||
// droning. A transition motion's completion takes a different slot-14 row and
|
||
// does not.
|
||
if completion_redispatches(t.id) {
|
||
dispatch.write(GoStateDispatch(entity));
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
// A REST pose completes too, and its completion re-arms *itself* — see [`drive_go_anim`]'s
|
||
// rest arm. Same device: forget the shown pose and the state arm re-resolves it, rolling a
|
||
// fresh variation. `rest_window` is cleared first so a refused re-arm settles instead of
|
||
// spinning.
|
||
if let Some(node) = go.rest_window {
|
||
if done(node) {
|
||
go.rest_window = None;
|
||
go.shown = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The GameObject arm's instrument** (`WOW_MOVE_TRACE_TAGS=goa`) — one line per arm, carrying
|
||
/// everything the roll decided: the requested `AnimationData.dbc` id, what the §2c remap turned it
|
||
/// into, the `_rand` draw itself, and the **file sequence slot** the weighted walk landed on.
|
||
///
|
||
/// That last column is the one this exists for. A GameObject's variation chain is not cosmetic:
|
||
/// the per-sequence material lanes (2295) key their UV and tint loops by `seq_index`, so *which
|
||
/// take is armed* is what decides whether a Blood of Heroes pool bubbles at all (slot 0 authors no
|
||
/// texture transform, slot 1 does) and whether an Onyxia lava trap spurts. From outside, an arm
|
||
/// that re-rolls every window and one that armed once and stalled look identical — both are "a
|
||
/// still pool" for as long as you happen to watch — and the three legs that produce the difference
|
||
/// (`0x5f39fa`'s already-armed skip, the collapse-to-0 leg that jumps past it, the completion that
|
||
/// re-enters through slot 14) are invisible in a screenshot. One line per arm makes the cycle,
|
||
/// its period and its distribution readable directly.
|
||
fn trace_arm(
|
||
entity: Entity,
|
||
lane: &str,
|
||
requested: u16,
|
||
want: u16,
|
||
frozen: bool,
|
||
roll: u16,
|
||
clip: &benilla_assets::AnimClip,
|
||
) {
|
||
if !benilla_assets::trace::enabled_for("goa") {
|
||
return;
|
||
}
|
||
benilla_assets::trace::line(
|
||
"goa",
|
||
&format!(
|
||
"{lane} e={entity} id={requested}->{want}{} roll={roll} seq={} freq={} dur={:.3}",
|
||
if frozen { " frozen" } else { "" },
|
||
clip.seq_index,
|
||
clip.frequency,
|
||
clip.duration,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Play the §243 sequence for a change of the client-side [`GoAnim::state`] (written by any of the three
|
||
/// callers). Mirrors the state-transition detection of [`crate::sound::gameobject`] (first sight silent),
|
||
/// but points it at the model instead of the mixer — one system owns the visual, the other the audio.
|
||
fn drive_go_anim(
|
||
mut gos: Query<
|
||
(
|
||
Entity,
|
||
&mut GoAnim,
|
||
&mut AnimationPlayer,
|
||
&mut AnimationTransitions,
|
||
&ModelAnimations,
|
||
),
|
||
Changed<GoAnim>,
|
||
>,
|
||
mut dispatch: MessageWriter<GoStateDispatch>,
|
||
// The client's single `_rand` stream (wow-re `rf36-rand-stub.md`), as `creature_anim` keeps
|
||
// one: op4's variation roll draws from it on every GameObject arm below.
|
||
mut rng: ResMut<benilla_assets::AnimRng>,
|
||
) {
|
||
for (entity, mut go, mut player, mut tr, anims) in &mut gos {
|
||
// ── The §243 state arm ─────────────────────────────────────────────────────────────────
|
||
if let Some(state) = go.state {
|
||
if go.shown != Some(state) {
|
||
let prev = go.shown;
|
||
go.shown = Some(state);
|
||
// `0x5f3cb0` proper — announced before anything below can refuse the arm, because
|
||
// the reference releases the display-sound loop at the head of the dispatch and
|
||
// *then* picks a substate. A re-resolve driven by [`retire_transient_anim`]
|
||
// leaves `dispatched` alone and so announces nothing (see [`GoAnim::dispatched`]).
|
||
if go.dispatched != Some(state) {
|
||
go.dispatched = Some(state);
|
||
dispatch.write(GoStateDispatch(entity));
|
||
}
|
||
// A fresh substate replaces whatever transient one was live — the reference keeps
|
||
// exactly ONE (`[handler+0x10]`), so an old motion's completion can never fire
|
||
// over the pose that superseded it.
|
||
go.transient = None;
|
||
go.rest_window = None;
|
||
if let Some(play) = resolve(prev, state) {
|
||
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as
|
||
// `creature_anim` does), through the §2c remap for a model that doesn't author
|
||
// it — that is what keeps a lidless model on a real pose instead of bind.
|
||
let (want, frozen) = remap_missing(anims, play.anim_id());
|
||
// **`variationIdx = -1` — the arm ROLLS a variation** (wow-re
|
||
// `gameobject-anim-arm.md` §2c, `0x5f3aee: push -1`), unlike the §1 loader seed
|
||
// beneath it, which passes an explicit `0` (`0x710189`). Where a model authors
|
||
// one sequence per id the two are the same clip and nothing changes; where it
|
||
// authors a chain they are not, and taking the head is a whole authored
|
||
// behaviour that never plays. Onyxia's lava traps are the case that named it:
|
||
// `ONYZIASLAIRLAVATRAP.M2` authors Stand twice — seq 0 (`freq` 29491, looping,
|
||
// silent) and seq 1 (`freq` 3276, one-shot) — and it is seq **1** that carries
|
||
// the 300/s `LAVALUMP2` burst, the lava spurting out of the floor. Every one of
|
||
// the 208 trap GameObjects in the lair took the head, so the chamber's whole
|
||
// ember field was missing.
|
||
// **The §2c already-playing skip, and it decides settle-vs-cycle.** Three
|
||
// edges reach the arm (`0x5f3a0b`): the model OWNS the table id
|
||
// (`0x5f396c jne`), the model is not live, and the 147-absent collapse to
|
||
// Stand (`0x5f3a52 xor esi,esi; 0x5f3a54 jmp 0x5f3a0b` — a backwards jump
|
||
// PAST the check). Only the other remap legs fall through `0x5f39fa`'s
|
||
// `cmp esi,eax; je 0x5f3b32`, and there a re-arm requesting what is already
|
||
// armed is refused — so a prop the remap sent to a *non-zero* substitute
|
||
// settles on it (the fishing bobber), while one that owns its id or collapsed
|
||
// to Stand re-arms, and re-rolls, every window (the lava trap).
|
||
let skip =
|
||
want != 0 && !anims.owns(play.anim_id()) && go.armed_id == Some(want);
|
||
if skip {
|
||
continue;
|
||
}
|
||
let roll = rng.draw();
|
||
if let Some(clip) = anims.pick_variation(want, roll) {
|
||
trace_arm(entity, "state", play.anim_id(), want, frozen, roll, clip);
|
||
go.armed_id = Some(want);
|
||
// Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion
|
||
// over its authored blend.
|
||
let blend = match play {
|
||
Play::Rest(_) => 0.0,
|
||
Play::Motion(_) => clip.blend_time.max(0.0),
|
||
};
|
||
let node = clip.node;
|
||
let active = tr.play(&mut player, node, Duration::from_secs_f32(blend));
|
||
if frozen {
|
||
// A motion standing in for a missing rest pose: the reference arms it
|
||
// at playback rate 0, so it holds frame 0 forever — the pose that
|
||
// motion departs from.
|
||
active.seek_to(0.0);
|
||
active.set_speed(0.0);
|
||
active.set_repeat(RepeatAnimation::Never);
|
||
} else {
|
||
// Explicit, not defaulted: `AnimationTransitions::play` →
|
||
// `AnimationPlayer::start` only `replay()`s the node, which rewinds the
|
||
// clock but keeps `speed` — so re-arming a node a frozen leg previously
|
||
// parked at rate 0 (the same Open clip serves both) would stay stuck.
|
||
active.set_speed(1.0);
|
||
// **ONE baked window, rest pose included** (decision 1151 for the
|
||
// motions; wow-re
|
||
// `gameobject-anim-arm.md` §6, correcting 1151's "nothing advances off
|
||
// a held pose"). The completion notification `0x719370` fires at
|
||
// `span × R` **without consulting the loop bit** (`0x719503` tests it
|
||
// only afterwards), so a bit-0-CLEAR rest clip completes too — and
|
||
// slot 14's row for a resting substate, `0x5f4167`, calls `0x5f3930`
|
||
// *directly*, past slot 34's change guard, on an unchanged substate.
|
||
// The arm is therefore re-issued every ~`span` ms with a fresh
|
||
// `variationIdx = -1` roll, for ever. For a single-variation id that
|
||
// is the loop it always was, restarted at its own band start; for a
|
||
// chain it is the whole point — Onyxia's traps re-roll every 867 ms at
|
||
// a 10 % weight, and with a 5 s ember life that leaves **47 % of them
|
||
// holding a live plume at any instant** (measured, `WOW_PARTICLE_CENSUS`
|
||
// in the lair: 49 of 104), instead of one flurry at stream-in. The
|
||
// period, the re-roll and `R = 1` from a `(0,0)` replay pair are all
|
||
// byte-converged across two independent §5 rounds; what is *not*
|
||
// settled is which per-frame advance list a GameObject's model rides
|
||
// (wow-re `gameobject-anim-arm.md` §6's named residual).
|
||
active.set_repeat(RepeatAnimation::Never);
|
||
match play {
|
||
Play::Motion(_) => {
|
||
go.transient = Some(Transient { id: want, node });
|
||
}
|
||
Play::Rest(_) => go.rest_window = Some(node),
|
||
}
|
||
}
|
||
}
|
||
// else: nothing playable even after the remap — hold whatever the loader
|
||
// seed armed.
|
||
}
|
||
}
|
||
}
|
||
// ── The one-shot Custom channel (step 8, decision 1086) — AFTER the state arm, so the
|
||
// bobber's same-frame pair (forced READY→ACTIVE flip + splash) lands splash-on-top.
|
||
// Gated on the model OWNING the id; no §2c remap on this channel — an unowned Custom
|
||
// plays nothing. Armed for ONE window regardless of the sequence's loop flag (decision
|
||
// 1099, correcting 1090's loop-forever): the kernel does loop a bit0-clear clip and
|
||
// re-fires its events per pass, but the reference's COMPLETION callback fires at window
|
||
// end (span × replay-count; the bobber's replay pair is 0..0 ⇒ one 1333 ms pass) and
|
||
// re-runs the state machine, overwriting the Custom block before a second pass begins.
|
||
// `RepeatAnimation::Never` + [`retire_transient_anim`] reproduce that endpoint exactly: one
|
||
// pass, one `$GC0` splash, then the state pose — never a churning loop.
|
||
// (`is_some` pre-check: an unconditional `take()` mut-derefs the `Mut` and re-marks the
|
||
// component Changed every frame, keeping this Changed-filtered query hot forever.)
|
||
if go.one_shot.is_some() {
|
||
let id = go.one_shot.take().expect("checked is_some");
|
||
if anims.owns(id) {
|
||
// Slot 15 funnels through the same slot-34 → `0x5f3930` arm as the state channel
|
||
// (§2c), so it rolls a variation for the same reason — a Custom0 with two authored
|
||
// takes alternates them.
|
||
let roll = rng.draw();
|
||
if let Some(clip) = anims.pick_variation(id, roll) {
|
||
trace_arm(entity, "custom", id, id, false, roll, clip);
|
||
go.armed_id = Some(id);
|
||
go.rest_window = None;
|
||
let node = clip.node;
|
||
let active = tr.play(
|
||
&mut player,
|
||
node,
|
||
Duration::from_secs_f32(clip.blend_time.max(0.0)),
|
||
);
|
||
active.set_speed(1.0);
|
||
active.set_repeat(RepeatAnimation::Never);
|
||
go.transient = Some(Transient { id, node });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gate a door/button's collision on its state: solid when **closed** (`GO_STATE_READY`), passable when
|
||
/// **open** (decision 0249). The model's collision is a single static hull (not bone-bound — it *can't*
|
||
/// swing with the mesh), so an open door is made walkable by disabling the collider, not by moving it —
|
||
/// the client's only option, and the faithful one. Keyed off the **wire** state (a door's real
|
||
/// passability is the server's, and it holds for an animation-less door too — no [`GoAnim`] required), and
|
||
/// scoped to the door/button types ([`collision_follows_state`]); a chest keeps its collider whatever its
|
||
/// state.
|
||
///
|
||
/// **Reconciles on a `GAMEOBJECT_STATE` edge OR on the collider's arrival** (idempotent either way).
|
||
/// The `Added<Collider>` half is load-bearing, not belt-and-braces (decision 0763): a streamed
|
||
/// GameObject's descriptor lands with the create block, but its `Collider` is baked from the M2
|
||
/// hull and inserted by `entities::attach` only when the **asset finishes loading**, frames later.
|
||
/// Watching the descriptor alone meant the two conditions were never true in the same frame
|
||
/// for a freshly streamed object — the descriptor changed while there was no collider, the collider
|
||
/// arrived while the descriptor was quiet — so the reconcile never ran at all, and every door kept
|
||
/// the enabled collider it was born with. A *closed* door is solid by default and looked correct;
|
||
/// an **open** one stayed solid, which is the entire reported symptom. It also explains the
|
||
/// reporter's workaround exactly: toggling the object from the GM panel changes the descriptor
|
||
/// *after* the collider exists, so the reconcile finally fires.
|
||
///
|
||
/// The descriptor half is the field edge on the one dword this reads (decision 2297) — the same
|
||
/// seed-plus-edge pair [`sync_wire_go_state`] uses.
|
||
fn drive_go_collision(
|
||
mut commands: Commands,
|
||
gos: Query<(&ObjectStore, Has<ColliderDisabled>), With<Collider>>,
|
||
arrived: Query<Entity, Added<Collider>>,
|
||
mut edges: MessageReader<crate::net::FieldChanged>,
|
||
) {
|
||
let state_edges = edges.read().filter(|e| {
|
||
e.kind == benilla_protocol::messages::ObjectType::GameObject
|
||
&& e.index == benilla_protocol::field::FIELD_GAMEOBJECT_STATE
|
||
});
|
||
let mut due: bevy::ecs::entity::EntityHashSet = arrived.iter().collect();
|
||
due.extend(state_edges.map(|e| e.entity));
|
||
for entity in due {
|
||
let Ok((store, disabled)) = gos.get(entity) else {
|
||
continue;
|
||
};
|
||
if !collision_follows_state(store.0.gameobject_type_id()) {
|
||
continue;
|
||
}
|
||
let solid = collider_is_solid(store.0.gameobject_state());
|
||
if solid && disabled {
|
||
commands.entity(entity).remove::<ColliderDisabled>();
|
||
} else if !solid && !disabled {
|
||
commands.entity(entity).insert(ColliderDisabled);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **A ghost walks through closed doors** — publish the door set the local mover's trace must
|
||
/// drop, which is [`benilla_world::collision::MoverTraceExclusions`]' only producer.
|
||
///
|
||
/// The reference builds a per-trace collision mask, and the local mover's mask gains bit `0x8000`
|
||
/// when the body it drives is a player carrying `PLAYER_FLAGS` bit `0x10` (GHOST) — `0x631658`,
|
||
/// off `OBJECT_FIELD_TYPE & TYPEMASK_PLAYER` plus the flag, pinned against the `UnitIsGhost`
|
||
/// registrar pair. The consumer is a single instruction, `0x5f85f6 test ah,ah` inside `0x5f85f0` —
|
||
/// `CGGameObject_C`'s collision-candidacy virtual at primary-vtable slot `+0x50` — which reads the
|
||
/// bit through the sign flag and answers *no candidate* for a GameObject whose
|
||
/// `GAMEOBJECT_TYPE_ID` is `0`, DOOR. Nothing else about a ghost's collision differs (wow-re
|
||
/// `collision/scratch/ghost-door-tracemask.md`, decision 1767).
|
||
///
|
||
/// **Off the MOVER's descriptor, not ours.** The producer tests the object being traced, so a
|
||
/// possessed creature is not a player and never sets the bit; `player_is_ghost` reading a field a
|
||
/// creature has no block for answers false on its own, which is the same guard the byte order
|
||
/// gives.
|
||
///
|
||
/// **DOOR only** — not BUTTON, which shares the state-driven collider lane in
|
||
/// [`drive_go_collision`] but is a different type id, and the predicate is an equality against `0`.
|
||
/// Note that an absent `GAMEOBJECT_TYPE_ID` *is* DOOR (vmangos omits zero-valued fields), which is
|
||
/// why the scan is scoped by [`EntityKind::GameObject`]: read across every `ObjectStore` the same
|
||
/// absence would make a DOOR of every unit in the world.
|
||
fn publish_ghost_door_exclusions(
|
||
mut exclusions: ResMut<benilla_world::collision::MoverTraceExclusions>,
|
||
mover: Query<&ObjectStore, With<crate::net::Embodied>>,
|
||
doors: Query<(Entity, &ObjectStore, &crate::net::NetEntity), With<Collider>>,
|
||
) {
|
||
let ghost = mover.single().is_ok_and(|s| s.0.player_is_ghost());
|
||
if !ghost {
|
||
// The overwhelmingly common frame. Clearing only when non-empty keeps this off the
|
||
// change-detection wire for the whole of a living session.
|
||
if !exclusions.0.is_empty() {
|
||
exclusions.0.clear();
|
||
}
|
||
return;
|
||
}
|
||
exclusions.0.clear();
|
||
exclusions.0.extend(
|
||
doors
|
||
.iter()
|
||
.filter(|(_, store, net)| ghost_passable(net.kind, store.0.gameobject_type_id()))
|
||
.map(|(entity, _, _)| entity),
|
||
);
|
||
}
|
||
|
||
/// Is this collider one a **ghost's** mover trace drops — a GameObject of type DOOR?
|
||
///
|
||
/// The kind test is not redundant with the type test, and that is the whole reason this is a named
|
||
/// function. An absent `GAMEOBJECT_TYPE_ID` reads as `0` = DOOR (vmangos omits zero-valued fields,
|
||
/// the law `go_templates` already documents), and a **unit** has no GameObject block at all — so a
|
||
/// scan that asked only about the type id would answer DOOR for every creature and player in the
|
||
/// world and quietly delete them from the mover's trace.
|
||
fn ghost_passable(kind: benilla_protocol::EntityKind, type_id: i32) -> bool {
|
||
kind == benilla_protocol::EntityKind::GameObject && type_id == GO_TYPE_DOOR
|
||
}
|
||
|
||
/// What the GameObject event scanner reads per object: its clock, its park state, and the frame
|
||
/// its fired keys resolve in ([`crate::creature_anim::EventFrame`]).
|
||
type ScannedGo = (
|
||
Entity,
|
||
&'static ModelAnimations,
|
||
&'static AnimationPlayer,
|
||
Has<benilla_world::rig_anim::AnimParked>,
|
||
&'static GlobalTransform,
|
||
Option<&'static benilla_world::rig_anim::RigPose>,
|
||
);
|
||
|
||
/// Fire the event keyframes an animated GameObject's playing clip crossed this frame — the GO
|
||
/// half of the M2 event-kernel surface (wow-re `go-display-sound-events.md`, the 1086 fold-back
|
||
/// record): the reference registers an event callback per **family-A** GO at create
|
||
/// (`0x5f7d1f` → vtable `+0x30` → dispatcher `0x5f3e20`), which is exactly the [`GoAnim`]
|
||
/// population — a loader-idle family-B GO has no dispatcher and stays silent. The events flow
|
||
/// into the same [`AnimSoundEvent`] stream the creature scanner feeds: the generic `$SND`/`$DSO`/
|
||
/// `$DSL` audio arms apply as-is, and the GO-only display-slot family (`$GO0..5`/`$GC0..3` →
|
||
/// `GameObjectDisplayInfo.Sound[0..9]`) is routed by [`crate::sound::gameobject`]. The
|
||
/// load-bearing tenant: the fishing bobber's Custom0 clip authors `$GC0` at t≈3.87 s — the
|
||
/// splash sound the real client plays from the *animation*, beside the server's explicit
|
||
/// object-sound packet (the audible double, faithful to the reference on vmangos).
|
||
///
|
||
/// The playing clip is found as the creature scanner finds a variation track: of the clips with
|
||
/// a live [`AnimationPlayer`] play, the smallest seek is the newest arm (a cross-fade's fading
|
||
/// source is older by construction). The shared [`advance_track`]/[`scan_events`] helpers give
|
||
/// the same arming rules — an arm frame fires nothing, the frame after it fires the clip's `t = 0`
|
||
/// head, a loop wrap fires tail-then-head — and a frozen rate-0 leg never advances, so it never
|
||
/// fires.
|
||
fn fire_go_anim_events(
|
||
gos: Query<ScannedGo, With<GoAnim>>,
|
||
globals: Query<&GlobalTransform>,
|
||
mut last: Local<crate::creature_anim::TrackMemory>,
|
||
mut out: MessageWriter<AnimSoundEvent>,
|
||
) {
|
||
for (entity, anims, player, parked, world, pose) in &gos {
|
||
// The election's tick half, GO twin (decision 1482): a parked GO's event track is not
|
||
// scanned — and there is no `MORE_AUDIBLE` exception here, because the flag lives on
|
||
// CREATURE templates only (the reference's re-link arm reads the creature query cache).
|
||
// Memory dropped so a wake re-arms instead of scanning the parked gap as one crossing.
|
||
if parked {
|
||
last.remove(&entity);
|
||
continue;
|
||
}
|
||
let playing = anims
|
||
.clips
|
||
.iter()
|
||
.filter_map(|c| player.animation(c.node).map(|a| (c, a.seek_time())))
|
||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||
if let Some((clip, cur)) = playing {
|
||
if let Some(prev) = advance_track(&mut last, entity, clip.node, cur) {
|
||
let frame = crate::creature_anim::EventFrame {
|
||
world,
|
||
rig: pose.and_then(|p| Some((p, globals.get(p.joints_root).ok()?))),
|
||
};
|
||
scan_events(clip, entity, prev, cur, &frame, &mut out);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Registration hook, mirrored on [`crate::sound::gameobject`]'s: the three state callers write
|
||
/// [`GoAnim::state`], then the animation + collision consumers act on it, after the Net drain wrote this
|
||
/// frame's descriptor deltas + queued the open-lock [`GoLidOpen`]. The event scanner runs last —
|
||
/// it reads the clip/seek state the drive just settled.
|
||
pub(crate) fn plugin(app: &mut App) {
|
||
app.add_message::<GoLidOpen>()
|
||
.add_message::<GoCustomAnim>()
|
||
.add_message::<GoStateDispatch>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
(
|
||
sync_wire_go_state,
|
||
open_go_lid,
|
||
close_go_lid,
|
||
queue_custom_anim,
|
||
arm_despawn_anim,
|
||
// The completion retire reads last frame's finished flags and must clear
|
||
// `shown` BEFORE the drive, so its state re-arm lands this frame — the
|
||
// reference's own one-frame-after-window-end timing.
|
||
retire_transient_anim,
|
||
),
|
||
(drive_go_anim, drive_go_collision),
|
||
// The pin release reads what the drive just armed — see its doc for why AFTER.
|
||
release_despawn_pin,
|
||
fire_go_anim_events,
|
||
)
|
||
.chain()
|
||
.in_set(WorldStage::Present),
|
||
);
|
||
// Ahead of the mover, not with the GO drive above: the set it publishes is read by
|
||
// `WorldCollision::cast_mover` inside the player controller, which runs in `Input`.
|
||
// Publishing in `Present` would hand the mover the previous frame's doors — imperceptible,
|
||
// but there is no reason to owe it.
|
||
app.add_systems(
|
||
Update,
|
||
publish_ghost_door_exclusions.in_set(WorldStage::Input),
|
||
);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use benilla_world::model_fade::DespawnFade;
|
||
|
||
/// **A ghost's mover drops DOOR GameObjects — and nothing else** (decision 1767).
|
||
///
|
||
/// The third case is the one this test exists for. `GAMEOBJECT_TYPE_ID` is absent on a unit,
|
||
/// and an absent field reads `0`, which *is* DOOR — so the type id alone says "door" for every
|
||
/// creature and player in the world. Excluding those from the mover's trace would mean walking
|
||
/// through units while dead, from a change that never mentions them.
|
||
#[test]
|
||
fn a_ghosts_trace_drops_doors_and_only_doors() {
|
||
use benilla_protocol::EntityKind;
|
||
|
||
assert!(
|
||
ghost_passable(EntityKind::GameObject, 0),
|
||
"a DOOR GameObject is what the reference's `0x5f85f0` refuses to make a candidate"
|
||
);
|
||
assert!(
|
||
!ghost_passable(EntityKind::GameObject, 1),
|
||
"a BUTTON is not — it shares the state-driven collider lane, not this one; the \
|
||
reference's predicate is an equality against 0"
|
||
);
|
||
assert!(
|
||
!ghost_passable(EntityKind::Unit, 0),
|
||
"and a UNIT reading type 0 is the absent field, not a door: units keep colliding"
|
||
);
|
||
assert!(
|
||
!ghost_passable(EntityKind::Player, 0),
|
||
"same for a player — the absent-field trap is not unit-specific"
|
||
);
|
||
}
|
||
|
||
/// The wire default that let open doors block (decision 0757). `None` must read ACTIVE/open,
|
||
/// never "unknown" — vmangos omits the zero-valued field, so `None` IS the open state for
|
||
/// every door that spawns open (ZG's Forcefield, Stratholme's small portcullises).
|
||
#[test]
|
||
fn an_absent_wire_state_is_open_not_unknown() {
|
||
assert!(!collider_is_solid(None), "absent == ACTIVE(0) == passable");
|
||
assert!(!collider_is_solid(Some(GO_STATE_ACTIVE)));
|
||
assert!(collider_is_solid(Some(GO_STATE_READY)));
|
||
// ALTERNATIVE (destroyed) is passable too — only READY is solid.
|
||
assert!(!collider_is_solid(Some(2)));
|
||
}
|
||
|
||
#[test]
|
||
fn rest_poses_match_the_verified_table() {
|
||
assert_eq!(rest_anim(1), Some(0x93)); // READY → Closed
|
||
assert_eq!(rest_anim(0), Some(0x95)); // ACTIVE → Opened (not the 0x94 Open *motion*)
|
||
assert_eq!(rest_anim(2), Some(0x97)); // ALT → Destroyed
|
||
assert_eq!(rest_anim(7), None);
|
||
}
|
||
|
||
#[test]
|
||
fn transitions_play_the_motion_then_settle() {
|
||
// closed → open swings the Open motion (0x94), open → closed the Close motion (0x92).
|
||
assert!(matches!(resolve(Some(1), 0), Some(Play::Motion(0x94))));
|
||
assert!(matches!(resolve(Some(0), 1), Some(Play::Motion(0x92))));
|
||
// First sight snaps the rest pose, never a motion (a door streamed in open must not swing).
|
||
assert!(matches!(resolve(None, 0), Some(Play::Rest(0x95))));
|
||
assert!(matches!(resolve(None, 1), Some(Play::Rest(0x93))));
|
||
// A change with no distinct motion snaps the destination rest pose.
|
||
assert!(matches!(resolve(Some(2), 0), Some(Play::Rest(0x95))));
|
||
// The destructible row dispatches on OLD too: only a CLOSED object swings Destroy; an open
|
||
// one rests straight on Destroyed (`0x5f3cb0`'s condition is `OLD == 1`, not any OLD).
|
||
assert!(matches!(resolve(Some(1), 2), Some(Play::Motion(0x96))));
|
||
assert!(matches!(resolve(Some(0), 2), Some(Play::Rest(0x97))));
|
||
// …and the rebuild leg stays the motion it always was.
|
||
assert!(matches!(resolve(Some(2), 1), Some(Play::Motion(0x98))));
|
||
}
|
||
|
||
/// The custom-anim channel's wire mapping (step 8, decision 1086): indices 0..3 arm
|
||
/// Custom0..3 (AnimationData 153..156); anything else is the reference handler's reject.
|
||
/// The fishing bobber's bite is index 0 → 153 — exactly the second sequence
|
||
/// `G_FishingBobber.m2` authors.
|
||
#[test]
|
||
fn custom_anim_maps_the_wire_index_and_rejects_past_3() {
|
||
assert_eq!(custom_anim_id(0), Some(153)); // Custom0 — the bobber splash
|
||
assert_eq!(custom_anim_id(1), Some(154));
|
||
assert_eq!(custom_anim_id(2), Some(155));
|
||
assert_eq!(custom_anim_id(3), Some(156));
|
||
assert_eq!(custom_anim_id(4), None);
|
||
assert_eq!(custom_anim_id(u32::MAX), None);
|
||
}
|
||
|
||
/// The delta [`step_clock`] uses for the next frame — the same device as the creature
|
||
/// driver's harness (`creature_anim/driver/tests.rs`) and for the same reason: with
|
||
/// `TimePlugin` live, `Time`'s delta is the REAL gap between two `app.update()` calls, so a
|
||
/// stalled frame on a loaded machine runs a whole clip out in one step and the assertions
|
||
/// below become a coin flip. Here the clock is a number the test writes.
|
||
#[derive(Resource, Default)]
|
||
struct NextStep(Option<Duration>);
|
||
|
||
fn step_clock(mut time: ResMut<Time>, mut next: ResMut<NextStep>) {
|
||
time.advance_by(next.0.take().unwrap_or(Duration::from_millis(1)));
|
||
}
|
||
|
||
/// Run one frame whose delta is exactly `ms`.
|
||
fn advance(app: &mut App, ms: u64) {
|
||
app.world_mut().resource_mut::<NextStep>().0 = Some(Duration::from_millis(ms));
|
||
app.update();
|
||
}
|
||
|
||
/// `G_Crate01`'s door family, as the bytes have it (pinned against the shipped asset by
|
||
/// `benilla-formats/tests/m2_go_crate_lid.rs`): Open/Opened/Close/Closed, **all four `looping`**
|
||
/// — `flags` bit 0 clear — with an empty replay range. Blend times are zeroed so the arm is a
|
||
/// cut and the assertions read the armed clip, not a fade.
|
||
const CRATE_FAMILY: [(u16, f32, u16); 4] = [
|
||
(0x94, 0.666, 0), // 148 Open — the lid sweeps 0° → 75°
|
||
(0x95, 0.100, 0), // 149 Opened — holds 75°
|
||
(0x92, 0.667, 0), // 146 Close — sweeps 75° → 0°
|
||
(0x93, 0.167, 29491), // 147 Closed — holds 0° (the chain HEAD, weighted like the traps')
|
||
];
|
||
|
||
/// An app running the two systems this file's law lives in, plus one GameObject wearing a
|
||
/// crate's animation set: REAL `AnimationClip` assets and a real graph, so Bevy's own
|
||
/// `advance_animations` ticks the completions [`retire_transient_anim`] watches.
|
||
fn crate_app(extra_ids: &[(u16, f32, u16)]) -> (App, Entity) {
|
||
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
|
||
use bevy::animation::AnimationClip;
|
||
|
||
let mut app = App::new();
|
||
app.add_plugins((
|
||
MinimalPlugins.build().disable::<bevy::time::TimePlugin>(),
|
||
AssetPlugin::default(),
|
||
bevy::animation::AnimationPlugin,
|
||
));
|
||
app.init_resource::<Time>();
|
||
app.init_resource::<NextStep>();
|
||
// The client's ONE `rand()` stream (2301) — the arm rolls a variation off it.
|
||
app.init_resource::<benilla_assets::AnimRng>();
|
||
// The state dispatch's audio consumer lives in `sound::gameobject`; here it is recorded
|
||
// instead, so a test can assert *which* edges re-enter `0x5f3cb0`.
|
||
app.add_message::<GoStateDispatch>();
|
||
app.init_resource::<Dispatched>();
|
||
app.add_systems(bevy::app::First, step_clock);
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
(arm_despawn_anim, retire_transient_anim),
|
||
drive_go_anim,
|
||
release_despawn_pin,
|
||
record_dispatches,
|
||
)
|
||
.chain(),
|
||
);
|
||
|
||
let authored: Vec<(u16, f32, u16)> =
|
||
CRATE_FAMILY.iter().chain(extra_ids).copied().collect();
|
||
let handles: Vec<_> = authored
|
||
.iter()
|
||
.map(|&(_, dur, _)| {
|
||
let mut c = AnimationClip::default();
|
||
c.set_duration(dur);
|
||
app.world_mut()
|
||
.resource_mut::<Assets<AnimationClip>>()
|
||
.add(c)
|
||
})
|
||
.collect();
|
||
let (graph, nodes) = AnimationGraph::from_clips(handles);
|
||
let graph = app
|
||
.world_mut()
|
||
.resource_mut::<Assets<AnimationGraph>>()
|
||
.add(graph);
|
||
|
||
let mut lookup = vec![0xffffu16; 160];
|
||
for (slot, &(id, _, _)) in authored.iter().enumerate() {
|
||
// A variation chain shares one id, and `animationLookup` holds its HEAD — so only the
|
||
// first slot claiming an id writes here, exactly as the exporter bakes it.
|
||
if lookup[id as usize] == 0xffff {
|
||
lookup[id as usize] = slot as u16;
|
||
}
|
||
}
|
||
let anims = ModelAnimations {
|
||
graph: graph.clone(),
|
||
clips: authored
|
||
.iter()
|
||
.zip(&nodes)
|
||
.map(|(&(id, dur, frequency), &node)| benilla_assets::AnimClip {
|
||
anim_id: id,
|
||
seq_index: 0,
|
||
node,
|
||
// The asset fact this whole record turns on: the crate's transitions are
|
||
// bit-0-clear bands, i.e. the kernel loops them.
|
||
looping: true,
|
||
duration: dur,
|
||
move_speed: 0.0,
|
||
blend_time: 0.0,
|
||
bounds_center: Vec3::ZERO,
|
||
bounds_radius: 0.0,
|
||
bounds_min: Vec3::ZERO,
|
||
bounds_max: Vec3::ZERO,
|
||
events: Vec::new().into(),
|
||
arm_nodes: None,
|
||
upper_node: None,
|
||
frequency,
|
||
replay: (0, 0),
|
||
poses_bones: true,
|
||
})
|
||
.collect(),
|
||
hand_close: [None, None],
|
||
playable_animation_lookup: Vec::new(),
|
||
animation_lookup: lookup,
|
||
global_bones: Vec::new(),
|
||
first_seq: None,
|
||
pose: Default::default(),
|
||
};
|
||
// First sight is a closed chest, exactly as it streams in.
|
||
let go = app
|
||
.world_mut()
|
||
.spawn((
|
||
anims,
|
||
AnimationPlayer::default(),
|
||
AnimationTransitions::new(),
|
||
AnimationGraphHandle(graph),
|
||
GoAnim {
|
||
state: Some(GO_STATE_READY),
|
||
..Default::default()
|
||
},
|
||
))
|
||
.id();
|
||
(app, go)
|
||
}
|
||
|
||
/// Every [`GoStateDispatch`] the run has produced, in order — the audio consumer's input,
|
||
/// stood in for.
|
||
#[derive(Resource, Default)]
|
||
struct Dispatched(Vec<Entity>);
|
||
|
||
fn record_dispatches(mut msgs: MessageReader<GoStateDispatch>, mut out: ResMut<Dispatched>) {
|
||
out.0.extend(msgs.read().map(|d| d.0));
|
||
}
|
||
|
||
/// What the object is actually playing: the armed `AnimationData` id and its repeat mode.
|
||
fn armed(app: &App, go: Entity) -> Option<(u16, RepeatAnimation)> {
|
||
let e = app.world().entity(go);
|
||
let node = e.get::<AnimationTransitions>()?.get_main_animation()?;
|
||
let id = e
|
||
.get::<ModelAnimations>()?
|
||
.clips
|
||
.iter()
|
||
.find(|c| c.node == node)?
|
||
.anim_id;
|
||
Some((
|
||
id,
|
||
e.get::<AnimationPlayer>()?.animation(node)?.repeat_mode(),
|
||
))
|
||
}
|
||
|
||
/// **The report** (decision 1151): loot the crate, close the loot window, and the lid must
|
||
/// settle SHUT — not spring open and slam once a window, for ever.
|
||
///
|
||
/// The whole door family is `flags` bit 0 clear, so a driver that arms a transition by the
|
||
/// clip's loop bit arms Close on `Forever`: the lid jumps back to 75° every 667 ms. What ends
|
||
/// a transition in the reference is the object layer's §2d advance (slot 14 `0x5f4120`:
|
||
/// substate 4 Close → 1 Closed), driven by the completion callback at the arm's baked window
|
||
/// — the loop bit ignored. This runs the whole click-to-settle cycle through the real systems
|
||
/// and real clips, and then keeps running: two seconds past the swing, three Close windows
|
||
/// wide, the crate must still be holding Closed.
|
||
#[test]
|
||
fn the_looted_crate_settles_shut_instead_of_flapping_for_ever() {
|
||
let (mut app, go) = crate_app(&[]);
|
||
let state = |app: &mut App, s: u32| {
|
||
app.world_mut()
|
||
.entity_mut(go)
|
||
.get_mut::<GoAnim>()
|
||
.unwrap()
|
||
.state = Some(s);
|
||
};
|
||
|
||
// Streamed in closed: the rest pose, snapped. It is ONE baked window like everything else
|
||
// — `0x5f4167` re-arms a resting substate through `0x5f3930` at its own completion, so the
|
||
// pose repeats by re-arming (and re-rolling its variation), not by the kernel's loop bit
|
||
// (wow-re `gameobject-anim-arm.md` §6; this corrects 1151's "nothing advances off a held
|
||
// pose"). What must hold across those windows is the armed ID, asserted below.
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
|
||
|
||
// The open-lock cast lands: the lid swings open, ONE window.
|
||
state(&mut app, GO_STATE_ACTIVE);
|
||
app.update();
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x94, RepeatAnimation::Never)),
|
||
"the Open motion is a transient substate — one window, whatever its loop bit says"
|
||
);
|
||
|
||
// …and at its window end the machine advances 2 Open → 3 Opened on its own.
|
||
advance(&mut app, 700);
|
||
app.update();
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x95, RepeatAnimation::Never)),
|
||
"the completion advance settles the swing onto the Opened rest pose"
|
||
);
|
||
|
||
// The loot window closes (`CMSG_LOOT_RELEASE`): the lid swings shut, ONE window.
|
||
state(&mut app, GO_STATE_READY);
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((0x92, RepeatAnimation::Never)));
|
||
|
||
// 4 Close → 1 Closed, and then it STAYS there — through many completion re-arms of the
|
||
// Closed pose itself. Pre-1151 the Close *motion* was armed `Forever`, so this is the
|
||
// assertion the director's crate report failed at, and the re-arm cycle must not
|
||
// resurrect it.
|
||
advance(&mut app, 700);
|
||
app.update();
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x93, RepeatAnimation::Never)),
|
||
"the lid must settle on Closed"
|
||
);
|
||
for _ in 0..20 {
|
||
advance(&mut app, 100);
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x93, RepeatAnimation::Never)),
|
||
"two seconds on — three Close windows — the crate is still shut"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **The arm rolls a VARIATION** — `variationIdx = -1` (wow-re `gameobject-anim-arm.md` §2c,
|
||
/// `0x5f3aee: push -1`), not the id's head. The §1 loader seed under it takes an explicit
|
||
/// variation 0 (`0x710189`), so a model's second and later takes are reachable ONLY through
|
||
/// this arm; resolving the id with `find()` made them unreachable everywhere.
|
||
///
|
||
/// The subject is Onyxia's lava traps in miniature: a Closed chain authored 29491 : 3276, where
|
||
/// the 10 %-weighted second take is the one that does something (there, a 300/s `LAVALUMP2`
|
||
/// burst — the lava spurting out of the lair floor; 208 trap GameObjects all played the silent
|
||
/// head). Spawning many objects makes the roll observable without a statistical test on one:
|
||
/// the count must be neither 0 (the bug) nor all (a broken walk), and near the authored tenth.
|
||
#[test]
|
||
fn the_arm_rolls_a_weighted_variation_not_the_head() {
|
||
const N: usize = 400;
|
||
let (mut app, go) = crate_app(&[(0x93, 0.866, 3276)]);
|
||
let (anims, player, tr, graph) = {
|
||
let e = app.world().entity(go);
|
||
(
|
||
e.get::<ModelAnimations>().unwrap().clone(),
|
||
AnimationPlayer::default(),
|
||
AnimationTransitions::new(),
|
||
e.get::<bevy::animation::graph::AnimationGraphHandle>()
|
||
.unwrap()
|
||
.clone(),
|
||
)
|
||
};
|
||
let second = anims
|
||
.clips
|
||
.iter()
|
||
.filter(|c| c.anim_id == 0x93)
|
||
.nth(1)
|
||
.expect("the chain's second take")
|
||
.node;
|
||
let mut gos = vec![go];
|
||
for _ in 1..N {
|
||
gos.push(
|
||
app.world_mut()
|
||
.spawn((
|
||
anims.clone(),
|
||
player.clone(),
|
||
tr.clone(),
|
||
graph.clone(),
|
||
GoAnim {
|
||
state: Some(GO_STATE_READY),
|
||
..Default::default()
|
||
},
|
||
))
|
||
.id(),
|
||
);
|
||
}
|
||
app.update();
|
||
let rolled = gos
|
||
.iter()
|
||
.filter(|&&e| {
|
||
app.world()
|
||
.entity(e)
|
||
.get::<AnimationPlayer>()
|
||
.unwrap()
|
||
.animation(second)
|
||
.is_some()
|
||
})
|
||
.count();
|
||
assert!(
|
||
rolled > 0,
|
||
"not one of {N} arms reached the second take — the arm is taking the head, which is \
|
||
the whole Onyxia lava-trap bug"
|
||
);
|
||
assert!(
|
||
(N / 40..N / 4).contains(&rolled),
|
||
"{rolled}/{N} on a 3276/32768 chain — the weighted walk is not weighting"
|
||
);
|
||
}
|
||
|
||
/// **A rest pose is ONE window, and its completion re-arms it — with a FRESH roll, for ever**
|
||
/// (wow-re `gameobject-anim-arm.md` §6c/§6d, correcting 1151's "nothing advances off a held
|
||
/// pose"). `0x719370` fires the completion at `span × R` without consulting the loop bit
|
||
/// (`0x719503` tests it only afterwards), and slot 14's resting row `0x5f4167` calls
|
||
/// `0x5f3930` directly — past slot 34's change guard — on an unchanged substate.
|
||
///
|
||
/// This is the half that makes Onyxia's Lair a *continuous* ember field rather than one flurry
|
||
/// at stream-in: ~200 trap GameObjects each re-roll a 10 %-weighted Stand chain every 867 ms,
|
||
/// against a 5 s particle lifespan. One object, many windows, is the same law in the small.
|
||
#[test]
|
||
fn a_rest_pose_re_arms_every_window_and_re_rolls_its_variation() {
|
||
let (mut app, go) = crate_app(&[(0x93, 0.866, 3276)]);
|
||
let nodes: Vec<_> = {
|
||
let e = app.world().entity(go);
|
||
e.get::<ModelAnimations>()
|
||
.unwrap()
|
||
.clips
|
||
.iter()
|
||
.filter(|c| c.anim_id == 0x93)
|
||
.map(|c| c.node)
|
||
.collect()
|
||
};
|
||
assert_eq!(nodes.len(), 2, "the chain is two takes");
|
||
let mut seen = [0u32; 2];
|
||
app.update();
|
||
for _ in 0..300 {
|
||
let player = app.world().entity(go).get::<AnimationPlayer>().unwrap();
|
||
for (i, n) in nodes.iter().enumerate() {
|
||
if player.animation(*n).is_some() {
|
||
seen[i] += 1;
|
||
}
|
||
}
|
||
// Longer than either take's band, so whichever is armed completes and re-arms.
|
||
advance(&mut app, 900);
|
||
}
|
||
assert!(
|
||
seen[0] > 0 && seen[1] > 0,
|
||
"over 300 windows the pose showed takes {seen:?} — a rest pose that never re-armed \
|
||
would show exactly one, and Onyxia's floor would spurt once and go quiet"
|
||
);
|
||
// The pose itself never wanders off Closed: re-arming is not the §2d substate advance.
|
||
assert!(matches!(armed(&app, go), Some((0x93, _))));
|
||
}
|
||
|
||
/// **The state dispatch is an EDGE, and a rest pose re-arming itself is not one** (wow-re
|
||
/// `go-display-sound-events.md` §6c: slot 14 sends a resting substate to `0x5f4167`, which
|
||
/// calls `0x5f3930` directly and never re-enters `0x5f3cb0`). That is the whole reason a
|
||
/// brazier's `TorchLoop` — a looping display-slot kit registered by a `$GO2` on the Opened
|
||
/// rest pose — hums continuously instead of being released and restarted every band pass,
|
||
/// because the dispatch's first act is `0x5f3cc8 call 0x5f40c0`.
|
||
///
|
||
/// What *does* dispatch: a genuine `SetGoState`, and a Custom substate's completion (slot
|
||
/// 14's `0x5f4190` row, `old == new`) — which is what ends a Gnome machine's Custom0 hum.
|
||
#[test]
|
||
fn only_a_state_change_and_a_custom_completion_re_enter_the_dispatch() {
|
||
let (mut app, go) = crate_app(&[(153, 0.667, 0)]); // Custom0 — the crate authors one
|
||
app.update();
|
||
let seen = |app: &App| app.world().resource::<Dispatched>().0.len();
|
||
assert_eq!(seen(&app), 1, "the spawn's own SetGoState is a dispatch");
|
||
|
||
// Fifty rest windows. The pose re-arms every one of them (the Onyxia law) and not one of
|
||
// those re-arms may announce a dispatch.
|
||
for _ in 0..50 {
|
||
advance(&mut app, 200);
|
||
}
|
||
assert!(matches!(armed(&app, go), Some((0x93, _))), "still Closed");
|
||
assert_eq!(seen(&app), 1, "a rest pose's own re-arm is not a dispatch");
|
||
|
||
// A genuine SetGoState — the lid opens.
|
||
app.world_mut()
|
||
.entity_mut(go)
|
||
.get_mut::<GoAnim>()
|
||
.unwrap()
|
||
.state = Some(GO_STATE_ACTIVE);
|
||
app.update();
|
||
assert_eq!(seen(&app), 2);
|
||
// …and the Open motion settling onto Opened is slot 14's `0x5f413d` row, not the dispatch.
|
||
// (Two ticks: the first runs the clip past its window, the next is where the retire sees
|
||
// the finished flag — the shape every completion test in this file uses.)
|
||
advance(&mut app, 700);
|
||
app.update();
|
||
assert_eq!(
|
||
seen(&app),
|
||
2,
|
||
"a transition motion advancing to its rest pose does not re-enter the dispatch"
|
||
);
|
||
|
||
// A Custom0 block, whose completion does.
|
||
app.world_mut()
|
||
.entity_mut(go)
|
||
.get_mut::<GoAnim>()
|
||
.unwrap()
|
||
.one_shot = Some(153);
|
||
app.update();
|
||
assert_eq!(seen(&app), 2, "arming a Custom is not itself a dispatch");
|
||
advance(&mut app, 700);
|
||
app.update();
|
||
assert_eq!(
|
||
seen(&app),
|
||
3,
|
||
"its completion re-runs the machine through `0x5f3cb0`"
|
||
);
|
||
assert!(
|
||
app.world()
|
||
.resource::<Dispatched>()
|
||
.0
|
||
.iter()
|
||
.all(|&e| e == go),
|
||
"every dispatch names the object it ran on"
|
||
);
|
||
}
|
||
|
||
/// The Custom channel shares the ONE transient slot with the motions (the reference's
|
||
/// `[handler+0x10]`), so 1100's bobber law has to keep holding through it: a Custom0 arms over
|
||
/// the state pose, runs exactly one window, and the completion re-runs the machine back onto
|
||
/// the state's own pose.
|
||
#[test]
|
||
fn a_custom_block_still_runs_one_window_and_hands_back_to_the_state() {
|
||
let (mut app, go) = crate_app(&[(153, 0.667, 0)]); // Custom0 — the crate authors one
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
|
||
|
||
app.world_mut()
|
||
.entity_mut(go)
|
||
.get_mut::<GoAnim>()
|
||
.unwrap()
|
||
.one_shot = Some(153);
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((153, RepeatAnimation::Never)));
|
||
|
||
advance(&mut app, 700);
|
||
app.update();
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x93, RepeatAnimation::Never)),
|
||
"one Custom window, then the state pose — never a churning loop"
|
||
);
|
||
}
|
||
|
||
/// **B140** (decision 1404): UBRS's Rookery Eggs. Walk into a `TRAP`'s radius, the server
|
||
/// spends its last charge and sends `SMSG_GAMEOBJECT_DESPAWN_ANIM` immediately followed by
|
||
/// `SMSG_DESTROY_OBJECT` in the same tick — and the egg must **hatch before it pops**: 157
|
||
/// Despawn, one window (2.667 s on `G_DragonEggFreeze`, whose bone 8 swells 4.76×), with the
|
||
/// object held alive for exactly that long by the pin.
|
||
///
|
||
/// The failing shape this pins is the whole report: the destroy freed the entity on arrival,
|
||
/// so 271 eggs blinked out with no animation at all.
|
||
#[test]
|
||
fn an_announced_despawn_plays_its_window_before_the_object_pops() {
|
||
let (mut app, go) = crate_app(&[(157, 2.667, 0)]);
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
|
||
|
||
// The wire pair, in the order vmangos sends it and Commands apply it.
|
||
app.world_mut().entity_mut(go).insert((
|
||
crate::net::Guid(0xF110_0000_0000_0E66),
|
||
DespawnAnimAnnounced,
|
||
PendingDestroy,
|
||
));
|
||
app.update();
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((157, RepeatAnimation::Never)),
|
||
"the announcement arms 157 Despawn for ONE window"
|
||
);
|
||
assert!(
|
||
app.world().get::<crate::net::Guid>(go).is_some(),
|
||
"the pin holds the OBJECT alive across its own destroy, not just its model"
|
||
);
|
||
|
||
advance(&mut app, 2700);
|
||
app.update();
|
||
assert!(
|
||
app.world().get::<DespawnFade>(go).is_some(),
|
||
"the window ended — the pin drops and the deferred destroy hands the model to the fade"
|
||
);
|
||
assert!(
|
||
app.world().get::<crate::net::Guid>(go).is_none(),
|
||
"…and the deferred destroy is the teardown: the object ends as the fade begins"
|
||
);
|
||
assert!(
|
||
app.world().get::<PendingDestroy>(go).is_none(),
|
||
"and the pin comes off with it, so the release stops re-arming the fade"
|
||
);
|
||
}
|
||
|
||
/// The other half of the same rule: a model that doesn't author 157 (or an object with no
|
||
/// animation machine at all — a totem, a DynamicObject, both of which `SendObjectDeSpawnAnim`
|
||
/// also fires for) has nothing to play, so the pin never forms and the release runs on its
|
||
/// first pass — straight to the fade, with no animation in front of it. **This is the looted
|
||
/// chest** (decision 2198): `DeadmineCargoBoxes.m2` is a fully static model, so the announced
|
||
/// despawn resolves to nothing and the teardown fade is the whole observable.
|
||
#[test]
|
||
fn an_unownable_despawn_anim_goes_straight_to_the_fade() {
|
||
let (mut app, go) = crate_app(&[]); // the crate family only — no 157
|
||
app.update();
|
||
|
||
app.world_mut()
|
||
.entity_mut(go)
|
||
.insert((DespawnAnimAnnounced, PendingDestroy));
|
||
app.update();
|
||
assert!(
|
||
app.world().get::<DespawnFade>(go).is_some(),
|
||
"nothing to play ⇒ the teardown fade, same frame — never a pop"
|
||
);
|
||
}
|
||
|
||
/// The announcement **alone** kills nothing. `SendObjectDeSpawnAnim` lives on `WorldObject`,
|
||
/// and two vmangos boss scripts send it for objects that go on living (Sapphiron sends it for
|
||
/// *himself*); only a destroy that actually arrives marks [`PendingDestroy`]. So an announced
|
||
/// object with no destroy plays its window and returns to its state pose, still there.
|
||
#[test]
|
||
fn an_announcement_without_a_destroy_never_despawns_anything() {
|
||
let (mut app, go) = crate_app(&[(157, 2.667, 0)]);
|
||
app.update();
|
||
|
||
app.world_mut().entity_mut(go).insert(DespawnAnimAnnounced);
|
||
app.update();
|
||
assert_eq!(armed(&app, go), Some((157, RepeatAnimation::Never)));
|
||
|
||
advance(&mut app, 2700);
|
||
app.update();
|
||
assert!(app.world().get_entity(go).is_ok(), "no destroy, no despawn");
|
||
assert_eq!(
|
||
armed(&app, go),
|
||
Some((0x93, RepeatAnimation::Never)),
|
||
"and the window hands back to the state pose, like any other one-shot"
|
||
);
|
||
}
|
||
|
||
/// A `ModelAnimations` that owns exactly `ids` — only the lookup table matters to the remap.
|
||
fn owning(ids: &[u16]) -> ModelAnimations {
|
||
let hi = ids.iter().copied().max().unwrap_or(0) as usize;
|
||
let mut lookup = vec![0xffffu16; hi + 1];
|
||
for (slot, &id) in ids.iter().enumerate() {
|
||
lookup[id as usize] = slot as u16;
|
||
}
|
||
ModelAnimations {
|
||
graph: Handle::default(),
|
||
clips: Vec::new(),
|
||
hand_close: [None, None],
|
||
playable_animation_lookup: Vec::new(),
|
||
animation_lookup: lookup,
|
||
global_bones: Vec::new(),
|
||
first_seq: None,
|
||
pose: Default::default(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_owned_id_is_never_remapped() {
|
||
let full = owning(&[146, 147, 148, 149]);
|
||
for id in [146, 147, 148, 149] {
|
||
assert_eq!(remap_missing(&full, id), (id, false));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_rest_pose_freezes_the_neighbouring_motion() {
|
||
// The Ahn'Qiraj gate roots: Stand + Open only, no Closed. The reference stands the Open
|
||
// motion in at rate 0 — its frame 0 IS the closed pose. Before this, benilla played nothing
|
||
// and the 42-bone root tangle rendered at bind pose.
|
||
let roots = owning(&[0, 148]);
|
||
assert_eq!(remap_missing(&roots, 147), (148, true));
|
||
// The mirror leg: no Opened, no Open, but a Close motion → freeze Close at frame 0 (open).
|
||
let no_open = owning(&[146]);
|
||
assert_eq!(remap_missing(&no_open, 149), (146, true));
|
||
}
|
||
|
||
#[test]
|
||
fn the_remap_falls_through_the_door_family_in_the_verified_order() {
|
||
// 146 Close missing: kept when Open exists (op4 resolves onward), else Closed.
|
||
assert_eq!(remap_missing(&owning(&[148]), 146), (146, false));
|
||
assert_eq!(remap_missing(&owning(&[147]), 146), (147, false));
|
||
// 147 Closed missing: kept when Close exists; else the frozen leg; else Stand.
|
||
assert_eq!(remap_missing(&owning(&[146]), 147), (147, false));
|
||
assert_eq!(remap_missing(&owning(&[0]), 147), (0, false));
|
||
// 148 Open missing: kept when Close exists; else Destroy if present; else Opened.
|
||
assert_eq!(remap_missing(&owning(&[146]), 148), (148, false));
|
||
assert_eq!(remap_missing(&owning(&[150]), 148), (150, false));
|
||
assert_eq!(remap_missing(&owning(&[0]), 148), (149, false));
|
||
// 149 Opened missing with neither motion: Destroyed.
|
||
assert_eq!(remap_missing(&owning(&[0]), 149), (151, false));
|
||
}
|
||
|
||
/// Slot 14's dispatch table (`0x5f41e4`): rows 0 and 8..11 land at `0x5f4190`, which re-runs
|
||
/// the state machine **through `0x5f3cb0`** and so drops the object's display-sound loop; the
|
||
/// motion rows (2/4/5/7) advance to their rest pose and the rest rows (1/3/6) re-arm
|
||
/// themselves, neither of which touches it. Read as `AnimationData.dbc` ids, the key this
|
||
/// file uses everywhere.
|
||
#[test]
|
||
fn only_spawn_and_the_custom_block_re_enter_the_state_dispatch() {
|
||
for id in [145, 153, 154, 155, 156] {
|
||
assert!(completion_redispatches(id), "substate id {id}");
|
||
}
|
||
// The four transition motions, the three rest poses — and Despawn (157), whose substate
|
||
// 12 is past slot 14's own `cmp 0xb` bound and dispatches nowhere at all.
|
||
for id in [146, 147, 148, 149, 150, 151, 152, 157] {
|
||
assert!(!completion_redispatches(id), "substate id {id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ids_outside_the_door_group_get_no_remap() {
|
||
// `lea eax,[esi-0x92]; cmp eax,3; ja` — only 146..149 index the jump table.
|
||
let none = owning(&[0]);
|
||
for id in [145, 150, 151, 152, 157] {
|
||
assert_eq!(remap_missing(&none, id), (id, false));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ownership_reads_the_lookup_table_not_the_clip_list() {
|
||
// The table is short — a model's `nAnimationLookup` stops just past its highest id, so an id
|
||
// beyond the end is the out-of-bounds sentinel. `clips` is deliberately empty here: a clip
|
||
// scan would answer "owns nothing" and take every remap's last leg.
|
||
let book = owning(&[146, 147, 148, 149]);
|
||
assert!(book.owns(147) && !book.owns(0) && !book.owns(150));
|
||
}
|
||
|
||
#[test]
|
||
fn chest_animates_but_keeps_its_collider() {
|
||
// A chest (3) is on the animation machine (2271) but off the collision gate (0249): you see the
|
||
// lid move, but you never walk through an open chest.
|
||
assert!(go_animates(3));
|
||
assert!(!collision_follows_state(3));
|
||
// Doors/buttons are on both.
|
||
assert!(go_animates(0) && collision_follows_state(0));
|
||
assert!(go_animates(1) && collision_follows_state(1));
|
||
}
|
||
|
||
#[test]
|
||
fn the_type_gate_is_the_verified_census() {
|
||
// The 20 family-A types (0x1c-byte handler, 36-slot vtable with the real arm).
|
||
for t in [
|
||
0, 1, 2, 3, 6, 8, 9, 10, 12, 16, 17, 18, 19, 23, 24, 26, 27, 28, 29, 30,
|
||
] {
|
||
assert!(go_animates(t), "type {t} runs the machine");
|
||
}
|
||
// The 11 family-B types, whose vtable slots are the abstract base's do-nothing bodies.
|
||
for t in [4, 5, 7, 11, 13, 14, 15, 20, 21, 22, 25] {
|
||
assert!(!go_animates(t), "type {t} keeps the loader seed");
|
||
}
|
||
// Past the table: the unsigned `ja` default, no handler.
|
||
assert!(!go_animates(31) && !go_animates(99));
|
||
// The three that used to be the whole gate are still on it; the ones it wrongly excluded —
|
||
// TEXT (every book) and GOOBER — are the reported regressions this widening closes.
|
||
assert!(go_animates(9) && go_animates(10));
|
||
// Type 0 is also what an ABSENT type id reads as (decision 0248), so the default animates.
|
||
assert!(go_animates(0));
|
||
}
|
||
}
|