diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b7a7d41..656ff93 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,6 +150,84 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _remoteLastMove = new(); + /// + /// Per-remote-entity dead-reckoning state for smoothing between server + /// UpdatePosition broadcasts. Without this, remote characters teleport + /// every ~100–200 ms when the server pushes a new position (the retail + /// client hides the gap by integrating CMotionInterp-surfaced + /// velocity forward each tick — see chunk_00520000.c + /// apply_current_movement L7132-L7189 and holtburger's + /// spatial/physics.rs::project_pose_by_velocity). + /// + /// + /// Each entry records the last authoritative server position + time + a + /// measured velocity inferred from the delta between consecutive + /// UpdatePositions. The client's per-tick integrator uses the + /// sequencer's CurrentVelocity (rotated into world space by the + /// entity's orientation) as the primary source and falls back to the + /// inferred velocity when the motion table doesn't carry one (e.g. NPC + /// motion tables with HasVelocity=0). + /// + /// + private readonly Dictionary _remoteDeadReckon = new(); + + private sealed class RemoteDeadReckonState + { + /// Last server-authoritative world position. + 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. + /// + public System.Numerics.Vector3 ObservedVelocity; + /// Server-supplied world velocity from UpdatePosition (HasVelocity flag). + public System.Numerics.Vector3? ServerVelocity; + + /// + /// 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; + } + + /// Soft-snap decay rate (1/sec). At this rate the residual + /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a + /// 2m residual fades within ~300ms without visible snap. + private const float SnapResidualDecayRate = 8.0f; + /// + /// When the prediction error exceeds this many meters, we treat the + /// 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. + /// + private const float SnapHardSnapThreshold = 5.0f; + + /// + /// Soft-snap window in seconds: after an UpdatePosition arrives for a + /// remote entity, dead-reckoning continues but the "origin" for + /// predicted position is the server pos. This matches retail's snap + /// behavior — the server is authoritative, we just interpolate between + /// authoritative samples. + /// + private const float DeadReckonMaxPredictSeconds = 1.0f; + // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. public readonly AcDream.Core.Chat.ChatLog Chat = new(); @@ -169,6 +247,7 @@ public sealed class GameWindow : IDisposable private bool _playerMode; private uint _playerServerGuid; private uint? _playerCurrentAnimCommand; + private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character // Accumulated mouse X delta for player turning; written in mouse-move // callback, consumed + reset in OnUpdate each frame. @@ -820,6 +899,12 @@ public sealed class GameWindow : IDisposable _animatedEntities.Remove(existingEntity.Id); // Physics collision registry entry is keyed by local id too. _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); + // Dead-reckon state is keyed by SERVER guid (not local id) so we + // clear using the same guid the new spawn will use. Leaving old + // SnapResidual / DeadReckonedPos in would make the next first + // UpdatePosition look like a 2m-residual soft-snap. + _remoteDeadReckon.Remove(spawn.Guid); + _remoteLastMove.Remove(spawn.Guid); } // Log every spawn that arrives so we can inventory what the server @@ -1399,13 +1484,80 @@ public sealed class GameWindow : IDisposable fullMotion = ae.Sequencer.CurrentMotion; } + // ForwardSpeed from the InterpretedMotionState (flag 0x10). + // 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 + // + // 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; + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" && update.Guid != _playerServerGuid) Console.WriteLine( - $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8})"); + $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})"); // No-op if same; the sequencer's fast path guards against that. - ae.Sequencer.SetCycle(fullStyle, fullMotion); + ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); + + // Route the Commands list — one-shot Actions, Modifiers, and + // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These + // live in the motion table's Links / Modifiers dicts, not + // Cycles, and are played on top of the current cycle via + // PlayAction which resolves the right dict and interleaves the + // action frames before the cyclic tail. + // + // A typical NPC wave looks like: + // ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}] + // [{0x0003=Ready, ...}] + // Each item runs through PlayAction (for 0x10/0x20 mask) or the + // standard SetCycle path (for 0x40 SubState). We leave SubState + // commands to fall through to the next UpdateMotion; that's how + // retail handles transition sequences (Wave → Ready). + if (update.MotionState.Commands is { Count: > 0 } cmds) + { + foreach (var item in cmds) + { + // Restore the 32-bit MotionCommand from the wire's 16-bit + // truncation by OR-ing class bits. The class is encoded + // in the low byte's high nibble via command ranges: + // 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx) + // 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx) + // 0x0051-0x00A1 — Action class (0x10xx xxxx) + // + // The retail MotionCommand enum carries the class byte in + // bits 24-31. DatReaderWriter's enum values match. For + // broadcasts, servers emit only low 16 bits (ACE + // InterpretedMotionState.cs:139). We reconstruct via a + // range-based lookup. See MotionCommand.generated.cs. + uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command); + if (fullCmd == 0) continue; + + // Action class: play through the link dict then drop back + // to the current cycle. Modifier class: resolve from the + // Modifiers dict and combine on top. SubState: cycle + // change; route through SetCycle so the style-specific + // cycle fallback applies. + uint cls = fullCmd & 0xFF000000u; + if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0 + || cls == 0x12000000u || cls == 0x13000000u) + { + ae.Sequencer.PlayAction(fullCmd, item.Speed); + } + else if ((cls & 0x40000000u) != 0) + { + // Substate in the command list — typically the "and + // then return to Ready" item. Update the cycle. + ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed); + } + // else: Style / UI / Toggle class — not animation-driving. + } + } return; } @@ -1463,6 +1615,12 @@ public sealed class GameWindow : IDisposable var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); + // Capture the pre-update render position for the soft-snap residual + // calculation below. Assign entity.Position to the server truth up + // front; if we then compute a snap residual, we restore the rendered + // position by adding the residual back (so the visual doesn't jerk + // for one frame before the residual decay kicks in on the next tick). + System.Numerics.Vector3 preSnapPos = entity.Position; entity.Position = worldPos; entity.Rotation = rot; @@ -1470,18 +1628,66 @@ public sealed class GameWindow : IDisposable // timestamp when position moved MEANINGFULLY (> 0.05m). Updates // that report the same position keep the old Time, so the // TickAnimations check can see when motion last changed. + // + // Also populate the dead-reckon state so TickAnimations can + // integrate velocity between server updates and avoid teleport jitter. + // Observed-velocity is computed from the position delta across + // consecutive updates — this is the fallback when the motion table's + // MotionData.Velocity is zero (NPCs without HasVelocity). if (update.Guid != _playerServerGuid) { + var now = System.DateTime.UtcNow; if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) { float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); if (moveDist > 0.05f) - _remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow); + _remoteLastMove[update.Guid] = (worldPos, now); // else: leave old entry so "Time" = last real movement time } else { - _remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow); + _remoteLastMove[update.Guid] = (worldPos, now); + } + + // Dead-reckon state: accumulate observed world-space velocity. + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState)) + { + drState = new RemoteDeadReckonState(); + _remoteDeadReckon[update.Guid] = drState; + } + else + { + float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds; + if (dtSec > 0.01f && dtSec < 1.0f) + { + // 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; + } + } + 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 } } @@ -3134,13 +3340,32 @@ public sealed class GameWindow : IDisposable // 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. - const double StopIdleMs = 400.0; + // + // 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; var now = System.DateTime.UtcNow; foreach (var kv in _animatedEntities) { 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. + 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 @@ -3153,24 +3378,162 @@ public sealed class GameWindow : IDisposable || motionLo == 0x07 // RunForward || motionLo == 0x0F // SideStepRight || motionLo == 0x10; // SideStepLeft - // Locate the server guid for this entity (reverse lookup). - // Skip the player's own entity — we drive our own anim locally. - uint serverGuid = 0; - foreach (var esg in _entitiesByServerGuid) - { - if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } - } if (inLocomotion && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteLastMove.TryGetValue(serverGuid, out var last) - && (now - last.Time).TotalMilliseconds > StopIdleMs) + && serverGuid != _playerServerGuid) { - uint curStyle = ae.Sequencer.CurrentStyle; - uint ready = (curStyle & 0xFF000000u) != 0 - ? ((curStyle & 0xFF000000u) | 0x01000003u) - : 0x41000003u; - ae.Sequencer.SetCycle(curStyle, ready); + 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 + // samples. Each tick we advance entity.Position by the + // sequencer's current velocity (rotated into world space by the + // entity's facing) — matching the retail client's + // apply_current_movement (chunk_00520000.c L7132-L7189) and + // holtburger's project_pose_by_velocity. + // + // The cap on predict-distance from the last server pos prevents + // runaway when the sequencer's velocity and the server's reality + // disagree (e.g. server is rubber-banding the entity). Retail + // uses a similar clamp at PhysicsObj::IsInterpolationComplete. + if (ae.Sequencer is not null + && serverGuid != 0 + && serverGuid != _playerServerGuid + && _remoteDeadReckon.TryGetValue(serverGuid, out var drState)) + { + System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero; + + // 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) + { + 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; + } + + 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; + } + } + } + + // 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); + } + } } } @@ -3343,9 +3706,16 @@ public sealed class GameWindow : IDisposable else animCommand = 0x41000003u; // Ready (idle) - // Fast path: no change. - if (animCommand == _playerCurrentAnimCommand) return; + // Fast path: no command change AND speed delta is negligible. If + // command is unchanged but speed changed, we must still propagate + // so the sequencer can MultiplyCyclicFramerate — keeping the run + // loop smooth without restart. + float newSpeed = result.ForwardSpeed ?? 1f; + bool sameCmd = animCommand == _playerCurrentAnimCommand; + bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f; + if (sameCmd && sameSpeed) return; _playerCurrentAnimCommand = animCommand; + _playerCurrentAnimSpeed = newSpeed; if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; @@ -3394,10 +3764,21 @@ public sealed class GameWindow : IDisposable // Sequencer path: SetCycle handles adjust_motion internally // (TurnLeft→TurnRight with negative speed, etc.) + // + // Speed scaling: use the MovementResult's ForwardSpeed for + // locomotion cycles. This mirrors what the server broadcasts for + // remote observers, and keeps our own character's animation rate + // in sync with movement velocity (a 1.5× RunRate player's anim + // plays 1.5× as fast — matching retail). if (ae.Sequencer is not null) { uint fullStyle = 0x80000000u | (uint)NonCombatStance; - ae.Sequencer.SetCycle(fullStyle, animCommand); + float animSpeed = 1f; + if (result.ForwardSpeed is { } fs && fs > 0f) + { + animSpeed = fs; + } + ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed); } // Legacy path: update the manual slerp fields (for entities without sequencer) diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index d08008d..6d3e918 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -109,7 +110,27 @@ 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. /// - public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null); + public readonly record struct ServerMotionState( + ushort Stance, + ushort? ForwardCommand, + float? ForwardSpeed = null, + IReadOnlyList? Commands = null); + + /// + /// One entry in the InterpretedMotionState's Commands list (MotionItem). + /// The server packs 0..many of these per broadcast: emotes, attacks, + /// and other one-shot motions arrive here, not in ForwardCommand. + /// + /// Wire layout (see ACE Network/Motion/MotionItem.cs): + /// u16 command — low 16 bits of MotionCommand (Action class + /// typically 0x10xx; ChatEmote 0x13xx) + /// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp + /// f32 speed — speedMod for the animation + /// + public readonly record struct MotionItem( + ushort Command, + ushort PackedSequence, + float Speed); /// /// Server instruction to replace the surface texture at @@ -480,6 +501,7 @@ public static class CreateObject ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; // 0 = Invalid is the only union variant we care about for static // entities. Walking/turning entities use the other variants but @@ -488,21 +510,20 @@ public static class CreateObject if (movementType == 0) { // InterpretedMotionState: u32 (flags | numCommands<<7), then - // each present field in flag order. We only care about - // ForwardCommand, so read in order and stop early if we - // can't get that far. + // each present field in flag order. Flag bits (low 7) are + // CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is + // the MotionItem list length that follows after the speed + // fields (see ACE InterpretedMotionState.cs::Write). if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p)); p += 4; uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits + uint numCommands = packed >> 7; // CurrentStyle (0x1) if ((flags & 0x1u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); - // The InterpretedMotionState's CurrentStyle is just a copy - // of MovementData.CurrentStyle per ACE source. Read and - // prefer it as the more specific value. currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } @@ -525,10 +546,32 @@ public static class CreateObject 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; } + + // Commands list: numCommands × 8-byte MotionItem (u16 cmd + + // u16 packedSeq + f32 speed). One-shot actions, emotes, + // attacks — everything that's NOT a looping cycle change + // arrives here. Cap read at the buffer boundary. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (mv.Length - p < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4)); + p += 8; + commands.Add(new MotionItem(cmd, seq, speed)); + } + } done:; } - return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed); + return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 5a74e76..f4f1486 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -122,17 +123,19 @@ public static class UpdateMotion ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; if (movementType == 0) { // InterpretedMotionState — same layout as in CreateObject's // MovementInvalid branch, just reached via the header'd path. - // Only ForwardCommand is pulled out; the rest is deliberately - // ignored because the animation system consumes nothing else. + // Includes the Commands list (MotionItem[]) that carries + // Actions, emotes, and other one-shots not in ForwardCommand. if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; + uint numCommands = packed >> 7; // CurrentStyle (0x1) — prefer the InterpretedMotionState's copy // if present, matching the CreateObject parser's behavior. @@ -161,10 +164,30 @@ public static class UpdateMotion 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; } + + // Commands list: actions/emotes/attacks. Guard against a + // malformed numCommands by capping at a sane max. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (body.Length - pos < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)); + pos += 8; + commands.Add(new CreateObject.MotionItem(cmd, seq, speed)); + } + } done:; } - return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed)); + return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands)); } catch { diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index d9ba4ca..4d77ef2 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -121,6 +121,26 @@ internal sealed class AnimNode Omega = omega; } + // ── FUN_005267E0 — multiply_framerate ───────────────────────────────── + // Scales this node's framerate by a factor. Used by + // AnimationSequencer.MultiplyCyclicFramerate to retarget an already-queued + // cyclic animation at a new playback speed without restarting. + // + // Retail's implementation additionally swapped StartFrame↔EndFrame for a + // negative factor (so the forward-playback advance loop could traverse + // either direction), but acdream's AnimNode keeps StartFrame ≤ EndFrame + // as an invariant and encodes direction purely via Framerate's sign — the + // Advance loop then checks against StartFrame as the lower bound for + // negative delta. So here we only scale. + // + // Mirrors ACE AnimSequenceNode.multiply_framerate / Sequence.cs L277-L287 + // modulo the swap difference. Valid because the callers we care about + // (ForwardSpeed updates from UpdateMotion) only ever pass positive factors. + public void MultiplyFramerate(double factor) + { + Framerate *= factor; + } + // ── FUN_00526880 — GetStartFramePosition ────────────────────────────── // Returns the initial framePosition cursor for this node. // speedScale >= 0 → (double)startFrame @@ -201,21 +221,34 @@ public sealed class AnimationSequencer public uint CurrentMotion { get; private set; } /// - /// World-space per-second velocity from the currently active - /// (Sequence.Velocity in retail). Zero when no - /// motion data carries a velocity. Scaled by speedMod at enqueue - /// time. + /// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 + /// and is updated by when the same motion is + /// re-issued with a different speed (which triggers + /// instead of a cycle restart). /// - public Vector3 CurrentVelocity => - _currNode?.Value.Velocity ?? Vector3.Zero; + public float CurrentSpeedMod { get; private set; } = 1f; /// - /// Radians-per-second omega (axis-angle integration rate) from the - /// currently active . Scaled by speedMod - /// at enqueue time. + /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field. + /// Updated each time a MotionData is appended or combined — reflects the + /// MOST RECENT MotionData's velocity × speedMod, matching + /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130, + /// MotionTable.add_motion L358-L370). + /// + /// + /// Crucially this is **not** per-node: while a link animation plays, the + /// surfaced velocity is still the cycle's velocity (the cycle was added + /// last, so SetVelocity's latest call wins). Remote entity dead-reckoning + /// reads this to integrate position without gapping during stance + /// transitions. + /// /// - public Vector3 CurrentOmega => - _currNode?.Value.Omega ?? Vector3.Zero; + public Vector3 CurrentVelocity { get; private set; } + + /// + /// Sequence-wide omega, matching 's semantics. + /// + public Vector3 CurrentOmega { get; private set; } // Diagnostics public int QueueCount => _queue.Count; @@ -313,10 +346,26 @@ public sealed class AnimationSequencer break; } - // Fast-path: already playing this exact motion at the same speed. + // Fast-path: already playing this exact motion. + // + // Retail (ACE MotionTable.cs:132-139): when motion == current and + // sign(speedMod) matches, DON'T restart the cycle — just rescale the + // in-flight cyclic-tail's framerate via multiply_cyclic_animation_framerate. + // This keeps the run/walk loop smooth when a new UpdateMotion arrives + // with a different ForwardSpeed (e.g. when the server broadcasts a + // player's updated RunRate mid-step). if (CurrentStyle == style && CurrentMotion == motion && _firstCyclic != null && _queue.Count > 0) + { + if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f + && MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod) + && MathF.Abs(CurrentSpeedMod) > 1e-6f) + { + MultiplyCyclicFramerate(speedMod / CurrentSpeedMod); + CurrentSpeedMod = speedMod; + } return; + } // Resolve transition link (currentSubstate → adjustedMotion). MotionData? linkData = CurrentMotion != 0 @@ -331,6 +380,11 @@ public sealed class AnimationSequencer // been played yet (ACE behaviour: non-cyclic anims drain naturally). ClearCyclicTail(); + // Clear sequence-wide physics before the rebuild. Retail's + // GetObjectSequence calls sequence.clear_physics() before each + // add_motion chain (MotionTable.cs L100-L101, L152-L153). + ClearPhysics(); + // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); @@ -371,6 +425,45 @@ public sealed class AnimationSequencer CurrentStyle = style; CurrentMotion = motion; + CurrentSpeedMod = speedMod; + } + + /// + /// Scale every cyclic node's framerate by , mirroring + /// ACE's Sequence.multiply_cyclic_animation_framerate + /// (references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287, + /// retail decompile FUN_00525CE0). Walks _firstCyclic through + /// the tail of the queue and calls + /// on each. The non-cyclic head (link frames) is untouched — those drain + /// at their original framerate, which matches retail: the sequencer + /// "catches up" the transition before applying the new run speed. + /// + /// + /// Called from when the same (style, motion) pair + /// is re-issued with a different speedMod — for instance, when a remote + /// player's ForwardSpeed changes mid-run. Does NOT restart the animation, + /// so footsteps keep planting where they are. + /// + /// + /// Framerate multiplier (newSpeed / oldSpeed). + public void MultiplyCyclicFramerate(float factor) + { + if (_firstCyclic == null) return; + if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor)) + return; + + for (var node = _firstCyclic; node != null; node = node.Next) + { + node.Value.MultiplyFramerate((double)factor); + } + + // Sequence-wide velocity/omega scale too. Retail's flow is + // subtract_motion(oldSpeed) + combine_motion(newSpeed) in + // MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which + // algebraically equals scaling by newSpeed/oldSpeed — exactly + // what the factor represents here. + CurrentVelocity *= factor; + CurrentOmega *= factor; } /// @@ -654,6 +747,9 @@ public sealed class AnimationSequencer _rootMotionRot = Quaternion.Identity; CurrentStyle = 0; CurrentMotion = 0; + CurrentSpeedMod = 1f; + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; } // ── Private helpers ────────────────────────────────────────────────────── @@ -745,6 +841,17 @@ public sealed class AnimationSequencer omega); } + /// + /// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics, + /// ACE Sequence.cs L256-L260). Called before a style-transition rebuild + /// in SetCycle so we don't inherit velocity from the previous cycle. + /// + private void ClearPhysics() + { + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; + } + /// /// Append all AnimData entries from to the /// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the @@ -758,6 +865,23 @@ public sealed class AnimationSequencer Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega) ? motionData.Omega * speedMod : Vector3.Zero; + // Sequence-wide velocity/omega update, matching ACE's + // MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity + // REPLACES the previous sequence velocity. When SetCycle enqueues + // link then cycle, the final CurrentVelocity is the cycle's — which + // is what dead-reckoning needs to read from the first frame of the + // link transition (the cycle velocity is already "queued up" even + // while a zero-velocity link plays visually). + // + // Only replace if HasVelocity (else we'd zero out a running cycle + // when a transient HasVelocity=0 modifier enqueues). Matches + // retail's conditional behavior: MotionData without HasVelocity + // doesn't touch the sequence velocity. + if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)) + CurrentVelocity = vel; + if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega)) + CurrentOmega = omg; + for (int i = 0; i < motionData.Anims.Count; i++) { bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1); diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs new file mode 100644 index 0000000..1a0a3e2 --- /dev/null +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Physics; + +/// +/// Reconstructs the 32-bit retail value from +/// a 16-bit wire value broadcast in InterpretedMotionState.Commands[]. +/// +/// +/// The server serializes MotionCommands as u16 (ACE +/// InterpretedMotionState.cs:139), truncating the class byte (Style / +/// SubState / Modifier / Action / ChatEmote / UI / Toggle / Mappable / +/// Command — see r03 §3.1). The client must re-attach the class byte before +/// routing the command into the motion table, because the same low 16 bits +/// can map to different classes (e.g. 0x0003 is Ready as a SubState, +/// but there's no other 0x0003). +/// +/// +/// +/// This is implemented as an eager lookup table built from all values of +/// via reflection. If the wire value matches +/// more than one enum value (different class bits), we prefer the +/// lowest-class-numbered variant that has a non-zero class byte — roughly +/// matching retail priority (Action < Modifier < SubState < Style). +/// +/// +/// +/// Cited references: +/// +/// +/// references/ACE/Source/ACE.Server/Network/Motion/InterpretedMotionState.cs::Write +/// L138-L144 — writer emits u16 for every command field. +/// +/// +/// references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs — the +/// class bit assignments: 0x80=Style, 0x40=SubState, 0x20=Modifier, +/// 0x10=Action, 0x13 and 0x12=ChatEmote (with Mappable set), etc. +/// +/// +/// docs/research/deepdives/r03-motion-animation.md §3 — complete +/// command catalogue. +/// +/// +/// +/// +public static class MotionCommandResolver +{ + // Lookup table built eagerly at type-init. Sparse: only values that + // appear in the DRW enum (which came from the generated protocol XML) + // are present. ~450 entries typical. + private static readonly Dictionary s_lookup = BuildLookup(); + + /// + /// Given a 16-bit wire value, return the full 32-bit MotionCommand + /// (class byte restored). Returns 0 if no matching enum value exists. + /// + public static uint ReconstructFullCommand(ushort wireCommand) + { + if (wireCommand == 0) return 0u; + s_lookup.TryGetValue(wireCommand, out var full); + return full; + } + + private static Dictionary BuildLookup() + { + var result = new Dictionary(512); + var values = Enum.GetValues(typeof(DRWMotionCommand)); + foreach (DRWMotionCommand v in values) + { + uint full = (uint)v; + ushort lo = (ushort)(full & 0xFFFFu); + if (lo == 0) continue; // Invalid / unmappable + + // If a value with this low-16-bit already exists, keep the one + // with the lower class byte (Action=0x10 beats SubState=0x41 + // beats Style=0x80). This matches retail: the server tends to + // emit Actions and ChatEmotes far more often than Styles, so + // the Action-class reconstruction is the common case. + if (!result.TryGetValue(lo, out var existing) + || (full >> 24) < (existing >> 24)) + { + result[lo] = full; + } + } + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 83b0ab4..d79ce03 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -110,6 +110,74 @@ public class UpdateMotionTests Assert.Null(result.Value.MotionState.ForwardCommand); } + [Fact] + public void ParsesForwardSpeed_WhenSpeedFlagSet() + { + // Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13) + // 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; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // MovementData header + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); 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 + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); + Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed); + } + + [Fact] + public void ParsesCommandsList_Wave() + { + // A typical NPC wave broadcast: + // - stance NonCombat (0x003D) + // - ForwardCommand flag set, command = 0x0003 (Ready) + // - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 } + // + // Packed u32 = (flags | numCommands << 7) + // flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03 + // numCommands << 7 = 1 << 7 = 0x80 + // total = 0x83 + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1 + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready + + // MotionItem: u16 command + u16 packedSeq + f32 speed + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand); + + Assert.NotNull(result.Value.MotionState.Commands); + Assert.Single(result.Value.MotionState.Commands!); + var wave = result.Value.MotionState.Commands![0]; + Assert.Equal((ushort)0x0087, wave.Command); + Assert.Equal(1.0f, wave.Speed); + } + [Fact] public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() { diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 6b278c8..ac492dd 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -979,6 +979,452 @@ public sealed class AnimationSequencerTests Assert.Contains(hooks, h => h is AnimationDoneHook); } + // ── MultiplyCyclicFramerate / speed-mod tracking ───────────────────────── + + [Fact] + public void MultiplyCyclicFramerate_DoublesPlaybackRate() + { + // A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback + // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). + const uint Style = 0x003Du; + const uint Motion = 0x0007u; // RunForward + const uint AnimId = 0x03000401u; + + // Unique per-frame Z so we can tell where the cursor lands. + var anim = new Animation(); + for (int f = 0; f < 10; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = 9, + Framerate = 10f, + }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + + // Halve the playback rate. + seq.MultiplyCyclicFramerate(0.5f); + + // 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5. + seq.Advance(1.0f); + var frames = seq.Advance(0.001f); + Assert.Single(frames); + Assert.InRange(frames[0].Origin.Z, 4f, 6f); + + // Velocity also scales: originally (0,4,0), now (0,2,0). + Assert.Equal(2f, seq.CurrentVelocity.Y, 1); + } + + [Fact] + public void MultiplyCyclicFramerate_PreservesCursorPosition() + { + // Changing speed mid-cycle must NOT reset the frame cursor — the + // animation keeps playing from where it was, just faster/slower. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000402u; + + var anim = new Animation(); + for (int f = 0; f < 10; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.Advance(0.3f); // cursor ~ frame 3 + double before = GetFramePosition(seq); + + seq.MultiplyCyclicFramerate(2.0f); + double after = GetFramePosition(seq); + + Assert.Equal(before, after, 5); + } + + [Fact] + public void SetCycle_SameMotionDifferentSpeed_RescalesInPlace() + { + // Re-issuing SetCycle with the same motion but a new speedMod must + // NOT reset the cursor — it should call MultiplyCyclicFramerate to + // keep the run loop smooth (retail behavior for a mid-run RunRate + // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000403u; + + var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + seq.Advance(0.3f); + double cursorMid = GetFramePosition(seq); + + Assert.Equal(1f, seq.CurrentSpeedMod, 3); + + // Re-issue with 2× speed — should rescale in place. + seq.SetCycle(Style, Motion, speedMod: 2f); + + Assert.Equal(2f, seq.CurrentSpeedMod, 3); + Assert.Equal(cursorMid, GetFramePosition(seq), 5); + } + + [Fact] + public void CurrentVelocity_ScalesWithSpeedMod() + { + // A RunForward motion with MotionData.Velocity = (0,4,0) should + // surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at + // 0.5×. The dead-reckoning integrator in TickAnimations reads + // CurrentVelocity each tick, so this has to be accurate. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000405u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + + var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = -1, + Framerate = 10f, + }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + Assert.Equal(4f, seq.CurrentVelocity.Y, 3); + + // Start a fresh sequencer so the initial SetCycle applies speedMod. + var seq2 = new AnimationSequencer(setup, mt, loader); + seq2.SetCycle(Style, Motion, speedMod: 1.5f); + Assert.Equal(6f, seq2.CurrentVelocity.Y, 3); + + // Same-motion rescale path also updates velocity. + seq2.SetCycle(Style, Motion, speedMod: 0.5f); + Assert.Equal(2f, seq2.CurrentVelocity.Y, 2); + } + + [Fact] + public void SetCycle_SameMotionSameSpeed_StaysNoOp() + { + // Guard: the new speed-path must not break the classic + // "identical call = no state change" behavior. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000404u; + + var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1.5f); + seq.Advance(0.2f); + double before = GetFramePosition(seq); + + seq.SetCycle(Style, Motion, speedMod: 1.5f); + + Assert.Equal(before, GetFramePosition(seq), 5); + Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); + } + + [Fact] + public void CurrentOmega_ReflectsMotionDataOmega() + { + // A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw) + // should surface as CurrentOmega = (0, 0, 1) after SetCycle. + // Scales with speedMod exactly like Velocity. + const uint Style = 0x003Du; + const uint Motion = 0x000Du; // TurnRight + const uint AnimId = 0x03000701u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + + var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 2f); + + // Omega scales by speedMod — 1.0 × 2 = 2 rad/sec. + Assert.Equal(2.0f, seq.CurrentOmega.Z, 3); + } + + [Fact] + public void CurrentVelocity_PersistsThroughLinkTransition() + { + // Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity): + // sequence.Velocity is REPLACED by the most-recent MotionData's + // velocity. When SetCycle enqueues [link][cycle], after the final + // add_motion the velocity is the cycle's velocity — ALREADY. + // So even while the link animation plays visually, dead-reckoning + // reads the cycle's run-speed and moves the entity smoothly. + // Crucial: otherwise remote entities would stutter at every stance + // transition while the link plays. + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; + const uint CycleAnim = 0x03000601u; + const uint LinkAnim = 0x03000602u; + + var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion; + + int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu)); + var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) }; + QualifiedDataId cycleQid = CycleAnim; + cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + mt.Cycles[cycleKey] = cycleMd; + + // Link from idle → walk. Link MotionData has no velocity (typical). + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var linkCmdData = new MotionCommandData(); + var linkMd = new MotionData(); // no HasVelocity flag + QualifiedDataId linkQid = LinkAnim; + linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + linkCmdData.MotionData[(int)WalkMotion] = linkMd; + mt.Links[linkOuter] = linkCmdData; + + var loader = new FakeLoader(); + loader.Register(CycleAnim, cycleAnim); + loader.Register(LinkAnim, linkAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + SetCurrentMotion(seq, Style, IdleMotion); + seq.SetCycle(Style, WalkMotion); + + // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is + // the link, but CurrentVelocity reflects the most recent + // SetVelocity call — the cycle's. So velocity is 3.12 even before + // the link plays out. + Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); + + // Advance past the link frames (2 frames at 10fps = 0.2s). + seq.Advance(0.25f); + + // Still 3.12 — cycle is now current. + Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); + } + + // ── PlayAction: Action / Modifier / ChatEmote routing ─────────────────── + + [Fact] + public void PlayAction_Action_ResolvesFromLinksDict() + { + // An Action-class command (mask 0x10) resolves via the Links dict + // keyed by (style, currentSubstate) → motion. Example: a ThrustMed + // attack while in SwordCombat stance. + const uint Style = 0x003Eu; // SwordCombat + const uint IdleMotion = 0x41000003u; // Ready + const uint ActionMotion = 0x10000058u; // ThrustMed (Action class) + const uint IdleAnimId = 0x03000501u; + const uint ActionAnimId= 0x03000502u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + // Action anim: distinct non-zero origin so we can detect it played. + var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Link: (SwordCombat, Ready) → ThrustMed + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var cmdData = new MotionCommandData(); + cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f); + mt.Links[linkOuter] = cmdData; + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(ActionAnimId, actionAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + seq.Advance(0.01f); // burn the first idle frame + + // Fire the action. + seq.PlayAction(ActionMotion); + + // After a small advance, we should be reading the action anim (origin X=99). + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(99f, fr[0].Origin.X, 1); + } + + [Fact] + public void PlayAction_Modifier_ResolvesFromModifiersDict() + { + // A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — + // resolves from the Modifiers dict, first with style-specific key + // then with unstyled fallback. Empirically: the modifier's anim + // plays on top of the current cycle. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint JumpMotion = 0x2500003Bu; // Modifier class + const uint IdleAnimId = 0x03000510u; + const uint JumpAnimId = 0x03000511u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Modifier: (Style, Jump) + int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); + mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(JumpAnimId, jumpAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + + seq.PlayAction(JumpMotion); + + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(77f, fr[0].Origin.Z, 1); + } + + [Fact] + public void PlayAction_Emote_RoutesThroughActionBranch() + { + // ChatEmotes like Wave (0x13000087) have class byte 0x13 = + // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the + // Action bit is set, they route through the Links-dict lookup just + // like attacks. Verifies the class-bit math. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint WaveMotion = 0x13000087u; + const uint IdleAnimId = 0x03000520u; + const uint WaveAnimId = 0x03000521u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Register Links[(style, Ready)][Wave] = wave anim. + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var cmdData = new MotionCommandData(); + cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f); + mt.Links[linkOuter] = cmdData; + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(WaveAnimId, waveAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + + seq.PlayAction(WaveMotion); + + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(55f, fr[0].Origin.Y, 1); + } + + [Fact] + public void PlayAction_NoEntryInTable_IsNoOp() + { + // If neither Links nor Modifiers has the motion, PlayAction should + // silently return without disturbing the current cycle. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint IdleAnimId = 0x03000530u; + const uint UnknownAction = 0x10001234u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + seq.Advance(0.05f); + int queueBefore = seq.QueueCount; + + seq.PlayAction(UnknownAction); // unknown motion → no-op + + Assert.Equal(queueBefore, seq.QueueCount); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// Expose _framePosition (double) via reflection (test-only). diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs new file mode 100644 index 0000000..a233b02 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13, +/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer +/// routes commands to the wrong MotionTable dict and NPC emotes/attacks +/// silently fail. +/// +public class MotionCommandResolverTests +{ + [Theory] + // SubState / Ready / Movement commands + [InlineData(0x0003, 0x41000003u)] // Ready + [InlineData(0x0005, 0x45000005u)] // WalkForward + [InlineData(0x0007, 0x44000007u)] // RunForward + [InlineData(0x0006, 0x45000006u)] // WalkBackward + [InlineData(0x000D, 0x6500000Du)] // TurnRight + [InlineData(0x000E, 0x6500000Eu)] // TurnLeft + [InlineData(0x000F, 0x6500000Fu)] // SideStepRight + [InlineData(0x0015, 0x40000015u)] // Falling + // Action-class one-shots: melee attacks, death, portals + [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) + [InlineData(0x0058, 0x10000058u)] // ThrustMed + [InlineData(0x005B, 0x1000005Bu)] // SlashHigh + [InlineData(0x0061, 0x10000061u)] // Shoot + [InlineData(0x004B, 0x1000004Bu)] // Jumpup + [InlineData(0x0050, 0x10000050u)] // FallDown + // ChatEmotes (class 0x13) + [InlineData(0x0087, 0x13000087u)] // Wave + [InlineData(0x0080, 0x13000080u)] // Laugh + [InlineData(0x007D, 0x1300007Du)] // BowDeep + public void ReconstructsKnownCommands(ushort wire, uint expected) + { + uint got = MotionCommandResolver.ReconstructFullCommand(wire); + Assert.Equal(expected, got); + } + + [Fact] + public void ZeroWireReturnsZero() + { + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0)); + } + + [Fact] + public void UnknownWireReturnsZero() + { + // 0xFFFF is not a real MotionCommand low-16. + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF)); + } +}