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

550 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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|OnWalkable|Active` flags and `ReportCollisions` state — gravity OFF by default, "remotes don't simulate gravity" comment at L420. Per L.3 spec this is **the correct retail invariant**. |
| `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|OnWalkable` transient bits + Gravity flag. We carry an extra bool because we toggle Gravity manually. |
| `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 > 96f` →
`SetPositionSimple`-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).