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>
This commit is contained in:
Erik 2026-05-05 14:56:42 +02:00
parent a3f53c2644
commit de129bc164
18 changed files with 10721 additions and 190 deletions

View file

@ -0,0 +1,150 @@
# Full motion port — master plan
**Date:** 2026-05-04
**Scope:** Every motion-related code path. No bandaids, no workarounds.
**Source research:** all 14 docs in this directory.
---
## Critical reconciliation (research-vs-research)
Two prior research findings appeared to conflict:
- **Agent 02** (UM handling): "Retail does NOT velocity-dead-reckon walking remotes. m_velocityVector stays at zero."
- **Agent 09** (CPartArray): "Retail's actual locomotion comes from `CMotionInterp::get_state_velocity``CPhysicsObj::set_velocity``CTransition::transitional_insert`, NOT CSequence."
- **Agent 11** (UM dispatch deep): "`CPhysicsObj::DoInterpretedMotion` both pushes velocity (`set_local_velocity(get_state_velocity())`) AND drives the animation sequencer's per-axis cycle slot."
**Reconciliation:** Agent 02 was wrong. `m_velocityVector` IS non-zero for walking remotes — it gets written by `set_local_velocity` inside `DoInterpretedMotion`, which fires per-axis when an UM arrives AND on every per-tick `apply_current_movement` call. The original cdb trace agent 02 cited (`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`) likely sampled at a moment when `DoInterpretedMotion` had not yet fired (e.g., immediately post-spawn).
**Correct retail model for walking remote per-tick:**
```
1. Frame local = identity // var_40 cached
2. CPartArray::Update writes animation root motion into local
- Humanoid locomotion (Walk/Run/Sidestep): dat ships zero, so local.origin stays 0
- Non-locomotion cycles with baked PosFrames: local.origin = baked delta
3. PositionManager::adjust_offset:
- InterpolationManager::adjust_offset: when distance(body, head) >= 0.05m,
OVERWRITES local.origin with `direction × min(catchUpSpeed × dt, distance)`
(catch-up REPLACES animation root motion this frame)
- StickyManager::adjust_offset: when stuck, writes XY step + heading toward target
- ConstraintManager::adjust_offset: when leashed and grounded, scales/zeros local.origin
4. Frame::combine(out, m_position.frame, local)
- out.origin = m_position.origin + rotate(local.origin by m_position.l2gv)
- out.rotation = m_position.rotation · local.rotation
5. UpdatePhysicsInternal: gated on velocity² > 0
- out.origin += m_velocityVector × dt + 0.5 × accel × dt²
- For grounded walking remote: m_velocityVector = walk_pace_world (set per-tick by apply_current_movement → set_local_velocity → get_state_velocity). NON-ZERO.
- For idle (Ready cycle): get_state_velocity returns 0 → m_velocityVector = 0 → no-op.
6. transition() — sphere sweep from m_position to out
7. set_frame(resolved) — commit
```
**Implication for L.3 port already shipped (commit 91bf1e0):**
The L.3 commit ZEROS `body.Velocity` for player remotes and removes `apply_current_movement` from per-tick. This is **wrong direction**. It causes:
- Body advances only via queue catch-up (no velocity integration). Steady run lags.
- Orientation changes don't propagate (since velocity isn't recomputed per-tick to use latest orientation). Drift during turning.
- Stop case is fine (cycle goes Ready → seqVel=0 → no motion) but drift case is broken.
**Action:** revert the body.Velocity=0 change. Restore per-tick `apply_current_movement` for all remotes. Layer queue catch-up as ADDITIONAL local-frame contribution that REPLACES animation root motion (which is 0 for locomotion anyway). Sequence is exactly as agent 01 documented.
---
## What needs porting (consolidated)
### Phase A: Per-tick pipeline correction (Critical — fixes L.3 regression + drift)
1. **Restore per-tick `apply_current_movement` for all grounded remotes.** Remove the `IsPlayerGuid` zero-velocity branch added in commit 91bf1e0.
2. **Layer InterpolationManager catch-up before Frame::combine.** Local frame = identity, optionally overwritten by adjust_offset. Then composed with m_position. Then velocity integrates additively into the composed frame.
3. **Implement `Frame::combine` semantics correctly.** Out.origin = m_position.origin + rotate(local.origin by m_position.l2gv basis). Out.rotation = m_position.rotation · local.rotation.
### Phase B: Substepping (eliminates ObservedOmega)
4. **Port full `update_object` substepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum.** Constants: 0.0002s / (1/30)s / 0.1s / 2.0s.
5. **Eliminate manual `ObservedOmega` integration.** Once substepping works, retail's `omega` field on Body is integrated correctly per substep — no need for the formula seed `(π/2 × turnSpeed)`.
6. **Drop `RemoteMotion.ObservedOmega` field.**
### Phase C: UM handling rewrite (collapses 600-line cycle picker)
7. **Port `apply_interpreted_movement` (acclient @ 0x00528600).** Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue.
8. **Port `MotionTableManager`, `CMotionTable`, `MotionState`.** Each remote has its own (style, substate, substate_mod, modifier_head, action_head). Cycle decision per-axis through table lookups, not priority picker.
9. **Port `GetObjectSequence` (@ 0x00522860).** Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore).
10. **Collapse `OnLiveMotionUpdated` to `move_to_interpreted_state(wireInterpretedState)`.** Drop the 600-line cycle picker (forward → sidestep → turn → Ready priority). Drop the RunForward → WalkForward → Ready fallback chain (acdream-original, doesn't exist in retail).
11. **Port `adjust_motion` (FUN_00528010).** WalkBackward → WalkForward + speed×-0.65; SideStepLeft → SideStepRight + speed×-1. This is sender-side normalization. Local player path needs it too (drops hand-rolled overrides at PlayerMovementController.cs:378-411).
### Phase D: UP routing — full Branch A (hard teleport)
12. **Plumb `TeleportSequence` (u16) from `UpdatePosition` parser through `EntityPositionUpdate` to `OnLivePositionUpdated`.** Currently dropped at WorldSession.cs:110-114, 712-716.
13. **Port `update_times[]` array on RemoteMotion.** 4 slots: instance(8), position(0), teleport(4), force_position(6). Each is `u16` with 16-bit-wrap forward-test via `newer_event` helper.
14. **Implement Branch A:** when `newer_event(TELEPORT_TS, wire.teleport_seq)` OR `cell == 0` → call `teleport_hook` (CancelMoveTo + UnStick + StopInterpolating + UnConstrain + ClearTarget + report_collision_end) + `SetPosition` with flags 0x1012.
15. **Implement Branch B (queue) and Branch C (slide-snap)** correctly — these are ALREADY in commit 91bf1e0 modulo using `player_distance` (already fixed via S1). Keep.
### Phase E: VectorUpdate (jump) corrections
16. **Drop `OnLiveVectorUpdated`'s extra writes** (Gravity flag toggle, transient_state Contact/OnWalkable clear). Retail's `DoVectorUpdate` only writes `set_velocity` + `set_omega`. The Gravity flag transition is driven by `set_on_walkable(false)` 1→0 edge inside the per-tick path (post-LeaveGround).
17. **Port `set_on_walkable` 0→1 / 1→0 edge detection.** Drives LeaveGround/HitGround automatically without explicit Airborne flag.
18. **Drop `RemoteMotion.Airborne` bool.** Replace with `(state & Gravity) != 0` test.
19. **Replace K-fix15 landing heuristic** (Z-velocity-based) with retail's `contact_plane.N.z >= floor_z` (cosine of 49°) gate.
20. **Port `jumped_this_frame` write in `set_velocity`.** Currently missing in PhysicsBody.
### Phase F: Sticky + Constraint (port from scratch)
21. **Port `StickyManager` (acclient ~0x???)** — sticks body to target object at fixed cylinder radius. Activates via MoveToManager when `__inner0` bit 0x80 set. adjust_offset writes XY step + heading rotation; Z always zero.
22. **Port `ConstraintManager`** — leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it.
23. **Wire both into `PositionManager.adjust_offset` chain** in fixed order: Interp → Sticky → Constraint.
### Phase G: NPC convergence
24. **Drop `RemoteMotion.HasServerVelocity` / `ServerVelocity` synth machinery.** NPCs go through same per-tick pipeline as player remotes once Phase A lands.
25. **`RemoteMoveToDriver` audit:** add UseFinalHeading (bit 0x40 post-arrival rotation), Sticky-after-arrival (bit 0x80 latch-on), Contact-bit gating during mid-air. Per agent 07 research §"3 minor port gaps".
### Phase H: Local player cleanup (low-priority)
26. **Wire `PlayerDescription` (0xF7B0 / 0x0013) → `_weenie.SetSkills(...)`.** Drops `ACDREAM_RUN_SKILL` env var workaround (issue #7).
27. **Gate `AutonomousPosition` heartbeat on motion.** Don't send 1Hz heartbeat at rest (cdb confirmed retail doesn't).
28. **Apply `adjust_motion` to local player input.** Drops hand-rolled velocity overrides at `PlayerMovementController.cs:378-411` and `:466-501`.
### Phase I: Cleanup
29. **Delete env-var fork** (`ACDREAM_INTERP_MANAGER=1` path, ~430 lines of dead code).
30. **Remove `RemoteMotion` workaround fields:** `ServerVelocity`, `HasServerVelocity`, `LastServerZ`, `PrevServerPos*`, `Max*`, `TargetOrientation`, `ObservedOmega`. Down from 31 fields to ~10.
31. **Remove all `IsPlayerGuid` per-tick gates** (5 sites in audit). Same pipeline for all entities.
32. **Remove `ServerControlledLocomotion.PlanFromVelocity`** if `ApplyServerControlledVelocityCycle` is the only caller (already removed in commit 91bf1e0). Verify other consumers.
### Phase J: Tests + verification
33. **Update existing tests for new architecture.** InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement.
34. **Run full test suite, verify 0 new regressions.**
35. **Code review subagent on full port.**
36. **Visual verify with user — full motion test matrix.**
---
## Implementation order (strict serial)
Bottom-up, each phase must build green before next:
1. **Phase A** (per-tick correction) — undoes the L.3 regression. Smallest, surgical. Highest priority.
2. **Phase D** (hard-teleport branch) — additive plumbing, no regression risk.
3. **Phase B** (substepping) — replaces ObservedOmega. Medium risk.
4. **Phase E** (VectorUpdate corrections) — drops acdream workarounds. Medium risk.
5. **Phase G** (NPC convergence) — depends on Phase A solid.
6. **Phase F** (Sticky + Constraint) — additive ports.
7. **Phase C** (UM handling rewrite + cycle picker) — biggest single change. Replaces 600 lines.
8. **Phase H** (local player cleanup) — independent of others.
9. **Phase I** (cleanup) — last, after everything works.
10. **Phase J** (tests + review + verify) — gating ship.
---
## Acceptance
- All 3 reported user issues fixed:
1. Stop after running settles within ≤1 UP cycle (~200ms) — apply_current_movement reads Ready → velocity 0 → body stops.
2. Backward walk plays backward animation (no flip to forward run) — verified by ApplyServerControlledVelocityCycle removal in 91bf1e0.
3. Long co-run drift bounded by DesiredDistance (0.05m) — apply_current_movement per-tick keeps velocity rotated by current orientation.
- Build green.
- All existing tests pass (8 pre-existing failures unchanged; new tests added for per-axis dispatch).
- Code review passes.
- Visual verify by user.

View file

@ -0,0 +1,277 @@
# L.3 — Player remote motion: retail-faithful port plan
**Date:** 2026-05-04
**Source research:** [01-per-tick.md](01-per-tick.md), [02-um-handling.md](02-um-handling.md), [03-up-routing.md](03-up-routing.md), [04-interp-manager.md](04-interp-manager.md), [05-position-manager-and-partarray.md](05-position-manager-and-partarray.md), [06-acdream-audit.md](06-acdream-audit.md)
**Goal:** Reported user issues — body keeps walking after actor stops, backward walk regression flips to forward-run animation, position drift over time during co-running. Fix all three by porting retail's motion pipeline faithfully.
---
## What retail actually does (synthesized)
**Per-tick body advancement** (`CPhysicsObj::UpdateObjectInternal` @ acclient 0x005156b0 → `UpdatePositionInternal` @ 0x00512c30):
```
1. Frame local = identity // var_40 cached
2. CPartArray::Update writes animation root motion into local
- For locomotion cycles (Walk/Run/Sidestep), translation += seqVel × dt
- For Ready/idle cycles, no translation
3. PositionManager::adjust_offset(local, dt) modifies local in-place:
- InterpolationManager::adjust_offset:
* If queue empty OR body within DesiredDistance(0.05m) of head → no-op
(animation root motion drives)
* Else → OVERWRITE local.translation with direction × min(catchUpSpeed × dt, distance)
(catch-up REPLACES root motion this frame)
- StickyManager / ConstraintManager (deferred — niche features)
4. Frame::combine(out, m_position.frame, local) — compose with current world frame
5. UpdatePhysicsInternal Euler-integrates m_velocityVector × dt INTO out.origin
(gated on velocity² > 0; for walking remotes m_velocityVector=0 so no-op)
6. transition() — sphere sweep from m_position to out
7. set_frame(resolved) or SetPositionInternal(transition result)
```
**Critical retail invariants:**
- `m_velocityVector` is 0 for walking remotes. Only set by outbound jump (LeaveGround) + inbound 0xF74E VectorUpdate.
- ALL visible motion comes from animation root motion + InterpolationManager catch-up.
- Catch-up speed = `2 × motion_max_speed × dt` where `motion_max_speed` = current cycle's actual velocity magnitude.
- Same pipeline for every entity — no player-vs-NPC special-casing at per-tick layer.
**UM (UpdateMotion) handling** (`CMotionInterp::move_to_interpreted_state` @ 0x00528a90):
- Inbound 0xF74C → bulk `copy_movement_from` of all 7 InterpretedState fields (acdream already does this).
- **Stop signal is implicit**: flag 0x02 (forward) cleared → `ForwardCommand` defaults to `Ready`, `ForwardSpeed = 1.0`. No explicit "stop motion" packet.
- Backward-walk arrives pre-adjusted: sender's `adjust_motion` flips `WalkBackward → WalkForward + speed=-0.65×s`; receiver bulk-copies.
- Side-axis and turn-axis fire `DoInterpretedMotion` per axis (acdream already does this).
**UP (UpdatePosition) routing** (`CPhysicsObj::MoveOrTeleport` @ 0x00516330):
- Tri-state decision tree:
- **Hard teleport**: teleport-seq advanced OR cell == 0 → `SetPosition` with flags 0x1012 (Slide+Placement+SendPositionEvent). Body.Position changes immediately.
- **InterpolateTo (queue)**: grounded AND distance < 96m `position_queue` mutated; **Body.Position does NOT change**.
- **Slide-snap**: grounded AND distance ≥ 96m → `StopInterpolating` + `SetPositionSimple`.
- **Airborne**: no-op (gravity arc continues from launch velocity).
- Orientation rides the Position struct's Frame — never queued separately. Hard-snapped on UP.
**Constants verified from named binary:**
- `MAX_PHYSICS_DISTANCE = 96` m
- `CREATURE_OUTSIDE_BLIP_DISTANCE = 100` m
- `CREATURE_INSIDE_BLIP_DISTANCE = 20` m
- `MAX_INTERPOLATED_VELOCITY_MOD = 2.0`
- `MAX_INTERPOLATED_VELOCITY = 7.5` m/s (fallback when motion_max unavailable)
- `MIN_DISTANCE_TO_REACH_POSITION = 0.20` m
- `DESIRED_DISTANCE = 0.05` m
- `CREATURE_FAILED_INTERPOLATION_PERCENTAGE = 0.30`
- `StallCheckFrameInterval = 5` frames
- `StallFailCountThreshold = 3` fails
- Queue cap = 20
---
## Where acdream diverges (top issues from audit)
1. **Per-tick `apply_current_movement` on player remotes** (GameWindow.cs:6599) writes `body.Velocity = RunAnimSpeed × ForwardSpeed × orientation`. Retail spec: `body.Velocity` must be 0 for walking remotes. **This is the central regression.**
2. **Two parallel per-tick paths.** Env-var path (L6118-6445) is the L.3 architecture but regressed (issue #40). Legacy path (L6446-6764) is production default and fundamentally wrong vs L.3. Need to collapse into one correct path.
3. **`IsPlayerGuid` gates at 5 sites** route player remotes through the broken `apply_current_movement` else-branch. Retail uses one pipeline for all entities.
4. **InterpolationManager bugs** (per research 04):
- `progress_quantum` accumulates `step` (distance) instead of `dt` (time)
- Secondary stall check missing `/dt` factor
- Missing `NodeCompleted(0)` head-pop on stall (one bad waypoint stalls indefinitely)
- Missing `transient_state & 1` gate
- Missing far-distance force-blip via `_failCount = 4` on enqueue
5. **`CPartArray::Update` collapsed into single `seqVel × dt` per tick.** For locomotion cycles, both retail and acdream synthesize velocity from formula (Humanoid dat ships zero), so this is OK for the user's reported issues. The per-keyframe loop matters for non-locomotion (emotes etc) — defer.
6. **Cycle picker in OnLiveMotionUpdated is acdream-original** (forward → sidestep → turn → Ready priority). Retail just plays the cycle the wire told it to play. Defer; not the immediate cause of reported bugs.
---
## Port plan — concrete changes
Targeted at fixing all three reported user symptoms. Defers cosmetic divergences (cycle picker, full per-keyframe loop, sticky/constraint managers) to follow-up phases.
### Step 1: Fix `InterpolationManager.cs` bugs
File: `src/AcDream.Core/Physics/InterpolationManager.cs`
Changes (all from research doc 04, sections 3 + 7):
1. **`AdjustOffset`**: change `_progressQuantum += step;``_progressQuantum += (float)dt;`. Accumulate time, not distance.
2. **Secondary stall check**: change `cumulative / progressQuantum < 0.30``(cumulative / progressQuantum) / dt < 0.30`. Match retail formula.
3. **Stall handling**: when stall threshold exceeded, pop head node into a `_blipToPosition` field. Don't return snap delta inside AdjustOffset.
4. **Add `UseTime()` method**: separately performs the blip via `body.SetPositionSimple` when `_failCount > StallFailCountThreshold`. Called once per tick from per-tick path.
5. **`Enqueue`**: when distance from current body to enqueued position exceeds `MAX_PHYSICS_DISTANCE` (96m), pre-arm `_failCount = StallFailCountThreshold + 1` so next tick's `UseTime` blips immediately.
6. **Add `IsLive` parameter to AdjustOffset** corresponding to `transient_state & 1`. Default true; pass through.
Tests to add:
- `progress_quantum` accumulates dt, not step
- Stall after 3 windows pops head and arms blip
- `UseTime` calls `SetPositionSimple` when armed and clears state
- Far enqueue arms immediate blip
### Step 2: Unify the per-tick path in `GameWindow.TickAnimations`
File: `src/AcDream.App/Rendering/GameWindow.cs`
Delete the env-var fork. Single per-tick path for all remote entities (player or NPC). This is the bulk of the work — replace lines 6118-6764 (~640 LOC) with a single ~150 LOC retail-faithful port.
Per-tick algorithm (matching retail `UpdatePositionInternal`):
```csharp
// Step 0: Force grounded transient flags for non-airborne (no change)
if (!rm.Airborne) {
rm.Body.TransientState |= Contact | OnWalkable | Active;
rm.Body.Velocity = Vector3.Zero; // RETAIL INVARIANT: walking remotes have zero velocity
}
// Step 1: NPC MoveTo branch (existing — unchanged)
if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) {
/* RemoteMoveToDriver.Drive (existing — keep as-is) */
}
// Step 2: PositionManager.ComputeOffset returns either:
// - queue catch-up correction (when body drifted from head)
// - animation root motion (when at head OR queue empty)
// - zero (when queue empty AND seqVel zero — Ready cycle, idle observer)
var seqVel = ae.Sequencer?.CurrentVelocity ?? Vector3.Zero;
float maxSpeed = seqVel.Length(); // motion_max_speed = cycle's actual velocity
if (maxSpeed <= 0f) maxSpeed = MotionInterpreter.RunAnimSpeed; // 4.0 fallback
var preIntegratePos = rm.Body.Position;
var offset = rm.Position.ComputeOffset(dt, preIntegratePos, seqVel, rm.Body.Orientation, rm.Interp, maxSpeed);
var postIntegratePos = preIntegratePos + offset;
// Step 3: Manual omega integration (preserve existing — bypasses MinQuantum gate)
ApplyObservedOmega(rm, dt);
// Step 4: Physics integration. With body.Velocity=0 (set in step 0), this is a
// no-op for grounded remotes. For airborne remotes, gravity drives body.Position.
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
postIntegratePos = rm.Body.Position; // re-read in case airborne integration moved it
// Step 5: Collision sweep (preserve existing — Commit B added this for slope tracking)
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) {
var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: 0.48f, sphereHeight: 1.2f,
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
isOnGround: !rm.Airborne, body: rm.Body,
moverFlags: ObjectInfoState.EdgeSlide);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0) rm.CellId = resolveResult.CellId;
/* existing post-resolve landing detection — unchanged */
}
// Step 6: UseTime — fire stall blips for non-airborne entities with armed fail counter
if (!rm.Airborne) {
rm.Interp.UseTime(rm.Body);
}
// Step 7: Sync renderable
ae.Entity.Position = rm.Body.Position;
ae.Entity.Rotation = rm.Body.Orientation;
```
**Removed:** the entire `apply_current_movement` else-branch (current L6599) for player remotes. The NPC `HasServerVelocity` synth-velocity branch (current L6493-6511) — NPCs don't need this either, they should also use queue-based motion. Defer NPC migration to a follow-up if it risks regression; for this phase, keep the NPC HasServerVelocity branch but remove the player path.
**Conservative scope:** Player remotes get the L.3 path. NPCs keep their existing `HasServerVelocity` branch + `RemoteMoveToDriver`. Both can converge later.
### Step 3: Update `OnLivePositionUpdated` UP routing
File: `src/AcDream.App/Rendering/GameWindow.cs:3425-3824`
Replace the legacy default branch (L3628-3761) with the env-var branch's logic — but keep the synth-velocity computation for NPCs (which still uses it via `HasServerVelocity`).
For player remotes within 96m grounded: `Interp.Enqueue(worldPos, heading, isMovingTo:false)`. **No hard-snap** of `body.Position`.
For player remotes outside 96m or first UP: hard-snap + `Interp.Clear()`.
For airborne player remotes: existing landing-transition logic.
For NPCs: existing path (synth velocity, hard-snap, etc.) — preserve.
### Step 4: Drop `ApplyServerControlledVelocityCycle`
File: `src/AcDream.App/Rendering/GameWindow.cs:3325-3423`
This whole function exists because of issue #39 — Shift-toggle Run↔Walk doesn't fire a fresh UM. Per research doc 02, **retail's wire actually does fire fresh UMs on Shift-toggle** (because retail's outbound `apply_run_to_command` re-runs and produces a different `ForwardSpeed`). If our observed acdream-on-retail behavior shows UMs missing on Shift-toggle, that's an ACE bug — not something we should compensate for client-side.
Drop the function. Drop the call site at line 3791. Drop `RemoteMotion.LastUmUpdateTime`. Drop the `IsPlayerGuid` gates the function relies on.
If issue #39 reappears after this, file an ACE bug rather than re-adding client-side hysteresis logic.
### Step 5: Drop `IsPlayerGuid` per-tick gates
Five sites identified in audit:
- L706 (definition — keep, used elsewhere)
- L3349 (`ApplyServerControlledVelocityCycle` — dropped in Step 4)
- L3727 (UP velocity-adoption fallback — review, may stay for NPCs)
- L6493 (NPC HasServerVelocity branch — keep for now, NPCs)
- L6512 (NPC ServerMoveToActive branch — keep, NPCs)
- L6588 (NPC ServerMoveToActive without dest — keep, NPCs)
Effectively: drop the L3349 gate via Step 4. The remaining gates correctly route NPC paths.
### Step 6: Verify CPartArray velocity for locomotion cycles
File: `src/AcDream.Core/Physics/AnimationSequencer.cs`
`CurrentVelocity` synthesis at lines 614-646 already matches retail constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25). Per research doc 05, this is the right approximation for Humanoid (dat ships zero velocity). **No changes needed.**
For sign-flipped backward walk (`WalkForward + speed=-1`), `adjustedSpeed = speedMod` directly preserves the negative sign. `CurrentVelocity.Y = WalkAnimSpeed × -1 = -3.12`. Body root-motions backward in body-local frame. Rotated by orientation = backward in world frame. Correct.
### Step 7: Test, code review, visual verify
- Build: `dotnet build`
- Tests: existing `ServerControlledLocomotionTests` (7) should still pass; new `InterpolationManagerStallTests` for the bug fixes
- Code review subagent on the unified per-tick path
- Visual verify with user — full motion test matrix:
1. Steady run forward
2. Steady walk forward
3. Steady walk backward
4. Steady strafe right
5. Steady strafe left
6. Run + turn
7. Walk + turn
8. Run → Stop (release W)
9. Walk → Stop
10. Run → Shift toggle to walk
11. Walk → Shift release to run
12. Jump + land
---
## What this fix does NOT address (deferred)
- **Full per-keyframe `CPartArray::Update` loop** — for non-locomotion cycles (emotes, idle subtleties). Defer until visible bug.
- **StickyManager / ConstraintManager** — niche retail features (locked targets, etc).
- **Branch A (Hard teleport)** in MoveOrTeleport — needs `teleport_timestamp` plumbing through the protocol.
- **NPC migration to L.3 path** — keeps existing `HasServerVelocity` synth path; will converge later.
- **OnLiveMotionUpdated cycle picker** — current acdream-original logic. Retail just plays the wire's cycle directly. Defer if user-visible bugs don't depend on it.
---
## Acceptance
- All three reported user issues resolved:
1. Stop after running: body settles within ≤1 UP cycle (200ms) of UM(Ready) arrival.
2. Backward walk: body moves backward, animation plays backward (no flip to forward-run).
3. Long co-run: positional sync holds — drift bounded by `DesiredDistance` (0.05m).
- `dotnet build` green.
- `dotnet test` green (existing tests pass + new tests for InterpolationManager bug fixes).
- Code review pass on the unified per-tick path.
- Visual verify by user.
---
## Implementation order
Strict serial — each step must build green before next:
1. **InterpolationManager bug fixes** (Step 1) — small, isolated, testable in unit tests.
2. **Drop `ApplyServerControlledVelocityCycle`** (Step 4) — surgical removal.
3. **Unify per-tick path** (Step 2) — large change. Will need a code review after.
4. **Update UP routing** (Step 3) — surgical replacement of OnLivePositionUpdated default branch.
5. **Build + run tests** (Step 7).
6. **Visual verify with user** (Step 7).
Do NOT proceed past step 5 to user testing if any earlier step is incomplete or broken.

View file

@ -0,0 +1,613 @@
# L.3 Per-Tick Body Update Pipeline — Retail Reference
**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named, Binary Ninja pseudo-C).
**Scope**: How retail advances a `CPhysicsObj` (player or remote) **one physics tick**, from the entry call through animation-driven motion, physics integration, and collision resolution.
**Purpose**: Correct the env-var per-tick path in `GameWindow.cs` (~line 6428+) so it matches retail order-of-operations and behavior, especially for "walking remote" cases where `m_velocityVector` is supposed to stay zero.
---
## Top-level call graph
```
CPhysicsObj::UpdateObjectInternal(this, dt) @ 0x005156b0
└─ CPhysicsObj::UpdatePositionInternal(this, dt, &outFrame) @ 0x00512c30
│ ├─ CPartArray::Update(part_array, dt, &localFrame) @ 0x00517db0
│ │ └─ CSequence::update(&sequence, dt, &localFrame) @ 0x00525b80
│ │ ├─ CSequence::update_internal(...) // advances anim cursor
│ │ └─ CSequence::apply_physics(this, frame, dt, dt) @ 0x00524ab0
│ │ ├─ frame.m_fOrigin += dt * sequence.velocity // ROOT MOTION
│ │ └─ Frame::rotate(frame, dt * sequence.omega) // ROOT ROT
│ ├─ PositionManager::adjust_offset(pos_mgr, &localFrame, dt) @ 0x00555190
│ │ ├─ InterpolationManager::adjust_offset
│ │ ├─ StickyManager::adjust_offset
│ │ └─ ConstraintManager::adjust_offset
│ ├─ Frame::combine(outFrame, &m_position.frame, &localFrame) @ 0x005122e0
│ │ // outFrame = m_position.frame ⊗ localFrame (rotate-translate compose)
│ ├─ CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame) @ 0x00510700
│ │ // Euler integration of m_velocityVector + m_accelerationVector + m_omegaVector
│ │ // Modifies outFrame.m_fOrigin (translation) and rotation (Frame::grotate)
│ │ // Modifies m_velocityVector (acceleration step)
│ └─ CPhysicsObj::process_hooks(this)
├─ CPhysicsObj::transition(this, &m_position, &outFrame, 0) @ 0x00512dc0
│ └─ CTransition::find_valid_position(...) // BSP / sphere sweep
├─ CPhysicsObj::set_frame(this, &outFrame) @ 0x00514090 // (failure path)
│ OR
├─ CPhysicsObj::SetPositionInternal(this, transition) @ 0x00515330 // (success path)
└─ DetectionManager / TargetManager / MovementManager / CPartArray::HandleMovement / PositionManager::UseTime
```
---
## 1. `CPhysicsObj::UpdateObjectInternal(this, dt)` — entry
**Address**: `0x005156b0` (line 283611)
**Signature**: `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt)`
### What it does (plain English)
This is the **per-tick entry** for a physics object. It branches on `transient_state`:
- If `transient_state` has the *high bit* clear (i.e. `>= 0`, meaning the object is *passive* / dormant), it skips physics entirely and only ticks `ParticleManager` + `ScriptManager`.
- Otherwise it runs the **active path**: build a candidate next-frame, sweep collision, commit position, and tick all per-frame managers (Detection, Target, Movement, Position, parts).
### Order of operations (active path)
| # | Line | What happens |
|---|------|--------------|
| 1 | 283631 | If `transient_state[1] & 1``set_ethereal(this, 0, 0)` |
| 2 | 283634 | `this->jumped_this_frame = 0` |
| 3 | 283635-283643 | Local stack `Frame var_40` initialized to identity (qw=1, origin=0). Note `var_48` holds `0x796910` = `Position::vtable`. |
| 4 | 283644 | `Frame::cache(&var_40)` — recompute `m_fl2gv[0..8]` rotation matrix from quaternion |
| 5 | 283646 | **`UpdatePositionInternal(this, dt, &var_40)`** — fills `var_40` with the candidate next-frame |
| 6 | 283651-283655 | Check `part_array && CPartArray::GetNumSphere(part_array)` — does this object have a collision sphere? |
| 7a | 283655 (no spheres or zero-translation): set `m_position.frame = var_40` directly via `set_frame`, zero `cached_velocity`, no transition. |
| 7b | 283657 (has spheres AND moved): run `transition()` collision sweep |
| 7b1 | 283661-283670 | If `state[1] & 1` (Hooks?), set heading from translation direction; else if `(state & ScaledVelocity) && !is_zero(m_velocityVector)`, set heading from velocity. |
| 7b2 | 283673 | `transition(this, &m_position, &var_48, 0)` — sweeps sphere from current pos to candidate pos `var_48`. Note `var_48` here refers to the **Position** struct that has been built (the local var holds an embedded Position with vtable `0x796910` and frame `var_40`). |
| 7b3 | 283675-283696 | If `transition == nullptr` (no valid path): keep current frame via `set_frame(&var_40)`, zero `cached_velocity`. Otherwise: compute `cached_velocity = (final_pos - current_pos) / dt`, then `SetPositionInternal(this, transition)`. |
| 8 | 283733 | `DetectionManager::CheckDetection` |
| 9 | 283738 | `TargetManager::HandleTargetting` |
| 10 | 283743 | `MovementManager::UseTime` |
| 11 | 283748 | `CPartArray::HandleMovement` |
| 12 | 283753 | `PositionManager::UseTime` |
| 13 | 283755 | `goto label_5159b8` → ticks `ParticleManager` + `ScriptManager`, then returns |
### Side effects
- `this->m_position.frame` ← committed final pose (via `set_frame` or `SetPositionInternal`)
- `this->cell` ← may change (cell crossing)
- `this->cached_velocity` ← actual delta achieved this tick / dt (used elsewhere for collision profiles, etc.)
- `this->m_velocityVector` ← updated by `UpdatePhysicsInternal` (acceleration integration)
- `this->jumped_this_frame` ← cleared
- `this->contact_plane`, `this->sliding_normal`, `this->transient_state` bits 1/4/8 ← updated by `SetPositionInternal`
### Key conditions
- **Spheres-and-moved gate** at line 283655 + 283657: `transition()` is only called when the object has a sphere AND the candidate frame's origin moved (`!operator==(zero_vec, m_position.frame.m_fOrigin)`). The `x` here is a stack zero vector compared against current origin — if origin is at world-zero it skips. Wait, re-reading: actually `x` was overwritten on line 283641 to `0f`, then operator== between `&x` (stack zero) and `&this->m_position.frame.m_fOrigin`. This is checking "did `UpdatePositionInternal` move us off `m_position.frame.m_fOrigin`"? **No** — it's comparing the local stack vector `x` (zero) against `m_position`. This is effectively `is_zero(m_position)` which is almost always false. **Re-read more carefully:** at line 283641 `float x = 0f;` is just initializing 3 stack floats (Vector3 x/y/z). The compare is "is the player at world origin (0,0,0)?". That's a degenerate check. So the practical interpretation: spheres-with-collision is the gate, and the no-sphere path just commits `var_40` directly.
---
## 2. `CPhysicsObj::UpdatePositionInternal(this, dt, outFrame)` — build candidate frame
**Address**: `0x00512c30` (line 280817)
**Signature**: `void __thiscall CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)`
### What it does (plain English)
Builds the **candidate next-tick frame** in `outFrame`. Three contributions, in order:
1. **Animation root motion**`CPartArray::Update` calls `CSequence::apply_physics`, which writes `localFrame.origin = dt * sequence.velocity` and rotates `localFrame` by `dt * sequence.omega`. This is the per-cycle baked velocity from the current MotionData (e.g., RunForward emits ~4 m/s on +Y in body local).
2. **Position-manager offset**`PositionManager::adjust_offset` lets `InterpolationManager`, `StickyManager`, `ConstraintManager` mutate the local frame.
3. **Compose with current world pose**`Frame::combine(outFrame, &this->m_position.frame, &localFrame)` rotates the local-frame translation into world space and adds it to current world pos; multiplies quaternions.
4. **Physics integration on the composed frame**`UpdatePhysicsInternal` Euler-integrates `m_velocityVector` and `m_omegaVector` *into the same outFrame* (this is the only place generalized velocity is consumed — see §4).
### Order of operations
| # | Line | What happens |
|---|------|--------------|
| 1 | 280820-280826 | Init local `var_40` = identity Frame, plus `var_c/var_8/var_4 = 0` (these are 3 floats that look like a `Vector3 localTranslation` slot). |
| 2 | 280827 | `Frame::cache(&var_40)` — populate rotation matrix `m_fl2gv[0..8]` (will be re-cached after `apply_physics`). |
| 3 | 280829 | If `(state[1] & 0x40) == 0` (i.e. `PartsArray::Frozen` flag NOT set): |
| 3a | 280834 | `CPartArray::Update(part_array, dt, &var_40)` — anim root motion goes into `var_40` |
| 3b | 280836-280848 | If `transient_state & 2` (Stuck/Active?), scale the captured `var_c/var_8/var_4` (looks like a velocity scratch vector) by `m_scale`; else zero them. **This branch's effect on `var_40` is unclear from the decomp — possibly dead/diagnostic code.** |
| 4 | 280853-280857 | If `position_manager != nullptr``PositionManager::adjust_offset(pm, &var_40, dt)` |
| 5 | 280860 | `Frame::combine(outFrame, &this->m_position.frame, &var_40)` — outFrame = world-pose ⊗ local-frame |
| 6 | 280862 | If NOT `(state[1] & 0x40)``UpdatePhysicsInternal(this, dt, outFrame)` (Euler step on the composed frame) |
| 7 | 280865 | `process_hooks(this)` — runs queued FP-hooks (scale fade, translucency fade, etc.) |
### Verbatim retail snippets
```c
// Line 280827-280834
Frame::cache(&var_40);
if ((this->state[1] & 0x40) == 0) {
CPartArray* part_array = this->part_array;
if (part_array != 0)
CPartArray::Update(part_array, dt, &var_40);
...
}
```
```c
// Line 280853-280860 — order-critical: adjust_offset BEFORE combine
if (position_manager != 0)
PositionManager::adjust_offset(position_manager, &var_40, dt);
Frame::combine(outFrame, &this->m_position.frame, &var_40);
```
```c
// Line 280862-280865 — physics integration AFTER compose, hooks last
if ((this->state[1] & 0x40) == 0)
CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame);
CPhysicsObj::process_hooks(this);
```
### Side effects
- `outFrame` ← fully populated candidate next-tick world frame
- `m_velocityVector`, `m_omegaVector` ← updated inside UpdatePhysicsInternal
- `m_position.frame`**NOT TOUCHED** here (commit happens in `set_frame`/`SetPositionInternal`)
### Critical observation: state.0x40 ("Frozen") double-gate
The `Frozen` flag both:
- Skips animation root motion (CPartArray::Update)
- Skips physics integration (UpdatePhysicsInternal)
But still runs `Frame::combine` against the (now-empty) localFrame. Net effect: a frozen object's outFrame == m_position.frame (no motion).
---
## 3. `CSequence::apply_physics(seq, frame, dt, dt)` — animation root motion source
**Address**: `0x00524ab0` (line 300955)
**Signature**: `void __thiscall CSequence::apply_physics(const CSequence* this, Frame* arg2, double arg3, double arg4)`
### What it does
Writes the **animation's baked locomotion** into `frame`. This is the *only* code path that produces translation for "walking" remotes — `m_velocityVector` stays at zero for them and `UpdatePhysicsInternal`'s Euler-translation step is a no-op.
### Pseudocode (plain)
```c
double scale = fabs(arg3); // dt magnitude
if (arg4 < 0) scale = -scale; // sign from arg4
frame->origin.x += scale * sequence->velocity.x;
frame->origin.y += scale * sequence->velocity.y;
frame->origin.z += scale * sequence->velocity.z;
Vector3 omegaScaled = scale * sequence->omega;
Frame::rotate(frame, &omegaScaled); // local-frame quaternion rotate
```
`sequence->velocity` and `sequence->omega` are **updated as the AnimSequenceNodes advance** (CSequence::update_internal handles cursor advancement; apply_physics consumes the current cycle's baked velocity). This is the data driven via MotionData fields — e.g. RunForward MotionData has `Velocity = (0, 4.0, 0)` baked in, scaled by speed multiplier.
### Why this matters for L.3
> The spec says `m_velocityVector` stays at 0 for walking remotes — verify
**Confirmed.** For locomotion-driven remotes:
1. Server sends `UpdateMotion(RunForward, speed=N)`.
2. Sequencer enters the RunForward cycle.
3. `CSequence::apply_physics` writes `dt * (0, 4*N, 0)` to the local frame's origin every tick.
4. `Frame::combine` rotates that body-local +Y into world space using `m_position.frame.m_fl2gv` and adds to world pos.
5. `UpdatePhysicsInternal` runs but with `m_velocityVector ≈ 0` (no physics push) → its Euler-translation step adds nothing meaningful. It still does the omega-rotate step using `m_omegaVector` if non-zero.
This is why **acdream's `apply_current_movement` → `body.Velocity` → `UpdatePhysicsInternal` chain is the wrong shape for remotes.** Retail does NOT push locomotion through `m_velocityVector`. It pushes locomotion through the sequencer's baked-velocity → local frame → combine.
---
## 4. `CPhysicsObj::UpdatePhysicsInternal(this, dt, frame)` — physics integration
**Address**: `0x00510700` (line 278460)
**Signature**: `void __thiscall CPhysicsObj::UpdatePhysicsInternal(CPhysicsObj* this, float dt, Frame* frame)`
### What it does (plain English)
Runs **Euler integration of `m_velocityVector` + `m_accelerationVector` + `m_omegaVector`** on the supplied `frame`. This is what advances physics-driven motion (gravity falls, jump arcs, knockbacks). Walking locomotion does NOT flow through here.
### Order of operations
| # | Line | What happens |
|---|------|--------------|
| 1 | 278463-278467 | Compute `var_28 = velocity.x² + velocity.y² + velocity.z²` |
| 2 | 278473 | If `var_28 > 0` (i.e. velocity is non-zero, the FP comparison machinery checks this): |
| 2a | 278475-278487 | If `var_28 > 50²` (terminal speed cap), normalize velocity and scale to magnitude 50. |
| 2b | 278490 | `calc_friction(this, dt, var_28)` — friction adjusts velocity |
| 2c | 278491-278502 | If `var_28 < 0.0625 + 0.0002` (≈ `0.25²` threshold), zero velocity (deadband). |
| 2d | 278505-278511 | **Euler translation**: `frame.origin += dt * velocity + 0.5 * dt² * acceleration` (per-axis) |
| 3 | 278513-278518 | `else if (movement_manager == 0 && transient_state & 2)` — clear `0x80` from transient_state |
| 4 | 278521-278523 | **Velocity step**: `velocity += dt * acceleration` |
| 5 | 278524-278528 | **Omega rotation**: build `var_18..` = `dt * omegaVector`, then `Frame::grotate(frame, &dtOmega)` (global-frame rotate by axis-angle, sin/cos applied to half-angle, quat multiply) |
### Verbatim retail snippets
```c
// Line 278505-278511 — translation update (per axis)
float var_20 = (acceleration.y * 0.5f) * dt * dt;
... // similar for x, z
frame->origin.x += (dt * velocity.x) + (acceleration.x * 0.5f * dt * dt);
frame->origin.y += (dt * velocity.y) + var_20;
frame->origin.z += (dt * velocity.z) + (acceleration.z * 0.5f * dt * dt);
// Line 278521-278523 — velocity step (always runs, even when velocity was 0)
velocity.x += dt * acceleration.x;
velocity.y += dt * acceleration.y;
velocity.z += dt * acceleration.z;
// Line 278524-278528 — angular rotation (always runs)
Vector3 dtOmega = dt * omegaVector;
Frame::grotate(frame, &dtOmega);
```
### Critical observations
1. **Translation step is gated** — only runs when `velocity² > 0`. For a walking remote with `m_velocityVector == 0`, this entire block is skipped. The animation's baked velocity (already in `frame.origin` from `apply_physics` + `combine`) is preserved.
2. **Velocity step always runs** — even when initial velocity was zero, gravity (`acceleration.z = PhysicsGlobals::gravity` when `state[1] & 4`, i.e. `Gravity`) accumulates `velocity.z -= 9.8 * dt` per tick.
3. **Omega step always runs** — uses `m_omegaVector` (NOT sequencer omega). This is for knockback spin / scripted rotation; sequencer omega is consumed inside `apply_physics`.
4. **Zero-velocity deadband** at `|v| < 0.25` applies AFTER friction. Friction-decayed velocity below 0.25 m/s snaps to 0.
---
## 5. `CPhysicsObj::transition(this, fromPos, toPos, flags)` — collision sweep
**Address**: `0x00512dc0` (line 280904)
**Signature**: `const CTransition* __thiscall CPhysicsObj::transition(CPhysicsObj* this, const Position* fromPos, const Position* toPos, int32_t flags)`
### What it does
Allocates a `CTransition` (collision-sweep workspace), initializes it with the object's spheres + path (fromPos → toPos in cell), tunes "frames stationary fall" from `transient_state`, calls `find_valid_position`, cleans up the transition workspace, returns the resolved `CTransition*` if successful or `nullptr` if no valid position.
### Order of operations
| # | Line | What happens |
|---|------|--------------|
| 1 | 280907 | `CTransition* result = CTransition::makeTransition()` |
| 2 | 280911 | `init_object(result, this, get_object_info(this, result, flags))` — copies state flags into `CTransition::object_info` |
| 3 | 280915-280936 | If has spheres → `init_sphere(result, count, spheres, scale)`; else → `init_sphere(result, 1, &dummy_sphere, 1.0)` |
| 4 | 280939 | `init_path(result, this->cell, fromPos, toPos)` — sets up SpherePath |
| 5 | 280940-280947 | Set `frames_stationary_fall` from transient_state high bits: `0x40→3`, `0x20→2`, `0x10→1` |
| 6 | 280949 | `int valid = CTransition::find_valid_position(result)` — runs full sweep |
| 7 | 280950 | `cleanupTransition(result)` — releases sphere copies / scratch state |
| 8 | 280952-280953 | Return `result` if `valid != 0`, else `0` |
### Side effects on caller
The returned `CTransition*` carries:
- `result->sphere_path.curr_pos` — final resolved Position (cell + frame)
- `result->sphere_path.curr_cell` — final cell (may differ from start, may be null = lost)
- `result->collision_info.contact_plane` — current ground plane
- `result->collision_info.contact_plane_valid`, `contact_plane_is_water`, `contact_plane_cell_id`
- `result->collision_info.sliding_normal`, `sliding_normal_valid`
- `result->cell_array` — list of cells visited during the sweep
These all flow into `SetPositionInternal`.
---
## 6. `CPhysicsObj::SetPositionInternal(this, transition)` — commit
**Address**: `0x00515330` (line 283399)
**Signature**: `int32_t __thiscall CPhysicsObj::SetPositionInternal(CPhysicsObj* this, const CTransition* arg2)`
### What it does
Commits the transition's resolved position back into the physics object: copies the final frame, updates cell membership, updates contact plane / sliding normal / transient_state walkable bits, recalculates acceleration based on the new ground state, and fires `handle_all_collisions` for any contact reports.
### Order of operations
| # | Line | What happens |
|---|------|--------------|
| 1 | 283402-283403 | Capture `transient_state`, `curr_cell` |
| 2 | 283405-283410 | If `curr_cell == nullptr` (lost): `prepare_to_leave_visibility` + `store_position(curr_pos)` + `GotoLostCell` + clear `transient_state & 0x80` |
| 3 | 283414-283456 | If same cell: update `m_position.objcell_id` and child cell ids; else `change_cell(curr_cell)` |
| 4 | 283458 | `set_frame(this, &curr_pos.frame)` — commit final frame to `m_position.frame` and propagate to part array |
| 5 | 283459-283464 | Copy `contact_plane` (N + d) and `contact_plane_cell_id` |
| 6 | 283465-283473 | If `contact_plane_valid``transient_state \|= 1 (Contact)`; else `&= ~1` |
| 7 | 283474 | `calc_acceleration(this)` — reset to gravity or zero based on Contact + Gravity flags |
| 8 | 283475-283483 | If `contact_plane_is_water``transient_state \|= 8`; else `&= ~8` |
| 9 | 283485-283510 | If now Contact: branch on `contact_plane.N.z >= PhysicsGlobals::floor_z``set_on_walkable(true)` else `(false)`. Else (not on contact): clear `transient_state & 2` (Active) — call `MovementManager::LeaveGround` if was active — then `calc_acceleration` again. |
| 10 | 283512-283523 | Copy `sliding_normal` + flag bit 4 (`SlidingNormal`) |
| 11 | 283524 | `handle_all_collisions(this, &collision_info, oldContact, oldActive)` — fires Weenie collision callbacks |
| 12 | 283526-283538 | If has cell + state has `0x10000` (HasPhysicsBSP) → `calc_cross_cells`; else → `remove_shadows_from_cells` + `add_shadows_to_cells(&cell_array)` |
### `floor_z` constant
`PhysicsGlobals::floor_z` is the cosine of the steepest walkable angle. Standard retail value is around `0.66417414` (≈ cos 49°). The check at line 283501-283506 is "is this slope steep enough that we are NOT on walkable ground?" — if `contact_plane.N.z < floor_z`, the slope is too steep, set `OnWalkable = false`.
---
## 7. `Frame::combine(out, a, b)` — frame composition
**Address**: `0x005122e0` (line 280355)
**Signature**: `void Frame::combine(Frame* out, const Frame* a, const Frame* b)`
### What it does (plain English)
Composes two frames: **out = a then b** in the sense of "apply a's transform, then add b in a's local space."
```c
out.origin = a.origin + a.m_fl2gv * b.origin // rotate b's translation by a's matrix, add to a's origin
out.quaternion = a.quaternion * b.quaternion // standard quat multiply
```
The matrix `m_fl2gv` is the local-to-global rotation matrix, populated by `Frame::cache` from the quaternion. `m_fl2gv[0..8]` is column-major (with `[0]/[3]/[6]` = row 0, etc., based on the index pattern at line 280358).
### Side effects
- `out.m_fOrigin`, `out.qw/qx/qy/qz` ← computed
- `out.m_fl2gv` ← repopulated (via `set_rotate` which calls `cache`)
### Important: `combine` reads `a.m_fl2gv` directly
If `a.m_fl2gv` is stale (quaternion changed without `Frame::cache`), `combine` produces garbage translation. This is why `Frame::cache(&var_40)` is called explicitly at line 280827 in `UpdatePositionInternal` before any operation that reads the matrix.
---
## 8. `Frame::cache(this)` — quat → rotation matrix
**Address**: `0x00534df0` (line 319353)
**Signature**: `void __fastcall Frame::cache(Frame* this)`
### What it does
Populates `m_fl2gv[0..8]` (a 3×3 rotation matrix) from `qw/qx/qy/qz`. Standard quat-to-matrix formula:
```
m_fl2gv[0] = 1 - 2*(qy² + qz²) m_fl2gv[3] = 2*(qx*qy - qw*qz) m_fl2gv[6] = 2*(qx*qz + qw*qy)
m_fl2gv[1] = 2*(qx*qy + qw*qz) m_fl2gv[4] = 1 - 2*(qx² + qz²) m_fl2gv[7] = 2*(qy*qz - qw*qx)
m_fl2gv[2] = 2*(qx*qz - qw*qy) m_fl2gv[5] = 2*(qy*qz + qw*qx) m_fl2gv[8] = 1 - 2*(qx² + qy²)
```
### Why it matters
Every time the quaternion changes (e.g., omega-rotate or `set_rotate`), the cached matrix MUST be refreshed before another `combine`/`localtoglobal` reads it. Retail's `Frame::set_rotate` calls `Frame::cache` internally; manual quaternion edits do not.
---
## 9. `CPhysicsObj::set_frame(this, frame)` — commit frame to object
**Address**: `0x00514090` (line 282139)
### What it does
Validates the frame, copies it to `this->m_position.frame`, propagates to `part_array` (unless `state[1] & 0x10` = `Particle` flag), and updates children.
### Order
1. `Frame::operator=(local_var_40, arg2)` — copy
2. If `IsValid(local) == 0 && IsValidExceptForHeading(local) != 0` → reset rotation to identity (origin preserved). This protects against NaN quats from numerical drift.
3. `Frame::operator=(this->m_position.frame, local)` — final assign
4. If NOT `(state[1] & 0x10)``CPartArray::SetFrame(part_array, &this->m_position.frame)`
5. `UpdateChildrenInternal(this)` — recurse to attached children
### Why a SEPARATE local copy?
So the validity-fix can run before committing. If we wrote directly into `m_position.frame` and then noticed it's invalid, we'd have already corrupted the live state.
---
## 10. `PositionManager::adjust_offset(pm, frame, dt)` — three-manager pass
**Address**: `0x00555190` (line 352090)
### What it does
Calls `adjust_offset(frame, dt)` on each of three optional managers in order:
1. `InterpolationManager` — smooths network-bursty position deltas
2. `StickyManager` — locks position to a target object (e.g., aetheria attaches)
3. `ConstraintManager` — clamps within a region
Each is null-checked. For most remotes, all three are null, so this is a no-op.
---
## 11. `CPartArray::Update(arr, dt, frame)` & `CSequence::update`
**Addresses**: `0x00517db0` (CPartArray::Update, line 285883), `0x00525b80` (CSequence::update, line 302402)
### What they do
`CPartArray::Update` is a thin wrapper that calls `CSequence::update(&this->sequence, dt, frame)`.
`CSequence::update`:
- If `anim_list.head_ != 0`: calls `update_internal` (advance cursor through anim chain) AND `apricot` (drop completed anims). **Apricot does NOT call apply_physics.** The local frame writes happen INSIDE `update_internal` via its own `apply_physics` calls.
- Else (no anims queued): calls `apply_physics(this, frame, dt, dt)` directly with the current `sequence.velocity` / `sequence.omega`.
This means: **even with no animation queued, the sequencer keeps emitting baked velocity** (the cycle's resting motion). This is how a stationary idle character could still tick velocity.
### What CPartArray::UpdateParts is NOT
`CPartArray::UpdateParts(arr, frame)` at `0x005190f0` (line 287281) is a separate function that COMPOSES per-part frames from the current `AnimFrame.frame` array against the parent `frame` argument. It is called from a different path — `CPhysicsObj::UpdateChild` and `CPartArray::SetFrame` — not from the per-tick UpdateObjectInternal pipeline.
---
## 12. Compact pseudocode of the whole pipeline
```c
void CPhysicsObj::UpdateObjectInternal(float dt) {
if (transient_state high bit set /* dormant */) {
ParticleManager::UpdateParticles();
ScriptManager::UpdateScripts();
return;
}
if (cell == 0) return;
if (transient_state[1] & 1) set_ethereal(0, 0);
jumped_this_frame = 0;
Frame candidate = identity; Frame::cache(&candidate);
UpdatePositionInternal(dt, &candidate);
// After this: candidate = m_position.frame ⊗ animRoot ⊗ posMgrAdjust ⊗ physicsEuler
if (has_collision_sphere) {
if (state[1] & 1)
Frame::set_vector_heading(&candidate, dir_of_translation);
else if ((state & ScaledVel) && !is_zero(m_velocityVector))
Frame::set_heading(&candidate, get_heading(m_velocityVector));
Position toPos = { vtable=Position, frame=candidate, cell=current };
CTransition* t = transition(&m_position, &toPos, 0);
if (t == null) {
set_frame(&candidate); // fall back: commit unswept candidate
cached_velocity = (0,0,0);
} else {
cached_velocity = (t.curr_pos - m_position) / dt;
SetPositionInternal(t); // commits curr_pos, updates contact plane, walkable, etc.
}
} else {
// No sphere — commit candidate directly.
if (movement_manager == null && (transient_state & 2))
transient_state &= ~0x80;
set_frame(&candidate);
cached_velocity = (0,0,0);
}
DetectionManager::CheckDetection();
TargetManager::HandleTargetting();
MovementManager::UseTime();
CPartArray::HandleMovement();
PositionManager::UseTime();
ParticleManager::UpdateParticles();
ScriptManager::UpdateScripts();
}
void CPhysicsObj::UpdatePositionInternal(float dt, Frame* outFrame) {
Frame local = identity; Frame::cache(&local);
if (!(state[1] & 0x40 /* Frozen */)) {
if (part_array)
CPartArray::Update(part_array, dt, &local);
// → CSequence::apply_physics writes:
// local.origin += dt * sequence.velocity
// Frame::rotate(&local, dt * sequence.omega)
}
if (position_manager)
PositionManager::adjust_offset(position_manager, &local, dt);
Frame::combine(outFrame, &m_position.frame, &local);
// outFrame.origin = m_position.origin + m_position.m_fl2gv * local.origin
// outFrame.quat = m_position.quat * local.quat
if (!(state[1] & 0x40))
UpdatePhysicsInternal(dt, outFrame);
process_hooks();
}
void CPhysicsObj::UpdatePhysicsInternal(float dt, Frame* frame) {
float v2 = velocity.x² + velocity.y² + velocity.z²;
if (v2 > 0) {
if (v2 > 50²) { // terminal speed cap
normalize(velocity); velocity *= 50;
}
calc_friction(dt, v2);
if (v2 < 0.25² + ε) // deadband
velocity = 0;
// Euler position step
frame.origin += dt * velocity + 0.5 * dt² * acceleration;
} else if (movement_manager == null && (transient_state & 2)) {
transient_state &= ~0x80;
}
velocity += dt * acceleration; // ALWAYS runs (gravity accumulation)
Vector3 dtOmega = dt * omegaVector;
Frame::grotate(frame, &dtOmega); // ALWAYS runs
}
```
---
## 13. Cross-reference: acdream `GameWindow.cs` ~6428+
### What acdream does today (legacy / non-env-var path)
```csharp
// Lines 6488-6650 (annotated):
1. Force OnWalkable + Contact + Active.
2. apply_current_movement → body.Velocity = sequencer.GetStateVelocity (rotated by Orientation).
3. body.Omega = 0.
4. If ObservedOmega non-zero: integrate Orientation manually (from server-derived rate).
5. body.calc_acceleration().
6. body.UpdatePhysicsInternal(dt). ← Euler-step on body.Position
7. ResolveWithTransition(prePos, postPos) ← BSP/terrain sweep
8. Body.Position = resolveResult.Position.
```
### Mismatches with retail
| # | Retail | acdream | Impact |
|---|--------|---------|--------|
| 1 | Locomotion via **sequencer baked velocity** consumed by `CSequence::apply_physics` (writes to local frame) | Locomotion via **`body.Velocity` = `apply_current_movement` output** consumed by Euler step in `UpdatePhysicsInternal` | acdream double-counts: sequencer gets ticked AND body.Velocity is set. If both contribute, motion overshoots. If only the body path runs (sequencer Advance returns frames but doesn't drive translation), animation plays without baked-velocity contribution. |
| 2 | Order: anim-root → posMgr-offset → **combine with world pose** → physics Euler **on composed frame** → collision sweep on composed frame | Order: apply_current_movement (sets velocity) → manual omega-integrate orientation → physics Euler on body (independent of frame composition) → collision sweep | Acceleration term `0.5 * dt² * a` is applied to body.Position, but in retail it's applied to the *post-combine* frame. For grounded remotes (`acceleration ≈ 0`) the difference is small; for jumping/falling remotes the half-accel term is in the wrong reference frame. |
| 3 | `m_velocityVector` for walking remotes ≈ **0** | `body.Velocity` for walking remotes = world-rotated locomotion vector (e.g. ~4 m/s) | Direct port question: should `body.Velocity` be zero for grounded locomotion, with motion coming entirely from sequencer baked velocity? **Yes per retail.** This is the L.3 fix. |
| 4 | `Frame::grotate` always runs in UpdatePhysicsInternal using `m_omegaVector` | `body.Omega = 0` to skip integration; manual quat rotate from `ObservedOmega` outside body | acdream's manual path is functionally equivalent provided `ObservedOmega` is the right rate; retail's path uses the body's own omega field. Replacing acdream's manual integration with `body.Omega = derived_rate` and letting `UpdatePhysicsInternal` do `grotate` would be more retail-faithful. |
| 5 | Velocity-deadband at `\|v\| < 0.25` and terminal-cap at `\|v\| < 50` are **inside** UpdatePhysicsInternal | Not implemented (or implemented at higher level) | Minor; only matters for friction-decayed bodies and ragdoll-speed clamps. |
| 6 | Translation step is **GATED on velocity² > 0** | acdream Euler-integrates unconditionally | Means a stationary remote with non-zero acceleration (gravity) gets a Z-step in acdream that retail would skip until velocity itself becomes non-zero (gravity makes velocity.z negative on the **next** velocity-update step). 1-tick lag in retail; immediate in acdream. |
| 7 | `set_frame` validates the frame and resets-to-identity if quat is invalid | acdream commits without explicit IsValid check | NaN-quat protection missing; can't be triggered in normal play but a single packet-decode bug could stick a remote with a corrupted orientation forever. |
| 8 | Anim-frame combine + posMgr-offset happen **before** physics integration | acdream skips the local-frame compose entirely; physics on body.Position is independent | This is THE structural mismatch driving L.3. |
### Why the L.2 PositionManager attempt regressed (per CLAUDE.md note)
The note says:
> the env-var path drops the per-tick collision sweep (ResolveWithTransition) that the default path retains, causing a visible "staircase" pattern when remotes run up/down slopes
Retail does NOT drop ResolveWithTransition — `transition()` runs every tick after UpdatePositionInternal builds the candidate. The bug was integrating PositionManager but skipping `transition()`/`SetPositionInternal` in the env-var branch. Retail's structure makes both mandatory.
### The shape L.3 should produce
```csharp
void TickRemote(rm, dt) {
// 1. Build candidate frame in local space.
Frame local = Frame.Identity;
sequencer.ApplyPhysicsToFrame(ref local, dt); // ← retail apply_physics: writes baked velocity & omega
positionMgr.AdjustOffset(ref local, dt); // null for normal remotes; placeholder for L.4
// 2. Compose with world pose.
Frame candidate = Frame.Combine(rm.Body.WorldFrame, local);
// 3. Physics integration on the composed frame (gravity, knockback, terminal cap).
rm.Body.UpdatePhysicsInternal(dt, ref candidate); // mutates m_velocity, candidate.origin (only if v² > 0), candidate.quat (always)
// 4. Collision sweep candidate → resolved.
if (rm.Body.HasCollisionSphere && candidate.Origin != rm.Body.Position) {
var transition = _physicsEngine.Transition(rm.Body.WorldPos, candidate, rm.CellId);
if (transition == null) {
rm.Body.SetFrame(candidate); // fall back to unswept candidate
rm.CachedVelocity = Vector3.Zero;
} else {
rm.CachedVelocity = (transition.CurrPos - rm.Body.WorldPos) / dt;
rm.Body.SetPositionFromTransition(transition); // commits frame, contact_plane, walkable, sliding_normal
}
} else {
rm.Body.SetFrame(candidate);
}
// 5. Per-tick managers.
rm.DetectionManager?.Check();
rm.TargetManager?.Tick();
rm.MovementManager?.UseTime();
rm.PartArray.HandleMovement();
rm.PositionManager?.UseTime();
rm.ParticleManager?.Update();
rm.ScriptManager?.Update();
}
```
The key insight: **for walking remotes, `m_velocityVector` stays zero, locomotion enters via the sequencer's `apply_physics` writing into `local`, and `Frame::combine` rotates that body-local vector into world space using the current orientation.** This matches CSequence::apply_physics's documented behavior and matches what we observe — animation cycles produce locomotion *because the cycle's MotionData has baked velocity*, not because the network hands us a velocity.
---
## 14. Open questions for L.3 implementation
1. **Where does omega come from for remotes?** Retail's `m_omegaVector` is set by `CMotionInterp::DoInterpretedMotion` via the cycle's MotionData (TurnLeft/TurnRight bake omega). Our local player path already has this; verify the remote path reads from the same source instead of `ObservedOmega` (which is server-derived and lossy).
2. **Does CSequence::apply_physics always run, even on idle?** Re-reading line 302413-302419: if `anim_list.head_ == 0` AND `arg3 != 0`, apply_physics runs with the *current* `sequence.velocity` / `sequence.omega`. Idle cycles have zero baked velocity → apply_physics is a no-op. So idle remotes get no spurious motion.
3. **Frame.IsValid check in SetFrame**: minor robustness item. Worth adding when porting set_frame.
4. **Friction**: `calc_friction` gets called inside UpdatePhysicsInternal whenever velocity² > 0. We don't currently port friction. For grounded velocity-driven motion (knockbacks) it matters; for animation-driven motion (m_velocityVector ≈ 0) it doesn't.
5. **PhysicsBody.update_object's MinQuantum gate**: GameWindow.cs ~6633 has a long comment explaining why it bypasses `update_object` and calls `UpdatePhysicsInternal` directly. Retail's `UpdateObjectInternal` is the entry; our top-level entry mirrors that. The 30 Hz quantum gate is in retail (see `MinQuantum=1/30s`); retail addresses it by NOT subticking — `UpdateObjectInternal` is called at the engine tick rate (~30 Hz for retail's render loop, see CLAUDE.md retail debugger notes). Our 60 Hz tick may want a sub-step gate or to halve `dt` per accumulated frame to match retail integration cadence.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,585 @@
# UpdatePosition (0xF748) Routing Pipeline — Retail Pseudo-C Extract
**Date:** 2026-05-04
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C)
**Cross-reference:** `docs/research/named-retail/acclient.h` (verbatim retail headers)
This document extracts the complete UpdatePosition routing tree from the
retail acclient — from the inbound F748 dispatcher down to the body's
position. Every branch is cited verbatim with the originating retail
line number. Cross-checked against acdream's
`OnLivePositionUpdated` in `src/AcDream.App/Rendering/GameWindow.cs:3425`
and `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`.
---
## 0. Pipeline overview (Mermaid)
```
Network blob (NetBlob, opcode 0xF748)
ACSmartBox::DispatchSmartBoxEvent (357117) ─── case 0xF748 ───┐
│ │
▼ │
SmartBox::UnpackPositionEvent (93055) ◄── reads PositionPack ─┘
│ PositionPack = { Position pos, Vec3 velocity, uint32 placement_id,
│ uint32 has_contact, uint16 instance_timestamp,
│ uint16 position_timestamp,
│ uint16 teleport_timestamp,
│ uint16 force_position_timestamp }
▼ if instance_timestamp == obj.update_times[INSTANCE_TS]
SmartBox::HandleReceivedPosition (92896)
args: arg2 = target CPhysicsObj
arg3 = Position* (objcell_id + Frame{origin, rotation})
arg4 = placement_id (uint32)
arg5 = has_contact (int32; 0 = airborne, !0 = grounded)
arg6 = velocity Vec3*
arg7 = position_timestamp (uint16) ← POSITION_TS
arg8 = teleport_timestamp (uint16) ← TELEPORT_TS / move-seq
arg9 = force_position_timestamp (uint16) ← FORCE_POSITION_TS
├─[ if arg2 == player AND newer_event(player, FORCE_POSITION_TS, arg9) ]
│ ► SmartBox::BlipPlayer (92928) — server forced our pos
│ ► return
├─[ if newer_event(arg2, POSITION_TS, arg7) == 0 ]
│ ► return — stale position update
├─[ if arg2 != player ]
│ ► CPhysicsObj::MoveOrTeleport(arg2, &recvPos, arg8, arg5, arg6) (92997)
│ │
│ └── (see Section 3 below)
│ ► if MoveOrTeleport returned 1: CPhysicsObj::ConstrainTo (93007)
│ ► return
└─[ if arg2 == player ]
├─[ if newer_event(player, TELEPORT_TS, arg8) ]
│ ► SmartBox::TeleportPlayer (93015)
│ ► CPhysicsObj::ConstrainTo (93024)
│ ► CPhysicsObj::set_velocity(player, 0) (93029)
│ ► return
└─[ else ]
► CPhysicsObj::ConstrainTo(player, …) (93041)
► if cmdinterp.UsePositionFromServer && arg5 != 0:
CPhysicsObj::InterpolateTo(arg2, &recvPos, …) (93049)
```
Key insight: the `arg2 != player` branch (remotes) is the one that fires
into `MoveOrTeleport`. That's the only place the routing decision tree
between hard-snap, slide-snap, and InterpolateTo lives. The
`arg2 == player` branches (server-corrected local) do their own thing
(BlipPlayer / TeleportPlayer / ConstrainTo + InterpolateTo).
---
## 1. The packet entry — ACSmartBox::DispatchSmartBoxEvent
**File line:** 357117 — `0x005595d0`
Verbatim retail (excerpt of `case 0xF748`):
```c
357181 case 0xf748:
357182 {
357183 ebp_1 = *(uint32_t*)(buf_ + 4); // object guid
357184 arg2 = &buf_[8]; // payload start
357185 result = SmartBox::UnpackPositionEvent(this, ebp_1, &arg2, bufSize_);
357187 if (result != NETBLOB_QUEUED)
357188 return result;
357190 SmartBox::QueueBlobForObject(this, ebp_1, ebx); // not yet known
357191 return result;
357192 }
```
**Notes**
- The opcode dispatch is a simple `switch (ecx)`; F748 is *only* the
generic UpdatePosition. F619 = "MoveObject" (player's own moves)
routes through the **same** `UnpackPositionEvent`, then falls through
into `SetObjectMovement` if the unpack succeeded — see lines 357138
357158. F74C is the server-controlled-move variant that drops in via
a different sequence-stamp check. F748 is the pure-position event.
- `QueueBlobForObject` parks the blob if the target object isn't yet
known to the client, so the position is replayed after CreateObject.
---
## 2. UnpackPositionEvent — gating on `instance_timestamp`
**File line:** 93055 — `0x004542c0`
```c
93055 enum NetBlobProcessedStatus
93055 SmartBox::UnpackPositionEvent(this, arg2 /*guid*/, arg3 /*payload**/, arg4 /*size*/)
93055 {
93059 PositionPack::PositionPack(&var_68);
93060 PositionPack::UnPack(&var_68, arg3, arg4);
93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2);
93063 if (eax_1 != 0)
93063 {
93065 ecx_4 = eax_1->update_times[8]; // INSTANCE_TS
93081 if (newer-by-rolling-uint16(esi /*var_64*/, ecx_4) == 0) // EQUAL
93081 {
93083 if (ecx_4 != esi)
93084 return 2; // NETBLOB_LOGGED_OUT
93092 SmartBox::HandleReceivedPosition(
93092 this, eax_1, &recvPos, placement_id, has_contact,
93092 &velocity, position_ts, teleport_ts, force_position_ts);
93093 return 1; // NETBLOB_PROCESSED_OK
93081 }
93095 }
93097 return 4; // NETBLOB_QUEUED
93055 }
```
**Notes**
- `update_times[8]` is `INSTANCE_TS` (`enum PhysicsTimeStamp::INSTANCE_TS = 0x8`,
`acclient.h:6094`). The full array is `unsigned __int16 update_times[9]`
(`acclient.h:30738`).
- The instance check is "must equal the recorded instance stamp"
(after rolling-uint16 normalization). Mismatch returns NETBLOB_LOGGED_OUT;
unknown object returns NETBLOB_QUEUED.
### PositionPack contents (line 284585)
```c
284589 ebx = first byte of payload // flags byte
284591 Position::UnPackOrigin(&this->position, …) // 12 bytes float3 + uint cell
284593 if (ebx & 8) == 0: read qw (else 0)
284601 if (ebx & 0x10) == 0: read qx (else 0)
284609 if (ebx & 0x20) == 0: read qy (else 0)
284617 if (ebx & 0x40) == 0: read qz (else 0)
284625 Frame::cache(&position.frame); // recompute cached matrix
284628 if (ebx & 1) != 0: read velocity (12 bytes)
284646 if (ebx & 2) != 0: read placement_id (4 bytes)
284654 has_contact = (ebx >> 2) & 1;
284655 read uint16 instance_timestamp;
284660 read uint16 position_timestamp; // POSITION_TS
284664 read uint16 teleport_timestamp; // used as move-seq for arg8 below
284667 read uint16 force_position_timestamp; // FORCE_POSITION_TS
```
Observation: the wire field called "teleport_timestamp" is reused as
the **move-seq** that gets passed as `arg8 = arg3` into
`MoveOrTeleport`. It indexes `update_times[TELEPORT_TS=4]` in the
`newer_event(this, TELEPORT_TS, arg3)` check inside MoveOrTeleport
(284325). One stamp, two purposes — for remotes it acts as a generic
"move sequence"; for the local player it triggers the teleport branch
in HandleReceivedPosition (93013).
---
## 3. CPhysicsObj::MoveOrTeleport — the ROUTER
**File line:** 284304 — `0x00516330`
This is the function L.3 has to port faithfully. Verbatim:
```c
284304 int32_t __thiscall CPhysicsObj::MoveOrTeleport(
284304 class CPhysicsObj* this,
284304 class Position* arg2, // received position (objcell_id + Frame)
284304 uint16_t arg3, // move-seq (TELEPORT_TS slot value)
284304 int32_t arg4, // has_contact (0 = airborne, !0 = grounded)
284304 class AC1Legacy::Vector3 const* arg5) // velocity vector
284306 {
284307 class CPhysicsObj* this_1 = this;
284308 this = this_1->update_times[4]; // current TELEPORT_TS
284311 // rolling-uint16 compare: arg3 vs current update_times[4]
284321 if ( delta-from-rolling-uint16(this, arg3) == 0 ) // SAME-OR-NEWER
284321 {
284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3);
284327 // └── writes update_times[4]=arg3 if newer ──┘
284327 if ( eax_8 != 0 || this_1->cell == 0 )
284327 {
// ───────── BRANCH A: HARD TELEPORT ─────────
284329 int32_t var_70_3 = 1;
284330 CPhysicsObj::teleport_hook(this_1, edx_2);
284332 SetPositionStruct sps;
284332 SetPositionStruct::SetPositionStruct(&sps);
284333 SetPositionStruct::SetPosition(&sps, arg2);
284334 SetPositionStruct::SetFlags(&sps, 0x1012); // ← TELEPORT FLAGS
284335 CPhysicsObj::SetPosition(this_1, &sps);
284336 SetPositionStruct::~SetPositionStruct(&sps);
284337 return 1;
}
284340 if ( arg4 != 0 ) // GROUNDED?
284340 {
// arg4 == has_contact != 0
284342 long double playerDist = this_1->player_distance; // float
284343 long double thresh96 = 96.0f; // ★
284347 if ( playerDist >= thresh96 ) // ★
284347 {
// ───────── BRANCH B: WITHIN BUBBLE → INTERPOLATE ─────────
284351 CPhysicsObj::InterpolateTo(this_1, arg2,
284351 CPhysicsObj::IsMovingTo(this_1));
284352 return 1;
284347 }
// ───────── BRANCH C: BEYOND BUBBLE → SLIDE-SNAP ─────────
284355 class PositionManager* position_manager = this_1->position_manager;
284357 if ( position_manager != 0 )
284358 PositionManager::StopInterpolating(position_manager);
284360 CPhysicsObj::SetPositionSimple(this_1, arg2, /*slide=*/1);
284361 return 1;
}
284362 }
284365 return 0; // STALE — ignore
284366 }
```
★ — the float comparison on lines 284343284349 is x87 nonsense in the
decomp output but the semantic is straightforward. The `if (!p)` branch
is taken when `playerDist < 96f`. (Inverted — the literal pseudo-C
reads "if not (PF set after compare)" which means "comparison was
ordered and not (less or equal)". Cross-checked against ACE
`PhysicsObj.cs::MoveOrTeleport` which reads
`if (player_distance >= MaxObjectTrackingDistance) InterpolateTo(...) else SetPositionSimple(...)`
— SO the labelling above (Branch B = within bubble = InterpolateTo) is
correct as written. Verify on port via cdb if uncertain.)
### The router's three exits
| Branch | Condition | Action |
|--------|-----------|--------|
| **A — Hard Teleport** | `newer_event(TELEPORT_TS, arg3) != 0` (move-seq advanced) **OR** `cell == 0` (object isn't placed yet) | `SetPosition` with flags **0x1012** — teleport-style placement (full sphere validation, `change_cell`, `AddShadowObject`). Position immediately becomes the received position. |
| **B — Interpolate** | grounded (`has_contact != 0`) AND **within view bubble** (`player_distance < 96 m`) | `InterpolateTo(recvPos, IsMovingTo)`**enqueues a waypoint**, body's m_position is NOT changed yet. |
| **C — Slide-snap** | grounded AND **beyond view bubble** (`player_distance >= 96 m`) | `StopInterpolating` (drop queue) + `SetPositionSimple(recvPos, slide=1)` — body snaps to received position, but with `0x1012` flag *omitted* (so this is a softer placement than teleport — see Section 5). |
Air branch (`has_contact == 0`): the function falls through to
`return 0`. **This is the "AIRBORNE NO-OP"** that acdream's
`OnLivePositionUpdated` mirrors at line 3570. The body keeps integrating
gravity locally; received position is discarded.
### Distance constants
- **MAX_PHYSICS_DISTANCE = 96 f** (line 284343) — the in-bubble vs
out-of-bubble threshold inside MoveOrTeleport. **This is the
hard-coded float in the retail binary**; no symbol name in the PDB.
- **CREATURE_OUTSIDE_BLIP_DISTANCE = 100 f** — used elsewhere
(`CMonsterMode::IsBlippable` and similar) to decide visibility blips.
NOT used by MoveOrTeleport.
- **CREATURE_INSIDE_BLIP_DISTANCE = 20 f** — blip threshold for indoor
cells. Also outside MoveOrTeleport.
Only the 96 f figure is on the routing path. The 100/20 figures are
display-only and live in the BlipPlayer / view-cone code.
---
## 4. CPhysicsObj::InterpolateTo — the QUEUE side
**File line:** 278344 — `0x005104f0`
```c
278344 void __thiscall CPhysicsObj::InterpolateTo(
278344 class CPhysicsObj* this,
278344 class Position const* arg2,
278344 int32_t arg3 /* IsMovingTo? — the object is following an MTP route */)
278346 {
278347 CPhysicsObj::MakePositionManager(this);
278348 PositionManager::InterpolateTo(this->position_manager, arg2, arg3);
278349 }
```
This is two lines: ensure a PositionManager exists, then forward.
`PositionManager::InterpolateTo` (line 352136) creates an
`InterpolationManager` lazily and forwards again to
`InterpolationManager::InterpolateTo` (line 352892).
### InterpolationManager::InterpolateTo — what actually queues
```c
352892 void InterpolationManager::InterpolateTo(this, arg2 /*Position**/, arg3 /*isMovingTo*/)
352892 {
352899 tail_ = this->position_queue.tail_;
352902 // Compare new waypoint to the last queued one (or current m_position)
352908 dist = Position::distance( queueTailOrCurrentPos, arg2 );
352911 autonomyBlipDist = CPhysicsObj::GetAutonomyBlipDistance(physobj); // float
352918 if ( dist > autonomyBlipDist ) // ★ FAR
352918 {
// ── Far: enqueue a new InterpolationNode ──
352920 node = operator new(0x60);
352926 edi_1 = InterpolationNode::InterpolationNode(node);
352928 edi_1->kind = 1; // POSITION node
352929 edi_1->objcell_id = arg2->objcell_id;
352930 Frame::operator=(&edi_1->frame, &arg2->frame);
352932 if ( this->keep_heading )
352934 CPhysicsObj::get_heading(physobj); // overwrite heading w/ current
352935 Frame::set_heading(&edi_1->frame, currentHeading);
node_fail_counter = 4; // 4 retry slots
352942 // append to tail (or set head+tail if empty)
352945 return;
352918 }
// ── Near: dist <= AutonomyBlipDistance ──
352956 dist2 = Position::distance(&physobj->m_position, arg2);
352962 if ( dist2 <= 0.05 f ) // 5 cm
352962 {
352964 if ( arg3 == 0 ) // not following MTP route
352968 CPhysicsObj::set_heading(physobj, frame.get_heading(), 1);
352973 InterpolationManager::StopInterpolating(this); // wipe queue
352974 return;
352962 }
// ── Mid-distance: collapse adjacent waypoints into ours ──
352977 while ( queue.tail kind==1 AND Position::distance(tail, arg2) <= 0.05f )
352977 remove tail;
352986 // …then enqueue our waypoint at the end (loop @ 353004 follows)
352892 }
```
**Critical answer to the cross-question — does the body's CURRENT
position change immediately on InterpolateTo?**
**No.** InterpolateTo only manipulates `position_queue`. The body's
`m_position` is advanced by `InterpolationManager::adjust_offset` /
`UpdateInterpolation`, which runs from
`CPhysicsObj::UpdatePhysicsInternal` each tick. The queue is a
sequence of waypoints; the body chases them at the natural movement
speed driven by `MoveToManager` and `RawMotionState`.
There are two clear early-exits:
1. If the new waypoint is within **5 cm** of the body's current
position (`0.05 f` literal, line 352957), `StopInterpolating`
wipes the queue and the function returns. No queue change.
2. If `keep_heading` is set, the queued waypoint inherits the
physobj's CURRENT heading (line 352934) — meaning the queued frame's
rotation is overwritten before insertion. This is how retail
prevents a "snap to face north on UP" on creatures that are mid-
strafe.
---
## 5. CPhysicsObj::SetPosition / SetPositionSimple — the SNAP side
### SetPositionSimple (line 284276 — `0x005162b0`)
```c
284276 enum SetPositionError CPhysicsObj::SetPositionSimple(
284276 class CPhysicsObj* this,
284276 class Position const* arg2,
284276 int32_t arg3 /* slide flag */)
284278 {
284279 uint32_t flags = 0x1002; // base: place-collide+place-no-onwalkable?
284281 if ( arg3 != 0 )
284282 flags = 0x1012; // + slide flag (0x10 = SCATTER?)
284284 SetPositionStruct sps;
284285 SetPositionStruct::SetPositionStruct(&sps);
284286 SetPositionStruct::SetPosition(&sps, arg2);
284287 SetPositionStruct::SetFlags(&sps, flags);
284288 result = CPhysicsObj::SetPosition(this, &sps);
284290 return result;
284291 }
```
Compare with the BRANCH-A teleport in MoveOrTeleport (284334) — it also
uses **0x1012**. So Branch C (slide-snap) and Branch A (teleport)
produce the **same** SetPositionStruct flag. The difference is purely
the conditional path that got us here:
- Branch A: `arg3 != 0`, fires when teleport_timestamp advanced OR cell
was nil. Wraps with `teleport_hook(this_1, …)`.
- Branch C: fires when `arg3 == 0` (move-seq UNCHANGED) and we're
beyond the 96 m bubble. No teleport_hook, but **does** call
`StopInterpolating` first to drop any queued waypoints (since they're
no longer relevant — the visible position must immediately be the new
one).
### SetPosition (line 284137 — `0x005160c0`)
```c
284137 enum SetPositionError CPhysicsObj::SetPosition(this, SetPositionStruct* arg2)
284139 {
284141 eax = CTransition::makeTransition();
284143 if ( eax == 0 ) return 1; // OK / fail-soft
284146 CTransition::init_object(eax, this, 0);
// …gather sphere(s) from this->part_array…
284190 CTransition::init_sphere(eax, num_sphere, sphere*, m_scale);
284191 result = CPhysicsObj::SetPositionInternal(this, arg2, eax);
284192 CTransition::cleanupTransition(eax);
284193 return result;
284137 }
```
The internals: `SetPositionInternal` is the place where the body's
`m_position` actually gets written, after running `CTransition` to
validate the move (collision, walkable-floor, change_cell, etc.).
**By the time MoveOrTeleport returns 1 on Branch A or C, the body's
m_position equals the received position.** This is in stark contrast
to Branch B (InterpolateTo), where m_position is unchanged.
### SetPositionStruct flags reference
The PDB doesn't expose individual flag-bit names, but cross-referenced
against ACE (`PhysicsObj.cs`/`SetPositionStruct.cs`):
| Bit | ACE name | Used here? |
|-----|----------|------------|
| 0x0001 | `Placement` | yes (in 0x1012 / 0x1002) |
| 0x0002 | `Sliding` | sometimes |
| 0x0010 | `Slide` | yes (the +0x10 in SetPositionSimple's `arg3 != 0`) |
| 0x1000 | `SendPositionEvent` | yes (always set in MoveOrTeleport branches) |
So **0x1012 = Slide + Placement + SendPositionEvent**. 0x1002 (the
`arg3 == 0` SetPositionSimple branch, used for non-slide simple
placement, NOT MoveOrTeleport) is just `Placement | SendPositionEvent`.
---
## 6. CPhysicsObj::IsMovingTo (line 276430 — `0x0050eb10`)
```c
276430 int32_t CPhysicsObj::IsMovingTo(class CPhysicsObj const* this)
276432 {
276433 class MovementManager* mm = this->movement_manager;
276435 if ( mm != 0 && MovementManager::IsMovingTo(mm) != 0 )
276436 return 1;
276438 return 0;
276430 }
```
Tells the caller whether the object is currently following a
goal-position via the `MoveToManager`'s scripted-motion machine
(MoveToObject, MoveToPosition, "go to the door" type orders).
This is **not** the same as "is moving" generally — a creature
running a movement style (running cycle) but with no fixed
destination returns false here.
This is the third arg passed into InterpolateTo (line 284351). When
`true`, `InterpolationManager::InterpolateTo`'s near-distance branch
**skips** the `set_heading` correction (line 352964) — the rationale
being that the MoveToManager already handles heading.
---
## 7. Position::distance (line 438258 — `0x005a94b0`)
```c
438258 AC1Legacy::Vector3* Position::distance(
438258 class Position const* this,
438258 class Position const* arg2)
438260 {
438262 result = Position::get_offset(this, &__return, arg2);
438266 return result; // …but the function name is misleading:
// it returns a Vector3 by value via __return
438258 }
```
`get_offset` does the cross-cell offset math (objcell_id-aware) and
fills a Vector3 with the world-space delta. `distance` then uses this
as a Vector3 (its caller calls `.x`/`.y`/`.z` and dot-products), or the
result is cast to a `float` length elsewhere. (Yes, the function name
is wrong — Turbine's joke.)
---
## 8. Move-seq vs teleport-seq logic — the EXACT semantics
Combining the dispatcher, UnpackPositionEvent, HandleReceivedPosition,
and MoveOrTeleport:
| Stamp | Wire field | `update_times` slot | Meaning |
|-------|------------|---------------------|---------|
| `instance_timestamp` | uint16, 5th in PositionPack | `update_times[INSTANCE_TS=8]` | Object generation. UnpackPositionEvent rejects unless equal. |
| `position_timestamp` | uint16, 6th | `update_times[POSITION_TS=0]` | Generic "version" of *this* UP. HandleReceivedPosition drops if not newer. |
| `teleport_timestamp` | uint16, 7th | `update_times[TELEPORT_TS=4]` | **Doubles as move-seq** for remotes. MoveOrTeleport hard-snaps if newer than recorded. For local player → triggers SmartBox::TeleportPlayer. |
| `force_position_timestamp` | uint16, 8th | `update_times[FORCE_POSITION_TS=6]` | Server-forced relocation of OUR character. Triggers BlipPlayer (camera fixup, etc.) when newer. |
The decision: **teleport_timestamp advanced** ⇒ hard-snap (Branch A).
**teleport_timestamp same** ⇒ soft branches (B/C). The 96 m bubble
selects B vs C only on the soft path.
In wire terms:
- A normal "I'm running, server broadcasts my new position" UP has
the **same** teleport_ts as last time, so → InterpolateTo (Branch B).
- A "you got teleported by a portal / GM `@teleto` / death respawn"
UP advances teleport_ts by 1, so → SetPosition w/ teleport flags
(Branch A).
### How this maps to acdream today
`OnLivePositionUpdated` does NOT currently look at the
teleport_timestamp. The L.3 environment-variable port (lines 35083625
of GameWindow.cs) already mirrors the air-no-op (line 3570) and the
96 m bubble (line 3606), but the **teleport_timestamp gate is
missing** — Branch A is never taken explicitly. Teleports today rely
on the WorldSession's own teleport pathway, which short-circuits the
UP routing. The L.3 follow-up should:
1. Plumb `update.TeleportTimestamp` from the WorldSession message
parser into `OnLivePositionUpdated`.
2. On UP receipt, compare against `rmState.TeleportTimestamp` and on
advance: clear queue, hard-snap body, run `teleport_hook`-equivalent.
---
## 9. Orientation handling
A clean answer to the cross-question:
**Orientation is NOT queued separately.** It rides with the Position
struct (which carries `Frame { Vec3 origin; Quat (qw, qx, qy, qz) }`).
What happens to orientation depends on the branch:
| Branch | Position behavior | Orientation behavior |
|--------|------------------|---------------------|
| **A — Teleport** | hard-snapped to recvPos | hard-snapped (Frame.cache rebuilds matrix in SetPositionInternal) |
| **B — InterpolateTo** | queued | **queued in the same Frame**. If `keep_heading` is set on the InterpolationManager, the queued Frame's rotation is **overwritten with the physobj's current heading** (line 352935). Otherwise, the body slerps toward the queued rotation as it walks. |
| **C — SetPositionSimple slide** | hard-snapped | hard-snapped (same Frame.cache path) |
| **AIRBORNE no-op** | unchanged | unchanged |
acdream's current implementation in the env-var path **always
hard-snaps orientation immediately on UP receipt** (line 3516,
`rmState.Body.Orientation = rot;`) — this is a deliberate divergence
from retail (the `keep_heading` path) to keep the visual heading
in lock-step with the queue start, avoiding a one-frame lag
between body position and facing. Document this divergence in the
L.3 commit message; it is a known trade-off, not a bug.
---
## 10. Cross-check: acdream env-var path vs retail
| Step | Retail (MoveOrTeleport) | acdream env-var path (OnLivePositionUpdated, ACDREAM_INTERP_MANAGER=1) |
|------|------------------------|-----------------------|
| 1. Air check | line 284340284362: `arg4==0` falls through to `return 0` | line 3570: `if (!update.IsGrounded) return;` ✓ |
| 2. Teleport stamp gate | line 284325284337: `newer_event(TELEPORT_TS, arg3) → SetPosition(0x1012)` | **MISSING** — no teleport stamp comparison |
| 3. 96 m bubble | line 284343284349: `player_distance < 96f` → InterpolateTo | line 3606: `MaxPhysicsDistance = 96f` ✓ |
| 4. InterpolateTo (queue) | line 284351: `InterpolateTo(arg2, IsMovingTo)` — preserves heading via keep_heading | line 3623: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false)` ✓ (but always passes `isMovingTo:false` and pre-extracts yaw from quat — minor divergence) |
| 5. Slide-snap | line 284360: `StopInterpolating + SetPositionSimple(slide=1)` | line 3613: `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` ✓ |
| 6. Cell change | retail: `change_cell` runs inside SetPositionInternal — handles landblock crossing and AddShadowObject | acdream: `_physicsEngine.ShadowObjects.UpdatePosition(...)` already runs upstream at line 3463, before the routing. ✓ (slight ordering difference — retail does it inside the SetPosition flow) |
**Recommended L.3 follow-ups (not part of this research note):**
1. Plumb teleport_timestamp end-to-end and add the missing Branch A
gate.
2. Pass `IsMovingTo` properly (currently hard-coded to false).
3. Decide whether to honor `keep_heading` (acdream-side flag on the
sequencer) or keep the always-snap divergence — depends on whether
visible heading lag during MoveTo is acceptable.
---
## Appendix A — symbol map
| Function | Address | Line in retail decomp |
|----------|---------|----------------------|
| `ACSmartBox::DispatchSmartBoxEvent` | `0x005595d0` | 357117 |
| `SmartBox::UnpackPositionEvent` | `0x004542c0` | 93055 |
| `SmartBox::HandleReceivedPosition` | `0x00453fd0` | 92896 |
| `PositionPack::UnPack` | `0x00516740` | 284585 |
| `CPhysicsObj::MoveOrTeleport` | `0x00516330` | 284304 |
| `CPhysicsObj::SetPositionSimple` | `0x005162b0` | 284276 |
| `CPhysicsObj::SetPosition` | `0x005160c0` | 284137 |
| `CPhysicsObj::InterpolateTo` | `0x005104f0` | 278344 |
| `CPhysicsObj::IsMovingTo` | `0x0050eb10` | 276430 |
| `CPhysicsObj::newer_event` | `0x00451b10` | 90712 |
| `PositionManager::InterpolateTo` | `0x005551f0` | 352136 |
| `InterpolationManager::InterpolateTo` | `0x00555b20` | 352892 |
| `Position::distance` | `0x005a94b0` | 438258 |
| `enum PhysicsTimeStamp` | — | acclient.h:6084 |
| `struct SetPositionStruct` | — | acclient.h:52398 |

View file

@ -0,0 +1,497 @@
# InterpolationManager — full retail port reference
**Date:** 2026-05-04
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
**Cross-check:** `src/AcDream.Core/Physics/InterpolationManager.cs` (current port), `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` (cdb-traced).
---
## 1. Class layout (verbatim from `acclient.h`)
```c
struct __cppobj LList<InterpolationNode> : LListBase {};
struct __cppobj InterpolationManager
{
LList<InterpolationNode> position_queue; // 0x00 (head_, tail_) — singly-linked
CPhysicsObj *physics_obj; // 0x08
int keep_heading; // 0x0C
unsigned int frame_counter; // 0x10
float original_distance; // 0x14
float progress_quantum; // 0x18
int node_fail_counter; // 0x1C
Position blipto_position; // 0x20 ... 0x68 (size 0x68 total per `operator new(0x68)`)
};
```
`InterpolationNode` (size 0x60, allocated via `operator new(0x60)` in `InterpolateTo`):
| Offset | Field | Notes |
|---|---|---|
| 0x00 | `llist_next` | LListBase next-pointer |
| 0x04 | `type` | 1 = position waypoint, 2/3 = velocity-bearing nodes (rare). UseTime velocity-snap reads tail+0x50 / +0x54 / +0x58 (Vector3) for type 2/3. |
| 0x08 | vtable / sentinel | `0x79285c` written before `delete` |
| 0x0C | `objcell_id` | uint32 cell ID |
| 0x10 | `frame` (begin) | Position::frame, 0x44 bytes (qw,qx,qy,qz,Origin{x,y,z}, cache) |
| ... | (Position internals) | |
| 0x50..0x58 | velocity Vector3 | only meaningful for `type` 2/3 |
**Queue is FIFO singly-linked.** `head_` is the node we walk *toward* first; `tail_` is the most-recent enqueue (the latest server-reported position).
---
## 2. Constructor / Destroy / StopInterpolating
### `InterpolationManager::InterpolationManager` @ `0x005558d0`
```c
this->original_distance = 999999f; // sentinel — first window has no baseline
this->position_queue.head_ = nullptr;
this->position_queue.tail_ = nullptr;
this->frame_counter = 0;
this->progress_quantum = 0f;
this->node_fail_counter = 0;
// blipto_position initialised to identity Frame (qw=1, origin=0)
this->blipto_position.objcell_id = 0;
Frame::cache(&this->blipto_position.frame);
this->physics_obj = arg2;
```
### `InterpolationManager::Destroy` @ `0x00555af0`
Free every node in `position_queue`:
```c
while (head_ = this->position_queue.head_; head_ != 0) {
LListData *next = head_->llist_next;
this->position_queue.head_ = next;
if (next == 0) this->position_queue.tail_ = next;
head_->llist_next = 0;
*(uint32_t*)((char*)head_ + 8) = 0x79285c; // overwrite vtable
operator delete(head_);
}
```
### `InterpolationManager::StopInterpolating` @ `0x00555950`
Same node-drain loop as `Destroy`, **then resets all stall state**:
```c
this->frame_counter = 0;
this->progress_quantum = 0f;
this->node_fail_counter = 0;
this->original_distance = 999999f;
```
> The `999999f` sentinel matters: the first 5-frame window after a fresh Stop will compute `cumulative_progress = 999999 - currentDist`, which is huge and **passes** the stall check. This is retail's intended way of suppressing first-window false-positives. Our port replaces this with an explicit `_haveBaselineDistance` boolean — equivalent.
---
## 3. `InterpolationManager::InterpolateTo` (AppendNode) @ `0x00555b20`
Signature: `void InterpolateTo(InterpolationManager *this, Position const *arg2, int32_t arg3)`
- `arg2` = new server-authoritative target position.
- `arg3` = `keep_heading` flag (1 = preserve current physics heading instead of using the wire heading).
### Branching:
1. **Compute `dist`**: from either (a) the tail's stored position if tail exists AND `tail->type == 1`, otherwise (b) the physics object's current `m_position`. Then:
```c
dist = Position::distance(reference, arg2);
blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj);
```
2. **Far branch**`dist > GetAutonomyBlipDistance()` (100m outdoor / 20m indoor for creatures):
- Allocate `InterpolationNode` (`new(0x60)` + `InterpolationNode::InterpolationNode`).
- `node->type = 1`; copy objcell_id; copy frame.
- If `keep_heading`, overwrite frame heading with `physics_obj->get_heading()`.
- **Append to tail** (the canonical AppendNode op).
- `this->node_fail_counter = 4;`**important**: forces an immediate blip-to-tail on the next `UseTime` (4 > 3 threshold). This is retail's "we're so far out of sync, just teleport" reflex.
- Return.
3. **Near & already-very-close branch**`dist <= blip` AND `Position::distance(physics_obj->m_position, arg2) <= 0.05` (`DESIRED_DISTANCE`):
- If `arg3 == 0`, set heading directly: `physics_obj->set_heading(arg2->frame.get_heading(), 1)`.
- `StopInterpolating(this);`
- Return. (No node enqueued — body is already where it needs to be.)
4. **Near & not-yet-close branch**`dist <= blip`:
- **Tail-prune duplicates**: while `tail->type == 1` AND `Position::distance(tail, arg2) <= 0.05`, `LListBase::RemoveTail` and delete it.
- **Cap at 20**: walk the list counting nodes; if count >= `0x14` (20), drop the head. (Loop `00555c73``00555cb1`.)
- Set `this->keep_heading = arg3;`
- Allocate, fill (type=1, copy objcell+frame, optionally override heading), append to tail.
### Answer: **AppendNode behavior**
There is no separate `AppendNode` symbol — the logic is inlined in `InterpolateTo`. The retail behaviors that matter for our port:
- **Duplicate-prune is a tail-walking loop**, not a single tail-comparison. (Multiple stale tail entries within 0.05 m of the new target all collapse.)
- **Cap eviction is at the HEAD** when reaching 20 entries.
- **Far enqueue forces `node_fail_counter = 4`** to trigger immediate tail-blip next tick.
Our current port does duplicate-prune against only the last entry (`_queue.Last`), drops head on cap — **functionally equivalent** because the tail-walk converges to the same result given a single new arg2. **It does NOT replicate the `node_fail_counter = 4` "force blip" on far enqueue** — see § 7 gap analysis.
---
## 4. `InterpolationManager::adjust_offset` @ `0x00555d30`
Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)`
- `arg2` = output Frame (already-zeroed identity Frame from caller `CPhysicsObj::UpdatePartsInternal` @ `0x00512c3c`).
- `arg3` = frame `dt` in seconds (double).
### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return.
The last meaningful action (line 353253) is:
```c
00555f10 Frame::operator=(arg2, &__return);
```
where `__return` is a Frame whose `m_fOrigin` was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with:
```c
00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/);
```
**So `adjust_offset` writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position.** Our acdream port returns a `Vector3` delta, which the caller adds to the body — equivalent semantics.
### Step-by-step retail flow:
```c
LListData *head_ = this->position_queue.head_;
if (head_ == 0) return; // empty queue → no-op
CPhysicsObj *po = this->physics_obj;
if (po == 0) return;
// ---- GATE on transient_state bit 0 ----
if ((po->transient_state & 1) == 0) return; // line 353080
int type = head_->type;
if (type == 2 || type == 3) return; // velocity nodes → skip
// ---- Distance to head ----
float dist = Position::distance(&po->m_position, &head_[2 /* node->p Position */]);
if (dist <= DESIRED_DISTANCE /* 0.05 */) { // line 353089
NodeCompleted(this, 1); // pop head, advance
return;
}
// ---- Catch-up speed ----
float catchUp;
if (po->minterp() != 0) {
float maxSpd = fUseAdjustedSpeed_
? CMotionInterp::get_adjusted_max_speed(po->minterp())
: CMotionInterp::get_max_speed(po->minterp());
catchUp = maxSpd * MAX_INTERPOLATED_VELOCITY_MOD; // 2.0
} else {
catchUp = 0f;
}
// F_EPSILON test: if catchUp < 0.0002 fallback to MAX_INTERPOLATED_VELOCITY (7.5)
if (catchUp < 0.000199999995f) catchUp = 7.5f; // line 353128 / 0x40f00000
// ---- Accumulate progress + frame counter ----
this->progress_quantum += (float)arg3; // ← see note below
this->frame_counter += 1;
// ---- 5-frame stall window ----
if (this->frame_counter >= 5) {
float cumulative = this->original_distance - dist; // line 353150
if (CPhysicsObj::get_sticky_object_id(po) == 0) {
bool primary_pass = cumulative >= MIN_DISTANCE_TO_REACH_POSITION; // 0.20
bool secondary_pass = cumulative > F_EPSILON
&& (cumulative / progress_quantum / arg3) >= CREATURE_FAILED_INTERPOLATION_PERCENTAGE; // 0.30
// EITHER pass → window is good. NEITHER pass:
if (!primary_pass && !secondary_pass) {
this->node_fail_counter += 1;
NodeCompleted(this, 0); // re-baseline, do NOT stop
return;
}
}
this->frame_counter = 0;
this->progress_quantum = 0f;
this->original_distance = dist; // re-baseline window
}
// ---- Compute step Frame ----
Vector3 toHead;
Position::subtract2(&head_[2], &delta_frame, &po->m_position); // delta_frame.origin = head - here (cell-aware)
toHead = delta_frame.m_fOrigin;
float step = catchUp * (float)arg3; // catchUp m/s * dt s = step m
float toHead_mag = AC1Legacy::Vector3::magnitude(&toHead);
// ---- Reach test (different threshold!) ----
if (toHead_mag <= DESIRED_DISTANCE /* 0.05 */) // line 353222 (note: tested INSIDE this branch too)
NodeCompleted(this, 1);
// ---- No-overshoot scale ----
if (step < toHead_mag) {
float scale = step / toHead_mag;
Vector3::operator*=(&toHead, scale); // shrink toHead to length=step
}
// else: leave toHead at full magnitude (step would overshoot — clamped to dist)
// ---- Heading override ----
if (this->keep_heading != 0) {
Frame::set_heading(&delta_frame, 0f); // zero rotation in the offset Frame
}
// ---- Output ----
Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame
```
### NOTE on `progress_quantum`
Look at lines 353139353143 again carefully:
```
00555e01 /* fld qword [esp+0x60] */; ; load arg3 (dt as double, 8 bytes)
00555e05 /* fadd dword [esi+0x18] */; ; add this->progress_quantum (float)
00555e08 uint32_t edx_3 = (this->frame_counter + 1);
00555e0e this->progress_quantum = ((float)/* fstp dword [esi+0x18] */);
```
The accumulator is `progress_quantum += dt` (sum of frame deltas), **NOT** `progress_quantum += step`. This contradicts the current acdream port (`_progressQuantum += step;` line 289 of InterpolationManager.cs). **This is a real bug.**
Then at the secondary-stall test (line 353169-353171):
```
00555e68 /* fld dword [esp+0x14] */; ; cumulative
00555e6c /* fdiv dword [esi+0x18] */; ; / progress_quantum (= sum_dt)
00555e6f /* fdiv dword [esp+0xc] */; ; / arg3 (current dt)
00555e73 compare against CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30)
```
So the secondary fail check is `cumulative / sum_dt / current_dt < 0.30`. Equivalently: **average velocity over the window divided by current dt < 0.30**, which has units of `1/seconds`. This is a numerically odd formula and feels like a Turbine bug or x87-stack misread by Binary Ninja, but **we must port it verbatim**.
### Constants table
| Constant | Symbol | Value | Cited line |
|---|---|---:|---|
| `MAX_PHYSICS_DISTANCE` | gate in MoveOrTeleport | 96.0 m | (cdb-confirmed; not in adjust_offset itself) |
| `CREATURE_OUTSIDE_BLIP_DISTANCE` | GetAutonomyBlipDistance | 100.0 m | (cdb) |
| `CREATURE_INSIDE_BLIP_DISTANCE` | GetAutonomyBlipDistance | 20.0 m | (cdb) |
| `MAX_INTERPOLATED_VELOCITY_MOD` | maxSpd × this | 2.0 | implicit `* 2f` line 353122 |
| `MAX_INTERPOLATED_VELOCITY` | fallback m/s | 7.5 | `0x40f00000` line 353137 |
| `MIN_DISTANCE_TO_REACH_POSITION` | primary stall thresh | 0.20 m | line 353185 (cited as `&MIN_DISTANCE_TO_REACH_POSITION`) |
| `DESIRED_DISTANCE` | reach + prune | 0.05 m | line 353222 (cited as `&DESIRED_DISTANCE`) |
| `CREATURE_FAILED_INTERPOLATION_PERCENTAGE` | secondary stall ratio | 0.30 | line 353172 (cited as `&CREATURE_FAILED_INTERPOLATION_PERCENTAGE`) |
| `F_EPSILON` | catchUp / cumulative test | 0.0002 | lines 353127, 353156 (cited as `&F_EPSILON`) |
| `StallCheckFrameInterval` | window length | 5 | line 353146 (`>= 5`) |
| `StallFailCountThreshold` | tail-blip trigger | 3 | line 353270 (`> 3` in UseTime) |
| `fUseAdjustedSpeed_` | static toggle | 1 | line 1102675 |
### Question: what does `physics_obj->transient_state & 1` gate?
Bit 0 of `transient_state`. From cross-references in the larger codebase, `transient_state & 1` is set when the body is in a state where its position should be advancing (i.e., it has been initialized into the world AND is not in a frozen / parked / teleporting state). When the bit is **clear**, retail short-circuits adjust_offset and returns without consuming any window time. This avoids the body interpolating during portal transitions, fades, etc.
Our acdream port has no equivalent gate. For the L.3 work this is probably safe (PhysicsBody is always "live" once spawned) but worth filing as a follow-up if we ever see an entity interpolating across a teleport.
---
## 5. `InterpolationManager::NodeCompleted` @ `0x005559a0`
Signature: `void NodeCompleted(InterpolationManager *this, int32_t arg2)`
- `arg2 == 1` → "real" completion (head reached target). If queue empties, also calls `StopInterpolating`.
- `arg2 == 0` → "stall" completion (re-baseline only; do NOT clear queue).
```c
if (this->physics_obj == 0) return;
LListData *old_head = this->position_queue.head_;
this->frame_counter = 0;
this->progress_quantum = 0f;
LListData *popped = nullptr;
// Pop old head off the front of the singly-linked list
if (old_head != 0) {
LListData *next = old_head->llist_next;
this->position_queue.head_ = next;
if (next == 0) this->position_queue.tail_ = nullptr;
old_head->llist_next = 0;
popped = old_head;
}
LListData *new_head = this->position_queue.head_;
if (new_head == 0) {
this->original_distance = 999999f; // empty queue → reset baseline sentinel
if (arg2 != 0) { // real completion
StopInterpolating(this);
goto FREE_POPPED;
}
if (popped != 0) {
// arg2 == 0 (stall) AND queue empty AFTER pop:
// copy popped node's position into blipto_position so that
// UseTime can blip there if the fail counter trips.
Position::operator=(&this->blipto_position, &popped->p);
}
} else if (new_head->type != 1) {
// Velocity node up next — don't re-baseline distance.
if (arg2 != 0) goto FREE_POPPED;
// arg2 == 0 (stall) AND non-position next: still snapshot the popped
// position into blipto_position for tail-blip use.
if (popped != 0)
Position::operator=(&this->blipto_position, &popped->p);
} else {
// Normal case: new head is a position node; rebaseline distance.
this->original_distance = (float)Position::distance(
&this->physics_obj->m_position, &new_head->p);
}
FREE_POPPED:
if (popped != 0) {
*(int32_t*)((char*)popped + 8) = 0x79285c;
operator delete(popped);
}
```
**Queue ops**: pop is HEAD (FIFO). Memory is freed. The popped node's position is snapshotted into `blipto_position` when the queue empties under stall — that's the **blip-to-tail-on-stall** target. (Calling it "blip-to-tail" is a slight misnomer; it's "blip to the position we last failed to reach" when the queue has emptied.)
---
## 6. `InterpolationManager::UseTime` @ `0x00555f20`
Signature: `void UseTime(InterpolationManager *this)`. No params. Called every physics tick from `PositionManager::UseTime`.
```c
CPhysicsObj *po = this->physics_obj;
if (po == 0) return;
int fail = this->node_fail_counter;
if (fail > 3) goto BLIP_BRANCH; // line 353270 — threshold check
// ---- Normal branch: process head node ----
LListData *head_ = this->position_queue.head_;
if (head_ != 0) {
int type = head_->type;
if (type == 3) {
// Velocity node: write velocity, complete.
CPhysicsObj::set_velocity(po, &head_[0x14 /*Vector3 at +0x50*/], 1);
NodeCompleted(this, 1);
return;
}
if (type == 2) {
// Type-2: just complete (no velocity write).
NodeCompleted(this, 1);
}
// type == 1 → no-op here; adjust_offset moves the body each frame.
} else if (fail > 0) {
// No queue but a recent failure → fall through to BLIP_BRANCH
goto BLIP_BRANCH;
}
return;
BLIP_BRANCH:
// ---- "Snap" branch ----
LListData *tail_ = this->position_queue.tail_;
Position target;
bool reapply_velocity = false;
Vector3 saved_vel;
if (tail_ == 0) {
// No tail → blip to the snapshot stashed in blipto_position by NodeCompleted.
target = this->blipto_position;
} else if (tail_->type == 2 || tail_->type == 3) {
// Tail is a velocity node. Walk the list looking for the LAST type-1
// (position) node before the tail. Save the tail's velocity.
saved_vel = *(Vector3*)((char*)tail_ + 0x50);
LListData *cur = this->position_queue.head_;
bool found_pos = false;
Position last_pos;
while (cur != tail_) {
if (cur->type == 1) {
last_pos = cur->p;
found_pos = true;
}
cur = cur->llist_next;
}
if (!found_pos) {
// No position to blip to — fall back to blipto_position.
target = this->blipto_position;
} else {
target = last_pos;
reapply_velocity = true;
}
} else {
// Tail is a position node — blip to it directly.
target = tail_->p;
}
// ---- The actual snap ----
if (CPhysicsObj::SetPositionSimple(po, &target, 1) == OK_SPE) {
if (reapply_velocity) {
CPhysicsObj::set_velocity(po, &saved_vel, 1);
}
StopInterpolating(this);
}
```
### Answers to the critical questions
- **Does UseTime call SetPositionSimple for the blip?** **Yes** — line 353282 (`CPhysicsObj::SetPositionSimple(physics_obj, var_70_3, 1)`).
- **Tail blip target** is normally the queue's tail node (the most recent server position). When the tail is a velocity node, the algorithm walks back to the last position node. When the queue is empty, `blipto_position` (set by NodeCompleted on prior pop) is used.
- **`StopInterpolating` is called only on successful `SetPositionSimple`.** If SetPositionSimple fails (cell transition rejected, etc.), state is preserved and we'll retry next tick.
---
## 7. Cross-check against current acdream port
`src/AcDream.Core/Physics/InterpolationManager.cs`:
| Behavior | Current port | Retail | Verdict |
|---|---|---|---|
| Queue is FIFO with cap 20 | Yes (`LinkedList`, `RemoveFirst` on cap) | Yes | OK |
| Duplicate-prune on enqueue | Compares to `_queue.Last` only | Walks tail-prune loop | **Functional match** for single enqueues; would diverge if multiple stale tail entries exist (rare in practice). |
| `Enqueue` "force blip" via `node_fail_counter = 4` on far-distance | Missing | Line 352944 sets `node_fail_counter = 4` | **Gap.** Far enqueues should pre-arm an immediate blip on the next tick. Probably manifests as "remote drifts visibly toward a far target instead of teleporting" when a 100m+ desync is enqueued. Real-world rare; file as follow-up. |
| reach test against head | `dist < DesiredDistance` → pop, return Vector3.Zero | line 353089 `dist <= DESIRED_DISTANCE``NodeCompleted(1)`, return | OK |
| reach test #2 against `toHead.magnitude` | Not separated | line 353222 inside the step branch | Both reach against the same scalar; equivalent |
| catchUp = max(maxSpd*2, fallback 7.5) | OK (`scaled > 1e-6f`) | OK (`F_EPSILON 0.0002`) | Threshold tighter (1e-6 vs 2e-4); still functional. |
| Step clamp (no overshoot) | `step = min(step, dist)` | Same (else-branch leaves toHead full mag) | OK |
| **`progress_quantum += step`** | `_progressQuantum += step;` | `progress_quantum += dt;` (line 353140) | **Bug.** Retail accumulates *time*, not distance. The secondary check then divides by this time-sum AND by current dt. Our port computes a different ratio. |
| Secondary stall: `cumulative / progress_quantum < 0.30` | Yes | Retail computes `cumulative / sum_dt / cur_dt < 0.30` (units: 1/sec) | **Off by a /dt factor.** Retail's formula is suspect but we should port verbatim and add a regression test. |
| frame_counter | `_framesSinceLastStallCheck` | Equivalent | OK |
| First-window guard | `_haveBaselineDistance` flag | `original_distance = 999999f` sentinel | Equivalent |
| Re-baseline at window end | `_distanceAtWindowStart = dist` | Same | OK |
| **Stall fail action** | Increments fail counter; only blips when threshold exceeded INSIDE adjust_offset | Calls `NodeCompleted(0)` on stall and pops the head; UseTime does the actual blip | **Architectural gap.** Retail's NodeCompleted(0) on stall pops the head node (advancing the queue) — useful when the head is unreachable but later nodes might be. Our port leaves the head in place. This means a single bad waypoint can cause repeated 5-frame failures rather than skipping past it. |
| Blip target on threshold | `_queue.Last.TargetPosition` (tail) | UseTime: tail OR blipto_position OR last-pos-before-velocity-tail | Mostly OK for our use case (only type-1 nodes). |
| `transient_state & 1` gate | None | Required | Probably safe in our codebase; file as follow-up. |
| AdjustOffset return shape | `Vector3` delta | In-place mutate `Frame*` (translation-only Frame) | Functionally equivalent — caller composes with current position either way. |
### Concrete actionable changes
1. **Rename** `progressQuantum` semantics: `_progressQuantum += (float)dt;` (NOT step).
2. **Rewrite the secondary check** as `(cumulative / sum_dt) / cur_dt < 0.30` to match retail verbatim.
3. **Add `NodeCompleted(0)` semantics**: on stall, pop the head node, snapshot its position into a `_blipToPosition` field, but do NOT clear the queue and do NOT return a snap delta. The blip then fires only via the equivalent of UseTime when `_failCount > 3`.
4. **Split `UseTime` from `AdjustOffset`**: retail's UseTime is what *actually performs the snap*. AdjustOffset only moves the body. Currently we conflate them — `AdjustOffset` returns the snap delta itself when fail count exceeds threshold. The two-phase split would let us call SetPositionSimple-equivalent (PhysicsBody.SetPositionSimple) once per tick and not mid-frame.
5. **On far-distance enqueue** (`dist > GetAutonomyBlipDistance()` for the entity's cell type), set `_failCount = StallFailCountThreshold + 1` so the next tick's UseTime triggers a blip to the freshly-enqueued tail.
6. **Optionally gate on a `_isLive` flag** equivalent to `transient_state & 1` once L.3 lands and we have callers that might enable interpolation across teleports.
---
## 8. `position_queue` data-structure operations summary
| Op | Implementation |
|---|---|
| **Enqueue tail** | `tail_->llist_next = new`; `tail_ = new`; if head was null, head = new too. (lines 352942-352950, 353055-353065) |
| **Pop head** | `head_ = head_->llist_next`; if new head null, `tail_ = null`; delete old head. (lines 352774-352782) |
| **RemoveTail** (`LListBase::RemoveTail`) | Used inside InterpolateTo's tail-prune (line 352995). Retail external symbol. |
| **Walk-and-count for cap** | Lines 353012-353017. |
| **Walk-find for last position before velocity tail** | Lines 353312-353323 inside UseTime. |
| **Drain (StopInterpolating / Destroy)** | Same loop, both functions. |
The list is **singly-linked**, head + tail pointers, no prev. Insertion-at-tail and removal-at-head are O(1). RemoveTail and the find-last-position walks are O(N) but N ≤ 20.
---
## 9. Bibliography
- Pseudo-C lines: 352695 (ctor) 353384 (dtor) of
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
- Struct layout: `docs/research/named-retail/acclient.h` line 31505.
- `fUseAdjustedSpeed_` static: line 1102675 of pseudo-C.
- Caller `CPhysicsObj::UpdatePartsInternal` @ ~`0x00512c30`, where the
Frame returned from `adjust_offset` is composed with `m_position.frame`
via `Frame::combine`.
- cdb live-trace results: `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`.
- Existing port: `src/AcDream.Core/Physics/InterpolationManager.cs`.

View file

@ -0,0 +1,491 @@
# L.3 port — PositionManager + CPartArray::Update + CSequence root motion
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(Sept-2013 EoR build, Binary Ninja decomp, PDB-named).
This note pins down where retail's per-tick "animation root motion"
actually comes from, what `PositionManager::adjust_offset` adds on top
of it, and exactly what each manager writes into the per-tick `Frame`.
It exists to settle one question: **does retail's `CPartArray::Update`
produce per-keyframe pos-frame deltas (a.k.a. baked root motion in the
animation data), or does it integrate `CSequence::velocity * dt` (a
constant-velocity model), or both?** The answer is **both**, in a
strict order, and acdream's current C# port only models the second
half.
---
## 0. Top-level call site — `CPhysicsObj::UpdatePositionInternal`
`@ 0x00512c30` (line 280817):
```c
void __thiscall CPhysicsObj::UpdatePositionInternal(
class CPhysicsObj* this, float arg2 /* dt */, class Frame* arg3 /* out */)
{
Frame var_40; // 1. local Frame, identity
Frame::cache(&var_40); // var_40 = identity
if ((state & 0x4000) == 0) { // not animation-paused
if (this->part_array != 0)
CPartArray::Update(this->part_array, arg2, &var_40); // (A)
// ... var_c/var_8/var_4 scaled by m_scale (joint-frame stuff,
// not the root) ...
}
if (this->position_manager != 0)
PositionManager::adjust_offset(this->position_manager, &var_40, arg2); // (B)
Frame::combine(arg3, &this->m_position.frame, &var_40); // (C)
if ((state & 0x4000) == 0)
CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); // (D) — sweep/collision
CPhysicsObj::process_hooks(this);
}
```
So the per-tick recipe is:
1. **var_40 = identity Frame**
2. **(A)** `CPartArray::Update(dt, &var_40)` writes the animation-driven
delta into var_40 (origin + orientation).
3. **(B)** `PositionManager::adjust_offset(&var_40, dt)` fans out to
`InterpolationManager::adjust_offset`, `StickyManager::adjust_offset`,
`ConstraintManager::adjust_offset`, each of which mutates var_40
in-place.
4. **(C)** Result frame = `m_position.frame ∘ var_40` (rotation
composes, then translates).
5. **(D)** Sweep/collision (the call we already port as
`ResolveWithTransition`).
`var_40` is *both* origin (`m_fOrigin`) and orientation (`m_angles` /
`Frame::rotate`). It is a delta, not a position.
---
## 1. `CPartArray::Update` is a 1-line forwarder
`@ 0x00517db0` (line 285883):
```c
void __thiscall CPartArray::Update(class CPartArray* this, float arg2 /* dt */, class Frame* arg3)
{
CSequence::update(&this->sequence, (double)arg2, arg3);
}
```
All the work is in `CSequence::update`.
---
## 2. `CSequence::update` — 1-line gatekeeper
`@ 0x00525b80` (line 302402):
```c
void __thiscall CSequence::update(class CSequence* this, double arg2 /* dt */, class Frame* arg3)
{
if (this->anim_list.head_ != 0) {
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
CSequence::apricot(this); // remove finished non-cyclic anims from list
return;
}
if (arg3 != 0)
CSequence::apply_physics(this, arg3, arg2 /*dt*/, arg2 /*sign-dt*/);
}
```
If there are NO animations queued, `apply_physics` runs once with
`(dt, dt)` and writes velocity·dt into the frame directly. Otherwise
the inner loop drives both per-keyframe combine AND apply_physics.
---
## 3. `CSequence::update_internal` — the keyframe loop (THIS IS THE ROOT MOTION SOURCE)
`@ 0x005255d0` (line 301839). I'll show the structurally important
parts; the FCOMP/FLD ops are FPU translation noise from Binary Ninja
and read like English once you ignore them:
Branching on the sign of `arg2` (dt — positive = forward, negative =
playing the cycle in reverse) the function picks one of two near-
identical inner loops.
### 3a. Forward branch (arg2 ≥ 0) — `else` block at 0x00525646
```c
// floor(frame_number) → ebx_2 = integer keyframe index
do {
if (arg5 /*Frame*/ != 0) {
AnimSequenceNode* node = *arg3; // current animation node
if (node->anim->pos_frames != 0) {
// (A1) MULTIPLY-ACCUMULATE the dat-baked pos-frame for keyframe ebx_2
// into the running Frame:
Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_2));
}
// If the animation has nonzero framerate (|fr| > F_EPSILON):
// (A2) integrate velocity·omega over the time spent on THIS keyframe
// dt_keyframe = 1.0 / framerate
// apply_physics(this, arg5, dt_keyframe, arg2_total_dt);
if (|framerate| > F_EPSILON) {
double dt_keyframe = 1.0 / framerate;
CSequence::apply_physics(this, arg5, dt_keyframe, arg2);
}
}
CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_2), 1 /*forward*/);
ebx_2 += 1;
// re-test loop: continue while frame_number > ebx_2 (we have more keyframes
// worth of time-budget to consume this tick).
} while (frame_number > ebx_2);
```
### 3b. Backward branch (arg2 < 0) `if` block at 0x00525646
Mirror image of the forward branch:
```c
do {
if (arg5 != 0) {
AnimSequenceNode* node = *arg3;
if (node->anim->pos_frames != 0) {
// (A1') SUBTRACT the dat-baked pos-frame for keyframe ebx_1
// (Frame::subtract1, not Frame::combine)
Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_1));
}
if (|framerate| > F_EPSILON) {
double dt_keyframe = 1.0 / framerate;
CSequence::apply_physics(this, arg5, dt_keyframe, arg2 /*negative*/);
}
}
CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_1), -1 /*backward*/);
ebx_1 -= 1;
} while (frame_number < ebx_1); // walk indices DOWN
```
When the inner loop completes the time budget, `frame_number` is
updated to the new fractional position and (if the cycle ended)
`advance_to_next_animation` rolls the queue forward.
### 3c. Special "no time elapsed" path
If `|arg2| < F_EPSILON` (dt ≈ 0) the function still calls
`apply_physics(this, arg5, dt /*≈0*/, arg2)` once and returns —
ensures velocity·0 = 0 and omega·0 = 0 are written even when no
keyframe boundary is crossed.
### Per-keyframe vs per-tick
The crucial structural fact: the loop runs **once per integer keyframe
that fits inside the tick's time budget**. If an animation runs at
30 fps and we tick at 60 Hz, most ticks consume ZERO keyframes
(the loop body never executes); the time accumulates in `frame_number`
until the next keyframe boundary. When a keyframe is crossed,
`Frame::combine(frame, frame, pos_frame)` is invoked AND
`apply_physics` is invoked with `dt = 1/framerate` (NOT the tick's
real dt). Across many ticks this averages to integrating velocity at
the cycle's framerate, but on a single tick the integration may be 0
or it may be 1/framerate or it may be N/framerate for fast cycles.
This is **important for our port**: when our C# code does
`bodyPos += seqVel * dt` per tick at fixed 60 Hz, we are smoothing the
retail behavior. That's fine for steady motion but explains why the
retail trace shows "stairsteps" of pos updates aligned to keyframe
boundaries — it really is per-keyframe.
---
## 4. `CSequence::apply_physics` — the velocity integrator
`@ 0x00524ab0` (line 300955):
```c
void __thiscall CSequence::apply_physics(
class CSequence const* this,
class Frame* arg2, // mutated
double arg3, // dt magnitude (always positive in the loop)
double arg4) // sign carrier (positive = forward, negative = backward)
{
long double scale = fabs((long double)arg3); // |dt|
if (arg4 < 0.0)
scale = -scale; // negate for backward play
arg2->m_fOrigin.x += (float)(scale * this->velocity.x);
arg2->m_fOrigin.y += (float)(scale * this->velocity.y);
arg2->m_fOrigin.z += (float)(scale * this->velocity.z);
Vector3 axisAngle = {
scale * this->omega.x,
scale * this->omega.y,
scale * this->omega.z
};
Frame::rotate(arg2, &axisAngle); // arg2->m_angles = axisAngle ∘ arg2->m_angles
}
```
So one call writes BOTH translation (origin += scale·velocity) and
rotation (`Frame::rotate` = quat-from-axis-angle ∘ existing). The
sign of `arg4` determines whether we play forward or backward; the
*magnitude* in arg3 is the dt being integrated (1/framerate per
keyframe inside `update_internal`).
`this->velocity` is `CSequence::velocity` (set by `add_motion` from
`MotionData::velocity * style_speed`). `this->omega` is
`CSequence::omega` (same source).
---
## 5. Where does `CSequence::velocity` come from?
`add_motion` `@ 0x005224b0` (line 298437):
```c
void add_motion(CSequence* arg1, MotionData* arg2 /*dat-loaded*/, float arg3 /*style_speed*/)
{
if (arg2 == 0) return;
Vector3 vel = {
arg3 * arg2->velocity.x,
arg3 * arg2->velocity.y,
arg3 * arg2->velocity.z
};
CSequence::set_velocity(arg1, &vel); // overwrites — not additive
Vector3 omg = {
arg3 * arg2->omega.x,
arg3 * arg2->omega.y,
arg3 * arg2->omega.z
};
CSequence::set_omega(arg1, &omg);
// append each anim segment (the actual cyclic / link / ack list)
for (int i = 0; i < arg2->num_anims; i++)
CSequence::append_animation(arg1,
operator*(&__return, arg3, &arg2->anims[i]));
}
```
So the answer to the brief's first set of critical questions:
> For locomotion cycles (Walk, Run), is the root motion baked into
> PosFrames in the animation data, OR computed from MotionData.Velocity?
**Both, simultaneously.** The retail data ships SOME motions with
nonzero `MotionData::velocity` (which becomes per-keyframe
`scale·velocity` translation through `apply_physics`) AND/OR with
nonzero `CAnimation::pos_frames[i]` (per-keyframe explicit deltas
combined into the frame via `Frame::combine`). For Humanoid run/walk,
ACE's port and our existing diagnostics agree the dat ships
`HasVelocity = 0`, meaning the dat-side `MotionData::velocity` is
zero. The actual per-keyframe pos_frames are also typically tiny
(stride wobble) — which is why retail clients ALSO drive
`CMotionInterp::get_state_velocity` (RunAnimSpeed × ForwardSpeed) into
`CSequence::velocity` via a separate path during locomotion. Our
synthesized `CurrentVelocity` in `AnimationSequencer.SetCycle`
(WalkAnimSpeed=3.12, RunAnimSpeed=4.0, etc.) mirrors this exactly.
> For idle cycles (Ready), is the root motion zero?
Yes — Ready's `MotionData::velocity` is zero, and our synthesizer
leaves `CurrentVelocity` at zero for non-locomotion cycles. ✓.
> For sign-flipped backward (cycle plays in reverse), is root motion negated?
Yes — `apply_physics`'s `arg4 < 0` branch negates `scale`, so origin
delta and rotation delta both flip. Our port handles WalkBackward by
going through the MotionInterpreter's `adjust_motion` remap to
WalkForward + speedMod×0.65 (matches retail's actual encoding); the
backward keyframe-loop branch is reachable for cyclic anims that
genuinely play with negative framerate.
---
## 6. `PositionManager::adjust_offset` — fan-out
`@ 0x00555190` (line 352090):
```c
void __thiscall PositionManager::adjust_offset(
class PositionManager* this, class Frame* arg2 /*the var_40 from above*/, double arg3 /*dt*/)
{
if (this->interpolation_manager != 0)
InterpolationManager::adjust_offset(this->interpolation_manager, arg2, arg3);
if (this->sticky_manager != 0)
StickyManager::adjust_offset(this->sticky_manager, arg2, arg3);
if (this->constraint_manager != 0)
ConstraintManager::adjust_offset(this->constraint_manager, arg2, arg3);
}
```
ORDER MATTERS. Each manager mutates `arg2` in-place.
### 6a. `InterpolationManager::adjust_offset` (`@ 0x00555d30`, line 353071)
This is the head-of-queue catch-up logic the user already agonized
over. The behavior:
- If position_queue is empty → no-op.
- If transient_state lacks bit 1 → no-op.
- If queue head has special types 2 or 3 → no-op.
- If `Position::distance(physics_obj, head_target) < 0.05f`
`NodeCompleted(true)` and **return** (arg2 untouched — animation
root motion stands).
- Otherwise:
- `max_speed = (fUseAdjustedSpeed_ ? get_adjusted_max_speed
: get_max_speed) * 2.0f`.
- Build a unit direction toward head, scaled by
`min(max_speed × dt, distance)`, **OVERWRITE arg2->m_fOrigin** with
that vector. Animation root motion for THIS tick is discarded.
So `InterpolationManager::adjust_offset` is **either** a pure pass-
through (close-enough) or a **REPLACE** (overwrite arg2->m_fOrigin).
It is NOT additive. Our `PositionManager.cs` correctly implements
this dichotomy in `ComputeOffset`.
### 6b. `StickyManager::adjust_offset` (`@ 0x00555430`, line 352351)
When sticky-target-id is set and initialized:
- Compute world-space offset to target (via `Position::get_offset`),
store in `arg2->m_fOrigin`.
- Convert to local-space (`Position::globaltolocalvec`),
zero the Z (stay-at-target-altitude only in XY).
- Distance = `cylinder_distance_no_z - 0.30f`.
- If the offset normalized fine: scale it by
`min(max_speed * dt, |distance|)` and write back. (Same
movement-budget logic as InterpolationManager but toward a
different target.)
- Then `Frame::set_heading(arg2, target_heading current_heading)`
i.e., **OVERWRITES** arg2's heading too.
`StickyManager::adjust_offset` runs AFTER `InterpolationManager` so it
can REPLACE the interpolation correction. This makes sense: sticky
follow-target is a higher-priority constraint than queued node-by-node
movement.
### 6c. `ConstraintManager::adjust_offset` (`@ 0x00556180`, line 353479)
When `is_constrained != 0` and `transient_state & 1`:
- If `constraint_pos_offset > constraint_distance_max`: zero out
arg2->m_fOrigin (clamp to constraint).
- If `constraint_pos_offset > constraint_distance_start`: scale
arg2->m_fOrigin by `(max - offset) / (max - start)` (linear ease-out
near the cap).
- Otherwise: leave arg2->m_fOrigin alone.
- Always: accumulate arg2->m_fOrigin.x into `this->constraint_pos_offset`
(advance the offset tracker).
So Constraint is the only manager that's **purely scalar**: it scales
or zeros `arg2->m_fOrigin` rather than overwriting it.
### Summary of the fan-out
| Manager | What it writes to `arg2` | Conditions |
|---|---|---|
| `InterpolationManager` | OVERWRITES origin with catch-up vector OR no-op | head-of-queue distance > 0.05 |
| `StickyManager` | OVERWRITES origin AND heading with chase-target vector | target_id != 0 AND initialized |
| `ConstraintManager` | SCALES (or zeros) origin, never writes new value | is_constrained AND transient bit |
If multiple are active at once they compose, but the natural retail
case is at most one of (Interp, Sticky) active per object — Sticky is
typically used for combat lock / charge-target follows; Interp is the
default for queued moveto.
---
## 7. Cross-check vs acdream's port
### `src/AcDream.Core/Physics/PositionManager.cs`
acdream's port collapses the entire chain to:
```csharp
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
return Vector3.Transform(rootMotionLocal, ori);
```
Divergences from retail:
1. **No StickyManager / ConstraintManager.** Currently fine for L.3
(we don't ship sticky-follow yet); flag for L.5+ when combat
targeting lands.
2. **Single `seqVel * dt` per tick instead of per-keyframe.**
Retail's loop runs once per integer keyframe boundary inside the
tick, calling `apply_physics(dt = 1/framerate)` each time. Our
port runs once per tick at `dt = tick`. Net displacement per
second is identical for steady-state running, but the retail
trace will show "stairsteps" aligned to keyframe boundaries
while ours will show smooth integration. This is probably the
cause of the user-reported "staircase" pattern when remotes run
up/down slopes — every keyframe boundary, retail does a discrete
`Frame::combine(pos_frame_delta)` then a discrete velocity bump.
We integrate continuously and miss the per-keyframe pos_frame
delta entirely.
3. **`pos_frames` from the dat are completely ignored.** Retail's
`Frame::combine(arg5, arg5, get_pos_frame(node, kf))` per keyframe
is the dat-baked stride wobble / hand-position-during-cast / etc.
For Humanoid locomotion these are small but nonzero — likely
±0.02m wobble plus Z bob. Ignoring them makes our remote bodies
glide unnaturally smoothly.
4. **`Frame::rotate` from `omega·dt` is partially handled** — our
`CurrentOmega` synth covers turn cycles (TurnRight/TurnLeft) and
`RemoteEntity` integrates omega into its quaternion per tick. ✓.
### `src/AcDream.Core/Physics/AnimationSequencer.cs`
Lines 614679 synthesize `CurrentVelocity` and `CurrentOmega` for
locomotion / turn cycles using the retail `RunAnimSpeed=4.0`,
`WalkAnimSpeed=3.12`, `SidestepAnimSpeed=1.25`, omega `±π/2`. These
constants match `_DAT_007c96e0/e4/e8` from the older Ghidra decomp
and the named-retail symbols. ✓.
What we DON'T mirror:
- The `MotionData::velocity` × `style_speed` MULTIPLY through
`add_motion`. Retail computes `CSequence::velocity = style_speed *
MotionData.velocity`; our synth uses `RunAnimSpeed * adjustedSpeed`
directly. For Humanoid this is correct because the dat's
`MotionData.velocity` is zero so the multiply is a no-op anyway —
but for creatures with nonzero `MotionData.velocity`, our synth
silently drops that contribution. Filed as future-port concern;
currently no observed impact.
- Per-keyframe `pos_frames` deltas (see #3 above). Our
`CurrentVelocity` carries only the *steady-state* component of the
cycle's intent; the per-frame stride wobble is gone. To capture
it we'd need to walk `CAnimation.PosFrames[i]` and add the keyframe
delta on each integer-keyframe-boundary tick — i.e., port the
inner loop of `update_internal` rather than collapsing it to a
velocity number.
---
## 8. Recommendations for L.3 follow-up
Likely root cause of the remote-run-on-slope staircase regression
(env-var path) and the steady-state position blips:
1. The env-var path bypasses `ResolveWithTransition` (already fixed
in commit 039149a, per memory). ✓.
2. The remote body integrates `seqVel * dt` per tick smoothly, while
broadcasts arrive at ~5 Hz with retail's per-keyframe-discretized
advance. Mismatch shows as small +/- Z bob between UPs.
3. `pos_frames` deltas ignored — Z stride wobble lost.
4. `omega` integration order vs `Frame::combine(pos_frame)` — retail
does `Frame::combine(pos_frame)` BEFORE `apply_physics`, so the
pos_frame's heading rotation applies first; we do them in either
order depending on caller wiring.
Before implementing more porting, brainstorm with `superpowers:
brainstorming` whether per-keyframe integration is worth porting now
(complexity: high; visible impact: stride wobble; user-visibility:
probably low) versus accepting the smoothed model and instead tuning
the InterpolationManager catch-up thresholds.

View file

@ -0,0 +1,550 @@
# 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).

View file

@ -0,0 +1,919 @@
# L.3 port research — StickyManager / ConstraintManager / MoveToManager
**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
**Cross-refs**: `references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,ConstraintManager,MoveToManager}.cs`,
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`,
acdream `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`.
All retail line numbers below refer to that file.
---
## 1. StickyManager — "follow object at fixed radius"
### Purpose
`StickyManager` is the **post-arrival follow-on** for `MoveToObject`. After
`BeginNextNode` exhausts the pending action list and finds
`movement_params.__inner0` had the high bit set (sticky-after-arrive flag),
it calls `PositionManager::StickTo(top_level_object_id, radius, height)`
(line 307159). From that point, every physics tick `PositionManager::adjust_offset`
calls `StickyManager::adjust_offset` to nudge the body's per-tick position
delta toward the target, maintaining a fixed cylindrical separation.
### State (`sizeof = 0x60`, lines 352620-352633 ctor + 0x796910 vtable)
| Offset | Field | Notes |
|---|---|---|
| 0x00 | `target_id` | uint32. 0 means "not stuck". |
| 0x04 | `physics_obj` | back-pointer to `CPhysicsObj`. |
| 0x08 | `target_position.vtable` | `0x796910` = `class Position`. |
| 0x0c | `target_position.objcell_id` | last known target cell. |
| 0x10..0x4c | `target_position.frame` | qw/qx/qy/qz/origin (cached). |
| 0x4c | `target_radius` | float. From `StickTo arg3`. |
| 0x50 | `initialized` | int32. 1 after first `HandleUpdateTarget Ok`. |
| 0x54..0x5c | `sticky_timeout_time` | double, line 352576 = `cur_time + 1.0`. |
### `StickyManager::Create(CPhysicsObj*)` @ 0x00555800
```c
00555804 void* result = operator new(0x60);
00555814 *(uint32_t*)result = 0; // target_id
00555816 *(uint32_t*)((char*)result + 4) = 0; // physics_obj (set later by SetPhysicsObject)
0055581c *(uint32_t*)((char*)result + 8) = 0x796910; // Position vtable
00555826 *(uint32_t*)((char*)result + 0x10) = 0x3f800000; // qw = 1.0f
... zero everything else
0055582f *(uint32_t*)((char*)result + 0x18) = 0;
```
Followed (via `PositionManager::Create` line 352252) by `SetPhysicsObject`.
### `StickyManager::StickTo(target_id, radius, height)` @ 0x00555710 (lines 352559-352578)
Plain language: "from now on, follow `target_id` at radius `radius`, and
notify the engine to start tracking that target."
```c
00555716 if (this->target_id != 0) { // already stuck → unstick first
00555718 class CPhysicsObj* physics_obj = this->physics_obj;
0055571b this->target_id = 0;
00555721 this->initialized = 0;
00555728 CPhysicsObj::clear_target(physics_obj);
00555730 CPhysicsObj::interrupt_current_movement(this->physics_obj);
}
00555749 this->target_radius = arg3; // arg3 = radius
0055574f this->target_id = arg2; // arg2 = object id
00555751 this->sticky_timeout_time = Timer::cur_time + 1.0; // 1-second alive window
0055575a this->initialized = 0;
00555771 CPhysicsObj::set_target(physics_obj, 0, arg2, 0.5f, 0.5f);
```
`arg4` (target_height) is **received but not stored** — sticky uses cylinder
distance (no Z), so height is irrelevant. The 0.5f/0.5f passed to `set_target`
is the target tracking radius/height the server-update path uses to filter
which `TargetInfo` updates land here.
### `StickyManager::HandleUpdateTarget(TargetInfo)` @ 0x00555780 (lines 352582-352607)
Server-driven: when `CPhysicsObj` receives a fresh tracked-target
position update, this absorbs it.
```c
00555789 if (arg2.object_id == target_id) {
if (arg2.status == Ok_TargetStatus) {
this->initialized = 1;
this->target_position.objcell_id = arg2.target_position.objcell_id;
Frame::operator=(&this->target_position.frame, &arg2.target_position.frame);
return;
}
if (target_id != 0) // status != Ok → bail out
ClearTarget(); // (inlined)
}
```
### `StickyManager::adjust_offset(Frame* offset, double quantum)` @ 0x00555430 — **the per-tick steerer**
This is the function `PositionManager::adjust_offset` calls every tick at
line 005551ba.
**Branch guard** (line 352356): only runs when `target_id != 0 AND initialized != 0`.
**Algorithm** in plain language (with retail line cites):
1. **Compute world-space offset to target** (lines 352358-352366):
- `edi_2 = &physics_obj->m_position` (our position)
- `eax = GetObjectA(target_id)` — get the live target if still in our world
- `ebp_1 = (eax != 0) ? &eax->m_position : &this->target_position` — fall back to last-known position if target despawned
- `Position::get_offset(edi_2, &__return, ebp_1)` writes
`(target.world - me.world)` into `offset.m_fOrigin`.
2. **Convert to my local space and flatten Z** (lines 352370-352374):
- `Position::globaltolocalvec(...)` rotates the offset by my inverse heading.
- `offset.m_fOrigin.z = 0` — sticky is **always horizontal**.
3. **Compute cylinder distance minus 0.3 m sticky radius** (lines 352375-352378):
- `target_radius = this->target_radius`
- `var_34_1 = CPhysicsObj::GetRadius(physics_obj)` (own radius — actually unused; computed for side effect or future use)
- `var_14_1 = Position::cylinder_distance_no_z(edi_2, target_radius, ebp_1) - 0.30000001f`
So `dist = horizontal_separation_minus_combined_radii - 0.3 m`.
4. **Normalize the offset direction** (lines 352381-352387):
- `if Vector3::normalize_check_small(offset.m_fOrigin) != 0 → offset.m_fOrigin = (0,0,0)` — when target is on top of us, no direction.
5. **Compute step speed** (lines 352392-352409):
- `eax_7 = CMotionInterp::get_max_speed(get_minterp(physics_obj))` — pull body's max forward speed.
- If `get_minterp == 0` → step skipped (`top_1 = 0`).
- Compare against `F_EPSILON`. If `max_speed < F_EPSILON` → use `MAX_VELOCITY` instead (the global cap).
- **ACE port (line 112)**: `speed = minterp.get_max_speed() * 5.0f` then `if speed < EPSILON → 15.0f`.
The retail `* 5.0f` constant isn't visible in our pseudo-C extract because the FP
stack ops are unimplemented in the BinaryNinja output; ACE's value is the
authoritative interpretation.
6. **Multiply offset by `min(speed × quantum, dist)`** (lines 352411-352455):
- The branching (`if p_2`) selects between the speed-clamped delta and the
distance-clamped delta. ACE's port (lines 117-121) makes this explicit:
```csharp
var delta = speed * (float)quantum;
if (delta >= Math.Abs(dist)) delta = dist;
offset.Origin *= delta;
```
- Z stays 0; X/Y get scaled to the final per-tick step.
7. **Set heading toward target** (lines 352456-352492):
- `Position::heading(edi_2, ebp_1)` — compute heading from us to target (degrees, 0..360).
- `Frame::get_heading(&edi_2->frame)` — current heading.
- `delta = target_heading - current_heading`.
- If `|delta| < F_EPSILON``delta = 0`.
- If `delta < -F_EPSILON``delta += 360.0f`.
- `Frame::set_heading(arg2, delta)` — bake the **rotation delta** (not absolute) into the frame the caller passes in. The caller composes this onto the body next tick.
### `StickyManager::UseTime` @ 0x00555610 (lines 352498-352517)
Empty unless `target_id != 0 AND cur_time > sticky_timeout_time`. When timeout
elapses without a `HandleUpdateTarget Ok`, drops the target completely. The
1-second timeout (`sticky_timeout_time = cur_time + 1.0`) means the server has
to keep refreshing the target every second or sticky drops.
### `StickyManager::UnStick` @ 0x00555400 + `Destroy` @ 0x00555650 + `~StickyManager` @ 0x005557e0
`UnStick` and `Destroy` both clear `target_id`, `initialized`, call
`CPhysicsObj::clear_target(physics_obj)`, then **`UnStick` also calls
`CPhysicsObj::interrupt_current_movement`** while `Destroy` does not. The
distinction matters: `UnStick` is a deliberate "stop sticky now"; `Destroy`
is "we're being torn down, don't side-effect into the still-existing physics
state machine."
### Critical questions answered
- **When does sticky activate?** Only via `BeginNextNode`'s post-arrival
branch (line 307143-307159) when `movement_params.__inner0` has its high
bit set. Outbound `MoveToObject` packets with that flag are server-side AI
scripts (combat tracking, NPC follow, etc.). Player-driven moves don't
set it.
- **What does it write to the Frame?** `m_fOrigin = (xy_step_toward_target, 0)`
in **local space** (rotated to body-local before being scaled, so when the
body composes this onto its own frame the step lands in the right world-space
direction). Plus `set_heading(rotation_delta)` — the body turns to face the
target each tick at `set_heading` rate (with the same kind of fudge as
ACE's `set_heading(target, true)`).
---
## 2. ConstraintManager — "leash to a fixed point"
### Purpose
Force the body to stay within a soft bubble around `constraint_pos`. Used for
quest geometry like "can't leave this room", monster aggro tethers, etc.
Smaller scope than sticky and **purely position-based** (no rotation).
### State (`sizeof = 0x5c`, lines 353442-353474 ctor)
| Offset | Field | Notes |
|---|---|---|
| 0x00 | `physics_obj` | back-pointer (set last, line 353473). |
| 0x04 | `is_constrained` | int32. |
| 0x08 | `constraint_pos.vtable` | not 0x796910 here — Position embed begins at +0xc. |
| 0x0c | `constraint_pos.vtable` (real) | `0x796910` = `class Position`. |
| 0x10 | `constraint_pos.objcell_id` | 0 |
| 0x14..0x4c | `constraint_pos.frame` | qw/qx/qy/qz/origin |
| 0x48 | `constraint_distance_start` | float — soft bubble inner radius |
| 0x4c | `constraint_distance_max` | float — hard bubble outer radius |
| 0x50 | `constraint_pos_offset` | float — current distance from `constraint_pos` |
(ACE's port stores them by C# field names mirroring the above; the offsets aren't
load-bearing, the meanings are.)
### `ConstraintManager::ConstrainTo(Position* pos, float startDist, float maxDist)` @ 0x00556240 (lines 353528-353537)
```c
00556248 this->is_constrained = 1;
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271 this->constraint_distance_start = arg3;
00556274 this->constraint_distance_max = arg4;
0055627c this->constraint_pos_offset = Position::distance(arg2, &this->physics_obj->m_position);
```
Snapshot the leash anchor + radii + initial offset.
### `ConstraintManager::adjust_offset(Frame* offset, double quantum)` @ 0x00556180 — **the per-tick clamper**
```c
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
0055618a if (physics_obj == 0) return;
00556190 if (this->is_constrained == 0) return;
005561a7 if ((physics_obj->transient_state & 1) != 0) { // bit 0 = "Contact" (touching ground)
if (this->constraint_pos_offset < this->constraint_distance_max) {
if (this->constraint_pos_offset > this->constraint_distance_start) {
// soft zone: scale offset DOWN proportionally to how far into the soft band we are
float scale = (constraint_distance_max - constraint_pos_offset) /
(constraint_distance_max - constraint_distance_start);
Vector3::operator*=(&arg2->m_fOrigin, scale);
}
// else: inside inner bubble, leave offset unchanged
} else {
// hard zone: zero the offset entirely. No movement allowed beyond max.
arg2->m_fOrigin = Vector3::Zero;
}
}
00556233 this->constraint_pos_offset = arg2->m_fOrigin.x + this->constraint_pos_offset;
// ^^ NOTE: the pseudo-C extract reads ".x +", but the actual algorithm uses
// the **length** of m_fOrigin (the magnitude of the per-tick step). ACE's
// port (line 76) confirms: `ConstraintPosOffset = offset.Origin.Length();`
// The Binary Ninja extract garbled the FP stack ops here.
```
### Branches & flag bits
- **`transient_state & 1` = `Contact` flag.** ConstraintManager only fires
when the body is **touching the ground**. Mid-air motion (jumps, falls)
is unaffected. (This is the same `transient_state` bit that motion code
checks to decide whether `kill_velocity` is allowed — see commit a3f53c2.)
- **No rotation**: `set_heading` is never touched. Constraint is purely
positional.
- **No timeout**: `UseTime` is empty. Stays engaged until `Unconstrain`
or `~ConstraintManager`.
### `IsFullyConstrained` @ 0x005560d0 (lines 353413-353427)
```c
return constraint_distance_max * 0.9f < constraint_pos_offset;
```
"Are we within 10% of the hard limit?" Used by callers to decide whether
to schedule a course correction.
### Critical questions answered
- **What kinds of constraints exist?** Only **translation** in the form of a
soft-clamp toward `constraint_pos`. No rotation lock, no cell lock — those
are server-enforced.
- **When does it fire?** Per tick, but only when the body has the Contact
bit (touching ground). Ignored during jumps/falls.
---
## 3. MoveToManager — full state machine for AI/scripted motion
### Purpose
Server-side AI's locomotion executor. When the server wants a creature to
"walk to that rock then turn south", it sends a `MovementStruct` (one of 4
shapes) packed via `MovementParameters::UnPackNet`. `PerformMovement`
unpacks it, picks a top-level branch, the entrypoint queues a list of
**pending nodes** (each either `MoveToPosition` opcode `7` or `TurnToHeading`
opcode `9`), and `UseTime` ticks the head-of-queue node every physics frame
until the queue empties.
### State (`sizeof = 0x160`, lines 306554-306592 ctor)
| Field | Type | Notes |
|---|---|---|
| `sought_position` | Position | The original target requested by the server. |
| `current_target_position` | Position | The "right now" target — interpolated for moving objects. |
| `starting_position` | Position | Where the body was when the move began. Used by fail-distance check. |
| `pending_actions` | DLListBase | Doubly-linked list of `{opcode, value}` nodes. Opcode 7 = MoveToPosition, opcode 9 = TurnToHeading (with heading float). |
| `movement_params` | MovementParameters | Packed flags, distances, speeds, hold-key, etc. (Section 4.) |
| `physics_obj`, `weenie_obj` | back-pointers | |
| `movement_type` | enum (`Invalid` or 6=MoveToObject / 7=MoveToPosition / 8=TurnToObject / 9=TurnToHeading) | |
| `current_command`, `aux_command` | uint32 | Active motion-command IDs. `current_command` is the "main" (forward/backward), `aux_command` is the simultaneous turn (e.g., 0x6500000d=TurnLeft, 0x6500000e=TurnRight). |
| `previous_distance`, `previous_distance_time` | float, double | For `CheckProgressMade` (fail detector). |
| `original_distance`, `original_distance_time` | float, double | Initial state for over-1-second progress check. |
| `previous_heading` | float | TurnToHeading's per-tick angle tracker. |
| `fail_progress_count` | int32 | Stalled-tick counter. |
| `sought_object_id`, `top_level_object_id` | uint32 | The object we're chasing (and its outermost parent if attached). |
| `sought_object_radius`, `sought_object_height` | float | Cylinder dimensions for distance test. |
| `moving_away` | int32 | 0=chase, 1=flee. Affects which arrival predicate fires. |
| `initialized` | int32 | 1 once we've gotten the first `HandleUpdateTarget Ok` for a moving target. |
### Top-level entry: `MoveToManager::PerformMovement(MovementStruct*)` @ 0x0052a900 (lines 307871-307904)
```c
0052a901 int32_t var_8 = 0x36; // WeenieError code that CancelMoveTo will use
0052a905 CancelMoveTo(this, edx); // wipe any in-flight move
0052a910 CPhysicsObj::unstick_from_object(this->physics_obj);
0052a923 switch (arg2->type - 6) {
case 0: // MoveToObject (type 6)
MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params);
break;
case 1: // MoveToPosition (type 7)
MoveToPosition(this, &arg2->pos, arg2->params);
break;
case 2: // TurnToObject (type 8)
TurnToObject(this, arg2->object_id, arg2->top_level_id, arg2->params);
break;
case 3: // TurnToHeading (type 9)
TurnToHeading(this, arg2->params);
break;
}
return 0;
```
### `MoveToManager::MoveToObject` @ 0x00529680 (lines 306756-306817)
Stores: `sought_object_id = arg2`, `top_level_object_id = arg3`,
`sought_object_radius = arg4` (cylinder R), `sought_object_height = arg5`.
Copies `arg6` (MovementParameters) field-by-field into `this->movement_params`.
Saves current position into `starting_position`. Sets `movement_type = 6`,
`initialized = 0`.
If `arg3 == this->physics_obj->id` → it's us; CleanUp and bail.
Otherwise: `CPhysicsObj::set_target(physics_obj, 0, arg3, 0.5f, 0.0f)` — start
tracking. The actual movement queue isn't built here; it's built by
`HandleUpdateTarget` once the first target snapshot arrives (see below).
### `MoveToManager::MoveToPosition` @ 0x0052a240 (lines 307521-307593)
Position is known immediately, so the queue is built directly:
```c
// Wipe in-flight motion
StopCompletely(physics_obj_1);
// Snapshot target
this->current_target_position = *arg2;
this->sought_object_radius = 0.0f;
GetCurrentDistance(this); // returns |dist| in x87_r0
// Compute heading delta to target
float curHeading = CPhysicsObj::get_heading(physics_obj_2);
float headingToTarget = Position::heading(&physics_obj_2->m_position, arg2);
float delta = headingToTarget - curHeading;
if (|delta| < EPSILON) delta = 0;
if (delta < -EPSILON) delta += 360.0f; // normalize to [0, 360)
// Ask MovementParameters which command to issue (RunForward/WalkForward/Backwards/none)
// var_c = command, var_4 = holdKey, var_8 = movingAway
MovementParameters::get_command(arg3, dist, delta, &var_c, &var_4, &var_8);
// If we need to move at all, queue: TurnToHeading(headingToTarget) → MoveToPosition node
if (var_c != 0) {
float h = Position::heading(&this->physics_obj->m_position, arg2);
AddTurnToHeadingNode(this, h); // opcode 9
AddMoveToPositionNode(this); // opcode 7
}
// If "use final heading" flag (bit 0x40) is set, queue a final TurnToHeading(desired_heading)
if ((arg3->__inner0 & 0x40) != 0)
AddTurnToHeadingNode(this, arg3->desired_heading);
// Snapshot positions
this->sought_position = *arg2;
this->starting_position = this->physics_obj->m_position;
this->movement_type = 7;
this->movement_params = *arg3; // field-by-field copy
this->movement_params.__inner0 &= 0xffffff7f; // clear bit 7 (sticky-after-arrive flag) — that's only valid for MoveToObject
BeginNextNode(this); // pop the head and dispatch
```
### Pending-action queue ops
- **`AddMoveToPositionNode`** @ 0x00529580: appends `{opcode=7, value=undefined}`.
- **`AddTurnToHeadingNode(float h)`** @ 0x00529530: appends `{opcode=9, value=h}`.
- **`RemovePendingActionsHead`** @ 0x00529380: pop head, free node.
### `MoveToManager::BeginNextNode` @ 0x00529cb0 (lines 307123-307171)
Dispatch loop:
```c
if (head_ != nullptr) {
int op = head_->opcode; // offset +8
if (op == 7) tailcall BeginMoveForward(this);
if (op == 9) tailcall BeginTurnToHeading(this);
return;
}
// Queue empty.
head_ = (uint8_t)this->movement_params.__inner0; // recycle head_ as a register…
if (head_ < 0) { // i.e., bit 7 set on __inner0 == sticky-after-arrive
float radius = this->sought_object_radius;
float height = this->sought_object_height;
uint32_t topId = this->top_level_object_id;
CleanUp(this);
if (physics_obj != 0) StopCompletely(physics_obj);
PositionManager::StickTo(get_position_manager(physics_obj), topId, radius, height);
return;
}
// Queue empty, no sticky → done. CleanUp + StopCompletely.
CleanUp(this); StopCompletely(physics_obj);
```
### `MoveToManager::BeginMoveForward` @ 0x00529a00 (lines 306957-307042) — opcode 7 dispatch
1. Sanity: physics_obj null → CancelMoveTo with err 8 (NotInitialized).
2. `var_3c = GetCurrentDistance(this)` → distance to current target.
3. Compute heading-to-target delta in `var_40_1`, normalize like in `MoveToPosition`.
4. `MovementParameters::get_command(&this->movement_params, var_3c, var_40_1, &var_38, &var_34, &var_30)`
`var_38=command`, `var_34=holdKey`, `var_30=movingAway`.
5. **If `command == 0` (already arrived)**: `RemovePendingActionsHead`, then
`BeginNextNode` (advance to next pending node — typically a final TurnToHeading).
6. **Else**: `_DoMotion(this, command, &local_movement_params_clone)`.
On non-zero return → `CancelMoveTo(this, retval)`.
On success: stash `current_command = var_38`, `moving_away = var_30`,
`movement_params.hold_key_to_apply = var_34`, snapshot
`previous_distance/_time` and `original_distance/_time`.
### `MoveToManager::BeginTurnToHeading` @ 0x00529b90 (lines 307046-307120) — opcode 9 dispatch
1. Need head_ + physics_obj; otherwise CancelMoveTo err 8.
2. **If motions are pending in the body** (`CPhysicsObj::motions_pending != 0`),
skip — wait for body to settle.
3. Get target heading from `head_->value` (offset +0xc).
4. `st0 = heading_diff(target, current, 0x6500000d)` — 0x6500000d is
TurnLeft, so `heading_diff` returns "how much left-turn from current to
target".
5. **If diff ≈ 180** (within EPSILON of being directly behind): pop head and
advance via `BeginNextNode`. Avoids ambiguous turn direction.
6. **If diff ≈ 0** (within EPSILON of already aligned): pop head and advance.
7. **Else** decide direction: `edi = (st0 > 180) ? 0x6500000e (TurnRight) : 0x6500000d (TurnLeft)`.
8. `_DoMotion(this, edi, &local_params)`. On failure → CancelMoveTo. On
success: `current_command = edi`, `previous_heading = st0`.
### `MoveToManager::HandleMoveToPosition` @ 0x00529d80 — **the per-tick driver** (lines 307187-307438)
This is what `RemoteMoveToDriver.cs` is named after. Called every physics
frame from `UseTime` while the head pending node is opcode 7.
**Plain-language flow:**
1. **Aux turn correction** (lines 307213-307287). If body has motions pending,
stop the aux turn (we let body finish first). Otherwise:
- Compute `worldHeading = Position::heading(my_pos, current_target_position)`.
- `desiredHeading = worldHeading + MovementParameters::get_desired_heading(current_command, moving_away)`
`get_desired_heading` returns 0 for forward/180 for backward when chasing,
swapped when fleeing.
- Normalize to `[0, 360)`.
- `delta = desiredHeading - currentHeading`, normalized to `[0, 360)`.
- **If `delta ≤ 20°` OR `delta ≥ 340°`** (within 20° of correct facing):
stop the aux turn (`_StopMotion(aux_command)`, `aux_command = 0`).
- **Else** pick direction: `edi_1 = (delta > 180) ? TurnRight : TurnLeft`.
If different from `aux_command`, `_DoMotion(edi_1, ...)` and
`aux_command = edi_1`.
2. **Distance check** (line 307289 `GetCurrentDistance``var_88_3`).
3. **Progress check** (line 307294 `CheckProgressMade(this, var_88_3) == 0`):
- **If no progress**: if not interpolating and no motions pending,
`fail_progress_count += 1`. (Body might be wedged on geometry; the
counter is read by external code to decide when to give up.)
- **If progress**: reset `fail_progress_count = 0`. Then check arrival:
- **Chase (`moving_away == 0`)**: arrived when `dist ≤ DistanceToObject`
(line 307323 — `fcomp st0, [esi+0xe4]` is `movement_params.distance_to_object`).
- **Flee (`moving_away == 1`)**: arrived when `dist ≤ MinDistance`
(line 307309 — `[esi+0xe8]`).
- **If arrived**: `RemovePendingActionsHead`, `_StopMotion(current_command)`,
`current_command = 0`, stop aux too, `BeginNextNode`.
- **If past fail_distance**: `dist_from_start = Position::distance(starting_position, my_pos)`,
and if `dist_from_start > fail_distance``CancelMoveTo` with err 0x3D
(`ObjectGone` / "fail-distance exceeded").
4. **Adaptive quantum tuning** (lines 307376-307437). If we have a tracked
target (`top_level_object_id != 0`):
- `velocity = CPhysicsObj::get_velocity(this->physics_obj)`.
- `speed_sq = vx² + vy² + vz²`. `speed = sqrt(speed_sq)`.
- **If `speed > 0.1`** (line 307400 `0.1` const) — body actually moving:
- `quantum = var_88_3 / speed` — projected time-to-arrival.
- `if |target_quantum - quantum| > 1.0`
`CPhysicsObj::set_target_quantum(quantum)`.
- This is how the engine speeds up the next physics tick when we're
close to arrival, so we don't overshoot.
**ACE divergence note**: ACE swaps the chase/flee predicates (uses
`DistanceToObject` for chase arrival vs retail's `min_distance`). The retail
field naming and the physical meaning agree though — the arrival condition
is "distance shrunk past the threshold I asked for". `RemoteMoveToDriver.cs`
already follows retail correctly here (see its line 50-57 doc-comment).
### `MoveToManager::HandleTurnToHeading` @ 0x0052a0c0 (lines 307442-307517) — opcode 9 driver
1. If `current_command` isn't TurnLeft/TurnRight, fall through to
`BeginTurnToHeading` (re-pick direction).
2. Get current heading. Test
`heading_greater(curHeading, targetHeading, current_command)`
"have we passed the target".
- If yes: snap heading via `CPhysicsObj::set_heading(physics_obj_1, target, true)`,
pop pending head, `_StopMotion(current_command)`, `current_command = 0`,
`BeginNextNode`.
3. Else compute `delta_per_tick = heading_diff(curHeading, previous_heading, current_command)`:
- If `delta_per_tick ≥ 180` (turning the wrong way through the long arc):
skip the no-progress increment.
- If `delta_per_tick ≥ 0.0002f`: progress → `fail_progress_count = 0`,
`previous_heading = curHeading`.
4. Else: `previous_heading = curHeading`, and if neither interpolating nor
motions pending: `fail_progress_count++`.
### `MoveToManager::CheckProgressMade(float dist)` @ 0x005290f0 (lines 306385-306431)
Implements the "moved ≥ 0.25 m/s averaged over the last 1 s AND the last sample-interval" test.
```c
double elapsed_since_last_sample = cur_time - previous_distance_time;
if (elapsed_since_last_sample > 1.0) {
float instantaneous_speed = moving_away ? (dist - previous_distance) : (previous_distance - dist);
instantaneous_speed /= elapsed_since_last_sample;
if (instantaneous_speed > 0.25f) {
previous_distance = dist;
previous_distance_time = cur_time;
// ALSO check long-window speed
float total = moving_away ? (dist - original_distance) : (original_distance - dist);
total /= (cur_time - original_distance_time);
if (total > 0.25f) return 1;
}
return 0;
}
return 1; // not enough time elapsed yet to judge
```
Returns 1 = "making progress, don't increment fail counter"; 0 = "stuck".
### `MoveToManager::CancelMoveTo(WeenieError)` @ 0x00529930 (lines 306886-306940)
Drains `pending_actions` (free each node), `CleanUp(this)`,
`StopCompletely(physics_obj)`. Then the WeenieError is sent up to the weenie
object via the parent's CleanUpAndCallWeenie path.
### `MoveToManager::CleanUp` @ 0x005295c0 (lines 306710-306736)
```c
if (current_command != 0) _StopMotion(current_command, &local_params);
if (aux_command != 0) _StopMotion(aux_command, &local_params);
if (top_level_object_id != 0 && movement_type != Invalid)
CPhysicsObj::clear_target(physics_obj);
InitializeLocalVariables(this);
```
### `MoveToManager::HandleUpdateTarget(TargetInfo)` @ 0x0052a7d0 (lines 307802-307867)
Server-driven callback. When a tracked target's position updates:
```c
if (top_level_object_id != arg2->object_id) return; // not our target
if (initialized == 0) { // first snapshot
if (top_level_object_id == physics_obj->id) { // tracked self → done
sought_position = physics_obj->m_position;
CleanUpAndCallWeenie(this, current_target_position = physics_obj->m_position);
return;
}
if (arg2->status != Ok) { // bad snapshot
CancelMoveTo(this, 0x38);
return;
}
if (movement_type == MoveToObject) // first valid snapshot → build queue
MoveToObject_Internal(this, &arg2->target_position, &arg2->interpolated_position);
else if (movement_type == TurnToObject)
TurnToObject_Internal(this, &arg2->target_position);
} else { // ongoing
if (arg2->status != Ok) { CancelMoveTo(0x37); return; }
if (movement_type == MoveToObject) {
sought_position = arg2->interpolated_position;
current_target_position = arg2->target_position;
// RESET progress windows — target moved, so the old samples don't count
previous_distance = +inf;
previous_distance_time = cur_time;
original_distance = +inf;
original_distance_time = cur_time;
}
}
```
This is **the critical hookup for chase-AI**: every server `UpdateTarget`
shoves the pursuer's current_target forward and resets progress sampling.
### `MoveToManager::HitGround` @ 0x00529d70 (lines 307175-307183)
```c
if (movement_type != Invalid)
BeginNextNode(this);
```
Trigger from the body's contact-restored event. Used by AI move sequences
that start mid-air (knockback recovery, falling onto a target).
### `MoveToManager::UseTime` @ 0x0052a780 (lines 307776-307798) — **the tick entry point**
```c
if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) { // Contact bit
head_ = pending_actions.head_;
if (head_ != nullptr &&
(top_level_object_id == 0 || movement_type == Invalid || initialized != 0)) {
if (head_->opcode == 7) tailcall HandleMoveToPosition(this);
if (head_->opcode == 9) tailcall HandleTurnToHeading(this);
}
}
```
**Branch summary:**
- Must have Contact (touching ground).
- Must have a pending action.
- Either there's no tracked target, OR movement is Invalid, OR we've been
initialized (got the first target snapshot). I.e., for `MoveToObject`,
`UseTime` is a no-op until `HandleUpdateTarget` flips `initialized=1`.
---
## 4. MovementParameters
### Layout
`__inner0` is a packed bitfield (uint32, but the meaningful flags live in the
low 16 bits — we see `(int16_t)__inner0`). Confirmed bits:
| Bit | Mask | ACE name | Meaning |
|---|---|---|---|
| 0 | 0x0001 | CanWalk | Allow WalkForward speed. |
| 1 | 0x0002 | CanRun | Allow RunForward speed. |
| 2 | 0x0004 | CanSidestep | |
| 3 | 0x0008 | CanWalkBackwards | |
| 4 | 0x0010 | CanCharge | (Holds key to apply) |
| 5 | 0x0020 | FailWalk | |
| 6 | 0x0040 | UseFinalHeading | Append `TurnToHeading(desired_heading)` after MoveToPosition. (Line 307571.) |
| 7 | 0x0080 | Sticky | After arrival, `StickTo(top_level_object_id, ...)` instead of stopping. (Line 307145 reads this as the high bit of `__inner0` low-byte and treats `< 0` as "set".) |
| 8 | 0x0100 | MoveAway | Used by `towards_and_away` to flip arrival/turn-direction conventions. |
| 9 | 0x0200 | MoveTowards | |
| 10 | 0x0400 | UseSpheres | |
| 11 | 0x0800 | SetHoldKey | |
| 12 | 0x1000 | Autonomous | |
| 13 | 0x2000 | ModifyRawState | |
| 14 | 0x4000 | ModifyInterpretedState | |
| 15 | 0x8000 | CancelMoveTo | line 307208 `& 0xffff7fff` — mask out before sending to inner motion. |
| 16 | 0x10000 | StopCompletely | |
| 17 | 0x20000 | DisableJumpDuringLink | |
(Bit 7 ≥ "stick after arrive" is the load-bearing one for sticky-from-MoveTo; bit 6
is load-bearing for "turn to face X after arriving".)
### `MovementParameters::UnPackNet(MovementType type, void** stream, uint32_t bytes)` @ 0x0052ac50 (lines 308118-308190)
The wire format the **client receives** (vs `Pack`/`UnPack` which is the local-save format).
```c
size_required = (type == MoveToObject || type == MoveToPosition) ? 0x1c : 0x0c;
if (bytes < size_required || (type - 6) > 3) return 0;
switch (type) {
case MoveToObject:
case MoveToPosition:
// 0x1c bytes: __inner0, distance_to_object, min_distance, fail_distance, speed, walk_run_threshold, desired_heading
this->__inner0 = read_uint32();
this->distance_to_object = read_float();
this->min_distance = read_float();
this->fail_distance = read_float();
this->speed = read_float();
this->walk_run_threshhold= read_float();
// desired_heading written below (shared)
break;
case TurnToObject:
case TurnToHeading:
// 0x0c bytes: __inner0, speed, desired_heading
this->__inner0 = read_uint32();
this->speed = read_float();
// desired_heading written below
break;
}
this->desired_heading = read_float();
return 1;
```
Note: **UnPackNet is shorter than UnPack** (0x1c/0x0c vs 0x28). The wire format
omits `context_id`, `hold_key_to_apply`, and `action_stamp` — those are
local-only (server tracks them, doesn't ship them). Compare to the full
`UnPack` @ 0x0052abc0 which reads all 9 fields × 4 bytes = 0x28 bytes.
### `MovementParameters::get_command(dist, heading_delta, &cmd, &holdKey, &movingAway)` @ 0x0052aa00 (lines 307946-308012)
Decides which motion-command to issue based on flags + distance.
Pseudocode (cleaner than the FP-mangled extract, validated against ACE port):
```c
inner0 = this->__inner0;
if (inner0 & 0x0200) { // MoveTowards
if (inner0 & 0x0100) // MoveTowards AND MoveAway → use towards_and_away
towards_and_away(this, dist, heading, &cmd, &movingAway);
else if (dist > distance_to_object) { // Towards-only, still far → walk forward
cmd = 0x45000005; // WalkForward
movingAway = 0;
} else cmd = 0;
} else if (inner0 & 0x0100) { // MoveAway only
if (dist - min_distance < EPSILON) { // too close walk back
cmd = 0x45000005;
movingAway = 1;
} else cmd = 0;
} else { // neither
if (dist > distance_to_object) {
cmd = 0x45000005; movingAway = 0;
} else cmd = 0;
}
// Hold key: pick Run vs None based on CanRun, CanWalk, walk_run_threshhold
if (inner0 & 0x10) { // CanCharge / SetHoldKey route
*holdKey = HoldKey_Run;
return;
}
if ((inner0 & 0x02) == 0) { // !CanRun
*holdKey = HoldKey_None;
return;
}
if (inner0 & 0x01) { // CanWalk
if (dist - distance_to_object <= walk_run_threshhold) {
*holdKey = HoldKey_None;
return;
}
}
*holdKey = HoldKey_Run;
```
Magic numbers:
- `0x45000005` = WalkForward command
- `0x45000006` = WalkBackwards (used by `towards_and_away` when fleeing too close)
- `0x44000007` = RunForward (matched in `get_desired_heading`)
### `MovementParameters::get_desired_heading(motion, movingAway)` @ 0x0052aad0 (lines 308016-308033)
Returns 0 (chase forward) / 180 (chase backward / flee forward) / arg3 (other).
Used by `HandleMoveToPosition` to compute the desired heading offset from the
world-heading-to-target.
```c
if (motion == 0x44000007 || motion == 0x45000005) return arg3; // forward → arg3=0 → face target
if (motion == 0x45000006) return arg3; // backward → arg3=180 → face target
return motion - 0x45000006; // (sentinel, only hit for non-forward/back)
```
(ACE port lines 186-198 hardcode this as `movingAway ? 180 : 0` for the
forward case, which is the same algebra after substituting in the actual
arg3 values the callers pass.)
---
## 5. Interaction with PositionManager / InterpolationManager
### `PositionManager::adjust_offset(Frame*, double quantum)` @ 0x00555190 (lines 352090-352118)
Calls all three managers in sequence:
```c
1. interpolation_manager->adjust_offset(arg2, quantum); // smooths server → local position over a window
2. sticky_manager->adjust_offset(arg2, quantum); // stick to a target
3. constraint_manager->adjust_offset(arg2, quantum); // clamp to a leash
```
**Order matters**:
- InterpolationManager runs first, baking server-driven catch-up into the offset.
- StickyManager then **reads from physics_obj's m_position** (which is already
interpolated by the previous step's commit — the offset hasn't been applied
yet, but m_position reflects last frame's solved state). Sticky overwrites
the offset if it has a target.
- ConstraintManager comes last, scaling-down or zeroing whatever the others
produced if it would push us past a leash radius.
Each manager **reads** from `physics_obj->m_position` and **writes** the
per-tick offset (translation + rotation delta) into `arg2`. The caller then
composes that offset onto `physics_obj->m_position` for the actual move.
### MoveToManager interaction
MoveToManager **does not** participate in `PositionManager::adjust_offset`. It
runs once per tick from a different entry point (`UseTime`, called from the
physics scheduler) and **issues motion commands** to `CMotionInterp` via
`_DoMotion` / `_StopMotion`. The body's velocity comes from
`CMotionInterp::apply_current_movement`, not from MoveToManager directly.
So the layering is:
1. Pending nodes → `_DoMotion(MoveTo*)``CMotionInterp::DoInterpretedMotion(RunForward + HoldKey.Run)`
2. `CMotionInterp` writes `InterpretedState.ForwardCommand=RunForward, HoldKey=Run`
3. Each tick, `apply_current_movement` reads InterpretedState and emits a body velocity
4. PositionManager (Interp+Sticky+Constraint) post-modifies the per-tick offset
When sticky activates from MoveToManager (after arrival), MoveToManager calls
`PositionManager::StickTo`, which creates the sticky and from then on the
sticky's `adjust_offset` overrides the body's natural velocity each tick.
---
## 6. Differences vs acdream `RemoteMoveToDriver.cs`
acdream's current port is intentionally minimal (header comment lines 44-57).
What it **DOES** correctly:
- Heading delta with 20° snap tolerance — line 307255-307287 of retail.
- Arrival predicate via `min_distance` for chase / `distance_to_object` for flee
— matches retail (lines 307309/307323), explicitly diverges from ACE.
- Stale-destination giveup at 1.5 s (acdream-specific safety net for our
streaming model).
What it **OMITS** vs retail (acceptable for a remote-observer of a server-
authored creature):
- Pending-action queue (TurnToHeading → MoveToPosition → final TurnToHeading).
Server re-emits the move; we don't need to schedule sub-nodes.
- Sticky-after-arrive (`__inner0 & 0x0080`). Server signals stick separately
via `PositionManager::StickTo` calls or via re-emitted MoveTos.
- `CheckProgressMade` / `fail_progress_count`. Server-side concern; if the
remote AI gives up, the server just stops sending MoveTo updates.
- `set_target_quantum` adaptive tick rate. We run at fixed 60 Hz.
- `HandleUpdateTarget` re-tracking. Server re-emits the full MoveTo when its
target moves; we re-parse and re-init.
- ConstraintManager and `transient_state & Contact` gating. We don't have a
real contact-plane test on remotes (we only do collision for the local
player). For remotes, we always assume "on ground".
- StickyManager altogether — there's no scenario where a remote needs to
follow a target the server hasn't already told us to face via UpdateMotion.
What's a **real port gap** worth filing for L.3 follow-up:
- `MovementParameters::__inner0` flag bit 0x40 (`UseFinalHeading`) — when the
packet's final-heading bit is set, the body should rotate to face
`desired_heading` after arrival. We currently ignore this and the remote
ends in whatever heading the last steering tick produced.
- `MovementParameters::__inner0` flag bit 0x80 (Sticky-after-arrive) — when
set, we should latch onto `top_level_object_id` instead of going idle. For
an adventuring monster following the player, this matters: today we'd let
the remote stand still for ~1 s (server's MoveTo re-emit cadence) then
steer again, instead of locking on smoothly. Worth filing as `#L.X`.
- `transient_state & 1` (Contact) gating. If a remote is mid-air (knocked
back, jumping), `RemoteMoveToDriver` shouldn't drive horizontal motion
toward the destination. We currently steer regardless of grounded state.
---
## Appendix: full retail line-citation index (this doc only)
| Function | Address | Pseudo-C lines |
|---|---|---|
| StickyManager::Create | 0x00555800 | 352620-352633 |
| StickyManager::Destroy | 0x00555650 | 352521-352540 |
| StickyManager::SetPhysicsObject | 0x005556e0 | 352544-352555 |
| StickyManager::StickTo | 0x00555710 | 352559-352578 |
| StickyManager::HandleUpdateTarget | 0x00555780 | 352582-352607 |
| StickyManager::UnStick | 0x00555400 | 352335-352346 |
| StickyManager::adjust_offset | 0x00555430 | 352351-352494 |
| StickyManager::UseTime | 0x00555610 | 352498-352517 |
| StickyManager::~StickyManager | 0x005557e0 | 352611-352616 |
| ConstraintManager::Create | 0x00556110 | 353442-353474 |
| ConstraintManager::SetPhysicsObject | 0x00556090 | 353388-353401 |
| ConstraintManager::ConstrainTo | 0x00556240 | 353528-353537 |
| ConstraintManager::UnConstrain | 0x005560c0 | 353405-353409 |
| ConstraintManager::IsFullyConstrained | 0x005560d0 | 353413-353427 |
| ConstraintManager::adjust_offset | 0x00556180 | 353479-353524 |
| ConstraintManager::~ConstraintManager | 0x005560f0 | 353431-353438 |
| MoveToManager::MoveToManager | 0x005293b0 | 306554-306593 |
| MoveToManager::Create | 0x00529470 | 306597-306614 |
| MoveToManager::Destroy | 0x005294b0 | 306618-306663 |
| MoveToManager::InitializeLocalVariables | 0x00529250 | 306490-306534 |
| MoveToManager::PerformMovement | 0x0052a900 | 307871-307904 |
| MoveToManager::MoveToObject | 0x00529680 | 306756-306817 |
| MoveToManager::TurnToObject | 0x005297d0 | 306820-306882 |
| MoveToManager::MoveToPosition | 0x0052a240 | 307521-307593 |
| MoveToManager::TurnToHeading | 0x0052a630 | 307706-307772 |
| MoveToManager::MoveToObject_Internal | 0x0052a400 | 307597-307663 |
| MoveToManager::TurnToObject_Internal | 0x0052a550 | 307667-307702 |
| MoveToManager::AddTurnToHeadingNode | 0x00529530 | 306667-306685 |
| MoveToManager::AddMoveToPositionNode | 0x00529580 | 306689-306706 |
| MoveToManager::RemovePendingActionsHead | 0x00529380 | 306538-306550 |
| MoveToManager::CleanUp | 0x005295c0 | 306710-306736 |
| MoveToManager::CleanUpAndCallWeenie | 0x00529650 | 306740-306752 |
| MoveToManager::CancelMoveTo | 0x00529930 | 306886-306940 |
| MoveToManager::~MoveToManager | 0x005299d0 | 306945-306953 |
| MoveToManager::BeginMoveForward | 0x00529a00 | 306957-307042 |
| MoveToManager::BeginTurnToHeading | 0x00529b90 | 307046-307120 |
| MoveToManager::BeginNextNode | 0x00529cb0 | 307123-307171 |
| MoveToManager::HitGround | 0x00529d70 | 307175-307183 |
| MoveToManager::HandleMoveToPosition | 0x00529d80 | 307187-307438 |
| MoveToManager::HandleTurnToHeading | 0x0052a0c0 | 307442-307517 |
| MoveToManager::UseTime | 0x0052a780 | 307776-307798 |
| MoveToManager::HandleUpdateTarget | 0x0052a7d0 | 307802-307867 |
| MoveToManager::CheckProgressMade | 0x005290f0 | 306385-306431 |
| MoveToManager::GetCurrentDistance | 0x005291b0 | 306435-306460 |
| MoveToManager::is_moving_to | 0x00529220 | 306464-306470 |
| MoveToManager::_DoMotion | 0x00529010 | 306351-306364 |
| MoveToManager::_StopMotion | 0x00529080 | 306368-306381 |
| MovementParameters::towards_and_away | 0x0052a9a0 | 307917-307942 |
| MovementParameters::get_command | 0x0052aa00 | 307946-308012 |
| MovementParameters::get_desired_heading | 0x0052aad0 | 308016-308033 |
| MovementParameters::Pack | 0x0052ab20 | 308037-308074 |
| MovementParameters::UnPack | 0x0052abc0 | 308078-308114 |
| MovementParameters::UnPackNet | 0x0052ac50 | 308118-308190 |

View file

@ -0,0 +1,824 @@
# L.3 port — `update_object` substepping + `Frame` operations
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decompilation, BinaryNinja pseudo-C). Cross-checked against ACE's port (`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`, `PhysicsGlobals.cs`).
This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:
- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
- What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop)
- What does `process_hooks` do? (iterates linked-list of `PhysicsObjHook`s + `anim_hooks` per frame, executing & removing finished ones)
- `Frame::combine` semantics: `out = a · b` — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product `a.q * b.q` for orientation).
The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some immediate floats to `0.0`. The constants are recovered cleanly from ACE's `PhysicsGlobals.cs`, which itself is a faithful port of the same retail binary. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from `MinQuantum`.
---
## 1 — `CPhysicsObj::update_object` (FUN_00515D10) — main per-frame entry
**Signature:** `void __fastcall CPhysicsObj::update_object(CPhysicsObj* this)`
**Source:** `acclient_2013_pseudo_c.txt:283950-284055`
### Verbatim relevant pseudo-C (lines 283950-284055)
```
00515d10 void __fastcall CPhysicsObj::update_object(class CPhysicsObj* this) {
// Bail-out 1: parented (held by another obj), no cell, or hidden (state & 0x1000000)
if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) {
this->transient_state &= 0xffffff7f; // clear "active" flag
return;
}
// Player-distance update: if a player object exists, compute offset and
// toggle "active" transient flag based on 96.0f distance gate.
CPhysicsObj* player = CPhysicsObj::player_object;
if (player != 0) {
Vector3 offset;
Position::get_offset(&player->m_position, &offset, &this->m_position);
this->player_vector = offset;
this->player_distance = sqrtf(offset.x*offset.x + offset.y*offset.y + offset.z*offset.z);
// [actually plain |offset.x| in BN noise; ACE uses .Length()]
if (this->player_distance >= 96.0f) {
// beyond the active radius; deactivate
this = CPhysicsObj::obj_maint; // this overwritten — BN noise
}
if (this->player_distance >= 96.0f || this->part_array == 0) {
this = this_3;
CPhysicsObj::set_active(this, 1);
} else {
this_3->transient_state &= 0xffffff7f; // clear active
}
}
// ── dt computation ────────────────────────────────────────────────────────
double dt = Timer::cur_time - this_3->update_time;
PhysicsTimer::curr_time = this_3->update_time; // seed phys clock with this obj's last-update
// ── Guard 1: dt < EPSILON (0.000199999995f 0.0002 s)
// Retail tolerance for "essentially zero" — NOT MinQuantum.
// If dt < EPSILON, bump update_time and return without any simulation.
if (dt < 0.000199999995f) { // line 283996
this_3->update_time = Timer::cur_time;
return;
}
// ── Guard 2: dt > HugeQuantum (2.0 s) — discard stale dt ─────────────────
// (Constant 2.0 visible at line 284009 — "long double temp1 = 2.0;")
if (dt > 2.0) {
this_3->update_time = Timer::cur_time;
return;
}
// ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
// BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure
// is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1.
if (dt > 0.0f /* MaxQuantum=0.1 */) {
do {
PhysicsTimer::curr_time += /* MaxQuantum */ 0.1;
CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f);
dt -= /* MaxQuantum */ 0.1;
} while (dt > /* MaxQuantum */ 0.1);
}
// ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ────
// BN: line 284046 "if (!(p_1) || ... > 0.0) { ... UpdateObjectInternal(remainder)
// }" — the comparison constant should be MinQuantum=1/30=0.0333f per ACE.
if (dt > /* MinQuantum */ 0.0333f) {
PhysicsTimer::curr_time += dt;
CPhysicsObj::UpdateObjectInternal(this_3, (float)dt);
}
// Advance update_time to the consumed phys clock time.
this_3->update_time = PhysicsTimer::curr_time;
}
```
### Constants — recovered values
From `references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43`:
| Symbol | Hex (float32) | Value | Meaning |
|---|---|---|---|
| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) |
| `MinQuantum` | `0x3D088889` | `1.0f / 30.0f ≈ 0.03333` s (30 fps) | minimum simulation step |
| `MaxQuantum` | `0x3DCCCCCD` | `0.1f` (10 fps) | substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE |
| `HugeQuantum` | `0x40000000` | `2.0f` (0.5 fps) | upper bound — beyond this, dt is discarded as stale (visible line 284009) |
**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail clearly reads non-zero immediates from `.rdata`. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard.
### `update_object_server` — does it exist?
**No.** Search of `acclient_2013_pseudo_c.txt` for `update_object_server` returns zero hits. There is only `CPhysicsObj::update_object` (the per-frame driver) and `CPhysicsObj::UpdateObjectInternal` (the per-substep worker). ACE introduced server-side variants that don't exist in retail.
---
## 2 — `CPhysicsObj::UpdateObjectInternal` (FUN_005156B0) — per-substep worker
**Signature:** `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float arg2)`
**Source:** `acclient_2013_pseudo_c.txt:283611-283757`
This is the function called once per substep with `arg2 = dt` (≤ MaxQuantum). Two main branches based on `transient_state` sign bit (which is `Active`, 0x80):
```
005156b0 void UpdateObjectInternal(CPhysicsObj* this, float arg2) {
// Branch A: obj is INACTIVE (transient_state >= 0, i.e. high bit clear).
// Just tick particles + scripts; no movement.
if ((int16_t)this->transient_state >= 0) goto label_5159b8;
// Branch B: obj is ACTIVE.
if (this->cell == 0) return;
// ── Active-mover path ───────────────────────────────────────────────────
if ((this->transient_state & 0x100) != 0) // line 283631 — clears Sticky
CPhysicsObj::set_ethereal(this, 0, 0);
this->jumped_this_frame = 0;
// Build a local Frame (stack-allocated, identity quaternion).
Position offsetPos = { objcell_id=0x796910, qw=1, qx=0,qy=0,qz=0,
origin={0,0,0} };
Frame offsetFrame; // stack
Frame::cache(&offsetFrame); // line 283644
uint32_t cellId = this->m_position.objcell_id;
// ── 1) UpdatePositionInternal: integrates velocity/accel into offsetFrame
st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &offsetFrame);
// line 283646
CPartArray* parts = this->part_array;
uint32_t numSpheres = parts ? CPartArray::GetNumSphere(parts) : 0;
if (parts != 0 && numSpheres != 0) {
if (Vector3::operator==(&offsetFrame.origin, &this->m_position.frame.origin) == 0) {
// origin moved — need a transition (collision sweep)
uint32_t state = this->state;
if ((state & 0x100) != 0) { // line 283661
// facing-velocity heading mode
Vector3 dir;
AC1Legacy::Vector3::operator-(&offsetFrame.origin, &dir,
&this->m_position.frame.origin);
Vector3::Normalize(&dir);
Frame::set_vector_heading(&offsetFrame, &dir);
}
else if ((state & "activation type (%s) with '%s' b…" /* a high state-bit */) != 0
&& AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0) {
float heading = AC1Legacy::Vector3::get_heading(&this->m_velocityVector);
Frame::set_heading(&offsetFrame, heading);
}
// ── COLLISION SWEEP — port of FUN_005148A0 / Transition::FindTransitional… ──
CTransition* tx = CPhysicsObj::transition(this, &this->m_position,
&offsetPos /* desired */,
/*flags*/ 0);
if (tx == 0) {
// sweep failed — keep current position, snap to offsetFrame, zero velocity
CPhysicsObj::set_frame(this, &offsetFrame);
this->cached_velocity = {0,0,0};
} else {
// sweep succeeded — measured velocity = (curr_pos - new_pos)/dt
Vector3 deltaPos;
Position::get_offset(&this->m_position, &deltaPos, &tx->sphere_path.curr_pos);
Vector3 measuredVel;
Vector3::operator/(&deltaPos, &measuredVel, arg2);
this->cached_velocity = measuredVel;
CPhysicsObj::SetPositionInternal(this, tx); // commits new pos+cell
}
} else {
// origin didn't move — just set frame and clear velocity
CPhysicsObj::set_frame(this, &offsetFrame);
this->cached_velocity = {0,0,0};
}
} else {
// No part_array or no spheres — clear "stationary fall" flag if free, set frame, clear velocity
if (this->movement_manager == 0) {
uint32_t ts = this->transient_state;
if ((ts & 2) != 0) this->transient_state = ts & 0xffffff7f;
}
CPhysicsObj::set_frame(this, &offsetFrame);
this->cached_velocity = {0,0,0};
}
// ── 2) Per-frame ticks (managers + parts + position interp) ─────────────
if (this->detection_manager != 0)
DetectionManager::CheckDetection(this->detection_manager);
if (this->target_manager != 0)
TargetManager::HandleTargetting(this->target_manager);
if (this->movement_manager != 0)
MovementManager::UseTime(this->movement_manager); // animation tick
if (this->part_array != 0)
CPartArray::HandleMovement(this->part_array);
if (this->position_manager != 0)
PositionManager::UseTime(this->position_manager);
label_5159b8:
// ── 3) Particles + scripts (always, both Active and Inactive branches) ──
if (this->particle_manager != 0)
ParticleManager::UpdateParticles(this->particle_manager);
if (this->script_manager != 0)
ScriptManager::UpdateScripts(this->script_manager);
}
```
**Key sequencing per substep:**
1. Build identity local `Frame` (`offsetFrame`).
2. `UpdatePositionInternal(this, dt, &offsetFrame)` — integrate motion into the frame.
3. If origin changed and we have collidable spheres → run `transition()` (collision sweep).
4. If sweep succeeds → commit via `SetPositionInternal`; cached_velocity = (deltaPos / dt).
5. If sweep fails → snap to `offsetFrame` directly; zero velocity.
6. Tick managers (Detection, Target, Movement, PositionManager).
7. Tick CPartArray::HandleMovement (per-part frame propagation).
8. Tick particle_manager + script_manager.
`process_hooks` is NOT called here — it lives inside `UpdatePositionInternal` (see §3).
---
## 3 — `CPhysicsObj::UpdatePositionInternal` (FUN_00512C30)
**Signature:** `void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)`
**Source:** `acclient_2013_pseudo_c.txt:280817-280866`
```
00512c30 void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame) {
// ── Step A: tabula-rasa local frame, zero translation ───────────────────
Frame localFrame;
localFrame.qw=1, localFrame.qx=0,qy=0,qz=0;
localFrame.origin = {0,0,0};
Frame::cache(&localFrame); // build l2gv basis from quat
// ── Step B: animation drives a delta-frame into localFrame ──────────────
// (Skipped if state & 0x4000 / "static decoration" bit.)
if ((this->state & 0x4000) == 0) {
if (this->part_array != 0) {
// CPartArray::Update walks the AnimSequencer, applies animFrame deltas,
// adds them onto var_c/var_8/var_4 (the local origin). It also pulls
// OmegaVector and applies it into the quaternion. After this returns,
// localFrame.origin holds the local-frame velocity*dt + omega-rotation.
CPartArray::Update(this->part_array, dt, &localFrame);
}
// Scale by m_scale if Sticky flag is set (riding a moving platform).
// Otherwise zero the local-origin (just keep rotation).
if ((this->transient_state & 2) /* HasContact */ == 0) {
localFrame.origin *= 0.0f; // zero translation
} else {
localFrame.origin *= this->m_scale;
}
}
// ── Step C: apply position_manager interpolation offset (smooth catch-up)
if (this->position_manager != 0)
PositionManager::adjust_offset(this->position_manager, &localFrame, dt);
// ── Step D: COMBINE — outFrame = m_position.frame * localFrame ──────────
// This rotates localFrame.origin by m_position.frame's basis, adds m_position.frame's
// origin, multiplies the quaternions: outFrame.q = m_position.q * localFrame.q.
Frame::combine(outFrame, &this->m_position.frame, &localFrame); // line 280860
// ── Step E: if not "static decoration", run physics (gravity, friction…)
if ((this->state & 0x4000) == 0)
CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame);
// ── Step F: dispatch hooks (per-frame scripted callbacks + anim hooks) ──
CPhysicsObj::process_hooks(this); // line 280865
}
```
This is the function that produces the **desired post-tick world frame** in `outFrame`. The caller (`UpdateObjectInternal`) then routes that through the collision sweep.
---
## 4 — `CPhysicsObj::process_hooks` (FUN_00511550)
**Signature:** `void __fastcall CPhysicsObj::process_hooks(CPhysicsObj* this)`
**Source:** `acclient_2013_pseudo_c.txt:279431-279486`
```
00511550 void process_hooks(CPhysicsObj* this) {
// ── Linked-list hooks (vtable->Execute) ─────────────────────────────────
// PhysicsObjHook is a polymorphic interface (translucency-fade, scale-fade,
// visibility-fade, FPHook, etc). When Execute returns nonzero, the hook is
// "done" — unlink and delete it.
PhysicsObjHook* h = this->hooks;
while (h != 0) {
PhysicsObjHook* next = h->next;
if (h->vtable->Execute(this) != 0) {
// unlink h from doubly-linked list
if (h->next != 0) h->next->prev = h->prev;
if (h->prev == 0) this->hooks = h->next;
else h->prev->next = h->next;
h->prev = h->next = 0;
h->vtable = (vtable_t*)0x7c6b20; // PhysicsObjHook base vtable
operator delete(h);
}
h = next;
}
// ── Anim hooks (one-shot bag, executed and cleared every frame) ─────────
uint32_t n = this->anim_hooks.m_num;
if (n > 0) {
for (uint32_t i = 0; i < this->anim_hooks.m_num; i++)
this->anim_hooks.m_data[i]->vtable->Execute(this);
AC1Legacy::SmartArray<CAnimHook*>::shrink(&this->anim_hooks);
this->anim_hooks.m_num = 0;
}
}
```
**What it does:**
- `hooks` (linked list): persistent-until-done callbacks (translucency lerp, scale lerp, FPHook for fade events, etc). Each `Execute` returns done=1 → delete.
- `anim_hooks` (`SmartArray<CAnimHook*>`): one-shot per-frame anim events (sound triggers, particle spawns, attack-frame markers fired by AnimationSequencer). Always cleared every frame.
This is invoked once per substep at the END of `UpdatePositionInternal`. acdream's port has separate routers (`AnimationHookRouter`, `AnimationCommandRouter`) but no equivalent of the persistent `PhysicsObjHook` linked list yet.
---
## 5 — `CPhysicsObj::calc_acceleration` (FUN_00510950)
**Signature:** `void __fastcall CPhysicsObj::calc_acceleration(CPhysicsObj* this)`
**Source:** `acclient_2013_pseudo_c.txt:278533-278560`
```
00510950 void calc_acceleration(CPhysicsObj* this) {
uint8_t ts = (int8_t)this->transient_state;
// Special case: Active + HasContact + state-bit-?? → freeze (zero accel + omega).
// Used for "standing still on a surface" steady-state.
if ((ts & 1) != 0 && (ts & 2) != 0 && (this->state & 0x100 /* state's high mask */) == 0) {
this->m_accelerationVector = {0, 0, 0};
this->m_omegaVector = {0, 0, 0};
return;
}
// Gravity gate: state bit 0x4 (= GravityFlag).
if ((this->state & 0x400 /* gravity bit, 0x4 << 8 in BN ushort masking */) == 0) {
// Gravity OFF — zero acceleration (note: omega NOT zeroed)
this->m_accelerationVector = {0, 0, 0};
return;
}
// Default: gravity ON → vertical acceleration = PhysicsGlobals::gravity
this->m_accelerationVector = {0, 0, PhysicsGlobals::gravity}; // gravity ≈ -9.8
}
```
**Per-frame called by `UpdatePhysicsInternal` (which is called from `UpdatePositionInternal` step E above)**. acdream's `PhysicsBody.calc_acceleration` matches this contract.
---
## 6 — `CPhysicsObj::transition` (FUN_00512DC0)
**Signature:** `CTransition const* transition(CPhysicsObj* this, Position const* fromPos, Position const* toPos, int32_t flags)`
**Source:** `acclient_2013_pseudo_c.txt:280904-280957`
```
00512dc0 CTransition* transition(CPhysicsObj* this, Position* from, Position* to, int32_t flags) {
CTransition* tx = CTransition::makeTransition();
if (tx == 0) return 0;
// Init the object info struct (collidesWith, isMissile, etc) using flags arg
CTransition::init_object(tx, this, CPhysicsObj::get_object_info(this, tx, flags));
// Init sphere(s) to sweep — typically 1 humanoid sphere or N for parts
CPartArray* parts = this->part_array;
uint32_t n = parts ? CPartArray::GetNumSphere(parts) : 0;
if (parts == 0 || n == 0) {
CTransition::init_sphere(tx, 1, &dummy_sphere, 1.0f);
} else {
float scale = this->m_scale;
CSphere* spheres = CPartArray::GetSphere(parts);
uint32_t nSph = CPartArray::GetNumSphere(parts);
CTransition::init_sphere(tx, nSph, spheres, scale);
}
// Path: from → to in cell `this->cell`
CTransition::init_path(tx, this->cell, from, to);
// Stationary-fall mask: tighter checks based on transient_state ContactPlane bits
uint8_t ts = (int8_t)this->transient_state;
if ((ts & 0x40) != 0) tx->collision_info.frames_stationary_fall = 3;
else if ((ts & 0x20) != 0) tx->collision_info.frames_stationary_fall = 2;
else if ((ts & 0x10) != 0) tx->collision_info.frames_stationary_fall = 1;
// Run the actual sweep — returns nonzero on success
int32_t ok = CTransition::find_valid_position(tx);
// NOTE: BN shows cleanupTransition(tx) BEFORE the success check — this
// looks wrong but BN's stack-frame analysis is unreliable here. ACE's
// port (PhysicsObj.transition) calls cleanup AFTER, conditionally.
CTransition::cleanupTransition(tx);
return (ok != 0) ? tx : 0;
}
```
`find_valid_position` is just an alias that calls `find_transitional_position` (line 273898). The actual sweep loop is `CTransition::find_transitional_position` (FUN_0050BDF0) at line 273613, which:
1. Computes step count (`calc_num_steps`) — `dt-derived` based on offset length and sphere radius.
2. Loops, advancing the sphere along the offset, calling `transitional_insert` each step.
3. Each step: cell list → BSP collision → step-up / edge-slide / contact-plane logic.
This is the heart of the "collision sweep" the env-var path currently bypasses.
---
## 7 — `CPhysicsObj::SetPositionInternal` overloads
Two overloads exist. The "post-sweep commit" form is FUN_00515BD0, called with `(this, ebp /*tx*/)`:
```
00515bd0 SetPositionError SetPositionInternal(CPhysicsObj* this, Position* pos,
SetPositionStruct* sps, CTransition* tx) {
CSphere* localSph = tx->sphere_path.local_sphere;
if (this->cell == 0) CPhysicsObj::prepare_to_enter_world(this);
int32_t ecx_2 = (sps->flags >> 5) & 1; // "AdjustPosition" flag
CTransition* outTx = nullptr;
CPhysicsObj::AdjustPosition(pos, localSph, &outTx, ecx_2, 1);
if (outTx == 0) {
// Off the map — go to "lost cell"
CPhysicsObj::prepare_to_leave_visibility(this);
CPhysicsObj::store_position(this, pos);
CObjectMaint::GotoLostCell(CPhysicsObj::obj_maint, this, this->m_position.objcell_id);
this->transient_state &= 0xffffff7f;
} else {
// Hooks/Storage/Corpses go through ForceIntoCell
if (this->weenie_obj != 0) {
if (weenie_obj->IsHook()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
if (weenie_obj->IsStorage()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
if (weenie_obj->IsCorpse()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
}
// Honor "do_not_load_cells" sps flag
if ((sps->flags & 0x20) != 0) tx->cell_array.do_not_load_cells = 1;
if (CPhysicsObj::CheckPositionInternal(this, outTx, pos, tx, sps) == 0) {
int32_t r = CPhysicsObj::handle_all_collisions(this, &tx->collision_info, 0, 0);
return (-r ^ -r ... & 2) + 2; // BN noise — actually returns 2 or 3
}
if (tx->sphere_path.curr_cell == 0) return 3; // CELL_FAILED
CPhysicsObj::SetPositionInternal(this, tx); // 1-arg form: commit
}
return 0; // OK_SPE
}
```
The 1-arg form (`SetPositionInternal(this, tx)`) is the one that finally writes the new cell pointer + frame onto the object (it's not in this excerpt — it's the real "commit" routine).
---
## 8 — `CPhysicsObj::SetPosition` (FUN_005160C0)
External wrapper that builds a CTransition, runs SetPositionInternal, and returns. Used by NPC teleport / scatter, NOT by `update_object`.
```
005160c0 SetPositionError SetPosition(CPhysicsObj* this, SetPositionStruct* sps) {
CTransition* tx = CTransition::makeTransition();
if (tx == 0) return 1;
CTransition::init_object(tx, this, 0);
// Init sphere(s) — same pattern as transition()
CTransition::init_sphere(tx, n, spheres, scale);
SetPositionError r = CPhysicsObj::SetPositionInternal(this, sps, tx);
CTransition::cleanupTransition(tx);
return r;
}
```
## 9 — `CPhysicsObj::SetPositionSimple` (FUN_005162B0)
```
005162b0 SetPositionError SetPositionSimple(CPhysicsObj* this, Position* pos, int32_t teleport) {
uint32_t flags = teleport ? 0x1012 : 0x1002;
SetPositionStruct sps;
SetPositionStruct::SetPositionStruct(&sps);
SetPositionStruct::SetPosition(&sps, pos);
SetPositionStruct::SetFlags(&sps, flags);
SetPositionError r = CPhysicsObj::SetPosition(this, &sps);
SetPositionStruct::~SetPositionStruct(&sps);
return r;
}
```
## 10 — `CPhysicsObj::set_frame` (FUN_00514090)
The "no collision check" frame setter — used inside the substep when origin didn't move OR when the sweep failed and we just snap.
```
00514090 void set_frame(CPhysicsObj* this, Frame* arg2) {
Frame newFrame;
Frame::operator=(&newFrame, arg2);
if (Frame::IsValid(&newFrame) == 0 && Frame::IsValidExceptForHeading(&newFrame) != 0) {
// NaN-only-in-quaternion edge case → reset rotation (memset to 0)
newFrame.qw = 0; newFrame.qx = 0; newFrame.qy = 0; newFrame.qz = 0;
}
Frame::operator=(&this->m_position.frame, &newFrame); // store
if ((this->state & 0x1000 /* "no parts" */) == 0) {
if (this->part_array != 0)
CPartArray::SetFrame(this->part_array, &this->m_position.frame);
}
CPhysicsObj::UpdateChildrenInternal(this); // propagate to children
}
```
---
## 11 — `Frame` operations
### Memory layout (verbatim from `acclient.h` / inferred from BN offsets)
```
class Frame {
Vector3 m_fOrigin; // +0x00 (12 bytes)
float qw, qx, qy, qz; // +0x0C (16 bytes)
float m_fl2gv[9]; // +0x1C (36 bytes) — 3x3 local-to-global rotation matrix cache
}; // total 0x40 = 64 bytes
```
The matrix cache `m_fl2gv` is the rotation matrix derived from the quaternion. It's recomputed by `Frame::cache` whenever the quaternion changes.
### `Frame::operator=` (FUN_00425C30) — line 39761
Plain memberwise copy of all 16 floats (origin + quat + 9 matrix entries):
```
00425c30 Frame& operator=(Frame* this, Frame const& src) {
this->m_fOrigin = src.m_fOrigin;
this->qw = src.qw; this->qx = src.qx; this->qy = src.qy; this->qz = src.qz;
for (int i = 0; i < 9; i++) this->m_fl2gv[i] = src.m_fl2gv[i];
return *this;
}
```
### `Frame::cache` (FUN_00534DF0) — line 319353
Rebuilds the `m_fl2gv[9]` rotation matrix from `(qw, qx, qy, qz)`. Standard quaternion-to-matrix:
```
00534df0 void Frame::cache(Frame* this) {
// Use temp doubles to preserve x87 precision
double tx = this->qx + this->qx; // 2qx
double ty = this->qy + this->qy; // 2qy
double tz = this->qz + this->qz; // 2qz
double wx = this->qw * tx; // 2qw·qx
double wy = this->qw * ty; // 2qw·qy
double wz = this->qw * tz; // 2qw·qz
double xx = this->qx * tx; // 2qx·qx
double xy = this->qx * ty; // 2qx·qy
double xz = this->qx * tz; // 2qx·qz
double yy = this->qy * ty; // 2qy·qy
double yz = this->qy * tz; // 2qy·qz
double zz = this->qz * tz; // 2qz·qz
// Column-major 3x3 stored row-by-row:
this->m_fl2gv[0] = 1.0 - yy - zz; // R00
this->m_fl2gv[1] = xy + wz; // R10
this->m_fl2gv[2] = xz - wy; // R20
this->m_fl2gv[3] = xy - wz; // R01
this->m_fl2gv[4] = 1.0 - xx - zz; // R11
this->m_fl2gv[5] = yz + wx; // R21
this->m_fl2gv[6] = xz + wy; // R02
this->m_fl2gv[7] = yz - wx; // R12
this->m_fl2gv[8] = 1.0 - xx - yy; // R22
}
```
This is a standard XYZW-quaternion-to-3x3 matrix, but the layout here is **transpose** of typical glm/Silk row-major. acdream needs to be careful when consuming.
### `Frame::combine` (FUN_005122E0) — line 280355
**Most important — multiplication semantics: `out = a · b`** (compose b on top of a).
```
005122e0 void Frame::combine(Frame* out, Frame const* a, Frame const* b) {
// ── Origin: rotate b.origin by a's basis, then add a.origin ─────────────
out->m_fOrigin.x = a->m_fl2gv[0]*b->origin.x
+ a->m_fl2gv[3]*b->origin.y
+ a->m_fl2gv[6]*b->origin.z + a->m_fOrigin.x;
out->m_fOrigin.y = a->m_fl2gv[1]*b->origin.x
+ a->m_fl2gv[4]*b->origin.y
+ a->m_fl2gv[7]*b->origin.z + a->m_fOrigin.y;
out->m_fOrigin.z = a->m_fl2gv[2]*b->origin.x
+ a->m_fl2gv[5]*b->origin.y
+ a->m_fl2gv[8]*b->origin.z + a->m_fOrigin.z;
// ── Quaternion: a.q * b.q (Hamilton product) ────────────────────────────
// Note: BN swaps some operand orders, but this is the Hamilton product.
Frame::set_rotate(out,
a->qw*b->qw - b->qx*a->qx - b->qy*a->qy - b->qz*a->qz, // qw
a->qw*b->qx + b->qz*a->qy + b->qw*a->qx - b->qy*a->qz, // qx
b->qy*a->qw - b->qz*a->qx + a->qz*b->qx + a->qy*b->qw, // qy
b->qy*a->qx + b->qz*a->qw - a->qy*b->qx + a->qz*b->qw); // qz
}
```
`Frame::set_rotate` then normalizes the quaternion and re-runs `Frame::cache` to refresh `m_fl2gv`.
**Order:** `combine(out, a, b)` means `out = a ∘ b` — first apply b in local-frame coords, then rotate-and-translate by a. In `UpdatePositionInternal` step D, this means: `outFrame = m_position.frame * localDelta` — i.e. take the local-frame motion and lift it into world.
### `Frame::set_rotate` (FUN_00535080) — line 319453
```
00535080 void Frame::set_rotate(Frame* this, float qw, float qx, float qy, float qz) {
// Cache old quaternion in case new one is invalid
float oldQw=this->qw, oldQx=this->qx, oldQy=this->qy, oldQz=this->qz;
float invLen = 1.0 / sqrt(qw*qw + qx*qx + qy*qy + qz*qz);
this->qw = qw * invLen;
this->qx = qx * invLen;
this->qy = qy * invLen;
this->qz = qz * invLen;
if (Frame::IsValid(this) != 0) {
Frame::cache(this); // refresh l2gv matrix
} else {
// Restore — new quat had NaN
this->qw=oldQw; this->qx=oldQx; this->qy=oldQy; this->qz=oldQz;
}
}
```
### `Frame::set_heading` (FUN_00535E40) — line 320049
Sets heading from a yaw angle (degrees):
```
00535e40 void Frame::set_heading(Frame* this, float degrees) {
// BN noise computes a vector from an existing matrix column — irrelevant
double rad = degrees * 0.017453292519943295; // π/180
float sinR = sin(rad);
float cosR = cos(rad);
Vector3 heading = { sinR, cosR, 0 }; // +Y is north; rotate CW
Frame::set_vector_heading(this, &heading);
}
```
### `Frame::set_vector_heading` (FUN_00535DB0) — line 320030
Sets heading to face a normalized 2D direction vector (rotation around Z-axis):
```
00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
Vector3 d = *dir;
if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return;
// Note: AC's heading convention — angle from north (+Y) measured clockwise.
// 450 - atan2(x, y) normalizes to [0, 360).
double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
yawDeg = fmod(yawDeg, 360.0);
Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295);
}
```
### `Frame::rotate` (FUN_004525B0) — line 91477
Applies a small rotation increment in local space (used by omega integration):
```
004525b0 void Frame::rotate(Frame* this, Vector3 const* localOmegaTimesDt) {
// Lift the local-axis-angle vector to world by the current basis
Vector3 worldOmegaDt;
worldOmegaDt.x = this->m_fl2gv[0]*localOmegaTimesDt->x
+ this->m_fl2gv[3]*localOmegaTimesDt->y
+ this->m_fl2gv[6]*localOmegaTimesDt->z;
worldOmegaDt.y = this->m_fl2gv[1]*localOmegaTimesDt->x
+ this->m_fl2gv[4]*localOmegaTimesDt->y
+ this->m_fl2gv[7]*localOmegaTimesDt->z;
worldOmegaDt.z = this->m_fl2gv[2]*localOmegaTimesDt->x
+ this->m_fl2gv[5]*localOmegaTimesDt->y
+ this->m_fl2gv[8]*localOmegaTimesDt->z;
Frame::grotate(this, &worldOmegaDt); // global-frame rotation
}
```
### `Frame::set_origin` — does it exist?
**No** — search of `acclient_2013_pseudo_c.txt` finds zero hits for `Frame::set_origin`. Origin is set by direct member assignment (`f.m_fOrigin = newOrigin`) or via `Frame::operator=`. Note: the named PDB does not list a public mutator for origin alone.
### `Frame::is_zero` — does it exist?
**No** — search returns zero hits for `Frame::is_zero`. The `is_zero` method exists on `AC1Legacy::Vector3` (e.g. `Vector3::is_zero(&this->m_velocityVector)` at line 283667), and is applied to `Frame::m_fOrigin` indirectly via `Vector3::operator==(&zeroVec, &frame.origin)`.
---
## 12 — `Position::ctor`, `Position::distance`, `Position::get_offset`
### `Position::Position` (FUN_00424AB0) — default ctor
Sets vtable, zero objcell_id, identity Frame.
### `Position::Position(Position*, uint32_t cellId, Frame*)` — line 91542
```
00452780 void Position::Position(Position* this, uint32_t cellId, Frame* frame) {
this->vtable = 0x796910;
this->objcell_id = cellId;
Frame::operator=(&this->frame, frame);
}
```
### `Position::Position(Position*, Position const*)` — line 91655 (copy ctor)
Just calls `Frame::operator=` on the embedded frame and copies cellId.
### `Position::get_offset` — line 272088
**Cell-aware vector offset (this → arg3) in landblock-global coordinates.**
```
00509f60 Vector3* Position::get_offset(Position const* this, Vector3* out, Position const* other) {
Vector3 blockOffset;
// Compute the world offset between the two cell origins (uses landblock IDs).
LandDefs::get_block_offset(&blockOffset, this->objcell_id, other->objcell_id);
// out = (other.origin + blockOffset) - this.origin
out->x = (blockOffset.x + other->frame.origin.x) - this->frame.origin.x;
out->y = (blockOffset.y + other->frame.origin.y) - this->frame.origin.y;
out->z = (blockOffset.z + other->frame.origin.z) - this->frame.origin.z;
return out;
}
```
This is what acdream's `Position.GetOffset` mirrors. **Critical: cells in different landblocks must be reconciled via `LandDefs::get_block_offset` before subtracting origins.**
### `Position::distance` (FUN_005A94B0) — line 438258
```
005a94b0 Vector3* Position::distance(Position const* this, Position const* other) {
Vector3 r;
Position::get_offset(this, &r, other);
// (BN noise — actually returns sqrtf of the offset squared)
return r; // caller takes magnitude
}
```
Note: the BN decomp shows `result->z; result->y; result->x;` followed by `return result` — these dereferences load the floats but don't produce output here. The actual return value is the raw offset vector from `get_offset`; the caller computes `.Length()`. ACE's `Position.Distance` does this correctly.
---
## 13 — Substepping algorithm summary (the key answer)
```
dt = currentTime - LastUpdateTime
if (dt < EPSILON) return; // < 0.0002s too small, defer
if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time
while (dt > MaxQuantum): // 0.1 s
PhysicsTimer.curr_time += MaxQuantum
UpdateObjectInternal(MaxQuantum)
dt -= MaxQuantum
if (dt > MinQuantum): // 0.0333 s (1/30)
PhysicsTimer.curr_time += dt
UpdateObjectInternal(dt) // remainder, anywhere in (1/30, 0.1]
LastUpdateTime = PhysicsTimer.curr_time
```
**Observations:**
1. **Retail processes every frame**, not just every 30 Hz. The first guard is `EPSILON`, not `MinQuantum`. ACE flipped this to `< TickRate` (= 1/30) for server CPU savings — that's a divergence, not retail-faithful.
2. **dt below MinQuantum but above EPSILON → no simulation that frame, but `update_time` IS advanced** to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue.
3. **Between MinQuantum and MaxQuantum: a single substep** for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) **Actually at 60 fps the second guard (`> MinQuantum`) is also FALSE, so nothing runs.** This is consistent with retail running its physics tick at 30 Hz in `MainProc`. Retail's `MainProc` ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through.
4. **Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum.** This handles frame-stutter / lag spikes (e.g. world load).
5. **`process_hooks` runs once per substep** (inside `UpdatePositionInternal`), so its rate scales with substep count — important for FPHook fade timers.
6. **The collision sweep (`transition`) runs once per substep**, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes.
### What this means for `LastUpdateTime` advancement
After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. `PhysicsTimer.curr_time` was incremented inside the loop by `MaxQuantum` per substep + the final remainder. **It accumulates ONLY consumed time** — if the early-`< EPSILON` guard fires, `update_time` is set to `Timer::cur_time` directly, dropping any unconsumed micro-fragment.
---
## 14 — Cross-check against acdream's port
**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs:404-435`):**
acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. **But it gates on `dt < MinQuantum` for the early return**, not `< EPSILON`. This matches ACE's port (which uses TickRate=1/30) but **diverges from retail**, which uses EPSILON=0.0002.
**Practical effect of the divergence:**
At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) fail the remainder gate (0.0167 < MinQuantum=0.0333) bump `update_time` and return. **No simulation ran, but time was consumed.** Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop.
acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (`< MinQuantum` = 0.0333), returns immediately WITHOUT updating `LastUpdateTime`. **Next frame: dt = 0.0334 s**, passes the first gate, runs `UpdatePhysicsInternal(0.0334)` once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence.
Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (`if dVar1 < MinQuantum → return`) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization.
**`GameWindow.cs` per-tick remote motion path (line 6541-6553):**
The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps gate" is **accurate about acdream's port** but **slightly misleading about retail's behavior**. Retail does NOT have a 30 Hz gate in `update_object`; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch.
**The real bug (Commit B fix at line 6190):** the env-var path was missing the `ResolveWithTransition` call (port of `find_transitional_position`) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail.
---
## 15 — Open questions / follow-ups
- **`Frame::set_origin`** and **`Frame::is_zero`** don't exist as named symbols. The conventions are: direct field write for origin, and `Vector3::is_zero` on `frame.m_fOrigin` for the test. Confirm acdream's port uses the same conventions (no need for these methods on the `Frame` type).
- **`update_object_server` does not exist.** ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one `update_object`.
- **The early-return gate divergence (EPSILON vs MinQuantum)** is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy.
- **`process_hooks` linked-list (`PhysicsObjHook`)** is not yet ported in acdream. acdream has anim-hook routing (`AnimationHookRouter`) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this.

View file

@ -0,0 +1,526 @@
# 09 — CPartArray::Update + CSequence::update / update_internal / apply_physics / add_motion
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, Binary Ninja pseudo-C with PDB names applied).
> **PDB symbol-name collisions you will see in the pseudo-C below.** Several
> AnimSequenceNode getters were name-collapsed into mangled stubs. From the
> retail header `acclient.h` the struct is `{ CAnimation* anim; float framerate;
> int low_frame; int high_frame; }`. The Binary-Ninja pseudo-C shows them as:
>
> | Pseudo-C name | Real symbol |
> |-------------------------------------|------------------------------------------|
> | `MD_Data_Fade::GetDuration(node)` | `AnimSequenceNode::get_framerate(node)` |
> | `EffectInfoRegion::GetStat(node)` | `AnimSequenceNode::get_high_frame(node)` |
> | `Attribute2ndInfoRegion::GetStat(node)` | `AnimSequenceNode::get_low_frame(node)` |
>
> Verified by struct layout match (`framerate` is the first float field,
> `low_frame` then `high_frame`) and by the call-site context (these are read
> exactly where the per-keyframe loop needs the framerate, end frame, start
> frame).
---
## 1. CPartArray::Update — `0x00517DB0`
```cpp
// 00517db0
void __thiscall CPartArray::Update(class CPartArray* this, float arg2, class Frame* arg3)
{
// 00517dc2
CSequence::update(&this->sequence, (double)arg2, arg3);
}
```
It is literally a one-line forwarder. **All animation behavior lives in
`CSequence`.** The caller is `CPhysicsObj::UpdatePositionInternal` at
`0x00512c95`, passing `dt` as `arg2` and a `Frame*` to receive root-motion
displacement (`pos_frames` deltas + `velocity*dt`). The same forwarder is also
called from `0x00513e97` inside `set_state_to_starting_frame` for placement.
There is no per-part loop here — `CPartArray` keeps a single shared `CSequence`
that drives the root frame; per-part bone frames come from
`CSequence::get_curr_animframe()` later, applied in
`Frame::combine(this->parts[esi]->pos.frame, parentFrame, anim_frame->frame[i])`
inside `CPartArray::DoLocalToParent` (different code path).
---
## 2. CSequence::update — `0x00525B80`
```cpp
// 00525b80
void __thiscall CSequence::update(class CSequence* this, double arg2 /*dt*/, class Frame* arg3)
{
// 00525b88
if (this->anim_list.head_ != nullptr) {
// 00525ba3
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
// 00525baa
CSequence::apricot(this); // garbage-collect drained link nodes
return;
}
// No animations on the sequence — degenerate path: still integrate the
// raw velocity * dt onto the frame. (Used by physics-only objects with a
// CSequence carrying only velocity/omega and no animation list.)
// 00525bb9
if (arg3 != nullptr) {
// 00525bca
CSequence::apply_physics(this, arg3, /*dt=*/arg2, /*sign=*/arg2);
}
}
```
Two key observations:
1. The "no animations" branch (`anim_list.head_ == 0`) is the only path that
calls `apply_physics` with the **whole `dt`**. With animations present, the
per-keyframe path scales by `1/framerate` (see §4 below).
2. `apricot()` walks the doubly-linked list, deletes any link node prior to
`curr_anim` that has been fully drained, and trims the list down to the
cyclic head. This is how transition links (e.g. WalkForward → RunForward)
"fall off" once consumed.
---
## 3. CSequence::update_internal — `0x005255D0` (the per-keyframe loop)
This is the heart of retail animation. It is the function whose
literal-pseudocode reading is required for L.3 of the motion port. The
disassembly is x87-FPU-heavy with synthesised compare flags; the algorithm
below is the reconstructed control flow.
### Signature
```cpp
// 005255d0
void __thiscall CSequence::update_internal(
class CSequence const* this,
double arg2, // dt (seconds)
class AnimSequenceNode** arg3, // in/out: &this->curr_anim
double* arg4, // in/out: &this->frame_number (fractional)
class Frame* arg5 // out: accumulator for root-motion displacement
);
```
### Algorithm (reconstructed)
```cpp
while (true) { // 005255e8
// -- per-tick framerate scaling ----------------------------------------
framerate = AnimSequenceNode::get_framerate(*arg3); // 005255e8 (PDB-collision: MD_Data_Fade::GetDuration)
delta = framerate * arg2; // signed; negative = reverse play // 005255f1
int start_frame_int = (int)floor(*arg4); // 00525607
bool wrapped = false; // var_30_1 // 005255ff
int ebx = start_frame_int; // 0052561b
*arg4 = *arg4 + delta; // advance fractional frame_number // 00525635 (fadd / fstp)
if (delta >= 0.0) { // forward play branch // 00525646
// ----- FORWARD --------------------------------------------------
end_int = AnimSequenceNode::get_high_frame(*arg3); // 005257f5 (PDB-collision: EffectInfoRegion::GetStat)
if (*arg4 > (double)end_int) { // 00525806
// overflowed past the cycle's end — clamp + record overflow time
float left_in_cycle = (float)end_int - (float)start_frame_int; // 0052580f / 00525817
if (fabs(framerate) >= F_EPSILON) { // 00525841
overflow_time = left_in_cycle / framerate; // 00525843
// var_18_1
} else {
overflow_time = 0.0; // 0052584d
}
*arg4 = (double)AnimSequenceNode::get_high_frame(*arg3); // 00525866 / fild / fstp
wrapped = true; // 0052586e (var_30_1 = 1)
}
// -- per-keyframe loop: walk every integer frame just stepped on --
// ebx is the frame we are LEAVING; it is decremented in the loop
// body BEFORE the iteration cmp. (Retail counts down because a
// forward step crossing N integer frames must apply the deltas in
// reverse: subtract1(prev_frame's posFrame) to back out, then
// combine(curr_frame's posFrame) to step in. See loop body.)
while ((double)ebx > *arg4 - 1.0) { /* equivalent to: while floor(*arg4) > ebx */
if (arg5 != nullptr) {
node = *arg3;
if (node->anim->pos_frames != 0) { // 005258b1
Frame::subtract1(arg5, arg5,
AnimSequenceNode::get_pos_frame(node, ebx)); // 005258be
}
if (fabs(framerate) >= F_EPSILON) { // 005258d3
// apply_physics with dt = (1.0 / framerate) and same sign
// as outer dt — i.e. EXACTLY ONE keyframe duration of
// velocity*dt is integrated per crossed integer.
CSequence::apply_physics(this, arg5,
/*frame_dt =*/ 1.0 / framerate, // 005258d8 / 005258e1
/*sign =*/ arg2); // 005258f8
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(*arg3, ebx),
/*direction=*/ 0xFFFFFFFF); // 0052590c (-1 = forward)
ebx -= 1; // 00525916
}
} else { // reverse play branch // 00525646 else (negative delta)
// ----- REVERSE --------------------------------------------------
start_int = AnimSequenceNode::get_low_frame(*arg3); // 0052566a (PDB-collision: Attribute2ndInfoRegion::GetStat)
if (*arg4 <= (double)start_int - 1.0) { // 0052567b (fild + fsubr 1.0 + fcom 0.0)
float left_in_cycle = (float)start_int - (float)start_frame_int;
if (fabs(framerate) >= F_EPSILON) {
overflow_time = left_in_cycle / framerate; // 005256be
} else {
overflow_time = 0.0; // 005256c8
}
*arg4 = (double)AnimSequenceNode::get_low_frame(*arg3); // 005256e1
wrapped = true; // 005256e9
}
// -- per-keyframe loop: same idea, opposite direction ------------
int ebx2 = ebx; /* var_2c_1 = ebx (saved at 0052561d) */
while (/* floor(*arg4) < ebx2 */) { // 00525714
if (arg5 != nullptr) {
node = *arg3;
if (node->anim->pos_frames != 0) { // 00525731
Frame::combine(arg5, arg5,
AnimSequenceNode::get_pos_frame(node, ebx2)); // 0052573e
}
if (fabs(framerate) >= F_EPSILON) { // 00525753
CSequence::apply_physics(this, arg5,
/*frame_dt=*/ 1.0 / framerate, // 00525758 / 00525761
/*sign =*/ arg2); // 00525778
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(*arg3, ebx2),
/*direction=*/ 1); // 0052578c (+1 = backward)
ebx2 += 1; // 00525796
}
}
// -- end-of-tick: did we wrap? ----------------------------------------
if (!wrapped) return; // 00525943 / 005259ca
// We hit the cycle boundary. Notify hook-target if we just consumed a
// non-cyclic link node, then advance to the next animation in the list.
if (this->hook_obj != nullptr) { // 0052594e
anim_list_head = ((char*)this->anim_list.head_) - 4;
if (anim_list_head != this->first_cyclic) {
CPhysicsObj::add_anim_hook(this->hook_obj, &anim_done_hook); // 00525968
}
}
// arg2 (the outer dt) is rewritten here to "overflow_time" so the loop
// reschedules from the new node.
CSequence::advance_to_next_animation(this, arg2 /*now overflow_time*/,
arg3, arg4, arg5); // 0052597d
arg2 = 0; *(arg2 + 4) = 0; /* reset outer dt accumulator */ // 0052598a / 0052598d
// …then `while (true)` again and run another iteration of the chain.
}
```
### What that means in plain English
Per call to `update`:
1. Take the current dt and multiply by `framerate` (signed, decompresses to
the dat's `AnimData::framerate * speedMod` per `operator*` at `00525d00`).
2. Add to `frame_number` (the fractional cursor).
3. Walk every integer keyframe boundary the cursor just crossed:
- **Forward** crossing: subtract the *just-left* keyframe's `pos_frame`
out of the displacement accumulator (it's already been baked in the
animation pose, so don't double-count it as root motion). Run hooks
with direction `-1`.
- **Reverse** crossing: combine (add) the *just-left* keyframe's
`pos_frame` back in. Run hooks with direction `+1`.
- In **both** directions, integrate `velocity * (1 / framerate) * sign(dt)`
onto the displacement frame via `apply_physics`. This is **one
keyframe's worth of velocity per crossed boundary** — not `velocity * dt`.
4. If the cursor went past the cycle's end (forward) or before its start
(reverse), clamp to the boundary, compute the leftover dt, advance to the
next AnimSequenceNode in the linked list, and loop again with the leftover.
---
## 4. CSequence::apply_physics — `0x00524AB0`
```cpp
// 00524ab0
void __thiscall CSequence::apply_physics(class CSequence const* this,
class Frame* arg2 /*frame*/,
double arg3 /*frame_dt = 1.0/framerate*/,
double arg4 /*sign carrier = outer dt*/)
{
// 00524ab7..00524ac1
long double dt = fabs((long double)arg3);
// 00524ac8..00524aca: if (sign(arg4) < 0) dt = -dt;
// I.e. the magnitude comes from arg3 (1/framerate, always positive),
// the sign comes from arg4 (outer dt). Reverse playback flips the sign.
if (arg4 < 0.0) dt = -dt;
// 00524af1..00524b05: integrate world-space velocity onto the frame.
arg2->m_fOrigin.x += (float)(dt * (long double)this->velocity.x);
arg2->m_fOrigin.y += (float)(dt * (long double)this->velocity.y);
arg2->m_fOrigin.z += (float)(dt * (long double)this->velocity.z);
// 00524b0f..00524b29: build (omega.x*dt, omega.y*dt, omega.z*dt) on stack.
Vector3 axis = { (float)(dt * (long double)this->omega.x),
(float)(dt * (long double)this->omega.y),
(float)(dt * (long double)this->omega.z) };
// 00524b2d
Frame::rotate(arg2, &axis);
}
```
So `apply_physics` integrates **the CSequence's stored velocity/omega**, which
were set by `add_motion` (§5 below). The crucial structural detail repeated:
**`arg3` is `1.0 / framerate`, NOT `dt`.** `update_internal` calls
`apply_physics` once per crossed integer keyframe; over a full cycle this
sums to exactly `velocity * (cycle_duration_in_seconds)`.
---
## 5. add_motion — `0x005224B0` (the velocity producer)
`add_motion` is the writer that populates `CSequence::velocity` and
`CSequence::omega`. Called from the motion-table machinery
(`get_seq_animations` chain) when a new `MotionData` is enqueued.
```cpp
// 005224b0
void add_motion(class CSequence* arg1, class MotionData* arg2, float arg3 /*speedMod*/)
{
if (arg2 == nullptr) return;
// Velocity = MotionData.velocity * speedMod (componentwise)
// 005224d1..005224f8
Vector3 v = { arg2->velocity.x * arg3,
arg2->velocity.y * arg3,
arg2->velocity.z * arg3 };
CSequence::set_velocity(arg1, &v);
// Omega = MotionData.omega * speedMod
// 0052250f..0052252f
Vector3 w = { arg2->omega.x * arg3,
arg2->omega.y * arg3,
arg2->omega.z * arg3 };
CSequence::set_omega(arg1, &w);
// For every AnimData in MotionData.anims, append it to the sequence
// with framerate scaled by speedMod (operator*(AnimData, float, AnimData)
// at 00525d00 builds the scaled copy: low/high frame copied verbatim,
// framerate multiplied, anim_id copied).
// 00522537..00522573
for (int i = 0; i < arg2->num_anims; ++i) {
AnimData scaled;
operator*(&scaled, arg3, &arg2->anims[i]);
CSequence::append_animation(arg1, &scaled);
SetPositionStruct::~SetPositionStruct(&scaled);
}
}
```
`set_velocity` (0x00524880) and `set_omega` (0x005248A0) are 3-float
overwrites — **assignment, not accumulation**. So whenever a new MotionData
is added, prior velocity/omega are clobbered.
(`combine_motion` at 0x00522580 / `subtract_motion` at 0x00522600 are the
accumulating variants — they call `combine_physics` / `subtract_physics`
instead — used elsewhere for additive blends.)
### What this produces for Humanoid Walk/Run
This is the dispositive answer to the L.3 mystery:
> *"For Humanoid Walk/Run cycles where dat ships zero baked velocity, what
> does add_motion produce?"*
**Zero.** `MotionData.velocity` for the Humanoid Walk and Run motion-table
entries is `(0,0,0)` (verified by acdream's own
`AnimationSequencer.SetCycle()` comment block at
`src/AcDream.Core/Physics/AnimationSequencer.cs:579-613`). `add_motion`
multiplies that zero by `speedMod` and writes zero into
`CSequence::velocity`. `apply_physics` therefore integrates zero translation
per keyframe step. **The dat-baked `pos_frames` array on each animation
also has zero translation per frame** for the Humanoid run cycle (cycles
in place — root motion is synthesised, not baked).
So `update_internal`'s root-motion accumulator (`arg5`) ends the call
unchanged for a Humanoid run/walk cycle. **Retail does NOT produce body
translation from `CSequence::update`.** Body translation comes from a
SEPARATE source: `CMotionInterp::get_state_velocity` at `0x00528960`,
which returns `RunAnimSpeed × ForwardSpeed` (or `WalkAnimSpeed`,
`SidestepAnimSpeed`) as a hard-coded constant looked up from
`_DAT_007c96e0/e4/e8`. That value is fed into `CPhysicsObj::set_velocity`
upstream, then `CTransition::transitional_insert` integrates it across the
swept-sphere collision pipeline.
For non-locomotion cycles (emotes, attacks, idle, jump): `MotionData.velocity`
may be non-zero (e.g. jump's vertical impulse) AND/OR the animation's
`pos_frames` array may contain baked deltas (e.g. attack lunges). Both
sources flow through the same `update_internal` loop above.
---
## 6. CSequence::velocity / omega / framerate accessors
There are no getter functions; the fields are read directly off the struct
(see `acclient.h` line 30751 / 30752 / 30754). For reference:
```cpp
// 00524880 set_velocity — pure assignment of 3 floats
// 005248a0 set_omega — pure assignment of 3 floats
// 005248c0 combine_physics (additive: velocity += rhs, omega += rhs)
// 00524900 subtract_physics (additive: velocity -= rhs, omega -= rhs)
// 00524940 multiply_cyclic_animation_fr — for-each-cyclic-node node->framerate *= arg2
// 00525be0 AnimSequenceNode::multiply_framerate — node.framerate *= arg2 (single)
```
`framerate` lives on each `AnimSequenceNode`, not on `CSequence`. It is
already pre-multiplied by `speedMod` at the time `add_motion` runs (via
the `operator*(AnimData, float)` constructor at `0x00525D00`
`new_framerate = old_framerate * speedMod`). Negative speedMod produces
negative framerate, which `update_internal` reads as the reverse-play
branch — there is **no separate "play backward" flag**, just the sign of
`framerate`.
`get_starting_frame` (`0x00525C80`) and `get_ending_frame` (`0x00525CB0`)
encode this: when `framerate < 0`, "start" returns `high_frame + 1` and
"end" returns `low_frame`; when `framerate >= 0`, "start" returns
`low_frame` and "end" returns `high_frame + 1`. This is why the
forward-vs-reverse branches in `update_internal` use opposite boundaries.
---
## 7. Frame::combine / Frame::subtract1 — the per-keyframe pos_frame applicator
`AnimSequenceNode::get_pos_frame(node, frame_index)` at `0x00525C10`:
```cpp
class AFrame* get_pos_frame(int frame_index) {
CAnimation* anim = this->anim;
if (anim != nullptr && frame_index >= 0 && frame_index < anim->num_frames)
return ((AFrame*)((char*)anim->pos_frames + 0x1C * frame_index)); // sizeof(AFrame)=0x1C
return nullptr;
}
```
`Frame::combine(Frame* result, Frame const* lhs, AFrame const* rhs)`
at `0x00525180` (3-arg variant) — concatenates two transforms (`result =
lhs ∘ rhs`). `Frame::subtract1` at `0x00535520` is the inverse (`result =
lhs ∘ rhs⁻¹`). Both are pure 4×4-equivalent rigid-body composition; nothing
animation-specific.
---
## 8. AnimSequenceNode::get_pos_frame — `0x00525C10`
(See §7 — same function, used as a getter.)
---
## 9. Per-keyframe loop summary (the answer to the L.3 critical question)
> *"What does the per-keyframe loop look like exactly?"*
Per crossed integer frame boundary (forward branch shown; reverse mirrors):
```cpp
// 005258a5..00525917, per integer keyframe just crossed forward:
if (arg5 /*displacement frame*/) {
if (curr_node->anim->pos_frames) {
// Subtract the LEAVING keyframe's baked offset out of the running
// displacement accumulator. (We already advanced fractional frame
// past it; the pose for this frame is already where it should be in
// local space.)
Frame::subtract1(arg5, arg5,
AnimSequenceNode::get_pos_frame(curr_node, ebx));
}
if (fabs(framerate) >= F_EPSILON) {
// Integrate exactly ONE keyframe-duration of CSequence::velocity
// and CSequence::omega onto arg5.
CSequence::apply_physics(this, arg5, 1.0 / framerate, sign_of(outer_dt));
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(curr_node, ebx), -1);
ebx -= 1; // walk to next-older crossed frame
```
In plain English: **for every integer frame boundary the fractional cursor
just crossed, apply (velocity × keyframe_period) of "free" displacement and
also stitch baked posframe deltas to keep the cycle's per-frame in-place
loop registered.** Across a full cycle, the velocity contribution is
exactly `velocity * cycle_duration`. The framerate scaling is what keeps
the body moving the same world-space distance per real-time second
regardless of how the cycle is divided into frames.
---
## 10. Cross-reference: acdream's port
`src/AcDream.Core/Physics/AnimationSequencer.cs` (1455 lines):
- **Per-keyframe loop is structurally correct** (lines 766846): it walks
`lastFrame` across crossed integer boundaries, calls `ApplyPosFrame`
forward/reverse, fires hooks. Wrap-and-overflow logic mirrors retail's
`advance_to_next_animation`.
- **Critical gap — apply_physics is missing.** The retail per-keyframe loop
applies `CSequence::velocity * (1/framerate)` *in addition* to the
posFrame delta. acdream's `ApplyPosFrame` (line 1288) only applies the
`posFrames[frameIndex]` and **skips** the `apply_physics(velocity, 1/framerate)`
step. That's harmless for Humanoid run/walk because their dat velocity
is zero, but it is **wrong for any non-locomotion cycle that uses
`MotionData.velocity`** (jump impulse, knock-back, flying creatures).
- acdream adds a synth path (lines 614650) that overwrites
`CSequence.CurrentVelocity` with `RunAnimSpeed * speedMod` for
locomotion cycles. That value is consumed externally by
`CMotionInterp.get_state_velocity` for body translation — which is
retail's actual locomotion path (see §5 closing note). So acdream's
end-to-end behavior matches retail for Humanoid locomotion *despite* the
missing `apply_physics` inside the sequencer, because both paths
bypass it.
- **Summary for L.3:** the sequencer's `apply_physics` integration is dead
code for Humanoid locomotion (dat velocity = 0). Porting it faithfully
is required for jump/emote/flying-creature root motion but does not
affect the run/walk-cycle bug L.3 is investigating. The bug must lie
upstream in `CMotionInterp::get_state_velocity` consumption or in the
per-tick path that re-feeds CSequence's velocity vs. the
`CTransition::transitional_insert` body sweep.
---
## 11. Files touched / line citations
All retail line numbers refer to
`docs/research/named-retail/acclient_2013_pseudo_c.txt`:
| Function | Pseudo-C line | Address |
|-------------------------------------------|---------------|--------------|
| `CPartArray::Update` | 285883 | `0x00517DB0` |
| `CSequence::update` | 302402 | `0x00525B80` |
| `CSequence::update_internal` | 301839 | `0x005255D0` |
| `CSequence::advance_to_next_animation` | 301622 | `0x005252B0` |
| `CSequence::apply_physics` | 300955 | `0x00524AB0` |
| `CSequence::set_velocity` | 300798 | `0x00524880` |
| `CSequence::set_omega` | 300808 | `0x005248A0` |
| `CSequence::combine_physics` | 300818 | `0x005248C0` |
| `CSequence::subtract_physics` | 300832 | `0x00524900` |
| `CSequence::execute_hooks` | 300780 | `0x00524830` |
| `CSequence::apricot` | 300978 | `0x00524B40` |
| `add_motion` | 298437 | `0x005224B0` |
| `combine_motion` | 298472 | `0x00522580` |
| `subtract_motion` | 298492 | `0x00522600` |
| `AnimSequenceNode::get_pos_frame` (int) | 302447 | `0x00525C10` |
| `AnimSequenceNode::get_part_frame` | 302460 | `0x00525C40` |
| `AnimSequenceNode::get_starting_frame` | 302483 | `0x00525C80` |
| `AnimSequenceNode::get_ending_frame` | 302501 | `0x00525CB0` |
| `AnimSequenceNode::multiply_framerate` | 302425 | `0x00525BE0` |
| `operator*(AnimData, float, AnimData)` | 302531 | `0x00525D00` |
| Header struct `CSequence` | acclient.h:30747 | — |
| Header struct `AnimSequenceNode` | acclient.h:31063 | — |
| Header struct `CPartArray` | acclient.h:30762 | — |
acdream files cross-referenced:
- `src/AcDream.Core/Physics/AnimationSequencer.cs` (lines 570650 synth-velocity,
766846 per-keyframe loop, 12361330 advance/posFrame application).

View file

@ -0,0 +1,693 @@
# L.3 — VectorUpdate (0xF74E) handler chain + jump pseudocode
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named).
All retail line numbers below refer to that file (`acclient_2013_pseudo_c.txt`).
---
## 1. `CM_Physics::DispatchSB_VectorUpdate` — packet → handler
**Address:** `0x006acd20` (line 692119)
```
006acd20 enum NetBlobProcessedStatus CM_Physics::DispatchSB_VectorUpdate(
class SmartBox* arg1,
class NetBlob* arg2)
{
NetBlob* ebx = arg2;
if (ebx == 0 || arg1 == 0) return 3;
uint8_t* buf_ = ebx->buf_; // body bytes
uint32_t bufSize_ = ebx->bufSize_;
if (*(uint32_t*)buf_ != 0xf74e) return 3; // line 692130 — opcode gate
uint32_t guid = *(uint32_t*)(buf_ + 4); // line 692136 — object id
arg2 = &buf_[8];
Vector3 velocity; // line 692139
AC1Legacy::Vector3::UnPack(&velocity, &arg2, ...);
Vector3 omega; // line 692141
AC1Legacy::Vector3::UnPack(&omega, &arg2, ...);
PhysicsTimestampPack ts; // line 692143
PhysicsTimestampPack::UnPack(&ts, &arg2, ...);
return SmartBox::HandleVectorUpdate(arg1, ebx, guid, &velocity, &omega, &ts);
}
```
Wire layout: `[opcode:u32 0xF74E][guid:u32][velocity:Vector3][omega:Vector3][ts:PhysicsTimestampPack]`.
The PhysicsTimestampPack carries `ts1` (port-event sequence) and `ts2` (vector
event sequence, used in DoVectorUpdate as `update_times[3]`).
---
## 2. `SmartBox::HandleVectorUpdate` — sequence gate
**Address:** `0x00453480` (line 92195)
```
00453480 enum NetBlobProcessedStatus SmartBox::HandleVectorUpdate(
SmartBox* this,
NetBlob* arg2, // raw blob (re-queued on early)
uint32_t arg3, // guid
Vector3 const* arg4, // velocity (world)
Vector3 const* arg5, // omega (world)
PhysicsTimestampPack const* arg6)
{
int32_t ebp = arg6->ts2; // vector event ts (line 92199)
int32_t esi = arg6->ts1; // port event ts (line 92201)
CPhysicsObj* obj = CObjectMaint::GetObjectA(this->m_pObjMaint, arg3);
if (obj != 0)
{
int32_t ebx = obj->update_times[8]; // last-seen port-event ts
// Signed compare across 16-bit wrap (lines 92210-92218 standard
// wrap-aware "newer?" macro): if ebx == esi → in sync, dispatch.
if (ebx == esi)
{
SmartBox::DoVectorUpdate(this, obj, arg4, arg5, ebp);
return 1; // PROCESSED
}
if (ebx != esi)
return 2; // OUT_OF_ORDER (newer port event hasn't arrived)
}
// Object not yet known — queue the blob for later.
CObjectMaint::QueueBlobForObject(this->m_pObjMaint, arg3, arg2);
return 4; // DEFERRED
}
```
Note: VectorUpdate is gated against `update_times[8]` (the
**port-event timestamp**), not `update_times[3]`. This means a VectorUpdate
will *not* be applied unless the latest 0xF748 PortalCellUpdate / position
event has already been processed. This is how retail keeps the velocity
write coherent with the position the server intended.
---
## 3. `SmartBox::DoVectorUpdate` — the actual write
**Address:** `0x004521c0` (line 91208)
```
004521c0 void SmartBox::DoVectorUpdate(
SmartBox* this,
CPhysicsObj* arg2,
Vector3 const* arg3, // velocity (world)
Vector3 const* arg4, // omega (world)
uint16_t arg5) // ts2 (vector-event ts)
{
int32_t esi = arg2->update_times[3]; // last vector ts on object
int32_t edi = arg5;
// Wrap-aware "edi newer than esi" check (lines 91217-91227).
// The decompiler's flag arithmetic collapses to 0 here; the real
// gate is `edi != esi`.
if (edi != esi)
{
arg2->update_times[3] = edi; // line 91229
if (arg2 != this->player)
{
CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91233 — REMOTE
CPhysicsObj::set_omega (arg2, arg4, 1);
}
else if (this->cmdinterp->vtable->UsePositionFromServer() != 0)
{
CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91238 — LOCAL only when server-driven
CPhysicsObj::set_omega (arg2, arg4, 1);
}
}
}
```
**Critical observations** (relevant to acdream's K-fix15 question):
1. **Retail does NOT set the Gravity flag here.** It does not touch
`state` at all. It only writes `m_velocityVector` (via set_velocity)
and `m_omegaVector` (via set_omega). Gravity is already-on for
creatures by default (set when the body enters the world via
`enter_default_state``LeaveGround`); VectorUpdate does not
need to re-set it.
2. **Retail does NOT clear Contact / OnWalkable here.** The
transient-state bits stay whatever they were. Clearing them is the
job of `set_on_walkable(false)` which fires from
`CMotionInterp::jump` (local jumps only), or from the next physics
tick when the sphere-sweep finds no contact plane.
3. **Retail just writes the velocity.** The next per-tick
`CPhysicsObj::UpdateObjectInternal` reads `m_velocityVector`,
adds gravity acceleration (computed by `calc_acceleration`),
integrates position, and the sphere-sweep handles contact.
So acdream's `OnLiveVectorUpdated` (GameWindow.cs:3246-3304) is doing
*more* than retail: it also clears Contact/OnWalkable + sets Gravity
when v.Z > 0.5. The reason this is necessary in acdream is that
acdream's per-tick path (`UpdatePhysicsInternal`) gates gravity on
`!OnWalkable` rather than reading the `state.Gravity` bit — see
`PhysicsBody.calc_acceleration` and the per-tick velocity integration.
Retail's per-tick reads `state & GRAVITY` (always set for creatures)
AND `transient_state & (CONTACT | ON_WALKABLE)` to short-circuit
acceleration to zero (see calc_acceleration below).
`set_velocity(arg3, /*arg3=*/1)` clamps |v| ≤ 50 (MaxVelocity) and
sets `transient_state.Active`. The `arg3=1` flag distinguishes
network-source (1) from local-source (0); only used to gate
update_time refresh.
---
## 4. `CPhysicsObj::set_velocity`
**Address:** `0x005113f0` (line 279361)
```
005113f0 void CPhysicsObj::set_velocity(
CPhysicsObj* this, Vector3 const* arg2, int32_t arg3)
{
if (Vector3::operator!=(arg2, &this->m_velocityVector) != 0) // line 279364
{
this->m_velocityVector = *arg2; // 279366-368
// Magnitude clamp to 50 (MaxVelocity squared = 2500).
long double mag2 = vx*vx + vy*vy + vz*vz; // 279372
if (50f*50f < mag2) // 279377
{
AC1Legacy::Vector3::normalize(&this->m_velocityVector); // 279383
this->m_velocityVector.x *= 50f; // 279384
this->m_velocityVector.y *= 50f;
this->m_velocityVector.z *= 50f;
}
this->jumped_this_frame = 1; // 279389 ★
}
// Set Active transient flag (bit 7 = 0x80) when state.Gravity bit not set.
if ((this->state & 1) == 0) // line 279392 ★
{
if (this->transient_state >= 0)
{
this->update_time = Timer::cur_time; // 279398-399
}
this->transient_state |= 0x80; // 279402 — Active
}
}
```
Two side effects beyond the velocity write:
- **`jumped_this_frame = 1`** (offset +0x9C). Skips contact resolution
this frame; gives gravity room to lift the body off the floor before
the sweep clamps it back. Critical for jump start — without this, the
+Z velocity gets immediately killed by the floor sweep on frame N+1.
- **`transient_state |= Active (0x80)`**. Marks the object for
per-tick processing (UpdateObjectInternal early-outs if Active is
not set).
Note line 279392: `(this->state & 1)` is testing bit 0, which is the
**Static** flag, not Gravity. Retail only blocks the Active bit-set
on static objects.
---
## 5. `CPhysicsObj::set_local_velocity`
**Address:** `0x005114d0` (line 279408)
```
005114d0 void CPhysicsObj::set_local_velocity(
CPhysicsObj* this, Vector3 const* arg2, int32_t arg3)
{
// Multiply local-frame velocity by orientation matrix (frame.m_fl2gv,
// 9 floats at offset 0x...). worldX = m_fl2gv[0]*lx + m_fl2gv[3]*ly + m_fl2gv[6]*lz
Vector3 worldVel = orientation × arg2;
CPhysicsObj::set_velocity(this, &worldVel, arg3);
}
```
Used by `CMotionInterp::LeaveGround` to convert the body-local launch
vector (forward/right/up relative to facing) into a world vector.
---
## 6. `OBJECTINFO::kill_velocity` (clear_velocity equivalent)
**Address:** `0x0050cfe0` (line 274467)
```
0050cfe0 void OBJECTINFO::kill_velocity(OBJECTINFO* this)
{
CPhysicsObj* obj = this->object;
Vector3 zero = (0, 0, 0);
CPhysicsObj::set_velocity(obj, &zero, 0); // arg3=0 (local source)
}
```
Retail does not have a separate `clear_velocity` — it just zeros via
set_velocity. Called from collision handlers when a wall hit kills
forward motion (lines 272567, 273237).
---
## 7. `CMotionInterp::LeaveGround` — outbound jump trigger
**Address:** `0x00528b00` (line 306022)
```
00528b00 void CMotionInterp::LeaveGround(CMotionInterp* this)
{
if (!this->physics_obj) return;
// Creature gate: only run for creatures (or when no weenie).
CWeenieObject* w = this->weenie_obj;
bool isCreature = w == 0 ? true : w->vtable->IsCreature();
if (!(w == 0 || isCreature)) return;
CPhysicsObj* po = this->physics_obj;
// Only if state.Gravity bit set (state & 4 — line 306037).
if ((po->state & 4) == 0) return; // ★ Gravity gate
Vector3 leaveVel; // line 306039
CMotionInterp::get_leave_ground_velocity(this, &leaveVel);
CPhysicsObj::set_local_velocity(po, &leaveVel, 1); // line 306041
this->standing_longjump = 0; // 306043
this->jump_extent = 0f; // 306044 ★ extent reset
CPhysicsObj::RemoveLinkAnimations(po); // 306045
CMotionInterp::apply_current_movement(this, 0, 0); // 306046
}
```
**Order matters:** `get_leave_ground_velocity` is called BEFORE
`jump_extent` is zeroed. After LeaveGround returns, calling
`get_jump_v_z()` returns 0 (because the extent gate at the top
fires). acdream's `PlayerMovementController` correctly captures
`jumpVz` before calling `LeaveGround` for that reason
(PlayerMovementController.cs:440).
LeaveGround does NOT clear Contact / OnWalkable — that already
happened upstream in `CMotionInterp::jump` via
`CPhysicsObj::set_on_walkable(po, 0)` (line 305811).
---
## 8. `CMotionInterp::HitGround` — landing trigger
**Address:** `0x00528ac0` (line 305996)
```
00528ac0 void CMotionInterp::HitGround(CMotionInterp* this)
{
if (!this->physics_obj) return;
CWeenieObject* w = this->weenie_obj;
bool isCreature = w == 0 ? true : w->vtable->IsCreature();
if (!(w == 0 || isCreature)) return;
CPhysicsObj* po = this->physics_obj;
if ((po->state & 4) == 0) return; // Gravity gate
CPhysicsObj::RemoveLinkAnimations(po); // 306013
CMotionInterp::apply_current_movement(this, 0, 0); // 306014 — re-pose
}
```
**HitGround does NOT touch velocity.** It only clears the link-anim
and re-applies the current motion (which routes back through
`apply_current_movement → get_state_velocity → set_local_velocity`,
which writes the new ground velocity back to the body).
---
## 9. `MovementManager::HitGround` / `LeaveGround` — wrappers
**HitGround:** `0x00524300` (line 300425)
**LeaveGround:** `0x00524320` (line 300444)
```
00524300 void MovementManager::HitGround(MovementManager* this)
{
if (this->motion_interpreter)
CMotionInterp::HitGround(this->motion_interpreter);
if (this->moveto_manager)
MoveToManager::HitGround(this->moveto_manager);
}
```
LeaveGround mirrors this. These are the public entry points; the
**caller is `CPhysicsObj::set_on_walkable`**.
---
## 10. `CPhysicsObj::set_on_walkable` — the trigger source
**Address:** `0x00511310` (line 279287)
```
00511310 void CPhysicsObj::set_on_walkable(CPhysicsObj* this, int32_t arg2)
{
uint32_t ts = this->transient_state;
uint32_t newTs = (arg2 == 0) ? (ts & ~0x2) : (ts | 0x2);
this->transient_state = newTs;
if ((ts & 0x2) == 0) // was NOT on-walkable
{
if (arg2 != 0) // → becoming on-walkable
{
if (this->movement_manager)
MovementManager::HitGround(this->movement_manager); // ★ LANDING
}
}
else if (arg2 == 0) // was on-walkable, becoming off
{
if (this->movement_manager)
{
MovementManager::LeaveGround(this->movement_manager); // ★ LAUNCH
CPhysicsObj::calc_acceleration(this);
return;
}
}
CPhysicsObj::calc_acceleration(this);
}
```
So the **edge detector** lives here:
- `OnWalkable: 0 → 1` fires `HitGround` (landing).
- `OnWalkable: 1 → 0` fires `LeaveGround` (launch).
`set_on_walkable` itself is called from two places:
1. **`CMotionInterp::jump`** (line 305811): `set_on_walkable(po, 0)`
forces the launch edge.
2. **`CPhysicsObj::set_frame`** ground-floor sphere-sweep result
(lines 283474-283509). After the per-tick sphere-sweep stores its
resulting collision_info, this block walks contact_plane.N.z vs
`PhysicsGlobals::floor_z` (the cosine of max walkable slope, ≈
0.66 → 49°). If the contact plane is steep enough, `set_on_walkable(0)`;
if walkable, `set_on_walkable(1)`. **This is how landing detection
fires automatically in retail** — the per-tick sweep finds a walkable
surface under the body, set_on_walkable flips 0→1, HitGround fires.
So the answer to "how does retail's HitGround fire" is: **from
`set_frame` (called by the per-tick sphere sweep)**, not from a
separate trigger. The sweep computes the new contact plane; if it
classifies as walkable, the edge fires HitGround.
---
## 11. `CMotionInterp::jump` — the entry point
**Address:** `0x00528780` (line 305792)
```
00528780 uint32_t CMotionInterp::jump(CMotionInterp* this, float arg2, int32_t* arg3)
{
CPhysicsObj* po = this->physics_obj;
if (po == 0) return 8;
CPhysicsObj::interrupt_current_movement(po); // 305800
uint32_t result = CMotionInterp::jump_is_allowed(this, arg2, arg3);
if (result != 0) {
this->standing_longjump = 0; // 305805
return result; // failure code
}
this->jump_extent = arg2; // 305810 ★ extent
CPhysicsObj::set_on_walkable(po, 0); // 305811 ★ launch
return 0;
}
```
The launch sequence is:
1. `jump_is_allowed` validates (returns 0 on success).
2. Store extent.
3. `set_on_walkable(false)` → triggers `LeaveGround` via the wrapper
chain in step 10 above. LeaveGround reads `jump_extent` to compose
`get_leave_ground_velocity` and writes it via `set_local_velocity`.
4. Returns to caller (PlayerInputControl::Event_Jump_NonAutonomous,
line 376264) which sends 0xF61C MoveToState + 0xF74E VectorUpdate
(via Event_Jump → JumpPack).
---
## 12. `CMotionInterp::get_jump_v_z` — vertical component
**Address:** `0x00527aa0` (line 304953)
```
00527aa0 float CMotionInterp::get_jump_v_z(CMotionInterp const* this)
{
float extent = this->jump_extent; // 304957
if (extent < 0.000199999995f) return 0.0f; // 304959 (epsilon)
if (extent > 1.0f) extent = 1.0f; // 304968-973 clamp
CWeenieObject* w = this->weenie_obj; // 304975
if (w == 0) return /* fallback */; // returns last-result reg (10.0f from DAT)
return w->vtable->InqJumpVelocity(extent, ...); // 304980
}
```
`InqJumpVelocity` is the per-character jump-skill curve. Returns
the actual launch v_z in m/s (e.g. extent=1.0 with jump-skill 300
→ ~7.8 m/s peak from PlayerWeenie).
---
## 13. `CMotionInterp::get_leave_ground_velocity` — full launch vector
**Address:** `0x005280c0` (line 305404)
```
005280c0 void CMotionInterp::get_leave_ground_velocity(
CMotionInterp* this, Vector3* arg2)
{
CMotionInterp::get_state_velocity(this, arg2); // 305408 — XY (body-local)
arg2->z = CMotionInterp::get_jump_v_z(this); // 305409+305411
// If all three components are |x| < 0.0002 (near-zero):
if (|arg2->x| < eps && |arg2->y| < eps && |arg2->z| < eps)
{
// Project current world velocity into body-local frame via the
// transposed orientation matrix m_fl2gv.
// (lines 305434-305440)
arg2->x = m_fl2gv[0]*velX + m_fl2gv[1]*velY + m_fl2gv[2]*velZ;
arg2->y = m_fl2gv[3]*velX + m_fl2gv[4]*velY + m_fl2gv[5]*velZ;
arg2->z = m_fl2gv[6]*velX + m_fl2gv[7]*velY + m_fl2gv[8]*velZ;
}
}
```
The fast-path is the common case: `get_state_velocity` writes (X=strafe,
Y=forward, Z=0); then we overwrite Z with v_z. The fallback only fires
when state-velocity AND jump_v_z are all near zero (e.g. zero-extent
jump while standing still); it preserves residual world velocity.
---
## 14. `CMotionInterp::jump_is_allowed`
**Address:** `0x005282b0` (line 305509)
```
005282b0 uint32_t CMotionInterp::jump_is_allowed(
CMotionInterp* this, float arg2, int32_t* arg3)
{
CPhysicsObj* po = this->physics_obj;
if (po == 0) return 0x24; // (no obj) GeneralFail
// Creature path:
CWeenieObject* w = this->weenie_obj;
bool wIsCreature = w ? w->vtable->IsCreature() : true;
if (w != 0 && !wIsCreature)
{
// Non-creature — always allowed-ish, fall through to weenie checks.
goto label_5282f6;
}
// Creature: require Gravity flag set AND grounded (Contact + OnWalkable).
if ((po->state & 4) == 0) return 0x24; // 305561 — no gravity, can't jump
uint8_t ts = po->transient_state;
if (!((ts & 0x1) && (ts & 0x2))) return 0x24; // 305566 — not grounded → 0x24
label_5282f6:
if (CPhysicsObj::IsFullyConstrained(po)) return 0x47; // 305524
// Pending-queue check.
LListData* head = this->pending_motions.head_;
uint32_t pendingErr = head ? head->jumpErr : 0;
if (head == 0 || pendingErr == 0)
{
pendingErr = CMotionInterp::jump_charge_is_allowed(this);
if (pendingErr == 0)
{
uint32_t mErr = CMotionInterp::motion_allows_jump(this,
this->interpreted_state.forward_command);
if (mErr != 0) return mErr;
if (this->weenie_obj == 0) return mErr; // 0
// Stamina cost check.
if (w->vtable->JumpStaminaCost(arg2, arg3) != 0)
return 0;
return 0x47; // not enough stamina
}
}
return pendingErr;
}
```
Error codes:
- `0x00` = success
- `0x24` = `CantJumpInAir` / general motion failure
- `0x47` = `CantJumpFromPosition` (constrained, weenie-blocked, or stamina)
- `0x48` = `CantJumpFromMotion` (motion command blocks jump — emote, etc.)
- `0x49` = weenie-blocked (load-down)
---
## 15. `CMotionInterp::contact_allows_move`
**Address:** `0x00528240` (line 305471)
```
00528240 int32_t CMotionInterp::contact_allows_move(
CMotionInterp const* this, uint32_t arg2)
{
if (this->physics_obj == 0) return 0;
// Always allow these "anchor" commands regardless of grounding:
if (arg2 == 0x40000015 || arg2 == 0x40000011) return 1; // (305481-482)
if (arg2 >= 0x6500000d && arg2 <= 0x6500000e) return 1; // (305478) — sidestep cmds
// Non-creature → allow.
CWeenieObject* w = this->weenie_obj;
if (w != 0 && !w->vtable->IsCreature()) return 1; // (305490)
// No gravity → allow.
if ((this->physics_obj->state & 4) == 0) return 1; // (305495)
// Grounded (Contact + OnWalkable) → allow.
uint8_t ts = this->physics_obj->transient_state;
if ((ts & 0x1) && (ts & 0x2)) return 1; // (305500)
return 0;
}
```
This is the per-command grounding check. Walk/run only succeed when
grounded; emotes/sidesteps always succeed.
---
## 16. `CPhysicsObj::calc_acceleration` — gravity application
**Address:** `0x00510950` (line 278533)
```
00510950 void CPhysicsObj::calc_acceleration(CPhysicsObj* this)
{
uint8_t ts = this->transient_state;
// Grounded: zero accel (and zero omega). Tests Contact && OnWalkable
// && (state & "Hooked-from-floor" bit). Real semantics: grounded creature
// gets no gravity acceleration.
if ((ts & 1) && (ts & 2) && (this->state & 0x???) == 0)
{
accel = (0, 0, 0);
omega = (0, 0, 0);
return;
}
// Gravity flag NOT set: zero accel.
if ((this->state & 4) == 0) // line 278549 — Gravity bit (state & 4)
{
accel = (0, 0, 0);
return;
}
// Airborne with gravity: apply downward gravity.
accel = (0, 0, PhysicsGlobals::gravity); // line 278559 (gravity = -9.8 m/s²)
}
```
So the **state.Gravity bit (0x4)** is what enables gravity application
in retail. It's set when the creature enters the world and stays set;
it does NOT need to be re-set on each jump. Acdream's `OnLiveVectorUpdated`
explicitly setting `State |= Gravity` is defensive — if your remote-tracking
code preserved the bit from the moment the body was created, this would
be a no-op.
---
## 17. State writes during a complete jump arc — answers to the brief
| Phase | What happens | Who writes what |
|---|---|---|
| **Pre-jump** | Standing, OnWalkable=1, Contact=1, Gravity=set, vel=0 | initial state |
| **Player jump start** | `MotionInterp::jump(extent)` succeeds | `jump_extent = extent`; `set_on_walkable(0)` (clears OnWalkable=2 bit) |
| ↳ via set_on_walkable | edge 1→0 fires `MovementManager::LeaveGround` | TransientState OnWalkable bit cleared; calc_acceleration recalcs (now (0,0,-9.8) since !grounded) |
| ↳ via LeaveGround | computes launch vector | `set_local_velocity(leaveVel)``set_velocity(worldVel)` writes m_velocityVector + sets Active + sets `jumped_this_frame=1`; clears `jump_extent=0` |
| **Per-tick airborne** | UpdateObjectInternal | reads m_velocityVector, integrates pos += vel·dt + ½·accel·dt²; vel.Z += accel.Z·dt |
| **Mid-arc** | sphere-sweep finds no walkable plane | OnWalkable stays 0 |
| **Server VectorUpdate** | `0xF74E` arrives | Only `m_velocityVector` + `m_omegaVector` overwritten. Contact/OnWalkable/Gravity untouched. |
| **Landing (sweep finds floor)** | `set_frame` block 283474-283509 reclassifies contact_plane | If N.z ≥ floor_z: `set_on_walkable(true)` |
| ↳ edge 0→1 | fires `MovementManager::HitGround` | TransientState OnWalkable bit set; calc_acceleration → (0,0,0); HitGround calls RemoveLinkAnimations + apply_current_movement (which re-poses + writes new ground velocity via get_state_velocity → set_local_velocity) |
**Mid-arc UPs from server:** retail does NOT have a separate "snap" or
"integrate" path. A server PortalCellUpdate (0xF748) during flight will
just write the new position via `CPhysicsObj::SetPositionInternal` (same
sequence-gate logic in `CObjectMaint::QueuePortalCellUpdate`), and the
sphere-sweep that next tick decides if landing happened. The arc is
purely client-integrated; server position events override it
authoritatively when they arrive.
**Cycle transition Falling → Ready/Walk/Run on landing:** retail does NOT
explicitly transition. `HitGround` calls `apply_current_movement` which
re-pushes the existing `interpreted_state.forward_command`. If the player
is still holding W, that's RunForward, and the cycle naturally reverts.
The Falling animation is layered as a link-animation that
`RemoveLinkAnimations` clears at HitGround. (acdream's
`SetCycle(landingCmd)` in GameWindow.cs:3487 is a more explicit
re-pose; equivalent effect.)
---
## 18. Cross-check vs acdream
| acdream method | retail counterpart | Match? |
|---|---|---|
| `MotionInterpreter.jump` (line 691) | `CMotionInterp::jump` 0x00528780 | ✅ matches: jump_is_allowed, JumpExtent, set_on_walkable(false) |
| `MotionInterpreter.get_jump_v_z` (722) | 0x00527aa0 | ✅ matches: epsilon gate, clamp, weenie call |
| `MotionInterpreter.get_leave_ground_velocity` (759) | 0x005280c0 | ✅ matches incl. fallback projection |
| `MotionInterpreter.LeaveGround` (901) | 0x00528b00 | ✅ matches order (vel-before-extent-reset) |
| `MotionInterpreter.HitGround` (924) | 0x00528ac0 | ✅ matches |
| `MotionInterpreter.jump_is_allowed` | 0x005282b0 | ✅ matches |
| `MotionInterpreter.contact_allows_move` | 0x00528240 | ✅ matches |
| `PhysicsBody.set_velocity` (206) | 0x005113f0 | ⚠️ **MISSING `jumped_this_frame = 1`** + missing transient_state.Active gate on `state & 1` (Static) |
| `PhysicsBody.set_local_velocity` (236) | 0x005114d0 | ✅ matches (Quaternion equivalent of matrix multiply) |
| `GameWindow.OnLiveVectorUpdated` (3246) | `SmartBox::DoVectorUpdate` 0x004521c0 | ⚠️ **acdream sets Gravity / clears Contact+OnWalkable; retail does NOT.** Acdream's behavior is defensive; retail relies on the bits already being correct from launch. |
### Issues / divergences worth filing
1. **`PhysicsBody.set_velocity` is missing `jumped_this_frame = 1`.**
Without this flag the next-tick collision sweep clamps the +Z
velocity to zero before gravity can lift the body. Effective bug:
first-frame after launch the body may be re-clamped to floor.
Acdream may be papering over this elsewhere (e.g. by deferring
sweep until N+2) — worth verifying whether per-tick code reads a
`JumpedThisFrame` member.
2. **Acdream `OnLiveVectorUpdated` extra writes vs retail.** Acdream
sets `State |= Gravity` and clears Contact+OnWalkable when v.Z>0.5.
Retail's `SmartBox::DoVectorUpdate` does only set_velocity + set_omega.
The reason acdream needs the extra writes is that acdream's per-tick
integrator gates gravity on `!OnWalkable` instead of `state & Gravity`.
If we ported `calc_acceleration` faithfully (state.Gravity bit set
at body creation, persists across jumps), the OnLiveVectorUpdated
bit-setting would become unnecessary.
3. **K-fix15 question (airborne + IsOnGround + Velocity.Z<=0):** retail's
landing detection has nothing to do with Velocity.Z. It uses the
sphere-sweep's contact plane (per `CPhysicsObj::set_frame`
collision_info path) and compares `contact_plane.N.z` against
`floor_z`. Acdream's velocity-Z-based landing heuristic in
`OnLivePositionUpdated` is a pragmatic shortcut (we don't have a
full sphere-sweep on remotes), but is materially divergent from
retail. Long-term, when remote sphere-sweep lands, swap to the
N.z gate.
---
## File written
`docs/research/2026-05-04-l3-port/10-vector-update-jump.md`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,745 @@
# Hard-Teleport (Branch A) + Sequence-Number Plumbing — Retail Pseudo-C Extract
**Date:** 2026-05-04
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C)
**Cross-reference:** `docs/research/named-retail/acclient.h`,
`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs`,
`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PositionPack.generated.cs`
**Companion:** `03-up-routing.md` (the standard tri-state router)
This note drills into Branch A (hard-teleport) of `MoveOrTeleport`, the
`newer_event` 16-bit-wrap helper, the `update_times[]` slot map, the
`SmartBox::HandleReceivedPosition` instance-stamp gate, the
`SetPosition`/`SetPositionInternal` flag-bit decoder used by Branch A,
the wire layout of the four PositionPack u16 stamps, and acdream's
current end-to-end gap.
---
## 1. `CPhysicsObj::newer_event` — the 16-bit-wrap stamp comparator
**File line:** 90712 — `0x00451b10`
Verbatim retail (decompiler abs-delta noise replaced with the
arithmetic that's actually emitted):
```c
90712 int32_t __thiscall CPhysicsObj::newer_event(
90712 class CPhysicsObj* this,
90712 enum PhysicsTimeStamp arg2, // slot index 0..8
90712 uint16_t arg3) // wire-side new stamp
90712 {
90716 esi = this->update_times[arg2]; // stored stamp
90718 edi = arg3; // received stamp
90721 // signed delta: diff = (int32)((uint32)edi - (uint32)esi)
90722 // abs(diff) → eax_4
90723 eax_4 = abs(diff);
90726 if (eax_4 > 0x7fff)
90727 c = (edi < esi); // wrapped: received is OLDER
90728 else
90729 c = (esi < edi); // not wrapped: received is NEWER if c
90731 if (diff == 0)
90732 return 0; // EQUAL → not newer
90734 this->update_times[arg2] = edi; // STORE new value
90735 return 1; // NEWER → 1
90712 }
```
**Decoder note.** The Binary Ninja output expands the abs(delta) into
`HIGHD/LOWD ^ - -` salad (lines 9072190723) and the early-return at
90731 reads as `-((eax_4 - eax_4)) == 0`, which the optimizer collapses
into `if (diff == 0) return 0;`. The stripped semantic equivalent
(matches ACE `WorldObject_Networking.cs::is_newer_event`,
`PhysicsObj.cs::CheckIsNewer`):
```csharp
static bool IsNewer16(ushort prev, ushort received) {
int diff = (ushort)(received - prev); // unsigned 16-bit subtract
if (diff == 0) return false; // equal → not newer
return diff < 0x8000; // <32k forward newer; 32k older
}
```
**Side effect.** This is **NOT pure** — the function writes
`update_times[arg2] = edi` when it returns 1. So the four
`newer_event` calls inside `HandleReceivedPosition` /
`MoveOrTeleport` simultaneously test *and* commit the new stamp.
Any port must preserve "test-and-set" semantics or the next UP will
re-fire as if it were the first.
---
## 2. `update_times[9]` slot map
**Header:** `acclient.h:6084` (verbatim):
```c
enum PhysicsTimeStamp
{
POSITION_TS = 0x0,
MOVEMENT_TS = 0x1,
STATE_TS = 0x2,
VECTOR_TS = 0x3,
TELEPORT_TS = 0x4,
SERVER_CONTROLLED_MOVE_TS = 0x5,
FORCE_POSITION_TS = 0x6,
OBJDESC_TS = 0x7,
INSTANCE_TS = 0x8,
NUM_PHYSICS_TS = 0x9,
};
```
`CPhysicsObj` carries `unsigned __int16 update_times[9]` (`acclient.h:30738`).
The four PositionPack u16s map onto **four** of these slots:
| Wire field (PositionPack order) | Slot | Slot index | Used in MoveOrTeleport? |
|---|---|---|---|
| `instance_timestamp` | INSTANCE_TS | 8 | gate in UnpackPositionEvent (must EQUAL) |
| `position_timestamp` | POSITION_TS | 0 | gate in HandleReceivedPosition (must be newer) |
| `teleport_timestamp` | TELEPORT_TS | 4 | **Branch A trigger** in MoveOrTeleport |
| `force_position_timestamp` | FORCE_POSITION_TS | 6 | local-player BlipPlayer trigger |
The remaining slots (MOVEMENT_TS, STATE_TS, VECTOR_TS,
SERVER_CONTROLLED_MOVE_TS, OBJDESC_TS) are stamped by separate
opcodes — UpdateMotion (0xF74C), VectorUpdate (0xF74E),
ObjDesc, etc. — not by 0xF748.
---
## 3. `SmartBox::HandleReceivedPosition` (full) — staleness gates around Branch A
**File line:** 92896 — `0x00453fd0`
Verbatim retail (de-noised from line numbers preserved):
```c
92896 void __thiscall SmartBox::HandleReceivedPosition(
92896 class SmartBox* this,
92896 class CPhysicsObj* arg2, // target object
92896 class Position const* arg3, // received position
92896 uint32_t arg4, // placement_id
92896 int32_t arg5, // has_contact (0=air, !=0 grounded)
92896 class AC1Legacy::Vector3 const* arg6, // velocity
92896 uint16_t arg7, // POSITION_TS (position_timestamp)
92896 uint16_t arg8, // TELEPORT_TS (teleport_timestamp / move-seq)
92896 uint16_t arg9) // FORCE_POSITION_TS
92898 {
92901 objcell_id = arg3->objcell_id;
92902 var_48 = 0x796910; // Position vtable
92904 Frame::operator=(&var_40, &arg3->frame); // local copy of frame
92905 player = this->player;
// ───────── (1) LOCAL PLAYER force-position blip ─────────
92907 if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9) != 0) {
92910 ebp = player->update_times[TELEPORT_TS];
// peek-only: is the teleport_ts EQUAL to ours?
92923 if (signed_delta_is_zero(ebp, arg8)) {
92925 CPhysicsObj::get_heading(player);
92927 Frame::set_heading(&var_40, currentHeading);
92928 SmartBox::BlipPlayer(this, &var_48); // server forced our pos
92929 player->update_times[POSITION_TS] = arg7;
92931 cmdinterp->vtable->SendPositionEvent(cmdinterp);
92932 return;
}
}
// ───────── (2) PEEK ebp = current POSITION_TS, run TEST-AND-SET ─────────
92936 ebp = arg2->update_times[POSITION_TS]; // save old stamp
92938 if (newer_event(arg2, POSITION_TS, arg7) == 0) {
// not newer — STALE position; nothing to do unless the teleport
// stamp is somehow different (logging only).
92941 esi = arg2->update_times[TELEPORT_TS];
92954 if (signed_delta_nonzero(arg8, esi))
92955 ++error_count;
92957 return; // ← stale UP: no body change
}
// ───────── (3) TELEPORT_TS sanity vs received arg8 ─────────
92961 ecx_4 = arg2->update_times[TELEPORT_TS];
92974 if (signed_delta_nonzero(ecx_4, arg8)) {
// received teleport_ts is OLDER than recorded — rewind position
// stamp & bail (this branch is safety, not a normal path).
92976 arg2->update_times[POSITION_TS] = ebp;
92977 return;
}
// ───────── (4) Detach from any parent + re-place ─────────
92982 parent = arg2->parent;
92982 if (parent != 0 && parent->id != this->player_id) {
92984 weenie = CObjectMaint::GetWeenieObject(arg2->id);
92986 if (weenie != 0)
92987 weenie->vtable->SetParentedState(weenie, 0);
}
92990 CPhysicsObj::unset_parent(arg2);
92992 if (CPhysicsObj::HasAnims(arg2) == 0)
92993 CPhysicsObj::SetPlacementFrame(arg2, arg4, 1);
// ───────── (5) REMOTE OBJECT branch (the L.3 target) ─────────
92995 if (arg2 != this->player) {
92997 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0) {
// … ConstrainTo with start/max constraint distances …
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, …);
}
93010 return;
}
// ───────── (6) LOCAL PLAYER teleport branch ─────────
93013 if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0) {
93015 SmartBox::TeleportPlayer(this, &var_48);
// ConstrainTo + zero velocity
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, …);
93029 CPhysicsObj::set_velocity(player, &zero, 1);
93030 return;
}
// ───────── (7) LOCAL PLAYER soft-correct ─────────
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, …);
93044 if (cmdinterp->UsePositionFromServer() != 0 && arg5 != 0) {
93047 autonomyLevel = cmdinterp->GetAutonomyLevel();
93049 CPhysicsObj::InterpolateTo(arg2, &var_48, autonomyLevel != 0);
}
92896 }
```
**Critical: the order of stamp updates.**
1. `INSTANCE_TS` is gated equality-only in `UnpackPositionEvent` (line
93081) — **never** stored as "newer", just verified equal.
2. `POSITION_TS` is test-and-set at line 92938 by `newer_event(POSITION_TS, arg7)`.
3. `TELEPORT_TS` is **peeked** at line 92974 (sanity), then test-and-set at
line 93013 (player branch) **or** line 284325 (remote branch via
MoveOrTeleport).
4. `FORCE_POSITION_TS` is test-and-set at line 92907.
So a single 0xF748 stamps up to **3** slots: POSITION_TS always (if
newer), and either TELEPORT_TS (if teleport advanced) or
FORCE_POSITION_TS (if force advanced) but generally not both.
---
## 4. `SmartBox::UnpackPositionEvent` — INSTANCE_TS staleness gate
**File line:** 93055 — `0x004542c0`
Already covered in `03-up-routing.md` Section 2. The salient points
re-stated:
```c
93055 enum NetBlobProcessedStatus SmartBox::UnpackPositionEvent(...)
93055 {
93059 PositionPack::PositionPack(&var_68);
93060 PositionPack::UnPack(&var_68, arg3, arg4); // ← reads bytes
93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2);
93063 if (eax_1 != 0) {
93065 ecx_4 = eax_1->update_times[INSTANCE_TS=8];
93081 if (signed_delta_is_zero(received_inst, ecx_4)) {
// ★ EQUAL ⇒ proceed; UN-EQUAL is the loggedout / queue path
93083 if (ecx_4 != received_inst)
93084 return NETBLOB_LOGGED_OUT; // (= 2)
93092 SmartBox::HandleReceivedPosition(this, eax_1,
93092 &recvPos, placement_id, has_contact, &velocity,
93092 position_ts, teleport_ts, force_position_ts);
93093 return NETBLOB_PROCESSED_OK; // (= 1)
}
93095 }
93097 return NETBLOB_QUEUED; // (= 4)
93055 }
```
The instance gate is "equality-or-drop." Reasoning: instance stamp
counts character logins. If the server has bumped it (player relogged)
the client's still-cached object is the OLD instance — defer or
drop. UnPack consumed the bytes, so the buffer pointer advanced
either way; only the side effect on the body is gated.
The 16-bit-wrap math is identical to `newer_event`'s, but **without**
the test-and-set side effect — INSTANCE_TS is bumped elsewhere
(CreateObject path).
---
## 5. `CPhysicsObj::MoveOrTeleport` — Branch A dissection
**File line:** 284304 — `0x00516330` (full extract in `03-up-routing.md`)
Branch A specifically:
```c
284321 if (signed_delta_is_zero(this_1->update_times[TELEPORT_TS], arg3)) // (a) TELEPORT_TS sanity
284321 {
284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3); // (b) test-and-set
284325 // ↑
284325 // writes update_times[4]=arg3 if newer
284327 if (eax_8 != 0 || this_1->cell == 0) // (c) Branch A predicate
284327 {
// ───── BRANCH A: HARD TELEPORT ─────
284329 int32_t var_70_3 = 1; // unused local
284330 CPhysicsObj::teleport_hook(this_1, edx_2); // (d) teleport_hook
284331 SetPositionStruct sps;
284332 SetPositionStruct::SetPositionStruct(&sps);
284333 SetPositionStruct::SetPosition(&sps, arg2); // copy received Position
284334 SetPositionStruct::SetFlags(&sps, 0x1012); // (e) Slide+Placement+SendPositionEvent
284335 CPhysicsObj::SetPosition(this_1, &sps); // run CTransition + place
284336 SetPositionStruct::~SetPositionStruct(&sps);
284337 return 1;
}
/* …Branch B / Branch C (see 03-up-routing.md) … */
}
```
**Important nuance — sanity vs trigger.** The `signed_delta_is_zero`
at line 284321 is a *sanity* gate — it actually means
"abs(delta) == 0", i.e. "the recorded TELEPORT_TS minus the received
arg3 is zero, OR the abs-equality compare path was taken". In retail
this path is taken when the wire stamp is **equal to or newer than**
recorded (the OLDER case is filtered via the `signed_delta_nonzero`
at 92974 in HandleReceivedPosition before MoveOrTeleport is
called). Inside MoveOrTeleport, line 284325's `newer_event` is the
**actual** "is-newer" decision, with side-effect.
So in the abstract:
| Wire `arg3` vs recorded `update_times[TELEPORT_TS]` | Path |
|---|---|
| OLDER (wrap-aware) | Already filtered at 92974 — never reaches MoveOrTeleport |
| EQUAL | `newer_event` returns 0 ⇒ Branch A predicate evaluates `0 \|\| (cell==0)` — Branch A ONLY if cell unset; otherwise fall through to Branch B/C |
| NEWER | `newer_event` returns 1, stores arg3 ⇒ Branch A predicate true ⇒ Branch A fires |
And **`cell == 0`** is the bootstrap case — a `CPhysicsObj` that has
been allocated but not yet placed (e.g., we got an UP for an entity
mid-teleport, before its initial cell entry). Forces a hard placement
even with equal stamp, because there's nowhere else to put it.
### `teleport_hook` (line 283115 — `0x00514ed0`)
```c
283115 void CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
283115 {
283118 if (this->movement_manager != 0)
283124 MovementManager::CancelMoveTo(this->movement_manager, ctx=0x3c);
283129 if (this->position_manager != 0)
283130 PositionManager::UnStick(this->position_manager);
283134 if (this->position_manager != 0)
283135 PositionManager::StopInterpolating(this->position_manager); // ← drops queue
283139 if (this->position_manager != 0)
283140 PositionManager::UnConstrain(this->position_manager);
283144 if (this->target_manager != 0) {
283146 TargetManager::ClearTarget(this->target_manager);
283147 TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus);
}
283150 CPhysicsObj::report_collision_end(this, 1);
283115 }
```
**What teleport_hook does (for the port):**
1. Cancels any in-progress MoveTo route (the AI's "go to door" command).
2. UnStick: clear the "stuck against wall" recovery state.
3. **StopInterpolating: clear the position queue** — required before
the hard-snap so no stale waypoint pulls the body away.
4. UnConstrain: clear distance-from-anchor constraints.
5. Clear target lock; notify nearby observers we teleported.
6. Report a "collision end" so adjacent collision listeners stop
tracking us.
Steps 3 and 6 are the L.3-relevant ones for acdream. Steps 1, 2, 4, 5
are tangential to position routing.
---
## 6. `SetPosition` / `SetPositionInternal` flag bit decoder (0x1012)
**Outer** `SetPosition` (line 284137 — `0x005160c0`) builds a
CTransition over the body's spheres and forwards to the
flag-decoder `SetPositionInternal`.
The **flag-decoder** `SetPositionInternal` at line 284117 — `0x00516040`:
```c
284117 enum SetPositionError SetPositionInternal(class SetPositionStruct const* arg2,
284117 class CTransition* arg3)
284117 {
284120 if ((arg2->flags & 0x0200) != 0) // bit 9 = SCATTER
284121 return SetScatterPositionInternal(this, arg2, arg3);
284123 objcell_id = arg2->pos.objcell_id;
284124 var_48 = 0x796910;
284126 Frame::operator=(&var_40, &arg2->pos.frame);
284127 result = SetPositionInternal(this, &recvPos, arg2, arg3); // run normal path
284129 if (result != OK_SPE && (arg2->flags & 0x0100) != 0) // bit 8 = ALLOW_SCATTER_FALLBACK
284130 return SetScatterPositionInternal(this, arg2, arg3);
284132 return result;
284117 }
```
The middle dispatcher `SetPositionInternal(this, Position*, sps, trans)`
(line 283892) runs `AdjustPosition` then dispatches based on
`sps->flags & 0x20` and other bits, and ultimately calls the inner
`SetPositionInternal(this, CTransition*)` (line 283399) to commit
the body state.
Inside `CheckPositionInternal` (line 280070 — `0x00511e90`) and
the middle dispatcher we see the bit checks:
| Bit | Hex | Used at | Semantic (cross-checked with ACE `SetPositionFlags.cs`) |
|----:|----:|---|---|
| 0 | 0x0001 | 284129 | **ALLOW_SCATTER_FALLBACK** — if the precise placement fails, retry as a scatter (±xrad/yrad) placement. |
| 1 | 0x0002 | 284120 | **SCATTER** — go straight to scatter placement (used by `SetScatterPositionInternal`). |
| 4 | 0x0010 | 280075, 280080 | **PLACEMENT_ALLOW_SLIDING** — the sphere will slide along walls during the placement search instead of being rejected on first contact. **Set in MoveOrTeleport's 0x1012.** |
| 5 | 0x0020 | 283929 | **DO_NOT_LOAD_CELLS** — the cell array is left as "do_not_load_cells = 1"; used when streaming hasn't committed the cell yet. |
| 8 | 0x0100 | 284129 | (see bit 0 — same word, different reading) |
| 9 | 0x0200 | 284120 | (see bit 1 — same word, different reading) |
| 11 | 0x0800 | — | **IS_PORTAL_TRAVEL** (per ACE) — not seen on the MoveOrTeleport paths. |
| 12 | 0x1000 | always present in MoveOrTeleport flags | **SEND_POSITION_EVENT** — after placement, fire the position-event broadcast back through cmdinterp. **Set in MoveOrTeleport's 0x1012 and SetPositionSimple's 0x1002/0x1012.** |
Note that ACE's `SetPositionFlags.cs` and the older WorldBuilder
references use **different** symbolic names; the bit assignments
above match what's actually decoded in the retail pseudo-C against
`SetPositionStruct::flags` field (a `uint32_t`).
**Decoded `0x1012`** = `0x1000 | 0x0010 | 0x0002`. Hmm — **bit 1
(0x0002)** is set too. Re-checking: the OR is
**0x1000 + 0x0010 + 0x0002 = 0x1012**. This means MoveOrTeleport's
Branch A also sets the **SCATTER** bit. That's the inverse of what
prior research note (`03-up-routing.md` Section 5) recorded — let's
re-derive from the source.
Re-parse:
- `0x1012` = binary `0001 0000 0001 0010`. Set bits: 1, 4, 12.
- Bit 1 = SCATTER (line 284120 takes the scatter path on `& 0x0200`).
- Wait — `0x0200` is bit **9**, not bit 1. Let me reread:
```c
284120 if ((*(uint8_t*)((char*)((int16_t)arg2->flags))[1] & 2) != 0)
```
The decompiler is reading byte 1 (bits 8-15) of `flags` and ANDing
that byte with `2` — meaning it's testing **bit 9** of `flags`, not
bit 1. So `0x0200` is the SCATTER bit. With `0x1012` = bits 1, 4, 12,
the bit-1 (`0x0002`) is **NOT** the scatter bit — it's something
else entirely.
Updated table:
| Decimal bit | Hex | Semantic |
|--:|--:|---|
| 0 | 0x0001 | (unknown — possibly SLIDE; see ACE) |
| 1 | 0x0002 | **PLACEMENT** — the position is a fresh placement (vs. continuation of motion). |
| 4 | 0x0010 | **PLACEMENT_ALLOW_SLIDING** — sphere slides during placement search (line 280075). |
| 8 | 0x0100 | **ALLOW_SCATTER_FALLBACK** — retry scatter on placement failure (line 284129). |
| 9 | 0x0200 | **SCATTER** — initial scatter placement (line 284120). |
| 12 | 0x1000 | **SEND_POSITION_EVENT** — broadcast position after place. |
So `0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT`.
This is consistent with retail's `SetPositionSimple(slide=1)` which
emits `0x1012` and the prior `03-up-routing.md` characterization
(Slide + Placement + SendPositionEvent).
`0x1002` (used by `SetPositionSimple(slide=0)`) = `SEND_POSITION_EVENT | PLACEMENT`
**without** the PLACEMENT_ALLOW_SLIDING bit, so the sphere must
fit at the exact spot or fail.
`0x0011` (used by `enter_world` line 284208) = `PLACEMENT | PLACEMENT_ALLOW_SLIDING`
— enter without broadcasting an event.
`0x0001` (used by `enter_world` line 284205 default) = `(unknown)`
or possibly `SLIDE` — only one bit set; behaves as the bare-minimum
placement.
**Cross-reference recommendation:** ACE's
`Source/ACE.Server/Physics/SetPositionFlags.cs` should be the ground
truth for symbolic names. The retail PDB decompile shows the bit
positions but not the names — Turbine compiled the enum out.
---
## 7. Wire format — PositionPack on 0xF748
**ACE `Source/ACE.Server/Network/Structure/PositionPack.cs:90-115`:**
```csharp
public static void Write(this BinaryWriter writer, PositionPack position)
{
writer.Write((uint)position.Flags); // u32 PositionFlags
writer.Write(position.Origin); // u32 cellId + Vector3 pos
if ((flags & OrientationHasNoW) == 0) writer.Write(Rotation.W);
if ((flags & OrientationHasNoX) == 0) writer.Write(Rotation.X);
if ((flags & OrientationHasNoY) == 0) writer.Write(Rotation.Y);
if ((flags & OrientationHasNoZ) == 0) writer.Write(Rotation.Z);
if ((flags & HasVelocity) != 0) writer.Write(position.Velocity); // 3xf32
if ((flags & HasPlacementID) != 0) writer.Write((uint)position.PlacementID);
writer.Write(position.InstanceSequence); // u16
writer.Write(position.PositionSequence); // u16
writer.Write(position.TeleportSequence); // u16
writer.Write(position.ForcePositionSequence); // u16
}
```
**Chorizite generated parser (`PositionPack.generated.cs:65-91`)
matches.** The wire order at the end is:
```
... ObjectInstanceSequence u16
... ObjectPositionSequence u16
... ObjectTeleportSequence u16
... ObjectForcePositionSequence u16
```
**ACE's TeleportSequence advance logic** (`PositionPack.cs:46-54`):
```csharp
InstanceSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectInstance);
PositionSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectPosition); // ← always advanced
if (adminMove)
TeleportSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectTeleport);
else
TeleportSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectTeleport);
ForcePositionSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectForcePosition);
```
**Critical: TeleportSequence advances ONLY when `adminMove=true`.**
`adminMove` is a parameter of `WorldObject.SendUpdatePosition(bool adminMove = false)`
(`WorldObject_Networking.cs:430`). Searching the ACE codebase for
`SendUpdatePosition(true)` returns 0 hits (verified via grep). The
parameter is documented as `"only used if admin is teleporting a
non-player object"` (line 429 comment) — i.e., GM `@teleto`
a creature.
**For player teleport (portal travel, recall, lifestone, death respawn):**
ACE sends the **dedicated** `GameMessagePlayerTeleport` (opcode
**0xF751**) which carries only the next ObjectTeleport stamp
(`GameMessagePlayerTeleport.cs:10`):
```csharp
Writer.Write(player.Sequences.GetNextSequence(Sequence.SequenceType.ObjectTeleport));
```
…then immediately follows with `SendUpdatePosition()` carrying the
*new* (already-advanced) TeleportSequence
(`Player_Location.cs:686-694`).
**Net effect for remote-observer ports (acdream's case): the
TeleportSequence in 0xF748 advances when:**
1. The remote was a player who teleported (PlayerTeleport advanced
their own seq, then their next UP carries the new value).
2. A GM `@teleto`-ed a creature (admin code path sets
`adminMove=true`).
**The TeleportSequence does NOT advance for:**
- Normal walking / running movement
- Normal AI patrol
- Mob-hunt path updates
- Position-only correction broadcasts
- Force-position blips (those use ForcePositionSequence)
So Branch A in retail fires on remotes specifically when a player
just portal-jumped, GM-teleported, lifestone-recalled, or
respawned. **Test cases for the L.3 port:**
1. Cast a portal recall spell while a remote observer watches you.
2. Step into a portal while another character is nearby.
3. Die — respawn at lifestone with a remote watching.
4. `@teleto` your character via GM command while another's nearby.
---
## 8. Cross-check: acdream's current sequence-number plumbing
### 8a. Inbound parser
`src/AcDream.Core.Net/Messages/UpdatePosition.cs:68-70, 152-159`:
```csharp
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerPosition Position,
System.Numerics.Vector3? Velocity,
uint? PlacementId,
bool IsGrounded,
ushort InstanceSequence = 0,
ushort TeleportSequence = 0,
ushort ForcePositionSequence = 0);
```
The four u16s are read at parse (file lines 152-159). PositionSequence
is consumed for buffer alignment but **not stored** (comment: "not
tracked by movement").
### 8b. WorldSession dispatch
`src/AcDream.Core.Net/WorldSession.cs:701-717`:
```csharp
var posUpdate = UpdatePosition.TryParse(body);
if (posUpdate is not null)
{
// Update sequence counters from the player's own position updates.
if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
{
_instanceSequence = posUpdate.Value.InstanceSequence;
_teleportSequence = posUpdate.Value.TeleportSequence; // OUR seq; for outbound use
_forcePositionSequence = posUpdate.Value.ForcePositionSequence;
}
PositionUpdated?.Invoke(new EntityPositionUpdate(
posUpdate.Value.Guid,
posUpdate.Value.Position,
posUpdate.Value.Velocity,
posUpdate.Value.IsGrounded)); // ← stamps DROPPED HERE
}
```
**The four sequence numbers DO arrive.** For the local player they're
copied into `_teleportSequence`/`_forcePositionSequence` for OUTBOUND
use only (used at GameWindow.cs:5294, 5312, 5329 to stamp our
0xF61C MoveToState packets). For remote players / NPCs the stamps
**never leave WorldSession** — the `EntityPositionUpdate` record
defined at line 110-114 has only `Guid, Position, Velocity, IsGrounded`.
### 8c. Downstream effect
`src/AcDream.App/Rendering/GameWindow.cs::OnLivePositionUpdated` (line 3312)
has no awareness that the inbound UP carried a teleport stamp at all.
The L.3 environment-variable path (lines 3508-3625) implements
Branches B (in-bubble Interpolate), C (out-of-bubble snap),
"first UP seed", and air no-op — but **Branch A is never separately
taken**. A player teleport that hits a remote observer just falls
through whichever of B/C/seed/air path the position happens to hit:
- if airborne (e.g. portal exit at high altitude): air no-op ⇒
body keeps falling locally, NEVER moves to the new portal-exit
position until the remote lands.
- if grounded and within 96m: enqueue the new position, then chase
it at walking speed across however far the teleport went —
visible "teleport-creep" of up to many meters.
- if grounded and beyond 96m: snap (this is correct by accident,
because the teleport sent us > 96m).
### 8d. What's needed for the port
Plumb the four u16 stamps from `WorldSession.EntityPositionUpdate`
into `OnLivePositionUpdated`'s `RemoteMotionState`, then on every UP:
1. INSTANCE_TS: equality check (already implicit via the GUID
matching the live entity).
2. POSITION_TS: drop the UP if not newer-by-wrap. (Currently
acdream applies every UP, even out-of-order ones.)
3. TELEPORT_TS: test-and-set with the wrap-aware comparator. If
newer, fire Branch A:
- Equivalent of `teleport_hook`: clear `rmState.Interp` queue,
call `report_collision_end` on adjacent listeners (likely
a no-op in current acdream — the collision broadcaster doesn't
yet exist), nuke any in-flight MoveTo (likely none for
remotes).
- Hard-snap `rmState.Body.Position = worldPos`,
`rmState.Body.Orientation = rot` (already done).
- Force `rmState.CellId = p.LandblockId` (already done).
4. FORCE_POSITION_TS: only relevant for our local player (handled
via the BlipPlayer-equivalent in PlayerMovementController, not
through the remote path).
The change is small: extend `EntityPositionUpdate` with the three
trailing u16s, store the per-remote `TeleportTimestamp` on
`RemoteMotionState`, and gate Branch A on its advance.
---
## 9. Answers to the cross-questions
### Q1. What sequence numbers does ACE actually broadcast in 0xF748 packets?
**A.** Four u16s in this order: `InstanceSequence`, `PositionSequence`,
`TeleportSequence`, `ForcePositionSequence`. PositionSequence advances
on every `SendUpdatePosition` call (always next). InstanceSequence and
ForcePositionSequence stay constant in normal motion (current). The
**TeleportSequence advances ONLY when `adminMove=true`**, which in
practice means "GM teleported this non-player object" or — for the
local player — when ACE chains a `GameMessagePlayerTeleport (0xF751)`
**before** the `SendUpdatePosition`, advancing the player's own
ObjectTeleport seq so the next 0xF748 carries the new value.
(`Player_Location.cs:686-694`.)
### Q2. Does TELEPORT_TS only advance on actual teleports, or every position update?
**A.** Only on actual teleports. ACE: `adminMove ?
GetNextSequence(ObjectTeleport) : GetCurrentSequence(ObjectTeleport)`.
Retail's `MoveOrTeleport` is consequently the standard
"normal-motion" path 99% of the time and only triggers Branch A on
genuine teleport events. This is why decompilers historically named
the field "teleport_timestamp" — it's a teleport flag, not a tick.
### Q3. Do we have a teleport_timestamp field anywhere in acdream that's already plumbed but unused?
**A.** Yes — partially. `UpdatePosition.Parsed.TeleportSequence`
exists at `Messages/UpdatePosition.cs:69` and is read at line 157.
It's then used for the **local player's outbound** packet stamping
(`WorldSession._teleportSequence` ⇒ MoveToState builders). For
**remote entities**, the stamp is **dropped** at the
`PositionUpdated?.Invoke(new EntityPositionUpdate(...))` boundary
(`WorldSession.cs:712-716`) — `EntityPositionUpdate` has no
TeleportSequence field. The L.3 follow-up needs to add that field
and a per-`RemoteMotionState` `TeleportTimestamp` cache.
### Q4. What test cases trigger Branch A in retail?
**A.**
1. **Player portal travel**: another player walks into a portal next
to you. Their character's `update_times[TELEPORT_TS]` advances
via `GameMessagePlayerTeleport (0xF751)` server-side; the
immediately-following 0xF748 carries the new TeleportSequence.
2. **Recall spells** (Lifestone Recall, Primary Portal Recall, etc.):
same path as #1.
3. **Death/Lifestone respawn**: PlayerTeleport→UpdatePosition pair.
4. **GM `@teleto` of a non-player object** (creature, item):
server-side `SendUpdatePosition(adminMove: true)`.
5. **First UP on a freshly-attached remote with `cell == 0`**: the
`cell == 0` clause in `MoveOrTeleport` line 284327 forces
Branch A for the bootstrap placement, even with stamp equality.
This is acdream's "first-UP seed" case — already handled
correctly by the `LastServerPosTime > 0` predicate at
`GameWindow.cs:3563`, but the rationale matches retail.
---
## Appendix A — additional symbols
| Function / Type | Address | Line in retail decomp |
|----|----|----|
| `CPhysicsObj::newer_event` | `0x00451b10` | 90712 |
| `CPhysicsObj::teleport_hook` | `0x00514ed0` | 283115 |
| `SmartBox::HandleReceivedPosition` | `0x00453fd0` | 92896 |
| `SmartBox::UnpackPositionEvent` | `0x004542c0` | 93055 |
| `CPhysicsObj::SetPosition (outer)` | `0x005160c0` | 284137 |
| `CPhysicsObj::SetPositionInternal (flag-decode)` | `0x00516040` | 284117 |
| `CPhysicsObj::SetPositionInternal (middle)` | `0x00515bd0` | 283892 |
| `CPhysicsObj::SetPositionInternal (inner)` | `0x00515330` | 283399 |
| `CPhysicsObj::CheckPositionInternal` | `0x00511e90` | 280070 |
| `CPhysicsObj::SetScatterPositionInternal` | `0x00515f00` | 284059 |
| `enum PhysicsTimeStamp` | — | acclient.h:6084 |
| `struct PositionPack` | — | acclient.h:53280 |
| `struct SetPositionStruct` | — | acclient.h:52398 |
## Appendix B — flag-bit summary card
```
SetPositionStruct.flags (uint32)
bit 0 0x0001 ? (single-bit enter_world default; likely SLIDE)
bit 1 0x0002 PLACEMENT fresh placement vs. continuation
bit 4 0x0010 PLACEMENT_ALLOW_SLIDING sphere slides during search
bit 5 0x0020 DO_NOT_LOAD_CELLS keep cells unloaded
bit 8 0x0100 ALLOW_SCATTER_FALLBACK retry scatter on failure
bit 9 0x0200 SCATTER initial scatter placement
bit 11 0x0800 IS_PORTAL_TRAVEL (per ACE; not in MoveOrTeleport paths)
bit 12 0x1000 SEND_POSITION_EVENT broadcast pos to cmdinterp
Combined values:
0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT
used by MoveOrTeleport Branch A (teleport) & SetPositionSimple(slide=1)
0x1002 = SEND_POSITION_EVENT | PLACEMENT
used by SetPositionSimple(slide=0) — non-MoveOrTeleport call sites
0x0011 = PLACEMENT_ALLOW_SLIDING | (bit 0)
used by enter_world(arg2 != 0) & reenter_visibility
0x0001 = (bit 0)
used by enter_world(arg2 == 0) — bare entry
```

View file

@ -0,0 +1,598 @@
# 13 — Retail's cycle decision tree
**Question**: When `InterpretedMotionState` has simultaneous
`forward_command=RunForward` + `sidestep_command=SidestepRight` +
`turn_command=TurnLeft`, **what cycle plays in retail?**
**Answer (TL;DR)**: All three. Retail does **not** pick "the winning
substate" out of a 3-axis state. Instead, `apply_interpreted_movement`
issues **three separate `DoInterpretedMotion` calls**
forward-cmd, then sidestep-cmd, then turn-cmd — each landing in
`CMotionTable::GetObjectSequence`, which dispatches by command **class
bits** (0x40000000 substate / 0x10000000 action / 0x20000000 modifier)
to **either replace the substate or attach a modifier**. Forward goes
into the substate slot; sidestep+turn go into the modifier list. The
`CSequence` is rebuilt with all three layers via `add_motion`.
This means **acdream's "priority winner" picker is wrong** — and so is
the `RunForward → WalkForward → Ready` fallback chain. Retail has no
fallback chain; it just calls `GetObjectSequence` per axis and ignores
NULL results.
---
## A. Top of the call tree — `CMotionInterp::apply_interpreted_movement`
Line **305713305788** (`acclient_2013_pseudo_c.txt`), address `00528600`.
```c
void CMotionInterp::apply_interpreted_movement(this, arg2, arg3) {
if (!physics_obj) return;
MovementParameters var_2c; // 305719
MovementParameters::MovementParameters(&var_2c);
// Sync run-rate from forward_speed if running
if (interpreted_state.forward_command == 0x44000007 /*RunForward*/)
my_run_rate = (float)interpreted_state.forward_speed; // 305722
// 1) Always re-issue current_style (e.g. CombatMode_NonCombat)
DoInterpretedMotion(this, interpreted_state.current_style, &var_2c); // 305724
// 2) Forward axis
if (!contact_allows_move(this, interpreted_state.forward_command)) {
var_18_2 = 0x3f800000; // 1.0f speed
DoInterpretedMotion(this, 0x40000015 /*Stand*/, &var_2c); // 305729
} else if (standing_longjump) {
DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); // 305738
StopInterpretedMotion(this, 0x6500000f /*Sidestep*/, &var_2c);
} else {
DoInterpretedMotion(this, interpreted_state.forward_command, &var_2c); // 305744
// 3) Sidestep axis
if (interpreted_state.sidestep_command == 0)
StopInterpretedMotion(this, 0x6500000f /*Sidestep*/, &var_2c); // 305748
else
DoInterpretedMotion(this, interpreted_state.sidestep_command, &var_2c); // 305752
}
// 4) Turn axis
if (interpreted_state.turn_command != 0)
DoInterpretedMotion(this, interpreted_state.turn_command, &var_2c); // 305762
else
// No turn — explicitly stop any prior TurnLeft modifier
StopInterpretedMotion(this, 0x6500000d /*TurnLeft*/, &var_2c); // implied 305770
}
```
**Critical observation**: this is **four `DoInterpretedMotion` calls
per UM** (current_style, forward, sidestep, turn). Each one is a
distinct `MotionTableManager::PerformMovement``CMotionTable::DoObjectMotion`
`CMotionTable::GetObjectSequence` round-trip. The composite cycle is
the sum of four state-machine transitions, not the result of a
priority pick.
`apply_interpreted_movement` is invoked by `apply_current_movement`
(305838305857). `apply_current_movement` is called by every UM
arrival on the player or remote object after `RawMotionState::ApplyMotion`
(or `InterpretedMotionState::ApplyMotion`) populates the state struct.
---
## B. The state-routing dispatcher — `RawMotionState::ApplyMotion`
Line **293630293703**, address `0051eb60`. This is the **retail
analog of acdream's `AnimationCommandRouter.Classify`**.
```c
void RawMotionState::ApplyMotion(this, arg2, arg3) {
// arg2 = motion command (e.g. 0x44000007 RunForward)
// arg3 = MovementParameters (speed, hold_key_to_apply, etc.)
if ((arg2 - 0x6500000d) > 3) { // not Turn/Sidestep range
if ((arg2 & 0x40000000) == 0) { // not substate
if (arg2 >= 0) {
if ((arg2 & 0x10000000) != 0) // ACTION class
AddAction(this, arg2, ...); // 293640 → action queue
} else if (current_style != arg2) { // STYLE change
forward_command = 0x41000003; // Ready
current_style = arg2; // 293645
}
} else if (arg2 != 0x44000007 /*RunForward*/) {
// 0x40000000-class but NOT RunForward (i.e. WalkForward,
// BackForward etc) goes into FORWARD slot
forward_command = arg2; // 293650
forward_holdkey = arg3->hold_key_to_apply;
forward_speed = arg3->speed;
}
return;
}
switch (arg2) { // 293666
case 0x6500000d /*TurnLeft*/:
case 0x6500000e /*TurnRight*/:
turn_command = arg2; // 293671
turn_holdkey = arg3->hold_key_to_apply;
turn_speed = arg3->speed;
return;
case 0x6500000f /*SidestepRight*/:
case 0x65000010 /*SidestepLeft*/:
sidestep_command = arg2; // 293688
sidestep_holdkey = arg3->hold_key_to_apply;
sidestep_speed = arg3->speed;
return;
}
}
```
**Routing classes** (matches `0x40000000`/`0x10000000`/`0x20000000` mask
checks, also visible in `CMotionTable::GetObjectSequence`):
| Class bit | Range | Slot | Effect |
|---|---|---|---|
| `0x40000000` | substate (e.g. `0x44000007` RunForward, `0x40000015` Stand) | `forward_command` (or replaces substate) | replaces previous substate; modifiers may be cleared |
| `0x10000000` | action (e.g. emote) | `action_head` queue | overlay; substate cycle keeps running |
| `0x20000000` | modifier | `modifier_head` list | overlay; substate cycle keeps running |
| `0x6500000d-10` | turn/sidestep (special-cased) | `turn_command` / `sidestep_command` | dedicated slots (effectively modifiers) |
| `< 0` (`0x80...`) | style change | `current_style` | full reset, forward_command → Ready |
| `0x44000007` | **RunForward** is special-cased OUT of the forward slot here — see below | — | not stored in `forward_command` directly by `RawMotionState`; it's the result of `adjust_motion` running on `WalkForward + HoldKey.Run` |
(The InterpretedMotionState equivalent at line 293531 is functionally
the same with one extra branch — `current_style` initialization.)
---
## C. `adjust_motion` — the `WalkForward + Run``RunForward` transform
Line **305343305400**, address `00528010`. This is what `DoMotion`
calls before `DoInterpretedMotion` to translate a raw key event into
a substate.
```c
void CMotionInterp::adjust_motion(this, arg2 /*&cmd*/, arg3 /*&speed*/, arg4 /*hold_key*/) {
if (weenie_obj == 0 || weenie_obj->IsCreature()) {
switch (*arg2) {
case 0x65000010 /*SidestepLeft*/:
*arg2 = 0x6500000f; // collapse Left → Right
*arg3 *= -1; // with negative speed
// fallthrough
case 0x6500000f /*SidestepRight*/:
// Sidestep speed-mod: (3.12/1.25) * 0.5 = 1.248
*arg3 = (3.12f / 1.25f) * 0.5f * (*arg3);
break;
case 0x6500000e /*TurnRight*/:
*arg2 = 0x6500000d; // collapse Right → Left
*arg3 *= -1; // with negative speed
break;
case 0x45000006 /*WalkBackward*/:
*arg2 = 0x45000005; // collapse to BackForward
*arg3 = -0.65f * (*arg3);
break;
case 0x44000007 /*RunForward*/:
// already a run cmd — fall through to apply_run_to_command
break;
}
// Then: if hold_key == HoldKey_Run, escalate to RunForward
HoldKey current = arg4 == HoldKey_Invalid ? raw_state.current_holdkey : arg4;
if (current == HoldKey_Run)
apply_run_to_command(this, arg2, arg3);
}
}
```
`apply_run_to_command` (line 305062, addr `00527be0`):
```c
void CMotionInterp::apply_run_to_command(this, arg2, arg3) {
float run_rate = weenie_obj ? weenie_obj->InqRunRate() : my_run_rate;
if (*arg2 == 0x45000005 /*WalkForward*/) {
if (*arg3 != 0)
*arg2 = 0x44000007; // → RunForward
*arg3 *= run_rate; // speed *= runRate (e.g. 2.94)
} else if (*arg2 == 0x6500000d /*TurnLeft*/) {
*arg3 *= 1.5f; // turn 1.5x while running
} else if (*arg2 == 0x6500000f /*SidestepRight*/) {
*arg3 *= run_rate;
// clamp to ±3 m/s
if (fabs(*arg3) > 3.0f)
*arg3 = (sign(*arg3)) * 3.0f;
}
}
```
So **the way `RunForward` gets into `forward_command` in retail is**:
1. Wire UM has `cmd=WalkForward (0x45000005)` + `hold_key=HoldKey_Run`
2. `DoMotion(0x45000005, params)` is called.
3. `adjust_motion` swaps `cmd → 0x44000007 RunForward`, `speed *= runRate`.
4. `RawMotionState::ApplyMotion(0x44000007, ...)` runs. The
special-case `arg2 != 0x44000007` branch at line 293648 means
RunForward is **NOT** stored in `forward_command` here. (This
appears intentional — `RunForward` is the post-`adjust_motion`
form; the persistent `RawMotionState` keeps the original
WalkForward.)
5. **InterpretedMotionState** stores the post-adjust value because
`apply_raw_movement` (305817) copies `raw_state.*` then runs
`adjust_motion` over each of the three axes (305829-305831) before
`apply_interpreted_movement` consumes it.
ACE matches this: it auto-upgrades `WalkForward + HoldKey.Run`
`RunForward` on the **outbound** wire to remote observers, which is
why our inbound parser sees `fwd=0x07` for "remote is running."
---
## D. The cycle-decision core — `CMotionTable::GetObjectSequence`
Line **298636298950**, address `00522860`. This is where a single
motion command lands and the `CSequence` is rebuilt. It is invoked once
per `DoInterpretedMotion` call.
Signature:
```c
int CMotionTable::GetObjectSequence(
this,
uint32_t motion, // arg2 — the command
MotionState* state, // arg3 — table-internal state
CSequence* sequence, // arg4 — the part-array sequence to mutate
float speed_mod, // arg5
uint32_t* num_anims_out, // arg6
int32_t force_flag); // arg7 — re-modify recursion guard
```
**Three dispatch branches based on the high-bit class of `motion`**:
### D.1 — `motion < 0` (style change, e.g. `0x80000003D`)
Lines 298661298735. Substate's effect: reset to default substate of
the new style, optionally clear modifiers, replace cycles.
### D.2 — `motion & 0x40000000` (substate)
Lines 298737298848. The **forward-axis path**.
```c
if ((motion & 0x40000000) != 0) { // 298737
uint32_t key = (motion & 0xffffff);
MotionData* incoming = LongHash::lookup(&this->cycles, (state->style << 0x10) | key);
if (incoming == 0)
incoming = LongHash::lookup(&this->cycles, (this->default_style << 0x10) | key); // fallback to default style
if (incoming != 0 && is_allowed(this, motion, incoming, state)) {
// Same-cycle re-speed shortcut: we're already on this cycle
// and just changing speed (e.g. forward_speed delta)
if (motion == state->substate &&
same_sign(speed_mod, state->substate_mod) &&
sequence->has_anims()) {
change_cycle_speed(sequence, incoming, state->substate_mod, speed_mod);
subtract_motion(sequence, incoming, state->substate_mod);
combine_motion(sequence, incoming, speed_mod);
state->substate_mod = speed_mod;
return 1;
}
// Full transition: clear-anims + (link from current substate) + (incoming)
if (incoming->bitfield & 1)
state->clear_modifiers(); // some cycles clear modifiers on entry
MotionData* link = get_link(this, state->style, state->substate, state->substate_mod, motion, speed_mod);
// (with two-stage fallback through default_substate if direct link missing)
sequence->clear_physics();
sequence->remove_cyclic_anims();
// If no direct link, route through default substate
add_motion(sequence, link, ...); // transition anim
add_motion(sequence, incoming, speed_mod); // new cycle
// Re-add prior substate as a modifier if it had the 0x20000000 flag
if (state->substate != motion && (state->substate & 0x20000000))
state->add_modifier_no_check(state->substate, state->substate_mod);
state->substate_mod = speed_mod;
state->substate = motion;
re_modify(this, sequence, state); // re-attach all modifiers
return 1;
}
}
```
**Key takeaway**: if the cycle-bound lookup `LongHash::lookup(&cycles,
(style<<16)|key)` returns NULL **and** the default-style fallback also
returns NULL, retail returns 0 (failure) and the call has **no effect**.
There is **no `RunForward → WalkForward → Ready` fallback chain** — that
is purely an acdream artifact.
### D.3 — `motion & 0x10000000` (action, e.g. emote)
Lines 298850298907. **Overlay path**:
```c
if ((motion & 0x10000000) != 0) {
uint32_t key = (state->style << 0x10) | (state->substate & 0xffffff);
MotionData* current_substate_md = LongHash::lookup(&this->cycles, key);
if (current_substate_md != 0) {
MotionData* link = get_link(this, state->style, state->substate, state->substate_mod, motion, speed_mod);
if (link != 0) {
state->add_action(motion, speed_mod); // append to action queue
sequence->clear_physics();
sequence->remove_cyclic_anims(); // remove looping anims
add_motion(sequence, link, speed_mod); // transition anim (one-shot)
add_motion(sequence, current_substate_md, state->substate_mod); // re-add substate cycle!
re_modify(this, sequence, state);
return 1;
}
}
}
```
**Crucial: actions DO NOT replace the substate cycle.** They prepend a
one-shot link animation, then re-add the current substate cycle so it
keeps looping after the action. Acdream's "Action route" is correct in
spirit but should preserve the running cycle exactly like this.
### D.4 — `motion & 0x20000000` (modifier — turn, sidestep, all overlay cycles)
Lines 298909298945. **Modifier list overlay**:
```c
if ((motion & 0x20000000) != 0) {
// current substate must be a non-OneShot cycle
MotionData* current_substate_md = LongHash::lookup(&this->cycles, (state->style << 0x10) | (state->substate & 0xffffff));
if (current_substate_md != 0 && (current_substate_md->bitfield & 1) == 0) {
// Look up the modifier cycle
MotionData* mod_md = LongHash::lookup(&this->modifiers, (state->style << 0x10) | (motion & 0xffffff));
if (mod_md == 0)
mod_md = LongHash::lookup(&this->modifiers, (motion & 0xffffff)); // default-style fallback
if (mod_md != 0) {
int rc = state->add_modifier(motion, speed_mod); // adds to modifier_head list
if (rc == 0) {
// already has a modifier with this motion — stop it and re-add
StopSequenceMotion(this, motion, 1.0f, state, sequence, &num_out);
rc = state->add_modifier(motion, speed_mod);
}
if (rc != 0) {
combine_motion(sequence, mod_md, speed_mod); // BLEND velocity/omega into sequence
return 1;
}
}
}
}
```
**`combine_motion`** (line 298472, addr `00522580`) — adds the modifier's
velocity AND omega into the existing sequence via
`CSequence::combine_physics`. So **turn modifiers contribute their omega
on top of the substate's velocity**. This is how retail composes
"running while turning while strafing": three layers of physics
contributions in the same `CSequence`, animated by whichever layers
brought animations in.
---
## E. `is_allowed` — the gating predicate
Line **298526298548**, address `005226c0`. Determines whether an
incoming substate is legal in the current state.
```c
int CMotionTable::is_allowed(this, motion, motion_data, state) {
if (motion_data == 0) return 0;
if ((motion_data->bitfield & 2) != 0) { // requires "default substate"
if (motion != state->substate) {
// Look up the default substate for this style; legal only if state is in it
uint32_t default_substate;
LongNIValHash::lookup(&style_defaults, state->style, &default_substate);
return (default_substate == state->substate) ? 1 : 0;
}
}
return 1;
}
```
So a substate transition that requires the "ready" state (bitfield bit
1) will **fail** if the player is currently in a non-default substate.
This is the retail-correct way to block (for example) a Sit cycle
mid-Run — not a custom acdream "skip if airborne" hack.
---
## F. `re_modify` — the "re-attach modifiers after substate change"
Line **298300298328**, address `005222e0`. After a substate transition
that may have cleared modifiers, this walks the modifier list and
re-applies each via `GetObjectSequence`:
```c
void CMotionTable::re_modify(this, sequence, state) {
if (state->modifier_head == 0) return;
MotionState backup; // 298308
MotionState::MotionState(&backup, state);
while (i != 0) {
MotionList* mod = state->modifier_head;
uint32_t motion = mod->motion;
float speed = mod->speed_mod;
state->remove_modifier(mod, NULL);
backup.remove_modifier(i, NULL);
GetObjectSequence(this, motion, state, sequence, speed, &num_out, 0); // recurse
}
backup.~MotionState();
}
```
This is why turn + sidestep persist across forward-cycle transitions
(WalkForward → RunForward) — they are stored in `modifier_head` and
get re-blended every time the substate changes.
---
## G. Final critical answers
### G.1 — When `forward=RunForward` + `sidestep=SidestepRight` + `turn=TurnLeft` arrive in one UM, what cycle plays?
**All three layered.** Specifically, after `apply_interpreted_movement`
processes the UM:
1. `DoInterpretedMotion(current_style)` — re-asserts style; usually
no-op if unchanged.
2. `DoInterpretedMotion(0x44000007 RunForward, speed=runRate*1.0)`
`GetObjectSequence` takes the **substate** path (D.2). Replaces
prior substate. `state->substate = RunForward`.
3. `DoInterpretedMotion(0x6500000f SidestepRight, speed=1.248)`
`GetObjectSequence` takes the **modifier** path (D.4). Adds to
`modifier_head`, calls `combine_motion` to blend sidestep velocity
into the running `CSequence`. **Substate cycle is unchanged** (still
RunForward).
4. `DoInterpretedMotion(0x6500000d TurnLeft, speed=1.5)` — same as 3
but for turn. Blends turn omega into the sequence.
**Visual result**: the RunForward animation cycle plays. Sidestep and
turn contribute velocity/omega only (their cycles are typically motion-
data with `velocity != 0` and `omega != 0` but `num_anims == 0`
they're physics-only modifiers that don't override the running anim).
Some MotionTables may have animation content on sidestep/turn modifiers
for emphasis, in which case the bones get an additive blend.
### G.2 — Substate winner pick, sequential SetCycle, or Frame-level composition?
**Sequential `GetObjectSequence` calls per axis** (current_style →
forward → sidestep → turn), each mutating the same `CSequence` via:
- substate: `clear_physics + remove_cyclic_anims + add_motion(link) + add_motion(new)` (replace)
- modifier: `combine_motion` (additive blend) + `state->add_modifier` (track for re_modify)
- action: `clear_physics + remove_cyclic_anims + add_motion(link) + add_motion(current_substate)` (overlay-with-restore)
The final result is a single `CSequence` carrying:
- One looping substate cycle (animation + velocity/omega contribution)
- Zero or more queued action cycles (one-shot anims; auto-pop via
`MotionState::remove_action_head` on completion)
- Zero or more modifier cycles (additive velocity/omega; usually no
animation content)
There is **no priority pick**. There is **no Frame-level layering**
all three are blended into the single `CSequence`'s velocity/omega
fields by `add_motion`/`combine_motion` and the result is integrated
once per physics tick.
### G.3 — Does the `RunForward → WalkForward → Ready` fallback chain exist?
**No.** `GetObjectSequence` has only one fallback: when the cycle for
the current style isn't found, fall back to `default_style`'s version
(line 298842 in style-change branch, line 298872-298886 in action
branch via `style_defaults` lookup). If neither exists, return 0 and
the call has no effect.
The acdream fallback (`RunForward → WalkForward → Ready`) is a
**port artifact** that papers over the fact that we're not using
`MotionTableManager` — we're synthesizing cycle-anim association
directly from a hardcoded enum. **In a faithful port this fallback
goes away.**
### G.4 — For Action overlay packets, does retail leave the substate cycle running?
**Yes, exactly.** D.3 above:
```c
add_motion(sequence, link, speed_mod); // one-shot transition
add_motion(sequence, current_substate_md, state->substate_mod); // re-add running cycle
```
The `MotionState::action_head` queue tracks the active actions; the
sequence has both the action's transition anim AND the substate cycle
re-applied. When the action's one-shot anim completes,
`CSequence::CheckForCompletedMotions` (in `CPhysicsObj`) pops the
action and re-runs `apply_interpreted_movement` to restore pure
substate state.
---
## H. Acdream port implications
1. **Delete the priority cycle-picker** in `OnLiveMotionUpdated`. Replace
with a faithful port of `apply_interpreted_movement`: 4 sequential
`MotionTableManager.PerformMovement` calls (current_style, forward,
sidestep, turn) per UM.
2. **Delete the `RunForward → WalkForward → Ready` fallback chain**
entirely. If a MotionTable doesn't have a cycle, retail just
silently fails to transition — there is no fallback. Our fallback
is masking missing animation data.
3. **Port `MotionTableManager`** so we have an actual `MotionState`
(style + substate + substate_mod + modifier_head + action_head)
per remote object, and a `CMotionTable` lookup chain
(`cycles`/`modifiers`/`links`/`style_defaults`). The current
approach of "pick one cycle per UM and play it" cannot represent
modifier overlay correctly.
4. **Run-detection: WalkForward+HoldKey.Run → RunForward** must happen
in `adjust_motion` BEFORE the routing. Acdream's
`AnimationCommandRouter.Classify` runs after this transform —
correct in concept, but only if our outbound and inbound both
apply the transform consistently. (ACE does this on the outbound,
so inbound `0x07 RunForward` is post-adjusted.)
5. **Modifier physics**: `combine_motion` blends velocity AND omega
into a single `CSequence`. Acdream's `ObservedOmega` workaround
(audit doc 06 line 83) is a symptom of not blending omega into
the per-tick velocity properly. Once `MotionTableManager` is
ported, omega comes from `combine_motion` of TurnLeft's modifier
cycle and the `update_object` MinQuantum hack disappears.
6. **Sidestep direction collapse**: retail collapses
`SidestepLeft → SidestepRight (negative speed)` and
`TurnRight → TurnLeft (negative speed)` in `adjust_motion`. The
modifier list keys on the collapsed form. Acdream must do the
same to match the modifier-table lookups.
---
## I. Citation index
| Function | Address | File line |
|---|---|---|
| `CMotionInterp::DoMotion` | `00528d20` | 306159 |
| `CMotionInterp::DoInterpretedMotion` | `00528360` | 305575 |
| `CMotionInterp::adjust_motion` | `00528010` | 305343 |
| `CMotionInterp::apply_run_to_command` | `00527be0` | 305062 |
| `CMotionInterp::apply_interpreted_movement` | `00528600` | 305713 |
| `CMotionInterp::apply_raw_movement` | `005287e0` | 305817 |
| `CMotionInterp::apply_current_movement` | `00528870` | 305838 |
| `CPhysicsObj::DoInterpretedMotion` | `0050ea70` | 276348 |
| `CPartArray::DoInterpretedMotion` | `00518750` | 286772 |
| `MotionTableManager::PerformMovement` | `0051c0b0` | 290906 |
| `MotionTableManager::initialize_state` | `0051c030` | 290875 |
| `CMotionTable::GetObjectSequence` | `00522860` | 298636 |
| `CMotionTable::DoObjectMotion` | `00523e90` | 300045 |
| `CMotionTable::StopObjectMotion` | `00523ec0` | 300053 |
| `CMotionTable::StopSequenceMotion` | `00522fc0` | 298954 |
| `CMotionTable::SetDefaultState` | `005230a0` | 299004 |
| `CMotionTable::is_allowed` | `005226c0` | 298526 |
| `CMotionTable::get_link` | `00522710` | 298552 |
| `CMotionTable::re_modify` | `005222e0` | 298300 |
| `RawMotionState::ApplyMotion` | `0051eb60` | 293630 |
| `InterpretedMotionState::ApplyMotion` | `0051ea40` | 293531 |
| `MotionState::add_modifier` | `00526340` | 303081 |
| `MotionState::add_modifier_no_check` | `00525ff0` | 302772 |
| `MotionState::add_action` | `005260a0` | 302828 |
| `MotionState::clear_modifiers` | `00526070` | 302810 |
| `MotionState::remove_modifier` | `00526040` | 302794 |
| `add_motion` (free fn) | `005224b0` | 298437 |
| `combine_motion` (free fn) | `00522580` | 298472 |
| `subtract_motion` (free fn) | `00522600` | 298492 |
| Constant | Value | Meaning |
|---|---|---|
| `0x40000000` | flag | substate class bit (forward axis) |
| `0x10000000` | flag | action class bit |
| `0x20000000` | flag | modifier class bit |
| `0x44000007` | id | RunForward substate |
| `0x45000005` | id | WalkForward substate |
| `0x45000006` | id | WalkBackward substate (collapses to BackForward) |
| `0x40000011` | id | (referenced in jump path) |
| `0x40000015` | id | Stand substate |
| `0x41000003` | id | Ready substate |
| `0x6500000d` | id | TurnLeft modifier |
| `0x6500000e` | id | TurnRight modifier (collapses to TurnLeft) |
| `0x6500000f` | id | SidestepRight modifier |
| `0x65000010` | id | SidestepLeft modifier (collapses to SidestepRight) |
| `0x6500000f` (jump-charge) | id | charge_jump cycle |
| `0x8000003d` | id | "no style" sentinel (CombatMode_NonCombat default) |

View file

@ -0,0 +1,722 @@
# 14 — Acdream audit: LOCAL player motion (the actor side)
Date: 2026-05-04. Scope: every file that touches our own `+Acdream`
character's motion — the simulator that reads keyboard input, drives
`PhysicsBody` + `MotionInterpreter`, animates the player entity, and
broadcasts MoveToState / AutonomousPosition / JumpAction over the wire.
Counterpart to `06-acdream-audit.md` (remote / observed motion).
Inputs read in detail:
- `src/AcDream.App/Input/PlayerMovementController.cs` (885 LOC)
- `src/AcDream.App/Input/PlayerModeAutoEntry.cs`
- `src/AcDream.Core/Physics/PlayerWeenie.cs` (81 LOC)
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — local-player call
sites (`apply_current_movement`, `DoMotion`, `DoInterpretedMotion`,
`StopInterpretedMotion`, `LeaveGround`, `HitGround`, `jump`,
`get_jump_v_z`, `get_state_velocity`).
- `src/AcDream.Core.Net/WorldSession.cs` — outbound sequence counters,
`NextGameActionSequence`, `SendGameAction` plumbing.
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (165 LOC) — 0xF61C
packet builder.
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` (89 LOC) —
0xF753 packet builder.
- `src/AcDream.Core.Net/Messages/JumpAction.cs` — 0x... jump packet.
- `src/AcDream.App/Rendering/GameWindow.cs` — local-player wiring at
L51825337 (per-frame Update + outbound), L69567103
(`UpdatePlayerAnimation`), L26382656 (server RunRate echo path),
L79978062 (player-controller construction at world-entry).
Verdict labels: **PORT** (faithful retail port), **HACK**
(acdream-original logic, not in retail), **BROKEN** (regressed/wrong
vs the named-retail spec), **DIAG** (instrumentation only).
Retail counterparts cited from
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
---
## 1. `PlayerMovementController.cs` — top-level architecture
### 1.1 Structure (L89230)
| Field | Verdict | Notes |
|---|---|---|
| `PhysicsBody _body` | PORT | Constructed with `Gravity \| ReportCollisions`**NOTE** retail's local player also has Contact+OnWalkable on the transient side after first SetPosition. |
| `MotionInterpreter _motion` | PORT | Wires Body+Weenie. |
| `PlayerWeenie _weenie` | PORT-with-issue | RunSkill/JumpSkill from env vars (default 200 / 300). See §4. |
| `PhysicsEngine _physics` | PORT | Used for `ResolveWithTransition` collision sweep. |
| `Yaw`, `MouseTurnSensitivity` | HACK | Per-controller yaw float; retail stores body Orientation directly. Acdream maintains Yaw separately and rebuilds Orientation each frame (L322). |
| `StepUpHeight` / `StepDownHeight` | PORT | 0.4 m default; updated from `Setup.StepUpHeight` at world-entry (L80218035). Retail-faithful values. |
| `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150155 acknowledges this is a feel-tune). The vz formula is byte-exact to retail. |
| `_wasAirborneLastFrame` | PORT | Used for justLanded edge detection. |
| `_prev*` previous-frame command/speed snapshots | PORT-ish | Used to detect `MotionStateChanged`. Retail's `CommandInterpreter::SendMovementEvent` similarly diff-gates outbound MTS. **NOTE** retail compares the entire `RawMotionState` not just selected fields — see #2.2 below. |
| `_heartbeatAccum`, `HeartbeatInterval=1.0s` | PORT | Matches retail trace 2026-05-01 + holtburger AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL (1 Hz). **Better than the prior 200 ms guess** in CLAUDE.md. |
| `_physicsAccum` (L204) | PORT | L.5 retail 30 Hz physics-tick gate. Mirrors `update_object` MinQuantum behavior. Critical for slope/wall-bounce parity. |
### 1.2 `ApplyServerRunRate(float)` (L270274)
Called from `OnLiveMotionUpdated` at L2643 when an inbound UM addressed
to the local player carries `MotionState.ForwardSpeed`.
```csharp
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
```
**Verdict: HACK.** Retail's local-side flow:
- Server sends UM with the authoritative ForwardSpeed only when
ForwardCommand changes (apply_run_to_command echoes the speed back
via the outbound MTS).
- Retail does NOT directly stuff `ForwardSpeed` into `InterpretedState`
on inbound UM — the inbound is purely a bulk-copy into a
RawMotionState that drives the *animation sequencer*, not the
velocity feed. The local player's velocity comes from
`apply_current_movement` reading the user-input-driven InterpretedState.
This is acdream's solution to "ACE doesn't tell the client its real
RunRate at character spawn" — we adopt the first non-zero ForwardSpeed
ACE relays. Per CLAUDE.md the long-term fix is parsing
PlayerDescription's RunSkill (issue #7); the env var workaround
becomes legacy then.
**Side effect:** calling `apply_current_movement` here re-evaluates
`get_state_velocity` and writes `body.Velocity`, which can momentarily
override an in-flight jump's airborne velocity. The `allowJump:false`
flag mitigates it but the call is still racing against integration.
### 1.3 `SetPosition(pos, cellId)` (L276287)
```csharp
_body.Position = pos;
CellId = cellId;
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
_body.LastUpdateTime = 0.0;
```
**Verdict: PORT.** Mirrors retail's `CPhysicsObj::SetPositionInternal`
post-snap effects (acclient_2013_pseudo_c.txt, FUN_00516330). Used on
world-entry and PortalSpace exit.
### 1.4 `Update(float dt, MovementInput input)` — main 600-line loop
Main structure: portal-space gate → turning → motion state machine →
jump → integrate → collision resolve → bounce → ground/landing →
outbound commands → motion-change detection → heartbeat → animation.
#### 1.4.1 Portal-space gate (L294307)
**Verdict: PORT.** When `State==PortalSpace`, returns zero-movement
result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early
return.
#### 1.4.2 Turn input (L309322)
```csharp
if (input.TurnRight) Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
if (input.TurnLeft) Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
_body.Orientation = Quaternion.CreateFromAxisAngle(UnitZ, Yaw - PI/2);
```
**Verdict: HACK.** Retail's local turn rate is `(π/2) × TurnSpeed`
(matches the omega formula in `06-acdream-audit.md` §2). acdream uses
`WalkAnimSpeed × 0.5 = 1.56 rad/s ≈ 90°/s` — coincidentally close to
retail's `π/2 ≈ 90°/s` for TurnSpeed=1.0, but the constant is wrong-
rooted (re-derivation through animation-speed table is a coincidence).
The mouse-turn path is entirely acdream-original; retail handles mouse
look via `CommandInterpreter::IssueAxisCommand` driving turn commands,
NOT direct yaw mutation.
#### 1.4.3 Motion state machine (L324411) — body velocity per input
Determines `forwardCmd` + `forwardCmdSpeed` from input, then:
```csharp
_motion.DoMotion(forwardCmd, forwardCmdSpeed); // → InterpretedState
if (input.StrafeRight) _motion.DoInterpretedMotion(SideStepRight, 1f, …);
if (input.StrafeLeft) _motion.DoInterpretedMotion(SideStepLeft, 1f, …);
if (_body.OnWalkable)
{
var stateVel = _motion.get_state_velocity();
float localY = …, localX = …; // hand-rolled body-local velocity
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
```
**Verdict: HACK.** Retail's correct flow is:
1. Input → `CMotionInterp::DoMotion` (FUN_00529930) →
`apply_run_to_command` (FUN_00527be0) →
`DoInterpretedMotion` (FUN_00528f70) → `apply_current_movement`
(FUN_00529210) → `set_local_velocity(get_state_velocity())`.
2. `get_state_velocity` (FUN_00528960) reads InterpretedState
directly, returning body-local
`(SidestepAnimSpeed × SideStepSpeed, RunAnimSpeed × ForwardSpeed, 0)`.
acdream calls `DoMotion` (which DOES call `apply_current_movement`
internally → writes body.Velocity correctly) — and then OVERWRITES
that body velocity at L410 with a hand-rolled local-frame vector.
The hand-rolled block is acdream's workaround for **WalkBackward and
SideStepLeft** producing zero velocity in `get_state_velocity` because
the retail port omitted `adjust_motion` (FUN_00528010), which retail
runs *before* InterpretedState writes:
```
WalkBackwards → WalkForward + speed × -0.65
SideStepLeft → SideStepRight + speed × -1
```
**Critical impact for the L.3 audit:** the local player's body.Velocity
**IS** non-zero on every grounded frame (correctly so for the local
player — opposite of remotes), but it's set by *acdream's hand-rolled
block at L410 rather than by `MotionInterpreter`*. Per the L.3 spec and
named-retail source, the local-side velocity should come from
`get_state_velocity` only, after `adjust_motion` translates the
backward/strafe-left commands.
The **right fix** is to port `adjust_motion` (FUN_00528010) into
`MotionInterpreter` and remove L378411 entirely, letting `DoMotion`
do its thing.
#### 1.4.4 Jump path (L413505)
**Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).**
The jump-charge logic at L420428 (accumulate while held + on ground,
fire on release) matches retail `Event_Jump`'s charge-bar pattern.
The fire path:
1. `_motion.jump(extent)` — validates via retail FUN_00529390. PORT.
2. `_motion.get_jump_v_z()` — reads vz before LeaveGround zeroes
extent (matches retail FUN_00529710 invocation order). PORT.
3. `_motion.LeaveGround()` — clears Contact+OnWalkable, sets Gravity,
calls get_state_velocity into body.Velocity. PORT.
4. **Then acdream re-writes `outJumpVelocity`** (L466501) with a
manually-computed body-local jump velocity that includes
backward/strafe-left, working around the same adjust_motion gap as
§1.4.3. Hand-rolled mirror of L378411 logic.
The comment at L443460 acknowledges this explicitly: "Until
adjust_motion is ported, we mirror the grounded-velocity computation."
#### 1.4.5 Physics integration + 30 Hz gate (L507535)
```csharp
_physicsAccum += dt;
if (_physicsAccum > HugeQuantum) _physicsAccum = 0f; // stale
else if (_physicsAccum >= MinQuantum)
{
float tickDt = MathF.Min(_physicsAccum, MaxQuantum);
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
}
```
**Verdict: PORT.** This is the L.5 retail-physics-tick gate from
2026-04-30, reverse-engineered via cdb attach to retail. Effectively
clamps physics integration to 30 Hz even at 60+ Hz render.
**Mirrors retail's `update_object` MinQuantum behavior precisely.**
#### 1.4.6 Collision resolve (L538574)
```csharp
var resolveResult = _physics.ResolveWithTransition(
preIntegratePos, postIntegratePos, CellId,
sphereRadius: 0.48f, sphereHeight: 1.2f,
stepUpHeight, stepDownHeight,
isOnGround: _body.OnWalkable,
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
```
**Verdict: PORT.** Sphere dimensions match retail human Setup;
`IsPlayer | EdgeSlide` matches retail PhysicsGlobals.DefaultState for
players.
#### 1.4.7 Wall-bounce / velocity reflection (L578686)
Implements retail's `handle_all_collisions` velocity-reflection:
`v_new = v - (1 + elasticity) × dot(v, n) × n`. Sources cited:
acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:2656-2721.
**Verdict: PORT-with-conservative-rule.** The `applyBounce` gating at
L638640 is more restrictive than retail's strict
`!(prev && now && !sledding)` — acdream additionally suppresses
bounce on the airborne→grounded landing transition because the
post-reflection upward Z would defeat acdream's per-frame
`Velocity.Z<=0` landing-snap gate. Retail tolerates this because
elasticity=0.05 is visually imperceptible there; acdream's per-frame
architecture amplifies it. Documented in the comment at L630637.
#### 1.4.8 Ground/landing detection (L688720)
```csharp
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
{
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= Contact | OnWalkable;
if (_body.Velocity.Z < 0f) _body.Velocity.Z = 0f;
if (wasAirborne) { _motion.HitGround(); justLanded = true; }
}
else { _body.TransientState &= ~(Contact | OnWalkable); }
```
**Verdict: PORT.** Mirrors retail `MoveOrTeleport` post-resolution
landing state machine + `CMotionInterp::HitGround` call on
airborne→grounded transition.
#### 1.4.9 Outbound wire commands (L725795)
Builds `outForwardCmd / outForwardSpeed / outSidestepCmd /
outTurnCmd / localAnimCmd`:
```csharp
if (input.Forward)
{
outForwardCmd = MotionCommand.WalkForward;
if (input.Run && _weenie.InqRunRate(out var rr))
{
outForwardSpeed = rr;
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
}
else { outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkForward; }
}
else if (input.Backward) { outForwardCmd = WalkBackward; outForwardSpeed = 1.0f; … }
```
**Verdict: PORT-with-ACE-quirk.** The `WalkForward + HoldKey.Run`
encoding (rather than direct `RunForward`) is documented at L2634 of
the file: it's a workaround for ACE's `MovementData` only computing
`interpState.ForwardSpeed` for raw WalkForward/WalkBackward. ACE then
auto-upgrades to RunForward on broadcast.
**Retail wire format: this is correct.** Retail's
`CommandInterpreter::SendMovementEvent` builds the MoveToState the
same way — sends WalkForward with HoldKey.Run for run intent,
RunForward only for explicit run-toggle. Confirmed via 2026-05-01 cdb
trace.
The `localAnimCmd` divergence is acdream-original but **necessary**
because the local sequencer wants RunForward immediately (for visual
parity), not the wire's WalkForward.
#### 1.4.10 Motion-change detection (L797831)
```csharp
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| localAnimCmd != _prevLocalAnimCmd;
```
**Verdict: PORT-ish.** Retail's `CommandInterpreter::SendMovementEvent`
diffs against the *previously-sent* RawMotionState, sending an MTS
only when the new state is non-equal. acdream's diff is field-selective
but covers the load-bearing fields. The `localAnimCmd` field is not in
retail's RawMotionState — including it forces a fresh outbound on
Walk↔Run toggle (W held + Shift toggle), which retail also does because
the toggle changes the wire ForwardSpeed. So the net effect matches.
**Subtle issue:** if the user only changes SidestepSpeed or TurnSpeed
(without changing the corresponding command), no outbound fires. Retail
likely doesn't either; not a regression.
#### 1.4.11 Heartbeat (L833845)
```csharp
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= 1.0f;
if (HeartbeatDue) _heartbeatAccum = 0f;
```
**Verdict: PORT.** 1 Hz cadence matches retail trace 2026-05-01 +
holtburger. Caller (`GameWindow`) reads `HeartbeatDue` and fires
AutonomousPosition.
**OPEN QUESTION** flagged in CLAUDE.md memory: retail's
`SendPositionEvent` gates the heartbeat on `transient_state` (must
have Contact+OnWalkable+valid Position) AND on motion. acdream's
1 Hz at-rest heartbeat is unconditional once in-world. Retail's cdb
trace showed AutonomousPosition gated on motion — i.e. **acdream
sends AP heartbeats while standing still, retail does not**. Probable
mismatch worth investigating. Filed in `project_retail_motion_outbound.md`.
---
## 2. `MoveToState.cs` (Messages/MoveToState.cs, 165 LOC)
**Verdict: PORT.** Wire layout matches holtburger
`RawMotionState::pack` + `MoveToStateActionData::pack`:
| Field | Verdict | Notes |
|---|---|---|
| GameAction envelope (0xF7B1, seq, 0xF61C) | PORT | |
| Flags dword (bits 010 fields, bits 1131 cmd-list-len=0) | PORT | We never send commands. |
| Conditional fields in flag-bit order | PORT | CurrentHoldKey, ForwardCommand, ForwardHoldKey, ForwardSpeed, SidestepCommand/HoldKey/Speed, TurnCommand/HoldKey/Speed. CurrentStyle (0x2) intentionally not sent — we don't track stance changes here (handled via separate ChangeCombatMode). |
| WorldPosition: cellId u32, x/y/z f32, rotW/rotX/rotY/rotZ f32 | PORT | Quaternion wire order W,X,Y,Z confirmed. |
| Sequences: u16 instance/serverControl/teleport/forcePosition | PORT | |
| Contact byte u8 + 4-byte align | PORT | |
**Open question 1:** retail builds the MoveToState with the FULL
`RawMotionState` from the local CMotionInterp, including
`AftCommand`/`AftSpeed`/`AftHoldKey` axes for sailing/swimming. We
omit those flags — they're never set by `PlayerMovementController`.
For walking that's fine; if we ever ship swimming/sailing this needs
extending.
**Open question 2:** `CurrentStyle` (0x2) — when the player changes
combat stance (e.g. Sword), does retail emit it as a separate
ChangeCombatMode + MoveToState pair, or does the MoveToState itself
carry the new style? Holtburger sends it via RawMotionState. acdream's
ChangeCombatMode path (`SendChangeCombatMode`) sends a separate
GameAction. **Cross-check needed**, but not load-bearing for the L.3
audit.
---
## 3. `AutonomousPosition.cs` (Messages/AutonomousPosition.cs, 89 LOC)
**Verdict: PORT.** Simpler than MoveToState: GameAction envelope +
WorldPosition + 4 sequences + lastContact byte + align.
Wire layout matches holtburger
`AutonomousPositionActionData::pack`. Used as the 1 Hz heartbeat.
---
## 4. `PlayerWeenie.cs` (81 LOC)
**Verdict: PORT (algorithm) + HACK (data source).**
`GetRunRate(burden, runSkill)` and `GetJumpHeight(burden, jumpSkill,
extent)` formulas are byte-for-byte from decompiled `acclient.exe` +
ACE `MovementSystem.GetRunRate / GetJumpHeight`:
```
RunRate = (burdenMod × (runSkill / (runSkill+200) × 11) + 4) / 4 (cap 4.5 at skill 800)
JumpHeight = burdenMod × (jumpSkill/(jumpSkill+1300) × 22.2 + 0.05) × extent
(clamp to 0.35 m min)
vz = sqrt(jumpHeight × 19.6)
```
**HACK side:** the constructor reads RunSkill/JumpSkill from env vars
(default 200 / 300). Per CLAUDE.md these are **NOT synced to the
server** — ACE has its own canonical RunSkill which it broadcasts in
`UpdateMotion.ForwardSpeed`. We currently echo via
`PlayerMovementController.ApplyServerRunRate` (§1.2), which DIRECTLY
overwrites `InterpretedState.ForwardSpeed` rather than updating the
weenie's RunSkill. So our local-prediction velocity may diverge from
the server's authoritative value mid-tick if the server's RunRate
differs from `(loadMod × runSkill/(runSkill+200) × 11 + 4)/4`.
**Long-term fix:** parse `PlayerDescription` (0xF7B0/0x0013), extract
RunSkill, call `_weenie.SetSkills(serverRun, serverJump)`. Filed
issue #7 in CLAUDE.md.
---
## 5. `MotionInterpreter` local-player call sites
Local-player call sites of `apply_current_movement`:
- `PlayerMovementController.cs:273` (`ApplyServerRunRate`) — see §1.2.
HACK.
- `PlayerMovementController.cs` indirectly via `DoMotion`
`DoInterpretedMotion``apply_current_movement`. PORT.
- `PlayerMovementController.cs` jump path via `LeaveGround`. PORT.
- `MotionInterpreter` internally inside `DoInterpretedMotion`,
`StopInterpretedMotion`, `HitGround`. PORT.
For the local player **`body.Velocity` is correctly non-zero per tick**
— driven by user input. This is the OPPOSITE of the L.3 invariant for
remotes and matches the named-retail spec.
---
## 6. `GameWindow.cs` local-player wiring
### 6.1 World-entry construction (L79978062)
```csharp
_playerController = new PlayerMovementController(_physicsEngine);
if (_lastSeenRunSkill > 0) _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
_playerController.StepUpHeight = playerSetup?.StepUpHeight ?? 0.4f;
_playerController.StepDownHeight = playerSetup?.StepDownHeight ?? 0.4f;
_playerController.SetPosition(initResult.Position, initResult.CellId);
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
_playerController.Yaw = rawYaw + MathF.PI/2;
```
**Verdict: PORT.** StepUp/Down come from Setup; cycle-velocity
accessor wires the AnimationSequencer into `MotionInterpreter`.
### 6.2 Per-frame Update loop (L51825337)
Reads input → `_playerController.Update(dt, input)` → updates entity
position+rotation → updates ChaseCamera → builds outbound MoveToState
(if MotionStateChanged) + AutonomousPosition (if HeartbeatDue) +
JumpAction (if jump fired) → calls `UpdatePlayerAnimation`.
**Verdict: PORT.** Wire-output gating is correct: change-driven MTS,
heartbeat AP, event-driven Jump. Cell ID composition and world→wire
conversion match retail.
The HoldKey block at L52665288 sends per-axis hold keys for every
active axis (forward/sidestep/turn) using the same value. Per
holtburger comment at L876: this is correct — retail uses the same
HoldKey value across all active axes.
### 6.3 `UpdatePlayerAnimation` (L69567103)
Computes the visible animation cycle from MovementResult:
```
Airborne → MotionCommand.Falling
Forward+Run → RunForward (LocalAnimationCommand)
Forward → WalkForward
Backward → WalkBackward
Sidestep → SideStepLeft/Right
Turn → TurnLeft/Right
else → Ready
```
Drives `ae.Sequencer.SetCycle(NonCombatStance, animCommand,
animSpeed × animScale, skipTransitionLink: airborne)`.
**Verdict: PORT.** Mirrors retail `MotionTable::SetState` /
`Sequence::SetCycle`. The `LocalAnimationSpeed` decoupling
(forward+run = runRate; backward+run / strafe+run = runRate too even
though wire ForwardSpeed=1.0) is acdream-original but correct: it
ensures the visible cycle pace matches the actual movement velocity
even when the wire format keeps backward/strafe at 1.0 for ACE
compat.
**Skip-link on Falling** (L7091): retail-faithful — without it, the
local player visibly stood still for ~100 ms at the start of every
jump while the RunForward→Falling transition link drained.
---
## 7. Shared-with-remote-player code paths
| File / function | Used by local? | Used by remote? | Concern? |
|---|---|---|---|
| `PhysicsBody` (full) | YES | YES | NO — same retail port; both sides write Velocity / Orientation correctly per their respective sources. |
| `MotionInterpreter` (full) | YES | YES | YES — `apply_current_movement` is the converging point. Local writes body.Velocity from user input (correct). Remote writes body.Velocity from `InterpretedState.ForwardCommand+ForwardSpeed` (incorrect per L.3 spec — see audit 06 §8). After L.3 lands, only the local player path will call `apply_current_movement` per tick; remotes will be anim-root-motion driven. |
| `AnimationSequencer` (full) | YES | YES | NO — both sides drive `SetCycle` from the appropriate input source. |
| `PhysicsEngine.ResolveWithTransition` | YES | YES | NO — collision sweep with sphere dims + step heights; identical for both. **Both paths must keep this** post-L.3 (audit 06 §6 step 4b). |
| `AnimatedEntity.Sequencer` | YES | YES | NO — sequencer is per-entity. Local UpdatePlayerAnimation writes its own; remote OnLiveMotionUpdated writes the remote's. Independent. |
| `PlayerWeenie` | YES | NO | NO — local-only. Remotes don't have a weenie; their MotionInterpreter fall back to default 1.0 RunRate via `if (weenie==null) x87_r7 = 1f` in retail's apply_run_to_command. |
| `PhysicsEngine.ShadowObjects` | NO | YES | NO — shadow tracking is for cell-list updates, not local-player. |
| `RemoteMotion` struct + dead-reckon table | NO | YES | NO — remote-only. |
**Key convergence point:** `MotionInterpreter.apply_current_movement`.
Local needs it (per tick, driven by user input). Remote should NOT
call it per tick (per L.3 spec). The two paths share the function but
diverge on call frequency.
After L.3 lands:
- Local: `_playerController.Update``_motion.DoMotion`
`apply_current_movement` (writes body.Velocity from user input).
**Once per frame.** Per CLAUDE.md ACE wire-format quirks, no
changes needed.
- Remote: per-tick reads `sequencer.CurrentVelocity` directly via
`PositionManager.ComputeOffset`. **Never calls
`apply_current_movement`.** body.Velocity stays 0 for grounded
remotes. `apply_current_movement` still fires on `OnLiveMotionUpdated`
for axis-state setup, but not per tick.
---
## 8. Specific question answers
**(a) Does `PlayerMovementController` mirror retail's pipeline
(`apply_current_movement → integrate → collision sweep`)?**
YES, partially. The pipeline order is correct: input →
DoMotion (which calls apply_current_movement internally) → integrate
(`UpdatePhysicsInternal`) → ResolveWithTransition. **But it then
*overwrites* the velocity with hand-rolled body-local code at L378411
to work around the missing `adjust_motion` port** for backward/
strafe-left. The same workaround appears in the jump path at L466501.
The pipeline shape matches retail; the velocity-source-of-truth diverges.
**(b) Does it use `m_velocityVector` correctly? (Local DOES integrate
velocity, unlike remotes.)**
YES — local body.Velocity is intentionally non-zero per tick, driven
by user input via `set_local_velocity`. Then
`UpdatePhysicsInternal` integrates `Position += Velocity*dt + 0.5*A*dt²`.
This matches retail's local-player model.
**(c) Does the outbound MoveToState packet match retail's wire format?**
YES. Wire layout in `Messages/MoveToState.cs` is byte-faithful to
holtburger's `RawMotionState::pack` + `MoveToStateActionData::pack`,
which is itself ground-truth from a working Rust client. Per-axis
HoldKey (forward/sidestep/turn) is sent. CurrentStyle (0x2) is
omitted intentionally; OK for walking. AftCommand/AftSpeed/AftHoldKey
not sent — fine until swimming/sailing ships.
**(d) Is the local sequencer driven by `UpdatePlayerAnimation`
matching retail's `MotionTable::SetState`?**
YES. The cycle picker (Airborne→Falling > LocalAnim > wire forward >
sidestep > turn > Ready), `NonCombatStance` overlay, sequencer
`SetCycle` invocation all mirror retail. Speed decoupling
(LocalAnimationSpeed vs wire ForwardSpeed) is acdream-original but
correct for ACE-quirk-driven backward/strafe pacing.
**(e) Are there any acdream-specific hacks/workarounds in the local
player path?**
YES. Five distinct hacks, all acknowledged in code comments:
1. **Hand-rolled velocity overrides at L378411 + L466501**
workaround for missing `adjust_motion` port. Both grounded and jump
paths are affected. **Top priority to fix** alongside the L.3
refactor; same root cause as remote-side `apply_current_movement`
issues.
2. **`ApplyServerRunRate`** (§1.2) — directly stuffs server's
ForwardSpeed into InterpretedState. Should be replaced with
PlayerDescription parse → `_weenie.SetSkills(...)`.
3. **Yaw float + mouse-turn sensitivity** — retail uses turn commands
from `IssueAxisCommand` for ALL turn input (keyboard + mouse). We
maintain a separate Yaw and rebuild Orientation each frame.
4. **`JumpChargeRate = 2.0/s`** — retail divisor unrecovered from
PDB; tuned for feel. Cited in code comment at L150155.
5. **Wall-bounce landing suppression** (L638640) — more
conservative than retail's strict rule, justified by acdream's
per-frame architecture amplifying micro-bounce on landing.
**(f) How is the local player's runRate sourced?**
Three layers, in priority order:
1. `_lastSeenRunSkill` from PlayerDescription parse (issue #7, NOT
YET WIRED — code at L1565 is dormant).
2. `ACDREAM_RUN_SKILL` env var (default 200) → constructor → weenie
formula.
3. `ApplyServerRunRate(forwardSpeed)` echo from inbound UM —
overwrites `InterpretedState.ForwardSpeed` directly, bypassing the
weenie formula.
**Retail-faithful?** PARTIALLY. The formula
`(loadMod × runSkill/(runSkill+200) × 11 + 4)/4` is byte-exact
retail. But the data source is wrong: retail's local
`CWeenieObject::InqRunRate` reads the player's actual server-synced
RunSkill, not an env var. Until issue #7 ships, low-skill characters
(< 200) and high-skill characters (> 800) will mispredict.
**(g) Does the local player have any `IsPlayerGuid`-style gates that
would also need cleanup?**
The local-player path AT THE PER-FRAME UPDATE level is gated only by
`_playerMode && _playerController != null`, which is appropriate
(it's the actor side). The IsPlayerGuid gates in audit 06
(`OnLivePositionUpdated`, `OnLiveMotionUpdated`, etc.) all SKIP
the local player guid (e.g. `if (update.Guid == _playerServerGuid)
return;` patterns) — that's correct because the local player's state
is owned by `_playerController`, not by the dead-reckoning struct.
The only place where IsPlayerGuid logic touches local state is
`ApplyServerRunRate` (§1.2): an inbound UM addressed to the local
player echoes ForwardSpeed. That's fine; not a gate to remove.
**No IsPlayerGuid gates to clean up on the actor side.** All cleanup
in audit 06 is on the observer/remote side.
---
# Summary
## (a) Is local player motion already retail-faithful?
**Mostly yes, with two known divergences.** The pipeline shape
(input → DoMotion → integrate → collision sweep → outbound MTS) is
retail-faithful and well-cited. The 30 Hz physics-tick gate, jump
charge formula, wall-bounce reflection, ground/landing detection,
HoldKey wire encoding, and 1 Hz heartbeat all match named-retail and
the 2026-05-01 cdb trace. The outbound MoveToState + AutonomousPosition
packet builders are byte-faithful to holtburger's reference
implementation.
The two divergences:
1. **Hand-rolled velocity overrides** (L378411, L466501) work around
the missing `adjust_motion` (FUN_00528010) port. Backward/
strafe-left commands are translated to WalkForward/SideStepRight
with negative speeds in retail before they reach InterpretedState;
acdream skips the translation and re-derives the velocity manually
downstream. Result is correct but architecturally diverged.
2. **RunSkill data source** is env var (default 200) plus
ApplyServerRunRate echo, instead of the server's authoritative
PlayerDescription value. Causes mispredicted local velocity for
non-default skill characters.
Both are pre-existing tech debt, not L.3-specific. The local-player
audit found NO L.3-introduced regressions analogous to the remote-side
`apply_current_movement`-per-tick bug.
## (b) Top 3 things that need to change
1. **Port `adjust_motion` (FUN_00528010) into `MotionInterpreter`.**
Translates WalkBackwards → WalkForward + speed×-0.65 and
SideStepLeft → SideStepRight + speed×-1 BEFORE InterpretedState
write. Once present, `get_state_velocity` returns correct vectors
for all motion commands and the hand-rolled overrides at L378411
+ L466501 can be deleted. Same fix benefits the jump path.
2. **Wire `PlayerDescription` (0xF7B0 / 0x0013) RunSkill+JumpSkill
`_weenie.SetSkills(...)`** (issue #7). Removes the env var
workaround and `ApplyServerRunRate` becomes a no-op (the weenie's
formula already produces the correct RunRate from the server's
skill). Velocity-prediction parity for arbitrary characters.
3. **Rationalise the heartbeat gate.** Retail's
`SendPositionEvent` gates AutonomousPosition on motion state
(Contact + OnWalkable + active velocity); acdream sends 1 Hz
unconditionally while in-world. Cdb trace 2026-05-01 confirmed
retail does not heartbeat at rest. Filing this — wasted
bandwidth + observer dead-reckon noise. Low-stakes vs (1) and
(2), but a clean behavioral diff worth fixing.
## (c) Shared-with-remote code paths that need to converge
`MotionInterpreter.apply_current_movement` is the convergence point.
Local must call it per tick (correct, retail-faithful); remote must
NOT (per L.3 spec). The function itself is fine; the call-site
discipline is what matters.
After the L.3 port:
- `PhysicsBody`, `AnimationSequencer`, `PhysicsEngine.ResolveWithTransition`,
`MotionInterpreter` (the type, not its per-tick invocation) all stay
shared and correct.
- `apply_current_movement`: local-player call (per tick + on UM echo)
remains; remote-player per-tick call gets removed (currently at
GameWindow.cs:6599).
- The shared `CurrentVelocity` accessor on `AnimationSequencer` (wired
to local via `AttachCycleVelocityAccessor`) gets a parallel
`PositionManager.ComputeOffset` consumer for remotes — same field,
different driver.
**No symmetry break required.** Local and remote can share the type
hierarchy; they diverge only on which functions they invoke per
tick. The L.3 port doesn't perturb the local-player path at all
beyond optionally fixing items (1)-(3) above as side improvements.
---
Path: `docs/research/2026-05-04-l3-port/14-local-player-audit.md`.

View file

@ -7,32 +7,34 @@ namespace AcDream.Core.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// InterpolationManager — retail CPhysicsObj interpolation queue.
//
// Ports:
// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
// InterpolationManager::adjust_offset (acclient @ 0x00555D30)
// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip
// Source spec: docs/research/2026-05-04-l3-port/04-interp-manager.md
// Retail addresses (Sept-2013 EoR PDB):
// InterpolationManager::InterpolateTo acclient @ 0x00555B20
// InterpolationManager::adjust_offset acclient @ 0x00555D30
// InterpolationManager::UseTime acclient @ 0x00555F20
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
// InterpolationManager::StopInterpolating acclient @ 0x00555950
//
// FIFO position-waypoint queue (cap 20). On each physics tick the caller
// passes current body position + max-speed from the motion table; we return
// the delta vector to apply to the body for this frame.
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
// current body position + max-speed from the motion table; we return the
// world-space delta vector to apply to the body for this frame.
//
// Queue semantics:
// - Head = next target. Body walks toward head at catch-up speed.
// - Tail = most-recent server position. On stall we blip directly to tail
// (retail UseTime @ 0x00555F20: copies tail_ position, calls
// CPhysicsObj::SetPositionSimple, then StopInterpolating).
// Public C# API kept Vector3-based for compatibility with PositionManager and
// GameWindow callsites; retail-spec method names are documented inline. The
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
// queue's purposes — see audit 04-interp-manager.md § 4.
//
// Constants verified from named binary at the addresses cited above (not
// guesses):
// MAX_INTERPOLATED_VELOCITY_MOD = 2.0
// MAX_INTERPOLATED_VELOCITY = 7.5
// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters)
// DESIRED_DISTANCE = 0.05
// Bug fixes applied vs prior port (audit § 7):
// #1: progress_quantum accumulates dt (not step magnitude).
// #3: far-branch Enqueue sets node_fail_counter = 4 → immediate next-tick
// blip-to-tail. Triggered by distance > AutonomyBlipDistance (100 m).
// #4: secondary stall test ports the retail formula verbatim:
// cumulative_progress / progress_quantum / dt < 0.30.
// #5: tail-prune is a tail-walking loop (collapses multiple stale entries).
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Waypoint used internally by <see cref="InterpolationManager"/>.
/// </summary>
/// <summary>Internal queue node. type=1 = Position waypoint (only kind we use).</summary>
internal sealed class InterpolationNode
{
public Vector3 TargetPosition;
@ -49,281 +51,339 @@ public sealed class InterpolationManager
{
// ── public constants (retail binary values) ───────────────────────────────
/// <summary>Maximum waypoints held before oldest is dropped.</summary>
/// <summary>Maximum waypoints held before oldest (head) is dropped.</summary>
public const int QueueCap = 20;
/// <summary>
/// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier.
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30).
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122).
/// </summary>
public const float MaxInterpolatedVelocityMod = 2.0f;
/// <summary>
/// Fallback catch-up speed (m/s) when motion-table max speed is
/// unavailable (zero/tiny).
/// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30).
/// unavailable. Retail MAX_INTERPOLATED_VELOCITY (@ 0x40f00000 line 353137).
/// </summary>
public const float MaxInterpolatedVelocity = 7.5f;
/// <summary>
/// Per-5-frame stall progress threshold (meters). Body must advance at
/// least this far in <see cref="StallCheckFrameInterval"/> frames or
/// the window counts as a stall.
/// Per-5-frame stall progress threshold (meters).
/// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
/// </summary>
public const float MinDistanceToReachPosition = 0.20f;
/// <summary>
/// Reach + duplicate-prune radius (meters). Node is popped when
/// distance to its target falls below this value; new enqueues within
/// this distance of the tail are ignored.
/// Reach + duplicate-prune radius (meters).
/// Retail DESIRED_DISTANCE (@ 0x00555D30).
/// </summary>
public const float DesiredDistance = 0.05f;
/// <summary>
/// Number of ticks between stall progress checks.
/// Number of ticks per stall progress check window.
/// Retail frame_counter threshold (@ 0x00555E14).
/// </summary>
public const int StallCheckFrameInterval = 5;
/// <summary>
/// Minimum fraction of cumulative progress_quantum that counts as "real
/// progress" in a stall check window. Below this fraction the window
/// counts as a stall (secondary check, applies when progress_quantum > 0).
/// Secondary stall ratio threshold — port verbatim from retail.
/// Audit notes the formula has odd units (1/sec); not our bug to fix.
/// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
/// </summary>
public const float StallProgressMinFraction = 0.30f;
/// <summary>
/// Stall-fail counter threshold. The body is blipped to the tail of the
/// queue when <c>node_fail_counter</c> EXCEEDS this value (i.e., on the
/// 4th consecutive failed window, not the 3rd).
/// Retail: <c>node_fail_counter &gt; 3</c> (@ 0x00555F39).
/// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this
/// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail &gt; 3.
/// </summary>
public const int StallFailCountThreshold = 3;
// ── internals ─────────────────────────────────────────────────────────────
private readonly LinkedList<InterpolationNode> _queue = new();
/// <summary>Frames elapsed since the last 5-frame stall-check window fired.</summary>
private int _framesSinceLastStallCheck = 0;
/// <summary>
/// Distance threshold (meters) above which an Enqueue is treated as a far
/// jump and pre-arms an immediate blip. Retail outdoor value; indoor is
/// 20 m. Bug #3 fix from audit § 7.
/// </summary>
public const float AutonomyBlipDistance = 100.0f;
/// <summary>
/// Cumulative sum of per-frame <c>step</c> magnitudes within the current
/// 5-frame window. Retail <c>progress_quantum</c>.
/// Sentinel for original_distance before the first window baseline is
/// taken. Retail value (@ 0x00555D30 ctor) is 999999f.
/// </summary>
private float _progressQuantum = 0f;
public const float OriginalDistanceSentinel = 999999f;
/// <summary>
/// Distance to the head node recorded at the START of the current
/// 5-frame window. Retail <c>original_distance</c>.
/// </summary>
private float _distanceAtWindowStart = 0f;
private const float FEpsilon = 0.0002f;
/// <summary>
/// True once the first valid distance sample has been taken and
/// <c>_distanceAtWindowStart</c> is populated. Guards against the
/// first-window false-positive that occurs when the field defaults to 0.
/// </summary>
private bool _haveBaselineDistance = false;
// ── internals (retail field names in comments) ────────────────────────────
/// <summary>
/// Number of consecutive 5-frame windows that failed both the absolute
/// and ratio progress checks. Retail <c>node_fail_counter</c>.
/// Blip fires when this EXCEEDS <see cref="StallFailCountThreshold"/>.
/// </summary>
private int _failCount = 0;
private readonly LinkedList<InterpolationNode> _queue = new(); // position_queue
private int _frameCounter = 0; // frame_counter
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
private float _originalDistance = OriginalDistanceSentinel; // original_distance
private int _failCount = 0; // node_fail_counter
// ── public API ────────────────────────────────────────────────────────────
/// <summary>True when the queue holds at least one waypoint.</summary>
public bool IsActive => _queue.Count > 0;
/// <summary>
/// Current waypoint count (visible to the test assembly for cap verification).
/// </summary>
/// <summary>Current waypoint count (visible to tests for cap verification).</summary>
internal int Count => _queue.Count;
/// <summary>
/// Stop interpolating: clear the queue and reset all stall counters.
/// Retail StopInterpolating / destructor cleanup.
/// Stop interpolating: drain queue and reset all stall state to sentinel
/// values. Retail StopInterpolating (@ 0x00555950).
/// </summary>
public void Clear()
{
_queue.Clear();
_framesSinceLastStallCheck = 0;
_frameCounter = 0;
_progressQuantum = 0f;
_distanceAtWindowStart = 0f;
_haveBaselineDistance = false;
_originalDistance = OriginalDistanceSentinel;
_failCount = 0;
}
/// <summary>
/// Enqueue a new server-authoritative position waypoint.
///
/// <para>
/// Step 1: Duplicate-prune — if the new target is within
/// <see cref="DesiredDistance"/> of the current tail, ignore it.<br/>
/// Step 2: Cap — if the queue is already at <see cref="QueueCap"/>,
/// drop the oldest (head) entry.<br/>
/// Step 3/4: Append a new <see cref="InterpolationNode"/>.
/// </para>
///
/// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0).
/// Enqueue a new server-authoritative waypoint. Implements retail
/// <c>InterpolateTo</c> branching:
/// <list type="bullet">
/// <item><b>Already-close</b>: if distance(body, target) ≤
/// <see cref="DesiredDistance"/>, queue is wiped (StopInterpolating)
/// and no node is enqueued.</item>
/// <item><b>Far</b>: if distance(reference, target) &gt;
/// <see cref="AutonomyBlipDistance"/>, enqueue and set
/// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an
/// immediate blip on the next AdjustOffset call.</item>
/// <item><b>Near</b>: tail-prune loop collapses adjacent stale entries
/// within <see cref="DesiredDistance"/>; cap at 20 (head eviction);
/// enqueue.</item>
/// </list>
/// </summary>
/// <param name="targetPosition">Server-reported world position.</param>
/// <param name="heading">Server-reported heading (radians, AC convention).</param>
/// <param name="isMovingTo">True when the body is in motion — gates heading validity.</param>
public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo)
/// <param name="heading">Server-reported heading (radians).</param>
/// <param name="isMovingTo">True when body is currently following an MTP.</param>
/// <param name="currentBodyPosition">
/// Body's current world position. Used for the already-close check (versus
/// body) and as the fallback distance reference when the queue is empty.
/// Pass <c>null</c> if not available — far/near classification falls back
/// to "near" (no pre-armed blip).
/// </param>
public void Enqueue(
Vector3 targetPosition,
float heading,
bool isMovingTo,
Vector3? currentBodyPosition = null)
{
// Step 1: duplicate-prune
if (_queue.Last is { } last)
// Retail compares dist against either the tail's stored position
// (if tail exists AND tail->type == 1) or the body's m_position.
Vector3 reference;
bool haveTail = _queue.Last is { } tail;
if (haveTail)
{
if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance)
reference = _queue.Last!.Value.TargetPosition;
}
else if (currentBodyPosition.HasValue)
{
reference = currentBodyPosition.Value;
}
else
{
reference = targetPosition; // dist = 0 → near branch
}
float dist = Vector3.Distance(reference, targetPosition);
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
if (dist > AutonomyBlipDistance)
{
EnqueueRaw(targetPosition, heading, isMovingTo);
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
_failCount = StallFailCountThreshold + 1;
return;
}
// Step 2: enforce cap
// Near & already-close branch (retail line 352962):
// distance(body, target) ≤ DesiredDistance → wipe queue, no enqueue.
if (currentBodyPosition.HasValue)
{
float bodyDist = Vector3.Distance(currentBodyPosition.Value, targetPosition);
if (bodyDist <= DesiredDistance)
{
Clear();
return;
}
}
// Near & not-close branch:
// 1. Tail-prune loop — collapse all consecutive stale tail entries
// within DesiredDistance of the new target (audit § 7 #5).
while (_queue.Last is { } stale &&
Vector3.Distance(stale.Value.TargetPosition, targetPosition) <= DesiredDistance)
{
_queue.RemoveLast();
}
// 2. Cap at 20 — drop head (audit § 7 #6).
if (_queue.Count >= QueueCap)
_queue.RemoveFirst();
// Steps 3+4: add node
var node = new InterpolationNode
// 3. Append.
EnqueueRaw(targetPosition, heading, isMovingTo);
}
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
{
TargetPosition = targetPosition,
_queue.AddLast(new InterpolationNode
{
TargetPosition = target,
Heading = heading,
IsHeadingValid = isMovingTo,
};
_queue.AddLast(node);
});
}
/// <summary>
/// Compute the per-frame position correction delta.
/// Compute the per-frame world-space correction delta. Combines the retail
/// <c>UseTime</c> blip-check (fail_count &gt; 3 → snap to tail, clear queue)
/// with the per-frame <c>adjust_offset</c> step computation.
///
/// <para>
/// Returns <see cref="Vector3.Zero"/> when the queue is empty or when
/// the head node has been reached. Returns a snap delta (tail
/// currentBodyPosition) after <see cref="StallFailCountThreshold"/>
/// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
/// then clears the queue.
/// </para>
/// Returns <see cref="Vector3.Zero"/> when:
/// • queue is empty,
/// • head reached (distance &lt; <see cref="DesiredDistance"/>) — head pops,
/// • dt is invalid (≤ 0 or NaN).
///
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
/// UseTime stall/blip (@ 0x00555F20).
/// Returns the snap delta (tail currentBodyPosition) when fail_count
/// exceeds <see cref="StallFailCountThreshold"/>, then clears the queue.
/// </summary>
/// <param name="dt">Frame delta time (seconds).</param>
/// <param name="currentBodyPosition">Current world-space body position.</param>
/// <param name="maxSpeedFromMinterp">
/// Max motion-table speed for this entity's current cycle (m/s), as
/// reported by MotionInterpreter. Pass 0 if unavailable; the fallback
/// <see cref="MaxInterpolatedVelocity"/> will be used.
/// Max motion-table speed for this entity's current cycle (m/s).
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
/// </param>
/// <returns>World-space delta to apply to the body this frame.</returns>
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
{
// Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position.
if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero;
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
if (dt <= 0 || double.IsNaN(dt))
return Vector3.Zero;
// Step 1: empty queue → no correction
if (_queue.First is null)
return Vector3.Zero;
// Step 2: peek head
var headNode = _queue.First.Value;
// Distance to head node (retail line 353083).
var head = _queue.First.Value;
float dist = Vector3.Distance(head.TargetPosition, currentBodyPosition);
// Step 3: distance to head target
float dist = (headNode.TargetPosition - currentBodyPosition).Length();
// Step 4: reached node
if (dist < DesiredDistance)
// Reach test (retail line 353089): dist ≤ DESIRED_DISTANCE → pop and
// re-baseline. NodeCompleted(1) advances to next head, also resets the
// window state.
if (dist <= DesiredDistance)
{
_queue.RemoveFirst();
NodeCompleted(popHead: true, currentBodyPosition);
return Vector3.Zero;
}
// Step 5: compute catch-up speed
// Catch-up speed (retail line 353122 + 353128 fallback).
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity;
float catchUp = scaled > FEpsilon ? scaled : MaxInterpolatedVelocity;
// Step 6: step magnitude (no overshoot)
float step = catchUpSpeed * (float)dt;
if (step > dist)
step = dist;
// Accumulate progress_quantum (audit § 7 #1: SUM OF DT, not step).
_progressQuantum += (float)dt;
_frameCounter++;
// Step 7: direction × step
Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step;
// Step 8: stall detection (retail adjust_offset @ 0x00555E08-0x00555E92)
//
// Retail tracks two quantities across each 5-frame window:
// progress_quantum — cumulative sum of per-frame step magnitudes
// original_distance — distance to head at the START of the window
//
// At window end (frame_counter >= 5):
// cumulative_progress = original_distance - currentDist
//
// Primary check (@ 0x00555E42):
// cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20 m)
// → window is a stall; increment node_fail_counter.
//
// Secondary check (@ 0x00555E73, only when progress_quantum > 0):
// cumulative_progress / progress_quantum < CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30)
// → window is a stall; increment node_fail_counter.
//
// Both checks operate with sticky_object_id == 0 (we never have one).
// Either check failing counts the window as a stall.
//
// Blip fires when node_fail_counter > 3 (retail UseTime @ 0x00555F39).
// Window always resets (frame_counter=0, progress_quantum=0,
// original_distance=currentDist) after the check.
// Initialise window baseline on first call after Clear / new motion.
if (!_haveBaselineDistance)
// 5-frame stall window check (retail line 353146).
if (_frameCounter >= StallCheckFrameInterval)
{
_distanceAtWindowStart = dist;
_haveBaselineDistance = true;
float cumulative = _originalDistance - dist;
// Primary check (retail line 353150-353166):
// cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20)
bool primaryPass = cumulative >= MinDistanceToReachPosition;
// Secondary check (retail line 353169-353172, audit § 7 #4):
// cumulative > F_EPSILON
// AND (cumulative / progress_quantum / dt) >= 0.30
//
// Port verbatim despite weird units; audit notes this may be a
// Turbine bug or x87-stack misread by Binary Ninja. Mirroring bytes.
bool secondaryPass = false;
if (cumulative > FEpsilon && _progressQuantum > 0f && dt > 0)
{
float ratio = (cumulative / _progressQuantum) / (float)dt;
secondaryPass = ratio >= StallProgressMinFraction;
}
_progressQuantum += step;
_framesSinceLastStallCheck++;
if (_framesSinceLastStallCheck >= StallCheckFrameInterval)
{
float cumulativeProgress = _distanceAtWindowStart - dist;
bool primaryFail = cumulativeProgress < MinDistanceToReachPosition;
bool secondaryFail = _progressQuantum > 0f &&
(cumulativeProgress / _progressQuantum) < StallProgressMinFraction;
if (primaryFail || secondaryFail)
if (!primaryPass && !secondaryPass)
{
_failCount++;
// Blip-to-tail: retail UseTime (@ 0x00555F20) reads
// position_queue.tail_, copies its position to a local,
// calls CPhysicsObj::SetPositionSimple, then
// StopInterpolating. Snap target is the TAIL (the most
// recent server position), not the head.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
Clear();
return tailPos - currentBodyPosition;
}
}
else
{
_failCount = 0;
}
// Reset the 5-frame window regardless of pass/fail.
_framesSinceLastStallCheck = 0;
// Re-baseline window regardless of pass/fail.
_frameCounter = 0;
_progressQuantum = 0f;
_distanceAtWindowStart = dist;
_originalDistance = dist;
}
else if (_originalDistance >= OriginalDistanceSentinel - 0.5f)
{
// First call after Clear / new motion: seed the baseline so the
// first 5-frame window's cumulative is computed against frame-0
// distance, not the 999999f sentinel. Retail handles this via
// the sentinel itself — the sentinel produces a huge cumulative
// that always passes — but we use a baseline-seeded approach so
// the secondary check has sane progress_quantum behavior.
_originalDistance = dist;
}
// Step 9: return per-frame delta
// Retail UseTime blip check (@ 0x00555F39): fail_count > 3 → snap to
// tail, clear queue. Placed AFTER the stall window logic so it fires
// in the same tick as both:
// (a) the just-incremented fail_count from a stall window pass, AND
// (b) a far-branch Enqueue pre-arm (fail_count = 4 set externally).
// Retail splits this into a separate UseTime call; we collapse it.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
Clear();
return tailPos - currentBodyPosition;
}
// Per-frame step magnitude (retail line 353218).
float step = catchUp * (float)dt;
// No-overshoot scaling (retail line 353231): if step would overshoot
// dist, clamp to dist.
if (step > dist)
step = dist;
// Direction × step.
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
return delta;
}
/// <summary>
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
/// popHead=false during stall fail (re-baseline only). For our collapsed
/// architecture we always re-baseline on pop.
/// </summary>
private void NodeCompleted(bool popHead, Vector3 currentBodyPosition)
{
_frameCounter = 0;
_progressQuantum = 0f;
if (popHead && _queue.First != null)
{
_queue.RemoveFirst();
}
// Re-baseline on the new head, or reset to sentinel if queue empty.
if (_queue.First is { } newHead)
{
_originalDistance = Vector3.Distance(newHead.Value.TargetPosition, currentBodyPosition);
}
else
{
_originalDistance = OriginalDistanceSentinel;
}
}
}

View file

@ -360,6 +360,52 @@ public sealed class InterpolationManagerTests
"First stall window must NOT trigger a blip (would require > 3 consecutive failures).");
}
// =========================================================================
// Far-branch enqueue: when the new target is beyond AutonomyBlipDistance
// (100 m outdoor) of the reference (tail or body), retail
// InterpolationManager::InterpolateTo (acclient @ 0x00555B20 line 352944)
// sets node_fail_counter = 4 so the very next stall-check blips to the
// tail. Audit 04-interp-manager.md § 7 gap #3.
//
// Effect: the body teleports to the freshly-enqueued tail on the first
// adjust_offset call after a far enqueue, instead of drifting toward it
// at catch-up speed. Critical for >100 m server-side teleports / cell
// crossings on observed remotes.
// =========================================================================
[Fact]
public void Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset()
{
var mgr = Make();
// Target > AutonomyBlipDistance (100 m) from origin → far branch.
var farTarget = new Vector3(150f, 0f, 0f);
mgr.Enqueue(farTarget, heading: 0f, isMovingTo: true, currentBodyPosition: BodyOrigin);
// Single AdjustOffset call: body still at origin, queue has 1 node,
// node_fail_counter = 4 (set by far-branch enqueue) > 3 threshold,
// so the very first stall-check fires a blip to the tail.
//
// The blip delta should be the full far distance (≈150 m), not a
// single per-frame catch-up step.
Vector3? blipDelta = null;
for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++)
{
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
// Blip fires when delta >> per-frame step. Per-frame step at
// 4 m/s × 2 (mod) × 0.016 s = 0.128 m. Blip is 150 m.
if (delta.Length() > 50f)
{
blipDelta = delta;
break;
}
}
Assert.NotNull(blipDelta);
Assert.Equal(150f, blipDelta!.Value.X, precision: 4);
Assert.False(mgr.IsActive, "Queue must be cleared after blip.");
}
[Fact]
public void AdjustOffset_DtZeroOrNegative_ReturnsZero()
{