//! GameObject state animation: `GAMEOBJECT_STATE` drives a skeletal M2 sequence, so a door //! swings, a button depresses and a chest lid opens and closes. //! //! The 1.12 client keeps one stored state per object (`go+0x27c`), set only through `SetGoState` //! (`0x5f8bd0`); [`GoAnim::state`] is that state. The server never flips a chest's wire state, so //! an open-lock cast and the loot release move its lid. //! //! A state maps, with no inversion, to a held rest pose ([`rest_anim`]), and a change plays a //! one-shot motion onto it ([`motion_anim`]). Keyed by `AnimationData.dbc` id: the machine's //! internal index and the debug state names at `0x860850` are off by one. //! //! The loop bit (`flags` bit 0, `0x714585`) does not end a motion; the object layer's completion //! does, so every arm is one window ([`RepeatAnimation::Never`]) and [`retire_transient_anim`] //! advances the machine at its end. //! //! Not built: `GAMEOBJECT_ANIMPROGRESS`, which picks the motion at spawn and seeks it to //! `duration × progress / 100` (`0x5f3ac5`), and the reverse blend of an interrupted swing. 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`): open, and passable. const GO_STATE_ACTIVE: u32 = 0; /// `GO_STATE_READY` (vmangos `GOState`): closed, the one state in which a door or button blocks. const GO_STATE_READY: u32 = 1; /// `AnimationData.dbc` 157 Despawn, the one-shot channel's code 6 (`0x80b0e0[6]` = substate 12, /// `0x8607e4[12]` = 157): the object plays it once, then goes. const ANIM_DESPAWN: u16 = 157; /// An animated GameObject's client-side state, tagged by [`crate::entities::attach`] in place of a /// creature's `AnimDriver`, and driven by [`drive_go_anim`]. #[derive(Component, Default)] pub(crate) struct GoAnim { /// The stored `GAMEOBJECT_STATE` (`go+0x27c`), the one truth for animation and collision; /// `None` until first sight. state: Option, /// The state last animated to, which picks the next motion; first sight settles silently, so a /// door that streams in open does not swing. shown: Option, /// A pending one-shot (an `AnimationData.dbc` id) on the reference's one slot, fed by /// `0x5f8c50(GO, code)` (slot 15) through `0x80b0e0`: code 1 Spawn on an update type 3 create /// (not built), 2..5 Custom0..3 from opcode `0xb3`, 6 Despawn from opcode `0x215`. one_shot: Option, /// The armed transient substate (a motion, or Custom0..3), the reference's one /// `[handler+0x10]` slot; [`retire_transient_anim`] advances it at its window end. transient: Option, /// The rest pose's armed clip while it is on the re-arm cycle; `None` while a transient owns /// the model, on a frozen leg, and once the already-playing skip has refused a re-arm. rest_window: Option, /// The state last dispatched (`0x5f3cb0(old, new)`, [`GoStateDispatch`]); the retire's /// re-resolve is not a dispatch. dispatched: Option, /// The requested id currently armed (`[block0+0xf8]`, written by op4 `0x71252f`, read by /// `0x712090(model, -1)`), which the already-playing skip compares. armed_id: Option, } /// The dispatch `0x5f3cb0` ran on this entity; its first act (`0x5f3cc8 call 0x5f40c0`) releases /// the display-sound loop [`crate::sound::gameobject`] owns. A rest pose's own re-arm /// (`0x5f4167`) is no dispatch, so a brazier's `TorchLoop` hums on. #[derive(Message)] pub(crate) struct GoStateDispatch(pub(crate) Entity); /// Slot 14 sends a completed Spawn (145) or Custom0..3 (153..156) back through the dispatch /// (`0x5f4190`); a motion advances (`0x5f413d`, `0x5f414b`, `0x5f4159`) and a rest pose re-arms /// (`0x5f4167`) without it. fn completion_redispatches(id: u16) -> bool { matches!(id, 145 | 153..=156) } /// The armed transient clip. The arm rolls a variation, so [`retire_transient_anim`] watches the /// node that was armed, not the id's head. #[derive(Clone, Copy)] struct Transient { /// The `AnimationData.dbc` id, the reference's `[handler+0x10]` substate. id: u16, node: AnimationNodeIndex, } /// The stored state (`go+0x27c`) every consumer reads (the animation, `usable`, the lock chain's /// per-slot Action gate): [`GoAnim::state`] when animated, since it holds what the wire never /// sends, else the wire field, else `0`, ACTIVE, the value vmangos omits. 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 readout: the armed id (the newest arm, the smallest seek), whether it is a /// transient, and its repeat mode; `None` for a static mesh or before any arm. 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 types run the state machine: `CGGameObject::LoadBaseObject` switches on the type through /// the 31-entry table at `0x5f76cc` (`cmp ecx,0x1e`, unsigned `ja`) into a handler at /// `[GO+0x210]`; a `0x1c`-byte handler gets the real `0x5f3c30`/`0x5f3b50` arm, every other size /// the base's empty bodies. Type 30 is past vmangos's enum but has a real arm; 21 GUARDPOST has no /// case and takes the default, which logs `"BADBASEGAMEOBJECT|%d"`. 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`, the one type a ghost's mover trace drops; also what an absent /// `GAMEOBJECT_TYPE_ID` means, since vmangos omits zero-valued fields. const GO_TYPE_DOOR: i32 = 0; /// Types whose collider follows the state: DOOR and BUTTON. The static hull cannot swing with the /// mesh, so an open one disables it; a chest keeps its collider in every state. fn collision_follows_state(type_id: i32) -> bool { matches!(type_id, 0 | 1) } /// An absent wire state is `0`, ACTIVE, open: vmangos omits zero-valued fields, so a door that /// spawns open (Zul'Gurub's Forcefield 180497) sends none. fn collider_is_solid(wire_state: Option) -> bool { wire_state.unwrap_or(GO_STATE_ACTIVE) == GO_STATE_READY } /// A cast at a GameObject (`SMSG_SPELL_GO` with `TARGET_FLAG_GAMEOBJECT`), bridged from the net /// layer for [`open_go_lid`]. #[derive(Message, Clone, Copy)] pub(crate) struct GoLidOpen { pub(crate) go_guid: u64, pub(crate) spell_id: u32, } /// `SMSG_GAMEOBJECT_CUSTOM_ANIM`, bridged from the net layer; the fishing bobber's bite sends /// `anim_id` 0 beside the forced state flip and the server's `SMSG_PLAY_OBJECT_SOUND`. #[derive(Message, Clone, Copy)] pub(crate) struct GoCustomAnim { pub(crate) go_guid: u64, pub(crate) anim_id: u32, } /// What a `GAMEOBJECT_STATE` plays: a held rest pose, or a one-shot motion onto the new pose. #[derive(Clone, Copy, Debug)] enum Play { /// Snap to a held pose, no swing (first sight). Rest(u16), /// Play a transition motion once, onto the destination rest pose. Motion(u16), } impl Play { fn anim_id(self) -> u16 { match self { Play::Rest(id) | Play::Motion(id) => id, } } } /// The missing-sequence remap (`0x5f3930`): what the arm requests when the model does not own the /// id ([`ModelAnimations::owns`], the reference's `0x711960`), by `0x5f3972`'s jump table /// `0x5f3b40`, for 146..149 only (`lea eax,[esi-0x92]; cmp eax,3; ja`). Ids: 0 Stand, 146 Close, /// 147 Closed, 148 Open, 149 Opened, 150 Destroy, 151 Destroyed; a kept id is one op4 resolves /// onward. The flag marks the frozen legs: a motion armed at rate 0 holds its frame 0, the pose it /// departs from, in place of the missing rest pose. 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 for a wire `GAMEOBJECT_STATE` (`0x5f3c30`). fn rest_anim(state: u32) -> Option { match state { 0 => Some(0x95), // ACTIVE → Opened (held open) 1 => Some(0x93), // READY → Closed (held closed) 2 => Some(0x97), // ALT → Destroyed _ => None, } } /// The motion for a `prev → cur` change. The reference (`0x5f3cb0`) dispatches on the new state and /// plays a motion from one old state only; each condition is also met by `ANIMPROGRESS < 100`, /// which benilla does not read. fn motion_anim(prev: u32, cur: u32) -> Option { match (prev, cur) { (1, 0) => Some(0x94), // closed → open : Open (0, 1) => Some(0x92), // open → closed : Close (2, 1) => Some(0x98), // rebuilt → closed : Rebuild (1, 2) => Some(0x96), // closed → destroyed : Destroy _ => None, } } /// First sight snaps the rest pose; a change plays its motion, else snaps the new rest pose. fn resolve(prev: Option, cur: u32) -> Option { 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 (`0x5f89e0`): seeds the state at first sight, then follows the /// `GAMEOBJECT_STATE` edge, so an unrelated field change never re-closes a looted chest. #[allow(clippy::type_complexity)] fn sync_wire_go_state( mut edges: MessageReader, // The seed leg and the edge leg both write `GoAnim`; one `ParamSet`, two disjoint views. mut gos: ParamSet<( Query<(&ObjectStore, &mut GoAnim), Added>, Query<&mut GoAnim>, )>, ) { for (store, mut anim) in &mut gos.p0() { // Absent is `0`, ACTIVE: vmangos omits zero fields. 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: a cast at an animated object opens it when the spell has an /// open-lock effect (`[spell+0xf4] ∈ {OPEN_LOCK, OPEN_LOCK_ITEM}`), whoever cast it. fn open_go_lid( mut opens: MessageReader, spells: Option>, index: Res, 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: queues a one-shot on the separate `0x5f8c50` channel; a guid /// with no [`GoAnim`] drops it, and ownership is judged in [`drive_go_anim`]. fn queue_custom_anim( mut plays: MessageReader, index: Res, 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 reject (`0x5f8971`) }; 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 to its `AnimationData` id (`153 + n`); the reference handler (`0x5f8971`) /// rejects `anim_id >= 4` and plays substate `8 + n`. fn custom_anim_id(anim_id: u32) -> Option { (anim_id < 4).then(|| 153 + anim_id as u16) } /// `SMSG_GAMEOBJECT_DESPAWN_ANIM` arrived: a component inserted at packet time, so it is on the /// entity before the same tick's `SMSG_DESTROY_OBJECT` frees it. It asserts nothing alone: /// `SendObjectDeSpawnAnim` also fires for totems, DynamicObjects and two boss scripts' living /// objects, so it only arms a [`GoAnim`] and defers a destroy that arrives. #[derive(Component)] pub(crate) struct DespawnAnimAnnounced; /// The reference's pending-destroy mark (`[GO+0xe4]` bit `0x10`), set by the destroy `0x464920` on /// a pinned object. Arming a non-resting substate pins (`0x5f3b27 call 0x4683e0`, refcount /// `[obj+0xe8]`), slot 14 unpins at the window end (`0x468410`), and the deferred destroy then runs /// (`0x46844a call 0x464920`). benilla pins only [`ANIM_DESPAWN`]; an object destroyed /// mid-transition still goes at once (not built). /// /// The reference keeps a pinned object addressable by guid while it plays; benilla drops the guid /// at destroy time, so a respawn reusing it cannot refresh a condemned object. Nothing observable /// differs: the object still draws, and the server no longer answers for that guid. #[derive(Component)] pub(crate) struct PendingDestroy; /// Caller 5, the despawn-anim opcode: one arm per announcement, as `0x5f8c50(GO, 6)` is one per /// packet. `Changed`, not `Added`: a re-inserted component is not added, and a boss script can /// announce the same living object twice. fn arm_despawn_anim(mut gos: Query<&mut GoAnim, Changed>) { for mut go in &mut gos { go.one_shot = Some(ANIM_DESPAWN); } } /// Releases a [`PendingDestroy`] object once it is not playing its despawn animation (`0x468410` /// reaching zero), after [`drive_go_anim`] so the arming frame holds. Release is a fade, not a /// pop: `0x464920` reaches OnDeactivate `0x6145e0`, which hands the model to the `SWModelFadeout` /// scheduler `0x672df0`; here that is [`crate::net::tear_down`]. fn release_despawn_pin( mut commands: Commands, pinned: Query<(Entity, Option<&GoAnim>), With>, ) { 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::() .queue_silenced(crate::net::tear_down); } } /// Caller 3, the loot release: the client drops the state to READY as it sends /// `CMSG_LOOT_RELEASE`, with no server round-trip, on any close of the loot source. fn close_go_lid( loot: Res, index: Res, mut gos: Query<&mut GoAnim>, mut last_source: Local>, ) { 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 substate advance. The completion callback registered at model attach (`0x5f7d43` → /// `[M2+0x70]`) fires once at the arm's window end, span × replay count, whatever the loop bit /// (`0x719370`; `0x719503` tests it after), and slot 14 (`0x5f4120`) dispatches on the substate: /// a motion advances to its rest pose (2 Open → 3 Opened, 4 Close → 1 Closed, 5 Destroy → /// 6 Destroyed, 7 Rebuild → 1 Closed); Spawn (0) and Custom0..3 (8..11) re-run the machine at the /// current state (`0x5f4190`); a rest pose (1/3/6) re-arms itself with a fresh roll (`0x5f4167`, /// calling `0x5f3930` past slot 34's change guard), every window for ever. /// /// Clearing `shown` makes [`drive_go_anim`], next in the chain, re-resolve the state that frame. /// Reads never deref-mut, so a quiet object stays out of the `Changed` stream. fn retire_transient_anim( mut gos: Query<(Entity, &mut GoAnim, &AnimationPlayer)>, mut dispatch: MessageWriter, ) { for (entity, mut go, player) in &mut gos { 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; // Forget the shown pose: the state arm re-resolves it as a silent rest snap. go.shown = None; // Re-enters `0x5f3cb0` with `old == new`: a GnomeMachine's Custom0 hum ends here. if completion_redispatches(t.id) { dispatch.write(GoStateDispatch(entity)); } } continue; } // A rest pose re-arms the same way; `rest_window` clears 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 arm's trace (`WOW_MOVE_TRACE_TAGS=goa`): requested and remapped id, roll, and the sequence /// slot landed on, which picks the material lanes (keyed by `seq_index`) and shows whether an arm /// re-rolls every window or stalls (`0x5f39fa`'s skip). 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, ), ); } /// Plays the sequence for a change of [`GoAnim::state`], first sight silent, as /// [`crate::sound::gameobject`] does for the audio. fn drive_go_anim( mut gos: Query< ( Entity, &mut GoAnim, &mut AnimationPlayer, &mut AnimationTransitions, &ModelAnimations, ), Changed, >, mut dispatch: MessageWriter, // The client's single `_rand` stream (`0x7400e5`); op4's variation roll draws from it. mut rng: ResMut, ) { for (entity, mut go, mut player, mut tr, anims) in &mut gos { // ── The state arm ────────────────────────────────────────────────────────────────────── if let Some(state) = go.state { if go.shown != Some(state) { let prev = go.shown; go.shown = Some(state); // Dispatch (`0x5f3cb0`) before anything below can refuse the arm; a re-resolve // from the retire leaves `dispatched` alone and announces nothing. if go.dispatched != Some(state) { go.dispatched = Some(state); dispatch.write(GoStateDispatch(entity)); } // A fresh substate replaces the live transient (the reference keeps one, // `[handler+0x10]`), so an old motion's completion never fires over it. go.transient = None; go.rest_window = None; if let Some(play) = resolve(prev, state) { // Remap an unowned id, so a lidless model holds a real pose, not bind. let (want, frozen) = remap_missing(anims, play.anim_id()); // The already-playing skip (`0x5f39fa: cmp esi,eax; je 0x5f3b32`) holds only // on the remap legs: an owned id (`0x5f396c jne`) and the collapse to Stand // (`0x5f3a54 jmp 0x5f3a0b`) jump past it. So the remapped fishing bobber // settles, and a lava trap that owns its id re-rolls every window. let skip = want != 0 && !anims.owns(play.anim_id()) && go.armed_id == Some(want); if skip { continue; } // The arm rolls a variation (`0x5f3aee: push -1`); the loader seed takes 0 // (`0x710189`). 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; 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 { // The reference arms a stand-in motion at rate 0, holding frame 0. active.seek_to(0.0); active.set_speed(0.0); active.set_repeat(RepeatAnimation::Never); } else { // Explicit: `AnimationTransitions::play` keeps a node's speed, so one // a frozen leg parked at rate 0 would stay stuck. active.set_speed(1.0); // One window, rest poses included: the period is `0x7126d8`'s, with // `R = 1` from a `(0,0)` replay pair (`0x7126c3`). Which per-frame // advance list a GameObject's model rides is untraced. 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 is playable after the remap, and the loader seed's arm holds. } } } // ── The one-shot Custom channel (`0x5f8c50`) ──────────────────────────────────────── // After the state arm, so the bobber's same-frame flip and splash end splash-on-top. Slot // 15 needs the model to own the id (no remap); one window, so one `$GC0` splash. // `is_some` first: `take()` through the `Mut` would mark the component changed every frame. if go.one_shot.is_some() { let id = go.one_shot.take().expect("checked is_some"); if anims.owns(id) { // Slot 15 arms through the same `0x5f3930`, so it rolls a variation too. 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 }); } } } } } /// Gates a door or button's collider on its wire state, the server's passability: solid when /// READY, disabled when open, since the static hull cannot swing. Reconciles on a state edge or on /// the collider's arrival, which `entities::attach` inserts only once the M2 loads, frames after /// the descriptor. fn drive_go_collision( mut commands: Commands, gos: Query<(&ObjectStore, Has), With>, arrived: Query>, mut edges: MessageReader, ) { 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::(); } else if !solid && !disabled { commands.entity(entity).insert(ColliderDisabled); } } } /// A ghost walks through doors: the mover's mask gains bit `0x8000` for a player body with /// `PLAYER_FLAGS` ghost bit `0x10` (`0x631658`), and `CGGameObject_C`'s candidacy virtual /// (`0x5f85f0`, vtable `+0x50`) tests it at `0x5f85f6` to refuse type DOOR. Nothing else about a /// ghost's collision differs; the flag is the mover's, so a possessed creature never sets it. fn publish_ghost_door_exclusions( mut exclusions: ResMut, mover: Query<&ObjectStore, With>, doors: Query<(Entity, &ObjectStore, &crate::net::NetEntity), With>, ) { let ghost = mover.single().is_ok_and(|s| s.0.player_is_ghost()); if !ghost { // Clear only when non-empty, so a living player's frames never trip change detection. 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), ); } /// Whether a ghost's mover trace drops this collider: a GameObject of type DOOR. The kind test /// matters: an absent `GAMEOBJECT_TYPE_ID` reads `0`, DOOR, and a unit has no GameObject block. fn ghost_passable(kind: benilla_protocol::EntityKind, type_id: i32) -> bool { kind == benilla_protocol::EntityKind::GameObject && type_id == GO_TYPE_DOOR } /// What the 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, &'static GlobalTransform, Option<&'static benilla_world::rig_anim::RigPose>, ); /// Fires the event keys an animated object's clip crossed this frame. The reference registers an /// event callback per family-A object (`0x5f7d1f` → vtable `+0x30` → `0x5f3e20`), the [`GoAnim`] /// population; [`crate::sound::gameobject`] routes `$GO0..5`/`$GC0..3` to /// `GameObjectDisplayInfo.Sound[0..9]`. The bobber's Custom0 `$GC0` splash plays beside the /// server's object-sound packet, so on vmangos it sounds twice, as in the reference. fn fire_go_anim_events( gos: Query>, globals: Query<&GlobalTransform>, mut last: Local, mut out: MessageWriter, ) { for (entity, anims, player, parked, world, pose) in &gos { // A parked object is not scanned (`MORE_AUDIBLE` is creature-only); its memory drops so // a wake re-arms rather than scanning the 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); } } } } /// After the net drain, the state callers write [`GoAnim::state`], the animation and collision /// consumers act on it, and the event scanner reads what the drive settled. pub(crate) fn plugin(app: &mut App) { app.add_message::() .add_message::() .add_message::() .add_systems( Update, ( ( sync_wire_go_state, open_go_lid, // After the selection writers, whose teardown can close the loot. close_go_lid.after(crate::target::TargetUpdate), queue_custom_anim, arm_despawn_anim, // Clears `shown` from last frame's finished flags before the drive, so the // re-arm lands one frame after the window end, as in the reference. retire_transient_anim, ), (drive_go_anim, drive_go_collision), // The pin release reads what the drive just armed. release_despawn_pin, fire_go_anim_events, ) .chain() .in_set(WorldStage::Present), ); // In `Input`, ahead of the mover: `WorldCollision::cast_mover` reads the set inside the // player controller, and `Present` would hand it the previous frame's doors. app.add_systems( Update, publish_ghost_door_exclusions.in_set(WorldStage::Input), ); } #[cfg(test)] mod tests { use super::*; use benilla_world::model_fade::DespawnFade; /// An absent `GAMEOBJECT_TYPE_ID` reads `0`, DOOR, on every unit, so the kind must be checked. #[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" ); } /// vmangos omits the zero field, so `None` is ACTIVE, open, for every door that spawns open. #[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() { 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: 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)))); assert!(matches!(resolve(Some(2), 0), Some(Play::Rest(0x95)))); // Destroy swings only from closed (`0x5f3cb0`'s condition is `OLD == 1`). assert!(matches!(resolve(Some(1), 2), Some(Play::Motion(0x96)))); assert!(matches!(resolve(Some(0), 2), Some(Play::Rest(0x97)))); assert!(matches!(resolve(Some(2), 1), Some(Play::Motion(0x98)))); } /// The custom-anim handler (`0x5f8930`); the bobber's bite, index 0 → 153, is 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 next frame: with `TimePlugin` live a stalled frame would run /// a whole clip out in one step, so the test writes the clock. #[derive(Resource, Default)] struct NextStep(Option); fn step_clock(mut time: ResMut