Files
benilla-octo/crates/benilla-app/src/go_anim.rs
T
SamandClaude Fable 5 404bbb0c6a lava creeps again, and sealed rooms empty of outdoor bodies
- lava and slime surfaces creep on the building path — the two liquid types the reference animates, and only those
- particle blend 1 is AlphaKey: the rock elemental throws tumbling chips instead of flat pale squares
- standing in a sealed room, outdoor creatures are never submitted to the scene at all — the reference's own election, not a hide
- an animation clip's arm frame fires no event keys, matching the client's walker rule frame for frame

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:23:04 +07:00

1125 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GameObject open/close animation (decision 0242; chest lid folded in by 0250) — 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 (0250, §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;
/// Marker + client-side state for an animated GameObject (decisions 0242/0250). 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>,
/// The last `GAMEOBJECT_STATE` seen on the wire, so [`sync_wire_go_state`] writes `state` only on a
/// *genuine* wire change — a chest's wire state is constant (its lid is driven by loot events, not the
/// wire), so an unrelated values-update (a dyn-flag, a position) must not re-close an open lid.
last_wire: Option<u32>,
/// A pending one-shot **Custom** play (AnimationData id 153..156) from
/// `SMSG_GAMEOBJECT_CUSTOM_ANIM` — the second, disjoint arm channel of wow-re
/// `gameobject-anim-arm.md` §"one-shot channel" step 8 (substates 8..11, never the §243 lid
/// family). Armed by [`queue_custom_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).
custom: 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<u16>,
}
/// 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 == Some(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
)
}
/// 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 0250). [`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). Runs on the seed
/// (`Added<GoAnim>`, when attach tags the entity) and on any later descriptor delta; the `last_wire`
/// guard makes an unrelated field change (position, dyn-flags) a no-op, so a chest whose wire state is
/// constant is never re-closed by one — its lid is owned by the loot callers below.
#[allow(clippy::type_complexity)]
fn sync_wire_go_state(
mut gos: Query<(&ObjectStore, &mut GoAnim), Or<(Changed<ObjectStore>, Added<GoAnim>)>>,
) {
for (store, mut anim) in &mut gos {
// 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. The `last_wire` guard is
// unaffected: a constant wire state (a chest's `Some(1)`) still compares equal frame to
// frame, so the client-predicted lid is still never re-closed by an unrelated delta.
let wire = Some(store.0.gameobject_state().unwrap_or(GO_STATE_ACTIVE));
if wire == anim.last_wire {
continue; // an unrelated field changed (position, flags, dyn-flags) — not our transition
}
anim.last_wire = wire;
if let Some(s) = wire {
anim.state = Some(s);
}
}
}
/// 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.custom = 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)
}
/// 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) | nothing (`0x5f4167`) | a held pose never advances |
///
/// **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.
///
/// Benilla arms every transient 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<(&mut GoAnim, &AnimationPlayer, &ModelAnimations)>) {
for (mut go, player, anims) in &mut gos {
let Some(id) = go.transient else {
continue;
};
let done = anims
.find(id)
.is_none_or(|clip| player.animation(clip.node).is_none_or(|a| a.is_finished()));
if done {
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;
}
}
}
/// 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<
(
&mut GoAnim,
&mut AnimationPlayer,
&mut AnimationTransitions,
&ModelAnimations,
),
Changed<GoAnim>,
>,
) {
for (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);
// 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;
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());
if let Some(clip) = anims.find(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 active =
tr.play(&mut player, clip.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);
// **The transition motion is ONE window, whatever the loop bit says**
// (decision 1151): it is a transient substate, ended by the object
// layer's §2d advance ([`retire_transient_anim`]), never by the
// kernel — whose bit-0-clear branch wraps the band for ever and would
// make the crate lid spring open and slam shut ~1.5×/s. A *rest* pose
// is the opposite: nothing advances off it (slot 14's `0x5f4167`), so
// it holds or loops exactly as the model authored it.
match play {
Play::Motion(_) => {
active.set_repeat(RepeatAnimation::Never);
go.transient = Some(want);
}
Play::Rest(_) => {
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
}
}
}
}
// 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.custom.is_some() {
let id = go.custom.take().expect("checked is_some");
if anims.owns(id) {
if let Some(clip) = anims.find(id) {
let active = tr.play(
&mut player,
clip.node,
Duration::from_secs_f32(clip.blend_time.max(0.0)),
);
active.set_speed(1.0);
active.set_repeat(RepeatAnimation::Never);
go.transient = Some(id);
}
}
}
}
}
/// 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 an `ObjectStore` change 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 `Changed<ObjectStore>` 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.
///
/// Same shape as [`sync_wire_go_state`]'s `Or<(Changed<ObjectStore>, Added<GoAnim>)>` — the
/// seed-plus-delta pair this file already uses for every other state consumer.
#[allow(clippy::type_complexity)]
fn drive_go_collision(
mut commands: Commands,
gos: Query<
(Entity, &ObjectStore, Has<ColliderDisabled>),
(With<Collider>, Or<(Changed<ObjectStore>, Added<Collider>)>),
>,
) {
for (entity, store, disabled) in &gos {
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);
}
}
}
/// 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<(Entity, &ModelAnimations, &AnimationPlayer), With<GoAnim>>,
mut last: Local<crate::creature_anim::TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
) {
for (entity, anims, player) in &gos {
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) {
scan_events(clip, entity, prev, cur, &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_systems(
Update,
(
(
sync_wire_go_state,
open_go_lid,
close_go_lid,
queue_custom_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),
fire_go_anim_events,
)
.chain()
.in_set(WorldStage::Present),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// 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); 4] = [
(0x94, 0.666), // 148 Open — the lid sweeps 0° → 75°
(0x95, 0.100), // 149 Opened — holds 75°
(0x92, 0.667), // 146 Close — sweeps 75° → 0°
(0x93, 0.167), // 147 Closed — holds 0°
];
/// 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)]) -> (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>();
app.add_systems(bevy::app::First, step_clock);
app.add_systems(Update, (retire_transient_anim, drive_go_anim).chain());
let authored: Vec<(u16, f32)> = 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() {
lookup[id as usize] = slot as u16;
}
let anims = ModelAnimations {
graph: graph.clone(),
clips: authored
.iter()
.zip(&nodes)
.map(|(&(id, dur), &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: 0,
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)
}
/// 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, and it keeps the loop the model authored —
// nothing advances off a held pose (`0x5f4167`), so this leg must NOT be narrowed to one
// window along with the motions.
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
// 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::Forever)),
"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. Pre-1151 the Close clip was armed
// `Forever`, so this is the assertion the director's report failed at.
advance(&mut app, 700);
app.update();
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"the lid must settle on Closed"
);
for _ in 0..20 {
advance(&mut app, 100);
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"two seconds on — three Close windows — the crate is still shut"
);
}
}
/// 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)]); // Custom0 — the crate authors one
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
app.world_mut()
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.custom = 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::Forever)),
"one Custom window, then the state pose — never a churning loop"
);
}
/// 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));
}
#[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 (0250) 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));
}
}