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

191 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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