Captures the full retail-faithful remote-entity motion port that shipped
today (commit 340dabb). Documents the wire-format discoveries (correct
MovementStateFlag bits, ACE stop signals, absent-HasVelocity semantics),
the architecture (per-remote PhysicsBody + MotionInterpreter), and the
5+ failed approaches we worked through before landing on the right one.
Key pickup for next session: investigate retail observer view of ACdream
player — user reported "not perfect" right before calling it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.7 KiB
Session 2026-04-19 — Full retail remote-entity motion port
Big win
Remote characters (viewed from ACdream) now render motion with full retail fidelity — walk, run, strafe, turn, run+turn, and stop all work end-to-end, user-confirmed via live play against ACE + a retail alt.
The port uses PhysicsBody + MotionInterpreter + AnimationSequencer
on every remote, mirroring retail's per-entity motion stack. No more
RemoteInterpolator shortcut. No timer-based stop heuristics. Pure
retail decompile semantics.
Commits this session (main):
5bd976e— docs(claude.md): live-server connection + launch workflow795d9c8— fix(anim): Option B — MotionData-sourced physics velocity00c8a4f— fix(anim): ACE-echo speedMod clobber + sequencer velocity synthesis340dabb— feat(anim): FULL retail remote-entity motion port — walk/run/strafe/turn/stop
All 717 tests green. +233 insertions, -60 deletions net for the big port.
The debugging saga (5+ failed approaches before the right one)
The user was rightly frustrated — we thrashed for hours on remote motion before nailing it. Learnings from every miss, for the benefit of future self:
- First thought it was a rendering issue — spent time tuning anim framerate / speedMod. Irrelevant.
- Tried EMA velocity extrapolation for dead-reckoning. Got "stuck-then-glide" because EMA direction lagged turns.
- Tried queue-based interpolation (InterpolationManager port from ACE). Got "pause at each target" because queue drained between UPs.
- Tried PosFrames root motion per the research. Zero effect — Humanoid dat's PosFrames are for visual foot placement, NOT world-space body position for grounded characters.
- Tried AC2D-style heading scalar + formula omega. Shortcut the user explicitly rejected ("AC2D is NOT the retail client").
- Then: the correct architecture —
PhysicsBody+MotionInterpreterper remote, exactly like retail. Worked for walk/run/strafe/stop in one shot. - But rotation "snapped" every UP. Added slerp — still snapped. Removed slerp + used formula omega — still snapped. Added observed omega from UP deltas — rotation FROZE entirely.
- Found the actual bug:
PhysicsBody.update_objecthasMinQuantum = 1/30 s— at our 60fps render tick (~16ms dt), every tick's dt < MinQuantum soupdate_objectreturned early, skipping rotation integration entirely. All prior "fixes" were on top of a no-op integration. - Fix: manual omega integration per tick, bypassing
update_objectfor rotation. Keptupdate_objectfor position (velocity × dt). - Final polish: TurnCommand presence as instant on/off switch for
ObservedOmega(seeded from π/2 × turnSpeed formula) — rotation begins immediately on turn-start and stops immediately on turn-end instead of coasting via stale observed rate.
Concrete wire-format discoveries (DURABLE)
Critical parser bug (fixed in commit 340dabb):
The MovementStateFlag enum bits don't match our earlier parser's
assumption. Correct bits per ACE MovementStateFlag.cs:
| Field | Wire flag | Earlier parser | Status |
|---|---|---|---|
| CurrentStyle | 0x01 | 0x01 | ✓ |
| ForwardCommand | 0x02 | 0x02 | ✓ |
| ForwardSpeed | 0x04 | 0x10 | was wrong |
| SideStepCommand | 0x08 | 0x04 | was wrong |
| SideStepSpeed | 0x10 | 0x20 | was wrong |
| TurnCommand | 0x20 | 0x08 | was wrong |
| TurnSpeed | 0x40 | 0x40 | ✓ |
Also: write order is Style, Fwd, Side, Turn then FwdSpd, SideSpd, TurnSpd
(commands first, speeds second — not in bit-order).
ACE's stop signals (discovered from live packet captures):
-
UpdateMotion with ForwardCommand flag CLEARED → retail's default ForwardCommand=Invalid (0) value → our handler must map null command to Ready (0x41000003). Previously we treated null as "keep current motion" which made alts run forever.
-
ForwardSpeed=0 on wire → IS a valid speed value (not "omitted default 1.0"). ACE sends this when alt releases W while still having WalkForward cmd. Previously we clamped
fs > 0f ? fs : 1fwhich produced "slow walk that never stops." -
ACE does NOT send HasVelocity on UpdatePosition for player broadcasts. Velocity field is always absent on player UP (even during active running). Don't treat null-velocity as a stop signal — it's just "no velocity info." This caused an earlier regression where every UP force-stopped the remote.
Architecture: how remote motion works now
Per remote entity:
RemoteMotion {
PhysicsBody Body; // position + velocity integration
MotionInterpreter Motion; // motion state machine (forward/side/turn cmds)
Vector3 ObservedOmega; // angular velocity (seeded from π/2 × turnSpeed)
}
On UpdateMotion:
- ForwardCommand →
MotionInterpreter.DoInterpretedMotion(cmd, speed). Absent flag → Ready (= stop). - SideStep cmd →
DoInterpretedMotion(sideCmd, sideSpd). Absent →StopInterpretedMotion(SideStepRight/Left). - Turn cmd →
DoInterpretedMotion(turnCmd, turnSpd)AND seedObservedOmega = ±π/2 × turnSpd. Absent → stop + zero ObservedOmega. - Animation cycle selection: forward if active, else sidestep, else turn, else Ready.
On UpdatePosition:
- Hard-snap
Body.Position+Body.Orientation(retailFUN_00514b90set_frame). - If HasVelocity + |v| < 0.2 →
StopCompletely+ SetCycle(Ready).
Per-tick in TickAnimations:
Motion.apply_current_movement()→ writesBody.Velocityviaget_state_velocity(retail FUN_00528960).- Manual omega integration:
Orientation *= quat(ObservedOmega × dt). Must be manual becauseBody.update_objecthas MinQuantum=1/30s which skips our 60fps ticks. Body.update_object(now)→ Euler integratesPosition += Velocity × dt.entity.Position = Body.Position;entity.Rotation = Body.Orientation.
What's still open
-
Jump for remotes. Not yet wired through
MotionInterpreter.jumpLeaveGround. Remote jumping currently does nothing visible. Low priority — user didn't report it as blocking.
-
Retail observer view of ACdream player. User's last comment before calling it: "we also need to investigate ACdream's movement in retail client. Does not look perfect." This is the next session's starting point. We haven't triaged yet; user should describe what the retail observer sees (speed mismatch? strafe invisible? turn not broadcasting? jump missing?). Then we investigate our outbound
MoveToStatewire and per-tick broadcast triggers. -
Minor polish — the following are not blocking but worth cleaning if we touch this code again:
TargetOrientationfield onRemoteMotionis now unused (legacy from the slerp approach); can be removed.PrevServerRot/HasPrevRotfields same story.ObservedVelocityonRemoteMotionis preserved but stale; can be removed if HUD doesn't use it.
Durable rules for next time
- ALWAYS check actual wire bits against ACE's enum definitions. The MovementStateFlag mapping was wrong for months. Diagnostic dumps pay for themselves in minutes.
- When a packet field is "absent," retail typically resets to default
(Invalid/zero). Do NOT treat absent as "keep state." The bulk-copy
semantics of
FUN_0051F260reset each field from the unpacked struct. PhysicsBody.update_objectis built for 30fps sub-stepping, NOT 60fps render ticks. Using it for per-frame rotation means 50% of frames are no-ops. For high-frequency integration, bypass.- Soft-snap + hard-snap fight each other. Pick one mechanism and commit. Retail hard-snaps on UP and relies on formula omega matching server rate.
- The "AC2D is a valid reference" trap: the user will reject any
shortcut that isn't from the retail decompile. Dispatch opus agents
to read
chunk_*.cfor every motion/physics question.
Pickup for next session
First thing: ask the user what looks wrong from the retail observer's view. Specifically:
- Walking/running speed + leg cadence.
- Strafing visible or invisible to retail.
- Turning — smooth or snap on retail side.
- Start/stop — glide past stop?
- Jump — rendering at all?
- Idle stance.
From there, investigate our outbound wire path:
PlayerMovementController.Update→ what MoveToState does it emit?MoveToStatewire builder inAcDream.Core.Net.- Sequence counters (instance, serverControl, teleport, forcePosition).
- Any missing flags ACE expects on our outbound (HoldKey, RawMotionFlags, etc.).
Cross-check against
references/holtburger/crates/holtburger-core/ src/client/movement/system.rswhich is a known-working AC client sender.