# 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 L5182–5337 (per-frame Update + outbound), L6956–7103 (`UpdatePlayerAnimation`), L2638–2656 (server RunRate echo path), L7997–8062 (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 (L89–230) | 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 (L8021–8035). Retail-faithful values. | | `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150–155 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)` (L270–274) 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)` (L276–287) ```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 (L294–307) **Verdict: PORT.** When `State==PortalSpace`, returns zero-movement result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early return. #### 1.4.2 Turn input (L309–322) ```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 (L324–411) — 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 L378–411 entirely, letting `DoMotion` do its thing. #### 1.4.4 Jump path (L413–505) **Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).** The jump-charge logic at L420–428 (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`** (L466–501) 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 L378–411 logic. The comment at L443–460 acknowledges this explicitly: "Until adjust_motion is ported, we mirror the grounded-velocity computation." #### 1.4.5 Physics integration + 30 Hz gate (L507–535) ```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 (L538–574) ```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 (L578–686) 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 L638–640 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 L630–637. #### 1.4.8 Ground/landing detection (L688–720) ```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 (L725–795) 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 L26–34 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 (L797–831) ```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 (L833–845) ```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 0–10 fields, bits 11–31 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 (L7997–8062) ```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 (L5182–5337) 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 L5266–5288 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` (L6956–7103) 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 L378–411 to work around the missing `adjust_motion` port** for backward/ strafe-left. The same workaround appears in the jump path at L466–501. 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 L378–411 + L466–501** — 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 L150–155. 5. **Wall-bounce landing suppression** (L638–640) — 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** (L378–411, L466–501) 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 L378–411 + L466–501 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`.