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>
550 lines
31 KiB
Markdown
550 lines
31 KiB
Markdown
# 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|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` — 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:
|
||
|
||
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` — 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.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 (L3512–3626)
|
||
|
||
Only runs when `ACDREAM_INTERP_MANAGER=1`.
|
||
|
||
- Hard-snaps `Body.Orientation = rot` (L3516).
|
||
- Tracks `LastServerZ` only for grounded UPs (L3529).
|
||
- Diagnostic VEL_DIAG block (L3537–3562).
|
||
- **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 (L3628–3761) — THE LEGACY PATH
|
||
|
||
- Synthesises `serverVelocity = (worldPos - rmState.LastServerPos)/dt`
|
||
for ALL remotes when `update.Velocity` is null (L3634–3639).
|
||
- 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 (L3705–3730).
|
||
- HasVelocity<0.2 m/s magnitude → `StopCompletely` + sequencer Ready
|
||
(L3712–3725). **Verdict: PORT.**
|
||
- Calls `ApplyServerControlledVelocityCycle` for player remotes too
|
||
(L3737–3757). **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 (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**).
|
||
- `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 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 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` (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)
|
||
|
||
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
|
||
(L6118–6445) clears `body.Velocity=0` each tick and translates via
|
||
`PositionManager.ComputeOffset` (anim root motion OR queue catch-up).
|
||
Legacy path (L6446–6764) 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).
|