acdream/docs/research/2026-05-04-l3-port/06-acdream-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

31 KiB
Raw Blame History

06 — Acdream audit: player remote-entity motion code as it stands

Date: 2026-05-04. Scope: every piece of code that touches a remote player character's motion between OnLiveMotionUpdated, OnLiveVectorUpdated, OnLivePositionUpdated, and the per-tick TickAnimations loop. Inputs:

  • src/AcDream.App/Rendering/GameWindow.cs (8346 LOC).
  • src/AcDream.Core/Physics/{MotionInterpreter,AnimationSequencer, PhysicsBody,InterpolationManager,PositionManager, ServerControlledLocomotion,RemoteMoveToDriver}.cs.

Verdict labels used below: PORT (faithful retail port, retail address cited), HACK (acdream-original logic with no retail equivalent), or BROKEN (regressed/wrong vs. the retail spec; see ISSUES.md / file header comments).


1. GameWindow.RemoteMotion (lines 224432)

Per-remote nested struct stored on _remoteDeadReckon[serverGuid]. One allocation per remote, lives until despawn. Owns:

Field Verdict Notes
PhysicsBody Body PORT Retail CPhysicsObj (acclient @0x00510000 region). Constructed with `Contact
MotionInterpreter Motion PORT Retail CMotionInterp. Body wired in ctor.
LastServerPosTime/LastServerPos HACK Diagnostic + dt-source for the legacy update_object path.
ServerVelocity / HasServerVelocity HACK Acdream-only: synthesises velocity from (pos - prevPos)/dt because ACE rarely sets HasVelocity on player UPs. Used only by ApplyServerControlledVelocityCycle. Not present in retail.
ServerMoveToActive PORT Bridges MovementType::MoveToObject/Position (6/7) to per-tick driver. Retail sets equivalent in MoveToManager.
LastUmUpdateTime HACK 200 ms grace window on UM authority for player remotes. Workaround for "Shift toggles Run↔Walk without firing a fresh UM" (issue #39). Not retail.
MoveToDestinationWorld / MoveToMinDistance / MoveToDistanceToObject / MoveToMoveTowards / HasMoveToDestination / LastMoveToPacketTime PORT Phase L.1c MoveTo state. Used by RemoteMoveToDriver.Drive.
TargetOrientation DEAD Comment: "legacy field — no longer used for slerp". Should delete.
ObservedOmega HACK Per-tick rotation rate seeded from (π/2)×TurnSpeed formula in OnLiveMotionUpdated. Bypasses PhysicsBody.update_object's 30 Hz quantum gate. Necessary because update_object skips most 60 Hz frames — a real port problem.
CellId PORT High 16 bits = LBxLBy; fed into ResolveWithTransition.
Airborne HACK Set by OnLiveVectorUpdated when launch velocity has +Z>0.5; cleared by post-resolve IsOnGround && Vel.Z<=0. Retail tracks airborne via `Contact
InterpolationManager Interp PORT Owned per-remote. Only consumed when ACDREAM_INTERP_MANAGER=1.
PositionManager Position PORT Same.
LastServerZ HACK Landing-fallback floor for env-var path (gravity drift recovery). Not retail.
PrevServerPos / *Time / Last*LogTime / MaxSeqSpeedSinceLastUP DIAG All gated on ACDREAM_REMOTE_VEL_DIAG=1.

Code-health note: the struct has 31 public fields spanning physics state, server-snapshot cache, MoveTo path, diagnostic throttles, and landing-fallback metadata. About half are workarounds for problems the retail port doesn't have once we follow the spec; the L.3 refactor should be able to remove ServerVelocity/HasServerVelocity, LastUmUpdateTime, LastServerZ, PrevServerPos*, Max*, and TargetOrientation.


2. GameWindow.OnLiveMotionUpdated — L25913214

Inbound 0xF74C UpdateMotion handler. Receives an EntityMotionUpdate with stance + ForwardCommand + ForwardSpeed + SideStepCommand + TurnCommand

  • optional MoveTo path payload. Roughly 600 lines.

Reads: update.MotionState fields, ae.Sequencer.{CurrentStyle, CurrentMotion}, _remoteDeadReckon[guid], _animatedEntities, env vars.

Writes: sequencer via SetCycle and RouteFullCommand/RouteWireCommand; rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed} (bulk-copy); rm.ServerMoveToActive; rm.LastUmUpdateTime; rm.MoveToDestination*; rm.HasMoveToDestination; rm.ObservedOmega (formula seed); also calls rm.Motion.DoInterpretedMotion/StopInterpretedMotion for sidestep/turn axes.

Verdict per sub-block

Sub-block L# Verdict Notes
Diag [UM_RAW] and ACDREAM_DUMP_MOTION blocks 26162646 DIAG Throw-away.
Player-only RunRate echo via ApplyServerRunRate 26492656 PORT Local player only. Out-of-scope for remote audit.
Style preservation when stance==0 26672671 PORT Retail bulk-copy semantics confirmed by named decomp.
Stop signal: command absent OR command.Value==0 → Ready 26852707 PORT Retail FUN_0051F260 bulk-copy of Invalid.
MoveTo seed via PlanMoveToStart(...) 26872703 PORT Wraps ServerControlledLocomotion; aligns with retail MoveToManager::BeginMoveForward.
Skip-self block at 2757 (don't echo SetCycle for local player) 27572761 PORT Local UM is authoritative on the local sequencer.
Action/Modifier/ChatEmote overlay route 27642767, 28962906 PORT AnimationCommandRouter.Classify.
InterpretedState bulk-copy of ForwardCommand/ForwardSpeed for ALL packets (including overlay) 28422868 PORT Mirrors retail copy_movement_from (acclient_2013_pseudo_c.txt:293301). Speed sign preserved.
MoveTo path capture 28702893 PORT World-converts OriginX/Y/Z.
Cycle picker: forward → sidestep → turn → Ready priority 29182953 HACK-ish Retail's apply_current_movement doesn't pick cycles like this; the SUB_STATE animation choice in retail is driven by the wire's ForwardCommand directly. Acdream's picker exists because we synthesise locomotion velocity from the cycle — we need a cycle to play even when only Sidestep or Turn axes are populated. Re-evaluate during port.
Skip-cycle-swap when airborne (K-fix17) 2966 HACK Workaround for ACE broadcasting UMs mid-arc that would otherwise stomp Falling. Retail handles this via the substate priority; we hack around it.
Cycle-fallback chain RunForward → WalkForward → Ready 29893027 HACK Defensive against MotionTables missing the requested cycle. Retail behavior unknown — likely a port artifact from MoveTo always seeding RunForward. Could be removed if upstream cycle picker matches retail substate.
DoInterpretedMotion(side/turn axis) + StopInterpretedMotion for absent axes 30663127 PORT Stops are explicit; mirrors retail StopMotion semantics.
ObservedOmega formula seed: (π/2)×TurnSpeed signed 31103127 HACK Compensates for update_object MinQuantum 30 Hz gate. Comment at 6610 explicitly notes this is to bypass that gate. A real retail port wouldn't need this — it would call UpdatePhysicsInternal directly OR fix the update_object substepping.
Commands[] list iteration, skipping SubState entries 31823213 PORT-with-FIX 2026-05-03 fix: ACE bundles a Ready into the Commands[] list of a RunForward UM, which our router used to re-cycle to Ready right after we set RunForward. Skipping SubState class entries restored the cycle.
enteringLocomotion timestamp refresh 31423160 HACK Stop-detection timer reset; the legacy stop-detection loop has been removed but this remnant still pokes _remoteLastMove and LastServerPosTime. Should be removable.
Legacy non-sequencer path 32173236 DEAD-ish All player remotes have a sequencer — only fired for entities without MotionTable (rare).

Critical observation: every UM unconditionally bulk-copies into InterpretedState.{ForwardCommand,ForwardSpeed}. That's correct vs. retail. But it's the bulk-copy that arms apply_current_movement to write body.Velocity = RunAnimSpeed × ForwardSpeed. Per the L.3 spec, for remote players body.Velocity should always be 0 — meaning apply_current_movement must NOT be called per tick on remote bodies. The legacy path at L6599 calls it. The env-var path at L6174 explicitly does NOT (and clears body.Velocity to zero each tick at L6205). The two paths are philosophically opposed.


3. GameWindow.OnLiveVectorUpdated — L32593317

Inbound 0xF74E VectorUpdate handler (jump/launch).

Reads: update.{Velocity,Omega}, _remoteDeadReckon, _entitiesByServerGuid, _animatedEntities.

Writes: rm.Body.{Velocity,Omega}, rm.Body.TransientState (clears Contact+OnWalkable), rm.Body.State (sets Gravity), rm.Airborne=true, ae.Sequencer.SetCycle(Falling, skipTransitionLink:true).

Verdict: PORT. Mirrors retail SmartBox::DoVectorUpdate (@0x004521C0). Sets velocity AND omega, K-fix9 marks airborne, K-fix10 swaps Falling cycle, K-fix18 skips link. Threshold Velocity.Z>0.5 to gate Airborne is acdream-original but harmless.

Skips: local player guid (_playerServerGuid).


4. GameWindow.ApplyServerControlledVelocityCycle — L33253423

Helper that classifies a server-derived velocity into a Walk/Run/Ready cycle and writes it to both the sequencer (visible cycle) and InterpretedState (body velocity feed).

Reads: rm.{Airborne,LastUmUpdateTime,ServerMoveToActive}, ae.Sequencer.{CurrentMotion,CurrentStyle,CurrentSpeedMod}, env vars.

Writes: ae.Sequencer.SetCycle(...), rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed}.

Verdict: HACK. This whole function exists because:

  1. ACE doesn't broadcast HasVelocity on player UPs.
  2. Retail clients don't broadcast a fresh UM on Shift-toggle Run↔Walk.

So we infer cycle from synthesised position-delta velocity. The 200 ms UM grace + IsPlayerGuid gate are workarounds for ACE-vs-retail timing asymmetries. None of this exists in retail. Issue #39.

The IsPlayerGuid(serverGuid) check at L3349 is one of three places we use the player-vs-NPC distinction to gate behavior — see §10.


5. GameWindow.OnLivePositionUpdated — L34253824

Inbound 0xF748 UpdatePosition handler. Roughly 400 lines, with TWO distinct code paths (env-var ON vs OFF).

Reads: update.{Guid,Position,IsGrounded,Velocity}, _entitiesByServerGuid, _remoteDeadReckon, _liveCenterX/Y, _playerController.{State,Position,StepUpHeight}, _physicsEngine.

Writes: entity.{Position,Rotation}, rmState.Body.{Position, Velocity,Orientation,LastUpdateTime,TransientState,State}, rmState.{Airborne,CellId,LastServerPos,LastServerPosTime,LastServerZ, ServerVelocity,HasServerVelocity,TargetOrientation,Interp}.

Common prologue (L34323464)

  • World-converts the LB-local position via (lbX-_liveCenterX)*192.
  • Snaps entity.Position/entity.Rotation to server truth UNCONDITIONALLY.
  • Updates _physicsEngine.ShadowObjects for non-self guids.

Verdict: PORT for the world conversion + entity snap. The ShadowObjects.UpdatePosition mirrors retail change_cell / AddShadowObject.

Env-var ON branch (L35123626)

Only runs when ACDREAM_INTERP_MANAGER=1.

  • Hard-snaps Body.Orientation = rot (L3516).
  • Tracks LastServerZ only for grounded UPs (L3529).
  • Diagnostic VEL_DIAG block (L35373562).
  • AIRBORNE NO-OP at L3570: if (!IsGrounded) return; — mirrors retail MoveOrTeleport "no-op when has_contact==0" branch.
  • LANDING TRANSITION at L3577: clears Airborne, restores ground flags, hard-snaps Body.Position = worldPos, clears Interp queue, resets sequencer cycle out of Falling.
  • GROUNDED ROUTING at L3605: distance check dist > 96fSetPositionSimple-style snap (clear + write Body.Position); otherwise enqueue waypoint via Interp.Enqueue(...).

Verdict: PORT of CPhysicsObj::MoveOrTeleport (acclient @0x00516330) but with one regression: the env-var per-tick path (§7) drops the collision sweep — see ISSUES.md #40. The OnLivePositionUpdated side here is correct; the regression is in TickAnimations.

Env-var OFF branch (L36283761) — THE LEGACY PATH

  • Synthesises serverVelocity = (worldPos - rmState.LastServerPos)/dt for ALL remotes when update.Velocity is null (L36343639).
  • Sets rmState.Body.Position = worldPos UNCONDITIONALLY (L3650).
  • Hard-snaps rmState.Body.Orientation = rot (L3686).
  • Adopts update.Velocity if present, falls back to synth velocity for NPCs (L37053730).
  • HasVelocity<0.2 m/s magnitude → StopCompletely + sequencer Ready (L37123725). Verdict: PORT.
  • Calls ApplyServerControlledVelocityCycle for player remotes too (L37373757). Verdict: HACK (issue #39).
  • Final entity.Position = rmState.Body.Position snap at L3759.

Verdict overall: HACK. The synth-velocity machinery is the acdream-only solution to ACE's wire shape. Retail does NOT do this; retail does pure waypoint-queue interpolation (the env-var branch).


6. GameWindow.TickAnimations — env-var path (L61186445)

Per-frame remote-motion tick when ACDREAM_INTERP_MANAGER=1. Marked DO-NOT-ENABLE per ISSUES.md #40.

dt source: OnRender passes (float)deltaSeconds from Silk.NET's OnRender(double deltaSeconds) callback (L5610). This is the render-frame dt — variable, not stable. Typically ~16 ms at 60 Hz, but spikes during landblock loads, GC, stalls.

Step-by-step

Step Verdict Notes
1. Force Contact+OnWalkable+Active for grounded; clear body.Velocity = 0 (L61946206) PORT "Body Velocity should be 0 for grounded remotes" — exactly the L.3 spec invariant.
2. PositionManager.ComputeOffset(...) returns either queue catch-up OR animation root motion; body.Position += offset (L62256232) PORT Mirrors CPhysicsObj::UpdatePositionInternal + InterpolationManager::adjust_offset. The REPLACE semantics in PositionManager.ComputeOffset match retail.
2.5. Apply ObservedOmega (or seqOmega) via manual Quaternion.Concatenate (L62476277) HACK Manual integration to bypass MinQuantum gate.
3. body.calc_acceleration() (L6283) PORT Retail FUN_00511420.
4. body.UpdatePhysicsInternal(dt) (L6286) PORT Retail FUN_005111D0. With body.Velocity=0 set in step 1, this is a no-op for grounded remotes — only airborne picks up gravity.
4b. _physicsEngine.ResolveWithTransition(preIntegrate, postIntegrate, ...) (L62886373) PORT Added by Commit B (039149a) to fix the missing-collision regression. Mirrors retail FUN_005148A0. Sphere dims 0.48 m radius / 1.2 m height / 0.4 m step-up/down.
5. Landing fallback: if airborne and Body.Z < LastServerZ - 0.5, force-land (L63876421) HACK Defensive against ACE not sending IsGrounded promptly.
6. MaxSeqSpeedSinceLastUP diag (L64326441) DIAG Tracks max body-velocity magnitude for the VEL_DIAG ratio.
Final: ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation (L64436444) PORT The renderable consumes body state.

Why marked broken: ISSUES.md #40 says the staircase-on-slope and position-blip bugs persist even with Commit B's collision sweep ported in. Comment at L61316141 acknowledges that body.Velocity=0 means pre/postIntegrate sweep input is just queue catch-up, which itself snaps in 1 Hz UP-cadence steps.


7. GameWindow.TickAnimations — legacy default path (L64466764)

Per-frame remote-motion tick when env var is unset. The CURRENT default in production builds.

Step-by-step

Step Verdict Notes
1. Force grounded transient flags (L64886492) HACK Stomps any airborne flag fluctuation; "remotes are server-authoritative".
1a. NPC HasServerVelocity branch: stale-after-X seconds → zero + cycle to Ready; otherwise body.Velocity = ServerVelocity (L64936511) HACK Synth-velocity continuation.
1b. NPC ServerMoveToActive branch with destination: RemoteMoveToDriver.Drive + apply_current_movement + ClampApproachVelocity (L65126587) PORT Phase L.1c MoveTo per-tick steering.
1c. ServerMoveToActive without destination → body.Velocity = 0 (L65886596) PORT Conservative hold.
1d. ELSE branch (everything else, including ALL player remotes): rm.Motion.apply_current_movement(...) (L6599) BROKEN per L.3 spec This is the bug. apply_current_movement reads InterpretedState.ForwardCommand=RunForward + ForwardSpeed and writes body.Velocity = RunAnimSpeed × ForwardSpeed × orientation. Per L.3 the player remote body should NEVER have non-zero velocity from this path; velocity should come solely from animation root motion + interpolation queue catch-up.
2. Manual omega integration via ObservedOmega (L66226631) HACK Same MinQuantum bypass as env-var path.
3. body.calc_acceleration() + body.UpdatePhysicsInternal(dt) (L66516653) PORT But because of 1d, body.Velocity is non-zero, so this double-integrates — the velocity drives translation here, then ResolveWithTransition resolves the Δ.
4. _physicsEngine.ResolveWithTransition(...) (L66746760) PORT Same call as env-var path.
4b. K-fix15 post-resolve landing (L67176759) HACK Same purpose as env-var step 5.
Final: ae.Entity.Position = rm.Body.Position (L6762) PORT

This is the path the user sees in production. The apply_current_movement call on every tick at L6599 is the central thing that the L.3 port has to remove for player remotes. Then we replace the per-tick translation source with animation-root-motion + Interpolation-queue catch-up (PositionManager.ComputeOffset from the env-var path) — but this time WITH the collision sweep retained.


8. MotionInterpreter (full file, 1023 LOC)

Verdict: PORT. All key methods cite retail addresses:

  • PerformMovement (FUN_00529a90), DoMotion (FUN_00529930), DoInterpretedMotion, StopMotion, StopInterpretedMotion, StopCompletely (FUN_00528a50), get_state_velocity (FUN_00528960), apply_current_movement (FUN_00529210), jump (FUN_00529390), get_jump_v_z, get_leave_ground_velocity, jump_is_allowed, contact_allows_move, LeaveGround, HitGround, GetMaxSpeed (CMotionInterp::get_max_speed @0x00527cb0).

apply_current_movement (L653673) gates on PhysicsObj.OnWalkable and calls set_local_velocity(get_state_velocity()). This is the single function that translates InterpretedState into body.Velocity. Per the L.3 spec, this must NOT be called per tick on remote players' bodies — only the local player.

GetMaxSpeed() (L972985) returns RunAnimSpeed × runRate (≈ 11.76 m/s for run-skill 200). This is the value passed to InterpolationManager.AdjustOffset as the catch-up speed cap.

Question 1 answer: body.Velocity is currently NON-ZERO for player remotes in the legacy default path, set every tick by apply_current_movement at L6599. Per L.3 spec it should always be 0. This is the regression to fix.

Question 2 answer: apply_current_movement is called from:

  • PlayerMovementController.cs:273 (local player — correct).
  • GameWindow.cs:6567 (NPC MoveTo steering — correct).
  • GameWindow.cs:6599 (legacy default per-tick for ALL non-MoveTo remotes — incorrect per L.3 spec).
  • MotionInterpreter internally inside DoInterpretedMotion / StopInterpretedMotion / HitGround (called from OnLiveMotionUpdated's sidestep+turn axis loops).

It produces: body.Velocity = world_rotation × Vector3(SidestepAnimSpeed×SideStepSpeed, WalkOrRunAnimSpeed×ForwardSpeed, 0), clamped to RunAnimSpeed × runRate.


9. AnimationSequencer (relevant fields, 1455 LOC total)

Verdict: PORT of retail Sequence. Relevant API surface for the remote-motion port:

  • CurrentVelocity (L246) — sequence-wide latest MotionData.Velocity × speedMod, body-local. Synthesised for known locomotion cycles (Walk/Run/SideStep) at L614646 because the Humanoid MotionTable ships HasVelocity=0 on those cycles. Synthesised values match retail constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25).
  • CurrentMotion / CurrentStyle / CurrentSpeedMod — sequencer's active cycle. Read by everywhere.
  • SetCycle(style, motion, speedMod, skipTransitionLink) — has fast-path for identical motion (sign-matched) → MultiplyCyclicFramerate. Cycle sign-flip path (e.g. positive→negative speed) takes the full rebuild branch. PORT.
  • MultiplyCyclicFramerate(factor) — scales every cyclic node's framerate AND scales CurrentVelocity *= factor, CurrentOmega *= factor. Mirrors retail multiply_cyclic_animation_framerate. PORT.
  • CurrentOmega — synthesised from TurnRight/TurnLeft motion (±π/2 × speedMod) when MotionData.Omega is silent. PORT.

CurrentVelocity is the canonical source for per-tick remote translation under the L.3 design. Already wired to MotionInterpreter.GetCycleVelocity for the local player path; for remotes, PositionManager.ComputeOffset reads it directly.


10. PhysicsBody (full file, 436 LOC)

Verdict: PORT. Every method cites a retail FUN address.

Key fields used by the per-tick remote path:

  • Position — written by OnLivePositionUpdated (hard-snap) AND by per-tick Position += offset in env-var path AND by UpdatePhysicsInternal's Position += Velocity*dt + 0.5*Accel*dt² AND by ResolveWithTransition.
  • Velocity — written by OnLiveVectorUpdated (jump launch), OnLivePositionUpdated (HasVelocity adoption), apply_current_movement via set_local_velocity, post-resolve landing (zero), and the env-var per-tick path (forced to 0 each tick).
  • Orientation — hard-snap on UP, manual integration via ObservedOmega in both per-tick paths.
  • update_object — has a MinQuantum=1/30s early-return that silently skips integration on 60 Hz frames. This is why we call UpdatePhysicsInternal directly from per-tick paths and do manual omega integration. The retail intent was sub-stepping for variable dt; our 60 Hz tick happens to fall under the gate.

Question 3 answer (body.Position write sites): see §6 list. Counted 21 distinct write sites in GameWindow.cs alone. The L.3 port should collapse this to a single canonical write per tick + a single hard-snap write in OnLivePositionUpdated.

Question 4 answer (per-tick render dt): L5610 — passed straight from Silk.NET OnRender(double deltaSeconds). It is render-frame-rate- dependent and not stable. This is a known issue: any code that uses this dt to integrate motion (e.g. body.UpdatePhysicsInternal(dt), PositionManager.ComputeOffset(dt, ...), manual omega step) is implicitly tied to render rate. Retail's update_object clamps dt to [MinQuantum, MaxQuantum=0.1, HugeQuantum=2.0] and sub-steps; we bypass that gate per §10.


11. InterpolationManager (full file, 329 LOC)

Verdict: PORT. Retail InterpolationManager::adjust_offset (@0x00555D30) + UseTime stall/blip (@0x00555F20). Constants verified from named binary. Only consumed when ACDREAM_INTERP_MANAGER=1 (i.e. the env-var path).

API surface used:

  • Enqueue(targetPosition, heading, isMovingTo) — called from OnLivePositionUpdated env-var path L3623.
  • AdjustOffset(dt, currentBodyPosition, maxSpeedFromMinterp) — called from PositionManager.ComputeOffset.
  • Clear() — on landing, on far-snap, on UP after stale.
  • IsActive — diagnostic.

Stall detection (5-frame window, progress_quantum, node_fail_counter) is faithfully ported. This whole machinery is currently unused in the default build — it's the env-var path's centerpiece.


12. PositionManager (76 LOC)

Verdict: PORT. Retail CPhysicsObj::UpdatePositionInternal (@0x00512c30) + InterpolationManager::adjust_offset (@0x00555D30).

ComputeOffset(dt, currentBodyPosition, seqVel, ori, interp, maxSpeed):

  1. Calls interp.AdjustOffset(...) for the queue catch-up vector.
  2. If catch-up is non-zero (queue active and body far from head), returns the catch-up directly — REPLACES the offset, doesn't add.
  3. If catch-up is zero (queue empty or body within DesiredDistance), returns animation root motion: Vector3.Transform(seqVel * dt, ori).

This is the canonical L.3 per-tick offset. Currently only consumed in the env-var path.


13. ServerControlledLocomotion (129 LOC)

Verdict: PORT-ish + HACK. Two functions:

  • PlanMoveToStart(moveToSpeed, runRate, canRun) — seeds RunForward (or WalkForward if !canRun) for an inbound MoveTo packet. Retail MoveToManager::BeginMoveForward + MovementParameters::get_command. PORT.
  • PlanFromVelocity(worldVelocity, currentMotion) — classifies a velocity into Ready/Walk/Run with hysteresis bands (Walk→Run at 3.90, Run→Walk at 3.43). HACK — this is the workaround for "ACE rarely sets HasVelocity on player UPs." Retail doesn't classify like this; it just plays the cycle the wire told it to play.

14. RemoteMoveToDriver (304 LOC)

Verdict: PORT. Retail MoveToManager::HandleMoveToPosition (@0x00529d80). Steers body orientation toward destination, fires arrival predicate, ports the ±20° HeadingSnapToleranceRad fudge. Used only for NPC MoveTo packets — not on the player-remote path. Out of scope for this audit's primary concern but listed because the spec asked.

ClampApproachVelocity (L260293) is acdream-original belt-and-braces to prevent overshoot in the final tick. HACK but harmless.


Specific question answers (§§ refs in §1§14)

  1. Is body.Velocity ever non-zero for player remotes? Yes, every tick of the legacy default path (L6599 apply_current_movement) writes body.Velocity = RunAnimSpeed×ForwardSpeed in world frame. Also briefly during jumps (OnLiveVectorUpdated) and during stop UPs with HasVelocity~0. Per L.3 spec this should be 0 except during airborne arcs. The legacy path is the regression.

  2. Where is apply_current_movement called? See §8. Three live call sites: PlayerMovementController (correct), GameWindow MoveTo steering (correct), GameWindow legacy per-tick for player remotes (wrong per L.3). It produces body-local (SidestepAnimSpeed×SideStepSpeed, RunAnimSpeed×ForwardSpeed, 0) then rotates by orientation.

  3. Where is body.Position written? 21 sites in GameWindow.cs: 2 in OnLiveVectorUpdated-adjacent code (none direct on Position), ~5 in OnLivePositionUpdated (hard-snap), ~14 in TickAnimations (env-var: 2 direct + 1 via Resolve; legacy: 2 + 1 via Resolve; plus airborne/landing fallbacks). See L3585, L3614, L3650, L6232, L6332, L6397, L6699 + the ae.Entity.Position = rm.Body.Position mirrors at L3759, L6443, L6762.

  4. Per-tick render dt source? Silk.NET OnRender(double deltaSeconds)TickAnimations((float)deltaSeconds) at L5610. Variable; tied to render rate; no clamp before reaching UpdatePhysicsInternal / ComputeOffset. PhysicsBody.update_object would clamp this if used, but we bypass it via direct UpdatePhysicsInternal calls.

  5. Env-var vs legacy default relationship? Two parallel per-tick implementations forked at L6118 in TickAnimations. Env-var path (L61186445) clears body.Velocity=0 each tick and translates via PositionManager.ComputeOffset (anim root motion OR queue catch-up). Legacy path (L64466764) calls apply_current_movement to write body.Velocity from InterpretedState then integrates via UpdatePhysicsInternal. They have parallel ResolveWithTransition collision sweeps (env-var added in Commit B as a regression fix). The env-var path is the L.3 architecture but is currently REGRESSED (issue #40 — staircase + blips). The legacy path is the production default but is fundamentally wrong vs L.3 spec.

  6. What does entity.Position (renderable) read from? body.Position only. Final assignment at L6443 (env-var) or L6762 (legacy) per tick; hard-snap to server worldPos in OnLivePositionUpdated (L3451 then over-written to rmState.Body.Position at L3759).

  7. IsPlayerGuid gate sites: L706 (definition), L3349 (ApplyServerControlledVelocityCycle UM-grace branch), L3727 (OnLivePositionUpdated velocity-adoption fallback), L6493/L6512/L6588 (legacy per-tick branch selection between NPC paths and the catch-all apply_current_movement else-branch). All five are guards that route player remotes through the broken apply_current_movement path. A port that drops the special player-vs-NPC distinction at the per-tick layer would invert all five.


Summary

(a) Code health for player remote motion

Mixed-to-poor. The retail-port primitives — MotionInterpreter, AnimationSequencer, PhysicsBody, InterpolationManager, PositionManager, and the inbound packet handlers' bulk-copy semantics — are individually faithful and well-cited. But the per-tick integration in GameWindow.TickAnimations has forked into two parallel paths (env-var-gated ACDREAM_INTERP_MANAGER=1 and legacy default), neither of which currently ships the right behavior. The legacy default calls apply_current_movement every tick on player remotes — directly violating the L.3 invariant that body.Velocity should be 0 for grounded remotes — and the env-var path drops too much (no body integration, body.Velocity forced to 0 even mid-jump-arc until the recent Commit B partial-fix). Worse, OnLiveMotionUpdated carries ~600 lines of cycle-picker logic, missing- cycle-fallback chains, ObservedOmega formula seeding, and IsPlayerGuid- gated workarounds that compensate for ACE wire shape vs. retail. The RemoteMotion struct has 31 fields, half of which are workaround state that the retail port shouldn't need.

(b) Top 3 things that need to change

  1. Remove apply_current_movement from the per-tick remote path entirely. Replace with PositionManager.ComputeOffset(dt, body.Position, sequencer.CurrentVelocity, body.Orientation, interp, GetMaxSpeed()) — the env-var path's translation source — but keep the ResolveWithTransition collision sweep that the legacy path correctly includes. body.Velocity stays at 0 except during airborne arcs.
  2. Collapse the env-var branch into the default and delete legacy. The fork is the bug; both paths should converge on the L.3 design with InterpolationManager queue + animation root motion + collision sweep. Remove _remoteDeadReckon's ServerVelocity / HasServerVelocity / LastUmUpdateTime / LastServerZ / PrevServerPos* workaround fields.
  3. Drop the IsPlayerGuid per-tick gate. Retail runs the same motion pipeline for every entity; the special-casing in ApplyServerControlledVelocityCycle (issue #39 hysteresis) and the 3 per-tick branch sites exist only because we synthesise velocity from position deltas. Once the per-tick translation is anim-root-motion driven, players and NPCs share one path and the gates can be inverted or removed.

(c) Path to written audit

docs/research/2026-05-04-l3-port/06-acdream-audit.md (this file).