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>
191 lines
8.7 KiB
Markdown
191 lines
8.7 KiB
Markdown
# 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.
|