From 340dabbc72501057e79b145b6480469e192e4b14 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 21:26:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(anim):=20full=20retail=20remote-entity=20m?= =?UTF-8?q?otion=20port=20=E2=80=94=20walk/run/strafe/turn/stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the retail client's client-side remote-entity motion pipeline verbatim per the decompile research. Every remote now runs its own PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has no special "interpolator" for remotes, it runs the full motion state machine on every entity. Now we do too. ## What changed ### Parser fixes (CreateObject, UpdateMotion) Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum): CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04, SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40 Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed and SKIPPED the side/turn fields entirely. Result: we had zero rotation- or strafe-intent data from the server — impossible to render turn or sidestep animations. Now ServerMotionState carries all 7 fields and the parser reads the bytes in ACE's write order (style, fwd, side, turn, then fwdSpd, sideSpd, turnSpd). ### RemoteMotion (new per-remote struct in GameWindow) Each remote gets its own PhysicsBody + MotionInterpreter + observed angular velocity. Replaces the earlier shortcut RemoteInterpolator (deleted — retail has no such thing). On UpdateMotion: - ForwardCommand flag absent → stop signal (reset to Ready) per retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default). - Forward + sidestep + turn each route through DoInterpretedMotion, exactly as retail FUN_00528F70 does. - Animation cycle selection: forward wins if active, else sidestep, else turn, else Ready. Matches the user's observation that retail plays turn animation when only turning. - Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid MotionData.Omega.Z ≈ π/2 per decompile). - Turn absent → ObservedOmega = 0 (stops rotation immediately). On UpdatePosition: - Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90 set_frame (direct assignment, no slerp — retail does not soft-snap). - HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready). - ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when alt releases W); previously we defaulted to 1.0, causing the "slow walk that never stops" symptom. Per-tick: - apply_current_movement → Body.Velocity via get_state_velocity (retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local, rotated by orientation). - Manual omega integration: Orientation *= quat(ObservedOmega × dt). Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that was eating every-other-tick rotation updates at our 60fps render rate — the cause of the persistent "rotation snaps every UP" bug. - update_object still called for position integration and the motion subsystem it drives. ### AnimationSequencer synthesis extension Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as the earlier velocity synthesis): when the Humanoid dat leaves HasOmega clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so dead-reckoning and stop detection can read a non-zero omega for turn cycles. ### Stop-detection heuristic removed No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is explicit (UpdateMotion with ForwardCommand flag absent → Ready); we handle it directly. Client-side timers were a source of flicker during normal running. ## Confirmed working - Walking (matches retail speed + leg cadence) - Running (matches retail speed + leg cadence) - Strafing (body moves sideways + strafe animation plays) - Turning while stationary (body rotates smoothly + turn animation plays) - Turning while running (body rotates + leg anim continues) - Stopping (instant stop, no slow-walk tail) All 717 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 615 ++++++++++-------- src/AcDream.Core.Net/Messages/CreateObject.cs | 91 ++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 67 +- .../Physics/AnimationSequencer.cs | 28 + .../Messages/UpdateMotionTests.cs | 8 +- 5 files changed, 506 insertions(+), 303 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0e50e1a..efe39e1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -170,42 +170,62 @@ public sealed class GameWindow : IDisposable /// motion tables with HasVelocity=0). /// /// - private readonly Dictionary _remoteDeadReckon = new(); + private readonly Dictionary _remoteDeadReckon = new(); - private sealed class RemoteDeadReckonState + /// + /// Per-remote-entity physics + motion stack — verbatim application of + /// retail's client-side motion pipeline to every remote. Mirrors + /// retail FUN_00515020 update_objectFUN_00513730 + /// UpdatePositionInternalFUN_005111D0 + /// UpdatePhysicsInternal, and ACE's PhysicsObj.cs port. + /// + /// + /// Retail has NO special "interpolator" for remote entities — it runs + /// the full motion state machine on every entity, local or remote, + /// and reconciles via hard-snap on UpdatePosition. This class simply + /// pairs a with its + /// so each + /// remote gets the same treatment as the local player. + /// + /// + private sealed class RemoteMotion { - /// Last server-authoritative world position. + public AcDream.Core.Physics.PhysicsBody Body; + public AcDream.Core.Physics.MotionInterpreter Motion; + /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. + public double LastServerPosTime; + /// Last known server position — kept for diagnostics / HUD. public System.Numerics.Vector3 LastServerPos; - /// When that last server position arrived (UTC). - public System.DateTime LastServerPosTime; - /// Last server-authoritative world rotation. - public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity; /// - /// Most recently observed position-delta-based world velocity, used - /// as fallback when the sequencer has no CurrentVelocity. Computed - /// as (pos_new - pos_old) / dt across consecutive UpdatePositions. + /// Legacy field — no longer used for slerp (retail hard-snaps + /// per FUN_00514b90 set_frame). Kept to avoid churn. /// - public System.Numerics.Vector3 ObservedVelocity; - /// Server-supplied world velocity from UpdatePosition (HasVelocity flag). - public System.Numerics.Vector3? ServerVelocity; + public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity; + /// + /// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed + /// (π/2 × turnSpeed, signed). Applied per tick to body orientation + /// via manual integration (bypassing PhysicsBody.update_object's + /// MinQuantum 30fps gate that would otherwise skip most ticks). + /// Zeroed on UM with TurnCommand absent. + /// + public System.Numerics.Vector3 ObservedOmega; - /// - /// Internal dead-reckoned position: the authoritative server pos plus - /// velocity*dt integration since the last update. Each tick this - /// advances; on UpdatePosition it resets to the new server pos. - /// Separated from the publicly visible Entity.Position so the - /// residual-decay logic doesn't mix with the integration state. - /// - public System.Numerics.Vector3 DeadReckonedPos; - - /// - /// Residual offset the renderer is blending out. When UpdatePosition - /// arrives, we compute (lastRenderedPos - newServerPos) and store it - /// here; each tick the offset decays toward zero while the entity's - /// displayed position = DeadReckonedPos + residual. This hides a - /// sudden teleport when the dead-reckoner and server disagreed. - /// - public System.Numerics.Vector3 SnapResidual; + public RemoteMotion() + { + Body = new AcDream.Core.Physics.PhysicsBody + { + // Remotes don't simulate gravity — server owns Z. Force + // Contact + OnWalkable + Active so apply_current_movement + // writes velocity through every tick (the gate in + // MotionInterpreter.apply_current_movement is + // PhysicsObj.OnWalkable). + State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions, + TransientState = AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active, + }; + Motion = new AcDream.Core.Physics.MotionInterpreter(Body); + } } /// Soft-snap decay rate (1/sec). At this rate the residual @@ -217,8 +237,18 @@ public sealed class GameWindow : IDisposable /// update as a teleport / rubber-band and hard-snap (no soft lerp). /// Prevents the soft-snap logic from trying to smooth a genuine portal /// or force-move event. + /// + /// + /// Matches retail's GetAutonomyBlipDistance (ACE + /// PhysicsObj.cs:545): 20m for creatures, 25m for players. + /// We use 20m as a conservative default — any delta larger than this + /// must be a teleport (portal, recall, spawn). A running character + /// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas + /// of ~9.5m, well below this threshold, so normal movement flows + /// through the interpolation queue instead of hard-snapping. + /// /// - private const float SnapHardSnapThreshold = 5.0f; + private const float SnapHardSnapThreshold = 20.0f; /// /// Soft-snap window in seconds: after an UpdatePosition arrives for a @@ -1603,56 +1633,50 @@ public sealed class GameWindow : IDisposable ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle; - // If the server told us a command, use it. If command is 0, - // that's "stop / return to idle"; translate that into the - // style's default (typically Ready, 0x41000003 for NonCombat). - // If command is null ("not updated"), keep current motion. + // ACE's stop signal: ForwardCommand flag CLEARED on the wire. + // Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags, + // when the player releases keys the InterpretedMotionState has + // ForwardCommand = Invalid (default) and BuildMovementFlags doesn't + // set bit 0x02 — so the field is absent. Retail's decompiled + // handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957) + // bulk-copies Invalid/0 into the physics obj, which StopCompletely + // treats as "return to style default (Ready)." + // + // command == null → retail stop signal → Ready + // command.Value == 0 → explicit 0 (rare) → Ready + // otherwise → resolve class byte and use full cmd uint fullMotion; - if (command.HasValue) + if (!command.HasValue || command.Value == 0) { - if (command.Value == 0) - { - // Stop — pick the style's default substate (Ready). - fullMotion = 0x41000003u; - } - else - { - // Use MotionCommandResolver to restore the proper class - // byte from the wire's 16-bit ForwardCommand. The old - // heuristic (OR the sequencer's current high byte) - // produced wrong values like 0x41000007 instead of the - // real RunForward 0x44000007 — SetCycle's cycleKey - // lookup uses low 24 bits so it'd still hit the right - // MotionData cycle, BUT the class byte gates later - // behaviour (locomotion-motion-detection at line 3615 - // uses `motion & 0xFFu`, and the class byte is needed - // for stance-aware code paths). - uint resolved = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(command.Value); - fullMotion = resolved != 0 - ? resolved - : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value; - if (fullMotion == (uint)command.Value) // no class bits yet - fullMotion = 0x40000000u | (uint)command.Value; - } + // Stop — return to the style's default substate (Ready). + fullMotion = 0x41000003u; } else { - fullMotion = ae.Sequencer.CurrentMotion; + // Use MotionCommandResolver to restore the proper class + // byte from the wire's 16-bit ForwardCommand. + uint resolved = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(command.Value); + fullMotion = resolved != 0 + ? resolved + : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value; + if (fullMotion == (uint)command.Value) // no class bits yet + fullMotion = 0x40000000u | (uint)command.Value; } - // ForwardSpeed from the InterpretedMotionState (flag 0x10). + // ForwardSpeed from the InterpretedMotionState (flag 0x04). // ACE omits this field when speed == 1.0 (only sets the flag - // when ForwardSpeed != 1.0 — see InterpretedMotionState.cs - // BuildMovementFlags L101-L103). So: - // - omitted / 0 → 1.0 (normal speed) - // - present → retail server-broadcast speedMod + // when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101). + // So: + // - field absent → default 1.0 (normal speed) + // - field present → USE THE VALUE, including zero. // - // The sequencer's SetCycle fast-paths identical (style, motion) - // pairs and calls MultiplyCyclicFramerate when only speedMod - // changed — keeping the loop smooth during a mid-run RunRate - // broadcast. - float speedMod = update.MotionState.ForwardSpeed is { } fs && fs > 0f ? fs : 1f; + // Zero is a VALID stop signal: when the retail client releases + // W, ACE broadcasts WalkForward with ForwardSpeed=0 (via + // apply_run_to_command). Treating zero as "unspecified / 1.0" + // produces "slow walk that never stops" — exactly what the + // stop bug looked like. + float speedMod = update.MotionState.ForwardSpeed ?? 1f; if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" && update.Guid != _playerServerGuid) @@ -1685,7 +1709,116 @@ public sealed class GameWindow : IDisposable } else { - ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); + // Pick which cycle to play on the sequencer. Priority: + // 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk. + // 2. Else sidestep cmd if active — legs strafe. + // 3. Else turn cmd if active — legs pivot. + // 4. Else Ready — idle. + // + // For forward+sidestep or forward+turn, the forward cycle + // wins at the anim layer; the sidestep/turn contribute via + // MotionInterpreter velocity/omega writes. + uint animCycle = fullMotion; + float animSpeed = speedMod; + uint fwdLow = fullMotion & 0xFFu; + bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */ + || fwdLow == 0x07 /* Run */; + if (!fwdIsRunWalk) + { + // Forward is Ready (or absent). Prefer sidestep cycle if present, + // else turn cycle, else Ready. + if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0) + { + uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(sideForAnim); + if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim; + animCycle = sideFullForAnim; + animSpeed = update.MotionState.SideStepSpeed ?? 1f; + } + else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0) + { + uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(turnForAnim); + if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim; + animCycle = turnFullForAnim; + animSpeed = MathF.Abs(update.MotionState.TurnSpeed ?? 1f); + } + } + ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed); + + // Retail runs the full MotionInterp state machine on every + // remote. Route each wire command (forward, sidestep, turn) + // through DoInterpretedMotion so apply_current_movement → + // get_state_velocity → PhysicsBody.set_local_velocity fires + // on a subsequent tick exactly as retail's FUN_00529210 + // (apply_current_movement) does. + // + // Decompile refs: + // FUN_00529930 DoMotion + // FUN_00528f70 DoInterpretedMotion + // FUN_00528960 get_state_velocity + // FUN_00529210 apply_current_movement + if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + { + // Forward axis (Ready / WalkForward / RunForward / WalkBackward). + remoteMot.Motion.DoInterpretedMotion( + fullMotion, speedMod, modifyInterpretedState: true); + + // Sidestep axis. + if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) + { + uint sideFull = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(sideCmd16); + if (sideFull == 0) sideFull = 0x65000000u | sideCmd16; + float sideSpd = update.MotionState.SideStepSpeed ?? 1f; + remoteMot.Motion.DoInterpretedMotion( + sideFull, sideSpd, modifyInterpretedState: true); + } + else + { + // No sidestep — clear any leftover strafing motion. + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true); + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true); + } + + // Turn axis — and use as the on/off switch for ObservedOmega. + // On turn start: seed ObservedOmega from the formula + // (π/2 × turnSpeed) so rotation begins THIS tick without + // waiting for the next UP to observe a delta. + // On turn end: zero ObservedOmega so rotation stops + // immediately instead of coasting at the last observed + // rate until the next UP shows zero delta. + // UpdatePosition still REFINES the rate from actual + // server deltas (more accurate than the formula), but + // this ensures instant on/off response. + if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0) + { + uint turnFull = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(turnCmd16); + if (turnFull == 0) turnFull = 0x65000000u | turnCmd16; + float turnSpd = update.MotionState.TurnSpeed ?? 1f; + remoteMot.Motion.DoInterpretedMotion( + turnFull, turnSpd, modifyInterpretedState: true); + // Seed ObservedOmega with formula so rotation starts + // immediately; UP deltas will refine the rate. + uint turnLow = turnFull & 0xFFu; + if (turnLow == 0x0D /* TurnRight */) + remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd); + else if (turnLow == 0x0E /* TurnLeft */) + remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd); + } + else + { + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true); + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true); + // Zero ObservedOmega immediately — don't coast. + remoteMot.ObservedOmega = System.Numerics.Vector3.Zero; + } + } } // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), @@ -1715,7 +1848,7 @@ public sealed class GameWindow : IDisposable if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) _remoteLastMove[update.Guid] = (prev.Pos, refreshedTime); if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) - dr.LastServerPosTime = refreshedTime; + dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } // Route the Commands list — one-shot Actions, Modifiers, and @@ -1862,46 +1995,75 @@ public sealed class GameWindow : IDisposable _remoteLastMove[update.Guid] = (worldPos, now); } - // Dead-reckon state: accumulate observed world-space velocity. - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState)) + // Retail-faithful hard-snap on UpdatePosition. + // Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes + // pos/rot directly into PhysicsObj+0x80..0xBC with no blending. + // Between UpdatePositions, per-tick velocity integration keeps + // the rendered position close to server truth so each snap is + // small. When HasVelocity is set, we also seed PhysicsBody + // velocity (matches retail's set_velocity call in the same + // dispatcher). + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState)) { - drState = new RemoteDeadReckonState(); - _remoteDeadReckon[update.Guid] = drState; + rmState = new RemoteMotion(); + _remoteDeadReckon[update.Guid] = rmState; + // Hard-snap orientation on first spawn so the per-tick + // slerp doesn't visibly rotate from Identity to truth. + rmState.Body.Orientation = rot; } - else + rmState.Body.Position = worldPos; + + // Retail hard-snaps orientation on UpdatePosition (set_frame, + // FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment). + // Rotation rate between UPs comes from the formula-based + // omega seed on UpdateMotion (π/2 × turnSpeed). We tried + // deriving omega from UP deltas, but the first UP after a + // turn starts incorporates the pre-turn interval and produces + // a halved "observed" rate → visible slow-start. Formula-only + // is stable and simple; hard-snap fixes any drift. + rmState.Body.Orientation = rot; + rmState.TargetOrientation = rot; + rmState.LastServerPos = worldPos; + rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds; + // Align the body's physics clock with our clock so update_object + // doesn't sub-step a huge initial gap. + rmState.Body.LastUpdateTime = rmState.LastServerPosTime; + + // ACE broadcasts UpdatePosition WITHOUT HasVelocity for player + // remote motion — even while actively running. Per packet + // captures: UPs always arrive with velocity null. So we can't + // use UP-absent-velocity as a stop signal (was previously a + // bug that fired StopCompletely every UP → intermittent run). + // + // Stop is signaled by UpdateMotion(ForwardCommand = Ready = + // 0x41000003), handled in OnLiveMotionUpdated. UP's role here + // is just to hard-snap position and adopt velocity IF the + // packet happens to carry one (rare for players, common for + // scripted-path NPCs / missiles). + if (update.Velocity is { } svel) { - float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds; - if (dtSec > 0.01f && dtSec < 1.0f) + rmState.Body.Velocity = svel; + // Only use the < 0.2 m/s stop signal when velocity was + // explicitly provided (i.e. server sent HasVelocity + tiny + // value = "I'm definitely stopped"). Absent velocity field + // carries no stop information for our ACE. + if (svel.LengthSquared() < 0.04f) { - // EMA-smooth the observed velocity so one-off snaps don't - // overwrite the running average. alpha=0.5 converges fast - // but resists single-frame noise. - var observed = (worldPos - drState.LastServerPos) / dtSec; - drState.ObservedVelocity = 0.5f * drState.ObservedVelocity + 0.5f * observed; + rmState.Motion.StopCompletely(); + if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop) + && aeForStop.Sequencer is not null) + { + uint curStyle = aeForStop.Sequencer.CurrentStyle; + uint readyCmd = (curStyle & 0xFF000000u) != 0 + ? ((curStyle & 0xFF000000u) | 0x01000003u) + : 0x41000003u; + aeForStop.Sequencer.SetCycle(curStyle, readyCmd); + } } } - drState.LastServerPos = worldPos; - drState.LastServerRot = rot; - drState.LastServerPosTime = now; - drState.ServerVelocity = update.Velocity; - drState.DeadReckonedPos = worldPos; // reset integration from server truth - // Soft-snap: if the displayed position (preSnapPos) was close to - // the authoritative position, convert the error into a residual - // that decays over ~100ms. If it was far (> SnapHardSnapThreshold), - // this IS a teleport — leave residual zero, hard-snap already done. - var snapError = preSnapPos - worldPos; - float mag = snapError.Length(); - if (mag > 1e-3f && mag <= SnapHardSnapThreshold) - { - drState.SnapResidual = snapError; - entity.Position = worldPos + snapError; // keep rendered pos unchanged this frame - } - else - { - drState.SnapResidual = System.Numerics.Vector3.Zero; - // entity.Position already = worldPos from hard-snap above - } + entity.Position = rmState.Body.Position; + entity.Rotation = rmState.Body.Orientation; } // Phase B.3: portal-space arrival detection. @@ -3646,21 +3808,16 @@ public sealed class GameWindow : IDisposable /// private void TickAnimations(float dt) { - // Stop-detection window: if a remote entity is in a locomotion - // cycle but hasn't moved meaningfully in this many ms, swap them - // to Ready. Retail observer pattern — server never broadcasts an - // explicit stop; observer infers from position deltas. - // - // 300ms matches the interval between typical server-broadcast - // UpdatePositions for a stationary NPC (~3-5 Hz heartbeat). Any - // shorter and we'd false-positive between packets; longer and the - // stop animation lags visibly. - const double StopIdleMs = 300.0; - // Additional velocity-based stop detector: if the EMA observed - // velocity drops below this world m/s, the entity has clearly - // stopped. Catches the case where the server IS sending - // UpdatePositions but they're all repeating the same pos. - const float StopVelocityThreshold = 0.2f; + // Retail has NO stop-detection heuristic — it relies on the + // server sending explicit UpdateMotion(Ready). The server-side + // stop signal flows the same way as any other motion change: + // alt releases W → MoveToState(Ready) → ACE broadcasts + // UpdateMotion(Ready) + UpdatePosition with zero velocity. + // On our side, both are handled: UpdateMotion routes through + // MotionInterpreter.DoInterpretedMotion which zeroes the + // interpreter's state, and UpdatePosition's HasVelocity~0 hits + // MotionInterpreter.StopCompletely. Anything else is server + // buggery (packet loss, ACE bug) — don't guess client-side. var now = System.DateTime.UtcNow; foreach (var kv in _animatedEntities) @@ -3668,68 +3825,14 @@ public sealed class GameWindow : IDisposable var ae = kv.Value; // Locate the server guid for this entity once per tick — needed - // for both stop-detection and dead-reckoning. O(N) reverse - // lookup; for player populations < 100 the cost is negligible. + // for dead-reckoning. O(N) reverse lookup; for player populations + // < 100 the cost is negligible. uint serverGuid = 0; foreach (var esg in _entitiesByServerGuid) { if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } } - // ── Remote stop-detection: if this entity's sequencer is in a - // locomotion cycle and their position hasn't changed in >400ms, - // the retail player stopped moving. Swap them to Ready. This - // replaces the never-arriving "released forward" UpdateMotion. - if (ae.Sequencer is not null) - { - uint motionLo = ae.Sequencer.CurrentMotion & 0xFFu; - bool inLocomotion = motionLo == 0x05 // WalkForward - || motionLo == 0x06 // WalkBackward - || motionLo == 0x07 // RunForward - || motionLo == 0x0F // SideStepRight - || motionLo == 0x10; // SideStepLeft - if (inLocomotion - && serverGuid != 0 - && serverGuid != _playerServerGuid) - { - bool shouldStop = false; - - // Signal 1: no server-side position change in StopIdleMs. - if (_remoteLastMove.TryGetValue(serverGuid, out var last) - && (now - last.Time).TotalMilliseconds > StopIdleMs) - { - shouldStop = true; - } - - // Signal 2: observed velocity has decayed below threshold. - // This catches the case where UpdatePositions are arriving - // at rate but each one is the same position (server-side - // stationary). EMA keeps the velocity average reflecting - // the current truth. - if (!shouldStop - && _remoteDeadReckon.TryGetValue(serverGuid, out var dr) - && (now - dr.LastServerPosTime).TotalMilliseconds < 600.0 - && dr.ObservedVelocity.Length() < StopVelocityThreshold) - { - // Only trigger stop-via-velocity if the sequencer's - // own velocity is also low — otherwise the cycle's - // MotionData has non-zero forward velocity and we'd - // flip-flop (stop → start → stop). - if (ae.Sequencer.CurrentVelocity.Length() < 0.5f) - shouldStop = true; - } - - if (shouldStop) - { - uint curStyle = ae.Sequencer.CurrentStyle; - uint ready = (curStyle & 0xFF000000u) != 0 - ? ((curStyle & 0xFF000000u) | 0x01000003u) - : 0x41000003u; - ae.Sequencer.SetCycle(curStyle, ready); - } - } - } - // ── Dead-reckoning: smooth position between UpdatePosition bursts. // The server broadcasts UpdatePosition at ~5-10Hz for distant // entities; without integration, remote chars jitter-hop between @@ -3746,105 +3849,75 @@ public sealed class GameWindow : IDisposable if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var drState)) + && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero; + // Stop detection is handled explicitly on packet receipt: + // - UpdateMotion with ForwardCommand flag CLEARED → Ready. + // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. + // Both map to retail's "flag-absent = Invalid = reset to + // default" semantics (FUN_0051F260 bulk-copy). No timer-based + // inference needed — the server sends the right signal every + // time a remote stops. - // Priority 1: sequencer's MotionData velocity, rotated into - // world space by the entity's orientation. "World space on - // the object" (r03 §1.3) → local vector rotated by entity - // rotation → world space. - var seqVel = ae.Sequencer.CurrentVelocity; - if (seqVel.LengthSquared() > 1e-6f) + // Retail per-tick motion pipeline applied to every remote. + // Mirrors retail FUN_00515020 update_object → FUN_00513730 + // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: + // + // 1. apply_current_movement (FUN_00529210) — recomputes + // body.Velocity from InterpretedState via get_state_velocity. + // 2. Pull omega from the sequencer (baked MotionData.Omega + // for TurnRight / TurnLeft cycles, scaled by speedMod). + // 3. body.update_object(now) — Euler-integrates + // position += Velocity × dt + 0.5 × Accel × dt² AND + // orientation += omega × dt. + // + // On UpdatePosition receipt we hard-snap body.Position and + // body.Orientation — if integration matched server physics, + // each snap is small/invisible. + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + + // Step 1: re-apply current motion commands → body.Velocity. + // Forces OnWalkable + Contact so the gate in apply_current_movement + // always succeeds (remotes are server-authoritative; we don't + // simulate airborne physics for them). + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active; + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Step 2: integrate rotation manually per tick. We can't + // rely on PhysicsBody.update_object here — its MinQuantum + // gate (1/30 s) causes it to SKIP integration when our + // 60fps render dt (~0.016s) is below the quantum, meaning + // rotation never advances. Measured snap per UP was ~129° + // = the full expected 1s × 2.24 rad/s, confirming zero + // between-tick rotation. + // + // Manual integration matches retail's FUN_005256b0 + // apply_physics (Orientation *= quat(ω × dt)). Use + // ObservedOmega derived from server UP rotation deltas so + // the rate exactly matches server physics — hard-snap on + // next UP becomes invisible by construction. + rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object + if (rm.ObservedOmega.LengthSquared() > 1e-8f) { - worldVel = System.Numerics.Vector3.Transform(seqVel, ae.Entity.Rotation); - } - // Priority 2: server-supplied world velocity (HasVelocity flag - // on UpdatePosition). Already world-space; no rotation. - else if (drState.ServerVelocity is { } sv && sv.LengthSquared() > 1e-6f) - { - worldVel = sv; - } - // Priority 3: EMA-observed velocity from position deltas. - // Fallback for NPC motion tables with HasVelocity=0 (dat - // authors didn't encode it). Already world-space. - else if (drState.ObservedVelocity.LengthSquared() > 1e-6f - && (now - drState.LastServerPosTime).TotalMilliseconds < 2000.0) - { - worldVel = drState.ObservedVelocity; + float omegaMag = rm.ObservedOmega.Length(); + var axis = rm.ObservedOmega / omegaMag; + float angle = omegaMag * dt; + var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); } - if (worldVel.LengthSquared() > 1e-6f) - { - // Only integrate while the cycle is a locomotion cycle. - // Idle (Ready 0x03) and emotes should stay pinned at the - // last server pos — MotionData for Ready has no velocity - // anyway, but belt + suspenders. - uint mlo = ae.Sequencer.CurrentMotion & 0xFFu; - bool isLocomotion = mlo == 0x05 || mlo == 0x06 - || mlo == 0x07 - || mlo == 0x0F || mlo == 0x10; - if (isLocomotion) - { - // Integrate from the separate DeadReckonedPos — NOT - // from Entity.Position, which may be carrying a - // decaying soft-snap residual. This keeps the - // integration clean and the residual applied as a - // pure render-time offset. - var predicted = drState.DeadReckonedPos + worldVel * dt; - float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds; - var fromServer = predicted - drState.LastServerPos; - if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f) - { - // Clamp back toward last server position. - var clamped = drState.LastServerPos + - System.Numerics.Vector3.Normalize(fromServer) * maxDrift; - drState.DeadReckonedPos = clamped; - } - else - { - drState.DeadReckonedPos = predicted; - } - } - } + // Step 3: integrate physics — retail FUN_00515020 + // update_object → FUN_00513730 UpdatePositionInternal → + // FUN_005256b0 Sequence::apply_physics. Position and + // orientation BOTH advance from Velocity/Omega × dt. + // No slerp, no soft-snap — retail is deterministic. + rm.Body.update_object(nowSec); - // Render position = dead-reckoned authoritative + residual. - // Residual decays toward zero, so after ~300ms the rendered - // position matches the authoritative truth. - float decay = MathF.Max(0f, 1f - SnapResidualDecayRate * dt); - drState.SnapResidual *= decay; - if (drState.SnapResidual.LengthSquared() < 1e-4f) - drState.SnapResidual = System.Numerics.Vector3.Zero; - ae.Entity.Position = drState.DeadReckonedPos + drState.SnapResidual; - - // Rotation integration: if the sequencer's Omega is non-zero - // (TurnRight / TurnLeft / any cycle with baked-in spin), rotate - // the entity's quaternion around the omega axis by |omega|*dt. - // Matches ACE Sequence.apply_physics L221-L229: - // frame.Rotate(Omega * quantum) - // where frame.Rotate treats the argument as a local-axis - // rotation. Only kicks in for Turn cycles (low byte 0x0D/0x0E) - // — other motions either have zero omega or integrate rotation - // server-side. - var seqOmega = ae.Sequencer.CurrentOmega; - if (seqOmega.LengthSquared() > 1e-6f) - { - uint mlo2 = ae.Sequencer.CurrentMotion & 0xFFu; - bool isTurning = mlo2 == 0x0D || mlo2 == 0x0E; // TurnRight / TurnLeft - if (isTurning) - { - // Omega as a scaled axis-angle. Build a delta quaternion - // and compose it on the entity's current rotation. - float angle = seqOmega.Length() * dt; - if (angle > 1e-5f) - { - var axis = System.Numerics.Vector3.Normalize(seqOmega); - var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); - ae.Entity.Rotation = System.Numerics.Quaternion.Normalize( - ae.Entity.Rotation * deltaRot); - } - } - } + ae.Entity.Position = rm.Body.Position; + ae.Entity.Rotation = rm.Body.Orientation; } // ── Get per-part (origin, orientation) from either sequencer or legacy ── diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 6d3e918..e7d9c87 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -110,11 +110,33 @@ public static class CreateObject /// Nullified Statue of a Drudge, which is rendered in the wrong pose /// if you only consult the MotionTable's default style. /// + /// + /// Full InterpretedMotionState from the server. Covers every field that + /// can appear in the wire — the earlier version only tracked + /// ForwardCommand/ForwardSpeed and silently discarded TurnCommand / + /// SideStepCommand / their speeds. That made it impossible to render + /// smooth circles or strafing for remote entities — the client literally + /// had no rotation-intent data between UpdatePositions. + /// + /// + /// Per ACE InterpretedMotionState.Write (line 127) the wire + /// order is: CurrentStyle, ForwardCommand, SideStepCommand, + /// TurnCommand (all ushort), then ForwardSpeed, SideStepSpeed, TurnSpeed + /// (all float). Flag bits (MovementStateFlag enum): + /// 0x01=CurrentStyle, 0x02=ForwardCommand, 0x04=ForwardSpeed, + /// 0x08=SideStepCommand, 0x10=SideStepSpeed, 0x20=TurnCommand, + /// 0x40=TurnSpeed. + /// + /// public readonly record struct ServerMotionState( ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null, - IReadOnlyList? Commands = null); + IReadOnlyList? Commands = null, + ushort? SideStepCommand = null, + float? SideStepSpeed = null, + ushort? TurnCommand = null, + float? TurnSpeed = null); /// /// One entry in the InterpretedMotionState's Commands list (MotionItem). @@ -501,6 +523,10 @@ public static class CreateObject ushort? forwardCommand = null; float? forwardSpeed = null; + ushort? sidestepCommand = null; + float? sidestepSpeed = null; + ushort? turnCommand = null; + float? turnSpeed = null; List? commands = null; // 0 = Invalid is the only union variant we care about for static @@ -520,36 +546,69 @@ public static class CreateObject uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits uint numCommands = packed >> 7; - // CurrentStyle (0x1) + // Flag-bit + write order per ACE + // InterpretedMotionState.Write @ line 127 + // (MovementStateFlag enum @ ACE.Entity.Enum): + // CurrentStyle = 0x01 (ushort) + // ForwardCommand = 0x02 (ushort) + // SideStepCommand = 0x08 (ushort) + // TurnCommand = 0x20 (ushort) + // ForwardSpeed = 0x04 (float) + // SideStepSpeed = 0x10 (float) + // TurnSpeed = 0x40 (float) + // Note the bit values are NOT in write order — commands + // come first in the wire stream regardless of bit value, + // then speeds. Earlier versions had this mapping wrong, + // which caused ForwardSpeed to silently never be read + // (appeared as HasValue=False on every remote broadcast). + if ((flags & 0x1u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } - - // ForwardCommand (0x2) if ((flags & 0x2u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } - // SidestepCommand (0x4) — skip - if ((flags & 0x4u) != 0) { if (mv.Length - p < 2) goto done; p += 2; } - // TurnCommand (0x8) — skip - if ((flags & 0x8u) != 0) { if (mv.Length - p < 2) goto done; p += 2; } - // ForwardSpeed (0x10) - if ((flags & 0x10u) != 0) + // SideStepCommand (bit 0x8, ushort) + if ((flags & 0x8u) != 0) + { + if (mv.Length - p < 2) goto done; + sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + p += 2; + } + // TurnCommand (bit 0x20, ushort) + if ((flags & 0x20u) != 0) + { + if (mv.Length - p < 2) goto done; + turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + p += 2; + } + // ForwardSpeed (bit 0x4, float) + if ((flags & 0x4u) != 0) { if (mv.Length - p < 4) goto done; forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); p += 4; } - // SidestepSpeed (0x20) — skip - if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } - // TurnSpeed (0x40) — skip - if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } + // SideStepSpeed (bit 0x10, float) + if ((flags & 0x10u) != 0) + { + if (mv.Length - p < 4) goto done; + sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); + p += 4; + } + // TurnSpeed (bit 0x40, float) + if ((flags & 0x40u) != 0) + { + if (mv.Length - p < 4) goto done; + turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); + p += 4; + } // Commands list: numCommands × 8-byte MotionItem (u16 cmd + // u16 packedSeq + f32 speed). One-shot actions, emotes, @@ -571,7 +630,9 @@ public static class CreateObject done:; } - return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands); + return new ServerMotionState( + currentStyle, forwardCommand, forwardSpeed, commands, + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index f4f1486..65791a7 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -123,6 +123,10 @@ public static class UpdateMotion ushort? forwardCommand = null; float? forwardSpeed = null; + ushort? sidestepCommand = null; + float? sidestepSpeed = null; + ushort? turnCommand = null; + float? turnSpeed = null; List? commands = null; if (movementType == 0) @@ -137,37 +141,68 @@ public static class UpdateMotion uint flags = packed & 0x7Fu; uint numCommands = packed >> 7; - // CurrentStyle (0x1) — prefer the InterpretedMotionState's copy - // if present, matching the CreateObject parser's behavior. + // Flag-bit layout + write order (ACE + // InterpretedMotionState.Write @ line 127 + MovementStateFlag + // enum — note the bit values are NOT in write order): + // CurrentStyle = 0x01 written first (ushort) + // ForwardCommand = 0x02 written second (ushort) + // SideStepCommand = 0x08 written third (ushort) + // TurnCommand = 0x20 written fourth (ushort) + // ForwardSpeed = 0x04 written fifth (float) + // SideStepSpeed = 0x10 written sixth (float) + // TurnSpeed = 0x40 written seventh (float) + // Our earlier version had the bit-to-field mapping wrong + // (treated Side/Turn commands as floats and ForwardSpeed as + // the wrong bit) — that's why every remote's ForwardSpeed + // was reading as "absent" (HasValue=False). + if ((flags & 0x1u) != 0) { if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } - - // ForwardCommand (0x2) if ((flags & 0x2u) != 0) { if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } - // SidestepCommand (0x4) — skip - if ((flags & 0x4u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; } - // TurnCommand (0x8) — skip - if ((flags & 0x8u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; } - // ForwardSpeed (0x10) - if ((flags & 0x10u) != 0) + // SideStepCommand — ushort, bit 0x8 + if ((flags & 0x8u) != 0) + { + if (body.Length - pos < 2) goto done; + sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + pos += 2; + } + // TurnCommand — ushort, bit 0x20 + if ((flags & 0x20u) != 0) + { + if (body.Length - pos < 2) goto done; + turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + pos += 2; + } + // ForwardSpeed — float, bit 0x4 + if ((flags & 0x4u) != 0) { if (body.Length - pos < 4) goto done; forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } - // SidestepSpeed (0x20) — skip - if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } - // TurnSpeed (0x40) — skip - if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } + // SideStepSpeed — float, bit 0x10 + if ((flags & 0x10u) != 0) + { + if (body.Length - pos < 4) goto done; + sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + // TurnSpeed — float, bit 0x40 + if ((flags & 0x40u) != 0) + { + if (body.Length - pos < 4) goto done; + turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } // Commands list: actions/emotes/attacks. Guard against a // malformed numCommands by capping at a sane max. @@ -187,7 +222,9 @@ public static class UpdateMotion done:; } - return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands)); + return new Parsed(guid, new CreateObject.ServerMotionState( + currentStyle, forwardCommand, forwardSpeed, commands, + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed)); } catch { diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 0a8249f..690d9ca 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -486,6 +486,34 @@ public sealed class AnimationSequencer if (yvel != 0f || xvel != 0f) CurrentVelocity = new Vector3(xvel, yvel, 0f); } + + // ── Synthesize CurrentOmega for turn cycles ─────────────────────── + // Same story as velocity synthesis above: Humanoid turn MotionData + // often ships without HasOmega. Retail clients turn the body via + // the baked omega, but if the dat is silent we fall back to the + // retail turn-rate constant. Decompile references: + // FUN_00529210 apply_current_movement (writes Omega) + // chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1) + // The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left + // turns (holtburger confirms the same via motion_resolution.rs). + if (CurrentOmega.LengthSquared() < 1e-9f) + { + float zomega = 0f; + uint low = motion & 0xFFu; + switch (low) + { + case 0x0D: // TurnRight — clockwise from above = -Z in right-handed. + zomega = -(MathF.PI / 2f) * adjustedSpeed; + break; + case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion + // may have remapped 0x0E → 0x0D with negated speed; + // in that case the negation preserves correct sign. + zomega = (MathF.PI / 2f) * adjustedSpeed; + break; + } + if (zomega != 0f) + CurrentOmega = new Vector3(0f, 0f, zomega); + } } // Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index d79ce03..08de618 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -113,7 +113,11 @@ public class UpdateMotionTests [Fact] public void ParsesForwardSpeed_WhenSpeedFlagSet() { - // Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13) + // Flags = CurrentStyle | ForwardCommand | ForwardSpeed + // = 0x1 | 0x2 | 0x4 = 0x7 + // (Per ACE MovementStateFlag enum — ForwardSpeed is bit 0x4, + // NOT 0x10. The earlier test had the wrong mapping; see + // references/ACE/Source/ACE.Entity/Enum/MovementStateFlag.cs) // Test value: 1.5× speed — matches a typical RunRate broadcast. var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4]; int p = 0; @@ -124,7 +128,7 @@ public class UpdateMotionTests body[p++] = 0; body[p++] = 0; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x7u); p += 4; BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed