acdream/memory/project_session_2026_04_19.md
Erik beffdf477e docs(memory): session 2026-04-19 handoff — remote motion port complete
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>
2026-04-19 21:33:12 +02:00

8.7 KiB
Raw Permalink Blame History

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 workflow
  • 795d9c8 — fix(anim): Option B — MotionData-sourced physics velocity
  • 00c8a4f — fix(anim): ACE-echo speedMod clobber + sequencer velocity synthesis
  • 340dabbfeat(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:

  1. First thought it was a rendering issue — spent time tuning anim framerate / speedMod. Irrelevant.
  2. Tried EMA velocity extrapolation for dead-reckoning. Got "stuck-then-glide" because EMA direction lagged turns.
  3. Tried queue-based interpolation (InterpolationManager port from ACE). Got "pause at each target" because queue drained between UPs.
  4. 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.
  5. Tried AC2D-style heading scalar + formula omega. Shortcut the user explicitly rejected ("AC2D is NOT the retail client").
  6. Then: the correct architecturePhysicsBody + MotionInterpreter per remote, exactly like retail. Worked for walk/run/strafe/stop in one shot.
  7. 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.
  8. Found the actual bug: PhysicsBody.update_object has MinQuantum = 1/30 s — at our 60fps render tick (~16ms dt), every tick's dt < MinQuantum so update_object returned early, skipping rotation integration entirely. All prior "fixes" were on top of a no-op integration.
  9. Fix: manual omega integration per tick, bypassing update_object for rotation. Kept update_object for position (velocity × dt).
  10. 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):

  1. 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.

  2. 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 : 1f which produced "slow walk that never stops."

  3. 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 seed ObservedOmega = ±π/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 (retail FUN_00514b90 set_frame).
  • If HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).

Per-tick in TickAnimations:

  1. Motion.apply_current_movement() → writes Body.Velocity via get_state_velocity (retail FUN_00528960).
  2. Manual omega integration: Orientation *= quat(ObservedOmega × dt). Must be manual because Body.update_object has MinQuantum=1/30s which skips our 60fps ticks.
  3. Body.update_object(now) → Euler integrates Position += Velocity × dt.
  4. entity.Position = Body.Position; entity.Rotation = Body.Orientation.

What's still open

  1. Jump for remotes. Not yet wired through MotionInterpreter.jump

    • LeaveGround. Remote jumping currently does nothing visible. Low priority — user didn't report it as blocking.
  2. 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 MoveToState wire and per-tick broadcast triggers.

  3. Minor polish — the following are not blocking but worth cleaning if we touch this code again:

    • TargetOrientation field on RemoteMotion is now unused (legacy from the slerp approach); can be removed.
    • PrevServerRot / HasPrevRot fields same story.
    • ObservedVelocity on RemoteMotion is 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_0051F260 reset each field from the unpacked struct.
  • PhysicsBody.update_object is 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_*.c for every motion/physics question.

Pickup for next session

First thing: ask the user what looks wrong from the retail observer's view. Specifically:

  1. Walking/running speed + leg cadence.
  2. Strafing visible or invisible to retail.
  3. Turning — smooth or snap on retail side.
  4. Start/stop — glide past stop?
  5. Jump — rendering at all?
  6. Idle stance.

From there, investigate our outbound wire path:

  • PlayerMovementController.Update → what MoveToState does it emit?
  • MoveToState wire builder in AcDream.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.rs which is a known-working AC client sender.