From de129bc1649feaf3d57b77ed8e03df9377912e3a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 14:56:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20L.3=20M1=20=E2=80=94=20fresh=20?= =?UTF-8?q?InterpolationManager=20port=20+=20retail=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-04-l3-port/00-master-plan.md | 150 ++ .../2026-05-04-l3-port/00-port-plan.md | 277 ++++ .../2026-05-04-l3-port/01-per-tick.md | 613 +++++++++ .../2026-05-04-l3-port/02-um-handling.md | 1206 +++++++++++++++++ .../2026-05-04-l3-port/03-up-routing.md | 585 ++++++++ .../2026-05-04-l3-port/04-interp-manager.md | 497 +++++++ .../05-position-manager-and-partarray.md | 491 +++++++ .../2026-05-04-l3-port/06-acdream-audit.md | 550 ++++++++ .../07-sticky-constraint-moveto.md | 919 +++++++++++++ .../08-update-object-substep-and-frame.md | 824 +++++++++++ .../09-cpart-array-cseq-update.md | 526 +++++++ .../10-vector-update-jump.md | 693 ++++++++++ .../2026-05-04-l3-port/11-um-dispatch-deep.md | 1029 ++++++++++++++ .../12-hard-teleport-branch.md | 745 ++++++++++ .../2026-05-04-l3-port/13-cycle-picker.md | 598 ++++++++ .../14-local-player-audit.md | 722 ++++++++++ .../Physics/InterpolationManager.cs | 440 +++--- .../Physics/InterpolationManagerTests.cs | 46 + 18 files changed, 10721 insertions(+), 190 deletions(-) create mode 100644 docs/research/2026-05-04-l3-port/00-master-plan.md create mode 100644 docs/research/2026-05-04-l3-port/00-port-plan.md create mode 100644 docs/research/2026-05-04-l3-port/01-per-tick.md create mode 100644 docs/research/2026-05-04-l3-port/02-um-handling.md create mode 100644 docs/research/2026-05-04-l3-port/03-up-routing.md create mode 100644 docs/research/2026-05-04-l3-port/04-interp-manager.md create mode 100644 docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md create mode 100644 docs/research/2026-05-04-l3-port/06-acdream-audit.md create mode 100644 docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md create mode 100644 docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md create mode 100644 docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md create mode 100644 docs/research/2026-05-04-l3-port/10-vector-update-jump.md create mode 100644 docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md create mode 100644 docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md create mode 100644 docs/research/2026-05-04-l3-port/13-cycle-picker.md create mode 100644 docs/research/2026-05-04-l3-port/14-local-player-audit.md diff --git a/docs/research/2026-05-04-l3-port/00-master-plan.md b/docs/research/2026-05-04-l3-port/00-master-plan.md new file mode 100644 index 0000000..7c6d470 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/00-master-plan.md @@ -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. diff --git a/docs/research/2026-05-04-l3-port/00-port-plan.md b/docs/research/2026-05-04-l3-port/00-port-plan.md new file mode 100644 index 0000000..6e13ecb --- /dev/null +++ b/docs/research/2026-05-04-l3-port/00-port-plan.md @@ -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. diff --git a/docs/research/2026-05-04-l3-port/01-per-tick.md b/docs/research/2026-05-04-l3-port/01-per-tick.md new file mode 100644 index 0000000..8789134 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/01-per-tick.md @@ -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. diff --git a/docs/research/2026-05-04-l3-port/02-um-handling.md b/docs/research/2026-05-04-l3-port/02-um-handling.md new file mode 100644 index 0000000..5233691 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/02-um-handling.md @@ -0,0 +1,1206 @@ +# L.3 port — UpdateMotion (0xF74C) handling pipeline + +**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` +(Sept 2013 EoR build, 18,366 named functions). All line numbers are +into that file unless otherwise noted. + +This document traces the full inbound path from the wire (the 0xF74C +packet hitting the network layer) down to body velocity and animation +state. It also covers the OUTBOUND path the local player uses (so we +know what acdream's `+Acdream` sends, and what an observing retail +client receives via the SAME entry point but for a different guid). + +--- + +## 0. Top-level flow (one-line summary) + +``` +0xF74C wire packet + └── CM_Physics::DispatchSB_* (357214) — picks branch by opcode + └── CPhysics::SetObjectMovement (271370) — staleness check, exit-29-style timestamps + └── CPhysicsObj::unpack_movement (280179) — lazy-creates MovementManager + └── MovementManager::unpack_movement (300563) — reads MovementType byte + params + ├── case 0 (RawCommand) → move_to_interpreted_state + ├── case 6 (MoveToObject) → MoveToManager.MoveToObject + ├── case 7 (MoveToPosition) → MoveToManager.MoveToPosition + ├── case 8 (TurnToObject) → MoveToManager.TurnToObject + └── case 9 (TurnToHeading) → MoveToManager.TurnToHeading + +InterpretedMotionState delivered by case 0 then drives: + CMotionInterp::move_to_interpreted_state (305936) + ↓ copy_movement_from + apply_current_movement + ↓ apply_interpreted_movement (305713) — re-runs DoInterpretedMotion for each axis + ↓ DoInterpretedMotion → contact_allows_move? + ApplyMotion → add_to_queue + ↓ get_state_velocity (305160) → CPhysicsObj.set_local_velocity +``` + +Outbound (when local player presses W or releases W): + +``` +W press (or release) + └── CommandInterpreter::SendMovementEvent (700274) + └── MoveToStatePack ctor (RawMotionState snapshot of player) + └── ACCmdInterp::SendMoveToStateEvent → 0xF61C MoveToState packet +``` + +That single 0xF61C goes to the server. ACE relays the player's wire +state to nearby observers as 0xF74C UpdateMotion. So the observer +side is the same code path described in §1–§7 below, just with the +remote player's guid. + +--- + +## 1. The 0xF74C dispatcher + +**Function:** `CM_Physics::DispatchSB_<...>` — opcode switch. +**Address:** `0x005595d0` (containing line 357211 reference). + +Verbatim retail (357211–357240): + +```c +00559605 } +005595ff } +005595ff else if (((char*)ecx - 0xf74c) <= 0x8f) +005597b8 switch (ecx) +005597b8 { +00559850 case 0xf74c: +00559850 { +00559850 uint32_t ebp_2 = *(uint32_t*)(buf_ + 4); // object guid +00559852 class CObjectMaint* m_pObjMaint = this->m_pObjMaint; +0055985c arg2 = &buf_[8]; // payload start +00559860 class CPhysicsObj* eax_25 = CObjectMaint::GetObjectA(m_pObjMaint, ebp_2); +00559867 class NetBlob* eax_26 = arg2; +0055986d uint16_t ecx_15 = eax_26->vtable; // instance_timestamp +00559875 arg2 = (&eax_26->vtable + 2); // skip 2 bytes +00559875 +0055987d if ((eax_25 != 0 && CPhysicsObj::is_newer(eax_25->update_times[8], ecx_15) == 0)) +0055987d { +00559896 int32_t eax_29; +00559896 eax_29 = eax_25->update_times[8]; +005598a2 if (eax_29 != ecx_15) +005598e8 return 2; // STALE → drop +005598bc if (CPhysics::SetObjectMovement(this->physics, eax_25, arg2, bufSize_) != 0) +005598be this->cmdinterp->vtable->LoseControlToServer(); +005598d7 return 1; +0055987d } +0055987d +005598ef SmartBox::QueueBlobForObject(this, ebp_2, ebx); // entity not yet known → queue +00559902 return 4; +``` + +**Behavior:** + +1. Read object guid from offset 4. +2. Read 2-byte `instance_timestamp` from payload start (offset 8). +3. Look up the entity. If we don't know it, queue the blob and return. +4. If `update_times[8]` (last seen instance ts) is newer than the + wire's, **drop the packet (return 2)**. This is the staleness gate. +5. Otherwise hand off the rest of the payload to + `CPhysics::SetObjectMovement`. + +--- + +## 2. CPhysics::SetObjectMovement + +**Function:** `CPhysics::SetObjectMovement` +**Address:** `0x00509690` +**Lines:** 271370–271431. + +```c +00509690 int32_t __stdcall CPhysics::SetObjectMovement(class CPhysics* this @ ecx, + class CPhysicsObj* arg2, // entity + void* arg3, // payload pointer + uint32_t arg4, // remaining size + uint16_t arg5, // instance_timestamp (already read) + uint16_t arg6, // server_control_timestamp + int32_t arg7) // forceTeleport flag +{ + int32_t ebx = 0; + if (weenie_obj != 0) + ebx = weenie_obj->vtable->IsThePlayer(); // is this the local player? + + weenie_obj = arg2->update_times[1]; // last instance_timestamp + int32_t edi = arg5; + // ... unsigned 16-bit "is wire newer?" comparison via 0x7fff-wrap ... + if (-((eax_7 - eax_7)) != 0) // i.e. newer + { + arg2->update_times[1] = edi; // record new instance ts + weenie_obj = arg2->update_times[5]; // last server_control_ts + edi = arg6; + // ... same wrap compare on server_control_ts ... + if (-((eax_14 - eax_14)) != 0) + return 0; // stale on server_control_ts + arg2->update_times[5] = edi; + + if ((arg7 == 0 || ebx == 0)) // not "force teleport on player" + { + arg2->last_move_was_autonomous = arg7; + CPhysicsObj::unpack_movement(arg2, &arg3, arg4); + if (ebx != 0) return 1; // local player echo: ask cmdinterp to LoseControl + } + } + return 0; +} +``` + +**Key behaviors:** + +- Two timestamp gates (instance_ts and server_control_ts) before any + state change — both use 16-bit wrap-aware ordering. +- For the **local player** (`IsThePlayer != 0`), if the timestamps are + newer AND `forceTeleport == 0`, returns 1 — the dispatcher then + calls `LoseControlToServer()`. This is how a server overrides the + local player's prediction (e.g. teleport, frozen, etc). +- For everyone else (remote players, NPCs, monsters), returns 0 and + proceeds to `unpack_movement`. + +--- + +## 3. CPhysicsObj::unpack_movement — lazy creates MovementManager + +**Function:** `CPhysicsObj::unpack_movement` +**Address:** `0x00512040` +**Lines:** 280179–280203. + +```c +00512040 void __thiscall CPhysicsObj::unpack_movement(this, arg2, arg3) { + if (this->movement_manager == 0) { + this->movement_manager = MovementManager::Create(this, this->weenie_obj); + // first creation also touches transient_state (sets bit 0x80) + } + MovementManager::unpack_movement(this->movement_manager, arg2, arg3); +} +``` + +Pure dispatch. The interesting work happens in MovementManager. + +--- + +## 4. MovementManager::unpack_movement — the actual wire reader + +**Function:** `MovementManager::unpack_movement` +**Address:** `0x00524440` +**Lines:** 300563–300704. + +This is the **real entry point for UM payload parsing**. It reads a +2-byte `MovementType` discriminator and a 2-byte initial style, then +branches. + +Verbatim core (300563–300668): + +```c +00524440 int32_t __thiscall MovementManager::unpack_movement(this, arg2, arg3) +{ + if (this->motion_interpreter != 0) + { + if (physics_obj != 0) + { + CPhysicsObj::interrupt_current_movement(physics_obj); + CPhysicsObj::unstick_from_object(this->physics_obj); + // ... Frame::cache(local_origin) ... + // var_9c = MovementParameters() with defaults + // var_28 = InterpretedMotionState() with defaults + + void* eax_1 = *arg2; + int16_t ecx_4 = *(uint16_t*)eax_1; // (a) movement_type byte + *arg2 = eax_1 + 2; + uint32_t ebp_1 = (uint32_t)ecx_4; // movement_type + + ecx_4 = *(uint16_t*)(eax_1 + 2); // (b) style 16-bit MotionCommand low + *arg2 = eax_1 + 4; + uint32_t ecx_5 = command_ids[(uint32_t)ecx_4]; // expand to full uint32 cmd + + // If the new style differs from current, fire DoMotion(style, default_params) + // — that switches the body's currentStyle (combat→peace etc). + if (CBaseFilter::GetPinVersion(this->motion_interpreter) != ecx_5) + CMotionInterp::DoMotion(this->motion_interpreter, ecx_5, &var_9c); + + switch (ebp_1) { + case 0: // RawCommand (the bulk of UMs) + InterpretedMotionState::UnPack(&var_28, arg2, arg3); + uint32_t ebx_3 = 0; + if ((var_a4_1 & 0x100) != 0) // bit indicates "stick to object" guid present + { + uint32_t* eax_8 = *arg2; + ebx_3 = *eax_8; // guid to stick to + *arg2 = &eax_8[1]; + } + MovementManager::move_to_interpreted_state(this, &var_28); + if (ebx_3 != 0) + CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); + this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); + return 1; + + case 6: /* MoveToObject — guid + Position + MovementParameters + runRate */ + case 7: /* MoveToPosition — Position + MovementParameters + runRate */ + case 8: /* TurnToObject — guid + heading + MovementParameters */ + case 9: /* TurnToHeading — MovementParameters */ + // each delegates to MoveToManager::* (out of scope here) + } + } + } + return 0; +} +``` + +**Reads from wire (case 0 only):** + +- 2 bytes — `movement_type` (0=RawCommand, 6/7/8/9=MoveTo variants). +- 2 bytes — initial currentStyle (16-bit MotionCommand low → expanded + to full uint32 via `command_ids[]` lookup). +- `InterpretedMotionState::UnPack` (see §5) consumes the rest. + +**Writes to MotionInterp:** + +- If the new style differs, `DoMotion(style, default_params)` runs + immediately — this is how stance changes (Combat ↔ Peace) occur. +- Then `move_to_interpreted_state` bulk-applies the unpacked state + (see §7). +- `standing_longjump` flag set from the high bit (0x200) of + movement_type. + +**Note:** Type 0 is what the ACE relay produces for nearly every +locomotion event. Types 6–9 are server-controlled MoveTo's (e.g. +"NPC walks to point X"). The MoveTo branches end up calling +`MoveToManager::MoveToObject`/`...Position`/`TurnToHeading` which is +its own state machine — out of scope for this doc. + +--- + +## 5. InterpretedMotionState::UnPack — flag-driven field reader + +**Function:** `InterpretedMotionState::UnPack` +**Address:** `0x0051f400` +**Lines:** 294360–294523. + +This reads a single `uint32_t` flag word and conditionally unpacks 13 +fields. **This is exactly the format ACE writes when relaying.** + +Verbatim core (294360–294492): + +```c +0051f400 int32_t __thiscall InterpretedMotionState::UnPack(this, arg2, arg3) +{ + InterpretedMotionState::Destroy(this); // clear actions list + + uint32_t edx; + if (arg3 < 4) edx = arg3; + else { + edx = *(uint32_t*)(*arg2); // FLAGS uint32 + *arg2 += 4; + } + + if ((edx & 0x01) == 0) this->current_style = 0x8000003d; // NonCombat + else { read uint16, expand via command_ids[] → current_style } + + if ((edx & 0x02) == 0) this->forward_command = 0x41000003; // Ready + else { read uint16, expand → forward_command } + + if ((edx & 0x08) == 0) this->sidestep_command = 0; + else { read uint16, expand → sidestep_command } + + if ((edx & 0x20) == 0) this->turn_command = 0; + else { read uint16, expand → turn_command } + + if ((edx & 0x04) == 0) this->forward_speed = 1.0f; + else { this->forward_speed = *(float*)(*arg2); *arg2 += 4; } + + if ((edx & 0x10) == 0) this->sidestep_speed = 1.0f; + else { this->sidestep_speed = *(float*)(*arg2); *arg2 += 4; } + + if ((edx & 0x40) == 0) this->turn_speed = 1.0f; + else { this->turn_speed = *(float*)(*arg2); *arg2 += 4; } + + int32_t i_4 = (edx >> 7) & 0x1f; // action count (5 bits) + while (i_4-- > 0) { + // each action: uint16 motion → command_ids[], uint32 speed, + // uint16 stamp+autonomous bit (0x7fff stamp; 0x8000 autonomous) + InterpretedMotionState::AddAction(this, motion, speed, stamp, autonomous); + } + + align_ptr_to_4(); + return 1; +} +``` + +**Key facts (this is THE definitive flag layout):** + +| Bit | Field | When CLEAR | +|----|----|----| +| 0x01 | current_style | defaults to NonCombat (0x8000003d) | +| 0x02 | forward_command | defaults to Ready (0x41000003) | +| 0x04 | forward_speed | defaults to 1.0f | +| 0x08 | sidestep_command | defaults to 0 | +| 0x10 | sidestep_speed | defaults to 1.0f | +| 0x20 | turn_command | defaults to 0 | +| 0x40 | turn_speed | defaults to 1.0f | +| 0x80–0x800 | action count (5 bits) | 0 | + +**Crucial corollary for the L.3 port:** when ACE omits a field on the +wire (e.g. doesn't set bit 0x02 because forward_command was +"Invalid" — its idle), the decompiled UnPack DEFAULTS that field to +the table-default value (Ready / 0 / 1.0f). This is **NOT** "preserve +previous." It's "reset to the per-axis default." That's why a stop +broadcast looks like an UM with all command bits cleared. + +The wire's `forward_command = 0` (clear bit 0x02) IS the stop signal. +The unpacker maps it to Ready. + +--- + +## 6. command_ids[] — 16-bit → 32-bit motion expansion + +`command_ids[]` is a static lookup table that takes the 16-bit +MotionCommand low word and returns the full uint32 (with class byte +reattached). This is how a wire `0x0007` (RunForward low) becomes +`0x44000007` (RunForward full). acdream's +`MotionCommandResolver.ReconstructFullCommand` is the equivalent. + +--- + +## 7. CMotionInterp::move_to_interpreted_state + +**Function:** `CMotionInterp::move_to_interpreted_state` +**Address:** `0x005289c0` +**Lines:** 305936–305992. + +Verbatim: + +```c +005289c0 int32_t __thiscall CMotionInterp::move_to_interpreted_state(this, arg2) +{ + if (physics_obj == 0) return 0; + + this->raw_state.current_style = arg2->current_style; + CPhysicsObj::interrupt_current_movement(physics_obj); + uint32_t eax_2 = motion_allows_jump(this, this->interpreted_state.forward_command); + int32_t esi_1 = -eax_2; + InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // ← bulk copy + apply_current_movement(this, 1, -((esi_1 - esi_1))); // cancelMoveTo=1, allowJump=stillAllowed + + MovementParameters var_2c; + MovementParameters::MovementParameters(&var_2c); + + for (LListData* i = arg2->actions.head_; i != 0; i = i->llist_next) + { + // 15-bit action stamp comparison (wrap-aware via 0x7fff) + int32_t actStamp = *(int32_t*)((char*)i + 0xc) & 0x7fff; + int32_t serverStamp = this->server_action_stamp & 0x7fff; + int32_t delta = abs(actStamp - serverStamp); + bool isNewer = (delta <= 0x3fff) ? (serverStamp < actStamp) : (actStamp < serverStamp); + + if (isNewer) + { + // gate: only fire actions that came from the network (autonomous=0) + // when this is a player; for NPCs always fire. + if (weenie_obj == 0 || weenie_obj->vtable->IsThePlayer() == 0 + || *(int32_t*)((char*)i + 0x10) == 0 /*autonomous bit*/) + { + this->server_action_stamp = *(int32_t*)((char*)i + 0xc); + var_2c.action_stamp = *(int32_t*)((char*)i + 8); + // var_28 |= 0x1000 = ModifyInterpretedState + CMotionInterp::DoInterpretedMotion(this, *(int32_t*)((char*)i + 4), &var_2c); + } + } + } + return 1; +} +``` + +**Critical facts:** + +1. **`copy_movement_from` is UNCONDITIONAL bulk copy** (lines + 293301–293311) — every field of InterpretedState is overwritten: + `current_style`, `forward_command`, `forward_speed`, + `sidestep_command`, `sidestep_speed`, `turn_command`, `turn_speed`. + No filter by stance change, no diff, no per-axis gate. Whatever + the wire said (post-defaults from UnPack) is now the body's state. + +2. After the copy, **`apply_current_movement(cancelMoveTo=true, allowJump)`** + re-runs the full state machine. This is where the body's velocity + gets re-derived from the new InterpretedState (see §8). + +3. The actions list is iterated separately with stamp-wrap protection + so we don't replay actions we already saw, and we skip player-self + echoes that we ourselves originated (autonomous=true). + +--- + +## 8. apply_current_movement → apply_interpreted_movement + +**Function:** `CMotionInterp::apply_interpreted_movement` +**Address:** `0x00528600` +**Lines:** 305713–305788. + +```c +00528600 void apply_interpreted_movement(this, arg2 /*cancelMoveTo*/, arg3 /*allowJump*/) +{ + if (physics_obj != 0) + { + MovementParameters var_2c; + MovementParameters::MovementParameters(&var_2c); + + // If forward is RunForward, cache the speed as MyRunRate + if (this->interpreted_state.forward_command == 0x44000007) + this->my_run_rate = this->interpreted_state.forward_speed; + + // Re-fire DoInterpretedMotion(currentStyle) — re-applies stance + DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c); + + if (contact_allows_move(this->interpreted_state.forward_command) == 0) + { + // Body is airborne / dead — force Falling + DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c); + } + else + { + if (this->standing_longjump != 0) + { + DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); + StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); + } + else + { + // FORWARD axis + var_2c.speed = this->interpreted_state.forward_speed; + DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c); + + // SIDESTEP axis + if (this->interpreted_state.sidestep_command == 0) + StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); + else + { + var_2c.speed = this->interpreted_state.sidestep_speed; + DoInterpretedMotion(this, this->interpreted_state.sidestep_command, &var_2c); + } + } + } + + // TURN axis + if (this->interpreted_state.turn_command != 0) { + var_2c.speed = this->interpreted_state.turn_speed; + DoInterpretedMotion(this, this->interpreted_state.turn_command, &var_2c); + return; + } + // No turn — explicit stop + uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c); + if (eax_10 == 0) { + add_to_queue(this, var_c, 0x41000003, 0); + // remove TurnRight from action queue + } + } +} +``` + +**This is the per-tick re-apply.** Every time the wire delivers a new +state (or we land, or we leave ground), this function fires +`DoInterpretedMotion` for each axis (forward, sidestep, turn) so the +physics body re-derives velocity. Velocity comes from +`get_state_velocity` which lives inside `DoInterpretedMotion` → +`CPhysicsObj::DoInterpretedMotion` (not shown in detail here, but +the chain runs through `set_local_velocity`). + +--- + +## 9. CMotionInterp::DoMotion (raw command path) + +**Function:** `CMotionInterp::DoMotion` +**Address:** `0x00528d20` +**Lines:** 306159–306217. + +```c +00528d20 uint32_t DoMotion(this, arg2 /*motion*/, arg3 /*MovementParameters*/) +{ + if (physics_obj == 0) return 8; + + uint32_t ebp = arg2; + // ... copy struct fields locally ... + + if (params->__inner0.byte1 < 0) // CancelMoveTo bit + CPhysicsObj::interrupt_current_movement(physics_obj); + + if ((params->__inner0.byte1 & 8) != 0) // SetHoldKey bit + SetHoldKey(this, params->hold_key_to_apply, ((__inner0 >> 0xf) & 1)); + + adjust_motion(this, &arg2, &speed, params->hold_key_to_apply); + + if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) { + if (ebp == 0x41000012 /*Crouch*/) return 0x3f; // CantCrouchInCombat + if (ebp == 0x41000013 /*Sit*/) return 0x40; + if (ebp == 0x41000014 /*Sleep*/) return 0x41; + if ((ebp & 0x2000000) != 0) return 0x42; // CantChatEmoteInCombat + } + + if ((ebp & 0x10000000 /*Action*/) != 0 + && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6) + return 0x45; // TooManyActions + + uint32_t result = DoInterpretedMotion(this, arg2, &var_2c); + + if (result == 0 && (params->__inner0.byte1 & 0x20 /*ModifyRawState*/) != 0) + RawMotionState::ApplyMotion(&this->raw_state, ebp, arg3); + + return result; +} +``` + +**Behavior:** + +- `adjust_motion` (see §10) folds HoldKey + sign-flipped commands. +- Combat-style guards reject Crouch/Sit/Sleep/ChatEmote. +- Action-class commands are queued, max 6 outstanding. +- All work delegates to `DoInterpretedMotion` (§11). +- If caller asked, the raw state is also updated via + `RawMotionState::ApplyMotion`. + +--- + +## 10. CMotionInterp::adjust_motion + +**Function:** `CMotionInterp::adjust_motion` +**Address:** `0x00528010` +**Lines:** 305343–305400. + +This is **the canonical sign-flipping / hold-key application function**. +Verbatim core: + +```c +00528010 void adjust_motion(this, uint32_t* motion, float* speed, HoldKey holdKey) +{ + if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) return; + + uint32_t cmd = *motion; + + if (cmd == 0x6500000e /*SideStepLeft*/) { + *motion = 0x6500000f /*SideStepRight*/; + *speed *= -1.0f; + } + else if (cmd == 0x65000010 /*TurnLeft???*/) { // really an alias + *motion = 0x6500000f; + *speed *= -1.0f; + } + else if (cmd == 0x45000006 /*WalkBackward*/) { + *motion = 0x45000005 /*WalkForward*/; + *speed *= -0.65f; // BackwardsFactor + } + // (RunForward 0x44000007 falls through unchanged) + + // Sidestep gets its own scale factor + if (*motion == 0x6500000f /*SideStepRight*/) { + *speed = (3.12f / 1.25f) * 0.5f * (*speed); // = 1.248 + } + + if (holdKey == HoldKey_Invalid) + holdKey = this->raw_state.current_holdkey; + + if (holdKey == HoldKey_Run) + apply_run_to_command(this, motion, speed); +} +``` + +**Mappings produced by adjust_motion:** + +| Input motion | Input speed | Output motion | Output speed | +|---|---|---|---| +| WalkForward | s | WalkForward | s | +| WalkBackward | s | WalkForward | -0.65 × s | +| TurnLeft | s | TurnRight | -s | +| SideStepLeft | s | SideStepRight | -s | +| SideStepRight (final) | s | SideStepRight | 1.248 × s | +| RunForward | s | RunForward | s | + +Then if HoldKey == Run, `apply_run_to_command` fires. + +--- + +## 11. CMotionInterp::apply_run_to_command + +**Function:** `CMotionInterp::apply_run_to_command` +**Address:** `0x00527be0` +**Lines:** 305062–305123. + +```c +00527be0 void apply_run_to_command(this, uint32_t* motion, float* speed) +{ + long double speedMod; + + if (weenie_obj != 0) { + if (weenie_obj->InqRunRate(&speedMod) != 0) { + // speedMod taken from weenie InqRunRate output + } else { + speedMod = (long double)this->my_run_rate; + } + } else { + speedMod = 1.0L; + } + + uint32_t cmd = *motion; + + if (cmd == 0x45000005 /*WalkForward*/) { + if (*speed > 0.0f) + *motion = 0x44000007 /*RunForward*/; // PROMOTION + *speed = (float)(speedMod * (*speed)); + return; + } + + if (cmd == 0x6500000d /*TurnRight*/) { + *speed = (float)(1.5f * (*speed)); // RunTurnFactor + return; + } + + if (cmd == 0x6500000f /*SideStepRight*/) { + speedMod *= (long double)*speed; + *speed = (float)speedMod; + if (fabsl(speedMod) > 3.0L) { // MaxSidestepAnimRate + *speed = (speedMod > 0) ? 3.0f : -3.0f; + } + } +} +``` + +**Critical asymmetry — the speed > 0.0 gate:** + +```c +if (*speed > 0.0f) + *motion = 0x44000007 /*RunForward*/; +``` + +This is the line that prevents `WalkBackward + HoldKey.Run` from +becoming `RunBackward`. After `adjust_motion` flips WalkBackward → +WalkForward with negative speed, this gate keeps the motion as +WalkForward (because speed ≤ 0) and the speed multiplication still +applies the runRate. + +So sign-flipped backward arrives at `get_state_velocity` as: +- `forward_command = WalkForward (0x45000005)` +- `forward_speed = -0.65 × runRate` (negative) + +Then `get_state_velocity` (next section) hits the WalkForward branch +and produces a NEGATIVE `velocity.Y` — the body moves backward at +walk-pace × 65% × runRate. + +--- + +## 12. CMotionInterp::get_state_velocity + +**Function:** `CMotionInterp::get_state_velocity` +**Address:** `0x00527d50` +**Lines:** 305160–305204. + +```c +00527d50 void get_state_velocity(this, AC1Legacy::Vector3* out) +{ + long double vx; + if (this->interpreted_state.sidestep_command != 0x6500000f) + vx = 0.0L; + else + vx = 1.25L * (long double)this->interpreted_state.sidestep_speed; + out->x = (float)vx; + + long double vy; + uint32_t fwd = this->interpreted_state.forward_command; + if (fwd == 0x45000005 /*WalkForward*/) + vy = 3.12L * (long double)this->interpreted_state.forward_speed; + else if (fwd == 0x44000007 /*RunForward*/) + vy = 4.0L * (long double)this->interpreted_state.forward_speed; + else + vy = 0.0L; + out->y = (float)vy; + + out->z = 0.0f; + + // Cap to maxSpeed = 4.0 * runRate + long double rate = this->my_run_rate; /* or InqRunRate */ + long double len = sqrtl(vx*vx + vy*vy + 0.0L); + if (len > 4.0L * rate) { + long double scale = (4.0L * rate) / len; + out->x *= (float)scale; + out->y *= (float)scale; + } +} +``` + +**Hard-coded constants (these match ACE 1:1):** + +- `WalkAnimSpeed = 3.12 m/s` +- `RunAnimSpeed = 4.0 m/s` +- `SidestepAnimSpeed = 1.25 m/s` +- `MaxSidestepAnimRate = 3.0` (clamp inside apply_run_to_command) +- `BackwardsFactor = 0.65` +- `RunTurnFactor = 1.5` + +Velocity output is body-local: X = strafe (right positive), +Y = forward (forward positive), Z = 0. Z gets composed by gravity / +LeaveGround in CPhysicsObj. + +--- + +## 13. CMotionInterp::contact_allows_move + +**Function:** `CMotionInterp::contact_allows_move` +**Address:** `0x00528240` +**Lines:** 305471–305505. + +```c +00528240 int32_t contact_allows_move(this, uint32_t motion) +{ + if (physics_obj != 0) { + if (motion > 0x40000015) { + if (motion >= 0x6500000d && motion <= 0x6500000e) // TurnRight..TurnLeft + return 1; // turns always allowed + } else if (motion == 0x40000015 /*Falling*/ || motion == 0x40000011 /*Dead*/) { + return 1; + } + + if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) // non-creatures (chess pieces, etc) + return 1; + + if (physics_obj == 0 || (physics_obj->state & 0x4 /*Gravity*/) == 0) + return 1; // no gravity → always + + uint8_t ts = physics_obj->transient_state; + if ((ts & 1 /*Contact*/) != 0 && (ts & 2 /*OnWalkable*/) != 0) + return 1; // grounded + } + return 0; // airborne creature on gravity-affected body +} +``` + +Used by `DoInterpretedMotion` to decide whether a motion can take +effect right now or must be deferred. + +--- + +## 14. CMotionInterp::DoInterpretedMotion (the leaf) + +**Function:** `CMotionInterp::DoInterpretedMotion` +**Address:** `0x00528360` +**Lines:** 305575–305631. + +```c +00528360 uint32_t DoInterpretedMotion(this, motion, params) +{ + if (physics_obj == 0) return 8; + uint32_t result; + + if (contact_allows_move(this, motion) != 0) { + if (this->standing_longjump != 0 + && (motion == 0x45000005 /*Walk*/ || motion == 0x44000007 /*Run*/ || motion == 0x6500000f /*SideStep*/)) + { + // skip the engine-side action; just touch InterpretedState if asked + if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + result = 0; + } + else { + if (motion == 0x40000011 /*Dead*/) + CPhysicsObj::RemoveLinkAnimations(this->physics_obj); + + result = CPhysicsObj::DoInterpretedMotion(this->physics_obj, motion, params); + if (result == 0) { + uint32_t jumpErr; + if ((params->__inner0 & 0x20000) == 0) { + jumpErr = motion_allows_jump(this, motion); + if (jumpErr == 0 && (motion & 0x10000000 /*Action*/) == 0) + jumpErr = motion_allows_jump(this, this->interpreted_state.forward_command); + } else { + jumpErr = 0x48; /*disable*/ + } + add_to_queue(this, params->context_id, motion, jumpErr); + if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + } + } + } + else if ((motion & 0x10000000 /*Action*/) == 0) { + if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + result = 0; + } + else result = 0x24 /*YouCantJumpWhileInTheAir*/; + + if (physics_obj != 0 && physics_obj->cell == 0) + CPhysicsObj::RemoveLinkAnimations(physics_obj); + + return result; +} +``` + +This is **the function `apply_interpreted_movement` calls 3× per UM** +(once each for forward/sidestep/turn axes). Note: + +- The actual physics velocity push is inside + `CPhysicsObj::DoInterpretedMotion` — that's where + `set_local_velocity(get_state_velocity(...))` happens. +- `InterpretedMotionState::ApplyMotion` updates the InterpretedState + ONLY when the params flag 0x40 (ModifyInterpretedState) is set. + In `apply_interpreted_movement`, params is a fresh + `MovementParameters()` with default flags — and the default has + ModifyInterpretedState=0. So per-axis re-application is purely a + PHYSICS push; the state itself stays as `copy_movement_from` + bulk-loaded it. + +--- + +## 15. InterpretedMotionState::ApplyMotion + +**Function:** `InterpretedMotionState::ApplyMotion` +**Address:** `0x0051ea40` +**Lines:** 293531–293564. + +```c +0051ea40 void ApplyMotion(this, uint32_t motion, params) +{ + if (motion == 0x6500000d /*TurnRight*/) { + this->turn_command = 0x6500000d; + this->turn_speed = params->speed; + return; + } + if (motion == 0x6500000f /*SideStepRight*/) { + this->sidestep_command = 0x6500000f; + this->sidestep_speed = params->speed; + return; + } + if ((motion & 0x40000000) != 0) { // any 0x4xxxxxxx (Walk/Run/Stand/Falling/Dead/etc) + this->forward_command = motion; + this->forward_speed = params->speed; + return; + } + if (motion < 0) { // 0x8xxxxxxx — style change + this->forward_command = 0x41000003 /*Ready*/; + this->current_style = motion; + return; + } + if ((motion & 0x10000000 /*Action*/) != 0) { + AddAction(this, motion, params->speed, params->action_stamp, + (params->__inner0 >> 0xc) & 1 /*autonomous bit*/); + } +} +``` + +**This drives the state machine when the engine is *generating* +motion locally (e.g. local player, MoveTo manager).** It is NOT what +the wire-driven path uses — the wire path uses +`copy_movement_from` (§7). + +This is the function ACE's `InterpretedMotionState.ApplyMotion` +mirrors verbatim. + +--- + +## 16. Outbound: CommandInterpreter::SendMovementEvent + +**Function:** `CommandInterpreter::SendMovementEvent` +**Address:** `0x006b4680` +**Lines:** 700274–700312. + +```c +006b4680 void SendMovementEvent(this) +{ + CPhysicsObj* player = this->player; + if (player != 0 && this->smartbox != 0 + && CPhysicsObj::InqRawMotionState(player) != 0) + { + if (this->autonomy_level != 0) // CLIENT IS IN CONTROL + { + uint16_t instTs = player->update_times[8]; + int32_t ctlTs = player->update_times[4]; + int32_t teleTs = player->update_times[5]; + int32_t forceTs= player->update_times[6]; + CMotionInterp* mi = CPhysicsObj::get_minterp(player); + // contact = (Contact && OnWalkable) + int32_t contact = (player->transient_state & 1) && (player->transient_state & 2); + + MoveToStatePack pkt; + MoveToStatePack::MoveToStatePack(&pkt, + CPhysicsObj::InqRawMotionState(player), // RAW state, not interpreted + &player->m_position, + contact, + mi->standing_longjump, + instTs, teleTs, ctlTs, forceTs); + + this->vtable->SendMoveToStateEvent(&pkt); // → 0xF61C wire + this->last_sent_position_time = Timer::cur_time; + } + } +} +``` + +**Critical facts:** + +- The **OUTBOUND packet (0xF61C MoveToState) uses the RawMotionState**, + not the InterpretedMotionState. RawState carries the player's + literal input (e.g. `forward_command = WalkForward`, + `forward_holdkey = Run`) — the server (or observer) does the + promotion via its own `apply_raw_movement` → `adjust_motion` chain. +- `RawMotionState::Pack` (`0x0051ed10`, lines 293761–293980) packs a + flag word with bits matching different fields than the interpreted + one: 0x01=holdkey, 0x02=style, 0x04=fwd_cmd, 0x08=fwd_holdkey, + 0x10=fwd_speed≠1.0, 0x20=side_cmd, 0x40=side_holdkey, 0x80=side_speed≠1, + 0x100=turn_cmd, 0x200=turn_holdkey, 0x400=turn_speed≠1, then 5-bit + action count starting at 0x800. + +This means the outbound flag layout is **different** from the +inbound InterpretedMotionState layout (different bit positions, plus +holdkey fields). When the local player presses W: +- `raw_state.forward_command = WalkForward` +- `raw_state.forward_holdkey = Run` (because shift not held) +- `raw_state.forward_speed = 1.0` (so flag 0x10 is CLEAR) + +When the local player **releases W** (stops walking forward): +- `raw_state.forward_command = Ready (0x41000003)` — the Ready + default → **flag 0x04 is CLEARED** +- `raw_state.forward_speed = 1.0` → flag 0x10 CLEARED +- (HoldKey may still be Run from the toggle, so flag 0x08 may be set) + +So a STOP is **the absence of forward_command on the wire**, plus +the absence of forward_speed. It's encoded as "both flag bits clear, +implicit defaults Ready/1.0." + +--- + +## 17. SendDoMovementEvent — slash-command only + +**Function:** `ACCmdInterp::SendDoMovementEvent` +**Address:** `0x0058b230` +**Lines:** 405442–405455. + +```c +0058b230 int32_t SendDoMovementEvent(this, motion, speed, holdKey) { + return CM_Movement::Event_DoMovementCommand(motion, speed, holdKey); +} +0058b250 int32_t SendStopMovementEvent(this, motion, holdKey) { + return CM_Movement::Event_StopMovementCommand(motion, holdKey); +} +``` + +These are the **single-action** outbound messages used by slash +commands and macros (e.g. `/say`, `/use`). The cdb live trace from +2026-05-01 confirmed `SendDoMovementEvent` is NOT in the WASD path — +WASD always goes through `SendMovementEvent` → MoveToState +(§16). The DoMovement / StopMovement events are for one-shot motion +commands only. + +--- + +## Answers to the critical questions + +### Q: When the local actor stops (releases W), what UM does retail SEND outbound? + +**A retail-format 0xF61C MoveToState packet.** The packet's +RawMotionState has `forward_command = Ready (0x41000003)` and +`forward_speed = 1.0`. Both flag bits 0x04 and 0x10 in the +RawMotionState's flag word are CLEARED. HoldKey may remain set. + +**There is no separate "stop motion" packet on the WASD path.** The +release of W simply produces another full MoveToState whose raw +state shows Ready+1.0. ACE's relay then re-emits this as a 0xF74C +UpdateMotion to nearby observers, with the InterpretedMotionState's +flag 0x02 cleared (no forward_command field). + +### Q: When observer receives that UM, what does CMotionInterp::DoMotion do? + +The observer's `MovementManager::unpack_movement` reads movement_type=0 +(RawCommand), then `InterpretedMotionState::UnPack` runs (§5). With +the wire's flag 0x02 clear, **forward_command defaults to Ready**. +Flag 0x04 clear → forward_speed defaults to 1.0. Flag 0x08 clear → +sidestep_command = 0. Flag 0x20 clear → turn_command = 0. + +Then `MovementManager::move_to_interpreted_state` → +`CMotionInterp::move_to_interpreted_state` (§7) runs: +1. `InterpretedMotionState::copy_movement_from` bulk-copies the + defaults into the body's interpreted state. +2. `apply_current_movement` → `apply_interpreted_movement` (§8) fires + `DoInterpretedMotion(Ready, ...)` for forward, + `StopInterpretedMotion(SideStepRight)` for sidestep, and + `StopInterpretedMotion(TurnRight)` for turn. +3. `get_state_velocity` returns (0, 0, 0) because forward_command is + Ready (matches neither WalkForward nor RunForward). +4. `set_local_velocity(0, 0, 0)` — body stops moving. + +**Note `DoMotion` itself is NOT called here.** The wire-driven +relay path uses `move_to_interpreted_state`, not `DoMotion`. +`DoMotion` is the LOCAL command path (e.g. `MoveToManager`, +slash commands, animation hooks). + +### Q: Does retail observer also have a "stop signal" path via UpdatePosition (separate from UM)? + +**No, not for the stop semantics.** UpdatePosition (0xF748) is for +position teleports / heartbeat re-syncs and goes through +`SmartBox::UnpackPositionEvent` (357185, line 357181 case 0xf748). +It does NOT touch InterpretedMotionState. Position can move the body +in space, but the locomotion command (Walk/Run/Ready) is purely +UM-driven. + +That said, if a player is moving and stops, the next AutonomousPosition +(0xF749/0xF75A) heartbeat from the server will keep the position +matching, but it's the UM that delivers the Ready transition. + +The local prediction layer (`SmartBox::QueueBlobForObject`) holds an +inbound UM if the entity is not yet known — but once known, every +inbound 0xF74C is processed by UnPack → move_to_interpreted_state in +order. + +### Q: How does sign-flipped backward (WalkForward + ForwardSpeed = -1) get processed? + +**Receiver side (UM observer / DoMotion local):** + +1. `InterpretedMotionState::UnPack` reads + `forward_command = 0x45000005 (WalkForward)` and + `forward_speed = -1.0f` verbatim from the wire. +2. `move_to_interpreted_state` → `copy_movement_from` writes those + into InterpretedState unchanged. +3. `apply_interpreted_movement` calls + `DoInterpretedMotion(WalkForward, params{speed=-1.0})`. +4. `DoInterpretedMotion` → `CPhysicsObj::DoInterpretedMotion` → + `get_state_velocity`: + - WalkForward branch hits, `velocity.Y = 3.12 × -1.0 = -3.12 m/s`. +5. `set_local_velocity` pushes a NEGATIVE Y velocity → body translates + backward in body-local frame. + +Critically: `adjust_motion` is **NOT** called on the receive path for +sign-flipped backward. It was already called at the SENDER (typically +the originating client's local `DoMotion`). Once the wire has +`WalkForward + speed=-1.0`, that's the canonical form. ApplyMotion +and copy_movement_from simply copy it. + +**On the SEND side**, the local `DoMotion(WalkBackward, +1.0)`: +1. `adjust_motion` flips: motion → WalkForward, speed → -0.65 × + BackwardsFactor. +2. If HoldKey == Run, `apply_run_to_command` checks `speed > 0` — + FALSE — so the motion stays WalkForward (no promotion to + RunForward), but speed gets multiplied by speedMod (runRate). +3. Final: motion=WalkForward, speed = -0.65 × runRate. +4. `RawMotionState::ApplyMotion` writes that back (when ModifyRawState + bit set), so the next outbound MoveToState carries + `forward_command=WalkForward, forward_speed=-0.65×runRate`. + +### Q: What's the difference between apply_run_to_command and DoInterpretedMotion? + +| Aspect | apply_run_to_command (305062) | DoInterpretedMotion (305575) | +|---|---|---| +| Purpose | **Modifier**: rewrite motion+speed for HoldKey.Run | **Action**: fire physics velocity push + queue + state update | +| Inputs | `motion*, speed*` (in/out), uses my_run_rate | `motion, MovementParameters` | +| Side effects | NONE (pure rewrite via ref params) | Updates InterpretedState (if flag set), enqueues motion, calls `CPhysicsObj::DoInterpretedMotion` (which calls `set_local_velocity`) | +| Promotes WalkForward → RunForward | YES (when speed > 0) | NO | +| Applies speedMod | YES (multiplies speed by runRate) | NO | +| Called from | `adjust_motion` (305388) when holdKey == Run | `DoMotion` (306211), `move_to_interpreted_state` (305983), `apply_interpreted_movement` (305744) | + +`apply_run_to_command` is a **command-rewriter** that runs once at +input time. `DoInterpretedMotion` is the **executor** that fires +many times per UM (once per forward/sidestep/turn axis). + +--- + +## Cross-reference with ACE's MotionInterp + +ACE's `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` +mirrors retail with high fidelity: + +| ACE method | Retail equivalent | Lines (retail) | Differences | +|---|---|---|---| +| `DoMotion` (l.112) | `CMotionInterp::DoMotion` | 306159 | Identical structure. Uses `ModifyRawState` flag from `MovementParameters`. | +| `DoInterpretedMotion` (l.51) | same name | 305575 | Identical. | +| `StopMotion` (l.367) | same | 305674 | Identical. | +| `StopInterpretedMotion` (l.329) | same | 305635 | Identical. | +| `StopCompletely` (l.301) | same | 305208 | Identical including `forward_command=Ready, speed=1, side=0, turn=0`. | +| `adjust_motion` (l.394) | same | 305343 | Identical. ACE uses `BackwardsFactor=-1` (?? check) — retail `-0.65`. ACE source shows `speed *= -BackwardsFactor` with a separate constant declaration; need to confirm value. | +| `apply_run_to_command` (l.525) | same | 305062 | Identical mappings. ACE `MaxSidestepAnimRate=3.0f` matches retail. | +| `contact_allows_move` (l.584) | same | 305471 | Identical. | +| `get_state_velocity` (l.678) | same | 305160 | Identical. ACE uses `WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25` matching retail. | +| `apply_interpreted_movement` (l.440) | same | 305713 | Identical re-application of forward → sidestep → turn. | +| `move_to_interpreted_state` (l.789) | same | 305936 | Identical. ACE's stamp comparison logic matches the 15-bit wrap. | + +ACE's port is faithful. The only deviations seen so far are in the +`apply_raw_movement` path which ACE uses for autonomous (player-self) +echoes, but this isn't on the L.3 critical path. + +--- + +## Cross-reference with acdream + +### `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2591) + +**This is acdream's UM handler. It DIVERGES from retail in important +structural ways.** + +acdream's path: +1. Pull `update.MotionState.Stance`, `ForwardCommand`, `ForwardSpeed` + etc. directly from the parsed wire packet. +2. For non-self entities, **directly mutate** + `remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion` + and `.ForwardSpeed = speedMod` (l.2860, 2868). This is + acdream's equivalent of `copy_movement_from` — but only for + ForwardCommand+ForwardSpeed, NOT a full bulk copy. +3. SideStep + Turn axes are handled separately via + `remoteMot.Motion.DoInterpretedMotion(...)` / + `StopInterpretedMotion(...)` (l.3073, 3079, 3109, 3121). +4. The animation sequencer is driven by a separately computed + `animCycle` with its own priority logic + (Forward → Sidestep → Turn → Ready) at l.2918–2952. + +### Divergences from retail: + +1. **No bulk `copy_movement_from`.** acdream only copies + ForwardCommand+ForwardSpeed when those wire bits change. Retail + always copies all 7 fields. Consequence: on a stop UM (no command + bits set), acdream's parser produces command=null and speed=null; + the assignment at l.2860 only fires when ForwardCommand changed. + **It's possible for InterpretedState fields to retain stale + values across stop UMs** if the parser logic doesn't normalize + absence to "Ready/1.0." (Need to audit + `WorldSession.EntityMotionUpdate.MotionState` — does it default + on absence the same way `InterpretedMotionState::UnPack` does? + Per CLAUDE.md memory entry on Phase L.X, the wire parser had + bits wrong before — flag mapping is now correct.) + +2. **No per-axis `apply_interpreted_movement` re-fire.** Retail's + `apply_interpreted_movement` re-runs `DoInterpretedMotion` for + each axis on every state change, which calls + `CPhysicsObj::DoInterpretedMotion` and ultimately + `get_state_velocity` → `set_local_velocity`. acdream's port skips + this re-fire — it relies on per-tick logic in `TickAnimations` to + pick up the InterpretedState change next frame. This is the + "staircase" issue noted in CLAUDE.md. + +3. **MotionInterpreter.cs `DoMotion` does not match retail.** + acdream's `MotionInterpreter.DoMotion` (l.381–395) just records + RawState and forwards to `DoInterpretedMotion(motion, speed, + modifyInterpretedState:true)`. Retail's `DoMotion` calls + `adjust_motion` first, then `DoInterpretedMotion`, then + conditionally `RawMotionState::ApplyMotion`. The acdream version + skips the `adjust_motion` call — meaning a local + `DoMotion(WalkBackward, +1.0)` would NOT get sign-flipped to + `WalkForward + -0.65`. (For the L.3 receiver path this doesn't + matter because the wire already carries the post-adjust form; + for the local-player command path it does matter and is a separate + bug.) + +4. **`get_state_velocity` (MotionInterpreter.cs l.587)** is faithful + to retail except it adds an Option-B path that reads from + `GetCycleVelocity` (the sequencer's MotionData.Velocity) when + available — overriding the hardcoded `RunAnimSpeed=4.0` constant + with the dat-baked velocity. This is a deliberate enhancement + for non-humanoid creatures (different MotionData scales) and is + noted in the comment block. It's safe because the max-speed clamp + below still uses `RunAnimSpeed × runRate`. + +5. **`StopInterpretedMotion` (MotionInterpreter.cs l.460)** does NOT + re-run `apply_interpreted_movement`. It only edits InterpretedState + then calls `apply_current_movement(false, false)` — which itself + doesn't re-fire per-axis like retail does. This matches the + retail single-stop semantics, but combined with #2 above it means + a single sidestep-clear UM doesn't immediately push zero X velocity + to the body. + +### Files to compare side-by-side during the L.3 port: + +- `src/AcDream.Core/Physics/MotionInterpreter.cs` — the executor +- `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` — the + receiver glue +- `src/AcDream.Core/Net/WorldSession.cs` (search for + `EntityMotionUpdate`) — the wire parser +- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` + — ACE's mirror +- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:305062–306268 + — retail source of truth. diff --git a/docs/research/2026-05-04-l3-port/03-up-routing.md b/docs/research/2026-05-04-l3-port/03-up-routing.md new file mode 100644 index 0000000..9fb7028 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/03-up-routing.md @@ -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 284343–284349 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 3508–3625 +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 284340–284362: `arg4==0` falls through to `return 0` | line 3570: `if (!update.IsGrounded) return;` ✓ | +| 2. Teleport stamp gate | line 284325–284337: `newer_event(TELEPORT_TS, arg3) → SetPosition(0x1012)` | **MISSING** — no teleport stamp comparison | +| 3. 96 m bubble | line 284343–284349: `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 | + diff --git a/docs/research/2026-05-04-l3-port/04-interp-manager.md b/docs/research/2026-05-04-l3-port/04-interp-manager.md new file mode 100644 index 0000000..cbfacf3 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/04-interp-manager.md @@ -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 : LListBase {}; + +struct __cppobj InterpolationManager +{ + LList 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 353139–353143 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`. diff --git a/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md b/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md new file mode 100644 index 0000000..28d573a --- /dev/null +++ b/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md @@ -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 614–679 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. diff --git a/docs/research/2026-05-04-l3-port/06-acdream-audit.md b/docs/research/2026-05-04-l3-port/06-acdream-audit.md new file mode 100644 index 0000000..b21802a --- /dev/null +++ b/docs/research/2026-05-04-l3-port/06-acdream-audit.md @@ -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 224–432) + +Per-remote nested struct stored on `_remoteDeadReckon[serverGuid]`. One +allocation per remote, lives until despawn. Owns: + +| Field | Verdict | Notes | +|---|---|---| +| `PhysicsBody Body` | PORT | Retail `CPhysicsObj` (acclient @0x00510000 region). Constructed with `Contact|OnWalkable|Active` flags and `ReportCollisions` state — gravity OFF by default, "remotes don't simulate gravity" comment at L420. Per L.3 spec this is **the correct retail invariant**. | +| `MotionInterpreter Motion` | PORT | Retail `CMotionInterp`. Body wired in ctor. | +| `LastServerPosTime/LastServerPos` | HACK | Diagnostic + dt-source for the legacy `update_object` path. | +| `ServerVelocity / HasServerVelocity` | HACK | Acdream-only: synthesises velocity from `(pos - prevPos)/dt` because ACE rarely sets HasVelocity on player UPs. Used only by `ApplyServerControlledVelocityCycle`. **Not present in retail.** | +| `ServerMoveToActive` | PORT | Bridges `MovementType::MoveToObject/Position` (6/7) to per-tick driver. Retail sets equivalent in `MoveToManager`. | +| `LastUmUpdateTime` | HACK | 200 ms grace window on UM authority for player remotes. Workaround for "Shift toggles Run↔Walk without firing a fresh UM" (issue #39). **Not retail.** | +| `MoveToDestinationWorld / MoveToMinDistance / MoveToDistanceToObject / MoveToMoveTowards / HasMoveToDestination / LastMoveToPacketTime` | PORT | Phase L.1c MoveTo state. Used by `RemoteMoveToDriver.Drive`. | +| `TargetOrientation` | DEAD | Comment: "legacy field — no longer used for slerp". Should delete. | +| `ObservedOmega` | HACK | Per-tick rotation rate seeded from `(π/2)×TurnSpeed` formula in `OnLiveMotionUpdated`. Bypasses `PhysicsBody.update_object`'s 30 Hz quantum gate. **Necessary because `update_object` skips most 60 Hz frames** — a real port problem. | +| `CellId` | PORT | High 16 bits = LBxLBy; fed into `ResolveWithTransition`. | +| `Airborne` | HACK | Set by `OnLiveVectorUpdated` when launch velocity has +Z>0.5; cleared by post-resolve `IsOnGround && Vel.Z<=0`. Retail tracks airborne via `Contact|OnWalkable` transient bits + Gravity flag. We carry an extra bool because we toggle Gravity manually. | +| `InterpolationManager Interp` | PORT | Owned per-remote. Only consumed when `ACDREAM_INTERP_MANAGER=1`. | +| `PositionManager Position` | PORT | Same. | +| `LastServerZ` | HACK | Landing-fallback floor for env-var path (gravity drift recovery). Not retail. | +| `PrevServerPos / *Time / Last*LogTime / MaxSeqSpeedSinceLastUP` | DIAG | All gated on `ACDREAM_REMOTE_VEL_DIAG=1`. | + +**Code-health note:** the struct has **31 public fields** spanning physics +state, server-snapshot cache, MoveTo path, diagnostic throttles, and +landing-fallback metadata. About half are workarounds for problems the +retail port doesn't have once we follow the spec; the L.3 refactor should +be able to remove `ServerVelocity/HasServerVelocity`, `LastUmUpdateTime`, +`LastServerZ`, `PrevServerPos*`, `Max*`, and `TargetOrientation`. + +--- + +## 2. `GameWindow.OnLiveMotionUpdated` — L2591–3214 + +Inbound `0xF74C UpdateMotion` handler. Receives an `EntityMotionUpdate` +with stance + ForwardCommand + ForwardSpeed + SideStepCommand + TurnCommand ++ optional MoveTo path payload. Roughly 600 lines. + +**Reads:** `update.MotionState` fields, `ae.Sequencer.{CurrentStyle, +CurrentMotion}`, `_remoteDeadReckon[guid]`, `_animatedEntities`, env vars. + +**Writes:** sequencer via `SetCycle` and `RouteFullCommand`/`RouteWireCommand`; +`rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed}` (bulk-copy); +`rm.ServerMoveToActive`; `rm.LastUmUpdateTime`; `rm.MoveToDestination*`; +`rm.HasMoveToDestination`; `rm.ObservedOmega` (formula seed); also calls +`rm.Motion.DoInterpretedMotion`/`StopInterpretedMotion` for sidestep/turn axes. + +### Verdict per sub-block + +| Sub-block | L# | Verdict | Notes | +|---|---|---|---| +| Diag `[UM_RAW]` and `ACDREAM_DUMP_MOTION` blocks | 2616–2646 | DIAG | Throw-away. | +| Player-only RunRate echo via `ApplyServerRunRate` | 2649–2656 | PORT | Local player only. Out-of-scope for remote audit. | +| Style preservation when `stance==0` | 2667–2671 | PORT | Retail bulk-copy semantics confirmed by named decomp. | +| Stop signal: `command absent OR command.Value==0 → Ready` | 2685–2707 | PORT | Retail `FUN_0051F260` bulk-copy of Invalid. | +| MoveTo seed via `PlanMoveToStart(...)` | 2687–2703 | PORT | Wraps `ServerControlledLocomotion`; aligns with retail `MoveToManager::BeginMoveForward`. | +| Skip-self block at 2757 (don't echo SetCycle for local player) | 2757–2761 | PORT | Local UM is authoritative on the local sequencer. | +| Action/Modifier/ChatEmote overlay route | 2764–2767, 2896–2906 | PORT | `AnimationCommandRouter.Classify`. | +| InterpretedState bulk-copy of `ForwardCommand/ForwardSpeed` for ALL packets (including overlay) | 2842–2868 | PORT | Mirrors retail `copy_movement_from` (`acclient_2013_pseudo_c.txt:293301`). Speed sign preserved. | +| MoveTo path capture | 2870–2893 | PORT | World-converts `OriginX/Y/Z`. | +| Cycle picker: forward → sidestep → turn → Ready priority | 2918–2953 | HACK-ish | Retail's `apply_current_movement` doesn't pick cycles like this; the SUB_STATE animation choice in retail is driven by the wire's ForwardCommand directly. Acdream's picker exists because we synthesise locomotion velocity from the cycle — we need a cycle to play even when only Sidestep or Turn axes are populated. **Re-evaluate during port**. | +| Skip-cycle-swap when airborne (K-fix17) | 2966 | HACK | Workaround for ACE broadcasting UMs mid-arc that would otherwise stomp Falling. Retail handles this via the substate priority; we hack around it. | +| Cycle-fallback chain RunForward → WalkForward → Ready | 2989–3027 | HACK | Defensive against MotionTables missing the requested cycle. Retail behavior unknown — likely a port artifact from MoveTo always seeding RunForward. Could be removed if upstream cycle picker matches retail substate. | +| `DoInterpretedMotion(side/turn axis)` + `StopInterpretedMotion` for absent axes | 3066–3127 | PORT | Stops are explicit; mirrors retail StopMotion semantics. | +| `ObservedOmega` formula seed: `(π/2)×TurnSpeed` signed | 3110–3127 | HACK | Compensates for `update_object` MinQuantum 30 Hz gate. Comment at 6610 explicitly notes this is to bypass that gate. **A real retail port wouldn't need this** — it would call `UpdatePhysicsInternal` directly OR fix the `update_object` substepping. | +| `Commands[]` list iteration, **skipping SubState entries** | 3182–3213 | PORT-with-FIX | 2026-05-03 fix: ACE bundles a Ready into the Commands[] list of a RunForward UM, which our router used to re-cycle to Ready right after we set RunForward. Skipping SubState class entries restored the cycle. | +| `enteringLocomotion` timestamp refresh | 3142–3160 | HACK | Stop-detection timer reset; the legacy stop-detection loop has been removed but this remnant still pokes `_remoteLastMove` and `LastServerPosTime`. Should be removable. | +| Legacy non-sequencer path | 3217–3236 | DEAD-ish | All player remotes have a sequencer — only fired for entities without MotionTable (rare). | + +**Critical observation:** every UM unconditionally bulk-copies into +`InterpretedState.{ForwardCommand,ForwardSpeed}`. That's correct vs. retail. +But it's the bulk-copy that arms `apply_current_movement` to write +`body.Velocity = RunAnimSpeed × ForwardSpeed`. Per the L.3 spec, **for +remote players body.Velocity should always be 0** — meaning `apply_current_movement` +must NOT be called per tick on remote bodies. The legacy path at L6599 +calls it. The env-var path at L6174 explicitly does NOT (and clears +`body.Velocity` to zero each tick at L6205). The two paths are +philosophically opposed. + +--- + +## 3. `GameWindow.OnLiveVectorUpdated` — L3259–3317 + +Inbound `0xF74E VectorUpdate` handler (jump/launch). + +**Reads:** `update.{Velocity,Omega}`, `_remoteDeadReckon`, +`_entitiesByServerGuid`, `_animatedEntities`. + +**Writes:** `rm.Body.{Velocity,Omega}`, `rm.Body.TransientState` (clears +Contact+OnWalkable), `rm.Body.State` (sets Gravity), `rm.Airborne=true`, +`ae.Sequencer.SetCycle(Falling, skipTransitionLink:true)`. + +**Verdict: PORT.** Mirrors retail `SmartBox::DoVectorUpdate` +(@0x004521C0). Sets velocity AND omega, K-fix9 marks airborne, K-fix10 +swaps Falling cycle, K-fix18 skips link. Threshold `Velocity.Z>0.5` to +gate Airborne is acdream-original but harmless. + +**Skips:** local player guid (`_playerServerGuid`). + +--- + +## 4. `GameWindow.ApplyServerControlledVelocityCycle` — L3325–3423 + +Helper that classifies a server-derived velocity into a Walk/Run/Ready +cycle and writes it to both the sequencer (visible cycle) and +`InterpretedState` (body velocity feed). + +**Reads:** `rm.{Airborne,LastUmUpdateTime,ServerMoveToActive}`, +`ae.Sequencer.{CurrentMotion,CurrentStyle,CurrentSpeedMod}`, env vars. + +**Writes:** `ae.Sequencer.SetCycle(...)`, +`rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed}`. + +**Verdict: HACK.** This whole function exists because: + +1. ACE doesn't broadcast HasVelocity on player UPs. +2. Retail clients don't broadcast a fresh UM on Shift-toggle Run↔Walk. + +So we infer cycle from synthesised position-delta velocity. The 200 ms UM +grace + `IsPlayerGuid` gate are workarounds for ACE-vs-retail timing +asymmetries. **None of this exists in retail.** Issue #39. + +The `IsPlayerGuid(serverGuid)` check at L3349 is one of three places we +use the player-vs-NPC distinction to gate behavior — see §10. + +--- + +## 5. `GameWindow.OnLivePositionUpdated` — L3425–3824 + +Inbound `0xF748 UpdatePosition` handler. Roughly 400 lines, with TWO +distinct code paths (env-var ON vs OFF). + +**Reads:** `update.{Guid,Position,IsGrounded,Velocity}`, +`_entitiesByServerGuid`, `_remoteDeadReckon`, `_liveCenterX/Y`, +`_playerController.{State,Position,StepUpHeight}`, `_physicsEngine`. + +**Writes:** `entity.{Position,Rotation}`, `rmState.Body.{Position, +Velocity,Orientation,LastUpdateTime,TransientState,State}`, +`rmState.{Airborne,CellId,LastServerPos,LastServerPosTime,LastServerZ, +ServerVelocity,HasServerVelocity,TargetOrientation,Interp}`. + +### Common prologue (L3432–3464) + +- World-converts the LB-local position via `(lbX-_liveCenterX)*192`. +- Snaps `entity.Position`/`entity.Rotation` to server truth UNCONDITIONALLY. +- Updates `_physicsEngine.ShadowObjects` for non-self guids. + +**Verdict: PORT** for the world conversion + entity snap. The +`ShadowObjects.UpdatePosition` mirrors retail `change_cell` / +`AddShadowObject`. + +### Env-var ON branch (L3512–3626) + +Only runs when `ACDREAM_INTERP_MANAGER=1`. + +- Hard-snaps `Body.Orientation = rot` (L3516). +- Tracks `LastServerZ` only for grounded UPs (L3529). +- Diagnostic VEL_DIAG block (L3537–3562). +- **AIRBORNE NO-OP** at L3570: `if (!IsGrounded) return;` — mirrors retail + `MoveOrTeleport` "no-op when has_contact==0" branch. +- **LANDING TRANSITION** at L3577: clears Airborne, restores ground + flags, hard-snaps `Body.Position = worldPos`, clears `Interp` queue, + resets sequencer cycle out of Falling. +- **GROUNDED ROUTING** at L3605: distance check `dist > 96f` → + `SetPositionSimple`-style snap (clear + write Body.Position); + otherwise enqueue waypoint via `Interp.Enqueue(...)`. + +**Verdict: PORT** of `CPhysicsObj::MoveOrTeleport` (acclient @0x00516330) +**but with one regression**: the env-var per-tick path (§7) drops the +collision sweep — see ISSUES.md #40. The OnLivePositionUpdated side here +is correct; the regression is in TickAnimations. + +### Env-var OFF branch (L3628–3761) — THE LEGACY PATH + +- Synthesises `serverVelocity = (worldPos - rmState.LastServerPos)/dt` + for ALL remotes when `update.Velocity` is null (L3634–3639). +- Sets `rmState.Body.Position = worldPos` UNCONDITIONALLY (L3650). +- Hard-snaps `rmState.Body.Orientation = rot` (L3686). +- Adopts `update.Velocity` if present, falls back to synth velocity for + NPCs (L3705–3730). +- HasVelocity<0.2 m/s magnitude → `StopCompletely` + sequencer Ready + (L3712–3725). **Verdict: PORT.** +- Calls `ApplyServerControlledVelocityCycle` for player remotes too + (L3737–3757). **Verdict: HACK** (issue #39). +- Final `entity.Position = rmState.Body.Position` snap at L3759. + +**Verdict overall: HACK.** The synth-velocity machinery is the +acdream-only solution to ACE's wire shape. Retail does NOT do this; retail +does pure waypoint-queue interpolation (the env-var branch). + +--- + +## 6. `GameWindow.TickAnimations` — env-var path (L6118–6445) + +Per-frame remote-motion tick when `ACDREAM_INTERP_MANAGER=1`. **Marked +DO-NOT-ENABLE** per ISSUES.md #40. + +**dt source:** `OnRender` passes `(float)deltaSeconds` from Silk.NET's +`OnRender(double deltaSeconds)` callback (L5610). **This is the +render-frame dt — variable, not stable. Typically ~16 ms at 60 Hz, but +spikes during landblock loads, GC, stalls.** + +### Step-by-step + +| Step | Verdict | Notes | +|---|---|---| +| 1. Force `Contact+OnWalkable+Active` for grounded; clear `body.Velocity = 0` (L6194–6206) | PORT | "Body Velocity should be 0 for grounded remotes" — exactly the L.3 spec invariant. | +| 2. `PositionManager.ComputeOffset(...)` returns either queue catch-up OR animation root motion; `body.Position += offset` (L6225–6232) | PORT | Mirrors `CPhysicsObj::UpdatePositionInternal` + `InterpolationManager::adjust_offset`. The REPLACE semantics in `PositionManager.ComputeOffset` match retail. | +| 2.5. Apply `ObservedOmega` (or seqOmega) via manual `Quaternion.Concatenate` (L6247–6277) | HACK | Manual integration to bypass MinQuantum gate. | +| 3. `body.calc_acceleration()` (L6283) | PORT | Retail `FUN_00511420`. | +| 4. `body.UpdatePhysicsInternal(dt)` (L6286) | PORT | Retail `FUN_005111D0`. With `body.Velocity=0` set in step 1, this is a no-op for grounded remotes — only airborne picks up gravity. | +| 4b. `_physicsEngine.ResolveWithTransition(preIntegrate, postIntegrate, ...)` (L6288–6373) | PORT | Added by Commit B (039149a) to fix the missing-collision regression. Mirrors retail `FUN_005148A0`. Sphere dims 0.48 m radius / 1.2 m height / 0.4 m step-up/down. | +| 5. Landing fallback: if airborne and `Body.Z < LastServerZ - 0.5`, force-land (L6387–6421) | HACK | Defensive against ACE not sending IsGrounded promptly. | +| 6. `MaxSeqSpeedSinceLastUP` diag (L6432–6441) | DIAG | Tracks max body-velocity magnitude for the VEL_DIAG ratio. | +| Final: `ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation` (L6443–6444) | PORT | The renderable consumes body state. | + +**Why marked broken:** ISSUES.md #40 says the staircase-on-slope and +position-blip bugs persist even with Commit B's collision sweep ported in. +Comment at L6131–6141 acknowledges that body.Velocity=0 means +pre/postIntegrate sweep input is just queue catch-up, which itself snaps +in 1 Hz UP-cadence steps. + +--- + +## 7. `GameWindow.TickAnimations` — legacy default path (L6446–6764) + +Per-frame remote-motion tick when env var is unset. **The CURRENT +default in production builds.** + +### Step-by-step + +| Step | Verdict | Notes | +|---|---|---| +| 1. Force grounded transient flags (L6488–6492) | HACK | Stomps any airborne flag fluctuation; "remotes are server-authoritative". | +| 1a. NPC HasServerVelocity branch: stale-after-X seconds → zero + cycle to Ready; otherwise `body.Velocity = ServerVelocity` (L6493–6511) | HACK | Synth-velocity continuation. | +| 1b. NPC ServerMoveToActive branch with destination: `RemoteMoveToDriver.Drive` + `apply_current_movement` + `ClampApproachVelocity` (L6512–6587) | PORT | Phase L.1c MoveTo per-tick steering. | +| 1c. `ServerMoveToActive` without destination → `body.Velocity = 0` (L6588–6596) | PORT | Conservative hold. | +| 1d. ELSE branch (everything else, including ALL player remotes): `rm.Motion.apply_current_movement(...)` (L6599) | **BROKEN per L.3 spec** | This is the bug. `apply_current_movement` reads `InterpretedState.ForwardCommand=RunForward + ForwardSpeed` and writes `body.Velocity = RunAnimSpeed × ForwardSpeed × orientation`. Per L.3 the player remote body should NEVER have non-zero velocity from this path; velocity should come solely from animation root motion + interpolation queue catch-up. | +| 2. Manual omega integration via `ObservedOmega` (L6622–6631) | HACK | Same MinQuantum bypass as env-var path. | +| 3. `body.calc_acceleration()` + `body.UpdatePhysicsInternal(dt)` (L6651–6653) | PORT | But because of 1d, body.Velocity is non-zero, so this **double-integrates** — the velocity drives translation here, then `ResolveWithTransition` resolves the Δ. | +| 4. `_physicsEngine.ResolveWithTransition(...)` (L6674–6760) | PORT | Same call as env-var path. | +| 4b. K-fix15 post-resolve landing (L6717–6759) | HACK | Same purpose as env-var step 5. | +| Final: `ae.Entity.Position = rm.Body.Position` (L6762) | PORT | | + +**This is the path the user sees in production.** The `apply_current_movement` +call on every tick at L6599 is **the central thing that the L.3 port has +to remove for player remotes.** Then we replace the per-tick translation +source with animation-root-motion + Interpolation-queue catch-up +(`PositionManager.ComputeOffset` from the env-var path) — but this time +WITH the collision sweep retained. + +--- + +## 8. `MotionInterpreter` (full file, 1023 LOC) + +**Verdict: PORT.** All key methods cite retail addresses: + +- `PerformMovement` (FUN_00529a90), `DoMotion` (FUN_00529930), + `DoInterpretedMotion`, `StopMotion`, `StopInterpretedMotion`, + `StopCompletely` (FUN_00528a50), `get_state_velocity` (FUN_00528960), + `apply_current_movement` (FUN_00529210), `jump` (FUN_00529390), + `get_jump_v_z`, `get_leave_ground_velocity`, `jump_is_allowed`, + `contact_allows_move`, `LeaveGround`, `HitGround`, `GetMaxSpeed` + (CMotionInterp::get_max_speed @0x00527cb0). + +`apply_current_movement` (L653–673) gates on `PhysicsObj.OnWalkable` +and calls `set_local_velocity(get_state_velocity())`. This is the +**single function that translates `InterpretedState` into +`body.Velocity`**. Per the L.3 spec, **this must NOT be called per tick on +remote players' bodies** — only the local player. + +`GetMaxSpeed()` (L972–985) returns `RunAnimSpeed × runRate` (≈ 11.76 m/s +for run-skill 200). This is the value passed to +`InterpolationManager.AdjustOffset` as the catch-up speed cap. + +Question 1 answer: **`body.Velocity` is currently NON-ZERO for player +remotes in the legacy default path**, set every tick by +`apply_current_movement` at L6599. Per L.3 spec it should always be 0. +This is the regression to fix. + +Question 2 answer: `apply_current_movement` is called from: + +- `PlayerMovementController.cs:273` (local player — correct). +- `GameWindow.cs:6567` (NPC MoveTo steering — correct). +- `GameWindow.cs:6599` (legacy default per-tick for ALL non-MoveTo + remotes — **incorrect per L.3 spec**). +- `MotionInterpreter` internally inside `DoInterpretedMotion` / + `StopInterpretedMotion` / `HitGround` (called from + `OnLiveMotionUpdated`'s sidestep+turn axis loops). + +It produces: `body.Velocity = world_rotation × Vector3(SidestepAnimSpeed×SideStepSpeed, +WalkOrRunAnimSpeed×ForwardSpeed, 0)`, clamped to `RunAnimSpeed × runRate`. + +--- + +## 9. `AnimationSequencer` (relevant fields, 1455 LOC total) + +**Verdict: PORT** of retail Sequence. Relevant API surface for the +remote-motion port: + +- `CurrentVelocity` (L246) — sequence-wide latest MotionData.Velocity × + speedMod, body-local. **Synthesised** for known locomotion cycles + (Walk/Run/SideStep) at L614–646 because the Humanoid MotionTable + ships HasVelocity=0 on those cycles. Synthesised values match retail + constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25). +- `CurrentMotion` / `CurrentStyle` / `CurrentSpeedMod` — sequencer's + active cycle. Read by everywhere. +- `SetCycle(style, motion, speedMod, skipTransitionLink)` — has fast-path + for identical motion (sign-matched) → `MultiplyCyclicFramerate`. Cycle + sign-flip path (e.g. positive→negative speed) takes the full rebuild + branch. **PORT**. +- `MultiplyCyclicFramerate(factor)` — scales every cyclic node's framerate + AND scales `CurrentVelocity *= factor`, `CurrentOmega *= factor`. Mirrors + retail `multiply_cyclic_animation_framerate`. **PORT**. +- `CurrentOmega` — synthesised from TurnRight/TurnLeft motion (`±π/2 × + speedMod`) when MotionData.Omega is silent. **PORT.** + +`CurrentVelocity` is **the canonical source for per-tick remote +translation** under the L.3 design. Already wired to +`MotionInterpreter.GetCycleVelocity` for the local player path; for +remotes, `PositionManager.ComputeOffset` reads it directly. + +--- + +## 10. `PhysicsBody` (full file, 436 LOC) + +**Verdict: PORT.** Every method cites a retail FUN address. + +Key fields used by the per-tick remote path: +- `Position` — written by `OnLivePositionUpdated` (hard-snap) AND by + per-tick `Position += offset` in env-var path AND by `UpdatePhysicsInternal`'s + `Position += Velocity*dt + 0.5*Accel*dt²` AND by `ResolveWithTransition`. +- `Velocity` — written by `OnLiveVectorUpdated` (jump launch), + `OnLivePositionUpdated` (HasVelocity adoption), `apply_current_movement` + via `set_local_velocity`, post-resolve landing (zero), and the env-var + per-tick path (forced to 0 each tick). +- `Orientation` — hard-snap on UP, manual integration via ObservedOmega + in both per-tick paths. +- `update_object` — has a `MinQuantum=1/30s` early-return that **silently + skips integration on 60 Hz frames**. This is why we call + `UpdatePhysicsInternal` directly from per-tick paths and do manual omega + integration. The retail intent was sub-stepping for variable dt; our + 60 Hz tick happens to fall under the gate. + +Question 3 answer (`body.Position` write sites): see §6 list. Counted +21 distinct write sites in `GameWindow.cs` alone. The L.3 port should +collapse this to a single canonical write per tick + a single hard-snap +write in OnLivePositionUpdated. + +Question 4 answer (per-tick render dt): L5610 — passed straight from +Silk.NET `OnRender(double deltaSeconds)`. **It is render-frame-rate- +dependent and not stable.** This is a known issue: any code that uses +this dt to integrate motion (e.g. `body.UpdatePhysicsInternal(dt)`, +`PositionManager.ComputeOffset(dt, ...)`, manual omega step) is +implicitly tied to render rate. Retail's `update_object` clamps dt to +[MinQuantum, MaxQuantum=0.1, HugeQuantum=2.0] and sub-steps; we bypass +that gate per §10. + +--- + +## 11. `InterpolationManager` (full file, 329 LOC) + +**Verdict: PORT.** Retail `InterpolationManager::adjust_offset` +(@0x00555D30) + UseTime stall/blip (@0x00555F20). Constants verified +from named binary. Only consumed when `ACDREAM_INTERP_MANAGER=1` (i.e. +the env-var path). + +API surface used: +- `Enqueue(targetPosition, heading, isMovingTo)` — called from + `OnLivePositionUpdated` env-var path L3623. +- `AdjustOffset(dt, currentBodyPosition, maxSpeedFromMinterp)` — + called from `PositionManager.ComputeOffset`. +- `Clear()` — on landing, on far-snap, on UP after stale. +- `IsActive` — diagnostic. + +Stall detection (5-frame window, `progress_quantum`, `node_fail_counter`) +is faithfully ported. **This whole machinery is currently unused in the +default build** — it's the env-var path's centerpiece. + +--- + +## 12. `PositionManager` (76 LOC) + +**Verdict: PORT.** Retail `CPhysicsObj::UpdatePositionInternal` +(@0x00512c30) + `InterpolationManager::adjust_offset` (@0x00555D30). + +`ComputeOffset(dt, currentBodyPosition, seqVel, ori, interp, maxSpeed)`: +1. Calls `interp.AdjustOffset(...)` for the queue catch-up vector. +2. If catch-up is non-zero (queue active and body far from head), + **returns the catch-up directly** — REPLACES the offset, doesn't add. +3. If catch-up is zero (queue empty or body within DesiredDistance), + **returns animation root motion**: `Vector3.Transform(seqVel * dt, + ori)`. + +**This is the canonical L.3 per-tick offset.** Currently only consumed +in the env-var path. + +--- + +## 13. `ServerControlledLocomotion` (129 LOC) + +**Verdict: PORT-ish + HACK.** Two functions: + +- `PlanMoveToStart(moveToSpeed, runRate, canRun)` — seeds RunForward (or + WalkForward if !canRun) for an inbound MoveTo packet. Retail + `MoveToManager::BeginMoveForward` + `MovementParameters::get_command`. + **PORT.** +- `PlanFromVelocity(worldVelocity, currentMotion)` — classifies a + velocity into Ready/Walk/Run with hysteresis bands (Walk→Run at 3.90, + Run→Walk at 3.43). **HACK** — this is the workaround for "ACE rarely + sets HasVelocity on player UPs." Retail doesn't classify like this; it + just plays the cycle the wire told it to play. + +--- + +## 14. `RemoteMoveToDriver` (304 LOC) + +**Verdict: PORT.** Retail `MoveToManager::HandleMoveToPosition` +(@0x00529d80). Steers body orientation toward destination, fires +arrival predicate, ports the ±20° HeadingSnapToleranceRad fudge. Used +only for NPC MoveTo packets — not on the player-remote path. Out of +scope for this audit's primary concern but listed because the spec +asked. + +`ClampApproachVelocity` (L260–293) is acdream-original belt-and-braces +to prevent overshoot in the final tick. **HACK** but harmless. + +--- + +## Specific question answers (§§ refs in §1–§14) + +1. **Is `body.Velocity` ever non-zero for player remotes?** + Yes, every tick of the legacy default path (L6599 + `apply_current_movement`) writes `body.Velocity = RunAnimSpeed×ForwardSpeed` + in world frame. Also briefly during jumps (`OnLiveVectorUpdated`) and + during stop UPs with `HasVelocity~0`. Per L.3 spec this should be 0 + except during airborne arcs. The legacy path is the regression. + +2. **Where is `apply_current_movement` called?** See §8. Three live + call sites: PlayerMovementController (correct), GameWindow MoveTo + steering (correct), GameWindow legacy per-tick for player remotes + (wrong per L.3). It produces body-local + `(SidestepAnimSpeed×SideStepSpeed, RunAnimSpeed×ForwardSpeed, 0)` + then rotates by orientation. + +3. **Where is `body.Position` written?** 21 sites in `GameWindow.cs`: + 2 in `OnLiveVectorUpdated`-adjacent code (none direct on Position), + ~5 in `OnLivePositionUpdated` (hard-snap), ~14 in `TickAnimations` + (env-var: 2 direct + 1 via Resolve; legacy: 2 + 1 via Resolve; + plus airborne/landing fallbacks). See L3585, L3614, L3650, L6232, + L6332, L6397, L6699 + the `ae.Entity.Position = rm.Body.Position` + mirrors at L3759, L6443, L6762. + +4. **Per-tick render dt source?** Silk.NET `OnRender(double deltaSeconds)` + → `TickAnimations((float)deltaSeconds)` at L5610. **Variable; tied to + render rate; no clamp before reaching `UpdatePhysicsInternal` / + `ComputeOffset`.** `PhysicsBody.update_object` would clamp this if + used, but we bypass it via direct `UpdatePhysicsInternal` calls. + +5. **Env-var vs legacy default relationship?** Two parallel per-tick + implementations forked at L6118 in `TickAnimations`. Env-var path + (L6118–6445) clears `body.Velocity=0` each tick and translates via + `PositionManager.ComputeOffset` (anim root motion OR queue catch-up). + Legacy path (L6446–6764) calls `apply_current_movement` to write + `body.Velocity` from InterpretedState then integrates via + `UpdatePhysicsInternal`. They have parallel `ResolveWithTransition` + collision sweeps (env-var added in Commit B as a regression fix). + The env-var path is the L.3 architecture but is currently REGRESSED + (issue #40 — staircase + blips). The legacy path is the production + default but is fundamentally wrong vs L.3 spec. + +6. **What does `entity.Position` (renderable) read from?** `body.Position` + only. Final assignment at L6443 (env-var) or L6762 (legacy) per tick; + hard-snap to server `worldPos` in `OnLivePositionUpdated` (L3451 then + over-written to `rmState.Body.Position` at L3759). + +7. **`IsPlayerGuid` gate sites:** L706 (definition), L3349 + (`ApplyServerControlledVelocityCycle` UM-grace branch), L3727 + (`OnLivePositionUpdated` velocity-adoption fallback), L6493/L6512/L6588 + (legacy per-tick branch selection between NPC paths and the catch-all + `apply_current_movement` else-branch). **All five are guards that route + player remotes through the broken `apply_current_movement` path.** A + port that drops the special player-vs-NPC distinction at the per-tick + layer would invert all five. + +--- + +# Summary + +## (a) Code health for player remote motion + +**Mixed-to-poor.** The retail-port primitives — `MotionInterpreter`, +`AnimationSequencer`, `PhysicsBody`, `InterpolationManager`, +`PositionManager`, and the inbound packet handlers' bulk-copy semantics +— are individually faithful and well-cited. But the per-tick integration +in `GameWindow.TickAnimations` has forked into two parallel paths +(env-var-gated `ACDREAM_INTERP_MANAGER=1` and legacy default), neither +of which currently ships the right behavior. The legacy default calls +`apply_current_movement` every tick on player remotes — directly violating +the L.3 invariant that body.Velocity should be 0 for grounded remotes — +and the env-var path drops too much (no body integration, body.Velocity +forced to 0 even mid-jump-arc until the recent Commit B partial-fix). Worse, +`OnLiveMotionUpdated` carries ~600 lines of cycle-picker logic, missing- +cycle-fallback chains, ObservedOmega formula seeding, and `IsPlayerGuid`- +gated workarounds that compensate for ACE wire shape vs. retail. The +`RemoteMotion` struct has 31 fields, half of which are workaround state +that the retail port shouldn't need. + +## (b) Top 3 things that need to change + +1. **Remove `apply_current_movement` from the per-tick remote path + entirely.** Replace with `PositionManager.ComputeOffset(dt, body.Position, + sequencer.CurrentVelocity, body.Orientation, interp, GetMaxSpeed())` + — the env-var path's translation source — but keep the + `ResolveWithTransition` collision sweep that the legacy path correctly + includes. `body.Velocity` stays at 0 except during airborne arcs. +2. **Collapse the env-var branch into the default and delete legacy.** + The fork is the bug; both paths should converge on the L.3 design with + `InterpolationManager` queue + animation root motion + collision sweep. + Remove `_remoteDeadReckon`'s `ServerVelocity / HasServerVelocity / + LastUmUpdateTime / LastServerZ / PrevServerPos*` workaround fields. +3. **Drop the `IsPlayerGuid` per-tick gate.** Retail runs the same motion + pipeline for every entity; the special-casing in + `ApplyServerControlledVelocityCycle` (issue #39 hysteresis) and the 3 + per-tick branch sites exist only because we synthesise velocity from + position deltas. Once the per-tick translation is anim-root-motion + driven, players and NPCs share one path and the gates can be inverted + or removed. + +## (c) Path to written audit + +`docs/research/2026-05-04-l3-port/06-acdream-audit.md` (this file). diff --git a/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md b/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md new file mode 100644 index 0000000..4ef12fc --- /dev/null +++ b/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md @@ -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 | diff --git a/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md b/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md new file mode 100644 index 0000000..76a68d9 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md @@ -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::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`): 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. diff --git a/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md b/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md new file mode 100644 index 0000000..7da9316 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md @@ -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 766–846): 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 614–650) 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 570–650 synth-velocity, + 766–846 per-keyframe loop, 1236–1330 advance/posFrame application). diff --git a/docs/research/2026-05-04-l3-port/10-vector-update-jump.md b/docs/research/2026-05-04-l3-port/10-vector-update-jump.md new file mode 100644 index 0000000..eb992f5 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/10-vector-update-jump.md @@ -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` diff --git a/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md b/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md new file mode 100644 index 0000000..dbb55dc --- /dev/null +++ b/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md @@ -0,0 +1,1029 @@ +# L.3 port — per-axis UM dispatch DEEP DIVE + +**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` +(Sept 2013 EoR build). All line numbers cite that file. + +This document picks up where `02-um-handling.md` left off: it goes +DEEPER into the per-axis dispatch chain that fires on each UM (and +on every per-tick re-application). It is the canonical reference for +"when an UpdateMotion arrives with forward+sidestep+turn populated, +what does retail call, in what order, and what does it write?" + +--- + +## 0. Ten-second summary + +`MovementManager::unpack_movement` (300563) reads movement_type=0, +calls `InterpretedMotionState::UnPack` (294360, see §5 of doc 02), then +calls `MovementManager::move_to_interpreted_state` (300259) which +delegates to **`CMotionInterp::move_to_interpreted_state`** (305936). + +That function does TWO things: + +1. **`InterpretedMotionState::copy_movement_from`** — bulk-overwrite + all 7 fields of the body's InterpretedState with the wire values + (current_style, fwd_cmd, fwd_speed, side_cmd, side_speed, turn_cmd, + turn_speed). Unconditional. No diffing. +2. **`apply_current_movement(cancelMoveTo=1, allowJump=…)`** (305838), + which routes to either `apply_raw_movement` (autonomous local + player) or **`apply_interpreted_movement`** (305713) — the latter + is what fires for remote observers. + +`apply_interpreted_movement` is the **per-axis dispatcher**. It calls +`DoInterpretedMotion` (305575) for each of: current_style, forward +axis, sidestep axis (or stop), turn axis (or stop). Each call ends in +`CPhysicsObj::DoInterpretedMotion` → `set_local_velocity(get_state_velocity())`. + +**The "staircase" bug acdream's env-var path showed = absence of this +per-axis dispatch on the per-tick remote driver.** The default acdream +path runs `apply_current_movement` (correct) on UM intake, but the +env-var experimental path bypassed it and only ran the manager's +position lerp — so velocity was never re-derived, and Z stayed +flat between UMs even when the wire said "running up a slope." + +--- + +## 1. CMotionInterp::PerformMovement (the top-level command pump) + +**Function:** `CMotionInterp::PerformMovement` +**Address:** `0x00528e80` +**Lines:** 306221–306268. + +```c +00528e80 uint32_t PerformMovement(this, MovementStruct const* arg2) +{ + int32_t ecx_1 = (arg2->type - 1); + if (ecx_1 > 4) return 0x47; // BadMovementType + switch (ecx_1) { + case 0: /* DoMotion */ + uint32_t eax = DoMotion(this, arg2->motion, arg2->params); + CPhysicsObj::CheckForCompletedMotions(this->physics_obj); + return eax; + case 1: /* DoInterpretedMotion */ + uint32_t eax_2 = DoInterpretedMotion(this, arg2->motion, arg2->params); + CPhysicsObj::CheckForCompletedMotions(this->physics_obj); + return eax_2; + case 2: /* StopMotion */ + uint32_t eax_4 = StopMotion(this, arg2->motion, arg2->params); + CPhysicsObj::CheckForCompletedMotions(this->physics_obj); + return eax_4; + case 3: /* StopInterpretedMotion */ + uint32_t eax_6 = StopInterpretedMotion(this, arg2->motion, arg2->params); + CPhysicsObj::CheckForCompletedMotions(this->physics_obj); + return eax_6; + case 4: /* StopCompletely */ + StopCompletely(this); + CPhysicsObj::CheckForCompletedMotions(this->physics_obj); + return 0; + } +} +``` + +**Behavior:** dispatch by `MovementStruct::type` (1..5): + +| type | meaning | handler | +|---|---|---| +| 1 | DoMotion (raw, with adjust_motion) | DoMotion (306159) | +| 2 | DoInterpretedMotion (already adjusted) | DoInterpretedMotion (305575) | +| 3 | StopMotion (raw stop) | StopMotion (305674) | +| 4 | StopInterpretedMotion (interpreted stop) | StopInterpretedMotion (305635) | +| 5 | StopCompletely | StopCompletely (305208) | + +The wrapping `MovementManager::PerformMovement` (300194) chooses +between this command pump (types 1–5 → `CMotionInterp`) and the +MoveTo manager (types 6–9 → `MoveToManager`). **Inbound 0xF74C +RawCommand packets do NOT call PerformMovement** — they call +`move_to_interpreted_state` directly. PerformMovement is the API for +LOCAL command sources (CommandInterpreter, MoveToManager re-emits, +slash commands). + +--- + +## 2. CMotionInterp::DoMotion (the raw command path) + +**Function:** `CMotionInterp::DoMotion` +**Address:** `0x00528d20` +**Lines:** 306159–306217. + +```c +00528d20 uint32_t DoMotion(this, uint32_t motion, MovementParameters const* params) +{ + if (physics_obj == 0) return 8; + + uint32_t ebp = motion; // saved unmodified + + if ((params->__inner0.byte1 & 0x80) != 0) // CancelMoveTo bit + CPhysicsObj::interrupt_current_movement(physics_obj); + + if ((params->__inner0.byte1 & 0x08) != 0) // SetHoldKey bit + SetHoldKey(this, params->hold_key_to_apply, ((params->__inner0 >> 0xf) & 1)); + + adjust_motion(this, &motion, &speed, params->hold_key_to_apply); + + // Combat-style guards on RAW (pre-adjust) motion + if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) { + if (ebp == 0x41000012) return 0x3f; // CantCrouchInCombat + if (ebp == 0x41000013) return 0x40; // CantSitInCombat + if (ebp == 0x41000014) return 0x41; // CantSleepInCombat + if ((ebp & 0x02000000) != 0) return 0x42; // CantChatEmoteInCombat + } + + // Action quota check on RAW (pre-adjust) motion + if ((ebp & 0x10000000) != 0 + && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6) + return 0x45; // TooManyActions + + uint32_t result = DoInterpretedMotion(this, motion /*adjusted*/, &var_2c); + + if (result == 0 && (params->__inner0.byte1 & 0x20) != 0) // ModifyRawState bit + RawMotionState::ApplyMotion(&this->raw_state, ebp /*pre-adjust*/, params); + + return result; +} +``` + +**Per-axis behavior:** DoMotion is a **single-axis** entry. It dispatches +ONE motion (passed as `arg2`) through `adjust_motion` → DoInterpretedMotion. +For multi-axis local input, the caller fires DoMotion three times +(once for forward, once for side, once for turn). Acdream's +`MotionInterpreter.DoMotion` (l.381) is structurally close BUT lacks +the `adjust_motion` call — meaning local `DoMotion(WalkBackward, +1.0)` +never gets sign-flipped to `WalkForward + -0.65`. (See §13 below.) + +The **RawState.ApplyMotion uses the PRE-adjust `ebp`**, the +InterpretedState path inside DoInterpretedMotion uses the post-adjust +`motion`. This is what lets the wire carry both "user said walk +backward" (raw) and "physics ran walk forward at -0.65" (interpreted) +on the same packet. + +--- + +## 3. CMotionInterp::adjust_motion (the canonical sign-flipper) + +**Function:** `CMotionInterp::adjust_motion` +**Address:** `0x00528010` +**Lines:** 305343–305400. + +Already documented in detail in `02-um-handling.md` §10. Repeating the +mappings table here for reference: + +| Input | Speed | Output | Speed | +|---|---|---|---| +| WalkForward (0x45000005) | s | WalkForward | s | +| WalkBackward (0x45000006) | s | WalkForward | -0.65 × s | +| SideStepLeft (0x6500000e) | s | SideStepRight | -s | +| ??? (0x65000010) alias | s | SideStepRight | -s | +| SideStepRight (0x6500000f) | s | SideStepRight | 1.248 × s | +| RunForward (0x44000007) | s | RunForward | s (no change here) | + +Then if `holdKey == HoldKey_Run`: **`apply_run_to_command(this, &motion, &speed)`** runs. + +--- + +## 4. CMotionInterp::apply_run_to_command (HoldKey rewriter) + +**Function:** `CMotionInterp::apply_run_to_command` +**Address:** `0x00527be0` +**Lines:** 305062–305123. + +Documented in `02-um-handling.md` §11. Key facts: + +- WalkForward + speed > 0 → **promotes to RunForward**, multiplies speed by speedMod (runRate). +- WalkForward + speed ≤ 0 → stays WalkForward, multiplies speed by speedMod (so backward stays backward but at run-pace × 0.65). +- TurnRight → speed *= 1.5 (RunTurnFactor). +- SideStepRight → speed *= speedMod, clamp |result| ≤ 3.0 (MaxSidestepAnimRate). + +--- + +## 5. CMotionInterp::DoInterpretedMotion (the leaf executor) + +**Function:** `CMotionInterp::DoInterpretedMotion` +**Address:** `0x00528360` +**Lines:** 305575–305631. + +```c +00528360 uint32_t DoInterpretedMotion(this, motion, params) +{ + if (physics_obj == 0) return 8; + uint32_t result; + + if (contact_allows_move(this, motion) != 0) { + if (this->standing_longjump != 0 + && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)) + { + // mid-longjump: skip engine-side action, just touch InterpretedState if asked + if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + result = 0; + } + else { + if (motion == 0x40000011 /*Dead*/) + CPhysicsObj::RemoveLinkAnimations(this->physics_obj); + + // The HOT call: pushes velocity through CPhysicsObj + result = CPhysicsObj::DoInterpretedMotion(this->physics_obj, motion, params); + if (result == 0) { + uint32_t jumpErr; + if ((params->__inner0 & 0x20000) == 0) { + jumpErr = motion_allows_jump(this, motion); + if (jumpErr == 0 && (motion & 0x10000000) == 0) + jumpErr = motion_allows_jump(this, this->interpreted_state.forward_command); + } else { + jumpErr = 0x48; /*disable*/ + } + add_to_queue(this, params->context_id, motion, jumpErr); + if (params->__inner0.byte1 & 0x40) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + } + } + } + else if ((motion & 0x10000000 /*Action*/) == 0) { + // Airborne but motion isn't an Action — just touch InterpretedState if asked + if (params->__inner0.byte1 & 0x40) + InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); + result = 0; + } + else result = 0x24 /*YouCantJumpWhileInTheAir*/; + + if (physics_obj != 0 && physics_obj->cell == 0) + CPhysicsObj::RemoveLinkAnimations(physics_obj); + + return result; +} +``` + +**This is the function `apply_interpreted_movement` calls 3× per UM** +(once each for forward/sidestep/turn axes). The actual physics velocity +push is inside `CPhysicsObj::DoInterpretedMotion` — that's where +`set_local_velocity(get_state_velocity(...))` happens, AND that's where +the animation sequencer is driven by `set_motion_table_data`. + +**Critical:** when called from `apply_interpreted_movement`, `params` +is a fresh `MovementParameters()` with default flags — and the default +has **`ModifyInterpretedState = 0`**. So per-axis re-application is +purely a PHYSICS push; the state itself stays as `copy_movement_from` +bulk-loaded it. + +--- + +## 6. CMotionInterp::apply_interpreted_movement (THE per-axis dispatcher) + +**Function:** `CMotionInterp::apply_interpreted_movement` +**Address:** `0x00528600` +**Lines:** 305713–305788. + +This is the heart of the per-axis dispatch. **Every UM that reaches +`move_to_interpreted_state` ends here**, and so does every state-change +that goes through `apply_current_movement` (e.g. landing, leave-ground, +SetWeenieObject, SetPhysicsObject, ReportExhaustion). + +```c +00528600 void apply_interpreted_movement(this, int32_t arg2 /*cancelMoveTo*/, int32_t arg3 /*allowJump*/) +{ + if (physics_obj == 0) return; + + MovementParameters var_2c; + MovementParameters::MovementParameters(&var_2c); // default flags: NO ModifyState, NO ModifyRaw + + // (1) Cache MyRunRate from forward_speed if we are in run state + if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) + this->my_run_rate = this->interpreted_state.forward_speed; + + // (2) STYLE axis — re-apply current_style as a motion (combat ↔ peace, etc) + DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c); + + // (3) FORWARD axis (with airborne / longjump branches) + if (contact_allows_move(this, this->interpreted_state.forward_command) == 0) + { + // Airborne / dead — force Falling animation + DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c); + } + else if (this->standing_longjump != 0) + { + // In a charged longjump — pin forward to Ready, kill any sidestep + DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); + StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); + } + else + { + // (3a) FORWARD: dispatch the wire-supplied forward command verbatim + var_2c.speed = this->interpreted_state.forward_speed; + DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c); + + // (3b) SIDESTEP: dispatch OR explicit-stop + if (this->interpreted_state.sidestep_command == 0) + StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); + else { + var_2c.speed = this->interpreted_state.sidestep_speed; + DoInterpretedMotion(this, this->interpreted_state.sidestep_command, &var_2c); + } + } + + // (4) TURN axis: dispatch OR explicit-stop + uint32_t turn_command = this->interpreted_state.turn_command; + if (turn_command != 0) + { + var_2c.speed = this->interpreted_state.turn_speed; + DoInterpretedMotion(this, turn_command, &var_2c); + return; + } + // turn_command == 0 → explicit stop on TurnRight, plus a Ready add_to_queue + uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c); + if (eax_10 == 0) { + add_to_queue(this, var_c, 0x41000003 /*Ready*/, eax_10); + if (params.byte1 & 0x40) + InterpretedMotionState::RemoveMotion(&this->interpreted_state, 0x6500000d); + } +} +``` + +### Per-axis dispatch order (CANONICAL) + +When a UM with all three axes populated arrives, retail does: + +1. `DoInterpretedMotion(current_style, default_params)` — applies + stance (Combat/Peace/Magic/etc.). +2. **If airborne**: `DoInterpretedMotion(Falling)` — forward axis is + skipped, body animates as Falling. +3. **Else if longjump**: `DoInterpretedMotion(Ready)` then + `StopInterpretedMotion(SideStepRight)` — forward pinned, sidestep + killed. +4. **Else (normal grounded path)**: + a. `DoInterpretedMotion(forward_command, params{speed=fwd_speed})` + b. If `sidestep_command == 0`: `StopInterpretedMotion(SideStepRight)` + Else: `DoInterpretedMotion(sidestep_command, params{speed=side_speed})` +5. If `turn_command != 0`: `DoInterpretedMotion(turn_command, params{speed=turn_speed})` and **RETURN**. +6. If `turn_command == 0`: `CPhysicsObj::StopInterpretedMotion(TurnRight, default_params)` directly on physics_obj (skipping the CMotionInterp wrapper), plus `add_to_queue(Ready)`. + +**Note the asymmetry:** the turn-stop bypasses CMotionInterp's +StopInterpretedMotion and goes straight to CPhysicsObj. This is +because the wrapper would re-enter the contact_allows_move check (turns +are always allowed per `contact_allows_move`'s special case), and the +direct call ensures the StopInterpretedMotion fires regardless of state. + +--- + +## 7. CMotionInterp::apply_current_movement (the dispatcher chooser) + +**Function:** `CMotionInterp::apply_current_movement` +**Address:** `0x00528870` +**Lines:** 305838–305857. + +```c +00528870 void apply_current_movement(this, int32_t arg2, int32_t arg3) +{ + if (physics_obj == 0 || initted == 0) return; + + int32_t isPlayer = (weenie_obj != 0) ? weenie_obj->vtable->IsThePlayer() : 0; + + // Local player + autonomous → use RAW path (so HoldKey gets re-applied via adjust_motion) + if ((weenie_obj == 0 || isPlayer != 0) + && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0) + { + apply_raw_movement(this, arg2, arg3); + return; + } + + // Everyone else → INTERPRETED path + apply_interpreted_movement(this, arg2, arg3); +} +``` + +**This is the bifurcation point**: local autonomous player runs +`apply_raw_movement`; remote observer runs `apply_interpreted_movement`. + +Acdream's port (`MotionInterpreter.apply_current_movement`, l.653) is +**a heavily-simplified single-shot** that does NOT branch and does NOT +re-fire per-axis — it just calls `set_local_velocity(get_state_velocity())` +once, gated on `OnWalkable`. **This is the structural divergence.** + +--- + +## 8. CMotionInterp::apply_raw_movement (LOCAL player path) + +**Function:** `CMotionInterp::apply_raw_movement` +**Address:** `0x005287e0` +**Lines:** 305817–305834. + +```c +005287e0 void apply_raw_movement(this, arg2, arg3) +{ + if (physics_obj == 0) return; + + // Bulk-copy 7 fields from RAW → INTERPRETED + this->interpreted_state.current_style = this->raw_state.current_style; + this->interpreted_state.forward_command = this->raw_state.forward_command; + this->interpreted_state.forward_speed = this->raw_state.forward_speed; + this->interpreted_state.sidestep_command = this->raw_state.sidestep_command; + this->interpreted_state.sidestep_speed = this->raw_state.sidestep_speed; + this->interpreted_state.turn_command = this->raw_state.turn_command; + this->interpreted_state.turn_speed = this->raw_state.turn_speed; + + // Re-run adjust_motion ONCE PER AXIS (so HoldKey.Run promotes Walk→Run, etc.) + adjust_motion(this, &interpreted_state.forward_command, + &interpreted_state.forward_speed, + raw_state.forward_holdkey); + adjust_motion(this, &interpreted_state.sidestep_command, + &interpreted_state.sidestep_speed, + raw_state.sidestep_holdkey); + adjust_motion(this, &interpreted_state.turn_command, + &interpreted_state.turn_speed, + raw_state.turn_holdkey); + + // Then dispatch each axis through DoInterpretedMotion (per-axis re-fire) + apply_interpreted_movement(this, arg2, arg3); +} +``` + +**Crucial:** the local player path **also** ends in +`apply_interpreted_movement`. The only difference is that it first +copies RAW → INTERPRETED and runs `adjust_motion` per axis. The +per-axis dispatcher is the same. + +This means there is **exactly ONE per-axis dispatch path** in retail: +`apply_interpreted_movement`. Both local autonomous and remote +observer go through it. + +--- + +## 9. CMotionInterp::move_to_interpreted_state (the UM entry) + +Already covered in `02-um-handling.md` §7. Quick recap: + +```c +005289c0 int32_t move_to_interpreted_state(this, InterpretedMotionState const* arg2) +{ + if (physics_obj == 0) return 0; + + this->raw_state.current_style = arg2->current_style; // raw mirrors style + CPhysicsObj::interrupt_current_movement(physics_obj); + + uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); + + InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // BULK COPY + apply_current_movement(this, 1 /*cancelMoveTo*/, !!allowJump); // PER-AXIS REFIRE + + // Then iterate actions[] with stamp-wrap protection + for each action in arg2->actions where stamp_is_newer: + DoInterpretedMotion(this, action.motion, params{action_stamp, autonomous}); + + return 1; +} +``` + +**copy_movement_from** is unconditional — overwrites all 7 fields: + +```c +InterpretedMotionState::copy_movement_from (lines 293301-293311): + this->current_style = src->current_style; + this->forward_command = src->forward_command; + this->forward_speed = src->forward_speed; + this->sidestep_command = src->sidestep_command; + this->sidestep_speed = src->sidestep_speed; + this->turn_command = src->turn_command; + this->turn_speed = src->turn_speed; +``` + +No diffing. No filter. **If the wire said it, it's in InterpretedState now.** + +--- + +## 10. CMotionInterp::StopInterpretedMotion + +**Function:** `CMotionInterp::StopInterpretedMotion` +**Address:** `0x00528470` +**Lines:** 305635–305670. + +```c +00528470 uint32_t StopInterpretedMotion(this, motion, params) +{ + if (physics_obj == 0) return 8; + + uint32_t result; + + bool airborne_skip = (contact_allows_move(this, motion) == 0) + || (standing_longjump != 0 + && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)); + + if (airborne_skip) { + if (params.byte1 & 0x40 /*ModifyInterpretedState*/) + InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); + result = 0; + } else { + result = CPhysicsObj::StopInterpretedMotion(this->physics_obj, motion, params); + if (result == 0) { + add_to_queue(this, params->context_id, 0x41000003 /*Ready*/, result); + if (params.byte1 & 0x40) + InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); + } + } + + if (physics_obj != 0 && physics_obj->cell == 0) + CPhysicsObj::RemoveLinkAnimations(physics_obj); + + return result; +} +``` + +--- + +## 11. CMotionInterp::StopMotion + +**Function:** `CMotionInterp::StopMotion` +**Address:** `0x00528530` +**Lines:** 305674–305708. + +```c +00528530 uint32_t StopMotion(this, motion, params) +{ + if (physics_obj == 0) return 8; + + if (params.byte1 & 0x80) CPhysicsObj::interrupt_current_movement(physics_obj); + + // Capture all params fields locally (because adjust_motion will mutate motion+speed) + float speed = params.speed; + HoldKey hk = params.hold_key_to_apply; + + arg2 = motion; + int32_t var_2c = 0x7c83f8; // some default flags + adjust_motion(this, &arg2, &speed, hk); // sign-flip + run-promote + + uint32_t result = StopInterpretedMotion(this, arg2, &var_2c); + + if (result == 0 && (params.byte1 & 0x20 /*ModifyRawState*/) != 0) + RawMotionState::RemoveMotion(&this->raw_state, motion); // PRE-adjust motion + + return result; +} +``` + +Same pattern as DoMotion: adjust_motion the motion+speed, dispatch the +adjusted form to StopInterpretedMotion, then optionally update RawState +with the PRE-adjust form. + +--- + +## 12. CMotionInterp::StopCompletely + +**Function:** `CMotionInterp::StopCompletely` +**Address:** `0x00527e40` +**Lines:** 305208–305234. + +```c +00527e40 uint32_t StopCompletely(this) +{ + if (physics_obj == 0) return 8; + + CPhysicsObj::interrupt_current_movement(physics_obj); + uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); + + // Reset RAW (5 fields) + this->raw_state.forward_command = 0x41000003 /*Ready*/; + this->raw_state.forward_speed = 1.0f; + this->raw_state.sidestep_command = 0; + this->raw_state.turn_command = 0; + + // Reset INTERPRETED (5 fields — note: side_speed and turn_speed NOT reset here!) + this->interpreted_state.forward_command = 0x41000003; + this->interpreted_state.forward_speed = 1.0f; + this->interpreted_state.sidestep_command = 0; + this->interpreted_state.turn_command = 0; + + CPhysicsObj::StopCompletely_Internal(this->physics_obj); + add_to_queue(this, 0, 0x41000003 /*Ready*/, allowJump); + + if (physics_obj != 0 && physics_obj->cell == 0) + CPhysicsObj::RemoveLinkAnimations(physics_obj); + + return 0; +} +``` + +**Note:** `sidestep_speed` and `turn_speed` are NOT reset to 1.0f +(neither in raw nor interpreted). Only the commands and forward_speed +are touched. Acdream's port (l.510) goes further and resets all six +speeds — slightly more aggressive than retail but harmless because +the speeds are only consumed when the matching command is non-zero. + +--- + +## 13. CMotionInterp::get_max_speed and get_adjusted_max_speed + +**Function:** `CMotionInterp::get_max_speed` +**Address:** `0x00527cb0` +**Lines:** 305127–305141. + +```c +00527cb0 void get_max_speed(this) +{ + CMotionInterp* this_1 = this; // probably an out-param + CWeenieObject* weenie = this->weenie_obj; + this_1 = nullptr; + + if (weenie == 0) return; + if (weenie->vtable->InqRunRate(&this_1) != 0) return; + this->my_run_rate; // (compiler artifact — load only) +} +``` + +The decompile is hard to read because of x87 return-value handling, but +semantically: **return InqRunRate(weenie) if available, else my_run_rate**. +Used by `set_target_movement` (`0x00509ed5` references in 352395, 353112). + +**Function:** `CMotionInterp::get_adjusted_max_speed` +**Address:** `0x00527d00` +**Lines:** 305145–305156. + +```c +00527d00 void get_adjusted_max_speed(this) +{ + CWeenieObject* weenie = this->weenie_obj; + if (weenie != 0 && weenie->vtable->InqRunRate(&this_1) == 0) + this->my_run_rate; // load + if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) + ((long double)this->interpreted_state.forward_speed) / ((long double)this->current_speed_factor); +} +``` + +Adjusts the cap by the **current_speed_factor** (an internal multiplier +e.g. for encumbrance / spell effects) when the body is in run state. + +Both functions return a float used as the speed cap inside +`get_state_velocity` (305160) — `len > 4.0L * rate` clamp scale. + +--- + +## 14. InterpretedMotionState::UnPack — flag bits in detail + +(Documented in `02-um-handling.md` §5; restating the bit table here +since this doc is the dispatch reference.) + +| Bit | Field | When CLEAR | +|---|---|---| +| 0x01 | `current_style` | NonCombat (0x8000003d) | +| 0x02 | `forward_command` | Ready (0x41000003) | +| 0x04 | `forward_speed` | 1.0f | +| 0x08 | `sidestep_command` | 0 | +| 0x10 | `sidestep_speed` | 1.0f | +| 0x20 | `turn_command` | 0 | +| 0x40 | `turn_speed` | 1.0f | +| 0x80–0x800 | action count (5 bits) | 0 | + +**Default-on-absence is the per-axis SEMANTIC that drives the stop +signal.** When ACE relays a stop, it omits the forward_command field +(bit 0x02 clear), and UnPack sets `forward_command = Ready`. The +copy_movement_from then writes Ready into InterpretedState. The next +`apply_interpreted_movement` calls `DoInterpretedMotion(Ready, +speed=1.0)`. `get_state_velocity` returns (0,0,0) because Ready is +neither WalkForward nor RunForward. + +--- + +## 15. The complete dispatch sequence for an inbound UM (annotated) + +For a UM with all three axes populated (forward + sidestep + turn) on +a remote observer, **the full call chain** is: + +``` +0xF74C wire packet + CM_Physics::DispatchSB_* (357214) + CPhysics::SetObjectMovement (271370) + CPhysicsObj::unpack_movement (280179) + MovementManager::unpack_movement (300563) + // read 2 bytes movement_type=0 + // read 2 bytes initial_style + if (style != current) DoMotion(style) (306159) <- one DoMotion for STANCE + InterpretedMotionState::UnPack (294360) + // read flags uint32 + each conditional field + MovementManager::move_to_interpreted_state(300259) + CMotionInterp::move_to_interpreted_state(305936) + raw_state.current_style = src.current_style + interrupt_current_movement + motion_allows_jump(prev forward_command) + InterpretedMotionState::copy_movement_from // BULK COPY 7 FIELDS + apply_current_movement(cancelMoveTo=1, allowJump) (305838) + if (local autonomous): + apply_raw_movement (305817) + bulk-copy raw → interpreted + adjust_motion × 3 (forward, sidestep, turn) + apply_interpreted_movement (305713) + else: // remote observer + apply_interpreted_movement (305713) + cache MyRunRate if RunForward + DoInterpretedMotion(current_style) // STYLE + CPhysicsObj::DoInterpretedMotion (varies) + if !contact_allows_move: + DoInterpretedMotion(Falling) // FORWARD branch (airborne) + elif standing_longjump: + DoInterpretedMotion(Ready) + StopInterpretedMotion(SideStepRight) + else: + DoInterpretedMotion(forward_command) // FORWARD axis + CPhysicsObj::DoInterpretedMotion + set_local_velocity(get_state_velocity) + // VELOCITY PUSH FOR FORWARD + if (sidestep_command == 0): + StopInterpretedMotion(SideStepRight) + else: + DoInterpretedMotion(sidestep_command) // SIDESTEP axis + CPhysicsObj::DoInterpretedMotion + set_local_velocity(get_state_velocity) + // VELOCITY PUSH FOR SIDESTEP (additive over forward) + if (turn_command != 0): + DoInterpretedMotion(turn_command) // TURN axis + CPhysicsObj::DoInterpretedMotion + (drives angular velocity / animation omega) + else: + CPhysicsObj::StopInterpretedMotion(TurnRight) + add_to_queue(Ready) + // Then for each action in src.actions: + for each action with newer stamp: + DoInterpretedMotion(action.motion, params{action_stamp, autonomous}) + // OVERLAY axis (Twitch / ChatEmote / etc — not a velocity push) +``` + +**Five DoInterpretedMotion calls per UM in the typical case (style + +forward + sidestep + turn + maybe one action).** Each call into +`CPhysicsObj::DoInterpretedMotion` pushes velocity via +`set_local_velocity(get_state_velocity())`. + +The fact that the velocity is pushed **MULTIPLE TIMES PER UM** (once +after each axis) is what makes the body's local velocity in retail +respond instantly to a multi-axis state change. Acdream's +`apply_current_movement` only pushes velocity once. + +--- + +## 16. Overlay (Action / ChatEmote / Modifier) dispatch + +Action commands (bit 0x10000000) and ChatEmote commands (bit +0x02000000) are overlay motions. They flow through: + +- **Inbound UM**: the `actions[]` list inside `InterpretedMotionState` + (5-bit action count starting at flag 0x80). Each action is + dispatched in `move_to_interpreted_state`'s for-loop via + `DoInterpretedMotion(action.motion, params{action_stamp, autonomous})`. +- **Locally generated**: `DoMotion(motion, params)` with the action + bit set. `DoMotion`'s combat-style guards reject ChatEmote in + combat (`return 0x42`). Actions over the 6-action quota return + 0x45. + +Within `DoInterpretedMotion`, an Action command **does** call +`CPhysicsObj::DoInterpretedMotion` (which routes the command to the +animation sequencer's overlay channel) but `get_state_velocity` does +NOT consume Action commands — they have no velocity contribution. +This is what makes "swing weapon while running" work: the forward +axis still produces RunForward velocity (because `forward_command` +in InterpretedState is still RunForward), and the action just plays +on the overlay channel. + +**Acdream's `AnimationCommandRouter.RouteFullCommand`** is the analog +of the overlay-channel routing inside `CPhysicsObj::DoInterpretedMotion`. +Note that acdream's GameWindow (l.2887–2892) calls RouteFullCommand +**only** when `forwardIsOverlay` and `!remoteIsAirborne`. This ONLY +handles the "first-class" overlay flag in the wire (the special-case +where the wire sets `forward_command` to an Action command directly, +which retail handles via `move_to_interpreted_state`'s bulk copy +followed by `apply_interpreted_movement`'s forward-axis dispatch). + +--- + +## 17. Cross-check: acdream `MotionInterpreter.cs` divergences + +| Retail | Acdream (`MotionInterpreter.cs`) | Verdict | +|---|---|---| +| `apply_interpreted_movement` (305713) — fires DoInterpretedMotion for STYLE + FORWARD + SIDESTEP + TURN | **MISSING.** `apply_current_movement` (l.653) just calls `set_local_velocity(get_state_velocity())` once, gated on OnWalkable. | **CRITICAL GAP** | +| `apply_raw_movement` (305817) — bulk-copy RAW→INTERPRETED, then adjust_motion × 3, then apply_interpreted_movement | **MISSING.** Acdream's local autonomous player path is improvised in `PlayerMovementController` — does not run adjust_motion per axis. | Bug for local-input WalkBackward + HoldKey.Run interaction | +| `DoMotion` (306159) — adjust_motion → DoInterpretedMotion → optional RawState.ApplyMotion | `DoMotion` (l.381) just sets RawState.ForwardCommand+Speed and forwards to `DoInterpretedMotion(modifyInterpretedState:true)`. **No adjust_motion call.** | Bug for local backward/sidestep input | +| `move_to_interpreted_state` (305936) — bulk-copy InterpretedState (7 fields), apply_current_movement, iterate actions | **PARTIAL.** GameWindow.cs:2847 sets only ForwardCommand+ForwardSpeed (NOT current_style, NOT all 7 fields). Sidestep/Turn handled via separate `DoInterpretedMotion`/`StopInterpretedMotion` calls (l.3060, 3066, 3096, 3108). | Functional but structurally divergent | +| `StopMotion` (305674) — adjust_motion the motion, StopInterpretedMotion(adjusted), optional RawMotionState::RemoveMotion(pre-adjust) | `StopMotion` (l.431) — early-rewrites RawState, then forwards. **No adjust_motion call.** | Functional for sign-aligned cases only | +| `apply_run_to_command` (305062) — speed > 0 gate for promote, multiplies speed by speedMod | `MotionInterpreter` doesn't have this method. Wire-arrival gives the post-adjust form so it's not strictly needed for L.3 receive path; but the local SEND path is missing it. | Required for outbound bug parity | +| `ApplyMotion` (293531) — switch by command class (TurnRight / SideStepRight / 0x4xxxxxxx) | `ApplyMotionToInterpretedState` (l.993) — `switch` over **specific commands** (Walk/Run/WalkBackward/SideStepRight/SideStepLeft/TurnRight/TurnLeft/Ready). Other 0x4xxxxxxx commands (Stand, Falling, Crouch, Sit, Sleep) **fall through silently.** | Bug for non-locomotion forward commands | + +--- + +## 18. Answers to the critical questions + +### Q1: Does retail call DoInterpretedMotion separately for forward, sidestep, and turn axes EACH UM AND EACH TICK? + +**EACH UM: YES.** `apply_interpreted_movement` (305713) makes 3–5 +DoInterpretedMotion calls in sequence: style, forward (or Falling / +Ready+SideStop for longjump), sidestep (or stop), turn (or stop). + +**EACH TICK: NO.** `apply_interpreted_movement` does NOT auto-fire on +every physics tick. It fires: +- On every UM intake (via `move_to_interpreted_state` → `apply_current_movement`). +- On `LeaveGround` / `LandingHandler` / `enter_default_state` / `SetWeenieObject` / `SetPhysicsObject` / `ReportExhaustion`. +- On `set_target_movement` and `cancel_moveto`. + +Per-tick, the body's velocity is preserved by the physics solver +(`PhysicsBody::update`) using the velocity that was last set via +`set_local_velocity` from the most recent `apply_interpreted_movement`. +**The body integrates with the SAME velocity until a new state event +fires another `apply_interpreted_movement`.** + +This is why retail does NOT have a "staircase" issue on slopes: the +last `set_local_velocity` from the last UM stays in effect, and the +collision sweep (`ResolveWithTransition`) handles the slope component +naturally. Acdream's env-var path bypassed this by skipping the +`set_local_velocity` re-push at UM time. + +### Q2: When UM arrives with all 3 axes populated, does retail dispatch all 3? In what order? + +**Yes.** Order is fixed in `apply_interpreted_movement`: + +1. STYLE (`current_style` — stance change) +2. FORWARD (or Falling / Ready+SideStop) +3. SIDESTEP (or explicit Stop) +4. TURN (or explicit Stop) + +Then iterate `actions[]` for overlays in stamp order. + +### Q3: What's the canonical "play this cycle now" decision tree based on InterpretedState? + +This is **NOT in CMotionInterp**. The cycle decision lives inside +`CPhysicsObj::DoInterpretedMotion` → `set_motion_table_data`. Each call +to `DoInterpretedMotion(motion, params)` from +`apply_interpreted_movement` ends in a `set_motion_table_data(motion, +speed)` that updates the corresponding cycle slot in the animation +sequencer. + +**The sequencer maintains one cycle per "axis class"** roughly: + +- Forward locomotion cycle: WalkForward / RunForward / Falling / Ready +- Sidestep cycle: SideStepRight (with sign-flip for left) +- Turn cycle: TurnRight (with sign-flip for left) +- Style: stance-class affects which (style, command) pair the motion + table looks up + +Multiple cycles play SIMULTANEOUSLY on different bone subsets, layered +by the motion table's part definitions. This is why "run forward AND +sidestep" produces a strafe-run animation: both cycles play, the parts +each owns are updated by their own cycle. + +The "priority" acdream's `OnLiveMotionUpdated` uses (l.2905-2939: +forward → sidestep → turn → ready, single SetCycle) is **a +simplification** that picks ONE cycle. Retail plays multiple in +parallel via the motion-table layering. + +### Q4: For overlay (Action / Modifier / ChatEmote) packets, does the dispatch chain differ? + +**Slightly.** Overlays (Action bit 0x10000000, ChatEmote bit 0x02000000) +ride in the `actions[]` list inside InterpretedMotionState. After +`copy_movement_from` and `apply_interpreted_movement`, the action +loop iterates with stamp-wrap protection and fires +`DoInterpretedMotion(action.motion, params{action_stamp, autonomous_bit_via_0x1000})`. + +Inside `DoInterpretedMotion`: +- `contact_allows_move` for Action commands always returns 1 (handled + by `(motion & 0x10000000) == 0` check in the false-branch — Action + airborne returns YouCantJumpWhileInTheAir). +- Action commands have no `get_state_velocity` contribution (the + switch in `get_state_velocity` only matches WalkForward / RunForward + / SideStepRight as side-step gate). + +Net effect: Action overlay does NOT change body velocity (forward axis +keeps producing whatever it was producing), but it DOES drive an +animation overlay channel. + +**HOWEVER**: when a UM's `forward_command` IS an Action (e.g. retail +sometimes encodes "swing this attack and stop running" by setting +forward_command = AttackHigh1 directly, bit 0x02 set, no separate +action entry), the bulk-copy lands AttackHigh1 in +`interpreted_state.forward_command`. `apply_interpreted_movement` +fires `DoInterpretedMotion(AttackHigh1, params{speed=fwd_speed})`, +and `get_state_velocity` returns 0 (no match for WalkForward / +RunForward) — body stops moving forward, attack animation plays +on the Action channel via DoInterpretedMotion's set_motion_table_data. + +This is why GameWindow.cs:2802-2855 (acdream's "lifted bulk-copy" +block) is correctly doing `InterpretedState.ForwardCommand = fullMotion` +unconditionally — to match retail's bulk-copy semantics. + +--- + +## 19. What would the "correct" UM handler do that acdream is missing? + +Given an inbound 0xF74C UM with movement_type=0 (RawCommand), retail's +end-to-end flow is: + +``` +1. STALENESS — check 16-bit instance_ts via SetObjectMovement; + drop if older. +2. STYLE PRE-DISPATCH — if (new_style != current), DoMotion(new_style) + to swap stance. +3. UNPACK — InterpretedMotionState::UnPack reads flags + each + conditional field. +4. BULK COPY — copy_movement_from writes all 7 fields into the body's + InterpretedState. +5. PER-AXIS RE-FIRE — apply_interpreted_movement runs: + a. DoInterpretedMotion(current_style) + b. DoInterpretedMotion(forward_command, params{speed=fwd_speed}) OR Falling-branch + c. DoInterpretedMotion(sidestep_command, params{speed=side_speed}) OR StopInterpretedMotion(SideStepRight) + d. DoInterpretedMotion(turn_command, params{speed=turn_speed}) OR StopInterpretedMotion(TurnRight) + Ready add_to_queue + Each of these fires CPhysicsObj::DoInterpretedMotion, which calls + set_local_velocity(get_state_velocity()) AND drives the animation + sequencer's per-axis cycle slot. +6. ACTIONS LOOP — iterate actions[] with stamp-wrap protection; + DoInterpretedMotion each newer action. +``` + +**Acdream is missing #5 (the per-axis re-fire) at the granularity +retail has.** + +What acdream does on UM (`OnLiveMotionUpdated` + `MotionInterpreter.apply_current_movement`): + +- ✅ Staleness (timestamp check in WorldSession parser). +- ✅ Style change (stance update). +- ✅ Unpack (parser handles flag bits). +- ✅ Bulk-copy ForwardCommand+ForwardSpeed (l.2847, 2855). **PARTIAL** — + doesn't bulk-copy current_style separately (relies on `Stance` + being lifted up by parser). +- ❌ Per-axis re-fire of `apply_interpreted_movement`. **MISSING.** + Instead: + - Sidestep: directly calls `DoInterpretedMotion(sideFull, sideSpd, modifyInterpretedState:true)` (l.3060). + - Turn: directly calls `DoInterpretedMotion(turnFull, turnSpd, modifyInterpretedState:true)` (l.3096). + - These each fire `MotionInterpreter.apply_current_movement` once, which only does ONE `set_local_velocity` call. +- ❌ Single `set_local_velocity` per UM rather than per-axis. **CRITICAL.** + Effect: after-Forward velocity overwrites after-Sidestep velocity + rather than building up. (Mitigated by `get_state_velocity` reading + ALL three axis fields in one call — so the final velocity is correct. + But the per-axis sequencer cycle slots may not all update because + `MotionInterpreter.apply_current_movement` doesn't drive the sequencer.) +- ❌ `apply_interpreted_movement`'s STYLE pre-fire. **MISSING.** Stance + changes don't get a DoInterpretedMotion call — this is why "draw + weapon while running" sometimes shows wrong stance pose. +- ❌ `apply_interpreted_movement`'s longjump branch. **MISSING.** + StandingLongJump-charged forward gets pinned to Ready in retail; in + acdream the forward command is whatever the wire said. +- ❌ `apply_interpreted_movement`'s explicit StopInterpretedMotion for + zero-axis. **PARTIAL.** Acdream's GameWindow.cs:3066-3070 calls + `StopInterpretedMotion(SideStepRight)` AND `StopInterpretedMotion(SideStepLeft)` + when sidestep is 0; same for turn at 3108-3111. Retail only stops + SideStepRight and TurnRight (relies on adjust_motion having + normalized Left → Right + sign). +- ❌ Falling-when-airborne fallback. **WORKAROUND.** acdream handles + this via `remoteIsAirborne` checks scattered through + `OnLiveMotionUpdated`, NOT in `apply_current_movement`. + +### Concrete fix proposal + +Port `apply_interpreted_movement` faithfully into MotionInterpreter.cs +and have `apply_current_movement` (l.653) **delegate** to it for the +remote-observer path. The single `set_local_velocity` call should +move INSIDE `CPhysicsObj.DoInterpretedMotion` (or a new method +`MotionInterpreter.DispatchAxis(motion, speed)`) so each axis call +both updates InterpretedState AND pushes velocity AND drives the +sequencer. + +Then `OnLiveMotionUpdated` becomes much shorter: + +``` +OnLiveMotionUpdated: + remoteMot.Motion.move_to_interpreted_state(wireInterpretedState) + // That single call does bulk-copy + per-axis dispatch + sequencer drive +``` + +This eliminates the divergence between "what retail does on UM" and +"what acdream does on UM" and removes the need for the heavy ad-hoc +logic at GameWindow.cs:2774–3300. + +--- + +## 20. Cross-references + +- Retail decomp source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- Companion docs: + - `02-um-handling.md` — UM intake top-level chain + - `03-up-routing.md` — UpdatePosition (0xF748) — separate path + - `04-interp-manager.md` — MovementManager + MoveToManager + - `06-acdream-audit.md` — full acdream audit +- Acdream code: + - `src/AcDream.Core/Physics/MotionInterpreter.cs` + - `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2579-3300) + - `src/AcDream.Core/Physics/AnimationCommandRouter.cs` (overlay channel routing) +- Retail named symbols (all addresses 0x005xxxxx in acclient.exe v11.4186): + - `CMotionInterp::PerformMovement` 0x00528e80 (306221) + - `CMotionInterp::DoMotion` 0x00528d20 (306159) + - `CMotionInterp::DoInterpretedMotion` 0x00528360 (305575) + - `CMotionInterp::StopMotion` 0x00528530 (305674) + - `CMotionInterp::StopInterpretedMotion` 0x00528470 (305635) + - `CMotionInterp::StopCompletely` 0x00527e40 (305208) + - `CMotionInterp::apply_current_movement` 0x00528870 (305838) + - `CMotionInterp::apply_interpreted_movement` 0x00528600 (305713) + - `CMotionInterp::apply_raw_movement` 0x005287e0 (305817) + - `CMotionInterp::move_to_interpreted_state` 0x005289c0 (305936) + - `CMotionInterp::adjust_motion` 0x00528010 (305343) + - `CMotionInterp::apply_run_to_command` 0x00527be0 (305062) + - `CMotionInterp::get_state_velocity` 0x00527d50 (305160) + - `CMotionInterp::get_max_speed` 0x00527cb0 (305127) + - `CMotionInterp::get_adjusted_max_speed` 0x00527d00 (305145) + - `CMotionInterp::contact_allows_move` 0x00528240 (305471) + - `CMotionInterp::enter_default_state` 0x00528c80 (306124) + - `CMotionInterp::HandleExitWorld` 0x00527f30 (305275) + - `MovementManager::PerformMovement` 0x005240d0 (300194) + - `MovementManager::unpack_movement` 0x00524440 (300563) + - `MovementManager::move_to_interpreted_state` 0x00524170 (300259) + - `InterpretedMotionState::UnPack` 0x0051f400 (294360) + - `InterpretedMotionState::ApplyMotion` 0x0051ea40 (293531) + - `InterpretedMotionState::copy_movement_from` (293301-293311) diff --git a/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md b/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md new file mode 100644 index 0000000..4552afd --- /dev/null +++ b/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md @@ -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 90721–90723) 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 +``` diff --git a/docs/research/2026-05-04-l3-port/13-cycle-picker.md b/docs/research/2026-05-04-l3-port/13-cycle-picker.md new file mode 100644 index 0000000..9ebc4a6 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/13-cycle-picker.md @@ -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 **305713–305788** (`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` +(305838–305857). `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 **293630–293703**, 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 **305343–305400**, 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 **298636–298950**, 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 298661–298735. Substate's effect: reset to default substate of +the new style, optionally clear modifiers, replace cycles. + +### D.2 — `motion & 0x40000000` (substate) + +Lines 298737–298848. 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 298850–298907. **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 298909–298945. **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 **298526–298548**, 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 **298300–298328**, 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) | diff --git a/docs/research/2026-05-04-l3-port/14-local-player-audit.md b/docs/research/2026-05-04-l3-port/14-local-player-audit.md new file mode 100644 index 0000000..28f3d41 --- /dev/null +++ b/docs/research/2026-05-04-l3-port/14-local-player-audit.md @@ -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 + L5182–5337 (per-frame Update + outbound), L6956–7103 + (`UpdatePlayerAnimation`), L2638–2656 (server RunRate echo path), + L7997–8062 (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 (L89–230) + +| 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 (L8021–8035). Retail-faithful values. | +| `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150–155 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)` (L270–274) + +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)` (L276–287) + +```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 (L294–307) + +**Verdict: PORT.** When `State==PortalSpace`, returns zero-movement +result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early +return. + +#### 1.4.2 Turn input (L309–322) + +```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 (L324–411) — 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 L378–411 entirely, letting `DoMotion` +do its thing. + +#### 1.4.4 Jump path (L413–505) + +**Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).** + +The jump-charge logic at L420–428 (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`** (L466–501) 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 L378–411 logic. + +The comment at L443–460 acknowledges this explicitly: "Until +adjust_motion is ported, we mirror the grounded-velocity computation." + +#### 1.4.5 Physics integration + 30 Hz gate (L507–535) + +```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 (L538–574) + +```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 (L578–686) + +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 +L638–640 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 L630–637. + +#### 1.4.8 Ground/landing detection (L688–720) + +```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 (L725–795) + +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 L26–34 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 (L797–831) + +```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 (L833–845) + +```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 0–10 fields, bits 11–31 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 (L7997–8062) + +```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 (L5182–5337) + +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 L5266–5288 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` (L6956–7103) + +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 L378–411 +to work around the missing `adjust_motion` port** for backward/ +strafe-left. The same workaround appears in the jump path at L466–501. +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 L378–411 + L466–501** — + 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 L150–155. +5. **Wall-bounce landing suppression** (L638–640) — 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** (L378–411, L466–501) 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 L378–411 + + L466–501 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`. diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs index 1a6ff53..aea05d3 100644 --- a/src/AcDream.Core/Physics/InterpolationManager.cs +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -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). // ───────────────────────────────────────────────────────────────────────────── -/// -/// Waypoint used internally by . -/// +/// Internal queue node. type=1 = Position waypoint (only kind we use). internal sealed class InterpolationNode { public Vector3 TargetPosition; @@ -41,7 +43,7 @@ internal sealed class InterpolationNode } /// -/// Per-remote-entity position interpolation queue. Caller enqueues server +/// Per-remote-entity position interpolation queue. Caller enqueues server /// position updates and calls once per physics /// tick to get the per-frame correction delta. /// @@ -49,281 +51,339 @@ public sealed class InterpolationManager { // ── public constants (retail binary values) ─────────────────────────────── - /// Maximum waypoints held before oldest is dropped. + /// Maximum waypoints held before oldest (head) is dropped. public const int QueueCap = 20; /// /// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier. - /// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30). + /// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122). /// public const float MaxInterpolatedVelocityMod = 2.0f; /// /// 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). /// public const float MaxInterpolatedVelocity = 7.5f; /// - /// Per-5-frame stall progress threshold (meters). Body must advance at - /// least this far in frames or - /// the window counts as a stall. + /// Per-5-frame stall progress threshold (meters). /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42). /// public const float MinDistanceToReachPosition = 0.20f; /// - /// 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). /// public const float DesiredDistance = 0.05f; /// - /// Number of ticks between stall progress checks. + /// Number of ticks per stall progress check window. /// Retail frame_counter threshold (@ 0x00555E14). /// public const int StallCheckFrameInterval = 5; /// - /// 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). /// public const float StallProgressMinFraction = 0.30f; /// - /// Stall-fail counter threshold. The body is blipped to the tail of the - /// queue when node_fail_counter EXCEEDS this value (i.e., on the - /// 4th consecutive failed window, not the 3rd). - /// Retail: node_fail_counter > 3 (@ 0x00555F39). + /// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this + /// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail > 3. /// public const int StallFailCountThreshold = 3; - // ── internals ───────────────────────────────────────────────────────────── - - private readonly LinkedList _queue = new(); - - /// Frames elapsed since the last 5-frame stall-check window fired. - private int _framesSinceLastStallCheck = 0; + /// + /// 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. + /// + public const float AutonomyBlipDistance = 100.0f; /// - /// Cumulative sum of per-frame step magnitudes within the current - /// 5-frame window. Retail progress_quantum. + /// Sentinel for original_distance before the first window baseline is + /// taken. Retail value (@ 0x00555D30 ctor) is 999999f. /// - private float _progressQuantum = 0f; + public const float OriginalDistanceSentinel = 999999f; - /// - /// Distance to the head node recorded at the START of the current - /// 5-frame window. Retail original_distance. - /// - private float _distanceAtWindowStart = 0f; + private const float FEpsilon = 0.0002f; - /// - /// True once the first valid distance sample has been taken and - /// _distanceAtWindowStart is populated. Guards against the - /// first-window false-positive that occurs when the field defaults to 0. - /// - private bool _haveBaselineDistance = false; + // ── internals (retail field names in comments) ──────────────────────────── - /// - /// Number of consecutive 5-frame windows that failed both the absolute - /// and ratio progress checks. Retail node_fail_counter. - /// Blip fires when this EXCEEDS . - /// - private int _failCount = 0; + private readonly LinkedList _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 ──────────────────────────────────────────────────────────── /// True when the queue holds at least one waypoint. public bool IsActive => _queue.Count > 0; - /// - /// Current waypoint count (visible to the test assembly for cap verification). - /// + /// Current waypoint count (visible to tests for cap verification). internal int Count => _queue.Count; /// - /// 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). /// public void Clear() { _queue.Clear(); - _framesSinceLastStallCheck = 0; - _progressQuantum = 0f; - _distanceAtWindowStart = 0f; - _haveBaselineDistance = false; - _failCount = 0; + _frameCounter = 0; + _progressQuantum = 0f; + _originalDistance = OriginalDistanceSentinel; + _failCount = 0; } /// - /// Enqueue a new server-authoritative position waypoint. - /// - /// - /// Step 1: Duplicate-prune — if the new target is within - /// of the current tail, ignore it.
- /// Step 2: Cap — if the queue is already at , - /// drop the oldest (head) entry.
- /// Step 3/4: Append a new . - ///
- /// - /// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0). + /// Enqueue a new server-authoritative waypoint. Implements retail + /// InterpolateTo branching: + /// + /// Already-close: if distance(body, target) ≤ + /// , queue is wiped (StopInterpolating) + /// and no node is enqueued. + /// Far: if distance(reference, target) > + /// , enqueue and set + /// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an + /// immediate blip on the next AdjustOffset call. + /// Near: tail-prune loop collapses adjacent stale entries + /// within ; cap at 20 (head eviction); + /// enqueue. + /// ///
/// Server-reported world position. - /// Server-reported heading (radians, AC convention). - /// True when the body is in motion — gates heading validity. - public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo) + /// Server-reported heading (radians). + /// True when body is currently following an MTP. + /// + /// 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 null if not available — far/near classification falls back + /// to "near" (no pre-armed blip). + /// + 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) - return; + reference = _queue.Last!.Value.TargetPosition; + } + else if (currentBodyPosition.HasValue) + { + reference = currentBodyPosition.Value; + } + else + { + reference = targetPosition; // dist = 0 → near branch } - // Step 2: enforce cap + 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; + } + + // 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) + { + _queue.AddLast(new InterpolationNode { - TargetPosition = targetPosition, + TargetPosition = target, Heading = heading, IsHeadingValid = isMovingTo, - }; - _queue.AddLast(node); + }); } /// - /// Compute the per-frame position correction delta. + /// Compute the per-frame world-space correction delta. Combines the retail + /// UseTime blip-check (fail_count > 3 → snap to tail, clear queue) + /// with the per-frame adjust_offset step computation. /// - /// - /// Returns when the queue is empty or when - /// the head node has been reached. Returns a snap delta (tail − - /// currentBodyPosition) after - /// consecutive stall failures (i.e., fail count EXCEEDS the threshold), - /// then clears the queue. - /// + /// Returns when: + /// • queue is empty, + /// • head reached (distance < ) — 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 , then clears the queue. /// /// Frame delta time (seconds). /// Current world-space body position. /// - /// Max motion-table speed for this entity's current cycle (m/s), as - /// reported by MotionInterpreter. Pass 0 if unavailable; the fallback - /// will be used. + /// Max motion-table speed for this entity's current cycle (m/s). + /// Pass 0 to use the fallback. /// - /// World-space delta to apply to the body this frame. 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 - float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod; - float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity; + // Catch-up speed (retail line 353122 + 353128 fallback). + float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod; + 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; - _progressQuantum += step; - _framesSinceLastStallCheck++; + // Primary check (retail line 353150-353166): + // cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20) + bool primaryPass = cumulative >= MinDistanceToReachPosition; - if (_framesSinceLastStallCheck >= StallCheckFrameInterval) - { - float cumulativeProgress = _distanceAtWindowStart - dist; + // 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; + } - 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; - _progressQuantum = 0f; - _distanceAtWindowStart = dist; + // Re-baseline window regardless of pass/fail. + _frameCounter = 0; + _progressQuantum = 0f; + _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; } + + /// + /// 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. + /// + 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; + } + } } diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs index fd23931..721ddbc 100644 --- a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs @@ -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() {