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