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>
This commit is contained in:
Erik 2026-04-19 21:33:12 +02:00
parent 340dabbc72
commit beffdf477e

View file

@ -0,0 +1,191 @@
# 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
- `340dabb` — **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:
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 architecture**`PhysicsBody` + `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.