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>
31 KiB
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 224–432)
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 — L2591–3214
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 |
2616–2646 | DIAG | Throw-away. |
Player-only RunRate echo via ApplyServerRunRate |
2649–2656 | PORT | Local player only. Out-of-scope for remote audit. |
Style preservation when stance==0 |
2667–2671 | PORT | Retail bulk-copy semantics confirmed by named decomp. |
Stop signal: command absent OR command.Value==0 → Ready |
2685–2707 | PORT | Retail FUN_0051F260 bulk-copy of Invalid. |
MoveTo seed via PlanMoveToStart(...) |
2687–2703 | PORT | Wraps ServerControlledLocomotion; aligns with retail MoveToManager::BeginMoveForward. |
| Skip-self block at 2757 (don't echo SetCycle for local player) | 2757–2761 | PORT | Local UM is authoritative on the local sequencer. |
| Action/Modifier/ChatEmote overlay route | 2764–2767, 2896–2906 | PORT | AnimationCommandRouter.Classify. |
InterpretedState bulk-copy of ForwardCommand/ForwardSpeed for ALL packets (including overlay) |
2842–2868 | PORT | Mirrors retail copy_movement_from (acclient_2013_pseudo_c.txt:293301). Speed sign preserved. |
| MoveTo path capture | 2870–2893 | PORT | World-converts OriginX/Y/Z. |
| Cycle picker: forward → sidestep → turn → Ready priority | 2918–2953 | 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 | 2989–3027 | 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 |
3066–3127 | PORT | Stops are explicit; mirrors retail StopMotion semantics. |
ObservedOmega formula seed: (π/2)×TurnSpeed signed |
3110–3127 | 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 |
3182–3213 | 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 |
3142–3160 | 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 | 3217–3236 | 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 — L3259–3317
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 — L3325–3423
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:
- ACE doesn't broadcast HasVelocity on player UPs.
- 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 — L3425–3824
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 (L3432–3464)
- World-converts the LB-local position via
(lbX-_liveCenterX)*192. - Snaps
entity.Position/entity.Rotationto server truth UNCONDITIONALLY. - Updates
_physicsEngine.ShadowObjectsfor non-self guids.
Verdict: PORT for the world conversion + entity snap. The
ShadowObjects.UpdatePosition mirrors retail change_cell /
AddShadowObject.
Env-var ON branch (L3512–3626)
Only runs when ACDREAM_INTERP_MANAGER=1.
- Hard-snaps
Body.Orientation = rot(L3516). - Tracks
LastServerZonly for grounded UPs (L3529). - Diagnostic VEL_DIAG block (L3537–3562).
- AIRBORNE NO-OP at L3570:
if (!IsGrounded) return;— mirrors retailMoveOrTeleport"no-op when has_contact==0" branch. - LANDING TRANSITION at L3577: clears Airborne, restores ground
flags, hard-snaps
Body.Position = worldPos, clearsInterpqueue, resets sequencer cycle out of Falling. - GROUNDED ROUTING at L3605: distance check
dist > 96f→SetPositionSimple-style snap (clear + write Body.Position); otherwise enqueue waypoint viaInterp.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 (L3628–3761) — THE LEGACY PATH
- Synthesises
serverVelocity = (worldPos - rmState.LastServerPos)/dtfor ALL remotes whenupdate.Velocityis null (L3634–3639). - Sets
rmState.Body.Position = worldPosUNCONDITIONALLY (L3650). - Hard-snaps
rmState.Body.Orientation = rot(L3686). - Adopts
update.Velocityif present, falls back to synth velocity for NPCs (L3705–3730). - HasVelocity<0.2 m/s magnitude →
StopCompletely+ sequencer Ready (L3712–3725). Verdict: PORT. - Calls
ApplyServerControlledVelocityCyclefor player remotes too (L3737–3757). Verdict: HACK (issue #39). - Final
entity.Position = rmState.Body.Positionsnap 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 (L6118–6445)
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 (L6194–6206) |
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 (L6225–6232) |
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 (L6247–6277) |
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, ...) (L6288–6373) |
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 (L6387–6421) |
HACK | Defensive against ACE not sending IsGrounded promptly. |
6. MaxSeqSpeedSinceLastUP diag (L6432–6441) |
DIAG | Tracks max body-velocity magnitude for the VEL_DIAG ratio. |
Final: ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation (L6443–6444) |
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 L6131–6141 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 (L6446–6764)
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 (L6488–6492) | 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 (L6493–6511) |
HACK | Synth-velocity continuation. |
1b. NPC ServerMoveToActive branch with destination: RemoteMoveToDriver.Drive + apply_current_movement + ClampApproachVelocity (L6512–6587) |
PORT | Phase L.1c MoveTo per-tick steering. |
1c. ServerMoveToActive without destination → body.Velocity = 0 (L6588–6596) |
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 (L6622–6631) |
HACK | Same MinQuantum bypass as env-var path. |
3. body.calc_acceleration() + body.UpdatePhysicsInternal(dt) (L6651–6653) |
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(...) (L6674–6760) |
PORT | Same call as env-var path. |
| 4b. K-fix15 post-resolve landing (L6717–6759) | 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 (L653–673) 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() (L972–985) 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).MotionInterpreterinternally insideDoInterpretedMotion/StopInterpretedMotion/HitGround(called fromOnLiveMotionUpdated'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 L614–646 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 scalesCurrentVelocity *= factor,CurrentOmega *= factor. Mirrors retailmultiply_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 byOnLivePositionUpdated(hard-snap) AND by per-tickPosition += offsetin env-var path AND byUpdatePhysicsInternal'sPosition += Velocity*dt + 0.5*Accel*dt²AND byResolveWithTransition.Velocity— written byOnLiveVectorUpdated(jump launch),OnLivePositionUpdated(HasVelocity adoption),apply_current_movementviaset_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 aMinQuantum=1/30searly-return that silently skips integration on 60 Hz frames. This is why we callUpdatePhysicsInternaldirectly 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 fromOnLivePositionUpdatedenv-var path L3623.AdjustOffset(dt, currentBodyPosition, maxSpeedFromMinterp)— called fromPositionManager.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):
- Calls
interp.AdjustOffset(...)for the queue catch-up vector. - If catch-up is non-zero (queue active and body far from head), returns the catch-up directly — REPLACES the offset, doesn't add.
- 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. RetailMoveToManager::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 (L260–293) is acdream-original belt-and-braces
to prevent overshoot in the final tick. HACK but harmless.
Specific question answers (§§ refs in §1–§14)
-
Is
body.Velocityever non-zero for player remotes? Yes, every tick of the legacy default path (L6599apply_current_movement) writesbody.Velocity = RunAnimSpeed×ForwardSpeedin world frame. Also briefly during jumps (OnLiveVectorUpdated) and during stop UPs withHasVelocity~0. Per L.3 spec this should be 0 except during airborne arcs. The legacy path is the regression. -
Where is
apply_current_movementcalled? 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. -
Where is
body.Positionwritten? 21 sites inGameWindow.cs: 2 inOnLiveVectorUpdated-adjacent code (none direct on Position), ~5 inOnLivePositionUpdated(hard-snap), ~14 inTickAnimations(env-var: 2 direct + 1 via Resolve; legacy: 2 + 1 via Resolve; plus airborne/landing fallbacks). See L3585, L3614, L3650, L6232, L6332, L6397, L6699 + theae.Entity.Position = rm.Body.Positionmirrors at L3759, L6443, L6762. -
Per-tick render dt source? Silk.NET
OnRender(double deltaSeconds)→TickAnimations((float)deltaSeconds)at L5610. Variable; tied to render rate; no clamp before reachingUpdatePhysicsInternal/ComputeOffset.PhysicsBody.update_objectwould clamp this if used, but we bypass it via directUpdatePhysicsInternalcalls. -
Env-var vs legacy default relationship? Two parallel per-tick implementations forked at L6118 in
TickAnimations. Env-var path (L6118–6445) clearsbody.Velocity=0each tick and translates viaPositionManager.ComputeOffset(anim root motion OR queue catch-up). Legacy path (L6446–6764) callsapply_current_movementto writebody.Velocityfrom InterpretedState then integrates viaUpdatePhysicsInternal. They have parallelResolveWithTransitioncollision 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. -
What does
entity.Position(renderable) read from?body.Positiononly. Final assignment at L6443 (env-var) or L6762 (legacy) per tick; hard-snap to serverworldPosinOnLivePositionUpdated(L3451 then over-written tormState.Body.Positionat L3759). -
IsPlayerGuidgate sites: L706 (definition), L3349 (ApplyServerControlledVelocityCycleUM-grace branch), L3727 (OnLivePositionUpdatedvelocity-adoption fallback), L6493/L6512/L6588 (legacy per-tick branch selection between NPC paths and the catch-allapply_current_movementelse-branch). All five are guards that route player remotes through the brokenapply_current_movementpath. 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
- Remove
apply_current_movementfrom the per-tick remote path entirely. Replace withPositionManager.ComputeOffset(dt, body.Position, sequencer.CurrentVelocity, body.Orientation, interp, GetMaxSpeed())— the env-var path's translation source — but keep theResolveWithTransitioncollision sweep that the legacy path correctly includes.body.Velocitystays at 0 except during airborne arcs. - 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
InterpolationManagerqueue + animation root motion + collision sweep. Remove_remoteDeadReckon'sServerVelocity / HasServerVelocity / LastUmUpdateTime / LastServerZ / PrevServerPos*workaround fields. - Drop the
IsPlayerGuidper-tick gate. Retail runs the same motion pipeline for every entity; the special-casing inApplyServerControlledVelocityCycle(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).