From beffdf477e4f382600354c8aa9668864d5316932 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 21:33:12 +0200 Subject: [PATCH] =?UTF-8?q?docs(memory):=20session=202026-04-19=20handoff?= =?UTF-8?q?=20=E2=80=94=20remote=20motion=20port=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- memory/project_session_2026_04_19.md | 191 +++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 memory/project_session_2026_04_19.md diff --git a/memory/project_session_2026_04_19.md b/memory/project_session_2026_04_19.md new file mode 100644 index 0000000..c9187f0 --- /dev/null +++ b/memory/project_session_2026_04_19.md @@ -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.