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>
32 KiB
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.cssrc/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,SendGameActionplumbing.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.
_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
ForwardSpeedintoInterpretedStateon 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 fromapply_current_movementreading 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)
_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)
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:
_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:
-
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()). -
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:
_motion.jump(extent)— validates via retail FUN_00529390. PORT._motion.get_jump_v_z()— reads vz before LeaveGround zeroes extent (matches retail FUN_00529710 invocation order). PORT._motion.LeaveGround()— clears Contact+OnWalkable, sets Gravity, calls get_state_velocity into body.Velocity. PORT.- 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)
_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)
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)
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:
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)
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)
_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.csindirectly viaDoMotion→DoInterpretedMotion→apply_current_movement. PORT.PlayerMovementController.csjump path viaLeaveGround. PORT.MotionInterpreterinternally insideDoInterpretedMotion,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)
_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.CurrentVelocitydirectly viaPositionManager.ComputeOffset. Never callsapply_current_movement. body.Velocity stays 0 for grounded remotes.apply_current_movementstill fires onOnLiveMotionUpdatedfor 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:
- Hand-rolled velocity overrides at L378–411 + L466–501 —
workaround for missing
adjust_motionport. Both grounded and jump paths are affected. Top priority to fix alongside the L.3 refactor; same root cause as remote-sideapply_current_movementissues. ApplyServerRunRate(§1.2) — directly stuffs server's ForwardSpeed into InterpretedState. Should be replaced with PlayerDescription parse →_weenie.SetSkills(...).- Yaw float + mouse-turn sensitivity — retail uses turn commands
from
IssueAxisCommandfor ALL turn input (keyboard + mouse). We maintain a separate Yaw and rebuild Orientation each frame. JumpChargeRate = 2.0/s— retail divisor unrecovered from PDB; tuned for feel. Cited in code comment at L150–155.- 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:
_lastSeenRunSkillfrom PlayerDescription parse (issue #7, NOT YET WIRED — code at L1565 is dormant).ACDREAM_RUN_SKILLenv var (default 200) → constructor → weenie formula.ApplyServerRunRate(forwardSpeed)echo from inbound UM — overwritesInterpretedState.ForwardSpeeddirectly, 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:
-
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. -
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
-
Port
adjust_motion(FUN_00528010) intoMotionInterpreter. Translates WalkBackwards → WalkForward + speed×-0.65 and SideStepLeft → SideStepRight + speed×-1 BEFORE InterpretedState write. Once present,get_state_velocityreturns 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.
-
Wire
PlayerDescription(0xF7B0 / 0x0013) RunSkill+JumpSkill →_weenie.SetSkills(...)(issue #7). Removes the env var workaround andApplyServerRunRatebecomes a no-op (the weenie's formula already produces the correct RunRate from the server's skill). Velocity-prediction parity for arbitrary characters. -
Rationalise the heartbeat gate. Retail's
SendPositionEventgates 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
CurrentVelocityaccessor onAnimationSequencer(wired to local viaAttachCycleVelocityAccessor) gets a parallelPositionManager.ComputeOffsetconsumer 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.