//! Object-stream arm bodies for [`super::apply_net_updates`]'s dispatch match — the streamed //! world's lifecycle (create / values-merge / destroy / stream-out), the relayed player movement, //! and the creature path packet. Each `pub(super)` fn here is exactly one arm's body; the match at //! the call site stays the dispatcher, one call per arm. use std::collections::hash_map::Entry; use std::collections::HashMap; use benilla_assets::coords::bevy_to_wow; use benilla_protocol::{ guid, EntityKind, MonsterMoveFacing, MoveSpeeds, ObjectFields, SpeedKind, SplineMode, }; use bevy::prelude::*; use crate::go_templates::GameObjectTemplates; use crate::items::Items; use crate::names::NameCache; use benilla_world::model_fade::DespawnFade; use benilla_world::vis_chain::VisChainOnly; use super::super::motion::{ create_spline, gameobject_rotation, monster_move_spline, pose_transform, resolve_facing, trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, ROOT_APPLY_WIPE, }; use super::super::{ Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds, }; /// A GameObject plays its one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision /// 1086) — bridged to the GO animation machine ([`crate::go_anim`]), which owns the reject /// (`anim_id >= 4`), the id mapping (153..156) and the model-ownership gate. The load-bearing /// sender: the fishing bobber's bite splash (`anim_id 0`). pub(super) fn gameobject_custom_anim( guid: u64, anim_id: u32, plays: &mut MessageWriter, ) { debug!("net: gameobject {guid:#x} custom anim {anim_id}"); plays.write(crate::go_anim::GoCustomAnim { go_guid: guid, anim_id, }); } /// An object plays its one-shot **Despawn** animation (`SMSG_GAMEOBJECT_DESPAWN_ANIM`, decision /// 1404) — the other half of wow-re `gameobject-anim-arm.md` §2c's channel, AnimationData id 157. /// /// This marks the entity **immediately**, with a component rather than a message, because vmangos /// sends `SMSG_DESTROY_OBJECT` for the same object in the same server tick: the mark has to be on /// the entity before [`object_destroyed`] runs, or the destroy frees it before anything can play. /// Commands apply in the order they were queued, so an insert queued here is visible to the /// closure the destroy queues below. The arm itself, and the model-ownership gate, are /// [`crate::go_anim`]'s. pub(super) fn gameobject_despawn_anim(guid: u64, commands: &mut Commands, index: &GuidIndex) { debug!("net: gameobject {guid:#x} despawn anim"); if let Some(&e) = index.0.get(&guid) { commands .entity(e) .insert(crate::go_anim::DespawnAnimAnnounced); } } /// An object entered range / was created (`SMSG_UPDATE_OBJECT` create block): spawn or refresh the /// entity, warm the ask-once caches, and seed its descriptor store via the per-drain `pending` map. #[allow(clippy::too_many_arguments)] pub(super) fn object_create( guid: u64, kind: EntityKind, display_id: Option, position: [f32; 3], orientation: f32, scale: f32, speeds: Option, mover: Option, transport_progress: Option, transport: Option, spline: Option, fields: ObjectFields, commands: &mut Commands, index: &mut GuidIndex, transforms: &mut Query<&mut Transform>, stores: &mut Query<&mut ObjectStore>, pending: &mut HashMap, speed_stage: &mut SpeedStage, names: &mut NameCache, go_templates: &mut GameObjectTemplates, net_commands: &NetCommands, ) { let net = NetEntity { kind, display_id, scale, }; // A transport's cycle anchor (decision 0438): the create block's `UPDATE_FLAG_TRANSPORT` // u32 + the local instant it landed. Re-creates re-anchor (the server re-sends the create at // map transitions and mid-course update frames precisely so clients can correct drift). The // transport tick owns it from here (`crate::transport`). Both ticking GO types (the client's // RF-0051 pair): 15 (boats — vmangos sends its path-progress clock) and 11 (elevators/lifts // — the same flag, `GameObject.cpp:246`, a `time-since-create % period` clock). let go_type = (kind == EntityKind::GameObject).then(|| fields.gameobject_type_id()); let transport_anchor = matches!(go_type, Some(11 | 15)) .then_some(transport_progress) .flatten() .map(|progress_ms| crate::transport::TransportAnchor { progress_ms, at: std::time::Instant::now(), }); // The spawn's `GAMEOBJECT_ROTATION` quaternion — read once, wanted twice: it is this object's // placement (below) and, on a type-11 lift, the basis its keyframe offsets rotate through. let go_quat = (kind == EntityKind::GameObject) .then(|| fields.gameobject_rotation()) .flatten(); // A type-11 lift's arm seed (decision 0438 phase 3's second consumer): the keyframe path is // keyed by the template **entry**, the offsets rotate through the spawn's `GAMEOBJECT_ROTATION` // quat, and the base is the stationary spot the movement block carried — all already in this // create block, so the lift arm needs no template round-trip. Anchor-gated: a type-11 whose // create carried no progress u32 has no clock to tick and stays frozen. let elevator_seed = (matches!(go_type, Some(11)) && transport_anchor.is_some()) .then(|| fields.object_entry()) .flatten() .map(|entry| crate::transport::ElevatorSeed { entry, base_pos: position, yaw: orientation, quat: go_quat, }); // Where this object is *pointed*. A mover carries one yaw; a GameObject is placed by its // `GAMEOBJECT_ROTATION` quaternion, which is the reference's own placement input and is a // strictly wider answer than the facing (decision 1459, `motion::gameobject_rotation`). // // **A mover's create pose is not a bare yaw.** The reference's create-block apply seeds both // halves of the body-pitch law from this very block — the flags word through `0x618c30`'s // `0x75a07dff` merge, the pitch through the pose commit `0x7c6420`'s unconditional // `fst [ecx+0x20]` — and it does so *before* `0x613e10` builds the model or registers the // per-frame render callback that reads them, so the tilt is in force on the unit's **first // drawn frame** (byte-VERIFIED; wow-5875-re // `system/collision/scratch/create-block-swim-pitch.md`). A player who swims into view // nose-down renders nose-down, not level: [`crate::creature_anim::swim_body_rotation`], the // same one law our own avatar and the relay extrapolator call. Without the block's flags + // pitch — which we parsed and threw away until now — every observed swimmer entered the world // flat, and an *idle* floater (who sends no packets at all) stayed flat for as long as they // floated. let placement = match kind { EntityKind::GameObject => gameobject_rotation(go_quat, orientation), _ => mover.map_or_else( || wire_yaw(orientation), |m| crate::creature_anim::swim_body_rotation(orientation, m.flags, m.pitch), ), }; // A unit/player created already ON a transport (deck NPCs stream in this way): its LIVING // block's rider tail is its local pose — `compose_riders` re-anchors it through the boat's // live matrix each frame (decision 0438 phase 2). The block's world `position` is the // spawn-time compose, kept as the pre-arm fallback pose. let rider = matches!(kind, EntityKind::Unit | EntityKind::Player) .then_some(transport) .flatten() .map(|t| crate::transport::TransportRider { transport_guid: t.guid, local_pos: [t.pos.x, t.pos.y, t.pos.z], local_orientation: t.orientation, }); // Warm the name cache the moment a unit streams in — the real client's cache is // demand-driven too, but persisted (`CreatureCache.wdb`, wow-re dbcache node), so in // practice it always answers instantly; asking at first *sight* rather than first // *target* gives our session-lifetime cache the same instant feel (the ask-once // discipline makes re-creates and shared templates free). if matches!(kind, EntityKind::Unit | EntityKind::Player) { let _ = names.resolve(guid, net_commands); } // Warm the lock cache the moment a GameObject streams in (decision 0239), so a // right-click resolves use-vs-cast instantly — the same ask-once, ask-at-sight // discipline as the name cache. The lockId isn't in the create packet; only the query // carries it. if matches!(kind, EntityKind::GameObject) { go_templates.request(guid, net_commands); // Where and how big, the moment it streams in — the readout that answers "is this prop in // the wrong place, or the wrong size, or just drawn wrong" without a guess (decision 0637: // the duel flag read as huge and mislocated, and nothing in the client could say which). // `RUST_LOG=benilla_app::net::apply::objects=debug`. debug!( "gameobject spawn: entry {:?} display {display_id:?} type {go_type:?} \ pos [{:.2}, {:.2}, {:.2}] scale {scale}", fields.object_entry(), position[0], position[1], position[2], ); } // The walk this unit is ALREADY on (decision 0708): its create block's live spline, joined at the // server's own progress along it. Traced before it is interpreted, so the `WOW_CREATE_SPLINE=off` // leg of the A/B still records what the wire offered. trace_create_spline(guid, spline.as_ref()); let walk = spline.and_then(create_spline); if let Some(&e) = index.0.get(&guid) { // Re-create of a tracked guid: refresh identity + pose. A create is a fresh server snapshot, so // any in-flight extrapolation is stale too — clear it. commands.entity(e).insert(net).remove::(); // Speeds go through the drain's stage, never straight onto the entity — a // `SMSG_FORCE_*_SPEED_CHANGE` riding the same tick as this create has to be able to land // on top of it (decision 1478, B213). if let Some(s) = speeds { speed_stage.seed(guid, s); } if let Some(anchor) = transport_anchor { commands.entity(e).insert(anchor); } if let Some(seed) = elevator_seed { commands.entity(e).insert(seed); } match rider { Some(r) => { commands.entity(e).insert(r); } None => { commands .entity(e) .remove::(); } } // The snapshot's own path outranks whatever we were riding; a create *without* one says the // unit is standing still, so a stale path goes. match walk { Some(s) => { commands.entity(e).insert(s); } None => { commands.entity(e).remove::(); } } write_pose(commands, transforms, e, position, placement); // Overlay the fresh snapshot's descriptor fields onto the existing store. merge_fields(stores, pending, e, guid, fields); } else { // A transport spawns hidden: its create pose is the *stationary* spawn point (or worse, // the origin), not where the boat is in its cycle — the transport tick unhides it at the // first sampled pose (decision 0438). let visibility = if transport_anchor.is_some() { Visibility::Hidden } else { Visibility::default() }; let mut entity = commands.spawn(( Guid(guid), net, pose_transform(position, placement), visibility, )); // Chain-only visibility (benilla_world::vis_chain): the net root renders nothing — // its model parts and joints are the children, and the cull authorities flip this // root's `Visibility` through `Mut` writes, which don't re-add the sweep row. entity.vis_chain_only(); if let Some(s) = speeds { speed_stage.seed(guid, s); // staged, like the re-create arm above (1478) } if let Some(anchor) = transport_anchor { entity.insert(anchor); } if let Some(seed) = elevator_seed { entity.insert(seed); } if let Some(r) = rider { entity.insert(r); } if let Some(s) = walk { entity.insert(s); } index.0.insert(guid, entity.id()); // Seed the store via the pending flush — the entity isn't spawned until the sync point. pending.insert(guid, fields); } } /// An item or container entered our view (`SMSG_UPDATE_OBJECT` descriptor-only create) — no scene /// entity; the item store owns it. pub(super) fn item_create(guid: u64, container: bool, fields: ObjectFields, items: &mut Items) { debug!("net: item create {guid:#x} (container: {container})"); items.insert_object(guid, fields); } /// An existing object moved to a new authoritative pose (an `SMSG_UPDATE_OBJECT` movement block) — /// a one-off correction/relocation that supersedes any active path. pub(super) fn object_move( guid: u64, position: [f32; 3], orientation: f32, commands: &mut Commands, index: &GuidIndex, transforms: &mut Query<&mut Transform>, ) { if let Some(&e) = index.0.get(&guid) { commands.entity(e).remove::(); // A movement block carries a yaw and nothing else — the only GameObjects that get one are // transports, whose pose the transport tick owns from the next frame (decision 0438). write_pose(commands, transforms, e, position, wire_yaw(orientation)); } } /// A relayed player movement packet (`MSG_MOVE_*`): the mover's authoritative pose + live move /// flags. The reference SCHEDULES a remote's apply (decision 0601, wow-re /// `remote-apply-timing.md`): the mover's own replay chain gives the packet a client fire-time /// (decision 0615, [`crate::net::motion::RelayMove`] → `RelayChain::schedule`); an already-due move /// applies now, a future one queues on the unit and fires in `drain_pending_moves` — the dead-reckon /// covering the mover's own timeline in between, which is what kills the arrival-jitter snap. /// `WOW_REMOTE_SNAP=1` restores raw apply-at-arrival for an A/B. #[allow(clippy::too_many_arguments)] // the wire fields + the apply context, one per concern pub(super) fn unit_move( guid: u64, mv: crate::net::motion::RelayMove, now_ms: f64, commands: &mut Commands, index: &GuidIndex, self_guid: &SelfGuid, remote_motion: &mut Query<&mut RemoteMotion>, transforms: &mut Query<&mut Transform>, landings: &mut MessageWriter, self_moves: &mut MessageWriter, ) { use crate::net::motion::{apply_move, arrival_snap, trace_relay, PendingMove, RelayOutcome}; // Addressed to US: the server writing our own pose, never an echo of ours (every one is // `SetAsServerSide`, `ctime = 0`). The reference APPLIES it — there is no mover-guid gate // anywhere on its inbound move path, and the local player resolves through the same object // lookup as anyone else (decision 0725; wow-re `self-addressed-move.md`). What is ours is only // *where it goes*: our avatar's motion source is the controller, not [`RemoteMotion`], so the // pose crosses to `player::wire_in` instead of down this lane. if self_guid.0 == Some(guid) { trace_relay( guid, &mv, &Default::default(), now_ms, 0, RelayOutcome::SelfMover, ); self_moves.write(crate::net::SelfMoveMessage { position: mv.position, orientation: mv.orientation, flags: mv.flags, pitch: mv.pitch, fall_time: mv.fall_time, jump: mv.jump, }); return; } let Some(&e) = index.0.get(&guid) else { // No entity for this guid — the packet changes nothing. Traced rather than dropped in // silence: a mover that keeps running while these pile up is a streaming bug, not a replay // one, and the two look identical from the outside (decision 0619). trace_relay( guid, &mv, &Default::default(), now_ms, 0, RelayOutcome::Unknown, ); return; }; { // The server is authoritative now, not any creature path — drop a stale spline. commands.entity(e).remove::(); if let Ok(mut rm) = remote_motion.get_mut(e) { // The chain reads the mover's state as it stands BEFORE this move applies — the // reference times the packet off the live `[esi+0x40]`/`[esi+0x150]`. let (flags, queue_empty) = (rm.flags, rm.pending.is_empty()); let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, flags, queue_empty); let at_arrival = arrival_snap() || rm.fires_at_arrival(fire_ms, now_ms); trace_relay( guid, &mv, &rm.relay, now_ms, rm.pending.len(), if at_arrival { RelayOutcome::Now } else { RelayOutcome::Queued }, ); if at_arrival { apply_move(e, &mv, &mut rm, now_ms, commands, landings); } else { // Fire-times are monotone per unit by construction (a chained fire never lands // before its predecessor — decision 0615), so the queue stays ordered. rm.pending.push_back(PendingMove { fire_ms, mv }); } } else { // First move for this unit: apply immediately (the chain seeds on it — its own fire // is arrival — and paces from the next packet on) and place it this frame; the // component insert is deferred, so the extrapolator won't see it until next frame. let mut rm = RemoteMotion { wow_pos: mv.position, orientation: mv.orientation, flags: 0, pitch: 0.0, speed: 0.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], fall_start_z: None, pending: std::collections::VecDeque::new(), relay: Default::default(), last_apply_ms: now_ms, last_apply_pos: mv.position, }; let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, 0, true); debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival"); trace_relay(guid, &mv, &rm.relay, now_ms, 0, RelayOutcome::Seed); apply_move(e, &mv, &mut rm, now_ms, commands, landings); write_pose( commands, transforms, e, mv.position, wire_yaw(mv.orientation), ); commands.entity(e).insert(rm); } } } /// A descriptor delta (`SMSG_UPDATE_OBJECT` values block): merge into the object's store — a scene /// object's in place (or into the pending seed), an item's into the item store. An unknown guid — /// a `Values` with no create seen — is dropped, as before. pub(super) fn object_values( guid: u64, fields: ObjectFields, index: &GuidIndex, stores: &mut Query<&mut ObjectStore>, pending: &mut HashMap, items: &mut Items, ) { if let Some(&e) = index.0.get(&guid) { merge_fields(stores, pending, e, guid, fields); } else if guid::is_item(guid) { items.merge_object(guid, fields); } } /// The object ceased to exist (`SMSG_DESTROY_OBJECT` — corpse decay ahead of respawn, a despawn). pub(super) fn object_destroyed( guid: u64, commands: &mut Commands, index: &mut GuidIndex, items: &mut Items, ) { // The reference frees it on the spot, an **instant pop** with no fade-out // (byte-verified, wow-re selection-death-clear RE — the only lifecycle fade is the // appear-fade on create). A respawn then streams in as a fresh entity. If it was the // target, the ring's gone-entity branch clears the selection next frame — the // reference's teardown does the same (and sends the same `CMSG_SET_SELECTION 0`). // // …*unless the object is pinned* — `0x464920` on a still-pinned object only sets the // pending-destroy bit and returns, and the real free waits for the last pin to drop (wow-re // `go-display-sound-events.md` §6d). The one pin benilla takes is the despawn animation // announced a moment earlier by `SMSG_GAMEOBJECT_DESPAWN_ANIM`, which is the whole of how an // object gets to play its own despawn after the server says it is gone (decision 1404). // The guid leaves the index either way: to the server it no longer exists. if let Some(e) = index.0.remove(&guid) { commands.queue(move |world: &mut bevy::ecs::world::World| { if world .get::(e) .is_some() { if let Ok(mut ent) = world.get_entity_mut(e) { ent.insert(crate::go_anim::PendingDestroy); } } else if let Ok(ent) = world.get_entity_mut(e) { ent.despawn(); } }); } // An item destroy (consumed, sold) never had a scene entity — clear the item store. items.remove_object(guid); } /// Stream-out (out-of-range, the update-object `OutOfRange` block): the unit still exists, we just /// left its range. pub(super) fn objects_removed(guids: Vec, commands: &mut Commands, index: &mut GuidIndex) { // Don't pop the entity, fade it out, then despawn (`apply_despawn_fade` drives the ramp; an // entity with no fadeable geometry pops straight out there). Director-verified look: // on the reference, distant mobs fade out, never blink out (destroy, above, is the // byte-verified instant pop; 0067's open question, settled by the director's eyes — // which reference mechanism produces the fade is unpinned and doesn't matter here). for g in guids { if let Some(e) = index.0.remove(&g) { commands.entity(e).insert(DespawnFade::default()); } } } /// A creature path packet (`SMSG_MONSTER_MOVE`, or its deck twin `SMSG_MONSTER_MOVE_TRANSPORT`): /// settle the unit's transport membership, apply the dictated facing snap, then attach or clear /// the travel spline. /// /// **`transport` changes the frame of everything else in the packet** (decision 1936). When it is /// `Some`, `start`, every `path` point and an `Angle` facing are offsets in that transport's frame, /// the unit becomes (or stays) a [`TransportRider`] on it, and the spline is sampled into the /// rider's local pose for `transport::compose_riders` to carry out to the world. When it is `None` /// on a unit we had riding, the unit has *left* the deck — vmangos drops it from the transport on /// exactly this edge (`MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:156-159`). #[allow(clippy::too_many_arguments)] pub(super) fn monster_move( guid: u64, transport: Option, start: [f32; 3], spline_id: u32, path: Vec<[f32; 3]>, facing: MonsterMoveFacing, stop: bool, duration_ms: u32, flying: bool, run_mode: bool, rooted: bool, commands: &mut Commands, index: &GuidIndex, transforms: &mut Query<&mut Transform>, riders: &mut Query<&mut crate::transport::TransportRider>, ) { if let Some(&e) = index.0.get(&guid) { // The DESYNC readout (decision 0708): how far this packet is about to teleport the unit — the // gap between where we have been drawing it and where the server says the path begins. A // correctly-followed creature reads ~0; a frozen one reads the whole walk it slept through. trace_move_snap( guid, // Both sides of the readout have to be in ONE frame. A deck packet's `start` is a // transport-local offset, so it is compared against the rider's own local pose, not // against the composed world position (which would read as the whole boat's travel). match transport { Some(_) => riders.get(e).ok().map(|r| r.local_pos), None => transforms.get(e).ok().map(|t| bevy_to_wow(t.translation)), }, start, stop, duration_ms, ); // **Transport membership, settled before anything reads a pose.** A packet naming a // transport attaches the unit to it (and re-seeds its deck-local pose from the packet's // own `start`, so the stop form places a body too); a packet naming none detaches a unit // we had riding. The facing that goes with it is deck-local for an `Angle` — vmangos runs // `CalculatePassengerOffset(…, &args.facing.angle)` on it (`MoveSplineInit.cpp:88`) — but // a `Spot`/`Target` facing is **not** converted server-side, so those resolve to a world // bearing and are rebased here by the deck's own yaw. // // **Above the root gate deliberately.** The gate below refuses the *path*, which is what // wow-re establishes; membership is not a path. A rooted body on a deck that was left // unattached would be abandoned in the sea as the boat sails out from under it, which is // a worse failure than carrying a pinned body along with the deck it is standing on. let deck_yaw = |t: u64| { index .0 .get(&t) .and_then(|&te| transforms.get(te).ok()) .map(|tf| tf.rotation.to_euler(EulerRot::YXZ).0) }; if let Some(t) = transport { let local_facing = match facing { MonsterMoveFacing::None => None, MonsterMoveFacing::Angle(a) => Some(a), other => { let target_pos = |g: u64| { index .0 .get(&g) .and_then(|&te| transforms.get(te).ok()) .map(|tf| bevy_to_wow(tf.translation)) }; // The unit's own WORLD position anchors the bearing (the wire's `start` is // deck-local and would put the unit near the map origin). let world_pos = transforms.get(e).ok().map(|tf| bevy_to_wow(tf.translation)); world_pos .and_then(|p| resolve_facing(other, p, target_pos)) .zip(deck_yaw(t)) .map(|(world, yaw)| world - yaw) } }; match riders.get_mut(e) { Ok(mut rider) => { rider.transport_guid = t; rider.local_pos = start; if let Some(o) = local_facing { rider.local_orientation = o; } } Err(_) => { commands.entity(e).insert(crate::transport::TransportRider { transport_guid: t, local_pos: start, local_orientation: local_facing.unwrap_or_default(), }); } } } else if riders.get(e).is_ok() { commands .entity(e) .remove::(); } // Apply the dictated final facing (moveType 2/3/4) as a **snap** — faithful to the // client, which stores it straight into the unit's **raw** movement facing (`0x7c6f30`). // This is the *packet*-driven re-face — a scripted/emote/aggro `SetFacingTo` the // server actually sends. (The client-local re-faces — squaring up on a target, and // turning to face you while an interaction window is open — carry no packet at all and // are `motion::drive_display_facing`'s, decision 1467.) Because it is the raw facing // that moved, the display smoother's state goes with it: the unit re-seeds from this // pose instead of swinging back to the heading it was snapped off. When a real path // follows, `sample_splines` overwrites the rotation with the travel direction each // frame (faithful — the client's spline-follow snaps the mesh yaw to the path tangent; // wow-re body-facing §4). The receipt snap thus only sticks for a path-less move (a // `Stop`/in-place re-face); a moving unit ends on its last tangent. // The world-space facing snap — for a deck packet the rider's `local_orientation` above is // the one that counts, and `compose_riders` writes the rotation from it. if transport.is_none() && !matches!(facing, MonsterMoveFacing::None) { let target_pos = |g: u64| { index .0 .get(&g) .and_then(|&te| transforms.get(te).ok()) .map(|t| bevy_to_wow(t.translation)) }; if let Some(orientation) = resolve_facing(facing, start, target_pos) { if let Ok(mut t) = transforms.get_mut(e) { t.rotation = Quat::from_rotation_y(orientation); commands .entity(e) .remove::(); } } } // A spline move **un-nocks and drops the weapon-visual hold**, unconditionally: // `0x6018f0` (this packet's handler, via `0x603f00` registered on opcodes 0xDD/0x2AE) has // exactly one `ret`, and its shared tail runs `0x6020e8 call 0x60d040` (clear `0x400`) → // `0x6020ef call 0x60f530` (un-nock) → `RecomputeBaseAnim(-1)` on every path through it // (wow-re `shooter-stop-law.md` §J1.2/§J1.3, byte-verified over the whole extent). So an // archer NPC yanked along a path, or a player charged/knocked back, loses the arrow — // the ranged sheath is untouched, exactly like the locomotion un-nock. commands.entity(e).remove::<( crate::creature_anim::NockLatch, crate::creature_anim::RangedHold, )>(); // **A rooted unit cannot be splined** (decision 1780). `0x6187a0` — the *server // position/spline apply*, and the sole path from this packet's parse chain into // `CMovement`'s spline installer `0x7c6a50` — opens with `0x6187c2 test ah,0x10` and // returns on ROOT (wow-re `moveflag-family.md` §5.3, and §5.4's verdict in as many words: // *"cannot translate, cannot jump, cannot be splined"*). // // **Scoped to the path deliberately.** What is byte-verified is that the *position/spline* // apply is refused; whether the rest of `0x6018f0` still runs for a rooted unit is not // recorded, and the one part of it we do know — the un-nock tail — is verified to run on // **every** path through the handler (`shooter-stop-law.md` §J1.2/§J1.3), so it stays above // this gate. The facing snap is left running for the same reason: refusing more than the // note establishes would be inventing a behaviour, and refusing the path is what keeps the // body still. // // On vmangos this is a race rather than the common case — `Unit::SetRooted(true)` calls // `StopMoving()` before the root goes out, so an already-walking creature is normally // stopped by its own stop-spline. The gate is what keeps a path the server had already // queued from moving a body it has since pinned. if rooted { return; } // The spline is authoritative now, not the relay stream — the mirror of `unit_move`'s // "drop a stale spline". A splined PLAYER (charge, taxi) otherwise keeps a stale // `RemoteMotion` whose old flags outrank the spline in the anim selector's `unify` // precedence (Stand frozen mid-flight); the relay re-seeds it on its next packet. Below the // root gate because it is the *install's* own consequence: with no path taken there is no // new authority to hand the pose to. commands.entity(e).remove::(); match monster_move_spline( path, spline_id, stop, duration_ms, flying, run_mode, transport, ) { // A moving path: sample_splines drives the transform along every waypoint. Some(spline) => { commands.entity(e).insert(spline).remove::(); } // Stop/clear: freeze where the last sample left it (≈ the endpoint) — and keep the id, // which the server is waiting to hear back for a player-driven unit (decision 1281). None => { commands .entity(e) .remove::() .insert(SplineStopped(spline_id)); } } } } /// **Per-drain staging for [`UnitMoveModes`]** — the same shape (and the same reason) as /// [`merge_fields`]' `pending`: a component this drain inserted through `Commands` is not queryable /// until the sync point, so a grant and the packet it changes the meaning of can arrive in one drain /// and the second must see the first. Seeded lazily from the live component; never read after the /// drain. pub(super) type StagedModes = HashMap; /// This unit's granted modes as of *now within the drain* — the staged word if it was granted this /// drain, else the live component, else none. The reference's word lives as long as the `CGUnit`, so /// "no component" and "all bits clear" are the same answer and both are [`UnitMoveModes::default`]. pub(super) fn modes_of( guid: u64, index: &GuidIndex, modes: &Query<&mut UnitMoveModes>, staged: &StagedModes, ) -> UnitMoveModes { staged .get(&guid) .copied() .or_else(|| index.0.get(&guid).and_then(|&e| modes.get(e).ok().copied())) .unwrap_or_default() } /// **A movement mode granted on a unit we do not control** — the `SMSG_SPLINE_MOVE_*` family /// (decision 1780). No ack, and `guid` is whatever unit the server named: normally a creature, which /// is exactly the body the ack'd `SMSG_FORCE_*` family structurally cannot address. /// /// An unresolvable guid is **dropped**, faithfully: the reference's handler `0x603c80` resolves with /// `ClntObjMgrObjectPtr(TYPEMASK_UNIT)` and returns without applying anything when it misses. /// /// Our own guid is not special-cased. vmangos cannot send it for our mover (`Unit::SetRooted` and /// its three siblings take the broadcast leg only when the unit is **not** moved by a player), and /// the reference applies to whatever it finds — so we do too. It is inert on the avatar either way: /// the animation selector's `unify` gives the controller's own `MovementState` precedence, and our /// mover's modes are the handshake family's ([`crate::player::state::MoveModes`]). #[allow(clippy::too_many_arguments)] // the wire fields + the apply context, one per concern pub(super) fn spline_move_mode( guid: u64, mode: SplineMode, apply: bool, commands: &mut Commands, index: &GuidIndex, modes: &mut Query<&mut UnitMoveModes>, remote: &mut Query<&mut RemoteMotion>, staged: &mut StagedModes, ) { let Some(&e) = index.0.get(&guid) else { return; // no such unit — the reference drops it too }; let mut word = modes_of(guid, index, modes, staged); word.set(mode, apply); staged.insert(guid, word); if let Ok(mut live) = modes.get_mut(e) { *live = word; } else { commands.entity(e).insert(word); } // **`SetRoot 0x7c7340`'s one-shot wipe**, on the one benilla body that has direction bits to // wipe — a relayed player's. The reference clears `0xffe07f00`'s complement from the flags word // at apply and calls `0x7c6290` StopFalling with it; with the direction bits gone the unit fails // the integration gate (`move_flags::INTEGRATED`) and is not stepped at all, which is *how* a // root stops a body rather than leaving it coasting on its last reported heading until the next // packet. Without this the dead-reckon keeps walking a rooted player forward for the ~500 ms to // its next heartbeat. // // A creature needs none of it: it has no direction bits (its motion is its [`Spline`]), and what // stops *it* is `monster_move`'s refusal above. if mode == SplineMode::Root && apply { if let Ok(mut rm) = remote.get_mut(e) { use crate::creature_anim::move_flags; rm.flags &= ROOT_APPLY_WIPE & !(move_flags::FALLING | move_flags::FALLING_FAR); rm.speed = 0.0; rm.vertical_velocity = 0.0; rm.jump_xy_vel = [0.0; 2]; rm.fall_start_z = None; } } } /// The ask-once GameObject template (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, decision 0239): cache it and /// resolve the lockId from the type-specific `data[]` slot — the interact routing reads it to choose /// use-vs-cast; the hover tooltip reads the name (decision 0276's GO law). pub(super) fn gameobject_info( entry: u32, type_id: u32, display_id: u32, name: String, data: &[i32; 24], go_templates: &mut GameObjectTemplates, ) { debug!("net: gameobject template {entry} type {type_id} display {display_id} {name:?}"); go_templates.insert(entry, type_id, name, data); } /// Merge a descriptor delta into an object's store: into the per-drain `pending` seed when the entity was /// created earlier this same drain (its spawn `Command` hasn't run, so it isn't queryable yet), else in /// place on the live component. The final `else` — in the index but neither live nor pending — should not /// happen (a create always seeds `pending` first), but seeds defensively rather than drop the delta. fn merge_fields( stores: &mut Query<&mut ObjectStore>, pending: &mut HashMap, entity: Entity, guid: u64, delta: ObjectFields, ) { if let Some(f) = pending.get_mut(&guid) { f.merge(delta); } else if let Ok(mut s) = stores.get_mut(entity) { s.0.merge(delta); } else { pending.insert(guid, delta); } } /// Per-drain staging for every mover's [`UnitSpeeds`] — the drain's single speed writer /// (decision 1478). /// /// The two speed sources used to disagree about *when* they land. [`object_create`] inserts a whole /// speed set through `Commands`, which is deferred to the drain's sync point; a /// `SMSG_FORCE_*_SPEED_CHANGE` edited one slot of the live component immediately. Mixed in one /// drain, the **later packet loses** either way: on an entity born this drain the component isn't /// there yet so the query miss was silent, and on one that already existed the create's deferred /// insert landed afterwards and overwrote it. /// /// vmangos puts exactly those two packets back to back on purpose. `HandleMoveWorldportAckOpcode` /// sends the self create block inside `Map::Add` → `SendInitSelf` (carrying the speeds the player /// still has) and then, three statements later, strips the mount on a map that forbids one /// (`if (!mEntry->IsMountAllowed()) RemoveSpellsCausingAura(SPELL_AURA_MOUNTED)`) — whose /// `SMSG_FORCE_RUN_SPEED_CHANGE` therefore rides the same tick, and so the same drain. That is /// B213: `.tele` into BWL/LBRS on a mount auto-dismounted you and left the mount's run speed on /// foot, because the create's 11.2 yd/s landed last. /// /// So both sources stage here instead, **in packet order**, and the drain flushes once at the end — /// the `pending: HashMap` pattern (decision 0061) applied to speeds. Nothing /// writes the component directly any more, which is what makes the order the wire's order. #[derive(Default)] pub(super) struct SpeedStage(HashMap); impl SpeedStage { /// A create block's whole speed set. A create is the server's newest snapshot of the mover, so /// it **replaces** anything staged for that guid rather than merging into it. fn seed(&mut self, guid: u64, speeds: MoveSpeeds) { self.0.insert(guid, speeds); } /// Write one speed-kind slot, seeding the staged set from the live component on first touch. /// `live` is only called when nothing is staged yet; `None` from it means there is nowhere to /// write — an untracked guid, or a mover whose create carried no movement block — and the /// change is reported unapplied rather than silently dropped. fn set( &mut self, guid: u64, kind: SpeedKind, speed: f32, live: impl FnOnce() -> Option, ) -> bool { let s = match self.0.entry(guid) { Entry::Occupied(o) => o.into_mut(), Entry::Vacant(v) => match live() { Some(s) => v.insert(s), None => return false, }, }; match kind { SpeedKind::Walk => s.walk = speed, SpeedKind::Run => s.run = speed, SpeedKind::RunBack => s.run_back = speed, SpeedKind::Swim => s.swim = speed, SpeedKind::SwimBack => s.swim_back = speed, SpeedKind::TurnRate => s.turn_rate = speed, } true } /// Land every staged set on its entity, now that this drain's spawn `Command`s have run. /// `try_insert`: the same drain may have queued a despawn for it (a stream-out, the worldport /// purge), and a guid the purge dropped from the index resolves to nothing at all. pub(super) fn flush(self, commands: &mut Commands, index: &GuidIndex) { for (guid, speeds) in self.0 { if let Some(&e) = index.0.get(&guid) { commands.entity(e).try_insert(UnitSpeeds(speeds)); } } } } /// Read a mover's live speed set — [`SpeedStage::set`]'s seed for a mover that already exists. fn live_speeds(index: &GuidIndex, speeds: &Query<&UnitSpeeds>, guid: u64) -> Option { index .0 .get(&guid) .and_then(|&e| speeds.get(e).ok()) .map(|s| s.0) } /// A forced speed change on a mover (aura/mount/GM `.modify speed`): stage the new value onto the /// entity's speed set, and — when the mover is our own player — forward to the controller, which /// answers the mandatory ack with its live pose (the TeleportMessage pattern). An unknown guid /// still acks if it's ours-by-guid; a foreign mover (we never control others) is only applied, /// never acked — acking a unit we don't control is the server's error path. #[allow(clippy::too_many_arguments)] pub(super) fn force_speed_change( guid: u64, kind: SpeedKind, counter: u32, speed: f32, index: &GuidIndex, speeds: &Query<&UnitSpeeds>, stage: &mut SpeedStage, self_guid: &SelfGuid, speed_changes: &mut MessageWriter, ) { let applied = stage.set(guid, kind, speed, || live_speeds(index, speeds, guid)); if self_guid.0 == Some(guid) { info!("net: force {kind:?} speed change -> {speed} yd/s (counter {counter})"); // Our own mover having no speed set to write is B213's failure shape, and it was silent // for as long as it existed. It should be unreachable now that the create stages too — // so if it ever fires, the next report starts from a line instead of from a guess. if !applied { warn!( "net: force {kind:?} speed change for OUR mover {guid:#x} landed nowhere \ (no speed set staged or live) — we still ack, so the server and we now disagree" ); } speed_changes.write(SpeedChangeMessage { guid, kind, counter, speed, }); } else { debug!("net: force speed change for foreign mover {guid:#x} — applied {applied}, no ack"); } } /// An observed unit's speed changed (the SPLINE_SET / MOVE_SET families — another player /// mounting up, a hastened creature): stage it onto that unit's speed set, nothing to ack /// (decision 0441). The MOVE_SET flavour's pose already arrived as its own UnitMove. pub(super) fn speed_changed( guid: u64, kind: SpeedKind, speed: f32, index: &GuidIndex, speeds: &Query<&UnitSpeeds>, stage: &mut SpeedStage, ) { if !stage.set(guid, kind, speed, || live_speeds(index, speeds, guid)) { // Ordinary: a unit's speed broadcast can outrun its create block. Nothing to ack and the // create that follows carries the same set, so this self-heals. debug!("net: {kind:?} speed change for untracked mover {guid:#x} — nothing to write"); } } #[cfg(test)] mod tests { use super::*; const ME: u64 = 0x0000_0000_0000_002A; /// The observer movement-mode family's own harness (decision 1780): one indexed unit, and a /// `World` small enough that the only thing that can move it is the code under test. mod spline_modes { use super::*; use benilla_protocol::SplineMode; use bevy::ecs::system::RunSystemOnce; const MOB: u64 = 0x0000_0000_0000_00F0; /// A world holding one indexed unit, optionally already dead-reckoning as a relayed player. fn world_with_unit(remote: Option) -> (World, Entity) { let mut w = World::new(); let mut e = w.spawn_empty(); if let Some(rm) = remote { e.insert(rm); } let entity = e.id(); let mut index = GuidIndex::default(); index.0.insert(MOB, entity); w.insert_resource(index); (w, entity) } fn remote_walking_forward() -> RemoteMotion { RemoteMotion { wow_pos: [0.0; 3], pending: std::collections::VecDeque::new(), orientation: 0.0, flags: crate::creature_anim::move_flags::FORWARD, pitch: 0.0, speed: 7.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], fall_start_z: None, relay: Default::default(), last_apply_ms: 0.0, last_apply_pos: [0.0; 3], } } /// Run one grant through the real arm body, then flush the `Commands` it queued. fn grant(w: &mut World, mode: SplineMode, apply: bool) { w.run_system_once( move |mut commands: Commands, index: Res, mut modes: Query<&mut UnitMoveModes>, mut remote: Query<&mut RemoteMotion>| { let mut staged = StagedModes::new(); spline_move_mode( MOB, mode, apply, &mut commands, &index, &mut modes, &mut remote, &mut staged, ); }, ) .unwrap(); } /// **The grant lands, and the revoke takes it back off** — the whole family in one shape, /// because every one of the twelve opcodes is this same set-or-clear of one bit. #[test] fn a_grant_sets_the_bit_and_its_pair_clears_it() { use crate::creature_anim::move_flags; let (mut w, e) = world_with_unit(None); grant(&mut w, SplineMode::Hover, true); grant(&mut w, SplineMode::WaterWalk, true); let m = *w .entity(e) .get::() .expect("first grant inserts"); assert_eq!( m.0, move_flags::HOVER | move_flags::WATER_WALKING, "two grants accumulate in one word — the reference has one CMovement+0x40" ); grant(&mut w, SplineMode::Hover, false); let m = *w.entity(e).get::().unwrap(); assert_eq!( m.0, move_flags::WATER_WALKING, "UNSET_HOVER takes back hover and nothing else" ); } /// **`SET_RUN_MODE` clears the walk bit.** The pair is inverted against its opcode names /// (`0x617e80` → `SetRunMode 0x7c71c0`, whose argument is *run*), folded at the parse — so /// by the time it reaches here `apply` is the bit's direction and this is a plain round /// trip. Pinned anyway: the inversion is the family's one trap, and a regression that /// flipped it would put every running creature into a walk with nothing else visibly wrong. #[test] fn the_walk_bit_round_trips_with_the_run_opcodes_sense() { use crate::creature_anim::move_flags; let (mut w, e) = world_with_unit(None); grant(&mut w, SplineMode::WalkMode, true); // SMSG_SPLINE_MOVE_SET_WALK_MODE assert_eq!( w.entity(e).get::().unwrap().0, move_flags::WALK_MODE ); grant(&mut w, SplineMode::WalkMode, false); // SMSG_SPLINE_MOVE_SET_RUN_MODE assert_eq!(w.entity(e).get::().unwrap().0, 0); } /// **A root stops a watched body dead, and that is the direction bits' doing.** `SetRoot /// 0x7c7340` sets the bit and then wipes `0xffe07f00`'s complement — the four direction /// bits among it — in one shot; with those gone the unit fails the client's integration /// gate (`0x616e20 test dword [esi+0x40],0x20ff`) and is not stepped at all. Without the /// wipe our dead-reckon walks a rooted player forward for the ~500 ms to its next /// heartbeat, which is the reported "rooted and still sliding". #[test] fn a_root_wipes_the_direction_bits_the_dead_reckon_runs_on() { use crate::creature_anim::move_flags; let (mut w, e) = world_with_unit(Some(remote_walking_forward())); grant(&mut w, SplineMode::Root, true); let rm = w.entity(e).get::().unwrap(); assert_eq!( rm.flags & move_flags::INTEGRATED, 0, "nothing the integration gate tests may survive the root — this is what stops it" ); assert_eq!(rm.speed, 0.0, "and it is not still travelling at run speed"); assert!(w.entity(e).get::().unwrap().rooted()); } /// The unroot is **not** the wipe run backwards: `ClearRoot 0x7c7370` only clears the bit. /// A unit is put back in motion by the server's next pose or path, never by us inventing /// direction bits for it. #[test] fn an_unroot_clears_the_bit_and_invents_no_movement() { let (mut w, e) = world_with_unit(Some(remote_walking_forward())); grant(&mut w, SplineMode::Root, true); grant(&mut w, SplineMode::Root, false); assert!(!w.entity(e).get::().unwrap().rooted()); assert_eq!( w.entity(e).get::().unwrap().flags, 0, "still no direction bits — the wipe is one-shot and the unroot does not undo it" ); } /// **An unknown guid is dropped, not defaulted.** The reference's handler `0x603c80` /// resolves with `ClntObjMgrObjectPtr(TYPEMASK_UNIT)` and returns having applied nothing /// when it misses. Ours must not spawn or index anything to hold the grant: a mode /// broadcast can outrun its unit's create block, and the create carries the modes itself. #[test] fn a_grant_for_a_unit_we_do_not_have_is_dropped() { let (mut w, e) = world_with_unit(None); w.run_system_once( |mut commands: Commands, index: Res, mut modes: Query<&mut UnitMoveModes>, mut remote: Query<&mut RemoteMotion>| { let mut staged = StagedModes::new(); spline_move_mode( 0xDEAD_BEEF, SplineMode::Root, true, &mut commands, &index, &mut modes, &mut remote, &mut staged, ); assert!( staged.is_empty(), "nothing staged for a unit we do not have" ); }, ) .unwrap(); assert!(w.entity(e).get::().is_none()); } /// **A rooted unit cannot be splined** — `0x6187a0`, the server position/spline apply, /// opens `0x6187c2 test ah,0x10` and returns. So a path the server queued before it pinned /// the body does not move it. /// /// The gate is scoped to the *path*, not to the packet: what wow-re establishes is that /// the position/spline apply is refused, and the one other thing the handler is known to do /// (the un-nock tail) is verified to run on every path through it. The assertion below is /// therefore about the `Spline` and nothing else. #[test] fn a_rooted_unit_refuses_the_path_the_server_queued() { let mut w = World::new(); let e = w .spawn(( Transform::default(), NetEntity { kind: EntityKind::Unit, display_id: None, scale: 1.0, }, )) .id(); let mut index = GuidIndex::default(); index.0.insert(MOB, e); w.insert_resource(index); let path = vec![[0.0, 0.0, 0.0], [30.0, 0.0, 0.0]]; for (rooted, expect_spline) in [(true, false), (false, true)] { let path = path.clone(); w.run_system_once( move |mut commands: Commands, index: Res, mut transforms: Query<&mut Transform>, mut riders: Query<&mut crate::transport::TransportRider>| { monster_move( MOB, None, [0.0, 0.0, 0.0], 7, path.clone(), MonsterMoveFacing::None, false, 3000, false, true, rooted, &mut commands, &index, &mut transforms, &mut riders, ); }, ) .unwrap(); assert_eq!( w.entity(e).get::().is_some(), expect_spline, "rooted={rooted}: a pinned body takes no path, an unpinned one does" ); } } /// **`SMSG_MONSTER_MOVE_TRANSPORT` attaches, and its plain twin detaches** (decision /// 1936). A packet naming a transport makes the unit a [`TransportRider`] on it, seeds the /// rider pose from the packet's own deck-local `start`, and hands the spline the deck so /// the sampler writes the local pose instead of the world transform. A packet naming none /// takes the unit off the deck, which is the edge vmangos itself drops the passenger on /// (`MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:156-159`). #[test] fn a_transport_path_attaches_the_rider_and_a_plain_one_lets_it_go() { const BOAT: u64 = 0x2000_0000_0000_0007; let mut w = World::new(); let e = w .spawn(( Transform::default(), NetEntity { kind: EntityKind::Unit, display_id: None, scale: 1.0, }, )) .id(); let boat = w.spawn(Transform::default()).id(); let mut index = GuidIndex::default(); index.0.insert(MOB, e); index.0.insert(BOAT, boat); w.insert_resource(index); let deck_path = vec![[1.5, -2.5, 0.75], [4.0, -2.5, 0.75]]; w.run_system_once( move |mut commands: Commands, index: Res, mut transforms: Query<&mut Transform>, mut riders: Query<&mut crate::transport::TransportRider>| { monster_move( MOB, Some(BOAT), [1.5, -2.5, 0.75], 7, deck_path.clone(), MonsterMoveFacing::Angle(1.25), false, 3000, false, true, false, &mut commands, &index, &mut transforms, &mut riders, ); }, ) .unwrap(); let rider = w .entity(e) .get::() .expect("a transport path attaches its rider"); assert_eq!(rider.transport_guid, BOAT); assert_eq!(rider.local_pos, [1.5, -2.5, 0.75], "seeded deck-local"); assert!( (rider.local_orientation - 1.25).abs() < 1e-6, "Angle is deck-local" ); assert_eq!( w.entity(e).get::().expect("a path").deck, Some(BOAT), "the spline rides the deck frame, not the world" ); // The rider's world transform is `compose_riders`' to write, not this apply's — the // deck-local start must never have been mistaken for a world position. assert_eq!( w.entity(e).get::().unwrap().translation, Vec3::ZERO ); let world_path = vec![[100.0, 200.0, 30.0], [110.0, 200.0, 30.0]]; w.run_system_once( move |mut commands: Commands, index: Res, mut transforms: Query<&mut Transform>, mut riders: Query<&mut crate::transport::TransportRider>| { monster_move( MOB, None, [100.0, 200.0, 30.0], 8, world_path.clone(), MonsterMoveFacing::None, false, 3000, false, true, false, &mut commands, &index, &mut transforms, &mut riders, ); }, ) .unwrap(); assert!( w.entity(e) .get::() .is_none(), "a plain path takes the unit off the deck" ); assert_eq!(w.entity(e).get::().expect("a path").deck, None); } } /// A dismounted human's set — 1.12.1's base run 7.0 (the `--speed` probe's own golden). fn on_foot() -> MoveSpeeds { MoveSpeeds { walk: 2.5, run: 7.0, run_back: 4.5, swim: 4.722_222, swim_back: 2.5, turn_rate: std::f32::consts::PI, } } /// The same set with a 60% mount's run speed on it — what `SendInitSelf` puts in the create /// block of a worldport that is *about* to strip the mount. fn mounted() -> MoveSpeeds { MoveSpeeds { run: 11.2, ..on_foot() } } /// **B213, pinned.** vmangos's `HandleMoveWorldportAckOpcode` sends the self create block /// (`Map::Add` → `SendInitSelf`, still carrying the mount's 11.2 yd/s) and then, three /// statements later, strips the mount because the destination map forbids one — so /// `SMSG_FORCE_RUN_SPEED_CHANGE` 7.0 rides the same tick and lands in the same drain. /// Staged in packet order the change wins; before 1478 the create's *deferred* `UnitSpeeds` /// insert landed last and `.tele` into BWL left the avatar running at mount speed on foot. #[test] fn a_force_change_beats_a_create_from_the_same_drain() { let mut stage = SpeedStage::default(); stage.seed(ME, mounted()); assert!(stage.set(ME, SpeedKind::Run, 7.0, || panic!( "the create staged a set this drain — the live component must not be consulted" ))); stage.flush_into(|guid, s| { assert_eq!(guid, ME); assert_eq!(s.run, 7.0, "the dismount is the newer packet"); assert_eq!( s.walk, 2.5, "the other slots still come from the create block" ); }); } /// The reverse order is just as much the wire's order: a create block is the server's newest /// snapshot of the mover, so one arriving *after* a change replaces it whole rather than /// merging under it. #[test] fn a_create_after_a_change_replaces_it() { let mut stage = SpeedStage::default(); assert!(stage.set(ME, SpeedKind::Run, 7.0, || Some(mounted()))); stage.seed(ME, mounted()); stage.flush_into(|_, s| assert_eq!(s.run, 11.2)); } /// Nothing staged yet (an entity that has existed since an earlier drain): the live component /// seeds the cell, so a one-slot change keeps every other slot it isn't addressing. #[test] fn a_change_on_a_live_mover_seeds_from_the_component() { let mut stage = SpeedStage::default(); assert!(stage.set(ME, SpeedKind::Run, 11.2, || Some(on_foot()))); stage.flush_into(|_, s| { assert_eq!(s.run, 11.2); assert_eq!(s.run_back, on_foot().run_back); assert_eq!(s.turn_rate, on_foot().turn_rate); }); } /// A change with nowhere to land — an untracked guid, or a mover whose create carried no /// movement block — reports itself unapplied rather than vanishing. It stages nothing, so a /// later create can't inherit a slot invented for a set that never existed. #[test] fn a_change_with_no_speed_set_is_reported_not_swallowed() { let mut stage = SpeedStage::default(); assert!(!stage.set(ME, SpeedKind::Run, 7.0, || None)); let mut flushed = 0; stage.flush_into(|_, _| flushed += 1); assert_eq!(flushed, 0); } /// The trap itself, machine-checked rather than asserted in prose: a component inserted /// through this drain's `Commands` is **not** visible to a query in the same drain — the spawn /// only runs at the sync point. That is why staging is the fix and why a live-component write /// could never have been the fix: the force-change arm had nothing to write onto. #[test] fn a_component_this_drain_queued_is_invisible_until_the_sync_point() { use bevy::ecs::system::RunSystemOnce; let mut world = World::new(); let missed = world .run_system_once(|mut commands: Commands, live: Query<&UnitSpeeds>| { let e = commands.spawn(UnitSpeeds(mounted())).id(); live.get(e).is_err() }) .expect("the probe system runs"); assert!( missed, "a create's UnitSpeeds must be unreadable later in the same drain — if this ever \ passes, `Commands` stopped being deferred and 1478's staging can be reconsidered" ); } impl SpeedStage { /// [`SpeedStage::flush`] without the ECS half — the staged sets in the shape the flush /// would insert them, so the ordering rules above are testable as the pure thing they are. fn flush_into(self, mut f: impl FnMut(u64, MoveSpeeds)) { for (guid, speeds) in self.0 { f(guid, speeds); } } } }