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:
parent
340dabbc72
commit
beffdf477e
1 changed files with 191 additions and 0 deletions
191
memory/project_session_2026_04_19.md
Normal file
191
memory/project_session_2026_04_19.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue