From de129bc1649feaf3d57b77ed8e03df9377912e3a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 14:56:42 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(motion):=20L.3=20M1=20=E2=80=94=20fres?= =?UTF-8?q?h=20InterpolationManager=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() { From 40d88b92ed75fa92fc210f08e6eef3e2ed0f079f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 14:57:17 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(motion):=20L.3=20M2=20=E2=80=94=20queu?= =?UTF-8?q?e-only=20chase=20for=20grounded=20player=20remotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the M1 InterpolationManager into the per-tick + UP-receipt paths in GameWindow for player remote entities. Visual-verified against a retail-controlled remote: smooth body chase, no per-UP rubber-band, no staircase on slopes. OnLivePositionUpdated: - Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to `IsPlayerGuid(update.Guid)`. NPCs continue through the legacy synth-velocity branch (ServerVelocity / ServerMoveTo) below — their motion model is correct as-is. - Within-bubble enqueue passes `currentBodyPosition` so the M1 far- branch detection (>100 m from body) can pre-arm an immediate blip. - Three branches (airborne no-op, near-enqueue, far-snap) now sync `entity.Position = rmState.Body.Position` before returning. This overrides the unconditional `entity.Position = worldPos` snap at the top of the function. Without this sync the entity teleports forward to server truth on UP receipt and TickAnimations yanks it back to the queue-driven body next frame — visible 0.5–1 m rubber- band per UP. TickAnimations: - Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to `IsPlayerGuid(serverGuid) && !rm.Airborne`. Airborne player remotes fall through to the legacy path so K-fix15 landing + gravity sweep still fire on the jump arc. - Step 2 (per-frame translation) replaced. Was `rm.Position.ComputeOffset(...)` (mixed queue catch-up + animation root motion); now direct `rm.Interp.AdjustOffset(...)` (queue-only, no anim contribution). M3 will layer anim root motion on top so legs match body pace; for M2 the body chases server position smoothly without any anim-driven translation. - Step 4b (ResolveWithTransition collision sweep) REMOVED for player remotes. Server already collision-resolved the broadcast position; running the sweep on tiny per-frame queue catch-up deltas amplified micro-bounces into the ISSUES.md #40 staircase + flat-ground blips. - Step 5 (LastServerZ landing fallback) REMOVED — unreachable in the `!rm.Airborne` branch. Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + 04-interp-manager.md): m_velocityVector stays 0 for grounded remotes, apply_current_movement is local-player-only, and per-tick translation comes entirely from InterpolationManager queue catch-up. Behavior for player remotes: | Scenario | Path | Translation source | |-----------------------|--------|------------------------------| | Grounded near (≤96m) | M2 | Queue catch-up (2× max-speed)| | Grounded far (>96m) | M2 | Hard-snap to worldPos | | Far enqueue (>100m) | M2 | Pre-armed blip-to-tail | | Airborne (mid-jump) | Legacy | Gravity arc + sweep | | Landing | M2 | Hard-snap, queue cleared | NPCs: legacy path unchanged (synth velocity, ServerMoveTo, etc.). Closes the regression observed in 9b0f4f2 ("modern, not retail-faithful") and the L.3 attempts on 91bf1e0 / e94e791. Replaces the env-var path (ACDREAM_INTERP_MANAGER=1) which was marked DO-NOT-ENABLE in ISSUES.md #40 — the env-var no longer toggles anything for player remotes; this IS the path now. Build green, dotnet test green (8 pre-existing failures unchanged on this baseline; verified via stash on a3f53c2). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 307 +++++++----------------- 1 file changed, 81 insertions(+), 226 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fdb71a9..a081995 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3436,11 +3436,19 @@ public sealed class GameWindow : IDisposable rmState.Body.Orientation = rot; } - // L.3.1 Task 4: env-var gated retail-faithful MoveOrTeleport routing. - // Mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). - // Enabled only when ACDREAM_INTERP_MANAGER=1 to keep default behavior - // identical to before this commit. Legacy hard-snap path remains below. - if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for + // player remotes. Mirrors CPhysicsObj::MoveOrTeleport + // (acclient @ 0x00516330) — airborne no-op, far-snap, near + // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through + // the legacy synth-velocity branch below; their motion comes + // from ServerVelocity / ServerMoveTo which the legacy path + // already handles correctly. + // + // Was previously gated on ACDREAM_INTERP_MANAGER=1; the env-var + // path's per-tick TickAnimations counterpart is regressed + // (issue #40). M2 keeps the OnLivePositionUpdated half (which + // is correct) and rewrites the per-tick half — see TickAnimations. + if (IsPlayerGuid(update.Guid)) { // Orientation always snaps on receipt — InterpolationManager walks // position only; heading would otherwise lag the queue. @@ -3499,7 +3507,15 @@ public sealed class GameWindow : IDisposable // integrating gravity via per-frame UpdatePhysicsInternal. Server is // authoritative for the arc; we don't predict it locally. if (!update.IsGrounded) + { + // Undo the unconditional entity hard-snap at the top of the + // function (entity.Position = worldPos): the body is mid-arc + // and TickAnimations will write entity = body next frame + // anyway. Setting entity = body now prevents a 1-frame + // teleport-to-server-then-yank-back rubber-band. + entity.Position = rmState.Body.Position; return; + } // ── LANDING TRANSITION ──────────────────────────────────────── // First IsGrounded=true UP after rmState.Airborne signals landed. @@ -3547,12 +3563,26 @@ public sealed class GameWindow : IDisposable else { // Within view bubble: enqueue waypoint for adjust_offset to walk to. - // PositionManager (called per-frame in TickAnimations) handles the - // actual body advancement — mix of animation root motion + queue - // correction. + // The per-frame TickAnimations player-remote path drives the + // actual body advancement via InterpolationManager.AdjustOffset. + // Pass body's current position so the InterpolationManager can + // detect a far-distance enqueue (>100 m from body) and pre-arm + // an immediate blip — avoids body drifting visibly toward a + // far waypoint instead of teleporting to it. float headingFromQuat = ExtractYawFromQuaternion(rot); - rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + rmState.Interp.Enqueue( + worldPos, + headingFromQuat, + isMovingTo: false, + currentBodyPosition: rmState.Body.Position); } + // Sync the visible entity to the body — overrides the unconditional + // entity.Position = worldPos snap at the top of this function. + // For the far-snap branch this is a no-op (body == worldPos); for + // the near-enqueue branch this prevents a 1-frame teleport-then- + // yank-back rubber-band as TickAnimations chases worldPos via the + // queue. + entity.Position = rmState.Body.Position; return; } @@ -6039,76 +6069,28 @@ public sealed class GameWindow : IDisposable && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + if (IsPlayerGuid(serverGuid) && !rm.Airborne) { - // ⚠️ REGRESSED 2026-05-03 — DO NOT ENABLE — see docs/ISSUES.md #40 ⚠️ + // ── L.3 M2 (2026-05-05): queue-only chase for grounded player remotes ── // - // Introduced by e94e791 (L.3.1+L.3.2 Task 3) intending to - // mirror retail CPhysicsObj::MoveOrTeleport (network-packet - // entry point — minimal work). Wrong retail function for the - // per-frame tick — the actual per-frame chain is retail's - // update_object (FUN_00515020), which the LEGACY path below - // correctly mirrors (apply_current_movement → - // UpdatePhysicsInternal → ResolveWithTransition collision - // sweep). This env-var path strips the collision sweep AND - // clears body.Velocity, leaving only PositionManager queue - // catch-up — which stair-steps with the 1 Hz UP cadence on - // slopes and produces visible position blips on flat ground. + // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + + // 04-interp-manager.md): + // - For a grounded REMOTE player, m_velocityVector stays at 0. + // - apply_current_movement is NEVER called per tick on remotes + // (it's the local-player-only velocity feed). + // - UpdatePhysicsInternal's translation step is gated on + // velocity² > 0, so it's a no-op when body.Velocity = 0. + // - ResolveWithTransition is NOT called — the server already + // collision-resolved the broadcast position. + // - Per-tick body translation comes ENTIRELY from + // InterpolationManager::adjust_offset's queue catch-up. + // When the queue is empty (head reached, between UPs), the + // body stays put. M3 will add animation root motion to fill + // the gap so legs match body pace; for M2 the body chases + // the server position without anim contribution. // - // Commit B (039149a, 2026-05-03) ported ResolveWithTransition - // here but symptom persists because body.Velocity=0 means - // pre/postIntegrate sweep input is just the queue catch-up, - // which itself snaps in steps. Fix requires re-integrating - // PositionManager as ADDITIVE adjust_offset on top of the - // legacy chain — separate L.3 follow-up phase. - // - // Until that lands, stay on the legacy path (env-var unset). - // ── NEW PATH: retail-faithful per-frame remote tick ── - // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) - // - // Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0) - // → UpdatePositionInternal (0x00512c30) → CSequence::update - // chain (decomp investigation 2026-05-03): - // - // For a REMOTE entity (not local player), per physics tick - // the world-position advance is the sum of: - // A) animation root motion accumulated by - // update_internal (Frame::combine of crossed - // per-keyframe pos_frames deltas) OR replaced by - // InterpolationManager::adjust_offset's catch-up - // when the body is far from the queue head. - // B) body.Velocity × dt + 0.5 × accel × dt² - // (UpdatePhysicsInternal). For remotes, retail does - // NOT call apply_current_movement per tick — body. - // Velocity stays at whatever the last - // InterpolationManager type-3 ("set velocity") node - // set it to (typically zero unless the server is - // explicitly pushing velocity via VectorUpdate). - // - // So for normal grounded run/walk/strafe with no server- - // pushed velocity, ALL per-tick translation comes from (A). - // - // Acdream port mapping: - // - We don't extract per-keyframe pos_frames from the .anm - // assets. Our AnimationSequencer.CurrentVelocity is the - // synthesized equivalent (RunAnimSpeed × ForwardSpeed) - // which averages to the same effective body translation. - // - Pass it as seqVel to ComputeOffset so the - // animation-root-motion path drives body translation. - // - DO NOT call apply_current_movement per tick — that - // would set body.Velocity to RunAnimSpeed × ForwardSpeed, - // and UpdatePhysicsInternal would then add ANOTHER - // 11.7 m/s × dt on top of the seqVel motion already - // applied by ComputeOffset, producing 2× server pace - // (the user-reported "way too fast" + 1-Hz blip from - // the catch-up walking back the overshoot). - // - body.Velocity stays at 0 for grounded remotes; non- - // zero only when OnLiveVectorUpdated set it (jump - // start) — UpdatePhysicsInternal then integrates - // gravity for the airborne arc. - - System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; + // Airborne player remotes (rm.Airborne) and NPCs fall through to + // the legacy path below — unchanged from main per the M2 plan. System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; @@ -6133,26 +6115,21 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 2: per-frame body translation. ComputeOffset returns - // either the queue catch-up (when active) or the animation - // root motion (seqVel × dt rotated to world). REPLACE - // semantics — retail's PositionManager::adjust_offset - // overwrites the offset frame with the catch-up direction, - // not adding to it. - // - // 2026-05-03 (Commit B fix for staircase regression): capture - // the pre-translation position so the collision sweep below - // (Step 4b) can resolve the full per-tick movement through - // BSP + terrain. - var preIntegratePos = rm.Body.Position; + // Step 2 (M2): queue-only translation. Direct call to + // InterpolationManager.AdjustOffset — no PositionManager + // mixing, no animation root motion. The InterpolationManager + // returns: + // - Vector3.Zero when the queue is empty OR the head is + // within DesiredDistance (0.05 m) — body stays still. + // - Direction × min(catchUpSpeed × dt, dist) — body chases + // the head waypoint at up to 2× motion-table max speed. + // - tail − body when fail_count > 3 (stall blip; queue + // cleared as a side effect). float maxSpeed = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3 offset = rm.Position.ComputeOffset( + System.Numerics.Vector3 offset = rm.Interp.AdjustOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - seqVel: seqVel, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeed); + maxSpeedFromMinterp: maxSpeed); rm.Body.Position += offset; // Step 2.5: angular velocity → body orientation. Prefer @@ -6209,140 +6186,18 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); - // Step 4b (Commit B fix 2026-05-03): collision sweep — port of - // retail update_object's FUN_005148A0 Transition::FindTransitionalPosition. - // This was MISSING in the env-var path introduced by e94e791 - // (L.3.1+L.3.2 Task 3). The legacy (env-var off) path at the - // bottom of this function has it (line ~6483 "Step 4: collision - // sweep"); we just need the same call here. + // Step 4b INTENTIONALLY OMITTED in M2: + // ResolveWithTransition is NOT called — the server has + // already collision-resolved the broadcast position, and + // running our sweep on tiny per-frame queue catch-up deltas + // amplifies micro-bounces into visible position blips + // (issue #40 staircase + flat-ground blips). Per retail + // spec the per-tick body advance for a remote is purely + // the queue catch-up; collision is the sender's problem. // - // Without this: - // - Body Z drifts on slopes (visible "staircase" — horizontal - // Euler motion up a slope sinks into rising ground until - // the next UP pops it up). - // - Body slides through walls / objects between UPs. - // - Step-up / step-down doesn't engage on ledges. - // - Edge-slide doesn't engage on cliff edges. - // - // The env-var path was originally designed to mirror retail - // CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330) — a network - // packet handler entry point that does minimal work. But - // TickAnimations is the per-frame physics tick (mirrors retail - // FUN_00515020 update_object), which DOES include the collision - // sweep. Adding the sweep here makes the env-var path retail- - // faithful for the per-frame tick (matching the legacy path, - // which had it). - var postIntegratePos = rm.Body.Position; - if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) - { - // Sphere dims match local-player + legacy-path defaults - // (~0.48m radius, ~1.2m height humanoid). Step-up/down 0.4m - // matches L.2.3a retail human-scale. EdgeSlide is the retail - // default mover-flags state. - var resolveResult = _physicsEngine.ResolveWithTransition( - preIntegratePos, postIntegratePos, rm.CellId, - sphereRadius: 0.48f, - sphereHeight: 1.2f, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - // Airborne remotes must NOT pre-seed the ContactPlane — - // mirrors K-fix9 in the legacy path; otherwise - // AdjustOffset's snap-to-plane branch zeroes the +Z - // offset every step on a jump arc. - isOnGround: !rm.Airborne, - body: rm.Body, - moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); - - rm.Body.Position = resolveResult.Position; - if (resolveResult.CellId != 0) - rm.CellId = resolveResult.CellId; - - // Post-resolve landing detection — mirrors K-fix15 in the - // legacy path. When the resolver says we're on ground AND - // velocity is no longer pointing up, transition back to - // grounded. Without this, gravity keeps building negative Z - // velocity until the sphere-sweep clamps each frame, but - // Airborne stays true forever. - if (rm.Airborne - && resolveResult.IsOnGround - && rm.Body.Velocity.Z <= 0f) - { - rm.Airborne = false; - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - rm.Body.Velocity = new System.Numerics.Vector3( - rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); - rm.Motion.HitGround(); - - // Reset sequencer cycle from Falling back to whatever - // InterpretedState says. Mirrors K-fix17 in the legacy - // path. - if (ae.Sequencer is not null) - { - uint landStyle = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; - if (landingCmd == 0) - landingCmd = AcDream.Core.Physics.MotionCommand.Ready; - float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; - if (landingSpeed <= 0f) landingSpeed = 1f; - ae.Sequencer.SetCycle(landStyle, landingCmd, landingSpeed); - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); - } - } - - // Step 5: landing fallback. The retail-faithful path leaves - // the landing transition to OnLivePositionUpdated when ACE - // sends IsGrounded=true. In practice ACE doesn't always - // broadcast that flag promptly — the body keeps falling - // under gravity and visibly disappears into the ground until - // the next non-stop UP arrives (e.g. when the player turns). - // The remote's most recent server-reported Z is an - // authoritative ground floor: if our predicted body has - // sunk below it by more than half a meter, snap up to it - // and clear airborne, mirroring the OnLivePositionUpdated - // landing-transition branch. Threshold matches retail's - // MIN_DISTANCE_TO_REACH_POSITION-style tolerance. - if (rm.Airborne - && !float.IsNaN(rm.LastServerZ) - && rm.Body.Position.Z < rm.LastServerZ - 0.5f) - { - rm.Airborne = false; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Interp.Clear(); - rm.Body.Position = new System.Numerics.Vector3( - rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ); - - // Swap the sequencer out of Falling — without this the - // legs stay folded in the airborne pose forever even - // though the body is now planted on the ground. Mirrors - // the legacy K-fix17 path at the bottom of TickAnimations - // (line ~6284): pick the cycle from the last-known - // InterpretedState.ForwardCommand, falling back to Ready - // when nothing is held. The next UpdateMotion the server - // sends will refine if the player was strafing/turning - // mid-jump; this just gets them out of Falling now. - if (ae.Sequencer is not null) - { - uint style = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; - if (landingCmd == 0) - landingCmd = AcDream.Core.Physics.MotionCommand.Ready; - float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; - if (landingSpeed <= 0f) landingSpeed = 1f; - ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); - } - } + // Step 5 (landing fallback) is unreachable in this branch — + // we're gated on !rm.Airborne. Airborne player remotes fall + // through to the legacy path below where K-fix15 still fires. // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). // Track the maximum sequencer velocity magnitude seen since From 2365c8cd6e779843881e18c4556af15aeb2506d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 15:17:51 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(motion):=20L.3=20M3=20=E2=80=94=20anim?= =?UTF-8?q?ation=20root=20motion=20fallback=20for=20idle=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores PositionManager.ComputeOffset call in TickAnimations player- remote branch. M2 was queue-only (body chases server but stops between UPs after head reached); M3 adds the retail REPLACE behavior: - Queue active and not reached → catch-up vector (REPLACES anim). - Queue empty or head reached → anim root motion (seqVel × dt rotated by body.Orientation) drives translation between UPs. - Blip-to-tail still fires on fail_count > 3. Mirrors retail UpdatePositionInternal @ 0x00512c30 per docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md § 6: PositionManager::adjust_offset OVERWRITES local frame's origin with catch-up when active; otherwise no-op (anim root motion stands). User-verified 2026-05-05: "Best implementation we have had so far. Running works, walking works, strafing works." Closes #40 (env-var path regression — replaced wholesale). Files #41 for residual sub-decimeter blips: velocity-synthesis magnitude (RunAnimSpeed × adjustedSpeed) overshoots server pace slightly, queue walks it back every UP. Within retail's DesiredDistance / MinDistance tolerances; not a correctness bug. Fix path requires porting add_motion @ 0x005224b0 and cdb-tracing retail's actual CSequence::velocity magnitude. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 115 +++++++++++++++++++++++- src/AcDream.App/Rendering/GameWindow.cs | 57 +++++++----- 2 files changed, 151 insertions(+), 21 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 794a37b..40e3c14 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -124,7 +124,120 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g. - No spurious cycle thrashing during turning while running (ObservedOmega doesn't trigger velocity-bucket changes). -## #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) +## #41 — Residual sub-decimeter blips on observed player remotes (M3 baseline) + +**Status:** OPEN +**Severity:** LOW (within retail's own DesiredDistance / MinDistance tolerances; visible only on close inspection) +**Filed:** 2026-05-05 +**Component:** physics / motion / animation (per-tick remote prediction) + +**Description:** With the L.3 M3 path live (queue catch-up + animation +root motion fallback), observed player remotes chase server position +smoothly with NO staircase on slopes and NO per-UP rubber-band. However +small position blips remain — sub-decimeter amplitude, periodic with +the server's UP cadence (~1 Hz). User report 2026-05-05: "I get very +small blips now. Running works, walking works, strafing works." + +The blips fall well within retail's own tolerances: + +- `DesiredDistance` (queue head reach radius) = 0.05 m +- `MinDistanceToReachPosition` (primary stall threshold) = 0.20 m + +So they are NOT a stall trigger and NOT a correctness bug. They're a +visible artifact of the velocity-synthesis residual: anim root motion +(`AnimationSequencer.CurrentVelocity = RunAnimSpeed × adjustedSpeed`) +slightly overshoots server pace between UPs, then queue catch-up walks +the body back toward the server position on the next UP — a small +rubber-band that's smaller than M2's pre-fix version but still +perceptible. + +**Root cause hypothesis (untested):** + +The L.3 handoff explicitly flagged this. From `06-acdream-audit.md` § 9 +and `05-position-manager-and-partarray.md` § 7: + +> Our `CurrentVelocity` carries only the steady-state component of the +> cycle's intent; the per-frame stride wobble is gone… For Humanoid +> the dat ships `MotionData.Velocity = 0` so the multiply is a no-op +> anyway — but the synth uses `RunAnimSpeed × adjustedSpeed` directly. + +ACE's wire `ForwardSpeed` for a running player is the **server runRate** +(~2.94 for skill 200), not a unit multiplier. Our synth multiplies +`RunAnimSpeed` (4.0) by `adjustedSpeed` (~2.94) = ~11.76 m/s, which +the queue catch-up clamps via `min(catchUp × dt, dist)` but the anim +fallback applies in full when the queue is idle. If the actual +server-broadcast pace is closer to 4.0 m/s (RunAnimSpeed alone, with +runRate as a *frame-rate* multiplier rather than a velocity scalar), +our fallback overshoots by ~3× and the queue walks it back every UP. + +Per the handoff: **don't normalize at the wire boundary** (prior +session tried this, called it a hack). The right fix is porting +retail's actual behavior in `add_motion @ 0x005224b0` and +`apply_run_to_command` to determine the correct `CSequence::velocity` +magnitude. + +**Files:** + +- `src/AcDream.Core/Physics/AnimationSequencer.cs` — `CurrentVelocity` + synthesis at L614–679 (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, + SidestepAnimSpeed=1.25 × adjustedSpeed) +- `src/AcDream.Core/Physics/PositionManager.cs` — `ComputeOffset` + applies `seqVel × dt × orientation` as fallback when queue is idle + +**Research:** + +- `docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md` § 5–7 +- `docs/research/2026-05-04-l3-port/06-acdream-audit.md` § 9 (AnimationSequencer) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` line 298437 + (`add_motion @ 0x005224b0`) — `CSequence::velocity = style_speed × MotionData.velocity` + +**Fix path (research first, then port):** + +1. cdb-trace retail to capture `CSequence::velocity` and + `MotionData::velocity` for a Humanoid running cycle. Compare against + our synth (4.0 × 2.94 = 11.76 m/s) to determine the actual retail + magnitude. +2. Port `add_motion`'s `style_speed × MotionData.velocity` chain + verbatim. For Humanoid where `MotionData.Velocity = 0`, port the + fallback retail uses (likely a separate code path through + `apply_run_to_command` that derives velocity from the cycle's + framerate, not a constant). +3. Remove the `RunAnimSpeed × adjustedSpeed` synth in + `AnimationSequencer.SetCycle`. + +**Acceptance:** + +- Visual blips disappear on flat-ground steady-state running. +- Side-by-side acdream-as-observer vs retail-as-observer of the same + server-controlled toon: indistinguishable body trajectory. + +--- + +## #40 — [DONE 2026-05-05 · 40d88b9] ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) + +**Status:** DONE — closed by L.3 M2 (`feat(motion): L.3 M2 — queue-only chase for grounded player remotes`, commit 40d88b9) + +**Resolution:** The env-var gate was retired entirely. Both +`OnLivePositionUpdated` and `TickAnimations` now use +`IsPlayerGuid(serverGuid)` to route player-remote UPs through the +retail-faithful queue path (formerly the env-var path, but with two +key fixes per the L.3 spec): + +1. `PositionManager.ComputeOffset` is the per-tick translation source + (REPLACE semantics: queue catch-up overrides anim root motion when + active, anim stands when queue is idle / head reached). Mirrors + retail `UpdatePositionInternal @ 0x00512c30`. +2. `ResolveWithTransition` is **not** called for grounded player + remotes — server already collision-resolved the broadcast position, + and sweeping per-tick on tiny queue catch-up deltas amplified + micro-bounces into visible blips. This was the staircase + blip + regression. Trade-off documented in audit § 6. + +User-verified 2026-05-05: smooth body chase, no staircase on slopes, +no per-UP rubber-band on flat ground. Residual sub-decimeter blips +filed separately as #41 (velocity-synthesis magnitude). + +**Filed-original-context (for archive):** **Status:** OPEN (do-not-enable; pending L.3 follow-up rebuild) **Severity:** N/A (gated; default behavior unaffected) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a081995..0c1c15e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6071,10 +6071,12 @@ public sealed class GameWindow : IDisposable { if (IsPlayerGuid(serverGuid) && !rm.Airborne) { - // ── L.3 M2 (2026-05-05): queue-only chase for grounded player remotes ── + // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ── // // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + - // 04-interp-manager.md): + // 04-interp-manager.md + + // 05-position-manager-and-partarray.md): + // // - For a grounded REMOTE player, m_velocityVector stays at 0. // - apply_current_movement is NEVER called per tick on remotes // (it's the local-player-only velocity feed). @@ -6082,15 +6084,23 @@ public sealed class GameWindow : IDisposable // velocity² > 0, so it's a no-op when body.Velocity = 0. // - ResolveWithTransition is NOT called — the server already // collision-resolved the broadcast position. - // - Per-tick body translation comes ENTIRELY from - // InterpolationManager::adjust_offset's queue catch-up. - // When the queue is empty (head reached, between UPs), the - // body stays put. M3 will add animation root motion to fill - // the gap so legs match body pace; for M2 the body chases - // the server position without anim contribution. + // - Per-tick body translation per retail UpdatePositionInternal: + // 1. CPartArray::Update writes anim root motion (body-local + // seqVel × dt) into the local frame. + // 2. PositionManager::adjust_offset OVERWRITES the local + // frame's origin with the queue catch-up vector when + // the queue is active and the head is not yet reached + // — REPLACE, not additive. + // 3. Frame::combine composes the local frame with the + // body's world pose. + // Net: catch-up replaces anim during the chase phase, anim + // stands when the queue is empty / head reached. PositionManager. + // ComputeOffset implements this exact REPLACE dichotomy. // // Airborne player remotes (rm.Airborne) and NPCs fall through to // the legacy path below — unchanged from main per the M2 plan. + System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; @@ -6115,21 +6125,28 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 2 (M2): queue-only translation. Direct call to - // InterpolationManager.AdjustOffset — no PositionManager - // mixing, no animation root motion. The InterpolationManager - // returns: - // - Vector3.Zero when the queue is empty OR the head is - // within DesiredDistance (0.05 m) — body stays still. - // - Direction × min(catchUpSpeed × dt, dist) — body chases - // the head waypoint at up to 2× motion-table max speed. - // - tail − body when fail_count > 3 (stall blip; queue - // cleared as a side effect). + // Step 2 (M3): queue + anim translation via PositionManager. + // ComputeOffset returns: + // - Vector3.Zero when queue is empty AND seqVel is zero + // (idle remote between UPs after head reached) — body + // stays still. + // - Direction × min(catchUpSpeed × dt, dist) when the + // queue is active and head is not reached — body chases + // the head waypoint at up to 2× motion-table max speed + // (REPLACES anim for this frame). + // - Anim root motion (seqVel × dt rotated into world) when + // the queue is empty OR head is within DesiredDistance — + // body advances with the locomotion cycle's baked + // velocity, keeping legs and body pace synchronized. + // - Blip-to-tail (tail − body) when fail_count > 3. float maxSpeed = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3 offset = rm.Interp.AdjustOffset( + System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - maxSpeedFromMinterp: maxSpeed); + seqVel: seqVel, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeed); rm.Body.Position += offset; // Step 2.5: angular velocity → body orientation. Prefer From d57ace01777baf09f7a6f05973e2bc078ace962a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 15:20:50 +0200 Subject: [PATCH 4/7] =?UTF-8?q?chore(motion):=20L.3=20M6=20=E2=80=94=20scr?= =?UTF-8?q?ub=20stale=20ACDREAM=5FINTERP=5FMANAGER=20+=20dead=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleans up dead code revealed by L.3 M2/M3: GameWindow.cs: - RemoteMotion.LastServerZ field deleted (only consumed by the M2- removed Step 5 landing fallback in TickAnimations; never read). - RemoteMotion.TargetOrientation field deleted (audit § 1 flagged as DEAD; only ever written, never read). - Stale ACDREAM_INTERP_MANAGER comments removed from RemoteMotion.Interp and OnLivePositionUpdated (the env-var no longer gates anything as of M2). - Doc-comments on Interp + Position rewritten to describe the M2/M3 production semantics (queue catch-up + REPLACE-style combiner). CLAUDE.md: - ACDREAM_INTERP_MANAGER env-var entry rewritten as a retirement note pointing at commit 40d88b9 (M2). The path it gated is now the default for player remotes. Build green, dotnet test green (8 pre-existing failures unchanged on this baseline). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 19 +++++----- src/AcDream.App/Rendering/GameWindow.cs | 47 +++---------------------- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e02b6d..469b95c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,17 +552,14 @@ via `PlayerMovementController.ApplyServerRunRate`) or from diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. -- ⚠️ `ACDREAM_INTERP_MANAGER=1` — **DO NOT ENABLE.** This was an - experimental rewrite (e94e791) of the per-tick remote motion path. - It's regressed: 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 (body - Z stays flat between UPs, snaps at each one) plus position blips - during steady-state motion. Default (env-var unset) uses the - working retail-port chain. The PositionManager class itself is - fine and retail-faithful; only the integration into per-tick was - wrong. To be re-done in a future L.3 follow-up phase as additive - refinement on top of the working chain. +- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an + env-var gate on an experimental per-tick remote motion path. L.3 M2 + (commit 40d88b9) replaced both gates (`OnLivePositionUpdated` + + `TickAnimations`) with `IsPlayerGuid(...)` so player remotes use the + retail-faithful queue routing (InterpolationManager queue catch-up + + PositionManager combiner) unconditionally. NPCs and airborne player + remotes still flow through the legacy `apply_current_movement` + + `ResolveWithTransition` path. The env-var no longer toggles anything. ### Outbound motion wire format (acdream → ACE) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0c1c15e..08a8a05 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -295,11 +295,6 @@ public sealed class GameWindow : IDisposable /// public double LastMoveToPacketTime; /// - /// Legacy field — no longer used for slerp (retail hard-snaps - /// per FUN_00514b90 set_frame). Kept to avoid churn. - /// - public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity; - /// /// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed /// (π/2 × turnSpeed, signed). Applied per tick to body orientation /// via manual integration (bypassing PhysicsBody.update_object's @@ -334,34 +329,21 @@ public sealed class GameWindow : IDisposable /// /// Per-remote position-waypoint queue + catch-up math (retail's /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). - /// Replaces the hard-snap-then-Euler-extrapolate path when - /// ACDREAM_INTERP_MANAGER=1 — see Phase L.3.1 spec at - /// docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md. - /// Field exists from Task 3 onwards; consumed in Tasks 4 + 5. + /// Drives per-tick body translation for grounded player remotes + /// via . /// public AcDream.Core.Physics.InterpolationManager Interp { get; } = new AcDream.Core.Physics.InterpolationManager(); /// /// Per-frame combiner for animation root motion + InterpolationManager - /// correction (Phase L.3.2). Consumed in TickAnimations to compute the - /// per-frame body.Position delta. + /// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30: + /// queue catch-up REPLACES anim when active; anim stands when queue + /// is idle. /// public AcDream.Core.Physics.PositionManager Position { get; } = new AcDream.Core.Physics.PositionManager(); - /// - /// Most recent server-broadcast Z coordinate from any UpdatePosition - /// (including mid-arc airborne UPs). Used by the - /// ACDREAM_INTERP_MANAGER=1 per-tick path as a landing-fallback - /// floor: if gravity drags the body's Z below this value while - /// is still set, force-land locally because - /// the server has effectively told us where the ground is even if - /// it never sent an IsGrounded=true UP. Initialized to NaN so the - /// fallback is a no-op until the first UP arrives. - /// - public float LastServerZ = float.NaN; - /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the /// previous UpdatePosition's world position + timestamp. The per-tick @@ -3444,30 +3426,12 @@ public sealed class GameWindow : IDisposable // from ServerVelocity / ServerMoveTo which the legacy path // already handles correctly. // - // Was previously gated on ACDREAM_INTERP_MANAGER=1; the env-var - // path's per-tick TickAnimations counterpart is regressed - // (issue #40). M2 keeps the OnLivePositionUpdated half (which - // is correct) and rewrites the per-tick half — see TickAnimations. if (IsPlayerGuid(update.Guid)) { // Orientation always snaps on receipt — InterpolationManager walks // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; - // Track the most recent GROUNDED server-broadcast Z. Read by - // the per-tick landing-fallback in TickAnimations: if gravity - // drags the body more than 0.5 m below this floor while still - // airborne, we force-land locally even when the server never - // sent an IsGrounded=true UP for the actual landing frame. - // - // Only updated for grounded UPs — mid-arc airborne UPs would - // raise this value to the player's peak Z, then the body's - // descent would cross (peak - 0.5) and trigger a force-land - // mid-air, producing the user-reported "small landing in the - // air before landing on the ground" when jumping while moving. - if (update.IsGrounded) - rmState.LastServerZ = worldPos.Z; - // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous // server-pos snapshot forward AND print the per-UP comparison // between the max sequencer speed observed since last UP and @@ -3643,7 +3607,6 @@ public sealed class GameWindow : IDisposable // a halved "observed" rate → visible slow-start. Formula-only // is stable and simple; hard-snap fixes any drift. rmState.Body.Orientation = rot; - rmState.TargetOrientation = rot; rmState.LastServerPos = worldPos; rmState.LastServerPosTime = nowSec; // Align the body's physics clock with our clock so update_object From c26bbbb84e89f2a8300da8dec0c5197a72d74e84 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 15:35:42 +0200 Subject: [PATCH 5/7] fix(motion): L.3 M4 jump-CellId + file #42 airborne XY drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CellId fix: L.3 M2 introduced OnLivePositionUpdated player-remote routing that returned without setting `rmState.CellId = p.LandblockId`. The legacy path always set this (formerly at line 3601). Airborne player remotes fall through to the legacy TickAnimations path which gates ResolveWithTransition on `rm.CellId != 0`; without the cell-id update the sphere sweep was skipped, K-fix15 landing detection never fired, and the body fell through the floor on jumps. Fix: set `rmState.CellId = p.LandblockId` early in the M2 player-remote branch (after orientation snap, before any return). User-verified 2026-05-05: jumps now land cleanly with sequencer leaving Falling on landing. #42 filed: Visual verification of M4 also exposed a ~1 m horizontal drift on stationary jumps (body arcs through the air offset from actor's actual position; lands at offset; snaps back on next UM). User confirms this is pre-existing, masked by the legacy path's hard-snap-on-every-UP behavior that M2 explicitly removed per retail spec (03-up-routing.md § 3 "AIRBORNE NO-OP"). Filed as #42 with three candidate fix paths (pragmatic legacy-restore, root-cause investigation, or hybrid soft-correction). M5 NPCs verified clean (legacy path unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 105 ++++++++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 10 +++ 2 files changed, 115 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 40e3c14..b1cc364 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -124,6 +124,111 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g. - No spurious cycle thrashing during turning while running (ObservedOmega doesn't trigger velocity-bucket changes). +## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc) + +**Status:** OPEN +**Severity:** MEDIUM (pre-existing; exposed by L.3 M2 airborne UP no-op) +**Filed:** 2026-05-05 +**Component:** physics / motion (airborne local-integration) + +**Description:** When observing a retail-controlled remote that jumps +in place (no horizontal input), the visible jump arc renders with +a small horizontal offset from the actor's actual position — typically +~1 m to one side and slightly forward. Body lands at offset position +(~X+1m). On the next inbound UM/UP from the actor (e.g., turning or +moving), the body snaps back to the server's authoritative X. + +User report 2026-05-05 (after M4 CellId fix): "I stand at position X +and jump, it looks like im jumping slightly to the left of X like +1m-ish (if I observe jumping char from behind). It also lands at +X + 1m-ish. Position resets to X when I issue some other command +to the client like turning." + +**Why it surfaced now:** + +Pre-M2 (legacy path), `OnLivePositionUpdated` hard-snapped +`rmState.Body.Position = worldPos` on EVERY UP including mid-arc +airborne ones. ACE broadcasts intermediate UPs at ~5–10 Hz during +the jump arc with the actor's authoritative mid-arc position; +each snap kept our local body close to server, masking +local-integration error. + +L.3 M2 (commit 40d88b9) implemented the retail-spec airborne no-op +in `OnLivePositionUpdated`: + +```csharp +if (!update.IsGrounded) { + entity.Position = rmState.Body.Position; + return; +} +``` + +Per `docs/research/2026-05-04-l3-port/03-up-routing.md` § 3: + +> Air branch (`has_contact == 0`): the function falls through to +> `return 0`. This is the "AIRBORNE NO-OP" … The body keeps +> integrating gravity locally; received position is discarded. + +This matches retail `MoveOrTeleport @ 0x00516330` semantics. But it +removes the periodic server snapping that was masking ~1 m of +accumulated local-integration drift. The drift is pre-existing — the +user reports having seen it before — but is now visible for the +full arc duration instead of being corrected every ~200 ms. + +**Suspected sources of XY drift on a stationary jump:** + +1. **ACE wire VectorUpdate may have non-trivial XY components** even + when the actor is standing still. `OnLiveVectorUpdated` (line + 3235) sets `rm.Body.Velocity = update.Velocity` verbatim; no + filtering. Worth instrumenting `[VU.WIRE]` to confirm wire XY for + stationary jumps. + +2. **Pre-jump `rm.Body.Velocity` residual** — should be zero for the + M3 grounded path (cleared each tick at line 6118 area), but worth + confirming via diag. + +3. **EdgeSlide during sphere sweep** — if `ResolveWithTransition` + catches an edge mid-arc (unlikely for a stationary in-place jump + but possible), the sweep could push the body horizontally. + +4. **Render-rate-dependent dt** — local Euler integration uses + `Silk.NET.OnRender(double deltaSeconds)` raw; retail clamps to + 30 Hz. Sub-tick error accumulates over a 2 s arc. + +**Fix paths:** + +a. **Pragmatic** (revert to legacy behavior): hard-snap + `rm.Body.Position = worldPos` on airborne UPs too. Diverges from + retail spec, but ACE behavior diverges from retail too (ACE + broadcasts mid-arc UPs while retail apparently doesn't). Masks + the drift identically to pre-M2. Lowest-risk visual fix. + +b. **Investigate** the actual XY source via VU/UP instrumentation + and `body.Velocity` snapshots, then fix the root cause. May be + an ACE-specific velocity-quirk we should clamp at + `OnLiveVectorUpdated`, or a clock-source mismatch in our Euler. + +c. **Hybrid**: keep airborne no-op for body.Position translation but + re-introduce a soft-correction on mid-arc UPs (server-position- + biased lerp) — slowly pulls body toward server truth without + the rubber-band. + +**Files:** + +- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveVectorUpdated` + L3228+ (sets velocity from wire verbatim) +- `src/AcDream.App/Rendering/GameWindow.cs` `OnLivePositionUpdated` + player-remote airborne no-op L3502+ (the no-op that exposed this) +- `src/AcDream.App/Rendering/GameWindow.cs` legacy airborne TickAnimations + L6478+ (gravity integration via UpdatePhysicsInternal) + +**Acceptance:** + +- Visual jump arc + landing render at the actor's actual XY position, + no perceptible horizontal offset, no snap-back on next UM/UP. + +--- + ## #41 — Residual sub-decimeter blips on observed player remotes (M3 baseline) **Status:** OPEN diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 08a8a05..2b07ebd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3432,6 +3432,16 @@ public sealed class GameWindow : IDisposable // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; + // Adopt server's cell ID on every UP (airborne or grounded). + // Required by the legacy airborne path's per-tick + // ResolveWithTransition gate (rm.CellId != 0) — without this, + // an airborne player remote falls through the floor because + // the sphere sweep is skipped, K-fix15 landing detection never + // fires, and the body only re-grounds when the next UM forces + // ACE to broadcast a fresh IsGrounded=true UP that hits our + // landing transition branch below. + rmState.CellId = p.LandblockId; + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous // server-pos snapshot forward AND print the per-UP comparison // between the max sequencer speed observed since last UP and From b37b7137f6d7008e57b893ed8b3c4d2617afe9fc Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 15:47:40 +0200 Subject: [PATCH 6/7] =?UTF-8?q?docs(motion):=20#42=20root=20cause=20confir?= =?UTF-8?q?med=20=E2=80=94=20ResolveWithTransition=20airborne=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B-tested 2026-05-05 with user observing retail-controlled remote: - With CellId fix removed: jumps render with geometrically-correct XY (no drift) but body falls through the floor. - With CellId fix applied: jumps land cleanly but arc shows ~1 m horizontal offset; snaps back on next UM. Confirms the drift originates inside ResolveWithTransition, not from wire data, local Euler error, or stale velocity. CellId fix kept in place because falling through the floor is more disruptive than ~1 m visual jitter that resolves on next input. #42 updated with the verified diagnosis, three ranked-by-probability hypotheses for the in-sweep mechanism (initial-overlap depenetration along non-+Z terrain normal is the leading candidate), three matching fix paths, and a deterministic repro recipe for the next session. The right next step is investigating PhysicsEngine.ResolveWithTransition and comparing against retail's CTransition::find_valid_position (docs/research/named-retail/) — out of scope for the L.3 motion port, files as a follow-up PhysicsEngine bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 133 +++++++++++++++++------- src/AcDream.App/Rendering/GameWindow.cs | 8 +- 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b1cc364..96fcfba 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -127,9 +127,30 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g. ## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc) **Status:** OPEN -**Severity:** MEDIUM (pre-existing; exposed by L.3 M2 airborne UP no-op) -**Filed:** 2026-05-05 -**Component:** physics / motion (airborne local-integration) +**Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix) +**Filed:** 2026-05-05 (root cause confirmed same day) +**Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior) + +**Root cause (verified 2026-05-05 via A/B test):** + +`ResolveWithTransition` running per-tick during the airborne arc is the +source of the drift. Verified by A/B-toggling the M4 CellId fix +(`rmState.CellId = p.LandblockId`) which is the gate that lets the +sweep run for player-remote jumps: + +- **CellId line removed** → sweep skipped → jumps render with + geometrically-correct XY (no drift) but body falls through the + floor (no terrain catch). +- **CellId line present** → sweep runs → jumps land correctly but + arc shows ~1 m horizontal offset from actor's actual XY; body + snaps back on next inbound UM. + +So the drift originates inside `ResolveWithTransition` itself, not +from wire data, not from local Euler integration, not from stale +velocity. Decision recorded in commit history: kept CellId fix in +production code so jumps land (`fall-through-floor` is more disruptive +to gameplay than `~1m visual jitter that resolves on next input`). +This issue tracks the proper fix. **Description:** When observing a retail-controlled remote that jumps in place (no horizontal input), the visible jump arc renders with @@ -175,57 +196,91 @@ accumulated local-integration drift. The drift is pre-existing — the user reports having seen it before — but is now visible for the full arc duration instead of being corrected every ~200 ms. -**Suspected sources of XY drift on a stationary jump:** +**Likely mechanism (ranked by probability):** -1. **ACE wire VectorUpdate may have non-trivial XY components** even - when the actor is standing still. `OnLiveVectorUpdated` (line - 3235) sets `rm.Body.Velocity = update.Velocity` verbatim; no - filtering. Worth instrumenting `[VU.WIRE]` to confirm wire XY for - stationary jumps. +1. **Initial-overlap depenetration along non-+Z terrain normal** — at + jump start the collision sphere is touching the floor at body Z. + Most outdoor terrain triangles are not perfectly horizontal — their + normals have a small horizontal component. The sweep's first action + each tick is to resolve overlap by separating the sphere along the + contact normal; on a tilted terrain triangle that separation has + horizontal magnitude. The body gets shoved sideways the first frame + of the jump and the rest of the arc carries that initial drift. + Direction-correlation with terrain orientation would confirm + (test in different landblocks; if drift direction varies with the + slope of the launch tile, this is it). -2. **Pre-jump `rm.Body.Velocity` residual** — should be zero for the - M3 grounded path (cleared each tick at line 6118 area), but worth - confirming via diag. +2. **Step-down probe firing despite `isOnGround: false`** — sweep's + internal "search for nearest walkable surface" might still scan + horizontally during airborne ticks even when we pass `isOnGround: + !rm.Airborne` (= false for airborne). Check whether the + `stepUpHeight` / `stepDownHeight` parameters are unconditionally + used inside `ResolveWithTransition` regardless of the + `isOnGround` flag. -3. **EdgeSlide during sphere sweep** — if `ResolveWithTransition` - catches an edge mid-arc (unlikely for a stationary in-place jump - but possible), the sweep could push the body horizontally. - -4. **Render-rate-dependent dt** — local Euler integration uses - `Silk.NET.OnRender(double deltaSeconds)` raw; retail clamps to - 30 Hz. Sub-tick error accumulates over a 2 s arc. +3. **EdgeSlide on near-vertical motion against a near-vertical + surface** — if the sphere even slightly grazes a wall while + ascending or descending, EdgeSlide projects motion tangent to the + wall, redirecting some Z velocity into XY. Less likely for + open-ground stationary jumps but could explain drift near + buildings. **Fix paths:** -a. **Pragmatic** (revert to legacy behavior): hard-snap - `rm.Body.Position = worldPos` on airborne UPs too. Diverges from - retail spec, but ACE behavior diverges from retail too (ACE - broadcasts mid-arc UPs while retail apparently doesn't). Masks - the drift identically to pre-M2. Lowest-risk visual fix. +a. **Skip initial-overlap depenetration when airborne** — gate the + "separate from initial contact plane" step inside + `ResolveWithTransition` on `isOnGround: true`. Trusts the previous + tick's resolve to have left the body in a non-overlapping position. + This is the most likely-correct fix if hypothesis (1) is right. -b. **Investigate** the actual XY source via VU/UP instrumentation - and `body.Velocity` snapshots, then fix the root cause. May be - an ACE-specific velocity-quirk we should clamp at - `OnLiveVectorUpdated`, or a clock-source mismatch in our Euler. +b. **Zero step-up/down for airborne sweeps** — pass + `stepUpHeight: 0f, stepDownHeight: 0f` when `rm.Airborne`. Kills + hypothesis (2) without other side effects (airborne bodies don't + step anyway). -c. **Hybrid**: keep airborne no-op for body.Position translation but - re-introduce a soft-correction on mid-arc UPs (server-position- - biased lerp) — slowly pulls body toward server truth without - the rubber-band. +c. **Stripped airborne sweep** — replace the full sphere sweep with + a simpler vertical sphere-vs-terrain intersection + wall-collision + stop. Loses some retail fidelity but eliminates all three + mechanisms. Probably overkill if (a) or (b) suffices. **Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveVectorUpdated` - L3228+ (sets velocity from wire verbatim) -- `src/AcDream.App/Rendering/GameWindow.cs` `OnLivePositionUpdated` - player-remote airborne no-op L3502+ (the no-op that exposed this) -- `src/AcDream.App/Rendering/GameWindow.cs` legacy airborne TickAnimations - L6478+ (gravity integration via UpdatePhysicsInternal) +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — + `ResolveWithTransition` and any internal `CTransition` / + `find_valid_position` helpers. The initial-overlap depenetration + path is the primary investigation target. +- `src/AcDream.App/Rendering/GameWindow.cs:6478+` (legacy airborne + TickAnimations, the call site) — reference only; not the bug. + +**Reference:** + +Retail equivalent at +`docs/research/named-retail/acclient_2013_pseudo_c.txt`: +- `CTransition::find_valid_position` (called from `transition()`) +- `SpherePath` initialization +- The verbatim retail depenetration logic for airborne bodies + +If our port differs from retail in this region, that diff is likely +the bug. + +**Repro:** + +1. Launch acdream + retail client side-by-side connected to local ACE. +2. Have retail char stand still on outdoor terrain at any position X. +3. Jump in place. +4. Observe acdream window: arc renders ~1 m offset from X, lands + offset, snaps back on next UM. + +To verify the depenetration hypothesis specifically, repeat the jump +in different landblock spots — drift direction should correlate with +the local terrain normal, not the actor's facing. **Acceptance:** - Visual jump arc + landing render at the actor's actual XY position, - no perceptible horizontal offset, no snap-back on next UM/UP. + no perceptible horizontal offset, no snap-back on next UM. +- Wall-collision airborne (jumping into building doorways, jumping + puzzles) still works — fix must not strip collision wholesale. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2b07ebd..280e96b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3434,12 +3434,10 @@ public sealed class GameWindow : IDisposable // Adopt server's cell ID on every UP (airborne or grounded). // Required by the legacy airborne path's per-tick - // ResolveWithTransition gate (rm.CellId != 0) — without this, + // ResolveWithTransition gate (rm.CellId != 0); without this // an airborne player remote falls through the floor because - // the sphere sweep is skipped, K-fix15 landing detection never - // fires, and the body only re-grounds when the next UM forces - // ACE to broadcast a fresh IsGrounded=true UP that hits our - // landing transition branch below. + // the sphere sweep is skipped. Note: enabling the sweep also + // exposes a pre-existing depenetration bug — see #42. rmState.CellId = p.LandblockId; // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous From 5cc281251aafa758231c7910c8395edbcac46b9b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 15:48:45 +0200 Subject: [PATCH 7/7] docs(research): handoff prompt for #42 PhysicsEngine investigation Self-contained next-session brief for the airborne XY drift follow-up. Captures: confirmed root cause (ResolveWithTransition, verified A/B), three ranked hypotheses for the in-sweep mechanism (initial-overlap depenetration on non-+Z terrain normal is leading), three fix paths in preference order, repro steps with terrain-slope direction-correlation test, and the acceptance criteria. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/research/2026-05-05-issue-42-handoff.md | 96 ++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/research/2026-05-05-issue-42-handoff.md diff --git a/docs/research/2026-05-05-issue-42-handoff.md b/docs/research/2026-05-05-issue-42-handoff.md new file mode 100644 index 0000000..b5c63dd --- /dev/null +++ b/docs/research/2026-05-05-issue-42-handoff.md @@ -0,0 +1,96 @@ +# #42 follow-up — PhysicsEngine.ResolveWithTransition airborne XY drift + +**Context:** L.3 motion port landed in this branch (commits `de129bc` +`40d88b9` `2365c8c` `d57ace0` `c26bbbb` `b37b713`) — player-remote +running/walking/strafing/NPCs all visually verified clean. M4 jump +landing was fixed (CellId update). But that fix re-enabled +`ResolveWithTransition` per-tick during airborne arcs and exposed a +pre-existing PhysicsEngine bug: stationary jumps render with ~1 m +horizontal offset from the actor's actual XY, then snap back on the +next UM/UP. + +**Confirmed root cause** (A/B-tested 2026-05-05): drift originates +inside `ResolveWithTransition`, not from wire data, local Euler error, +or stale velocity. With the sweep skipped, jumps render geometrically +correct (but body falls through floor). With it enabled, jumps land +correctly but show the drift. So the bug lives in the sweep. + +## Most likely mechanism + +**Initial-overlap depenetration along non-+Z terrain normal.** At +jump start the collision sphere is touching the floor at body Z. Most +outdoor terrain triangles have non-vertical normals (small horizontal +component). The sweep's first-frame action is to resolve the +penetration by separating the sphere along the contact normal — and +on a tilted triangle that separation has horizontal magnitude. The +body gets shoved sideways the first frame; the rest of the arc +carries the offset. + +Direction-correlation test: jump at multiple landblock positions; if +drift direction varies with terrain slope orientation (not actor +facing), this hypothesis is confirmed. + +Other candidates ranked by probability: +2. Step-down probe firing despite `isOnGround: false` parameter. +3. EdgeSlide on near-vertical motion against near-vertical surface. + +## Files to investigate + +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — `ResolveWithTransition` + + any internal `CTransition` / `find_valid_position` helpers. The + initial-overlap depenetration path is the primary target. +- Reference at `docs/research/named-retail/acclient_2013_pseudo_c.txt`: + - `CTransition::find_valid_position` (called from `transition()`) + - `SpherePath` initialization + - Verbatim retail depenetration logic for airborne bodies + +If our port differs from retail in this region, that diff is the bug. + +## Fix paths (in order of preference) + +**(a) Skip initial-overlap depenetration when airborne.** Gate the +"separate from initial contact plane" step inside +`ResolveWithTransition` on `isOnGround: true`. Trusts the previous +tick's resolve to have left the body in a non-overlapping position. +Most likely correct fix. + +**(b) Zero step-up/down for airborne sweeps.** Pass +`stepUpHeight: 0f, stepDownHeight: 0f` from +`GameWindow.cs:6478+` when `rm.Airborne`. No side effects since +airborne bodies don't step. + +**(c) Stripped airborne sweep.** Replace the full sphere sweep with a +simpler vertical sphere-vs-terrain intersection + wall-collision +stop. Loses retail fidelity but eliminates all three mechanisms. +Probably overkill if (a) or (b) suffices. + +## Repro + +1. Launch acdream + retail client side-by-side on local ACE + (127.0.0.1:9000). +2. Have a retail-controlled toon stand still on outdoor terrain. +3. Jump in place. +4. Observe acdream window: arc shows ~1 m XY offset, lands offset, + snaps back on next inbound UM. + +To verify hypothesis (1) specifically: repeat the jump on terrain +patches with different visible slope orientations; if drift direction +changes accordingly, depenetration is confirmed. + +## Acceptance + +- Stationary jumps render at the actor's actual XY (no perceptible + drift, no snap-back on next UM). +- Wall-collision airborne still works (jumping into doorways, jumping + puzzles where you have to thread between platforms or through arches). + +## Operating notes + +- This is a `PhysicsEngine` bug, not a motion-port bug. The L.3 work + is done; this is a separate investigation. +- The `[VU.WIRE]` instrumentation idea from #42's earlier draft can + be skipped — we already proved wire data isn't the source via the + A/B test. +- cdb attach to retail (`docs/research/2026-05-04-l3-port/`-adjacent + toolchain documented in CLAUDE.md) is available if comparing + retail's airborne sweep behavior against ours becomes useful.