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).
|
/// motion tables with HasVelocity=0).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </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;
|
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>
|
/// <summary>
|
||||||
/// Most recently observed position-delta-based world velocity, used
|
/// Legacy field — no longer used for slerp (retail hard-snaps
|
||||||
/// as fallback when the sequencer has no CurrentVelocity. Computed
|
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
||||||
/// as (pos_new - pos_old) / dt across consecutive UpdatePositions.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public System.Numerics.Vector3 ObservedVelocity;
|
public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity;
|
||||||
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
|
/// <summary>
|
||||||
public System.Numerics.Vector3? ServerVelocity;
|
/// 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>
|
public RemoteMotion()
|
||||||
/// Internal dead-reckoned position: the authoritative server pos plus
|
{
|
||||||
/// velocity*dt integration since the last update. Each tick this
|
Body = new AcDream.Core.Physics.PhysicsBody
|
||||||
/// advances; on UpdatePosition it resets to the new server pos.
|
{
|
||||||
/// Separated from the publicly visible Entity.Position so the
|
// Remotes don't simulate gravity — server owns Z. Force
|
||||||
/// residual-decay logic doesn't mix with the integration state.
|
// Contact + OnWalkable + Active so apply_current_movement
|
||||||
/// </summary>
|
// writes velocity through every tick (the gate in
|
||||||
public System.Numerics.Vector3 DeadReckonedPos;
|
// MotionInterpreter.apply_current_movement is
|
||||||
|
// PhysicsObj.OnWalkable).
|
||||||
/// <summary>
|
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
|
||||||
/// Residual offset the renderer is blending out. When UpdatePosition
|
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
/// arrives, we compute (lastRenderedPos - newServerPos) and store it
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
/// here; each tick the offset decays toward zero while the entity's
|
| AcDream.Core.Physics.TransientStateFlags.Active,
|
||||||
/// displayed position = DeadReckonedPos + residual. This hides a
|
};
|
||||||
/// sudden teleport when the dead-reckoner and server disagreed.
|
Motion = new AcDream.Core.Physics.MotionInterpreter(Body);
|
||||||
/// </summary>
|
}
|
||||||
public System.Numerics.Vector3 SnapResidual;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
|
/// <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).
|
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
|
||||||
/// Prevents the soft-snap logic from trying to smooth a genuine portal
|
/// Prevents the soft-snap logic from trying to smooth a genuine portal
|
||||||
/// or force-move event.
|
/// 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>
|
/// </summary>
|
||||||
private const float SnapHardSnapThreshold = 5.0f;
|
private const float SnapHardSnapThreshold = 20.0f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
|
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
|
||||||
|
|
@ -1603,56 +1633,50 @@ public sealed class GameWindow : IDisposable
|
||||||
? (0x80000000u | (uint)stance)
|
? (0x80000000u | (uint)stance)
|
||||||
: ae.Sequencer.CurrentStyle;
|
: ae.Sequencer.CurrentStyle;
|
||||||
|
|
||||||
// If the server told us a command, use it. If command is 0,
|
// ACE's stop signal: ForwardCommand flag CLEARED on the wire.
|
||||||
// that's "stop / return to idle"; translate that into the
|
// Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
|
||||||
// style's default (typically Ready, 0x41000003 for NonCombat).
|
// when the player releases keys the InterpretedMotionState has
|
||||||
// If command is null ("not updated"), keep current motion.
|
// 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;
|
uint fullMotion;
|
||||||
if (command.HasValue)
|
if (!command.HasValue || command.Value == 0)
|
||||||
{
|
{
|
||||||
if (command.Value == 0)
|
// Stop — return to the style's default substate (Ready).
|
||||||
{
|
fullMotion = 0x41000003u;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
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
|
// ACE omits this field when speed == 1.0 (only sets the flag
|
||||||
// when ForwardSpeed != 1.0 — see InterpretedMotionState.cs
|
// when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
|
||||||
// BuildMovementFlags L101-L103). So:
|
// So:
|
||||||
// - omitted / 0 → 1.0 (normal speed)
|
// - field absent → default 1.0 (normal speed)
|
||||||
// - present → retail server-broadcast speedMod
|
// - field present → USE THE VALUE, including zero.
|
||||||
//
|
//
|
||||||
// The sequencer's SetCycle fast-paths identical (style, motion)
|
// Zero is a VALID stop signal: when the retail client releases
|
||||||
// pairs and calls MultiplyCyclicFramerate when only speedMod
|
// W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
|
||||||
// changed — keeping the loop smooth during a mid-run RunRate
|
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
||||||
// broadcast.
|
// produces "slow walk that never stops" — exactly what the
|
||||||
float speedMod = update.MotionState.ForwardSpeed is { } fs && fs > 0f ? fs : 1f;
|
// stop bug looked like.
|
||||||
|
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||||
|
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||||
&& update.Guid != _playerServerGuid)
|
&& update.Guid != _playerServerGuid)
|
||||||
|
|
@ -1685,7 +1709,116 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
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),
|
// 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))
|
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
||||||
_remoteLastMove[update.Guid] = (prev.Pos, refreshedTime);
|
_remoteLastMove[update.Guid] = (prev.Pos, refreshedTime);
|
||||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
|
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
|
// Route the Commands list — one-shot Actions, Modifiers, and
|
||||||
|
|
@ -1862,46 +1995,75 @@ public sealed class GameWindow : IDisposable
|
||||||
_remoteLastMove[update.Guid] = (worldPos, now);
|
_remoteLastMove[update.Guid] = (worldPos, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dead-reckon state: accumulate observed world-space velocity.
|
// Retail-faithful hard-snap on UpdatePosition.
|
||||||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState))
|
// 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();
|
rmState = new RemoteMotion();
|
||||||
_remoteDeadReckon[update.Guid] = drState;
|
_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;
|
rmState.Body.Velocity = svel;
|
||||||
if (dtSec > 0.01f && dtSec < 1.0f)
|
// 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
|
rmState.Motion.StopCompletely();
|
||||||
// overwrite the running average. alpha=0.5 converges fast
|
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||||||
// but resists single-frame noise.
|
&& aeForStop.Sequencer is not null)
|
||||||
var observed = (worldPos - drState.LastServerPos) / dtSec;
|
{
|
||||||
drState.ObservedVelocity = 0.5f * drState.ObservedVelocity + 0.5f * observed;
|
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
|
entity.Position = rmState.Body.Position;
|
||||||
// the authoritative position, convert the error into a residual
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B.3: portal-space arrival detection.
|
// Phase B.3: portal-space arrival detection.
|
||||||
|
|
@ -3646,21 +3808,16 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void TickAnimations(float dt)
|
private void TickAnimations(float dt)
|
||||||
{
|
{
|
||||||
// Stop-detection window: if a remote entity is in a locomotion
|
// Retail has NO stop-detection heuristic — it relies on the
|
||||||
// cycle but hasn't moved meaningfully in this many ms, swap them
|
// server sending explicit UpdateMotion(Ready). The server-side
|
||||||
// to Ready. Retail observer pattern — server never broadcasts an
|
// stop signal flows the same way as any other motion change:
|
||||||
// explicit stop; observer infers from position deltas.
|
// alt releases W → MoveToState(Ready) → ACE broadcasts
|
||||||
//
|
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
|
||||||
// 300ms matches the interval between typical server-broadcast
|
// On our side, both are handled: UpdateMotion routes through
|
||||||
// UpdatePositions for a stationary NPC (~3-5 Hz heartbeat). Any
|
// MotionInterpreter.DoInterpretedMotion which zeroes the
|
||||||
// shorter and we'd false-positive between packets; longer and the
|
// interpreter's state, and UpdatePosition's HasVelocity~0 hits
|
||||||
// stop animation lags visibly.
|
// MotionInterpreter.StopCompletely. Anything else is server
|
||||||
const double StopIdleMs = 300.0;
|
// buggery (packet loss, ACE bug) — don't guess client-side.
|
||||||
// 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;
|
var now = System.DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var kv in _animatedEntities)
|
foreach (var kv in _animatedEntities)
|
||||||
|
|
@ -3668,68 +3825,14 @@ public sealed class GameWindow : IDisposable
|
||||||
var ae = kv.Value;
|
var ae = kv.Value;
|
||||||
|
|
||||||
// Locate the server guid for this entity once per tick — needed
|
// Locate the server guid for this entity once per tick — needed
|
||||||
// for both stop-detection and dead-reckoning. O(N) reverse
|
// for dead-reckoning. O(N) reverse lookup; for player populations
|
||||||
// lookup; for player populations < 100 the cost is negligible.
|
// < 100 the cost is negligible.
|
||||||
uint serverGuid = 0;
|
uint serverGuid = 0;
|
||||||
foreach (var esg in _entitiesByServerGuid)
|
foreach (var esg in _entitiesByServerGuid)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
|
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.
|
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||||
// entities; without integration, remote chars jitter-hop between
|
// entities; without integration, remote chars jitter-hop between
|
||||||
|
|
@ -3746,105 +3849,75 @@ public sealed class GameWindow : IDisposable
|
||||||
if (ae.Sequencer is not null
|
if (ae.Sequencer is not null
|
||||||
&& serverGuid != 0
|
&& serverGuid != 0
|
||||||
&& serverGuid != _playerServerGuid
|
&& 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
|
// Retail per-tick motion pipeline applied to every remote.
|
||||||
// world space by the entity's orientation. "World space on
|
// Mirrors retail FUN_00515020 update_object → FUN_00513730
|
||||||
// the object" (r03 §1.3) → local vector rotated by entity
|
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
|
||||||
// rotation → world space.
|
//
|
||||||
var seqVel = ae.Sequencer.CurrentVelocity;
|
// 1. apply_current_movement (FUN_00529210) — recomputes
|
||||||
if (seqVel.LengthSquared() > 1e-6f)
|
// 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);
|
float omegaMag = rm.ObservedOmega.Length();
|
||||||
}
|
var axis = rm.ObservedOmega / omegaMag;
|
||||||
// Priority 2: server-supplied world velocity (HasVelocity flag
|
float angle = omegaMag * dt;
|
||||||
// on UpdatePosition). Already world-space; no rotation.
|
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||||
else if (drState.ServerVelocity is { } sv && sv.LengthSquared() > 1e-6f)
|
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||||
{
|
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||||
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)
|
// Step 3: integrate physics — retail FUN_00515020
|
||||||
{
|
// update_object → FUN_00513730 UpdatePositionInternal →
|
||||||
// Only integrate while the cycle is a locomotion cycle.
|
// FUN_005256b0 Sequence::apply_physics. Position and
|
||||||
// Idle (Ready 0x03) and emotes should stay pinned at the
|
// orientation BOTH advance from Velocity/Omega × dt.
|
||||||
// last server pos — MotionData for Ready has no velocity
|
// No slerp, no soft-snap — retail is deterministic.
|
||||||
// anyway, but belt + suspenders.
|
rm.Body.update_object(nowSec);
|
||||||
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.
|
ae.Entity.Position = rm.Body.Position;
|
||||||
// Residual decays toward zero, so after ~300ms the rendered
|
ae.Entity.Rotation = rm.Body.Orientation;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
// ── 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
|
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
|
||||||
/// if you only consult the MotionTable's default style.
|
/// if you only consult the MotionTable's default style.
|
||||||
/// </summary>
|
/// </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(
|
public readonly record struct ServerMotionState(
|
||||||
ushort Stance,
|
ushort Stance,
|
||||||
ushort? ForwardCommand,
|
ushort? ForwardCommand,
|
||||||
float? ForwardSpeed = null,
|
float? ForwardSpeed = null,
|
||||||
IReadOnlyList<MotionItem>? Commands = null);
|
IReadOnlyList<MotionItem>? Commands = null,
|
||||||
|
ushort? SideStepCommand = null,
|
||||||
|
float? SideStepSpeed = null,
|
||||||
|
ushort? TurnCommand = null,
|
||||||
|
float? TurnSpeed = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||||
|
|
@ -501,6 +523,10 @@ public static class CreateObject
|
||||||
|
|
||||||
ushort? forwardCommand = null;
|
ushort? forwardCommand = null;
|
||||||
float? forwardSpeed = null;
|
float? forwardSpeed = null;
|
||||||
|
ushort? sidestepCommand = null;
|
||||||
|
float? sidestepSpeed = null;
|
||||||
|
ushort? turnCommand = null;
|
||||||
|
float? turnSpeed = null;
|
||||||
List<MotionItem>? commands = null;
|
List<MotionItem>? commands = null;
|
||||||
|
|
||||||
// 0 = Invalid is the only union variant we care about for static
|
// 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 flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
|
||||||
uint numCommands = packed >> 7;
|
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 ((flags & 0x1u) != 0)
|
||||||
{
|
{
|
||||||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||||
p += 2;
|
p += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForwardCommand (0x2)
|
|
||||||
if ((flags & 0x2u) != 0)
|
if ((flags & 0x2u) != 0)
|
||||||
{
|
{
|
||||||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||||||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||||
p += 2;
|
p += 2;
|
||||||
}
|
}
|
||||||
// SidestepCommand (0x4) — skip
|
// SideStepCommand (bit 0x8, ushort)
|
||||||
if ((flags & 0x4u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
|
if ((flags & 0x8u) != 0)
|
||||||
// TurnCommand (0x8) — skip
|
{
|
||||||
if ((flags & 0x8u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
|
if (mv.Length - p < 2) goto done;
|
||||||
// ForwardSpeed (0x10)
|
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||||
if ((flags & 0x10u) != 0)
|
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;
|
if (mv.Length - p < 4) goto done;
|
||||||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||||||
p += 4;
|
p += 4;
|
||||||
}
|
}
|
||||||
// SidestepSpeed (0x20) — skip
|
// SideStepSpeed (bit 0x10, float)
|
||||||
if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
if ((flags & 0x10u) != 0)
|
||||||
// TurnSpeed (0x40) — skip
|
{
|
||||||
if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
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 +
|
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
|
||||||
// u16 packedSeq + f32 speed). One-shot actions, emotes,
|
// u16 packedSeq + f32 speed). One-shot actions, emotes,
|
||||||
|
|
@ -571,7 +630,9 @@ public static class CreateObject
|
||||||
done:;
|
done:;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands);
|
return new ServerMotionState(
|
||||||
|
currentStyle, forwardCommand, forwardSpeed, commands,
|
||||||
|
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,10 @@ public static class UpdateMotion
|
||||||
|
|
||||||
ushort? forwardCommand = null;
|
ushort? forwardCommand = null;
|
||||||
float? forwardSpeed = null;
|
float? forwardSpeed = null;
|
||||||
|
ushort? sidestepCommand = null;
|
||||||
|
float? sidestepSpeed = null;
|
||||||
|
ushort? turnCommand = null;
|
||||||
|
float? turnSpeed = null;
|
||||||
List<CreateObject.MotionItem>? commands = null;
|
List<CreateObject.MotionItem>? commands = null;
|
||||||
|
|
||||||
if (movementType == 0)
|
if (movementType == 0)
|
||||||
|
|
@ -137,37 +141,68 @@ public static class UpdateMotion
|
||||||
uint flags = packed & 0x7Fu;
|
uint flags = packed & 0x7Fu;
|
||||||
uint numCommands = packed >> 7;
|
uint numCommands = packed >> 7;
|
||||||
|
|
||||||
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
|
// Flag-bit layout + write order (ACE
|
||||||
// if present, matching the CreateObject parser's behavior.
|
// 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 ((flags & 0x1u) != 0)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForwardCommand (0x2)
|
|
||||||
if ((flags & 0x2u) != 0)
|
if ((flags & 0x2u) != 0)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
}
|
}
|
||||||
// SidestepCommand (0x4) — skip
|
// SideStepCommand — ushort, bit 0x8
|
||||||
if ((flags & 0x4u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; }
|
if ((flags & 0x8u) != 0)
|
||||||
// TurnCommand (0x8) — skip
|
{
|
||||||
if ((flags & 0x8u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; }
|
if (body.Length - pos < 2) goto done;
|
||||||
// ForwardSpeed (0x10)
|
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
if ((flags & 0x10u) != 0)
|
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;
|
if (body.Length - pos < 4) goto done;
|
||||||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
}
|
}
|
||||||
// SidestepSpeed (0x20) — skip
|
// SideStepSpeed — float, bit 0x10
|
||||||
if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
|
if ((flags & 0x10u) != 0)
|
||||||
// TurnSpeed (0x40) — skip
|
{
|
||||||
if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
|
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
|
// Commands list: actions/emotes/attacks. Guard against a
|
||||||
// malformed numCommands by capping at a sane max.
|
// malformed numCommands by capping at a sane max.
|
||||||
|
|
@ -187,7 +222,9 @@ public static class UpdateMotion
|
||||||
done:;
|
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
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,34 @@ public sealed class AnimationSequencer
|
||||||
if (yvel != 0f || xvel != 0f)
|
if (yvel != 0f || xvel != 0f)
|
||||||
CurrentVelocity = new Vector3(xvel, yvel, 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
|
// Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,11 @@ public class UpdateMotionTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParsesForwardSpeed_WhenSpeedFlagSet()
|
public void ParsesForwardSpeed_WhenSpeedFlagSet()
|
||||||
{
|
{
|
||||||
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13)
|
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed
|
||||||
|
// = 0x1 | 0x2 | 0x4 = 0x7
|
||||||
|
// (Per ACE MovementStateFlag enum — ForwardSpeed is bit 0x4,
|
||||||
|
// NOT 0x10. The earlier test had the wrong mapping; see
|
||||||
|
// references/ACE/Source/ACE.Entity/Enum/MovementStateFlag.cs)
|
||||||
// Test value: 1.5× speed — matches a typical RunRate broadcast.
|
// Test value: 1.5× speed — matches a typical RunRate broadcast.
|
||||||
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4];
|
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4];
|
||||||
int p = 0;
|
int p = 0;
|
||||||
|
|
@ -124,7 +128,7 @@ public class UpdateMotionTests
|
||||||
body[p++] = 0;
|
body[p++] = 0;
|
||||||
body[p++] = 0;
|
body[p++] = 0;
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4;
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x7u); p += 4;
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed
|
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue