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

32 KiB
Raw Blame History

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

_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)

_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)

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:

_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)

_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)

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)

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:

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)

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)

_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 DoMotionDoInterpretedMotionapply_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)

_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.DoMotionapply_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.