fix(anim): remote-entity stop detection from position deltas
Root cause found from ACE source:
- Player_Tick.cs:368 — "the client will never send a 'client released
forward' MoveToState in this scenario unfortunately"
- Server therefore can't broadcast a MotionCommand.Ready UpdateMotion
when a remote player stops moving.
- Retail observer infers stopped state from position deltas going to
zero, not from an explicit motion message.
Also found + fixed the UpdateMotion parser's 2-byte offset bug: ACE's
Align() pads based on absolute stream length (length=15 → 1 pad byte),
not relative-to-block. Previous parser assumed 3 pad bytes after the
MovementData header, which mis-aligned every subsequent field by 2.
After fix, stance/command/speed decode correctly for both server-
controlled NPCs (full stance 0x003D + cmd transitions) and remote
players (stance=0 meaning "no change" + per-axis commands).
OnLiveMotionUpdated rewrite: use SetCycle directly for sequencer
entities instead of routing through GetIdleCycle (which ignored
command when stance was 0). Preserve current style/motion when the
server omits a field ("no change" semantics). Reconstruct full
MotionCommand high byte from current motion or SubState mask.
Remote stop-detection: new _remoteLastMove dict tracks per-entity last
meaningful position + time. OnLivePositionUpdated updates only on
moves > 0.05m so the timestamp captures last actual movement.
TickAnimations checks every entity in a locomotion cycle; if their
last-move time is >400ms stale, swap sequencer to Ready. Excludes
player's own entity (driven by local input, not server observation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b092a6090d
commit
53f0110b89
2 changed files with 191 additions and 35 deletions
|
|
@ -141,6 +141,15 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
||||
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
||||
|
||||
// Remote-entity motion inference: tracks when each remote entity last
|
||||
// moved meaningfully. Used in TickAnimations to swap to Ready when
|
||||
// position has stalled for >StopIdleMs — retail observer pattern per
|
||||
// ACE Player_Tick.cs line 368: the client never sends "released forward"
|
||||
// MoveToState, so the server never broadcasts an explicit stop. Observer
|
||||
// must infer it from position deltas.
|
||||
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
|
||||
_remoteLastMove = new();
|
||||
|
||||
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
||||
// Exposed publicly so plugins + UI panels can bind directly.
|
||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||
|
|
@ -1301,29 +1310,33 @@ public sealed class GameWindow : IDisposable
|
|||
// Re-resolve using the new stance/command. Keep the setup and
|
||||
// motion-table we already know about — the server's motion
|
||||
// updates override state within the same table, not swap tables.
|
||||
//
|
||||
// IMPORTANT: stance and command are BOTH optional. Remote-player
|
||||
// autonomous broadcasts frequently set only one flag (e.g. just
|
||||
// ForwardCommand) with currentStyle=0x0000 meaning "no stance
|
||||
// change — keep current." Treating stance=0 as "default stance"
|
||||
// drops the real state; instead we preserve the sequencer's
|
||||
// current style.
|
||||
ushort stance = update.MotionState.Stance;
|
||||
ushort? command = update.MotionState.ForwardCommand;
|
||||
|
||||
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
|
||||
ae.Setup, _dats,
|
||||
motionTableIdOverride: null, // same table; already burned into ae.Animation
|
||||
stanceOverride: stance,
|
||||
commandOverride: command);
|
||||
// Diagnostic: dump every inbound UpdateMotion so we can trace why
|
||||
// remote chars don't transition off RunForward when they stop.
|
||||
// Enable with ACDREAM_DUMP_MOTION=1.
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
&& update.Guid != _playerServerGuid)
|
||||
{
|
||||
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||||
float spd = update.MotionState.ForwardSpeed ?? 0f;
|
||||
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
|
||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||
Console.WriteLine(
|
||||
$"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||||
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
|
||||
}
|
||||
|
||||
// If the new cycle is bad (null, framerate=0, or single-frame), do
|
||||
// NOT remove the entity from the animated set. Keep its existing
|
||||
// cycle running so it continues to breathe / idle. Removing on
|
||||
// re-resolve failure was a bug that silently unregistered NPCs the
|
||||
// moment the server sent a motion update with a stance/command
|
||||
// pair the resolver couldn't translate cleanly. Defensive: switch
|
||||
// only when we have a clearly better cycle.
|
||||
bool newCycleIsGood = newCycle is not null
|
||||
&& newCycle.Framerate != 0f
|
||||
&& newCycle.HighFrame > newCycle.LowFrame
|
||||
&& newCycle.Animation.PartFrames.Count > 1;
|
||||
|
||||
// Wire server-echoed RunRate BEFORE the animation early-return.
|
||||
// If the cycle can't resolve (bad stance), we still need the speed.
|
||||
// Wire server-echoed RunRate first — used for the player's own
|
||||
// locomotion tuning regardless of whether a cycle resolves.
|
||||
if (_playerController is not null
|
||||
&& update.Guid == _playerServerGuid
|
||||
&& update.MotionState.ForwardSpeed.HasValue
|
||||
|
|
@ -1332,20 +1345,85 @@ public sealed class GameWindow : IDisposable
|
|||
_playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value);
|
||||
}
|
||||
|
||||
if (!newCycleIsGood)
|
||||
return;
|
||||
|
||||
// (RunRate wiring moved above the early-return)
|
||||
|
||||
// Sequencer path
|
||||
// ── Sequencer path (preferred) ──────────────────────────────────
|
||||
// Call SetCycle directly. The sequencer already handles:
|
||||
// - left→right / backward→forward remapping via adjust_motion
|
||||
// - style and motion as u32 MotionCommand values
|
||||
// - fast-path for identical state
|
||||
//
|
||||
// When the server omits a field (stance flag not set, or command
|
||||
// flag not set), "no change" means we must preserve the sequencer's
|
||||
// current state, NOT fall back to a table default.
|
||||
if (ae.Sequencer is not null)
|
||||
{
|
||||
uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle;
|
||||
uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u;
|
||||
uint fullStyle = stance != 0
|
||||
? (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.
|
||||
uint fullMotion;
|
||||
if (command.HasValue)
|
||||
{
|
||||
if (command.Value == 0)
|
||||
{
|
||||
// Stop — pick the style's default substate (Ready).
|
||||
fullMotion = 0x41000003u;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore the high byte of the MotionCommand; the server
|
||||
// only transmits the low 16 bits of the 32-bit enum.
|
||||
// Known patterns:
|
||||
// 0x0003 (Ready) → 0x41000003
|
||||
// 0x0005 (WalkForward) → 0x45000005
|
||||
// 0x0007 (RunForward) → 0x44000007
|
||||
// 0x000D (TurnRight) → 0x65000015 (close; use 0x65xx)
|
||||
// We can reconstruct by masking in the high nibble from
|
||||
// the sequencer's current motion (preserves class bits),
|
||||
// but for the substate-class commands the cycle lookup
|
||||
// uses only the low 24 bits anyway. Just OR in the
|
||||
// SubState class byte if none is present.
|
||||
uint cmdLo = (uint)command.Value;
|
||||
fullMotion = (cmdLo & 0xFF000000u) != 0
|
||||
? cmdLo
|
||||
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | cmdLo;
|
||||
if (fullMotion == cmdLo) // nothing in current; mark as SubState
|
||||
fullMotion = 0x40000000u | cmdLo;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
fullMotion = ae.Sequencer.CurrentMotion;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
&& update.Guid != _playerServerGuid)
|
||||
Console.WriteLine(
|
||||
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8})");
|
||||
|
||||
// No-op if same; the sequencer's fast path guards against that.
|
||||
ae.Sequencer.SetCycle(fullStyle, fullMotion);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy path
|
||||
// ── Legacy path (entities without a sequencer) ──────────────────
|
||||
// Here we DO use GetIdleCycle because the legacy tick loop needs
|
||||
// a concrete Animation + frame range. Only swap when the resolver
|
||||
// returns a clearly-better cycle.
|
||||
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
|
||||
ae.Setup, _dats,
|
||||
motionTableIdOverride: null,
|
||||
stanceOverride: stance,
|
||||
commandOverride: command);
|
||||
bool newCycleIsGood = newCycle is not null
|
||||
&& newCycle.Framerate != 0f
|
||||
&& newCycle.HighFrame >= newCycle.LowFrame
|
||||
&& newCycle.Animation.PartFrames.Count >= 1;
|
||||
if (!newCycleIsGood) return;
|
||||
|
||||
ae.Animation = newCycle!.Animation;
|
||||
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
|
||||
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
|
||||
|
|
@ -1388,6 +1466,25 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Position = worldPos;
|
||||
entity.Rotation = rot;
|
||||
|
||||
// Track remote-entity motion for stop detection. Only record the
|
||||
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
||||
// that report the same position keep the old Time, so the
|
||||
// TickAnimations check can see when motion last changed.
|
||||
if (update.Guid != _playerServerGuid)
|
||||
{
|
||||
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
||||
{
|
||||
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
|
||||
if (moveDist > 0.05f)
|
||||
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
|
||||
// else: leave old entry so "Time" = last real movement time
|
||||
}
|
||||
else
|
||||
{
|
||||
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase B.3: portal-space arrival detection.
|
||||
// Only runs for our own player character while in PortalSpace.
|
||||
if (_playerController is not null
|
||||
|
|
@ -3033,10 +3130,50 @@ 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.
|
||||
const double StopIdleMs = 400.0;
|
||||
var now = System.DateTime.UtcNow;
|
||||
|
||||
foreach (var kv in _animatedEntities)
|
||||
{
|
||||
var ae = kv.Value;
|
||||
|
||||
// ── 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
|
||||
// Locate the server guid for this entity (reverse lookup).
|
||||
// Skip the player's own entity — we drive our own anim locally.
|
||||
uint serverGuid = 0;
|
||||
foreach (var esg in _entitiesByServerGuid)
|
||||
{
|
||||
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
|
||||
}
|
||||
if (inLocomotion
|
||||
&& serverGuid != 0
|
||||
&& serverGuid != _playerServerGuid
|
||||
&& _remoteLastMove.TryGetValue(serverGuid, out var last)
|
||||
&& (now - last.Time).TotalMilliseconds > StopIdleMs)
|
||||
{
|
||||
uint curStyle = ae.Sequencer.CurrentStyle;
|
||||
uint ready = (curStyle & 0xFF000000u) != 0
|
||||
? ((curStyle & 0xFF000000u) | 0x01000003u)
|
||||
: 0x41000003u;
|
||||
ae.Sequencer.SetCycle(curStyle, ready);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
|
||||
if (ae.Sequencer is not null)
|
||||
|
|
|
|||
|
|
@ -86,13 +86,23 @@ public static class UpdateMotion
|
|||
pos += 2;
|
||||
|
||||
// MovementData header: u16 movementSequence, u16 serverControlSequence,
|
||||
// u8 isAutonomous, then align to 4. The header bytes total 6 (2+2+1+1 pad),
|
||||
// because 2+2+1 = 5, and aligning to 4 from offset 5 needs 3 pad bytes...
|
||||
// Actually, ACE's writer.Align() pads the CURRENT BaseStream position
|
||||
// after writing the byte, so after u16 + u16 + u8 we're at 5 bytes into
|
||||
// MovementData; alignment rounds up to 8. So the header slot is 8 bytes.
|
||||
if (body.Length - pos < 8) return null;
|
||||
pos += 8;
|
||||
// u8 isAutonomous, then Align().
|
||||
//
|
||||
// ACE's Align() (Network/Extensions.cs:55) uses
|
||||
// CalculatePadMultiple(BaseStream.Length, 4) — i.e. it pads based on
|
||||
// the ABSOLUTE stream length, not a relative offset within the
|
||||
// MovementData block.
|
||||
//
|
||||
// At this point the absolute stream has: opcode (4) + guid (4) +
|
||||
// objectInstance (2) + movSeq (2) + srvSeq (2) + isAut (1) = 15.
|
||||
// Align(4) rounds 15 → 16, so ONE pad byte is written.
|
||||
// MovementData header = 2+2+1+1 = 6 bytes.
|
||||
//
|
||||
// Previous version mistakenly reserved 8 bytes here, which shifted
|
||||
// every subsequent field by 2 and made every remote-char UpdateMotion
|
||||
// decode as garbage (stance read from the packed-flags dword).
|
||||
if (body.Length - pos < 6) return null;
|
||||
pos += 6;
|
||||
|
||||
// movementType u8, motionFlags u8, currentStyle u16
|
||||
if (body.Length - pos < 4) return null;
|
||||
|
|
@ -101,6 +111,15 @@ public static class UpdateMotion
|
|||
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
int preHex = Math.Min(body.Length, 32);
|
||||
var hex = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
|
||||
System.Console.WriteLine(
|
||||
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
|
||||
}
|
||||
|
||||
ushort? forwardCommand = null;
|
||||
float? forwardSpeed = null;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue