feat(anim): full retail remote-entity motion port — walk/run/strafe/turn/stop
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) <noreply@anthropic.com>
This commit is contained in:
parent
00c8a4feb5
commit
340dabbc72
5 changed files with 506 additions and 303 deletions
|
|
@ -170,42 +170,62 @@ public sealed class GameWindow : IDisposable
|
|||
/// motion tables with HasVelocity=0).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, RemoteDeadReckonState> _remoteDeadReckon = new();
|
||||
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
|
||||
|
||||
private sealed class RemoteDeadReckonState
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
/// retail's client-side motion pipeline to every remote. Mirrors
|
||||
/// retail <c>FUN_00515020</c> <c>update_object</c> → <c>FUN_00513730</c>
|
||||
/// <c>UpdatePositionInternal</c> → <c>FUN_005111D0</c>
|
||||
/// <c>UpdatePhysicsInternal</c>, and ACE's <c>PhysicsObj.cs</c> port.
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
|
||||
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
|
||||
/// remote gets the same treatment as the local player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class RemoteMotion
|
||||
{
|
||||
/// <summary>Last server-authoritative world position.</summary>
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
public AcDream.Core.Physics.MotionInterpreter Motion;
|
||||
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
|
||||
public double LastServerPosTime;
|
||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <summary>When that last server position arrived (UTC).</summary>
|
||||
public System.DateTime LastServerPosTime;
|
||||
/// <summary>Last server-authoritative world rotation.</summary>
|
||||
public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ObservedVelocity;
|
||||
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
|
||||
public System.Numerics.Vector3? ServerVelocity;
|
||||
public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity;
|
||||
/// <summary>
|
||||
/// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed
|
||||
/// (π/2 × turnSpeed, signed). Applied per tick to body orientation
|
||||
/// via manual integration (bypassing <c>PhysicsBody.update_object</c>'s
|
||||
/// MinQuantum 30fps gate that would otherwise skip most ticks).
|
||||
/// Zeroed on UM with TurnCommand absent.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ObservedOmega;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 DeadReckonedPos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.
|
||||
///
|
||||
/// <para>
|
||||
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
|
||||
/// <c>PhysicsObj.cs:545</c>): 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private const float SnapHardSnapThreshold = 5.0f;
|
||||
private const float SnapHardSnapThreshold = 20.0f;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
|||
/// </summary>
|
||||
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 ──
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Per ACE <c>InterpretedMotionState.Write</c> (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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ServerMotionState(
|
||||
ushort Stance,
|
||||
ushort? ForwardCommand,
|
||||
float? ForwardSpeed = null,
|
||||
IReadOnlyList<MotionItem>? Commands = null);
|
||||
IReadOnlyList<MotionItem>? Commands = null,
|
||||
ushort? SideStepCommand = null,
|
||||
float? SideStepSpeed = null,
|
||||
ushort? TurnCommand = null,
|
||||
float? TurnSpeed = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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<MotionItem>? 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<CreateObject.MotionItem>? 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue