docs(spec): Phase L.3 — Remote Entity Motion Conformance design

Port retail's InterpolationManager + MoveOrTeleport routing into
acdream so remote players, creatures, and NPCs stop popping at every
server position update and instead glide smoothly between sparse
authoritative updates the way retail does.

Three sub-lanes (incremental, each visually verifiable):
- L.3.1 — InterpolationManager core + routing + Omega + soft-snap teardown
- L.3.2 — PositionManager (root-motion + interp-offset combiner)
- L.3.3 — MoveToManager (server-controlled creature MoveTo)

This commit specs L.3.1 in detail and sketches L.3.2/L.3.3.

Research baseline (cdb live-trace + named-decomp dive 2026-05-02)
captured in docs/research/2026-05-02-remote-entity-motion/
resolved-via-cdb.md. All key constants confirmed from binary, not
guessed: MAX_PHYSICS_DISTANCE=96, MAX_INTERPOLATED_VELOCITY_MOD=2.0,
MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20,
DESIRED_DISTANCE=0.05, queue cap 20, stall window 5/30%/3.

Rollout: ACDREAM_INTERP_MANAGER=1 env-var gate during development
(dual-path), single cleanup commit after visual verification removes
the flag + old hard-snap path + dead RemoteMotion soft-snap fields.

Test plan: ~15 unit tests against the InterpolationManager class
(pure-data, no game/window deps). Visual verification primary —
parallel retail observer of +Acdream walking/running/strafing/
jumping/turning, all should glide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-02 18:12:18 +02:00
parent 77b59d89e2
commit 08cb7f9614
2 changed files with 595 additions and 0 deletions

View file

@ -0,0 +1,192 @@
# Remote-entity motion — open questions resolved via cdb live trace
**Date:** 2026-05-02
**Companion to:** the three agent research reports (paste them in alongside if needed; they live in worktree `adoring-torvalds-d796cf`, `sleepy-grothendieck-9d7483`, `gracious-wright-7af984`).
The three reports converged on the same overall pipeline (queued
position-chase via `InterpolationManager`) but diverged on three
specifics. We resolved all three with a focused cdb attach to the live
retail acclient.exe v11.4186.
## Resolution method
1. Static decomp dive into `MoveOrTeleport`, `InterpolateTo`,
`InterpolationManager::adjust_offset`, `set_velocity`,
`GetAutonomyBlipDistance`, `set_local_velocity`.
2. Constant-value lookup for every named distance/velocity referenced
by those functions (`.formats poi(acclient!NAME)`).
3. Live trace with bps on the routing functions while the user did a
~30-second mixed-motion scenario in retail (walk, strafe, jump,
run-jump). Captured per-bp hit counts + `set_velocity` caller
return addresses.
Scripts: `interp_discovery.cdb`, `interp_constants.cdb`,
`interp_const2.cdb`, `interp_trace.cdb`. Logs in worktree.
## Question 1 — distance threshold value(s)
**Answer:** the agents weren't disagreeing — **they were describing two
different thresholds doing two different jobs.**
| Threshold | Constant | Value | Where | Decision |
|---|---|---:|---|---|
| Routing gate | `MAX_PHYSICS_DISTANCE` | **96 m** | `CPhysicsObj::MoveOrTeleport` | within → InterpolateTo (queue); beyond → SetPositionSimple (slide-snap) |
| Enqueue blip | `GetAutonomyBlipDistance()` | 100 m outdoor / 20 m indoor (creature)<br>100 m outdoor / 25 m indoor (player) | `CPhysicsObj::InterpolateTo` | beyond → enqueue InterpolationNode; within → simpler path (set_heading + StopInterpolating) |
| Reach / duplicate-prune | `DESIRED_DISTANCE` | 0.05 m | `InterpolateTo` + `adjust_offset` | node "reached"; tail-prune duplicates |
The agents who said "96" cited `MAX_PHYSICS_DISTANCE` in MoveOrTeleport
correctly. The one who said "100/25" cited `GetAutonomyBlipDistance`
correctly but had the indoor-creature value off by 5 (it's 20, not 25 —
**25 is the PLAYER indoor distance**, used for self-correction not for
remote entities).
`GetAutonomyBlipDistance` decoded:
```c
float CPhysicsObj::GetAutonomyBlipDistance(this) {
bool isPlayer = (this == CPhysicsObj::player_object);
bool isIndoor = (this->cell_id & 0xFFFF) >= 0x100; // 2-byte cell IDs are dungeon/inside
if (isPlayer) {
return isIndoor ? PLAYER_INSIDE_BLIP_DISTANCE // 25
: PLAYER_OUTSIDE_BLIP_DISTANCE; // 100
} else {
return isIndoor ? CREATURE_INSIDE_BLIP_DISTANCE // 20
: CREATURE_OUTSIDE_BLIP_DISTANCE; // 100
}
}
```
## Question 2 — polarity of the 96 m branch
**Answer: within 96 m → queue (InterpolateTo). Beyond 96 m → snap
(SetPositionSimple).** Decoded from the disasm AND confirmed by trace.
Disasm of `CPhysicsObj::MoveOrTeleport` at +0x60:
```
fld [esi+20h] ; this->distance_to_player
fcomp MAX_PHYSICS_DISTANCE ; vs 96.0
fnstsw ax
test ah, 5
jp +0x91 ; JP fires when distance > 96 → snap branch
; FALL-THROUGH (distance ≤ 96): queue path
call CPhysicsObj::InterpolateTo
+0x91: ; (distance > 96): snap branch
call PositionManager::StopInterpolating
call CPhysicsObj::SetPositionSimple
```
Trace results (~30 sec mixed motion in Holtburg, all entities visible
within ~30 m of camera):
| BP | Hits | Notes |
|---|---:|---|
| `MoveOrTeleport` | 207 | every inbound UpdatePosition |
| `InterpolateTo` | 207 | **100 % routed to queue** |
| `SetPositionSimple` | **0** | no slide-snaps |
| `SetPosition` | 0 | no teleports/no-cell |
Confirmed: every routing decision in the test went to the queue.
SetPositionSimple is the rare exception, only used when the entity is
beyond camera range of significance.
## Question 3 — does walking-remote use UpdatePosition velocity (`set_velocity`)?
**Answer: NO.** R3 was correct. Walking remote entities never call
`set_velocity`; their `m_velocityVector` stays at zero (or whatever
prior). Position progress is achieved entirely through `adjust_offset`
walking the body toward queued waypoints.
Trace evidence:
| BP | Hits | Notes |
|---|---:|---|
| `set_velocity` | 7 | All 7 had **caller = `0x00511534`** |
| `HandleVectorUpdate` (inbound 0xF74E) | 4 | jumps |
Resolved `0x00511534` → it's inside `CPhysicsObj::set_local_velocity`
(starts at `0x005114d0`). And `set_local_velocity` is what retail's
local-jump path (CMotionInterp::LeaveGround) uses to stuff the launch
velocity into the body. So:
- Local player jumps 4 times → `LeaveGround → set_local_velocity →
set_velocity` fires repeatedly (7 hits across 4 jumps; charge-frames
+ release).
- Inbound 0xF74E packets arrive (4) — these did NOT cause additional
`set_velocity` hits on remote physobjs in our window. Either retail
gates inside `DoVectorUpdate` based on entity type, or the velocity
field got applied via a different path that doesn't trip our bp.
- 207 walking-remote `UpdatePosition`s → **zero `set_velocity` hits**.
So **for walking remotes, `m_velocityVector` is zero and the
`UpdatePhysicsInternal` Euler integration `position += velocity*dt`
contributes nothing.** All visible motion is from `adjust_offset`
walking the body toward queue head.
## Bonus — the rest of the constants we needed
Resolved while we were in there:
| Constant | Value | Where used |
|---|---:|---|
| `MAX_INTERPOLATED_VELOCITY_MOD` | **2.0** | `adjust_offset` — multiplier on `minterp->get_adjusted_max_speed()` (the catch-up gain) |
| `MAX_INTERPOLATED_VELOCITY` | 7.5 m/s | `adjust_offset` fallback when minterp is unavailable |
| `MIN_DISTANCE_TO_REACH_POSITION` | 0.20 m | `adjust_offset` per-5-frame progress threshold |
| `max_velocity` | 50 m/s | `set_velocity` magnitude clamp |
| `BIG_DISTANCE` | 999999 m | sentinel |
| `CAMERA_MAP_DISTANCE` | 450 m | unrelated; map render only |
Constraint distances (used by the constraint sub-system, not the queue):
| Constant | Value |
|---|---:|
| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 |
| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 |
| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_START` | 5 |
| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 |
| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 |
| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 |
| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_START` | 5 |
| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 |
## Where this leaves the implementation plan
The picture is now fully drawn. To match retail, acdream needs to:
1. **Implement `InterpolationManager`** — FIFO queue (cap 20),
`InterpolateTo(targetPosition, isMovingTo)` enqueues with
GetAutonomyBlipDistance + DESIRED_DISTANCE prune, `adjust_offset(dt)`
per-tick walks toward head at `min(minterp.get_adjusted_max_speed() ×
2, MAX_INTERPOLATED_VELOCITY_FALLBACK 7.5) × dt`,
`NodeCompleted` pops on arrival within `DESIRED_DISTANCE 0.05`,
`UseTime` periodic stall detection (every 5 frames; if progress < 30 %
of expected → fail counter; > 3 fails → blip-to head).
2. **Implement `MoveOrTeleport` routing** in the inbound UpdatePosition
handler. Replace acdream's current hard-snap with:
- Stale-sequence (instance/position) → ignore.
- Teleport-sequence newer or no-cell → SetPosition (hard-snap).
- has_contact false → no-op.
- has_contact true && distance ≤ 96 → InterpolateTo.
- has_contact true && distance > 96 → SetPositionSimple slide-snap.
3. **Drop velocity-based dead-reckoning for walking remotes.** Remote
`m_velocityVector` should stay at zero unless an inbound 0xF74E
VectorUpdate sets it. The body's progress comes from `adjust_offset`,
not from Euler integration of state-derived velocity.
4. **Apply VectorUpdate.Omega.** Currently parsed but not applied — fix
to make jumping/turning remote arcs match.
acdream code that goes away when this lands:
- `RemoteMotion.SnapResidualDecayRate` and the soft-snap residual blend.
- The locally-recomputed velocity drive between UpdatePosition packets
(`apply_current_movement → get_state_velocity → Euler` path on remote
entities).
## Files
- `interp_discovery.cdb` / `.log` — symbol resolution + prologues
- `interp_constants.cdb` / `.log` — first constant lookup
- `interp_const2.cdb` / `.log` — remaining constant lookup
- `interp_trace.cdb` / `.log` — live routing distribution + set_velocity callers
- This doc consolidates the answers