acdream/docs/research/2026-05-04-l3-port/14-local-player-audit.md
Erik de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00

722 lines
32 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.

# 14 — Acdream audit: LOCAL player motion (the actor side)
Date: 2026-05-04. Scope: every file that touches our own `+Acdream`
character's motion — the simulator that reads keyboard input, drives
`PhysicsBody` + `MotionInterpreter`, animates the player entity, and
broadcasts MoveToState / AutonomousPosition / JumpAction over the wire.
Counterpart to `06-acdream-audit.md` (remote / observed motion).
Inputs read in detail:
- `src/AcDream.App/Input/PlayerMovementController.cs` (885 LOC)
- `src/AcDream.App/Input/PlayerModeAutoEntry.cs`
- `src/AcDream.Core/Physics/PlayerWeenie.cs` (81 LOC)
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — local-player call
sites (`apply_current_movement`, `DoMotion`, `DoInterpretedMotion`,
`StopInterpretedMotion`, `LeaveGround`, `HitGround`, `jump`,
`get_jump_v_z`, `get_state_velocity`).
- `src/AcDream.Core.Net/WorldSession.cs` — outbound sequence counters,
`NextGameActionSequence`, `SendGameAction` plumbing.
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (165 LOC) — 0xF61C
packet builder.
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` (89 LOC) —
0xF753 packet builder.
- `src/AcDream.Core.Net/Messages/JumpAction.cs` — 0x... jump packet.
- `src/AcDream.App/Rendering/GameWindow.cs` — local-player wiring at
L51825337 (per-frame Update + outbound), L69567103
(`UpdatePlayerAnimation`), L26382656 (server RunRate echo path),
L79978062 (player-controller construction at world-entry).
Verdict labels: **PORT** (faithful retail port), **HACK**
(acdream-original logic, not in retail), **BROKEN** (regressed/wrong
vs the named-retail spec), **DIAG** (instrumentation only).
Retail counterparts cited from
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
---
## 1. `PlayerMovementController.cs` — top-level architecture
### 1.1 Structure (L89230)
| Field | Verdict | Notes |
|---|---|---|
| `PhysicsBody _body` | PORT | Constructed with `Gravity \| ReportCollisions`**NOTE** retail's local player also has Contact+OnWalkable on the transient side after first SetPosition. |
| `MotionInterpreter _motion` | PORT | Wires Body+Weenie. |
| `PlayerWeenie _weenie` | PORT-with-issue | RunSkill/JumpSkill from env vars (default 200 / 300). See §4. |
| `PhysicsEngine _physics` | PORT | Used for `ResolveWithTransition` collision sweep. |
| `Yaw`, `MouseTurnSensitivity` | HACK | Per-controller yaw float; retail stores body Orientation directly. Acdream maintains Yaw separately and rebuilds Orientation each frame (L322). |
| `StepUpHeight` / `StepDownHeight` | PORT | 0.4 m default; updated from `Setup.StepUpHeight` at world-entry (L80218035). Retail-faithful values. |
| `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150155 acknowledges this is a feel-tune). The vz formula is byte-exact to retail. |
| `_wasAirborneLastFrame` | PORT | Used for justLanded edge detection. |
| `_prev*` previous-frame command/speed snapshots | PORT-ish | Used to detect `MotionStateChanged`. Retail's `CommandInterpreter::SendMovementEvent` similarly diff-gates outbound MTS. **NOTE** retail compares the entire `RawMotionState` not just selected fields — see #2.2 below. |
| `_heartbeatAccum`, `HeartbeatInterval=1.0s` | PORT | Matches retail trace 2026-05-01 + holtburger AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL (1 Hz). **Better than the prior 200 ms guess** in CLAUDE.md. |
| `_physicsAccum` (L204) | PORT | L.5 retail 30 Hz physics-tick gate. Mirrors `update_object` MinQuantum behavior. Critical for slope/wall-bounce parity. |
### 1.2 `ApplyServerRunRate(float)` (L270274)
Called from `OnLiveMotionUpdated` at L2643 when an inbound UM addressed
to the local player carries `MotionState.ForwardSpeed`.
```csharp
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
```
**Verdict: HACK.** Retail's local-side flow:
- Server sends UM with the authoritative ForwardSpeed only when
ForwardCommand changes (apply_run_to_command echoes the speed back
via the outbound MTS).
- Retail does NOT directly stuff `ForwardSpeed` into `InterpretedState`
on inbound UM — the inbound is purely a bulk-copy into a
RawMotionState that drives the *animation sequencer*, not the
velocity feed. The local player's velocity comes from
`apply_current_movement` reading the user-input-driven InterpretedState.
This is acdream's solution to "ACE doesn't tell the client its real
RunRate at character spawn" — we adopt the first non-zero ForwardSpeed
ACE relays. Per CLAUDE.md the long-term fix is parsing
PlayerDescription's RunSkill (issue #7); the env var workaround
becomes legacy then.
**Side effect:** calling `apply_current_movement` here re-evaluates
`get_state_velocity` and writes `body.Velocity`, which can momentarily
override an in-flight jump's airborne velocity. The `allowJump:false`
flag mitigates it but the call is still racing against integration.
### 1.3 `SetPosition(pos, cellId)` (L276287)
```csharp
_body.Position = pos;
CellId = cellId;
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
_body.LastUpdateTime = 0.0;
```
**Verdict: PORT.** Mirrors retail's `CPhysicsObj::SetPositionInternal`
post-snap effects (acclient_2013_pseudo_c.txt, FUN_00516330). Used on
world-entry and PortalSpace exit.
### 1.4 `Update(float dt, MovementInput input)` — main 600-line loop
Main structure: portal-space gate → turning → motion state machine →
jump → integrate → collision resolve → bounce → ground/landing →
outbound commands → motion-change detection → heartbeat → animation.
#### 1.4.1 Portal-space gate (L294307)
**Verdict: PORT.** When `State==PortalSpace`, returns zero-movement
result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early
return.
#### 1.4.2 Turn input (L309322)
```csharp
if (input.TurnRight) Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
if (input.TurnLeft) Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
_body.Orientation = Quaternion.CreateFromAxisAngle(UnitZ, Yaw - PI/2);
```
**Verdict: HACK.** Retail's local turn rate is `(π/2) × TurnSpeed`
(matches the omega formula in `06-acdream-audit.md` §2). acdream uses
`WalkAnimSpeed × 0.5 = 1.56 rad/s ≈ 90°/s` — coincidentally close to
retail's `π/2 ≈ 90°/s` for TurnSpeed=1.0, but the constant is wrong-
rooted (re-derivation through animation-speed table is a coincidence).
The mouse-turn path is entirely acdream-original; retail handles mouse
look via `CommandInterpreter::IssueAxisCommand` driving turn commands,
NOT direct yaw mutation.
#### 1.4.3 Motion state machine (L324411) — body velocity per input
Determines `forwardCmd` + `forwardCmdSpeed` from input, then:
```csharp
_motion.DoMotion(forwardCmd, forwardCmdSpeed); // → InterpretedState
if (input.StrafeRight) _motion.DoInterpretedMotion(SideStepRight, 1f, );
if (input.StrafeLeft) _motion.DoInterpretedMotion(SideStepLeft, 1f, );
if (_body.OnWalkable)
{
var stateVel = _motion.get_state_velocity();
float localY = , localX = ; // hand-rolled body-local velocity
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
```
**Verdict: HACK.** Retail's correct flow is:
1. Input → `CMotionInterp::DoMotion` (FUN_00529930) →
`apply_run_to_command` (FUN_00527be0) →
`DoInterpretedMotion` (FUN_00528f70) → `apply_current_movement`
(FUN_00529210) → `set_local_velocity(get_state_velocity())`.
2. `get_state_velocity` (FUN_00528960) reads InterpretedState
directly, returning body-local
`(SidestepAnimSpeed × SideStepSpeed, RunAnimSpeed × ForwardSpeed, 0)`.
acdream calls `DoMotion` (which DOES call `apply_current_movement`
internally → writes body.Velocity correctly) — and then OVERWRITES
that body velocity at L410 with a hand-rolled local-frame vector.
The hand-rolled block is acdream's workaround for **WalkBackward and
SideStepLeft** producing zero velocity in `get_state_velocity` because
the retail port omitted `adjust_motion` (FUN_00528010), which retail
runs *before* InterpretedState writes:
```
WalkBackwards → WalkForward + speed × -0.65
SideStepLeft → SideStepRight + speed × -1
```
**Critical impact for the L.3 audit:** the local player's body.Velocity
**IS** non-zero on every grounded frame (correctly so for the local
player — opposite of remotes), but it's set by *acdream's hand-rolled
block at L410 rather than by `MotionInterpreter`*. Per the L.3 spec and
named-retail source, the local-side velocity should come from
`get_state_velocity` only, after `adjust_motion` translates the
backward/strafe-left commands.
The **right fix** is to port `adjust_motion` (FUN_00528010) into
`MotionInterpreter` and remove L378411 entirely, letting `DoMotion`
do its thing.
#### 1.4.4 Jump path (L413505)
**Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).**
The jump-charge logic at L420428 (accumulate while held + on ground,
fire on release) matches retail `Event_Jump`'s charge-bar pattern.
The fire path:
1. `_motion.jump(extent)` — validates via retail FUN_00529390. PORT.
2. `_motion.get_jump_v_z()` — reads vz before LeaveGround zeroes
extent (matches retail FUN_00529710 invocation order). PORT.
3. `_motion.LeaveGround()` — clears Contact+OnWalkable, sets Gravity,
calls get_state_velocity into body.Velocity. PORT.
4. **Then acdream re-writes `outJumpVelocity`** (L466501) with a
manually-computed body-local jump velocity that includes
backward/strafe-left, working around the same adjust_motion gap as
§1.4.3. Hand-rolled mirror of L378411 logic.
The comment at L443460 acknowledges this explicitly: "Until
adjust_motion is ported, we mirror the grounded-velocity computation."
#### 1.4.5 Physics integration + 30 Hz gate (L507535)
```csharp
_physicsAccum += dt;
if (_physicsAccum > HugeQuantum) _physicsAccum = 0f; // stale
else if (_physicsAccum >= MinQuantum)
{
float tickDt = MathF.Min(_physicsAccum, MaxQuantum);
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
}
```
**Verdict: PORT.** This is the L.5 retail-physics-tick gate from
2026-04-30, reverse-engineered via cdb attach to retail. Effectively
clamps physics integration to 30 Hz even at 60+ Hz render.
**Mirrors retail's `update_object` MinQuantum behavior precisely.**
#### 1.4.6 Collision resolve (L538574)
```csharp
var resolveResult = _physics.ResolveWithTransition(
preIntegratePos, postIntegratePos, CellId,
sphereRadius: 0.48f, sphereHeight: 1.2f,
stepUpHeight, stepDownHeight,
isOnGround: _body.OnWalkable,
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
```
**Verdict: PORT.** Sphere dimensions match retail human Setup;
`IsPlayer | EdgeSlide` matches retail PhysicsGlobals.DefaultState for
players.
#### 1.4.7 Wall-bounce / velocity reflection (L578686)
Implements retail's `handle_all_collisions` velocity-reflection:
`v_new = v - (1 + elasticity) × dot(v, n) × n`. Sources cited:
acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:2656-2721.
**Verdict: PORT-with-conservative-rule.** The `applyBounce` gating at
L638640 is more restrictive than retail's strict
`!(prev && now && !sledding)` — acdream additionally suppresses
bounce on the airborne→grounded landing transition because the
post-reflection upward Z would defeat acdream's per-frame
`Velocity.Z<=0` landing-snap gate. Retail tolerates this because
elasticity=0.05 is visually imperceptible there; acdream's per-frame
architecture amplifies it. Documented in the comment at L630637.
#### 1.4.8 Ground/landing detection (L688720)
```csharp
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
{
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= Contact | OnWalkable;
if (_body.Velocity.Z < 0f) _body.Velocity.Z = 0f;
if (wasAirborne) { _motion.HitGround(); justLanded = true; }
}
else { _body.TransientState &= ~(Contact | OnWalkable); }
```
**Verdict: PORT.** Mirrors retail `MoveOrTeleport` post-resolution
landing state machine + `CMotionInterp::HitGround` call on
airborne→grounded transition.
#### 1.4.9 Outbound wire commands (L725795)
Builds `outForwardCmd / outForwardSpeed / outSidestepCmd /
outTurnCmd / localAnimCmd`:
```csharp
if (input.Forward)
{
outForwardCmd = MotionCommand.WalkForward;
if (input.Run && _weenie.InqRunRate(out var rr))
{
outForwardSpeed = rr;
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
}
else { outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkForward; }
}
else if (input.Backward) { outForwardCmd = WalkBackward; outForwardSpeed = 1.0f; }
```
**Verdict: PORT-with-ACE-quirk.** The `WalkForward + HoldKey.Run`
encoding (rather than direct `RunForward`) is documented at L2634 of
the file: it's a workaround for ACE's `MovementData` only computing
`interpState.ForwardSpeed` for raw WalkForward/WalkBackward. ACE then
auto-upgrades to RunForward on broadcast.
**Retail wire format: this is correct.** Retail's
`CommandInterpreter::SendMovementEvent` builds the MoveToState the
same way — sends WalkForward with HoldKey.Run for run intent,
RunForward only for explicit run-toggle. Confirmed via 2026-05-01 cdb
trace.
The `localAnimCmd` divergence is acdream-original but **necessary**
because the local sequencer wants RunForward immediately (for visual
parity), not the wire's WalkForward.
#### 1.4.10 Motion-change detection (L797831)
```csharp
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| localAnimCmd != _prevLocalAnimCmd;
```
**Verdict: PORT-ish.** Retail's `CommandInterpreter::SendMovementEvent`
diffs against the *previously-sent* RawMotionState, sending an MTS
only when the new state is non-equal. acdream's diff is field-selective
but covers the load-bearing fields. The `localAnimCmd` field is not in
retail's RawMotionState — including it forces a fresh outbound on
Walk↔Run toggle (W held + Shift toggle), which retail also does because
the toggle changes the wire ForwardSpeed. So the net effect matches.
**Subtle issue:** if the user only changes SidestepSpeed or TurnSpeed
(without changing the corresponding command), no outbound fires. Retail
likely doesn't either; not a regression.
#### 1.4.11 Heartbeat (L833845)
```csharp
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= 1.0f;
if (HeartbeatDue) _heartbeatAccum = 0f;
```
**Verdict: PORT.** 1 Hz cadence matches retail trace 2026-05-01 +
holtburger. Caller (`GameWindow`) reads `HeartbeatDue` and fires
AutonomousPosition.
**OPEN QUESTION** flagged in CLAUDE.md memory: retail's
`SendPositionEvent` gates the heartbeat on `transient_state` (must
have Contact+OnWalkable+valid Position) AND on motion. acdream's
1 Hz at-rest heartbeat is unconditional once in-world. Retail's cdb
trace showed AutonomousPosition gated on motion — i.e. **acdream
sends AP heartbeats while standing still, retail does not**. Probable
mismatch worth investigating. Filed in `project_retail_motion_outbound.md`.
---
## 2. `MoveToState.cs` (Messages/MoveToState.cs, 165 LOC)
**Verdict: PORT.** Wire layout matches holtburger
`RawMotionState::pack` + `MoveToStateActionData::pack`:
| Field | Verdict | Notes |
|---|---|---|
| GameAction envelope (0xF7B1, seq, 0xF61C) | PORT | |
| Flags dword (bits 010 fields, bits 1131 cmd-list-len=0) | PORT | We never send commands. |
| Conditional fields in flag-bit order | PORT | CurrentHoldKey, ForwardCommand, ForwardHoldKey, ForwardSpeed, SidestepCommand/HoldKey/Speed, TurnCommand/HoldKey/Speed. CurrentStyle (0x2) intentionally not sent — we don't track stance changes here (handled via separate ChangeCombatMode). |
| WorldPosition: cellId u32, x/y/z f32, rotW/rotX/rotY/rotZ f32 | PORT | Quaternion wire order W,X,Y,Z confirmed. |
| Sequences: u16 instance/serverControl/teleport/forcePosition | PORT | |
| Contact byte u8 + 4-byte align | PORT | |
**Open question 1:** retail builds the MoveToState with the FULL
`RawMotionState` from the local CMotionInterp, including
`AftCommand`/`AftSpeed`/`AftHoldKey` axes for sailing/swimming. We
omit those flags — they're never set by `PlayerMovementController`.
For walking that's fine; if we ever ship swimming/sailing this needs
extending.
**Open question 2:** `CurrentStyle` (0x2) — when the player changes
combat stance (e.g. Sword), does retail emit it as a separate
ChangeCombatMode + MoveToState pair, or does the MoveToState itself
carry the new style? Holtburger sends it via RawMotionState. acdream's
ChangeCombatMode path (`SendChangeCombatMode`) sends a separate
GameAction. **Cross-check needed**, but not load-bearing for the L.3
audit.
---
## 3. `AutonomousPosition.cs` (Messages/AutonomousPosition.cs, 89 LOC)
**Verdict: PORT.** Simpler than MoveToState: GameAction envelope +
WorldPosition + 4 sequences + lastContact byte + align.
Wire layout matches holtburger
`AutonomousPositionActionData::pack`. Used as the 1 Hz heartbeat.
---
## 4. `PlayerWeenie.cs` (81 LOC)
**Verdict: PORT (algorithm) + HACK (data source).**
`GetRunRate(burden, runSkill)` and `GetJumpHeight(burden, jumpSkill,
extent)` formulas are byte-for-byte from decompiled `acclient.exe` +
ACE `MovementSystem.GetRunRate / GetJumpHeight`:
```
RunRate = (burdenMod × (runSkill / (runSkill+200) × 11) + 4) / 4 (cap 4.5 at skill 800)
JumpHeight = burdenMod × (jumpSkill/(jumpSkill+1300) × 22.2 + 0.05) × extent
(clamp to 0.35 m min)
vz = sqrt(jumpHeight × 19.6)
```
**HACK side:** the constructor reads RunSkill/JumpSkill from env vars
(default 200 / 300). Per CLAUDE.md these are **NOT synced to the
server** — ACE has its own canonical RunSkill which it broadcasts in
`UpdateMotion.ForwardSpeed`. We currently echo via
`PlayerMovementController.ApplyServerRunRate` (§1.2), which DIRECTLY
overwrites `InterpretedState.ForwardSpeed` rather than updating the
weenie's RunSkill. So our local-prediction velocity may diverge from
the server's authoritative value mid-tick if the server's RunRate
differs from `(loadMod × runSkill/(runSkill+200) × 11 + 4)/4`.
**Long-term fix:** parse `PlayerDescription` (0xF7B0/0x0013), extract
RunSkill, call `_weenie.SetSkills(serverRun, serverJump)`. Filed
issue #7 in CLAUDE.md.
---
## 5. `MotionInterpreter` local-player call sites
Local-player call sites of `apply_current_movement`:
- `PlayerMovementController.cs:273` (`ApplyServerRunRate`) — see §1.2.
HACK.
- `PlayerMovementController.cs` indirectly via `DoMotion`
`DoInterpretedMotion``apply_current_movement`. PORT.
- `PlayerMovementController.cs` jump path via `LeaveGround`. PORT.
- `MotionInterpreter` internally inside `DoInterpretedMotion`,
`StopInterpretedMotion`, `HitGround`. PORT.
For the local player **`body.Velocity` is correctly non-zero per tick**
— driven by user input. This is the OPPOSITE of the L.3 invariant for
remotes and matches the named-retail spec.
---
## 6. `GameWindow.cs` local-player wiring
### 6.1 World-entry construction (L79978062)
```csharp
_playerController = new PlayerMovementController(_physicsEngine);
if (_lastSeenRunSkill > 0) _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
_playerController.StepUpHeight = playerSetup?.StepUpHeight ?? 0.4f;
_playerController.StepDownHeight = playerSetup?.StepDownHeight ?? 0.4f;
_playerController.SetPosition(initResult.Position, initResult.CellId);
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
_playerController.Yaw = rawYaw + MathF.PI/2;
```
**Verdict: PORT.** StepUp/Down come from Setup; cycle-velocity
accessor wires the AnimationSequencer into `MotionInterpreter`.
### 6.2 Per-frame Update loop (L51825337)
Reads input → `_playerController.Update(dt, input)` → updates entity
position+rotation → updates ChaseCamera → builds outbound MoveToState
(if MotionStateChanged) + AutonomousPosition (if HeartbeatDue) +
JumpAction (if jump fired) → calls `UpdatePlayerAnimation`.
**Verdict: PORT.** Wire-output gating is correct: change-driven MTS,
heartbeat AP, event-driven Jump. Cell ID composition and world→wire
conversion match retail.
The HoldKey block at L52665288 sends per-axis hold keys for every
active axis (forward/sidestep/turn) using the same value. Per
holtburger comment at L876: this is correct — retail uses the same
HoldKey value across all active axes.
### 6.3 `UpdatePlayerAnimation` (L69567103)
Computes the visible animation cycle from MovementResult:
```
Airborne → MotionCommand.Falling
Forward+Run → RunForward (LocalAnimationCommand)
Forward → WalkForward
Backward → WalkBackward
Sidestep → SideStepLeft/Right
Turn → TurnLeft/Right
else → Ready
```
Drives `ae.Sequencer.SetCycle(NonCombatStance, animCommand,
animSpeed × animScale, skipTransitionLink: airborne)`.
**Verdict: PORT.** Mirrors retail `MotionTable::SetState` /
`Sequence::SetCycle`. The `LocalAnimationSpeed` decoupling
(forward+run = runRate; backward+run / strafe+run = runRate too even
though wire ForwardSpeed=1.0) is acdream-original but correct: it
ensures the visible cycle pace matches the actual movement velocity
even when the wire format keeps backward/strafe at 1.0 for ACE
compat.
**Skip-link on Falling** (L7091): retail-faithful — without it, the
local player visibly stood still for ~100 ms at the start of every
jump while the RunForward→Falling transition link drained.
---
## 7. Shared-with-remote-player code paths
| File / function | Used by local? | Used by remote? | Concern? |
|---|---|---|---|
| `PhysicsBody` (full) | YES | YES | NO — same retail port; both sides write Velocity / Orientation correctly per their respective sources. |
| `MotionInterpreter` (full) | YES | YES | YES — `apply_current_movement` is the converging point. Local writes body.Velocity from user input (correct). Remote writes body.Velocity from `InterpretedState.ForwardCommand+ForwardSpeed` (incorrect per L.3 spec — see audit 06 §8). After L.3 lands, only the local player path will call `apply_current_movement` per tick; remotes will be anim-root-motion driven. |
| `AnimationSequencer` (full) | YES | YES | NO — both sides drive `SetCycle` from the appropriate input source. |
| `PhysicsEngine.ResolveWithTransition` | YES | YES | NO — collision sweep with sphere dims + step heights; identical for both. **Both paths must keep this** post-L.3 (audit 06 §6 step 4b). |
| `AnimatedEntity.Sequencer` | YES | YES | NO — sequencer is per-entity. Local UpdatePlayerAnimation writes its own; remote OnLiveMotionUpdated writes the remote's. Independent. |
| `PlayerWeenie` | YES | NO | NO — local-only. Remotes don't have a weenie; their MotionInterpreter fall back to default 1.0 RunRate via `if (weenie==null) x87_r7 = 1f` in retail's apply_run_to_command. |
| `PhysicsEngine.ShadowObjects` | NO | YES | NO — shadow tracking is for cell-list updates, not local-player. |
| `RemoteMotion` struct + dead-reckon table | NO | YES | NO — remote-only. |
**Key convergence point:** `MotionInterpreter.apply_current_movement`.
Local needs it (per tick, driven by user input). Remote should NOT
call it per tick (per L.3 spec). The two paths share the function but
diverge on call frequency.
After L.3 lands:
- Local: `_playerController.Update``_motion.DoMotion`
`apply_current_movement` (writes body.Velocity from user input).
**Once per frame.** Per CLAUDE.md ACE wire-format quirks, no
changes needed.
- Remote: per-tick reads `sequencer.CurrentVelocity` directly via
`PositionManager.ComputeOffset`. **Never calls
`apply_current_movement`.** body.Velocity stays 0 for grounded
remotes. `apply_current_movement` still fires on `OnLiveMotionUpdated`
for axis-state setup, but not per tick.
---
## 8. Specific question answers
**(a) Does `PlayerMovementController` mirror retail's pipeline
(`apply_current_movement → integrate → collision sweep`)?**
YES, partially. The pipeline order is correct: input →
DoMotion (which calls apply_current_movement internally) → integrate
(`UpdatePhysicsInternal`) → ResolveWithTransition. **But it then
*overwrites* the velocity with hand-rolled body-local code at L378411
to work around the missing `adjust_motion` port** for backward/
strafe-left. The same workaround appears in the jump path at L466501.
The pipeline shape matches retail; the velocity-source-of-truth diverges.
**(b) Does it use `m_velocityVector` correctly? (Local DOES integrate
velocity, unlike remotes.)**
YES — local body.Velocity is intentionally non-zero per tick, driven
by user input via `set_local_velocity`. Then
`UpdatePhysicsInternal` integrates `Position += Velocity*dt + 0.5*A*dt²`.
This matches retail's local-player model.
**(c) Does the outbound MoveToState packet match retail's wire format?**
YES. Wire layout in `Messages/MoveToState.cs` is byte-faithful to
holtburger's `RawMotionState::pack` + `MoveToStateActionData::pack`,
which is itself ground-truth from a working Rust client. Per-axis
HoldKey (forward/sidestep/turn) is sent. CurrentStyle (0x2) is
omitted intentionally; OK for walking. AftCommand/AftSpeed/AftHoldKey
not sent — fine until swimming/sailing ships.
**(d) Is the local sequencer driven by `UpdatePlayerAnimation`
matching retail's `MotionTable::SetState`?**
YES. The cycle picker (Airborne→Falling > LocalAnim > wire forward >
sidestep > turn > Ready), `NonCombatStance` overlay, sequencer
`SetCycle` invocation all mirror retail. Speed decoupling
(LocalAnimationSpeed vs wire ForwardSpeed) is acdream-original but
correct for ACE-quirk-driven backward/strafe pacing.
**(e) Are there any acdream-specific hacks/workarounds in the local
player path?**
YES. Five distinct hacks, all acknowledged in code comments:
1. **Hand-rolled velocity overrides at L378411 + L466501**
workaround for missing `adjust_motion` port. Both grounded and jump
paths are affected. **Top priority to fix** alongside the L.3
refactor; same root cause as remote-side `apply_current_movement`
issues.
2. **`ApplyServerRunRate`** (§1.2) — directly stuffs server's
ForwardSpeed into InterpretedState. Should be replaced with
PlayerDescription parse → `_weenie.SetSkills(...)`.
3. **Yaw float + mouse-turn sensitivity** — retail uses turn commands
from `IssueAxisCommand` for ALL turn input (keyboard + mouse). We
maintain a separate Yaw and rebuild Orientation each frame.
4. **`JumpChargeRate = 2.0/s`** — retail divisor unrecovered from
PDB; tuned for feel. Cited in code comment at L150155.
5. **Wall-bounce landing suppression** (L638640) — more
conservative than retail's strict rule, justified by acdream's
per-frame architecture amplifying micro-bounce on landing.
**(f) How is the local player's runRate sourced?**
Three layers, in priority order:
1. `_lastSeenRunSkill` from PlayerDescription parse (issue #7, NOT
YET WIRED — code at L1565 is dormant).
2. `ACDREAM_RUN_SKILL` env var (default 200) → constructor → weenie
formula.
3. `ApplyServerRunRate(forwardSpeed)` echo from inbound UM —
overwrites `InterpretedState.ForwardSpeed` directly, bypassing the
weenie formula.
**Retail-faithful?** PARTIALLY. The formula
`(loadMod × runSkill/(runSkill+200) × 11 + 4)/4` is byte-exact
retail. But the data source is wrong: retail's local
`CWeenieObject::InqRunRate` reads the player's actual server-synced
RunSkill, not an env var. Until issue #7 ships, low-skill characters
(< 200) and high-skill characters (> 800) will mispredict.
**(g) Does the local player have any `IsPlayerGuid`-style gates that
would also need cleanup?**
The local-player path AT THE PER-FRAME UPDATE level is gated only by
`_playerMode && _playerController != null`, which is appropriate
(it's the actor side). The IsPlayerGuid gates in audit 06
(`OnLivePositionUpdated`, `OnLiveMotionUpdated`, etc.) all SKIP
the local player guid (e.g. `if (update.Guid == _playerServerGuid)
return;` patterns) — that's correct because the local player's state
is owned by `_playerController`, not by the dead-reckoning struct.
The only place where IsPlayerGuid logic touches local state is
`ApplyServerRunRate` (§1.2): an inbound UM addressed to the local
player echoes ForwardSpeed. That's fine; not a gate to remove.
**No IsPlayerGuid gates to clean up on the actor side.** All cleanup
in audit 06 is on the observer/remote side.
---
# Summary
## (a) Is local player motion already retail-faithful?
**Mostly yes, with two known divergences.** The pipeline shape
(input → DoMotion → integrate → collision sweep → outbound MTS) is
retail-faithful and well-cited. The 30 Hz physics-tick gate, jump
charge formula, wall-bounce reflection, ground/landing detection,
HoldKey wire encoding, and 1 Hz heartbeat all match named-retail and
the 2026-05-01 cdb trace. The outbound MoveToState + AutonomousPosition
packet builders are byte-faithful to holtburger's reference
implementation.
The two divergences:
1. **Hand-rolled velocity overrides** (L378411, L466501) work around
the missing `adjust_motion` (FUN_00528010) port. Backward/
strafe-left commands are translated to WalkForward/SideStepRight
with negative speeds in retail before they reach InterpretedState;
acdream skips the translation and re-derives the velocity manually
downstream. Result is correct but architecturally diverged.
2. **RunSkill data source** is env var (default 200) plus
ApplyServerRunRate echo, instead of the server's authoritative
PlayerDescription value. Causes mispredicted local velocity for
non-default skill characters.
Both are pre-existing tech debt, not L.3-specific. The local-player
audit found NO L.3-introduced regressions analogous to the remote-side
`apply_current_movement`-per-tick bug.
## (b) Top 3 things that need to change
1. **Port `adjust_motion` (FUN_00528010) into `MotionInterpreter`.**
Translates WalkBackwards → WalkForward + speed×-0.65 and
SideStepLeft → SideStepRight + speed×-1 BEFORE InterpretedState
write. Once present, `get_state_velocity` returns correct vectors
for all motion commands and the hand-rolled overrides at L378411
+ L466501 can be deleted. Same fix benefits the jump path.
2. **Wire `PlayerDescription` (0xF7B0 / 0x0013) RunSkill+JumpSkill
`_weenie.SetSkills(...)`** (issue #7). Removes the env var
workaround and `ApplyServerRunRate` becomes a no-op (the weenie's
formula already produces the correct RunRate from the server's
skill). Velocity-prediction parity for arbitrary characters.
3. **Rationalise the heartbeat gate.** Retail's
`SendPositionEvent` gates AutonomousPosition on motion state
(Contact + OnWalkable + active velocity); acdream sends 1 Hz
unconditionally while in-world. Cdb trace 2026-05-01 confirmed
retail does not heartbeat at rest. Filing this — wasted
bandwidth + observer dead-reckon noise. Low-stakes vs (1) and
(2), but a clean behavioral diff worth fixing.
## (c) Shared-with-remote code paths that need to converge
`MotionInterpreter.apply_current_movement` is the convergence point.
Local must call it per tick (correct, retail-faithful); remote must
NOT (per L.3 spec). The function itself is fine; the call-site
discipline is what matters.
After the L.3 port:
- `PhysicsBody`, `AnimationSequencer`, `PhysicsEngine.ResolveWithTransition`,
`MotionInterpreter` (the type, not its per-tick invocation) all stay
shared and correct.
- `apply_current_movement`: local-player call (per tick + on UM echo)
remains; remote-player per-tick call gets removed (currently at
GameWindow.cs:6599).
- The shared `CurrentVelocity` accessor on `AnimationSequencer` (wired
to local via `AttachCycleVelocityAccessor`) gets a parallel
`PositionManager.ComputeOffset` consumer for remotes — same field,
different driver.
**No symmetry break required.** Local and remote can share the type
hierarchy; they diverge only on which functions they invoke per
tick. The L.3 port doesn't perturb the local-player path at all
beyond optionally fixing items (1)-(3) above as side improvements.
---
Path: `docs/research/2026-05-04-l3-port/14-local-player-audit.md`.