feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive, Count) so PositionManager + GameWindow callers continue to compile; internals are full retail spec. Bug fixes vs prior port (audit 04-interp-manager.md § 7): #1 progress_quantum accumulates dt (sum of frame deltas), not step magnitude. Retail line 353140; the prior port's `+= step` made the secondary stall ratio meaningless. #3 Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets _failCount = StallFailCountThreshold + 1 = 4, so the next AdjustOffset call's post-stall check fires an immediate blip-to- tail snap. Retail line 352944. Prior port silently drifted toward far targets at catch-up speed instead of teleporting. #4 Secondary stall test ports the retail formula verbatim: cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE. Audit notes the units are 1/sec (likely Turbine bug or x87 FPU misread by Binary Ninja) — mirrored byte-for-byte regardless. #5 Tail-prune is a tail-walking loop, not a single-tail compare. Multiple consecutive stale tail entries within DesiredDistance (0.05 m) of the new target collapse together. Retail line 352977. #6 Cap-eviction at the HEAD when count reaches 20 (already correct in the prior port; verified). New API: Enqueue gains an optional `currentBodyPosition` parameter so the far-branch detection can reference the body when the queue is empty. Backward-compatible (default null = pre-far-branch behavior). UseTime collapsed into AdjustOffset's tail (post-stall blip check) since acdream has no per-tick UseTime call separate from adjust_offset; identical semantic outcome. State fields renamed to retail names with sentinel values: _frameCounter, _progressQuantum, _originalDistance (init = 999999f sentinel per retail line 0x00555D30 ctor), _failCount. Tests: - 17/17 InterpolationManagerTests green. - New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset pins the bug #3 fix: enqueueing 150 m away triggers a same-tick blip (delta length ≈ 150 m), and the queue clears. Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/. 00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick, 03-up-routing, 04-interp-manager, 05-position-manager-and-partarray, 06-acdream-audit, 14-local-player-audit are the L.3 spec used by this commit and the M2 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a3f53c2644
commit
de129bc164
18 changed files with 10721 additions and 190 deletions
150
docs/research/2026-05-04-l3-port/00-master-plan.md
Normal file
150
docs/research/2026-05-04-l3-port/00-master-plan.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Full motion port — master plan
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Scope:** Every motion-related code path. No bandaids, no workarounds.
|
||||||
|
**Source research:** all 14 docs in this directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical reconciliation (research-vs-research)
|
||||||
|
|
||||||
|
Two prior research findings appeared to conflict:
|
||||||
|
|
||||||
|
- **Agent 02** (UM handling): "Retail does NOT velocity-dead-reckon walking remotes. m_velocityVector stays at zero."
|
||||||
|
- **Agent 09** (CPartArray): "Retail's actual locomotion comes from `CMotionInterp::get_state_velocity` → `CPhysicsObj::set_velocity` → `CTransition::transitional_insert`, NOT CSequence."
|
||||||
|
- **Agent 11** (UM dispatch deep): "`CPhysicsObj::DoInterpretedMotion` both pushes velocity (`set_local_velocity(get_state_velocity())`) AND drives the animation sequencer's per-axis cycle slot."
|
||||||
|
|
||||||
|
**Reconciliation:** Agent 02 was wrong. `m_velocityVector` IS non-zero for walking remotes — it gets written by `set_local_velocity` inside `DoInterpretedMotion`, which fires per-axis when an UM arrives AND on every per-tick `apply_current_movement` call. The original cdb trace agent 02 cited (`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`) likely sampled at a moment when `DoInterpretedMotion` had not yet fired (e.g., immediately post-spawn).
|
||||||
|
|
||||||
|
**Correct retail model for walking remote per-tick:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Frame local = identity // var_40 cached
|
||||||
|
2. CPartArray::Update writes animation root motion into local
|
||||||
|
- Humanoid locomotion (Walk/Run/Sidestep): dat ships zero, so local.origin stays 0
|
||||||
|
- Non-locomotion cycles with baked PosFrames: local.origin = baked delta
|
||||||
|
3. PositionManager::adjust_offset:
|
||||||
|
- InterpolationManager::adjust_offset: when distance(body, head) >= 0.05m,
|
||||||
|
OVERWRITES local.origin with `direction × min(catchUpSpeed × dt, distance)`
|
||||||
|
(catch-up REPLACES animation root motion this frame)
|
||||||
|
- StickyManager::adjust_offset: when stuck, writes XY step + heading toward target
|
||||||
|
- ConstraintManager::adjust_offset: when leashed and grounded, scales/zeros local.origin
|
||||||
|
4. Frame::combine(out, m_position.frame, local)
|
||||||
|
- out.origin = m_position.origin + rotate(local.origin by m_position.l2gv)
|
||||||
|
- out.rotation = m_position.rotation · local.rotation
|
||||||
|
5. UpdatePhysicsInternal: gated on velocity² > 0
|
||||||
|
- out.origin += m_velocityVector × dt + 0.5 × accel × dt²
|
||||||
|
- For grounded walking remote: m_velocityVector = walk_pace_world (set per-tick by apply_current_movement → set_local_velocity → get_state_velocity). NON-ZERO.
|
||||||
|
- For idle (Ready cycle): get_state_velocity returns 0 → m_velocityVector = 0 → no-op.
|
||||||
|
6. transition() — sphere sweep from m_position to out
|
||||||
|
7. set_frame(resolved) — commit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implication for L.3 port already shipped (commit 91bf1e0):**
|
||||||
|
|
||||||
|
The L.3 commit ZEROS `body.Velocity` for player remotes and removes `apply_current_movement` from per-tick. This is **wrong direction**. It causes:
|
||||||
|
- Body advances only via queue catch-up (no velocity integration). Steady run lags.
|
||||||
|
- Orientation changes don't propagate (since velocity isn't recomputed per-tick to use latest orientation). Drift during turning.
|
||||||
|
- Stop case is fine (cycle goes Ready → seqVel=0 → no motion) but drift case is broken.
|
||||||
|
|
||||||
|
**Action:** revert the body.Velocity=0 change. Restore per-tick `apply_current_movement` for all remotes. Layer queue catch-up as ADDITIONAL local-frame contribution that REPLACES animation root motion (which is 0 for locomotion anyway). Sequence is exactly as agent 01 documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What needs porting (consolidated)
|
||||||
|
|
||||||
|
### Phase A: Per-tick pipeline correction (Critical — fixes L.3 regression + drift)
|
||||||
|
|
||||||
|
1. **Restore per-tick `apply_current_movement` for all grounded remotes.** Remove the `IsPlayerGuid` zero-velocity branch added in commit 91bf1e0.
|
||||||
|
2. **Layer InterpolationManager catch-up before Frame::combine.** Local frame = identity, optionally overwritten by adjust_offset. Then composed with m_position. Then velocity integrates additively into the composed frame.
|
||||||
|
3. **Implement `Frame::combine` semantics correctly.** Out.origin = m_position.origin + rotate(local.origin by m_position.l2gv basis). Out.rotation = m_position.rotation · local.rotation.
|
||||||
|
|
||||||
|
### Phase B: Substepping (eliminates ObservedOmega)
|
||||||
|
|
||||||
|
4. **Port full `update_object` substepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum.** Constants: 0.0002s / (1/30)s / 0.1s / 2.0s.
|
||||||
|
5. **Eliminate manual `ObservedOmega` integration.** Once substepping works, retail's `omega` field on Body is integrated correctly per substep — no need for the formula seed `(π/2 × turnSpeed)`.
|
||||||
|
6. **Drop `RemoteMotion.ObservedOmega` field.**
|
||||||
|
|
||||||
|
### Phase C: UM handling rewrite (collapses 600-line cycle picker)
|
||||||
|
|
||||||
|
7. **Port `apply_interpreted_movement` (acclient @ 0x00528600).** Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue.
|
||||||
|
8. **Port `MotionTableManager`, `CMotionTable`, `MotionState`.** Each remote has its own (style, substate, substate_mod, modifier_head, action_head). Cycle decision per-axis through table lookups, not priority picker.
|
||||||
|
9. **Port `GetObjectSequence` (@ 0x00522860).** Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore).
|
||||||
|
10. **Collapse `OnLiveMotionUpdated` to `move_to_interpreted_state(wireInterpretedState)`.** Drop the 600-line cycle picker (forward → sidestep → turn → Ready priority). Drop the RunForward → WalkForward → Ready fallback chain (acdream-original, doesn't exist in retail).
|
||||||
|
11. **Port `adjust_motion` (FUN_00528010).** WalkBackward → WalkForward + speed×-0.65; SideStepLeft → SideStepRight + speed×-1. This is sender-side normalization. Local player path needs it too (drops hand-rolled overrides at PlayerMovementController.cs:378-411).
|
||||||
|
|
||||||
|
### Phase D: UP routing — full Branch A (hard teleport)
|
||||||
|
|
||||||
|
12. **Plumb `TeleportSequence` (u16) from `UpdatePosition` parser through `EntityPositionUpdate` to `OnLivePositionUpdated`.** Currently dropped at WorldSession.cs:110-114, 712-716.
|
||||||
|
13. **Port `update_times[]` array on RemoteMotion.** 4 slots: instance(8), position(0), teleport(4), force_position(6). Each is `u16` with 16-bit-wrap forward-test via `newer_event` helper.
|
||||||
|
14. **Implement Branch A:** when `newer_event(TELEPORT_TS, wire.teleport_seq)` OR `cell == 0` → call `teleport_hook` (CancelMoveTo + UnStick + StopInterpolating + UnConstrain + ClearTarget + report_collision_end) + `SetPosition` with flags 0x1012.
|
||||||
|
15. **Implement Branch B (queue) and Branch C (slide-snap)** correctly — these are ALREADY in commit 91bf1e0 modulo using `player_distance` (already fixed via S1). Keep.
|
||||||
|
|
||||||
|
### Phase E: VectorUpdate (jump) corrections
|
||||||
|
|
||||||
|
16. **Drop `OnLiveVectorUpdated`'s extra writes** (Gravity flag toggle, transient_state Contact/OnWalkable clear). Retail's `DoVectorUpdate` only writes `set_velocity` + `set_omega`. The Gravity flag transition is driven by `set_on_walkable(false)` 1→0 edge inside the per-tick path (post-LeaveGround).
|
||||||
|
17. **Port `set_on_walkable` 0→1 / 1→0 edge detection.** Drives LeaveGround/HitGround automatically without explicit Airborne flag.
|
||||||
|
18. **Drop `RemoteMotion.Airborne` bool.** Replace with `(state & Gravity) != 0` test.
|
||||||
|
19. **Replace K-fix15 landing heuristic** (Z-velocity-based) with retail's `contact_plane.N.z >= floor_z` (cosine of 49°) gate.
|
||||||
|
20. **Port `jumped_this_frame` write in `set_velocity`.** Currently missing in PhysicsBody.
|
||||||
|
|
||||||
|
### Phase F: Sticky + Constraint (port from scratch)
|
||||||
|
|
||||||
|
21. **Port `StickyManager` (acclient ~0x???)** — sticks body to target object at fixed cylinder radius. Activates via MoveToManager when `__inner0` bit 0x80 set. adjust_offset writes XY step + heading rotation; Z always zero.
|
||||||
|
22. **Port `ConstraintManager`** — leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it.
|
||||||
|
23. **Wire both into `PositionManager.adjust_offset` chain** in fixed order: Interp → Sticky → Constraint.
|
||||||
|
|
||||||
|
### Phase G: NPC convergence
|
||||||
|
|
||||||
|
24. **Drop `RemoteMotion.HasServerVelocity` / `ServerVelocity` synth machinery.** NPCs go through same per-tick pipeline as player remotes once Phase A lands.
|
||||||
|
25. **`RemoteMoveToDriver` audit:** add UseFinalHeading (bit 0x40 post-arrival rotation), Sticky-after-arrival (bit 0x80 latch-on), Contact-bit gating during mid-air. Per agent 07 research §"3 minor port gaps".
|
||||||
|
|
||||||
|
### Phase H: Local player cleanup (low-priority)
|
||||||
|
|
||||||
|
26. **Wire `PlayerDescription` (0xF7B0 / 0x0013) → `_weenie.SetSkills(...)`.** Drops `ACDREAM_RUN_SKILL` env var workaround (issue #7).
|
||||||
|
27. **Gate `AutonomousPosition` heartbeat on motion.** Don't send 1Hz heartbeat at rest (cdb confirmed retail doesn't).
|
||||||
|
28. **Apply `adjust_motion` to local player input.** Drops hand-rolled velocity overrides at `PlayerMovementController.cs:378-411` and `:466-501`.
|
||||||
|
|
||||||
|
### Phase I: Cleanup
|
||||||
|
|
||||||
|
29. **Delete env-var fork** (`ACDREAM_INTERP_MANAGER=1` path, ~430 lines of dead code).
|
||||||
|
30. **Remove `RemoteMotion` workaround fields:** `ServerVelocity`, `HasServerVelocity`, `LastServerZ`, `PrevServerPos*`, `Max*`, `TargetOrientation`, `ObservedOmega`. Down from 31 fields to ~10.
|
||||||
|
31. **Remove all `IsPlayerGuid` per-tick gates** (5 sites in audit). Same pipeline for all entities.
|
||||||
|
32. **Remove `ServerControlledLocomotion.PlanFromVelocity`** if `ApplyServerControlledVelocityCycle` is the only caller (already removed in commit 91bf1e0). Verify other consumers.
|
||||||
|
|
||||||
|
### Phase J: Tests + verification
|
||||||
|
|
||||||
|
33. **Update existing tests for new architecture.** InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement.
|
||||||
|
34. **Run full test suite, verify 0 new regressions.**
|
||||||
|
35. **Code review subagent on full port.**
|
||||||
|
36. **Visual verify with user — full motion test matrix.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order (strict serial)
|
||||||
|
|
||||||
|
Bottom-up, each phase must build green before next:
|
||||||
|
|
||||||
|
1. **Phase A** (per-tick correction) — undoes the L.3 regression. Smallest, surgical. Highest priority.
|
||||||
|
2. **Phase D** (hard-teleport branch) — additive plumbing, no regression risk.
|
||||||
|
3. **Phase B** (substepping) — replaces ObservedOmega. Medium risk.
|
||||||
|
4. **Phase E** (VectorUpdate corrections) — drops acdream workarounds. Medium risk.
|
||||||
|
5. **Phase G** (NPC convergence) — depends on Phase A solid.
|
||||||
|
6. **Phase F** (Sticky + Constraint) — additive ports.
|
||||||
|
7. **Phase C** (UM handling rewrite + cycle picker) — biggest single change. Replaces 600 lines.
|
||||||
|
8. **Phase H** (local player cleanup) — independent of others.
|
||||||
|
9. **Phase I** (cleanup) — last, after everything works.
|
||||||
|
10. **Phase J** (tests + review + verify) — gating ship.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- All 3 reported user issues fixed:
|
||||||
|
1. Stop after running settles within ≤1 UP cycle (~200ms) — apply_current_movement reads Ready → velocity 0 → body stops.
|
||||||
|
2. Backward walk plays backward animation (no flip to forward run) — verified by ApplyServerControlledVelocityCycle removal in 91bf1e0.
|
||||||
|
3. Long co-run drift bounded by DesiredDistance (0.05m) — apply_current_movement per-tick keeps velocity rotated by current orientation.
|
||||||
|
- Build green.
|
||||||
|
- All existing tests pass (8 pre-existing failures unchanged; new tests added for per-axis dispatch).
|
||||||
|
- Code review passes.
|
||||||
|
- Visual verify by user.
|
||||||
277
docs/research/2026-05-04-l3-port/00-port-plan.md
Normal file
277
docs/research/2026-05-04-l3-port/00-port-plan.md
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
# L.3 — Player remote motion: retail-faithful port plan
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Source research:** [01-per-tick.md](01-per-tick.md), [02-um-handling.md](02-um-handling.md), [03-up-routing.md](03-up-routing.md), [04-interp-manager.md](04-interp-manager.md), [05-position-manager-and-partarray.md](05-position-manager-and-partarray.md), [06-acdream-audit.md](06-acdream-audit.md)
|
||||||
|
**Goal:** Reported user issues — body keeps walking after actor stops, backward walk regression flips to forward-run animation, position drift over time during co-running. Fix all three by porting retail's motion pipeline faithfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What retail actually does (synthesized)
|
||||||
|
|
||||||
|
**Per-tick body advancement** (`CPhysicsObj::UpdateObjectInternal` @ acclient 0x005156b0 → `UpdatePositionInternal` @ 0x00512c30):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Frame local = identity // var_40 cached
|
||||||
|
2. CPartArray::Update writes animation root motion into local
|
||||||
|
- For locomotion cycles (Walk/Run/Sidestep), translation += seqVel × dt
|
||||||
|
- For Ready/idle cycles, no translation
|
||||||
|
3. PositionManager::adjust_offset(local, dt) modifies local in-place:
|
||||||
|
- InterpolationManager::adjust_offset:
|
||||||
|
* If queue empty OR body within DesiredDistance(0.05m) of head → no-op
|
||||||
|
(animation root motion drives)
|
||||||
|
* Else → OVERWRITE local.translation with direction × min(catchUpSpeed × dt, distance)
|
||||||
|
(catch-up REPLACES root motion this frame)
|
||||||
|
- StickyManager / ConstraintManager (deferred — niche features)
|
||||||
|
4. Frame::combine(out, m_position.frame, local) — compose with current world frame
|
||||||
|
5. UpdatePhysicsInternal Euler-integrates m_velocityVector × dt INTO out.origin
|
||||||
|
(gated on velocity² > 0; for walking remotes m_velocityVector=0 so no-op)
|
||||||
|
6. transition() — sphere sweep from m_position to out
|
||||||
|
7. set_frame(resolved) or SetPositionInternal(transition result)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical retail invariants:**
|
||||||
|
- `m_velocityVector` is 0 for walking remotes. Only set by outbound jump (LeaveGround) + inbound 0xF74E VectorUpdate.
|
||||||
|
- ALL visible motion comes from animation root motion + InterpolationManager catch-up.
|
||||||
|
- Catch-up speed = `2 × motion_max_speed × dt` where `motion_max_speed` = current cycle's actual velocity magnitude.
|
||||||
|
- Same pipeline for every entity — no player-vs-NPC special-casing at per-tick layer.
|
||||||
|
|
||||||
|
**UM (UpdateMotion) handling** (`CMotionInterp::move_to_interpreted_state` @ 0x00528a90):
|
||||||
|
- Inbound 0xF74C → bulk `copy_movement_from` of all 7 InterpretedState fields (acdream already does this).
|
||||||
|
- **Stop signal is implicit**: flag 0x02 (forward) cleared → `ForwardCommand` defaults to `Ready`, `ForwardSpeed = 1.0`. No explicit "stop motion" packet.
|
||||||
|
- Backward-walk arrives pre-adjusted: sender's `adjust_motion` flips `WalkBackward → WalkForward + speed=-0.65×s`; receiver bulk-copies.
|
||||||
|
- Side-axis and turn-axis fire `DoInterpretedMotion` per axis (acdream already does this).
|
||||||
|
|
||||||
|
**UP (UpdatePosition) routing** (`CPhysicsObj::MoveOrTeleport` @ 0x00516330):
|
||||||
|
- Tri-state decision tree:
|
||||||
|
- **Hard teleport**: teleport-seq advanced OR cell == 0 → `SetPosition` with flags 0x1012 (Slide+Placement+SendPositionEvent). Body.Position changes immediately.
|
||||||
|
- **InterpolateTo (queue)**: grounded AND distance < 96m → `position_queue` mutated; **Body.Position does NOT change**.
|
||||||
|
- **Slide-snap**: grounded AND distance ≥ 96m → `StopInterpolating` + `SetPositionSimple`.
|
||||||
|
- **Airborne**: no-op (gravity arc continues from launch velocity).
|
||||||
|
- Orientation rides the Position struct's Frame — never queued separately. Hard-snapped on UP.
|
||||||
|
|
||||||
|
**Constants verified from named binary:**
|
||||||
|
- `MAX_PHYSICS_DISTANCE = 96` m
|
||||||
|
- `CREATURE_OUTSIDE_BLIP_DISTANCE = 100` m
|
||||||
|
- `CREATURE_INSIDE_BLIP_DISTANCE = 20` m
|
||||||
|
- `MAX_INTERPOLATED_VELOCITY_MOD = 2.0`
|
||||||
|
- `MAX_INTERPOLATED_VELOCITY = 7.5` m/s (fallback when motion_max unavailable)
|
||||||
|
- `MIN_DISTANCE_TO_REACH_POSITION = 0.20` m
|
||||||
|
- `DESIRED_DISTANCE = 0.05` m
|
||||||
|
- `CREATURE_FAILED_INTERPOLATION_PERCENTAGE = 0.30`
|
||||||
|
- `StallCheckFrameInterval = 5` frames
|
||||||
|
- `StallFailCountThreshold = 3` fails
|
||||||
|
- Queue cap = 20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where acdream diverges (top issues from audit)
|
||||||
|
|
||||||
|
1. **Per-tick `apply_current_movement` on player remotes** (GameWindow.cs:6599) writes `body.Velocity = RunAnimSpeed × ForwardSpeed × orientation`. Retail spec: `body.Velocity` must be 0 for walking remotes. **This is the central regression.**
|
||||||
|
|
||||||
|
2. **Two parallel per-tick paths.** Env-var path (L6118-6445) is the L.3 architecture but regressed (issue #40). Legacy path (L6446-6764) is production default and fundamentally wrong vs L.3. Need to collapse into one correct path.
|
||||||
|
|
||||||
|
3. **`IsPlayerGuid` gates at 5 sites** route player remotes through the broken `apply_current_movement` else-branch. Retail uses one pipeline for all entities.
|
||||||
|
|
||||||
|
4. **InterpolationManager bugs** (per research 04):
|
||||||
|
- `progress_quantum` accumulates `step` (distance) instead of `dt` (time)
|
||||||
|
- Secondary stall check missing `/dt` factor
|
||||||
|
- Missing `NodeCompleted(0)` head-pop on stall (one bad waypoint stalls indefinitely)
|
||||||
|
- Missing `transient_state & 1` gate
|
||||||
|
- Missing far-distance force-blip via `_failCount = 4` on enqueue
|
||||||
|
|
||||||
|
5. **`CPartArray::Update` collapsed into single `seqVel × dt` per tick.** For locomotion cycles, both retail and acdream synthesize velocity from formula (Humanoid dat ships zero), so this is OK for the user's reported issues. The per-keyframe loop matters for non-locomotion (emotes etc) — defer.
|
||||||
|
|
||||||
|
6. **Cycle picker in OnLiveMotionUpdated is acdream-original** (forward → sidestep → turn → Ready priority). Retail just plays the cycle the wire told it to play. Defer; not the immediate cause of reported bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port plan — concrete changes
|
||||||
|
|
||||||
|
Targeted at fixing all three reported user symptoms. Defers cosmetic divergences (cycle picker, full per-keyframe loop, sticky/constraint managers) to follow-up phases.
|
||||||
|
|
||||||
|
### Step 1: Fix `InterpolationManager.cs` bugs
|
||||||
|
|
||||||
|
File: `src/AcDream.Core/Physics/InterpolationManager.cs`
|
||||||
|
|
||||||
|
Changes (all from research doc 04, sections 3 + 7):
|
||||||
|
|
||||||
|
1. **`AdjustOffset`**: change `_progressQuantum += step;` → `_progressQuantum += (float)dt;`. Accumulate time, not distance.
|
||||||
|
2. **Secondary stall check**: change `cumulative / progressQuantum < 0.30` → `(cumulative / progressQuantum) / dt < 0.30`. Match retail formula.
|
||||||
|
3. **Stall handling**: when stall threshold exceeded, pop head node into a `_blipToPosition` field. Don't return snap delta inside AdjustOffset.
|
||||||
|
4. **Add `UseTime()` method**: separately performs the blip via `body.SetPositionSimple` when `_failCount > StallFailCountThreshold`. Called once per tick from per-tick path.
|
||||||
|
5. **`Enqueue`**: when distance from current body to enqueued position exceeds `MAX_PHYSICS_DISTANCE` (96m), pre-arm `_failCount = StallFailCountThreshold + 1` so next tick's `UseTime` blips immediately.
|
||||||
|
6. **Add `IsLive` parameter to AdjustOffset** corresponding to `transient_state & 1`. Default true; pass through.
|
||||||
|
|
||||||
|
Tests to add:
|
||||||
|
- `progress_quantum` accumulates dt, not step
|
||||||
|
- Stall after 3 windows pops head and arms blip
|
||||||
|
- `UseTime` calls `SetPositionSimple` when armed and clears state
|
||||||
|
- Far enqueue arms immediate blip
|
||||||
|
|
||||||
|
### Step 2: Unify the per-tick path in `GameWindow.TickAnimations`
|
||||||
|
|
||||||
|
File: `src/AcDream.App/Rendering/GameWindow.cs`
|
||||||
|
|
||||||
|
Delete the env-var fork. Single per-tick path for all remote entities (player or NPC). This is the bulk of the work — replace lines 6118-6764 (~640 LOC) with a single ~150 LOC retail-faithful port.
|
||||||
|
|
||||||
|
Per-tick algorithm (matching retail `UpdatePositionInternal`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Step 0: Force grounded transient flags for non-airborne (no change)
|
||||||
|
if (!rm.Airborne) {
|
||||||
|
rm.Body.TransientState |= Contact | OnWalkable | Active;
|
||||||
|
rm.Body.Velocity = Vector3.Zero; // RETAIL INVARIANT: walking remotes have zero velocity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: NPC MoveTo branch (existing — unchanged)
|
||||||
|
if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) {
|
||||||
|
/* RemoteMoveToDriver.Drive (existing — keep as-is) */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: PositionManager.ComputeOffset returns either:
|
||||||
|
// - queue catch-up correction (when body drifted from head)
|
||||||
|
// - animation root motion (when at head OR queue empty)
|
||||||
|
// - zero (when queue empty AND seqVel zero — Ready cycle, idle observer)
|
||||||
|
var seqVel = ae.Sequencer?.CurrentVelocity ?? Vector3.Zero;
|
||||||
|
float maxSpeed = seqVel.Length(); // motion_max_speed = cycle's actual velocity
|
||||||
|
if (maxSpeed <= 0f) maxSpeed = MotionInterpreter.RunAnimSpeed; // 4.0 fallback
|
||||||
|
var preIntegratePos = rm.Body.Position;
|
||||||
|
var offset = rm.Position.ComputeOffset(dt, preIntegratePos, seqVel, rm.Body.Orientation, rm.Interp, maxSpeed);
|
||||||
|
var postIntegratePos = preIntegratePos + offset;
|
||||||
|
|
||||||
|
// Step 3: Manual omega integration (preserve existing — bypasses MinQuantum gate)
|
||||||
|
ApplyObservedOmega(rm, dt);
|
||||||
|
|
||||||
|
// Step 4: Physics integration. With body.Velocity=0 (set in step 0), this is a
|
||||||
|
// no-op for grounded remotes. For airborne remotes, gravity drives body.Position.
|
||||||
|
rm.Body.calc_acceleration();
|
||||||
|
rm.Body.UpdatePhysicsInternal(dt);
|
||||||
|
postIntegratePos = rm.Body.Position; // re-read in case airborne integration moved it
|
||||||
|
|
||||||
|
// Step 5: Collision sweep (preserve existing — Commit B added this for slope tracking)
|
||||||
|
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) {
|
||||||
|
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||||
|
preIntegratePos, postIntegratePos, rm.CellId,
|
||||||
|
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
||||||
|
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
|
||||||
|
isOnGround: !rm.Airborne, body: rm.Body,
|
||||||
|
moverFlags: ObjectInfoState.EdgeSlide);
|
||||||
|
rm.Body.Position = resolveResult.Position;
|
||||||
|
if (resolveResult.CellId != 0) rm.CellId = resolveResult.CellId;
|
||||||
|
/* existing post-resolve landing detection — unchanged */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: UseTime — fire stall blips for non-airborne entities with armed fail counter
|
||||||
|
if (!rm.Airborne) {
|
||||||
|
rm.Interp.UseTime(rm.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Sync renderable
|
||||||
|
ae.Entity.Position = rm.Body.Position;
|
||||||
|
ae.Entity.Rotation = rm.Body.Orientation;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Removed:** the entire `apply_current_movement` else-branch (current L6599) for player remotes. The NPC `HasServerVelocity` synth-velocity branch (current L6493-6511) — NPCs don't need this either, they should also use queue-based motion. Defer NPC migration to a follow-up if it risks regression; for this phase, keep the NPC HasServerVelocity branch but remove the player path.
|
||||||
|
|
||||||
|
**Conservative scope:** Player remotes get the L.3 path. NPCs keep their existing `HasServerVelocity` branch + `RemoteMoveToDriver`. Both can converge later.
|
||||||
|
|
||||||
|
### Step 3: Update `OnLivePositionUpdated` UP routing
|
||||||
|
|
||||||
|
File: `src/AcDream.App/Rendering/GameWindow.cs:3425-3824`
|
||||||
|
|
||||||
|
Replace the legacy default branch (L3628-3761) with the env-var branch's logic — but keep the synth-velocity computation for NPCs (which still uses it via `HasServerVelocity`).
|
||||||
|
|
||||||
|
For player remotes within 96m grounded: `Interp.Enqueue(worldPos, heading, isMovingTo:false)`. **No hard-snap** of `body.Position`.
|
||||||
|
|
||||||
|
For player remotes outside 96m or first UP: hard-snap + `Interp.Clear()`.
|
||||||
|
|
||||||
|
For airborne player remotes: existing landing-transition logic.
|
||||||
|
|
||||||
|
For NPCs: existing path (synth velocity, hard-snap, etc.) — preserve.
|
||||||
|
|
||||||
|
### Step 4: Drop `ApplyServerControlledVelocityCycle`
|
||||||
|
|
||||||
|
File: `src/AcDream.App/Rendering/GameWindow.cs:3325-3423`
|
||||||
|
|
||||||
|
This whole function exists because of issue #39 — Shift-toggle Run↔Walk doesn't fire a fresh UM. Per research doc 02, **retail's wire actually does fire fresh UMs on Shift-toggle** (because retail's outbound `apply_run_to_command` re-runs and produces a different `ForwardSpeed`). If our observed acdream-on-retail behavior shows UMs missing on Shift-toggle, that's an ACE bug — not something we should compensate for client-side.
|
||||||
|
|
||||||
|
Drop the function. Drop the call site at line 3791. Drop `RemoteMotion.LastUmUpdateTime`. Drop the `IsPlayerGuid` gates the function relies on.
|
||||||
|
|
||||||
|
If issue #39 reappears after this, file an ACE bug rather than re-adding client-side hysteresis logic.
|
||||||
|
|
||||||
|
### Step 5: Drop `IsPlayerGuid` per-tick gates
|
||||||
|
|
||||||
|
Five sites identified in audit:
|
||||||
|
- L706 (definition — keep, used elsewhere)
|
||||||
|
- L3349 (`ApplyServerControlledVelocityCycle` — dropped in Step 4)
|
||||||
|
- L3727 (UP velocity-adoption fallback — review, may stay for NPCs)
|
||||||
|
- L6493 (NPC HasServerVelocity branch — keep for now, NPCs)
|
||||||
|
- L6512 (NPC ServerMoveToActive branch — keep, NPCs)
|
||||||
|
- L6588 (NPC ServerMoveToActive without dest — keep, NPCs)
|
||||||
|
|
||||||
|
Effectively: drop the L3349 gate via Step 4. The remaining gates correctly route NPC paths.
|
||||||
|
|
||||||
|
### Step 6: Verify CPartArray velocity for locomotion cycles
|
||||||
|
|
||||||
|
File: `src/AcDream.Core/Physics/AnimationSequencer.cs`
|
||||||
|
|
||||||
|
`CurrentVelocity` synthesis at lines 614-646 already matches retail constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25). Per research doc 05, this is the right approximation for Humanoid (dat ships zero velocity). **No changes needed.**
|
||||||
|
|
||||||
|
For sign-flipped backward walk (`WalkForward + speed=-1`), `adjustedSpeed = speedMod` directly preserves the negative sign. `CurrentVelocity.Y = WalkAnimSpeed × -1 = -3.12`. Body root-motions backward in body-local frame. Rotated by orientation = backward in world frame. Correct.
|
||||||
|
|
||||||
|
### Step 7: Test, code review, visual verify
|
||||||
|
|
||||||
|
- Build: `dotnet build`
|
||||||
|
- Tests: existing `ServerControlledLocomotionTests` (7) should still pass; new `InterpolationManagerStallTests` for the bug fixes
|
||||||
|
- Code review subagent on the unified per-tick path
|
||||||
|
- Visual verify with user — full motion test matrix:
|
||||||
|
1. Steady run forward
|
||||||
|
2. Steady walk forward
|
||||||
|
3. Steady walk backward
|
||||||
|
4. Steady strafe right
|
||||||
|
5. Steady strafe left
|
||||||
|
6. Run + turn
|
||||||
|
7. Walk + turn
|
||||||
|
8. Run → Stop (release W)
|
||||||
|
9. Walk → Stop
|
||||||
|
10. Run → Shift toggle to walk
|
||||||
|
11. Walk → Shift release to run
|
||||||
|
12. Jump + land
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this fix does NOT address (deferred)
|
||||||
|
|
||||||
|
- **Full per-keyframe `CPartArray::Update` loop** — for non-locomotion cycles (emotes, idle subtleties). Defer until visible bug.
|
||||||
|
- **StickyManager / ConstraintManager** — niche retail features (locked targets, etc).
|
||||||
|
- **Branch A (Hard teleport)** in MoveOrTeleport — needs `teleport_timestamp` plumbing through the protocol.
|
||||||
|
- **NPC migration to L.3 path** — keeps existing `HasServerVelocity` synth path; will converge later.
|
||||||
|
- **OnLiveMotionUpdated cycle picker** — current acdream-original logic. Retail just plays the wire's cycle directly. Defer if user-visible bugs don't depend on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- All three reported user issues resolved:
|
||||||
|
1. Stop after running: body settles within ≤1 UP cycle (200ms) of UM(Ready) arrival.
|
||||||
|
2. Backward walk: body moves backward, animation plays backward (no flip to forward-run).
|
||||||
|
3. Long co-run: positional sync holds — drift bounded by `DesiredDistance` (0.05m).
|
||||||
|
- `dotnet build` green.
|
||||||
|
- `dotnet test` green (existing tests pass + new tests for InterpolationManager bug fixes).
|
||||||
|
- Code review pass on the unified per-tick path.
|
||||||
|
- Visual verify by user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
Strict serial — each step must build green before next:
|
||||||
|
|
||||||
|
1. **InterpolationManager bug fixes** (Step 1) — small, isolated, testable in unit tests.
|
||||||
|
2. **Drop `ApplyServerControlledVelocityCycle`** (Step 4) — surgical removal.
|
||||||
|
3. **Unify per-tick path** (Step 2) — large change. Will need a code review after.
|
||||||
|
4. **Update UP routing** (Step 3) — surgical replacement of OnLivePositionUpdated default branch.
|
||||||
|
5. **Build + run tests** (Step 7).
|
||||||
|
6. **Visual verify with user** (Step 7).
|
||||||
|
|
||||||
|
Do NOT proceed past step 5 to user testing if any earlier step is incomplete or broken.
|
||||||
613
docs/research/2026-05-04-l3-port/01-per-tick.md
Normal file
613
docs/research/2026-05-04-l3-port/01-per-tick.md
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
# L.3 Per-Tick Body Update Pipeline — Retail Reference
|
||||||
|
|
||||||
|
**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named, Binary Ninja pseudo-C).
|
||||||
|
**Scope**: How retail advances a `CPhysicsObj` (player or remote) **one physics tick**, from the entry call through animation-driven motion, physics integration, and collision resolution.
|
||||||
|
**Purpose**: Correct the env-var per-tick path in `GameWindow.cs` (~line 6428+) so it matches retail order-of-operations and behavior, especially for "walking remote" cases where `m_velocityVector` is supposed to stay zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-level call graph
|
||||||
|
|
||||||
|
```
|
||||||
|
CPhysicsObj::UpdateObjectInternal(this, dt) @ 0x005156b0
|
||||||
|
└─ CPhysicsObj::UpdatePositionInternal(this, dt, &outFrame) @ 0x00512c30
|
||||||
|
│ ├─ CPartArray::Update(part_array, dt, &localFrame) @ 0x00517db0
|
||||||
|
│ │ └─ CSequence::update(&sequence, dt, &localFrame) @ 0x00525b80
|
||||||
|
│ │ ├─ CSequence::update_internal(...) // advances anim cursor
|
||||||
|
│ │ └─ CSequence::apply_physics(this, frame, dt, dt) @ 0x00524ab0
|
||||||
|
│ │ ├─ frame.m_fOrigin += dt * sequence.velocity // ROOT MOTION
|
||||||
|
│ │ └─ Frame::rotate(frame, dt * sequence.omega) // ROOT ROT
|
||||||
|
│ ├─ PositionManager::adjust_offset(pos_mgr, &localFrame, dt) @ 0x00555190
|
||||||
|
│ │ ├─ InterpolationManager::adjust_offset
|
||||||
|
│ │ ├─ StickyManager::adjust_offset
|
||||||
|
│ │ └─ ConstraintManager::adjust_offset
|
||||||
|
│ ├─ Frame::combine(outFrame, &m_position.frame, &localFrame) @ 0x005122e0
|
||||||
|
│ │ // outFrame = m_position.frame ⊗ localFrame (rotate-translate compose)
|
||||||
|
│ ├─ CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame) @ 0x00510700
|
||||||
|
│ │ // Euler integration of m_velocityVector + m_accelerationVector + m_omegaVector
|
||||||
|
│ │ // Modifies outFrame.m_fOrigin (translation) and rotation (Frame::grotate)
|
||||||
|
│ │ // Modifies m_velocityVector (acceleration step)
|
||||||
|
│ └─ CPhysicsObj::process_hooks(this)
|
||||||
|
├─ CPhysicsObj::transition(this, &m_position, &outFrame, 0) @ 0x00512dc0
|
||||||
|
│ └─ CTransition::find_valid_position(...) // BSP / sphere sweep
|
||||||
|
├─ CPhysicsObj::set_frame(this, &outFrame) @ 0x00514090 // (failure path)
|
||||||
|
│ OR
|
||||||
|
├─ CPhysicsObj::SetPositionInternal(this, transition) @ 0x00515330 // (success path)
|
||||||
|
└─ DetectionManager / TargetManager / MovementManager / CPartArray::HandleMovement / PositionManager::UseTime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `CPhysicsObj::UpdateObjectInternal(this, dt)` — entry
|
||||||
|
|
||||||
|
**Address**: `0x005156b0` (line 283611)
|
||||||
|
**Signature**: `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt)`
|
||||||
|
|
||||||
|
### What it does (plain English)
|
||||||
|
|
||||||
|
This is the **per-tick entry** for a physics object. It branches on `transient_state`:
|
||||||
|
|
||||||
|
- If `transient_state` has the *high bit* clear (i.e. `>= 0`, meaning the object is *passive* / dormant), it skips physics entirely and only ticks `ParticleManager` + `ScriptManager`.
|
||||||
|
- Otherwise it runs the **active path**: build a candidate next-frame, sweep collision, commit position, and tick all per-frame managers (Detection, Target, Movement, Position, parts).
|
||||||
|
|
||||||
|
### Order of operations (active path)
|
||||||
|
|
||||||
|
| # | Line | What happens |
|
||||||
|
|---|------|--------------|
|
||||||
|
| 1 | 283631 | If `transient_state[1] & 1` → `set_ethereal(this, 0, 0)` |
|
||||||
|
| 2 | 283634 | `this->jumped_this_frame = 0` |
|
||||||
|
| 3 | 283635-283643 | Local stack `Frame var_40` initialized to identity (qw=1, origin=0). Note `var_48` holds `0x796910` = `Position::vtable`. |
|
||||||
|
| 4 | 283644 | `Frame::cache(&var_40)` — recompute `m_fl2gv[0..8]` rotation matrix from quaternion |
|
||||||
|
| 5 | 283646 | **`UpdatePositionInternal(this, dt, &var_40)`** — fills `var_40` with the candidate next-frame |
|
||||||
|
| 6 | 283651-283655 | Check `part_array && CPartArray::GetNumSphere(part_array)` — does this object have a collision sphere? |
|
||||||
|
| 7a | 283655 (no spheres or zero-translation): set `m_position.frame = var_40` directly via `set_frame`, zero `cached_velocity`, no transition. |
|
||||||
|
| 7b | 283657 (has spheres AND moved): run `transition()` collision sweep |
|
||||||
|
| 7b1 | 283661-283670 | If `state[1] & 1` (Hooks?), set heading from translation direction; else if `(state & ScaledVelocity) && !is_zero(m_velocityVector)`, set heading from velocity. |
|
||||||
|
| 7b2 | 283673 | `transition(this, &m_position, &var_48, 0)` — sweeps sphere from current pos to candidate pos `var_48`. Note `var_48` here refers to the **Position** struct that has been built (the local var holds an embedded Position with vtable `0x796910` and frame `var_40`). |
|
||||||
|
| 7b3 | 283675-283696 | If `transition == nullptr` (no valid path): keep current frame via `set_frame(&var_40)`, zero `cached_velocity`. Otherwise: compute `cached_velocity = (final_pos - current_pos) / dt`, then `SetPositionInternal(this, transition)`. |
|
||||||
|
| 8 | 283733 | `DetectionManager::CheckDetection` |
|
||||||
|
| 9 | 283738 | `TargetManager::HandleTargetting` |
|
||||||
|
| 10 | 283743 | `MovementManager::UseTime` |
|
||||||
|
| 11 | 283748 | `CPartArray::HandleMovement` |
|
||||||
|
| 12 | 283753 | `PositionManager::UseTime` |
|
||||||
|
| 13 | 283755 | `goto label_5159b8` → ticks `ParticleManager` + `ScriptManager`, then returns |
|
||||||
|
|
||||||
|
### Side effects
|
||||||
|
|
||||||
|
- `this->m_position.frame` ← committed final pose (via `set_frame` or `SetPositionInternal`)
|
||||||
|
- `this->cell` ← may change (cell crossing)
|
||||||
|
- `this->cached_velocity` ← actual delta achieved this tick / dt (used elsewhere for collision profiles, etc.)
|
||||||
|
- `this->m_velocityVector` ← updated by `UpdatePhysicsInternal` (acceleration integration)
|
||||||
|
- `this->jumped_this_frame` ← cleared
|
||||||
|
- `this->contact_plane`, `this->sliding_normal`, `this->transient_state` bits 1/4/8 ← updated by `SetPositionInternal`
|
||||||
|
|
||||||
|
### Key conditions
|
||||||
|
|
||||||
|
- **Spheres-and-moved gate** at line 283655 + 283657: `transition()` is only called when the object has a sphere AND the candidate frame's origin moved (`!operator==(zero_vec, m_position.frame.m_fOrigin)`). The `x` here is a stack zero vector compared against current origin — if origin is at world-zero it skips. Wait, re-reading: actually `x` was overwritten on line 283641 to `0f`, then operator== between `&x` (stack zero) and `&this->m_position.frame.m_fOrigin`. This is checking "did `UpdatePositionInternal` move us off `m_position.frame.m_fOrigin`"? **No** — it's comparing the local stack vector `x` (zero) against `m_position`. This is effectively `is_zero(m_position)` which is almost always false. **Re-read more carefully:** at line 283641 `float x = 0f;` is just initializing 3 stack floats (Vector3 x/y/z). The compare is "is the player at world origin (0,0,0)?". That's a degenerate check. So the practical interpretation: spheres-with-collision is the gate, and the no-sphere path just commits `var_40` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `CPhysicsObj::UpdatePositionInternal(this, dt, outFrame)` — build candidate frame
|
||||||
|
|
||||||
|
**Address**: `0x00512c30` (line 280817)
|
||||||
|
**Signature**: `void __thiscall CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)`
|
||||||
|
|
||||||
|
### What it does (plain English)
|
||||||
|
|
||||||
|
Builds the **candidate next-tick frame** in `outFrame`. Three contributions, in order:
|
||||||
|
|
||||||
|
1. **Animation root motion** — `CPartArray::Update` calls `CSequence::apply_physics`, which writes `localFrame.origin = dt * sequence.velocity` and rotates `localFrame` by `dt * sequence.omega`. This is the per-cycle baked velocity from the current MotionData (e.g., RunForward emits ~4 m/s on +Y in body local).
|
||||||
|
2. **Position-manager offset** — `PositionManager::adjust_offset` lets `InterpolationManager`, `StickyManager`, `ConstraintManager` mutate the local frame.
|
||||||
|
3. **Compose with current world pose** — `Frame::combine(outFrame, &this->m_position.frame, &localFrame)` rotates the local-frame translation into world space and adds it to current world pos; multiplies quaternions.
|
||||||
|
4. **Physics integration on the composed frame** — `UpdatePhysicsInternal` Euler-integrates `m_velocityVector` and `m_omegaVector` *into the same outFrame* (this is the only place generalized velocity is consumed — see §4).
|
||||||
|
|
||||||
|
### Order of operations
|
||||||
|
|
||||||
|
| # | Line | What happens |
|
||||||
|
|---|------|--------------|
|
||||||
|
| 1 | 280820-280826 | Init local `var_40` = identity Frame, plus `var_c/var_8/var_4 = 0` (these are 3 floats that look like a `Vector3 localTranslation` slot). |
|
||||||
|
| 2 | 280827 | `Frame::cache(&var_40)` — populate rotation matrix `m_fl2gv[0..8]` (will be re-cached after `apply_physics`). |
|
||||||
|
| 3 | 280829 | If `(state[1] & 0x40) == 0` (i.e. `PartsArray::Frozen` flag NOT set): |
|
||||||
|
| 3a | 280834 | `CPartArray::Update(part_array, dt, &var_40)` — anim root motion goes into `var_40` |
|
||||||
|
| 3b | 280836-280848 | If `transient_state & 2` (Stuck/Active?), scale the captured `var_c/var_8/var_4` (looks like a velocity scratch vector) by `m_scale`; else zero them. **This branch's effect on `var_40` is unclear from the decomp — possibly dead/diagnostic code.** |
|
||||||
|
| 4 | 280853-280857 | If `position_manager != nullptr` → `PositionManager::adjust_offset(pm, &var_40, dt)` |
|
||||||
|
| 5 | 280860 | `Frame::combine(outFrame, &this->m_position.frame, &var_40)` — outFrame = world-pose ⊗ local-frame |
|
||||||
|
| 6 | 280862 | If NOT `(state[1] & 0x40)` → `UpdatePhysicsInternal(this, dt, outFrame)` (Euler step on the composed frame) |
|
||||||
|
| 7 | 280865 | `process_hooks(this)` — runs queued FP-hooks (scale fade, translucency fade, etc.) |
|
||||||
|
|
||||||
|
### Verbatim retail snippets
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Line 280827-280834
|
||||||
|
Frame::cache(&var_40);
|
||||||
|
if ((this->state[1] & 0x40) == 0) {
|
||||||
|
CPartArray* part_array = this->part_array;
|
||||||
|
if (part_array != 0)
|
||||||
|
CPartArray::Update(part_array, dt, &var_40);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Line 280853-280860 — order-critical: adjust_offset BEFORE combine
|
||||||
|
if (position_manager != 0)
|
||||||
|
PositionManager::adjust_offset(position_manager, &var_40, dt);
|
||||||
|
Frame::combine(outFrame, &this->m_position.frame, &var_40);
|
||||||
|
```
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Line 280862-280865 — physics integration AFTER compose, hooks last
|
||||||
|
if ((this->state[1] & 0x40) == 0)
|
||||||
|
CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame);
|
||||||
|
CPhysicsObj::process_hooks(this);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side effects
|
||||||
|
|
||||||
|
- `outFrame` ← fully populated candidate next-tick world frame
|
||||||
|
- `m_velocityVector`, `m_omegaVector` ← updated inside UpdatePhysicsInternal
|
||||||
|
- `m_position.frame` ← **NOT TOUCHED** here (commit happens in `set_frame`/`SetPositionInternal`)
|
||||||
|
|
||||||
|
### Critical observation: state.0x40 ("Frozen") double-gate
|
||||||
|
|
||||||
|
The `Frozen` flag both:
|
||||||
|
- Skips animation root motion (CPartArray::Update)
|
||||||
|
- Skips physics integration (UpdatePhysicsInternal)
|
||||||
|
|
||||||
|
But still runs `Frame::combine` against the (now-empty) localFrame. Net effect: a frozen object's outFrame == m_position.frame (no motion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `CSequence::apply_physics(seq, frame, dt, dt)` — animation root motion source
|
||||||
|
|
||||||
|
**Address**: `0x00524ab0` (line 300955)
|
||||||
|
**Signature**: `void __thiscall CSequence::apply_physics(const CSequence* this, Frame* arg2, double arg3, double arg4)`
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Writes the **animation's baked locomotion** into `frame`. This is the *only* code path that produces translation for "walking" remotes — `m_velocityVector` stays at zero for them and `UpdatePhysicsInternal`'s Euler-translation step is a no-op.
|
||||||
|
|
||||||
|
### Pseudocode (plain)
|
||||||
|
|
||||||
|
```c
|
||||||
|
double scale = fabs(arg3); // dt magnitude
|
||||||
|
if (arg4 < 0) scale = -scale; // sign from arg4
|
||||||
|
frame->origin.x += scale * sequence->velocity.x;
|
||||||
|
frame->origin.y += scale * sequence->velocity.y;
|
||||||
|
frame->origin.z += scale * sequence->velocity.z;
|
||||||
|
Vector3 omegaScaled = scale * sequence->omega;
|
||||||
|
Frame::rotate(frame, &omegaScaled); // local-frame quaternion rotate
|
||||||
|
```
|
||||||
|
|
||||||
|
`sequence->velocity` and `sequence->omega` are **updated as the AnimSequenceNodes advance** (CSequence::update_internal handles cursor advancement; apply_physics consumes the current cycle's baked velocity). This is the data driven via MotionData fields — e.g. RunForward MotionData has `Velocity = (0, 4.0, 0)` baked in, scaled by speed multiplier.
|
||||||
|
|
||||||
|
### Why this matters for L.3
|
||||||
|
|
||||||
|
> The spec says `m_velocityVector` stays at 0 for walking remotes — verify
|
||||||
|
|
||||||
|
**Confirmed.** For locomotion-driven remotes:
|
||||||
|
1. Server sends `UpdateMotion(RunForward, speed=N)`.
|
||||||
|
2. Sequencer enters the RunForward cycle.
|
||||||
|
3. `CSequence::apply_physics` writes `dt * (0, 4*N, 0)` to the local frame's origin every tick.
|
||||||
|
4. `Frame::combine` rotates that body-local +Y into world space using `m_position.frame.m_fl2gv` and adds to world pos.
|
||||||
|
5. `UpdatePhysicsInternal` runs but with `m_velocityVector ≈ 0` (no physics push) → its Euler-translation step adds nothing meaningful. It still does the omega-rotate step using `m_omegaVector` if non-zero.
|
||||||
|
|
||||||
|
This is why **acdream's `apply_current_movement` → `body.Velocity` → `UpdatePhysicsInternal` chain is the wrong shape for remotes.** Retail does NOT push locomotion through `m_velocityVector`. It pushes locomotion through the sequencer's baked-velocity → local frame → combine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `CPhysicsObj::UpdatePhysicsInternal(this, dt, frame)` — physics integration
|
||||||
|
|
||||||
|
**Address**: `0x00510700` (line 278460)
|
||||||
|
**Signature**: `void __thiscall CPhysicsObj::UpdatePhysicsInternal(CPhysicsObj* this, float dt, Frame* frame)`
|
||||||
|
|
||||||
|
### What it does (plain English)
|
||||||
|
|
||||||
|
Runs **Euler integration of `m_velocityVector` + `m_accelerationVector` + `m_omegaVector`** on the supplied `frame`. This is what advances physics-driven motion (gravity falls, jump arcs, knockbacks). Walking locomotion does NOT flow through here.
|
||||||
|
|
||||||
|
### Order of operations
|
||||||
|
|
||||||
|
| # | Line | What happens |
|
||||||
|
|---|------|--------------|
|
||||||
|
| 1 | 278463-278467 | Compute `var_28 = velocity.x² + velocity.y² + velocity.z²` |
|
||||||
|
| 2 | 278473 | If `var_28 > 0` (i.e. velocity is non-zero, the FP comparison machinery checks this): |
|
||||||
|
| 2a | 278475-278487 | If `var_28 > 50²` (terminal speed cap), normalize velocity and scale to magnitude 50. |
|
||||||
|
| 2b | 278490 | `calc_friction(this, dt, var_28)` — friction adjusts velocity |
|
||||||
|
| 2c | 278491-278502 | If `var_28 < 0.0625 + 0.0002` (≈ `0.25²` threshold), zero velocity (deadband). |
|
||||||
|
| 2d | 278505-278511 | **Euler translation**: `frame.origin += dt * velocity + 0.5 * dt² * acceleration` (per-axis) |
|
||||||
|
| 3 | 278513-278518 | `else if (movement_manager == 0 && transient_state & 2)` — clear `0x80` from transient_state |
|
||||||
|
| 4 | 278521-278523 | **Velocity step**: `velocity += dt * acceleration` |
|
||||||
|
| 5 | 278524-278528 | **Omega rotation**: build `var_18..` = `dt * omegaVector`, then `Frame::grotate(frame, &dtOmega)` (global-frame rotate by axis-angle, sin/cos applied to half-angle, quat multiply) |
|
||||||
|
|
||||||
|
### Verbatim retail snippets
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Line 278505-278511 — translation update (per axis)
|
||||||
|
float var_20 = (acceleration.y * 0.5f) * dt * dt;
|
||||||
|
... // similar for x, z
|
||||||
|
frame->origin.x += (dt * velocity.x) + (acceleration.x * 0.5f * dt * dt);
|
||||||
|
frame->origin.y += (dt * velocity.y) + var_20;
|
||||||
|
frame->origin.z += (dt * velocity.z) + (acceleration.z * 0.5f * dt * dt);
|
||||||
|
|
||||||
|
// Line 278521-278523 — velocity step (always runs, even when velocity was 0)
|
||||||
|
velocity.x += dt * acceleration.x;
|
||||||
|
velocity.y += dt * acceleration.y;
|
||||||
|
velocity.z += dt * acceleration.z;
|
||||||
|
|
||||||
|
// Line 278524-278528 — angular rotation (always runs)
|
||||||
|
Vector3 dtOmega = dt * omegaVector;
|
||||||
|
Frame::grotate(frame, &dtOmega);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical observations
|
||||||
|
|
||||||
|
1. **Translation step is gated** — only runs when `velocity² > 0`. For a walking remote with `m_velocityVector == 0`, this entire block is skipped. The animation's baked velocity (already in `frame.origin` from `apply_physics` + `combine`) is preserved.
|
||||||
|
2. **Velocity step always runs** — even when initial velocity was zero, gravity (`acceleration.z = PhysicsGlobals::gravity` when `state[1] & 4`, i.e. `Gravity`) accumulates `velocity.z -= 9.8 * dt` per tick.
|
||||||
|
3. **Omega step always runs** — uses `m_omegaVector` (NOT sequencer omega). This is for knockback spin / scripted rotation; sequencer omega is consumed inside `apply_physics`.
|
||||||
|
4. **Zero-velocity deadband** at `|v| < 0.25` applies AFTER friction. Friction-decayed velocity below 0.25 m/s snaps to 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `CPhysicsObj::transition(this, fromPos, toPos, flags)` — collision sweep
|
||||||
|
|
||||||
|
**Address**: `0x00512dc0` (line 280904)
|
||||||
|
**Signature**: `const CTransition* __thiscall CPhysicsObj::transition(CPhysicsObj* this, const Position* fromPos, const Position* toPos, int32_t flags)`
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Allocates a `CTransition` (collision-sweep workspace), initializes it with the object's spheres + path (fromPos → toPos in cell), tunes "frames stationary fall" from `transient_state`, calls `find_valid_position`, cleans up the transition workspace, returns the resolved `CTransition*` if successful or `nullptr` if no valid position.
|
||||||
|
|
||||||
|
### Order of operations
|
||||||
|
|
||||||
|
| # | Line | What happens |
|
||||||
|
|---|------|--------------|
|
||||||
|
| 1 | 280907 | `CTransition* result = CTransition::makeTransition()` |
|
||||||
|
| 2 | 280911 | `init_object(result, this, get_object_info(this, result, flags))` — copies state flags into `CTransition::object_info` |
|
||||||
|
| 3 | 280915-280936 | If has spheres → `init_sphere(result, count, spheres, scale)`; else → `init_sphere(result, 1, &dummy_sphere, 1.0)` |
|
||||||
|
| 4 | 280939 | `init_path(result, this->cell, fromPos, toPos)` — sets up SpherePath |
|
||||||
|
| 5 | 280940-280947 | Set `frames_stationary_fall` from transient_state high bits: `0x40→3`, `0x20→2`, `0x10→1` |
|
||||||
|
| 6 | 280949 | `int valid = CTransition::find_valid_position(result)` — runs full sweep |
|
||||||
|
| 7 | 280950 | `cleanupTransition(result)` — releases sphere copies / scratch state |
|
||||||
|
| 8 | 280952-280953 | Return `result` if `valid != 0`, else `0` |
|
||||||
|
|
||||||
|
### Side effects on caller
|
||||||
|
|
||||||
|
The returned `CTransition*` carries:
|
||||||
|
- `result->sphere_path.curr_pos` — final resolved Position (cell + frame)
|
||||||
|
- `result->sphere_path.curr_cell` — final cell (may differ from start, may be null = lost)
|
||||||
|
- `result->collision_info.contact_plane` — current ground plane
|
||||||
|
- `result->collision_info.contact_plane_valid`, `contact_plane_is_water`, `contact_plane_cell_id`
|
||||||
|
- `result->collision_info.sliding_normal`, `sliding_normal_valid`
|
||||||
|
- `result->cell_array` — list of cells visited during the sweep
|
||||||
|
|
||||||
|
These all flow into `SetPositionInternal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `CPhysicsObj::SetPositionInternal(this, transition)` — commit
|
||||||
|
|
||||||
|
**Address**: `0x00515330` (line 283399)
|
||||||
|
**Signature**: `int32_t __thiscall CPhysicsObj::SetPositionInternal(CPhysicsObj* this, const CTransition* arg2)`
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Commits the transition's resolved position back into the physics object: copies the final frame, updates cell membership, updates contact plane / sliding normal / transient_state walkable bits, recalculates acceleration based on the new ground state, and fires `handle_all_collisions` for any contact reports.
|
||||||
|
|
||||||
|
### Order of operations
|
||||||
|
|
||||||
|
| # | Line | What happens |
|
||||||
|
|---|------|--------------|
|
||||||
|
| 1 | 283402-283403 | Capture `transient_state`, `curr_cell` |
|
||||||
|
| 2 | 283405-283410 | If `curr_cell == nullptr` (lost): `prepare_to_leave_visibility` + `store_position(curr_pos)` + `GotoLostCell` + clear `transient_state & 0x80` |
|
||||||
|
| 3 | 283414-283456 | If same cell: update `m_position.objcell_id` and child cell ids; else `change_cell(curr_cell)` |
|
||||||
|
| 4 | 283458 | `set_frame(this, &curr_pos.frame)` — commit final frame to `m_position.frame` and propagate to part array |
|
||||||
|
| 5 | 283459-283464 | Copy `contact_plane` (N + d) and `contact_plane_cell_id` |
|
||||||
|
| 6 | 283465-283473 | If `contact_plane_valid` → `transient_state \|= 1 (Contact)`; else `&= ~1` |
|
||||||
|
| 7 | 283474 | `calc_acceleration(this)` — reset to gravity or zero based on Contact + Gravity flags |
|
||||||
|
| 8 | 283475-283483 | If `contact_plane_is_water` → `transient_state \|= 8`; else `&= ~8` |
|
||||||
|
| 9 | 283485-283510 | If now Contact: branch on `contact_plane.N.z >= PhysicsGlobals::floor_z` → `set_on_walkable(true)` else `(false)`. Else (not on contact): clear `transient_state & 2` (Active) — call `MovementManager::LeaveGround` if was active — then `calc_acceleration` again. |
|
||||||
|
| 10 | 283512-283523 | Copy `sliding_normal` + flag bit 4 (`SlidingNormal`) |
|
||||||
|
| 11 | 283524 | `handle_all_collisions(this, &collision_info, oldContact, oldActive)` — fires Weenie collision callbacks |
|
||||||
|
| 12 | 283526-283538 | If has cell + state has `0x10000` (HasPhysicsBSP) → `calc_cross_cells`; else → `remove_shadows_from_cells` + `add_shadows_to_cells(&cell_array)` |
|
||||||
|
|
||||||
|
### `floor_z` constant
|
||||||
|
|
||||||
|
`PhysicsGlobals::floor_z` is the cosine of the steepest walkable angle. Standard retail value is around `0.66417414` (≈ cos 49°). The check at line 283501-283506 is "is this slope steep enough that we are NOT on walkable ground?" — if `contact_plane.N.z < floor_z`, the slope is too steep, set `OnWalkable = false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `Frame::combine(out, a, b)` — frame composition
|
||||||
|
|
||||||
|
**Address**: `0x005122e0` (line 280355)
|
||||||
|
**Signature**: `void Frame::combine(Frame* out, const Frame* a, const Frame* b)`
|
||||||
|
|
||||||
|
### What it does (plain English)
|
||||||
|
|
||||||
|
Composes two frames: **out = a then b** in the sense of "apply a's transform, then add b in a's local space."
|
||||||
|
|
||||||
|
```c
|
||||||
|
out.origin = a.origin + a.m_fl2gv * b.origin // rotate b's translation by a's matrix, add to a's origin
|
||||||
|
out.quaternion = a.quaternion * b.quaternion // standard quat multiply
|
||||||
|
```
|
||||||
|
|
||||||
|
The matrix `m_fl2gv` is the local-to-global rotation matrix, populated by `Frame::cache` from the quaternion. `m_fl2gv[0..8]` is column-major (with `[0]/[3]/[6]` = row 0, etc., based on the index pattern at line 280358).
|
||||||
|
|
||||||
|
### Side effects
|
||||||
|
|
||||||
|
- `out.m_fOrigin`, `out.qw/qx/qy/qz` ← computed
|
||||||
|
- `out.m_fl2gv` ← repopulated (via `set_rotate` which calls `cache`)
|
||||||
|
|
||||||
|
### Important: `combine` reads `a.m_fl2gv` directly
|
||||||
|
|
||||||
|
If `a.m_fl2gv` is stale (quaternion changed without `Frame::cache`), `combine` produces garbage translation. This is why `Frame::cache(&var_40)` is called explicitly at line 280827 in `UpdatePositionInternal` before any operation that reads the matrix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `Frame::cache(this)` — quat → rotation matrix
|
||||||
|
|
||||||
|
**Address**: `0x00534df0` (line 319353)
|
||||||
|
**Signature**: `void __fastcall Frame::cache(Frame* this)`
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Populates `m_fl2gv[0..8]` (a 3×3 rotation matrix) from `qw/qx/qy/qz`. Standard quat-to-matrix formula:
|
||||||
|
|
||||||
|
```
|
||||||
|
m_fl2gv[0] = 1 - 2*(qy² + qz²) m_fl2gv[3] = 2*(qx*qy - qw*qz) m_fl2gv[6] = 2*(qx*qz + qw*qy)
|
||||||
|
m_fl2gv[1] = 2*(qx*qy + qw*qz) m_fl2gv[4] = 1 - 2*(qx² + qz²) m_fl2gv[7] = 2*(qy*qz - qw*qx)
|
||||||
|
m_fl2gv[2] = 2*(qx*qz - qw*qy) m_fl2gv[5] = 2*(qy*qz + qw*qx) m_fl2gv[8] = 1 - 2*(qx² + qy²)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why it matters
|
||||||
|
|
||||||
|
Every time the quaternion changes (e.g., omega-rotate or `set_rotate`), the cached matrix MUST be refreshed before another `combine`/`localtoglobal` reads it. Retail's `Frame::set_rotate` calls `Frame::cache` internally; manual quaternion edits do not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `CPhysicsObj::set_frame(this, frame)` — commit frame to object
|
||||||
|
|
||||||
|
**Address**: `0x00514090` (line 282139)
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Validates the frame, copies it to `this->m_position.frame`, propagates to `part_array` (unless `state[1] & 0x10` = `Particle` flag), and updates children.
|
||||||
|
|
||||||
|
### Order
|
||||||
|
|
||||||
|
1. `Frame::operator=(local_var_40, arg2)` — copy
|
||||||
|
2. If `IsValid(local) == 0 && IsValidExceptForHeading(local) != 0` → reset rotation to identity (origin preserved). This protects against NaN quats from numerical drift.
|
||||||
|
3. `Frame::operator=(this->m_position.frame, local)` — final assign
|
||||||
|
4. If NOT `(state[1] & 0x10)` → `CPartArray::SetFrame(part_array, &this->m_position.frame)`
|
||||||
|
5. `UpdateChildrenInternal(this)` — recurse to attached children
|
||||||
|
|
||||||
|
### Why a SEPARATE local copy?
|
||||||
|
|
||||||
|
So the validity-fix can run before committing. If we wrote directly into `m_position.frame` and then noticed it's invalid, we'd have already corrupted the live state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `PositionManager::adjust_offset(pm, frame, dt)` — three-manager pass
|
||||||
|
|
||||||
|
**Address**: `0x00555190` (line 352090)
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Calls `adjust_offset(frame, dt)` on each of three optional managers in order:
|
||||||
|
1. `InterpolationManager` — smooths network-bursty position deltas
|
||||||
|
2. `StickyManager` — locks position to a target object (e.g., aetheria attaches)
|
||||||
|
3. `ConstraintManager` — clamps within a region
|
||||||
|
|
||||||
|
Each is null-checked. For most remotes, all three are null, so this is a no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `CPartArray::Update(arr, dt, frame)` & `CSequence::update`
|
||||||
|
|
||||||
|
**Addresses**: `0x00517db0` (CPartArray::Update, line 285883), `0x00525b80` (CSequence::update, line 302402)
|
||||||
|
|
||||||
|
### What they do
|
||||||
|
|
||||||
|
`CPartArray::Update` is a thin wrapper that calls `CSequence::update(&this->sequence, dt, frame)`.
|
||||||
|
|
||||||
|
`CSequence::update`:
|
||||||
|
- If `anim_list.head_ != 0`: calls `update_internal` (advance cursor through anim chain) AND `apricot` (drop completed anims). **Apricot does NOT call apply_physics.** The local frame writes happen INSIDE `update_internal` via its own `apply_physics` calls.
|
||||||
|
- Else (no anims queued): calls `apply_physics(this, frame, dt, dt)` directly with the current `sequence.velocity` / `sequence.omega`.
|
||||||
|
|
||||||
|
This means: **even with no animation queued, the sequencer keeps emitting baked velocity** (the cycle's resting motion). This is how a stationary idle character could still tick velocity.
|
||||||
|
|
||||||
|
### What CPartArray::UpdateParts is NOT
|
||||||
|
|
||||||
|
`CPartArray::UpdateParts(arr, frame)` at `0x005190f0` (line 287281) is a separate function that COMPOSES per-part frames from the current `AnimFrame.frame` array against the parent `frame` argument. It is called from a different path — `CPhysicsObj::UpdateChild` and `CPartArray::SetFrame` — not from the per-tick UpdateObjectInternal pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Compact pseudocode of the whole pipeline
|
||||||
|
|
||||||
|
```c
|
||||||
|
void CPhysicsObj::UpdateObjectInternal(float dt) {
|
||||||
|
if (transient_state high bit set /* dormant */) {
|
||||||
|
ParticleManager::UpdateParticles();
|
||||||
|
ScriptManager::UpdateScripts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell == 0) return;
|
||||||
|
|
||||||
|
if (transient_state[1] & 1) set_ethereal(0, 0);
|
||||||
|
jumped_this_frame = 0;
|
||||||
|
|
||||||
|
Frame candidate = identity; Frame::cache(&candidate);
|
||||||
|
|
||||||
|
UpdatePositionInternal(dt, &candidate);
|
||||||
|
// After this: candidate = m_position.frame ⊗ animRoot ⊗ posMgrAdjust ⊗ physicsEuler
|
||||||
|
|
||||||
|
if (has_collision_sphere) {
|
||||||
|
if (state[1] & 1)
|
||||||
|
Frame::set_vector_heading(&candidate, dir_of_translation);
|
||||||
|
else if ((state & ScaledVel) && !is_zero(m_velocityVector))
|
||||||
|
Frame::set_heading(&candidate, get_heading(m_velocityVector));
|
||||||
|
|
||||||
|
Position toPos = { vtable=Position, frame=candidate, cell=current };
|
||||||
|
CTransition* t = transition(&m_position, &toPos, 0);
|
||||||
|
if (t == null) {
|
||||||
|
set_frame(&candidate); // fall back: commit unswept candidate
|
||||||
|
cached_velocity = (0,0,0);
|
||||||
|
} else {
|
||||||
|
cached_velocity = (t.curr_pos - m_position) / dt;
|
||||||
|
SetPositionInternal(t); // commits curr_pos, updates contact plane, walkable, etc.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No sphere — commit candidate directly.
|
||||||
|
if (movement_manager == null && (transient_state & 2))
|
||||||
|
transient_state &= ~0x80;
|
||||||
|
set_frame(&candidate);
|
||||||
|
cached_velocity = (0,0,0);
|
||||||
|
}
|
||||||
|
|
||||||
|
DetectionManager::CheckDetection();
|
||||||
|
TargetManager::HandleTargetting();
|
||||||
|
MovementManager::UseTime();
|
||||||
|
CPartArray::HandleMovement();
|
||||||
|
PositionManager::UseTime();
|
||||||
|
ParticleManager::UpdateParticles();
|
||||||
|
ScriptManager::UpdateScripts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CPhysicsObj::UpdatePositionInternal(float dt, Frame* outFrame) {
|
||||||
|
Frame local = identity; Frame::cache(&local);
|
||||||
|
|
||||||
|
if (!(state[1] & 0x40 /* Frozen */)) {
|
||||||
|
if (part_array)
|
||||||
|
CPartArray::Update(part_array, dt, &local);
|
||||||
|
// → CSequence::apply_physics writes:
|
||||||
|
// local.origin += dt * sequence.velocity
|
||||||
|
// Frame::rotate(&local, dt * sequence.omega)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position_manager)
|
||||||
|
PositionManager::adjust_offset(position_manager, &local, dt);
|
||||||
|
|
||||||
|
Frame::combine(outFrame, &m_position.frame, &local);
|
||||||
|
// outFrame.origin = m_position.origin + m_position.m_fl2gv * local.origin
|
||||||
|
// outFrame.quat = m_position.quat * local.quat
|
||||||
|
|
||||||
|
if (!(state[1] & 0x40))
|
||||||
|
UpdatePhysicsInternal(dt, outFrame);
|
||||||
|
|
||||||
|
process_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CPhysicsObj::UpdatePhysicsInternal(float dt, Frame* frame) {
|
||||||
|
float v2 = velocity.x² + velocity.y² + velocity.z²;
|
||||||
|
if (v2 > 0) {
|
||||||
|
if (v2 > 50²) { // terminal speed cap
|
||||||
|
normalize(velocity); velocity *= 50;
|
||||||
|
}
|
||||||
|
calc_friction(dt, v2);
|
||||||
|
if (v2 < 0.25² + ε) // deadband
|
||||||
|
velocity = 0;
|
||||||
|
// Euler position step
|
||||||
|
frame.origin += dt * velocity + 0.5 * dt² * acceleration;
|
||||||
|
} else if (movement_manager == null && (transient_state & 2)) {
|
||||||
|
transient_state &= ~0x80;
|
||||||
|
}
|
||||||
|
|
||||||
|
velocity += dt * acceleration; // ALWAYS runs (gravity accumulation)
|
||||||
|
Vector3 dtOmega = dt * omegaVector;
|
||||||
|
Frame::grotate(frame, &dtOmega); // ALWAYS runs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Cross-reference: acdream `GameWindow.cs` ~6428+
|
||||||
|
|
||||||
|
### What acdream does today (legacy / non-env-var path)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Lines 6488-6650 (annotated):
|
||||||
|
1. Force OnWalkable + Contact + Active.
|
||||||
|
2. apply_current_movement → body.Velocity = sequencer.GetStateVelocity (rotated by Orientation).
|
||||||
|
3. body.Omega = 0.
|
||||||
|
4. If ObservedOmega non-zero: integrate Orientation manually (from server-derived rate).
|
||||||
|
5. body.calc_acceleration().
|
||||||
|
6. body.UpdatePhysicsInternal(dt). ← Euler-step on body.Position
|
||||||
|
7. ResolveWithTransition(prePos, postPos) ← BSP/terrain sweep
|
||||||
|
8. Body.Position = resolveResult.Position.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mismatches with retail
|
||||||
|
|
||||||
|
| # | Retail | acdream | Impact |
|
||||||
|
|---|--------|---------|--------|
|
||||||
|
| 1 | Locomotion via **sequencer baked velocity** consumed by `CSequence::apply_physics` (writes to local frame) | Locomotion via **`body.Velocity` = `apply_current_movement` output** consumed by Euler step in `UpdatePhysicsInternal` | acdream double-counts: sequencer gets ticked AND body.Velocity is set. If both contribute, motion overshoots. If only the body path runs (sequencer Advance returns frames but doesn't drive translation), animation plays without baked-velocity contribution. |
|
||||||
|
| 2 | Order: anim-root → posMgr-offset → **combine with world pose** → physics Euler **on composed frame** → collision sweep on composed frame | Order: apply_current_movement (sets velocity) → manual omega-integrate orientation → physics Euler on body (independent of frame composition) → collision sweep | Acceleration term `0.5 * dt² * a` is applied to body.Position, but in retail it's applied to the *post-combine* frame. For grounded remotes (`acceleration ≈ 0`) the difference is small; for jumping/falling remotes the half-accel term is in the wrong reference frame. |
|
||||||
|
| 3 | `m_velocityVector` for walking remotes ≈ **0** | `body.Velocity` for walking remotes = world-rotated locomotion vector (e.g. ~4 m/s) | Direct port question: should `body.Velocity` be zero for grounded locomotion, with motion coming entirely from sequencer baked velocity? **Yes per retail.** This is the L.3 fix. |
|
||||||
|
| 4 | `Frame::grotate` always runs in UpdatePhysicsInternal using `m_omegaVector` | `body.Omega = 0` to skip integration; manual quat rotate from `ObservedOmega` outside body | acdream's manual path is functionally equivalent provided `ObservedOmega` is the right rate; retail's path uses the body's own omega field. Replacing acdream's manual integration with `body.Omega = derived_rate` and letting `UpdatePhysicsInternal` do `grotate` would be more retail-faithful. |
|
||||||
|
| 5 | Velocity-deadband at `\|v\| < 0.25` and terminal-cap at `\|v\| < 50` are **inside** UpdatePhysicsInternal | Not implemented (or implemented at higher level) | Minor; only matters for friction-decayed bodies and ragdoll-speed clamps. |
|
||||||
|
| 6 | Translation step is **GATED on velocity² > 0** | acdream Euler-integrates unconditionally | Means a stationary remote with non-zero acceleration (gravity) gets a Z-step in acdream that retail would skip until velocity itself becomes non-zero (gravity makes velocity.z negative on the **next** velocity-update step). 1-tick lag in retail; immediate in acdream. |
|
||||||
|
| 7 | `set_frame` validates the frame and resets-to-identity if quat is invalid | acdream commits without explicit IsValid check | NaN-quat protection missing; can't be triggered in normal play but a single packet-decode bug could stick a remote with a corrupted orientation forever. |
|
||||||
|
| 8 | Anim-frame combine + posMgr-offset happen **before** physics integration | acdream skips the local-frame compose entirely; physics on body.Position is independent | This is THE structural mismatch driving L.3. |
|
||||||
|
|
||||||
|
### Why the L.2 PositionManager attempt regressed (per CLAUDE.md note)
|
||||||
|
|
||||||
|
The note says:
|
||||||
|
> the env-var path drops the per-tick collision sweep (ResolveWithTransition) that the default path retains, causing a visible "staircase" pattern when remotes run up/down slopes
|
||||||
|
|
||||||
|
Retail does NOT drop ResolveWithTransition — `transition()` runs every tick after UpdatePositionInternal builds the candidate. The bug was integrating PositionManager but skipping `transition()`/`SetPositionInternal` in the env-var branch. Retail's structure makes both mandatory.
|
||||||
|
|
||||||
|
### The shape L.3 should produce
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
void TickRemote(rm, dt) {
|
||||||
|
// 1. Build candidate frame in local space.
|
||||||
|
Frame local = Frame.Identity;
|
||||||
|
sequencer.ApplyPhysicsToFrame(ref local, dt); // ← retail apply_physics: writes baked velocity & omega
|
||||||
|
positionMgr.AdjustOffset(ref local, dt); // null for normal remotes; placeholder for L.4
|
||||||
|
|
||||||
|
// 2. Compose with world pose.
|
||||||
|
Frame candidate = Frame.Combine(rm.Body.WorldFrame, local);
|
||||||
|
|
||||||
|
// 3. Physics integration on the composed frame (gravity, knockback, terminal cap).
|
||||||
|
rm.Body.UpdatePhysicsInternal(dt, ref candidate); // mutates m_velocity, candidate.origin (only if v² > 0), candidate.quat (always)
|
||||||
|
|
||||||
|
// 4. Collision sweep candidate → resolved.
|
||||||
|
if (rm.Body.HasCollisionSphere && candidate.Origin != rm.Body.Position) {
|
||||||
|
var transition = _physicsEngine.Transition(rm.Body.WorldPos, candidate, rm.CellId);
|
||||||
|
if (transition == null) {
|
||||||
|
rm.Body.SetFrame(candidate); // fall back to unswept candidate
|
||||||
|
rm.CachedVelocity = Vector3.Zero;
|
||||||
|
} else {
|
||||||
|
rm.CachedVelocity = (transition.CurrPos - rm.Body.WorldPos) / dt;
|
||||||
|
rm.Body.SetPositionFromTransition(transition); // commits frame, contact_plane, walkable, sliding_normal
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rm.Body.SetFrame(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Per-tick managers.
|
||||||
|
rm.DetectionManager?.Check();
|
||||||
|
rm.TargetManager?.Tick();
|
||||||
|
rm.MovementManager?.UseTime();
|
||||||
|
rm.PartArray.HandleMovement();
|
||||||
|
rm.PositionManager?.UseTime();
|
||||||
|
rm.ParticleManager?.Update();
|
||||||
|
rm.ScriptManager?.Update();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key insight: **for walking remotes, `m_velocityVector` stays zero, locomotion enters via the sequencer's `apply_physics` writing into `local`, and `Frame::combine` rotates that body-local vector into world space using the current orientation.** This matches CSequence::apply_physics's documented behavior and matches what we observe — animation cycles produce locomotion *because the cycle's MotionData has baked velocity*, not because the network hands us a velocity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Open questions for L.3 implementation
|
||||||
|
|
||||||
|
1. **Where does omega come from for remotes?** Retail's `m_omegaVector` is set by `CMotionInterp::DoInterpretedMotion` via the cycle's MotionData (TurnLeft/TurnRight bake omega). Our local player path already has this; verify the remote path reads from the same source instead of `ObservedOmega` (which is server-derived and lossy).
|
||||||
|
|
||||||
|
2. **Does CSequence::apply_physics always run, even on idle?** Re-reading line 302413-302419: if `anim_list.head_ == 0` AND `arg3 != 0`, apply_physics runs with the *current* `sequence.velocity` / `sequence.omega`. Idle cycles have zero baked velocity → apply_physics is a no-op. So idle remotes get no spurious motion.
|
||||||
|
|
||||||
|
3. **Frame.IsValid check in SetFrame**: minor robustness item. Worth adding when porting set_frame.
|
||||||
|
|
||||||
|
4. **Friction**: `calc_friction` gets called inside UpdatePhysicsInternal whenever velocity² > 0. We don't currently port friction. For grounded velocity-driven motion (knockbacks) it matters; for animation-driven motion (m_velocityVector ≈ 0) it doesn't.
|
||||||
|
|
||||||
|
5. **PhysicsBody.update_object's MinQuantum gate**: GameWindow.cs ~6633 has a long comment explaining why it bypasses `update_object` and calls `UpdatePhysicsInternal` directly. Retail's `UpdateObjectInternal` is the entry; our top-level entry mirrors that. The 30 Hz quantum gate is in retail (see `MinQuantum=1/30s`); retail addresses it by NOT subticking — `UpdateObjectInternal` is called at the engine tick rate (~30 Hz for retail's render loop, see CLAUDE.md retail debugger notes). Our 60 Hz tick may want a sub-step gate or to halve `dt` per accumulated frame to match retail integration cadence.
|
||||||
1206
docs/research/2026-05-04-l3-port/02-um-handling.md
Normal file
1206
docs/research/2026-05-04-l3-port/02-um-handling.md
Normal file
File diff suppressed because it is too large
Load diff
585
docs/research/2026-05-04-l3-port/03-up-routing.md
Normal file
585
docs/research/2026-05-04-l3-port/03-up-routing.md
Normal file
|
|
@ -0,0 +1,585 @@
|
||||||
|
# UpdatePosition (0xF748) Routing Pipeline — Retail Pseudo-C Extract
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C)
|
||||||
|
**Cross-reference:** `docs/research/named-retail/acclient.h` (verbatim retail headers)
|
||||||
|
|
||||||
|
This document extracts the complete UpdatePosition routing tree from the
|
||||||
|
retail acclient — from the inbound F748 dispatcher down to the body's
|
||||||
|
position. Every branch is cited verbatim with the originating retail
|
||||||
|
line number. Cross-checked against acdream's
|
||||||
|
`OnLivePositionUpdated` in `src/AcDream.App/Rendering/GameWindow.cs:3425`
|
||||||
|
and `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Pipeline overview (Mermaid)
|
||||||
|
|
||||||
|
```
|
||||||
|
Network blob (NetBlob, opcode 0xF748)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ACSmartBox::DispatchSmartBoxEvent (357117) ─── case 0xF748 ───┐
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
SmartBox::UnpackPositionEvent (93055) ◄── reads PositionPack ─┘
|
||||||
|
│
|
||||||
|
│ PositionPack = { Position pos, Vec3 velocity, uint32 placement_id,
|
||||||
|
│ uint32 has_contact, uint16 instance_timestamp,
|
||||||
|
│ uint16 position_timestamp,
|
||||||
|
│ uint16 teleport_timestamp,
|
||||||
|
│ uint16 force_position_timestamp }
|
||||||
|
│
|
||||||
|
▼ if instance_timestamp == obj.update_times[INSTANCE_TS]
|
||||||
|
SmartBox::HandleReceivedPosition (92896)
|
||||||
|
args: arg2 = target CPhysicsObj
|
||||||
|
arg3 = Position* (objcell_id + Frame{origin, rotation})
|
||||||
|
arg4 = placement_id (uint32)
|
||||||
|
arg5 = has_contact (int32; 0 = airborne, !0 = grounded)
|
||||||
|
arg6 = velocity Vec3*
|
||||||
|
arg7 = position_timestamp (uint16) ← POSITION_TS
|
||||||
|
arg8 = teleport_timestamp (uint16) ← TELEPORT_TS / move-seq
|
||||||
|
arg9 = force_position_timestamp (uint16) ← FORCE_POSITION_TS
|
||||||
|
│
|
||||||
|
├─[ if arg2 == player AND newer_event(player, FORCE_POSITION_TS, arg9) ]
|
||||||
|
│ ► SmartBox::BlipPlayer (92928) — server forced our pos
|
||||||
|
│ ► return
|
||||||
|
│
|
||||||
|
├─[ if newer_event(arg2, POSITION_TS, arg7) == 0 ]
|
||||||
|
│ ► return — stale position update
|
||||||
|
│
|
||||||
|
├─[ if arg2 != player ]
|
||||||
|
│ ► CPhysicsObj::MoveOrTeleport(arg2, &recvPos, arg8, arg5, arg6) (92997)
|
||||||
|
│ │
|
||||||
|
│ └── (see Section 3 below)
|
||||||
|
│ ► if MoveOrTeleport returned 1: CPhysicsObj::ConstrainTo (93007)
|
||||||
|
│ ► return
|
||||||
|
│
|
||||||
|
└─[ if arg2 == player ]
|
||||||
|
├─[ if newer_event(player, TELEPORT_TS, arg8) ]
|
||||||
|
│ ► SmartBox::TeleportPlayer (93015)
|
||||||
|
│ ► CPhysicsObj::ConstrainTo (93024)
|
||||||
|
│ ► CPhysicsObj::set_velocity(player, 0) (93029)
|
||||||
|
│ ► return
|
||||||
|
│
|
||||||
|
└─[ else ]
|
||||||
|
► CPhysicsObj::ConstrainTo(player, …) (93041)
|
||||||
|
► if cmdinterp.UsePositionFromServer && arg5 != 0:
|
||||||
|
CPhysicsObj::InterpolateTo(arg2, &recvPos, …) (93049)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key insight: the `arg2 != player` branch (remotes) is the one that fires
|
||||||
|
into `MoveOrTeleport`. That's the only place the routing decision tree
|
||||||
|
between hard-snap, slide-snap, and InterpolateTo lives. The
|
||||||
|
`arg2 == player` branches (server-corrected local) do their own thing
|
||||||
|
(BlipPlayer / TeleportPlayer / ConstrainTo + InterpolateTo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The packet entry — ACSmartBox::DispatchSmartBoxEvent
|
||||||
|
|
||||||
|
**File line:** 357117 — `0x005595d0`
|
||||||
|
|
||||||
|
Verbatim retail (excerpt of `case 0xF748`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
357181 case 0xf748:
|
||||||
|
357182 {
|
||||||
|
357183 ebp_1 = *(uint32_t*)(buf_ + 4); // object guid
|
||||||
|
357184 arg2 = &buf_[8]; // payload start
|
||||||
|
357185 result = SmartBox::UnpackPositionEvent(this, ebp_1, &arg2, bufSize_);
|
||||||
|
357187 if (result != NETBLOB_QUEUED)
|
||||||
|
357188 return result;
|
||||||
|
357190 SmartBox::QueueBlobForObject(this, ebp_1, ebx); // not yet known
|
||||||
|
357191 return result;
|
||||||
|
357192 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- The opcode dispatch is a simple `switch (ecx)`; F748 is *only* the
|
||||||
|
generic UpdatePosition. F619 = "MoveObject" (player's own moves)
|
||||||
|
routes through the **same** `UnpackPositionEvent`, then falls through
|
||||||
|
into `SetObjectMovement` if the unpack succeeded — see lines 357138–
|
||||||
|
357158. F74C is the server-controlled-move variant that drops in via
|
||||||
|
a different sequence-stamp check. F748 is the pure-position event.
|
||||||
|
- `QueueBlobForObject` parks the blob if the target object isn't yet
|
||||||
|
known to the client, so the position is replayed after CreateObject.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UnpackPositionEvent — gating on `instance_timestamp`
|
||||||
|
|
||||||
|
**File line:** 93055 — `0x004542c0`
|
||||||
|
|
||||||
|
```c
|
||||||
|
93055 enum NetBlobProcessedStatus
|
||||||
|
93055 SmartBox::UnpackPositionEvent(this, arg2 /*guid*/, arg3 /*payload**/, arg4 /*size*/)
|
||||||
|
93055 {
|
||||||
|
93059 PositionPack::PositionPack(&var_68);
|
||||||
|
93060 PositionPack::UnPack(&var_68, arg3, arg4);
|
||||||
|
93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2);
|
||||||
|
93063 if (eax_1 != 0)
|
||||||
|
93063 {
|
||||||
|
93065 ecx_4 = eax_1->update_times[8]; // INSTANCE_TS
|
||||||
|
93081 if (newer-by-rolling-uint16(esi /*var_64*/, ecx_4) == 0) // EQUAL
|
||||||
|
93081 {
|
||||||
|
93083 if (ecx_4 != esi)
|
||||||
|
93084 return 2; // NETBLOB_LOGGED_OUT
|
||||||
|
93092 SmartBox::HandleReceivedPosition(
|
||||||
|
93092 this, eax_1, &recvPos, placement_id, has_contact,
|
||||||
|
93092 &velocity, position_ts, teleport_ts, force_position_ts);
|
||||||
|
93093 return 1; // NETBLOB_PROCESSED_OK
|
||||||
|
93081 }
|
||||||
|
93095 }
|
||||||
|
93097 return 4; // NETBLOB_QUEUED
|
||||||
|
93055 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- `update_times[8]` is `INSTANCE_TS` (`enum PhysicsTimeStamp::INSTANCE_TS = 0x8`,
|
||||||
|
`acclient.h:6094`). The full array is `unsigned __int16 update_times[9]`
|
||||||
|
(`acclient.h:30738`).
|
||||||
|
- The instance check is "must equal the recorded instance stamp"
|
||||||
|
(after rolling-uint16 normalization). Mismatch returns NETBLOB_LOGGED_OUT;
|
||||||
|
unknown object returns NETBLOB_QUEUED.
|
||||||
|
|
||||||
|
### PositionPack contents (line 284585)
|
||||||
|
|
||||||
|
```c
|
||||||
|
284589 ebx = first byte of payload // flags byte
|
||||||
|
284591 Position::UnPackOrigin(&this->position, …) // 12 bytes float3 + uint cell
|
||||||
|
284593 if (ebx & 8) == 0: read qw (else 0)
|
||||||
|
284601 if (ebx & 0x10) == 0: read qx (else 0)
|
||||||
|
284609 if (ebx & 0x20) == 0: read qy (else 0)
|
||||||
|
284617 if (ebx & 0x40) == 0: read qz (else 0)
|
||||||
|
284625 Frame::cache(&position.frame); // recompute cached matrix
|
||||||
|
284628 if (ebx & 1) != 0: read velocity (12 bytes)
|
||||||
|
284646 if (ebx & 2) != 0: read placement_id (4 bytes)
|
||||||
|
284654 has_contact = (ebx >> 2) & 1;
|
||||||
|
284655 read uint16 instance_timestamp;
|
||||||
|
284660 read uint16 position_timestamp; // POSITION_TS
|
||||||
|
284664 read uint16 teleport_timestamp; // used as move-seq for arg8 below
|
||||||
|
284667 read uint16 force_position_timestamp; // FORCE_POSITION_TS
|
||||||
|
```
|
||||||
|
|
||||||
|
Observation: the wire field called "teleport_timestamp" is reused as
|
||||||
|
the **move-seq** that gets passed as `arg8 = arg3` into
|
||||||
|
`MoveOrTeleport`. It indexes `update_times[TELEPORT_TS=4]` in the
|
||||||
|
`newer_event(this, TELEPORT_TS, arg3)` check inside MoveOrTeleport
|
||||||
|
(284325). One stamp, two purposes — for remotes it acts as a generic
|
||||||
|
"move sequence"; for the local player it triggers the teleport branch
|
||||||
|
in HandleReceivedPosition (93013).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CPhysicsObj::MoveOrTeleport — the ROUTER
|
||||||
|
|
||||||
|
**File line:** 284304 — `0x00516330`
|
||||||
|
|
||||||
|
This is the function L.3 has to port faithfully. Verbatim:
|
||||||
|
|
||||||
|
```c
|
||||||
|
284304 int32_t __thiscall CPhysicsObj::MoveOrTeleport(
|
||||||
|
284304 class CPhysicsObj* this,
|
||||||
|
284304 class Position* arg2, // received position (objcell_id + Frame)
|
||||||
|
284304 uint16_t arg3, // move-seq (TELEPORT_TS slot value)
|
||||||
|
284304 int32_t arg4, // has_contact (0 = airborne, !0 = grounded)
|
||||||
|
284304 class AC1Legacy::Vector3 const* arg5) // velocity vector
|
||||||
|
284306 {
|
||||||
|
284307 class CPhysicsObj* this_1 = this;
|
||||||
|
284308 this = this_1->update_times[4]; // current TELEPORT_TS
|
||||||
|
284311 // rolling-uint16 compare: arg3 vs current update_times[4]
|
||||||
|
284321 if ( delta-from-rolling-uint16(this, arg3) == 0 ) // SAME-OR-NEWER
|
||||||
|
284321 {
|
||||||
|
284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3);
|
||||||
|
284327 // └── writes update_times[4]=arg3 if newer ──┘
|
||||||
|
284327 if ( eax_8 != 0 || this_1->cell == 0 )
|
||||||
|
284327 {
|
||||||
|
// ───────── BRANCH A: HARD TELEPORT ─────────
|
||||||
|
284329 int32_t var_70_3 = 1;
|
||||||
|
284330 CPhysicsObj::teleport_hook(this_1, edx_2);
|
||||||
|
284332 SetPositionStruct sps;
|
||||||
|
284332 SetPositionStruct::SetPositionStruct(&sps);
|
||||||
|
284333 SetPositionStruct::SetPosition(&sps, arg2);
|
||||||
|
284334 SetPositionStruct::SetFlags(&sps, 0x1012); // ← TELEPORT FLAGS
|
||||||
|
284335 CPhysicsObj::SetPosition(this_1, &sps);
|
||||||
|
284336 SetPositionStruct::~SetPositionStruct(&sps);
|
||||||
|
284337 return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
284340 if ( arg4 != 0 ) // GROUNDED?
|
||||||
|
284340 {
|
||||||
|
// arg4 == has_contact != 0
|
||||||
|
284342 long double playerDist = this_1->player_distance; // float
|
||||||
|
284343 long double thresh96 = 96.0f; // ★
|
||||||
|
284347 if ( playerDist >= thresh96 ) // ★
|
||||||
|
284347 {
|
||||||
|
// ───────── BRANCH B: WITHIN BUBBLE → INTERPOLATE ─────────
|
||||||
|
284351 CPhysicsObj::InterpolateTo(this_1, arg2,
|
||||||
|
284351 CPhysicsObj::IsMovingTo(this_1));
|
||||||
|
284352 return 1;
|
||||||
|
284347 }
|
||||||
|
// ───────── BRANCH C: BEYOND BUBBLE → SLIDE-SNAP ─────────
|
||||||
|
284355 class PositionManager* position_manager = this_1->position_manager;
|
||||||
|
284357 if ( position_manager != 0 )
|
||||||
|
284358 PositionManager::StopInterpolating(position_manager);
|
||||||
|
|
||||||
|
284360 CPhysicsObj::SetPositionSimple(this_1, arg2, /*slide=*/1);
|
||||||
|
284361 return 1;
|
||||||
|
}
|
||||||
|
284362 }
|
||||||
|
284365 return 0; // STALE — ignore
|
||||||
|
284366 }
|
||||||
|
```
|
||||||
|
|
||||||
|
★ — the float comparison on lines 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 |
|
||||||
|
|
||||||
497
docs/research/2026-05-04-l3-port/04-interp-manager.md
Normal file
497
docs/research/2026-05-04-l3-port/04-interp-manager.md
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
# InterpolationManager — full retail port reference
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
|
||||||
|
**Cross-check:** `src/AcDream.Core/Physics/InterpolationManager.cs` (current port), `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` (cdb-traced).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Class layout (verbatim from `acclient.h`)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct __cppobj LList<InterpolationNode> : LListBase {};
|
||||||
|
|
||||||
|
struct __cppobj InterpolationManager
|
||||||
|
{
|
||||||
|
LList<InterpolationNode> position_queue; // 0x00 (head_, tail_) — singly-linked
|
||||||
|
CPhysicsObj *physics_obj; // 0x08
|
||||||
|
int keep_heading; // 0x0C
|
||||||
|
unsigned int frame_counter; // 0x10
|
||||||
|
float original_distance; // 0x14
|
||||||
|
float progress_quantum; // 0x18
|
||||||
|
int node_fail_counter; // 0x1C
|
||||||
|
Position blipto_position; // 0x20 ... 0x68 (size 0x68 total per `operator new(0x68)`)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`InterpolationNode` (size 0x60, allocated via `operator new(0x60)` in `InterpolateTo`):
|
||||||
|
|
||||||
|
| Offset | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x00 | `llist_next` | LListBase next-pointer |
|
||||||
|
| 0x04 | `type` | 1 = position waypoint, 2/3 = velocity-bearing nodes (rare). UseTime velocity-snap reads tail+0x50 / +0x54 / +0x58 (Vector3) for type 2/3. |
|
||||||
|
| 0x08 | vtable / sentinel | `0x79285c` written before `delete` |
|
||||||
|
| 0x0C | `objcell_id` | uint32 cell ID |
|
||||||
|
| 0x10 | `frame` (begin) | Position::frame, 0x44 bytes (qw,qx,qy,qz,Origin{x,y,z}, cache) |
|
||||||
|
| ... | (Position internals) | |
|
||||||
|
| 0x50..0x58 | velocity Vector3 | only meaningful for `type` 2/3 |
|
||||||
|
|
||||||
|
**Queue is FIFO singly-linked.** `head_` is the node we walk *toward* first; `tail_` is the most-recent enqueue (the latest server-reported position).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Constructor / Destroy / StopInterpolating
|
||||||
|
|
||||||
|
### `InterpolationManager::InterpolationManager` @ `0x005558d0`
|
||||||
|
|
||||||
|
```c
|
||||||
|
this->original_distance = 999999f; // sentinel — first window has no baseline
|
||||||
|
this->position_queue.head_ = nullptr;
|
||||||
|
this->position_queue.tail_ = nullptr;
|
||||||
|
this->frame_counter = 0;
|
||||||
|
this->progress_quantum = 0f;
|
||||||
|
this->node_fail_counter = 0;
|
||||||
|
// blipto_position initialised to identity Frame (qw=1, origin=0)
|
||||||
|
this->blipto_position.objcell_id = 0;
|
||||||
|
Frame::cache(&this->blipto_position.frame);
|
||||||
|
this->physics_obj = arg2;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `InterpolationManager::Destroy` @ `0x00555af0`
|
||||||
|
|
||||||
|
Free every node in `position_queue`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
while (head_ = this->position_queue.head_; head_ != 0) {
|
||||||
|
LListData *next = head_->llist_next;
|
||||||
|
this->position_queue.head_ = next;
|
||||||
|
if (next == 0) this->position_queue.tail_ = next;
|
||||||
|
head_->llist_next = 0;
|
||||||
|
*(uint32_t*)((char*)head_ + 8) = 0x79285c; // overwrite vtable
|
||||||
|
operator delete(head_);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `InterpolationManager::StopInterpolating` @ `0x00555950`
|
||||||
|
|
||||||
|
Same node-drain loop as `Destroy`, **then resets all stall state**:
|
||||||
|
|
||||||
|
```c
|
||||||
|
this->frame_counter = 0;
|
||||||
|
this->progress_quantum = 0f;
|
||||||
|
this->node_fail_counter = 0;
|
||||||
|
this->original_distance = 999999f;
|
||||||
|
```
|
||||||
|
|
||||||
|
> The `999999f` sentinel matters: the first 5-frame window after a fresh Stop will compute `cumulative_progress = 999999 - currentDist`, which is huge and **passes** the stall check. This is retail's intended way of suppressing first-window false-positives. Our port replaces this with an explicit `_haveBaselineDistance` boolean — equivalent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `InterpolationManager::InterpolateTo` (AppendNode) @ `0x00555b20`
|
||||||
|
|
||||||
|
Signature: `void InterpolateTo(InterpolationManager *this, Position const *arg2, int32_t arg3)`
|
||||||
|
- `arg2` = new server-authoritative target position.
|
||||||
|
- `arg3` = `keep_heading` flag (1 = preserve current physics heading instead of using the wire heading).
|
||||||
|
|
||||||
|
### Branching:
|
||||||
|
|
||||||
|
1. **Compute `dist`**: from either (a) the tail's stored position if tail exists AND `tail->type == 1`, otherwise (b) the physics object's current `m_position`. Then:
|
||||||
|
```c
|
||||||
|
dist = Position::distance(reference, arg2);
|
||||||
|
blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Far branch** — `dist > GetAutonomyBlipDistance()` (100m outdoor / 20m indoor for creatures):
|
||||||
|
- Allocate `InterpolationNode` (`new(0x60)` + `InterpolationNode::InterpolationNode`).
|
||||||
|
- `node->type = 1`; copy objcell_id; copy frame.
|
||||||
|
- If `keep_heading`, overwrite frame heading with `physics_obj->get_heading()`.
|
||||||
|
- **Append to tail** (the canonical AppendNode op).
|
||||||
|
- `this->node_fail_counter = 4;` ← **important**: forces an immediate blip-to-tail on the next `UseTime` (4 > 3 threshold). This is retail's "we're so far out of sync, just teleport" reflex.
|
||||||
|
- Return.
|
||||||
|
|
||||||
|
3. **Near & already-very-close branch** — `dist <= blip` AND `Position::distance(physics_obj->m_position, arg2) <= 0.05` (`DESIRED_DISTANCE`):
|
||||||
|
- If `arg3 == 0`, set heading directly: `physics_obj->set_heading(arg2->frame.get_heading(), 1)`.
|
||||||
|
- `StopInterpolating(this);`
|
||||||
|
- Return. (No node enqueued — body is already where it needs to be.)
|
||||||
|
|
||||||
|
4. **Near & not-yet-close branch** — `dist <= blip`:
|
||||||
|
- **Tail-prune duplicates**: while `tail->type == 1` AND `Position::distance(tail, arg2) <= 0.05`, `LListBase::RemoveTail` and delete it.
|
||||||
|
- **Cap at 20**: walk the list counting nodes; if count >= `0x14` (20), drop the head. (Loop `00555c73`–`00555cb1`.)
|
||||||
|
- Set `this->keep_heading = arg3;`
|
||||||
|
- Allocate, fill (type=1, copy objcell+frame, optionally override heading), append to tail.
|
||||||
|
|
||||||
|
### Answer: **AppendNode behavior**
|
||||||
|
|
||||||
|
There is no separate `AppendNode` symbol — the logic is inlined in `InterpolateTo`. The retail behaviors that matter for our port:
|
||||||
|
|
||||||
|
- **Duplicate-prune is a tail-walking loop**, not a single tail-comparison. (Multiple stale tail entries within 0.05 m of the new target all collapse.)
|
||||||
|
- **Cap eviction is at the HEAD** when reaching 20 entries.
|
||||||
|
- **Far enqueue forces `node_fail_counter = 4`** to trigger immediate tail-blip next tick.
|
||||||
|
|
||||||
|
Our current port does duplicate-prune against only the last entry (`_queue.Last`), drops head on cap — **functionally equivalent** because the tail-walk converges to the same result given a single new arg2. **It does NOT replicate the `node_fail_counter = 4` "force blip" on far enqueue** — see § 7 gap analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `InterpolationManager::adjust_offset` @ `0x00555d30`
|
||||||
|
|
||||||
|
Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)`
|
||||||
|
- `arg2` = output Frame (already-zeroed identity Frame from caller `CPhysicsObj::UpdatePartsInternal` @ `0x00512c3c`).
|
||||||
|
- `arg3` = frame `dt` in seconds (double).
|
||||||
|
|
||||||
|
### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return.
|
||||||
|
|
||||||
|
The last meaningful action (line 353253) is:
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555f10 Frame::operator=(arg2, &__return);
|
||||||
|
```
|
||||||
|
|
||||||
|
where `__return` is a Frame whose `m_fOrigin` was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with:
|
||||||
|
|
||||||
|
```c
|
||||||
|
00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/);
|
||||||
|
```
|
||||||
|
|
||||||
|
**So `adjust_offset` writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position.** Our acdream port returns a `Vector3` delta, which the caller adds to the body — equivalent semantics.
|
||||||
|
|
||||||
|
### Step-by-step retail flow:
|
||||||
|
|
||||||
|
```c
|
||||||
|
LListData *head_ = this->position_queue.head_;
|
||||||
|
if (head_ == 0) return; // empty queue → no-op
|
||||||
|
|
||||||
|
CPhysicsObj *po = this->physics_obj;
|
||||||
|
if (po == 0) return;
|
||||||
|
|
||||||
|
// ---- GATE on transient_state bit 0 ----
|
||||||
|
if ((po->transient_state & 1) == 0) return; // line 353080
|
||||||
|
|
||||||
|
int type = head_->type;
|
||||||
|
if (type == 2 || type == 3) return; // velocity nodes → skip
|
||||||
|
|
||||||
|
// ---- Distance to head ----
|
||||||
|
float dist = Position::distance(&po->m_position, &head_[2 /* node->p Position */]);
|
||||||
|
if (dist <= DESIRED_DISTANCE /* 0.05 */) { // line 353089
|
||||||
|
NodeCompleted(this, 1); // pop head, advance
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Catch-up speed ----
|
||||||
|
float catchUp;
|
||||||
|
if (po->minterp() != 0) {
|
||||||
|
float maxSpd = fUseAdjustedSpeed_
|
||||||
|
? CMotionInterp::get_adjusted_max_speed(po->minterp())
|
||||||
|
: CMotionInterp::get_max_speed(po->minterp());
|
||||||
|
catchUp = maxSpd * MAX_INTERPOLATED_VELOCITY_MOD; // 2.0
|
||||||
|
} else {
|
||||||
|
catchUp = 0f;
|
||||||
|
}
|
||||||
|
// F_EPSILON test: if catchUp < 0.0002 → fallback to MAX_INTERPOLATED_VELOCITY (7.5)
|
||||||
|
if (catchUp < 0.000199999995f) catchUp = 7.5f; // line 353128 / 0x40f00000
|
||||||
|
|
||||||
|
// ---- Accumulate progress + frame counter ----
|
||||||
|
this->progress_quantum += (float)arg3; // ← see note below
|
||||||
|
this->frame_counter += 1;
|
||||||
|
|
||||||
|
// ---- 5-frame stall window ----
|
||||||
|
if (this->frame_counter >= 5) {
|
||||||
|
float cumulative = this->original_distance - dist; // line 353150
|
||||||
|
if (CPhysicsObj::get_sticky_object_id(po) == 0) {
|
||||||
|
bool primary_pass = cumulative >= MIN_DISTANCE_TO_REACH_POSITION; // 0.20
|
||||||
|
bool secondary_pass = cumulative > F_EPSILON
|
||||||
|
&& (cumulative / progress_quantum / arg3) >= CREATURE_FAILED_INTERPOLATION_PERCENTAGE; // 0.30
|
||||||
|
// EITHER pass → window is good. NEITHER pass:
|
||||||
|
if (!primary_pass && !secondary_pass) {
|
||||||
|
this->node_fail_counter += 1;
|
||||||
|
NodeCompleted(this, 0); // re-baseline, do NOT stop
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this->frame_counter = 0;
|
||||||
|
this->progress_quantum = 0f;
|
||||||
|
this->original_distance = dist; // re-baseline window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Compute step Frame ----
|
||||||
|
Vector3 toHead;
|
||||||
|
Position::subtract2(&head_[2], &delta_frame, &po->m_position); // delta_frame.origin = head - here (cell-aware)
|
||||||
|
toHead = delta_frame.m_fOrigin;
|
||||||
|
|
||||||
|
float step = catchUp * (float)arg3; // catchUp m/s * dt s = step m
|
||||||
|
|
||||||
|
float toHead_mag = AC1Legacy::Vector3::magnitude(&toHead);
|
||||||
|
|
||||||
|
// ---- Reach test (different threshold!) ----
|
||||||
|
if (toHead_mag <= DESIRED_DISTANCE /* 0.05 */) // line 353222 (note: tested INSIDE this branch too)
|
||||||
|
NodeCompleted(this, 1);
|
||||||
|
|
||||||
|
// ---- No-overshoot scale ----
|
||||||
|
if (step < toHead_mag) {
|
||||||
|
float scale = step / toHead_mag;
|
||||||
|
Vector3::operator*=(&toHead, scale); // shrink toHead to length=step
|
||||||
|
}
|
||||||
|
// else: leave toHead at full magnitude (step would overshoot — clamped to dist)
|
||||||
|
|
||||||
|
// ---- Heading override ----
|
||||||
|
if (this->keep_heading != 0) {
|
||||||
|
Frame::set_heading(&delta_frame, 0f); // zero rotation in the offset Frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Output ----
|
||||||
|
Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame
|
||||||
|
```
|
||||||
|
|
||||||
|
### NOTE on `progress_quantum`
|
||||||
|
|
||||||
|
Look at lines 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`.
|
||||||
|
|
@ -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.
|
||||||
550
docs/research/2026-05-04-l3-port/06-acdream-audit.md
Normal file
550
docs/research/2026-05-04-l3-port/06-acdream-audit.md
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
# 06 — Acdream audit: player remote-entity motion code as it stands
|
||||||
|
|
||||||
|
Date: 2026-05-04. Scope: every piece of code that touches a *remote* player
|
||||||
|
character's motion between `OnLiveMotionUpdated`, `OnLiveVectorUpdated`,
|
||||||
|
`OnLivePositionUpdated`, and the per-tick `TickAnimations` loop. Inputs:
|
||||||
|
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs` (8346 LOC).
|
||||||
|
- `src/AcDream.Core/Physics/{MotionInterpreter,AnimationSequencer,
|
||||||
|
PhysicsBody,InterpolationManager,PositionManager,
|
||||||
|
ServerControlledLocomotion,RemoteMoveToDriver}.cs`.
|
||||||
|
|
||||||
|
Verdict labels used below: **PORT** (faithful retail port, retail address
|
||||||
|
cited), **HACK** (acdream-original logic with no retail equivalent), or
|
||||||
|
**BROKEN** (regressed/wrong vs. the retail spec; see ISSUES.md / file
|
||||||
|
header comments).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `GameWindow.RemoteMotion` (lines 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).
|
||||||
919
docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md
Normal file
919
docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md
Normal file
|
|
@ -0,0 +1,919 @@
|
||||||
|
# L.3 port research — StickyManager / ConstraintManager / MoveToManager
|
||||||
|
|
||||||
|
**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
|
||||||
|
**Cross-refs**: `references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,ConstraintManager,MoveToManager}.cs`,
|
||||||
|
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`,
|
||||||
|
acdream `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`.
|
||||||
|
|
||||||
|
All retail line numbers below refer to that file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. StickyManager — "follow object at fixed radius"
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`StickyManager` is the **post-arrival follow-on** for `MoveToObject`. After
|
||||||
|
`BeginNextNode` exhausts the pending action list and finds
|
||||||
|
`movement_params.__inner0` had the high bit set (sticky-after-arrive flag),
|
||||||
|
it calls `PositionManager::StickTo(top_level_object_id, radius, height)`
|
||||||
|
(line 307159). From that point, every physics tick `PositionManager::adjust_offset`
|
||||||
|
calls `StickyManager::adjust_offset` to nudge the body's per-tick position
|
||||||
|
delta toward the target, maintaining a fixed cylindrical separation.
|
||||||
|
|
||||||
|
### State (`sizeof = 0x60`, lines 352620-352633 ctor + 0x796910 vtable)
|
||||||
|
|
||||||
|
| Offset | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x00 | `target_id` | uint32. 0 means "not stuck". |
|
||||||
|
| 0x04 | `physics_obj` | back-pointer to `CPhysicsObj`. |
|
||||||
|
| 0x08 | `target_position.vtable` | `0x796910` = `class Position`. |
|
||||||
|
| 0x0c | `target_position.objcell_id` | last known target cell. |
|
||||||
|
| 0x10..0x4c | `target_position.frame` | qw/qx/qy/qz/origin (cached). |
|
||||||
|
| 0x4c | `target_radius` | float. From `StickTo arg3`. |
|
||||||
|
| 0x50 | `initialized` | int32. 1 after first `HandleUpdateTarget Ok`. |
|
||||||
|
| 0x54..0x5c | `sticky_timeout_time` | double, line 352576 = `cur_time + 1.0`. |
|
||||||
|
|
||||||
|
### `StickyManager::Create(CPhysicsObj*)` @ 0x00555800
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555804 void* result = operator new(0x60);
|
||||||
|
00555814 *(uint32_t*)result = 0; // target_id
|
||||||
|
00555816 *(uint32_t*)((char*)result + 4) = 0; // physics_obj (set later by SetPhysicsObject)
|
||||||
|
0055581c *(uint32_t*)((char*)result + 8) = 0x796910; // Position vtable
|
||||||
|
00555826 *(uint32_t*)((char*)result + 0x10) = 0x3f800000; // qw = 1.0f
|
||||||
|
... zero everything else
|
||||||
|
0055582f *(uint32_t*)((char*)result + 0x18) = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Followed (via `PositionManager::Create` line 352252) by `SetPhysicsObject`.
|
||||||
|
|
||||||
|
### `StickyManager::StickTo(target_id, radius, height)` @ 0x00555710 (lines 352559-352578)
|
||||||
|
|
||||||
|
Plain language: "from now on, follow `target_id` at radius `radius`, and
|
||||||
|
notify the engine to start tracking that target."
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555716 if (this->target_id != 0) { // already stuck → unstick first
|
||||||
|
00555718 class CPhysicsObj* physics_obj = this->physics_obj;
|
||||||
|
0055571b this->target_id = 0;
|
||||||
|
00555721 this->initialized = 0;
|
||||||
|
00555728 CPhysicsObj::clear_target(physics_obj);
|
||||||
|
00555730 CPhysicsObj::interrupt_current_movement(this->physics_obj);
|
||||||
|
}
|
||||||
|
00555749 this->target_radius = arg3; // arg3 = radius
|
||||||
|
0055574f this->target_id = arg2; // arg2 = object id
|
||||||
|
00555751 this->sticky_timeout_time = Timer::cur_time + 1.0; // 1-second alive window
|
||||||
|
0055575a this->initialized = 0;
|
||||||
|
00555771 CPhysicsObj::set_target(physics_obj, 0, arg2, 0.5f, 0.5f);
|
||||||
|
```
|
||||||
|
|
||||||
|
`arg4` (target_height) is **received but not stored** — sticky uses cylinder
|
||||||
|
distance (no Z), so height is irrelevant. The 0.5f/0.5f passed to `set_target`
|
||||||
|
is the target tracking radius/height the server-update path uses to filter
|
||||||
|
which `TargetInfo` updates land here.
|
||||||
|
|
||||||
|
### `StickyManager::HandleUpdateTarget(TargetInfo)` @ 0x00555780 (lines 352582-352607)
|
||||||
|
|
||||||
|
Server-driven: when `CPhysicsObj` receives a fresh tracked-target
|
||||||
|
position update, this absorbs it.
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555789 if (arg2.object_id == target_id) {
|
||||||
|
if (arg2.status == Ok_TargetStatus) {
|
||||||
|
this->initialized = 1;
|
||||||
|
this->target_position.objcell_id = arg2.target_position.objcell_id;
|
||||||
|
Frame::operator=(&this->target_position.frame, &arg2.target_position.frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target_id != 0) // status != Ok → bail out
|
||||||
|
ClearTarget(); // (inlined)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `StickyManager::adjust_offset(Frame* offset, double quantum)` @ 0x00555430 — **the per-tick steerer**
|
||||||
|
|
||||||
|
This is the function `PositionManager::adjust_offset` calls every tick at
|
||||||
|
line 005551ba.
|
||||||
|
|
||||||
|
**Branch guard** (line 352356): only runs when `target_id != 0 AND initialized != 0`.
|
||||||
|
|
||||||
|
**Algorithm** in plain language (with retail line cites):
|
||||||
|
|
||||||
|
1. **Compute world-space offset to target** (lines 352358-352366):
|
||||||
|
- `edi_2 = &physics_obj->m_position` (our position)
|
||||||
|
- `eax = GetObjectA(target_id)` — get the live target if still in our world
|
||||||
|
- `ebp_1 = (eax != 0) ? &eax->m_position : &this->target_position` — fall back to last-known position if target despawned
|
||||||
|
- `Position::get_offset(edi_2, &__return, ebp_1)` writes
|
||||||
|
`(target.world - me.world)` into `offset.m_fOrigin`.
|
||||||
|
|
||||||
|
2. **Convert to my local space and flatten Z** (lines 352370-352374):
|
||||||
|
- `Position::globaltolocalvec(...)` rotates the offset by my inverse heading.
|
||||||
|
- `offset.m_fOrigin.z = 0` — sticky is **always horizontal**.
|
||||||
|
|
||||||
|
3. **Compute cylinder distance minus 0.3 m sticky radius** (lines 352375-352378):
|
||||||
|
- `target_radius = this->target_radius`
|
||||||
|
- `var_34_1 = CPhysicsObj::GetRadius(physics_obj)` (own radius — actually unused; computed for side effect or future use)
|
||||||
|
- `var_14_1 = Position::cylinder_distance_no_z(edi_2, target_radius, ebp_1) - 0.30000001f`
|
||||||
|
|
||||||
|
So `dist = horizontal_separation_minus_combined_radii - 0.3 m`.
|
||||||
|
|
||||||
|
4. **Normalize the offset direction** (lines 352381-352387):
|
||||||
|
- `if Vector3::normalize_check_small(offset.m_fOrigin) != 0 → offset.m_fOrigin = (0,0,0)` — when target is on top of us, no direction.
|
||||||
|
|
||||||
|
5. **Compute step speed** (lines 352392-352409):
|
||||||
|
- `eax_7 = CMotionInterp::get_max_speed(get_minterp(physics_obj))` — pull body's max forward speed.
|
||||||
|
- If `get_minterp == 0` → step skipped (`top_1 = 0`).
|
||||||
|
- Compare against `F_EPSILON`. If `max_speed < F_EPSILON` → use `MAX_VELOCITY` instead (the global cap).
|
||||||
|
- **ACE port (line 112)**: `speed = minterp.get_max_speed() * 5.0f` then `if speed < EPSILON → 15.0f`.
|
||||||
|
The retail `* 5.0f` constant isn't visible in our pseudo-C extract because the FP
|
||||||
|
stack ops are unimplemented in the BinaryNinja output; ACE's value is the
|
||||||
|
authoritative interpretation.
|
||||||
|
|
||||||
|
6. **Multiply offset by `min(speed × quantum, dist)`** (lines 352411-352455):
|
||||||
|
- The branching (`if p_2`) selects between the speed-clamped delta and the
|
||||||
|
distance-clamped delta. ACE's port (lines 117-121) makes this explicit:
|
||||||
|
```csharp
|
||||||
|
var delta = speed * (float)quantum;
|
||||||
|
if (delta >= Math.Abs(dist)) delta = dist;
|
||||||
|
offset.Origin *= delta;
|
||||||
|
```
|
||||||
|
- Z stays 0; X/Y get scaled to the final per-tick step.
|
||||||
|
|
||||||
|
7. **Set heading toward target** (lines 352456-352492):
|
||||||
|
- `Position::heading(edi_2, ebp_1)` — compute heading from us to target (degrees, 0..360).
|
||||||
|
- `Frame::get_heading(&edi_2->frame)` — current heading.
|
||||||
|
- `delta = target_heading - current_heading`.
|
||||||
|
- If `|delta| < F_EPSILON` → `delta = 0`.
|
||||||
|
- If `delta < -F_EPSILON` → `delta += 360.0f`.
|
||||||
|
- `Frame::set_heading(arg2, delta)` — bake the **rotation delta** (not absolute) into the frame the caller passes in. The caller composes this onto the body next tick.
|
||||||
|
|
||||||
|
### `StickyManager::UseTime` @ 0x00555610 (lines 352498-352517)
|
||||||
|
|
||||||
|
Empty unless `target_id != 0 AND cur_time > sticky_timeout_time`. When timeout
|
||||||
|
elapses without a `HandleUpdateTarget Ok`, drops the target completely. The
|
||||||
|
1-second timeout (`sticky_timeout_time = cur_time + 1.0`) means the server has
|
||||||
|
to keep refreshing the target every second or sticky drops.
|
||||||
|
|
||||||
|
### `StickyManager::UnStick` @ 0x00555400 + `Destroy` @ 0x00555650 + `~StickyManager` @ 0x005557e0
|
||||||
|
|
||||||
|
`UnStick` and `Destroy` both clear `target_id`, `initialized`, call
|
||||||
|
`CPhysicsObj::clear_target(physics_obj)`, then **`UnStick` also calls
|
||||||
|
`CPhysicsObj::interrupt_current_movement`** while `Destroy` does not. The
|
||||||
|
distinction matters: `UnStick` is a deliberate "stop sticky now"; `Destroy`
|
||||||
|
is "we're being torn down, don't side-effect into the still-existing physics
|
||||||
|
state machine."
|
||||||
|
|
||||||
|
### Critical questions answered
|
||||||
|
|
||||||
|
- **When does sticky activate?** Only via `BeginNextNode`'s post-arrival
|
||||||
|
branch (line 307143-307159) when `movement_params.__inner0` has its high
|
||||||
|
bit set. Outbound `MoveToObject` packets with that flag are server-side AI
|
||||||
|
scripts (combat tracking, NPC follow, etc.). Player-driven moves don't
|
||||||
|
set it.
|
||||||
|
- **What does it write to the Frame?** `m_fOrigin = (xy_step_toward_target, 0)`
|
||||||
|
in **local space** (rotated to body-local before being scaled, so when the
|
||||||
|
body composes this onto its own frame the step lands in the right world-space
|
||||||
|
direction). Plus `set_heading(rotation_delta)` — the body turns to face the
|
||||||
|
target each tick at `set_heading` rate (with the same kind of fudge as
|
||||||
|
ACE's `set_heading(target, true)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ConstraintManager — "leash to a fixed point"
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Force the body to stay within a soft bubble around `constraint_pos`. Used for
|
||||||
|
quest geometry like "can't leave this room", monster aggro tethers, etc.
|
||||||
|
Smaller scope than sticky and **purely position-based** (no rotation).
|
||||||
|
|
||||||
|
### State (`sizeof = 0x5c`, lines 353442-353474 ctor)
|
||||||
|
|
||||||
|
| Offset | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x00 | `physics_obj` | back-pointer (set last, line 353473). |
|
||||||
|
| 0x04 | `is_constrained` | int32. |
|
||||||
|
| 0x08 | `constraint_pos.vtable` | not 0x796910 here — Position embed begins at +0xc. |
|
||||||
|
| 0x0c | `constraint_pos.vtable` (real) | `0x796910` = `class Position`. |
|
||||||
|
| 0x10 | `constraint_pos.objcell_id` | 0 |
|
||||||
|
| 0x14..0x4c | `constraint_pos.frame` | qw/qx/qy/qz/origin |
|
||||||
|
| 0x48 | `constraint_distance_start` | float — soft bubble inner radius |
|
||||||
|
| 0x4c | `constraint_distance_max` | float — hard bubble outer radius |
|
||||||
|
| 0x50 | `constraint_pos_offset` | float — current distance from `constraint_pos` |
|
||||||
|
|
||||||
|
(ACE's port stores them by C# field names mirroring the above; the offsets aren't
|
||||||
|
load-bearing, the meanings are.)
|
||||||
|
|
||||||
|
### `ConstraintManager::ConstrainTo(Position* pos, float startDist, float maxDist)` @ 0x00556240 (lines 353528-353537)
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556248 this->is_constrained = 1;
|
||||||
|
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
|
||||||
|
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
|
||||||
|
00556271 this->constraint_distance_start = arg3;
|
||||||
|
00556274 this->constraint_distance_max = arg4;
|
||||||
|
0055627c this->constraint_pos_offset = Position::distance(arg2, &this->physics_obj->m_position);
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot the leash anchor + radii + initial offset.
|
||||||
|
|
||||||
|
### `ConstraintManager::adjust_offset(Frame* offset, double quantum)` @ 0x00556180 — **the per-tick clamper**
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
|
||||||
|
0055618a if (physics_obj == 0) return;
|
||||||
|
00556190 if (this->is_constrained == 0) return;
|
||||||
|
|
||||||
|
005561a7 if ((physics_obj->transient_state & 1) != 0) { // bit 0 = "Contact" (touching ground)
|
||||||
|
if (this->constraint_pos_offset < this->constraint_distance_max) {
|
||||||
|
if (this->constraint_pos_offset > this->constraint_distance_start) {
|
||||||
|
// soft zone: scale offset DOWN proportionally to how far into the soft band we are
|
||||||
|
float scale = (constraint_distance_max - constraint_pos_offset) /
|
||||||
|
(constraint_distance_max - constraint_distance_start);
|
||||||
|
Vector3::operator*=(&arg2->m_fOrigin, scale);
|
||||||
|
}
|
||||||
|
// else: inside inner bubble, leave offset unchanged
|
||||||
|
} else {
|
||||||
|
// hard zone: zero the offset entirely. No movement allowed beyond max.
|
||||||
|
arg2->m_fOrigin = Vector3::Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
00556233 this->constraint_pos_offset = arg2->m_fOrigin.x + this->constraint_pos_offset;
|
||||||
|
// ^^ NOTE: the pseudo-C extract reads ".x +", but the actual algorithm uses
|
||||||
|
// the **length** of m_fOrigin (the magnitude of the per-tick step). ACE's
|
||||||
|
// port (line 76) confirms: `ConstraintPosOffset = offset.Origin.Length();`
|
||||||
|
// The Binary Ninja extract garbled the FP stack ops here.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branches & flag bits
|
||||||
|
|
||||||
|
- **`transient_state & 1` = `Contact` flag.** ConstraintManager only fires
|
||||||
|
when the body is **touching the ground**. Mid-air motion (jumps, falls)
|
||||||
|
is unaffected. (This is the same `transient_state` bit that motion code
|
||||||
|
checks to decide whether `kill_velocity` is allowed — see commit a3f53c2.)
|
||||||
|
- **No rotation**: `set_heading` is never touched. Constraint is purely
|
||||||
|
positional.
|
||||||
|
- **No timeout**: `UseTime` is empty. Stays engaged until `Unconstrain`
|
||||||
|
or `~ConstraintManager`.
|
||||||
|
|
||||||
|
### `IsFullyConstrained` @ 0x005560d0 (lines 353413-353427)
|
||||||
|
|
||||||
|
```c
|
||||||
|
return constraint_distance_max * 0.9f < constraint_pos_offset;
|
||||||
|
```
|
||||||
|
|
||||||
|
"Are we within 10% of the hard limit?" Used by callers to decide whether
|
||||||
|
to schedule a course correction.
|
||||||
|
|
||||||
|
### Critical questions answered
|
||||||
|
|
||||||
|
- **What kinds of constraints exist?** Only **translation** in the form of a
|
||||||
|
soft-clamp toward `constraint_pos`. No rotation lock, no cell lock — those
|
||||||
|
are server-enforced.
|
||||||
|
- **When does it fire?** Per tick, but only when the body has the Contact
|
||||||
|
bit (touching ground). Ignored during jumps/falls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MoveToManager — full state machine for AI/scripted motion
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Server-side AI's locomotion executor. When the server wants a creature to
|
||||||
|
"walk to that rock then turn south", it sends a `MovementStruct` (one of 4
|
||||||
|
shapes) packed via `MovementParameters::UnPackNet`. `PerformMovement`
|
||||||
|
unpacks it, picks a top-level branch, the entrypoint queues a list of
|
||||||
|
**pending nodes** (each either `MoveToPosition` opcode `7` or `TurnToHeading`
|
||||||
|
opcode `9`), and `UseTime` ticks the head-of-queue node every physics frame
|
||||||
|
until the queue empties.
|
||||||
|
|
||||||
|
### State (`sizeof = 0x160`, lines 306554-306592 ctor)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `sought_position` | Position | The original target requested by the server. |
|
||||||
|
| `current_target_position` | Position | The "right now" target — interpolated for moving objects. |
|
||||||
|
| `starting_position` | Position | Where the body was when the move began. Used by fail-distance check. |
|
||||||
|
| `pending_actions` | DLListBase | Doubly-linked list of `{opcode, value}` nodes. Opcode 7 = MoveToPosition, opcode 9 = TurnToHeading (with heading float). |
|
||||||
|
| `movement_params` | MovementParameters | Packed flags, distances, speeds, hold-key, etc. (Section 4.) |
|
||||||
|
| `physics_obj`, `weenie_obj` | back-pointers | |
|
||||||
|
| `movement_type` | enum (`Invalid` or 6=MoveToObject / 7=MoveToPosition / 8=TurnToObject / 9=TurnToHeading) | |
|
||||||
|
| `current_command`, `aux_command` | uint32 | Active motion-command IDs. `current_command` is the "main" (forward/backward), `aux_command` is the simultaneous turn (e.g., 0x6500000d=TurnLeft, 0x6500000e=TurnRight). |
|
||||||
|
| `previous_distance`, `previous_distance_time` | float, double | For `CheckProgressMade` (fail detector). |
|
||||||
|
| `original_distance`, `original_distance_time` | float, double | Initial state for over-1-second progress check. |
|
||||||
|
| `previous_heading` | float | TurnToHeading's per-tick angle tracker. |
|
||||||
|
| `fail_progress_count` | int32 | Stalled-tick counter. |
|
||||||
|
| `sought_object_id`, `top_level_object_id` | uint32 | The object we're chasing (and its outermost parent if attached). |
|
||||||
|
| `sought_object_radius`, `sought_object_height` | float | Cylinder dimensions for distance test. |
|
||||||
|
| `moving_away` | int32 | 0=chase, 1=flee. Affects which arrival predicate fires. |
|
||||||
|
| `initialized` | int32 | 1 once we've gotten the first `HandleUpdateTarget Ok` for a moving target. |
|
||||||
|
|
||||||
|
### Top-level entry: `MoveToManager::PerformMovement(MovementStruct*)` @ 0x0052a900 (lines 307871-307904)
|
||||||
|
|
||||||
|
```c
|
||||||
|
0052a901 int32_t var_8 = 0x36; // WeenieError code that CancelMoveTo will use
|
||||||
|
0052a905 CancelMoveTo(this, edx); // wipe any in-flight move
|
||||||
|
0052a910 CPhysicsObj::unstick_from_object(this->physics_obj);
|
||||||
|
|
||||||
|
0052a923 switch (arg2->type - 6) {
|
||||||
|
case 0: // MoveToObject (type 6)
|
||||||
|
MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params);
|
||||||
|
break;
|
||||||
|
case 1: // MoveToPosition (type 7)
|
||||||
|
MoveToPosition(this, &arg2->pos, arg2->params);
|
||||||
|
break;
|
||||||
|
case 2: // TurnToObject (type 8)
|
||||||
|
TurnToObject(this, arg2->object_id, arg2->top_level_id, arg2->params);
|
||||||
|
break;
|
||||||
|
case 3: // TurnToHeading (type 9)
|
||||||
|
TurnToHeading(this, arg2->params);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `MoveToManager::MoveToObject` @ 0x00529680 (lines 306756-306817)
|
||||||
|
|
||||||
|
Stores: `sought_object_id = arg2`, `top_level_object_id = arg3`,
|
||||||
|
`sought_object_radius = arg4` (cylinder R), `sought_object_height = arg5`.
|
||||||
|
Copies `arg6` (MovementParameters) field-by-field into `this->movement_params`.
|
||||||
|
Saves current position into `starting_position`. Sets `movement_type = 6`,
|
||||||
|
`initialized = 0`.
|
||||||
|
|
||||||
|
If `arg3 == this->physics_obj->id` → it's us; CleanUp and bail.
|
||||||
|
|
||||||
|
Otherwise: `CPhysicsObj::set_target(physics_obj, 0, arg3, 0.5f, 0.0f)` — start
|
||||||
|
tracking. The actual movement queue isn't built here; it's built by
|
||||||
|
`HandleUpdateTarget` once the first target snapshot arrives (see below).
|
||||||
|
|
||||||
|
### `MoveToManager::MoveToPosition` @ 0x0052a240 (lines 307521-307593)
|
||||||
|
|
||||||
|
Position is known immediately, so the queue is built directly:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Wipe in-flight motion
|
||||||
|
StopCompletely(physics_obj_1);
|
||||||
|
// Snapshot target
|
||||||
|
this->current_target_position = *arg2;
|
||||||
|
this->sought_object_radius = 0.0f;
|
||||||
|
GetCurrentDistance(this); // returns |dist| in x87_r0
|
||||||
|
// Compute heading delta to target
|
||||||
|
float curHeading = CPhysicsObj::get_heading(physics_obj_2);
|
||||||
|
float headingToTarget = Position::heading(&physics_obj_2->m_position, arg2);
|
||||||
|
float delta = headingToTarget - curHeading;
|
||||||
|
if (|delta| < EPSILON) delta = 0;
|
||||||
|
if (delta < -EPSILON) delta += 360.0f; // normalize to [0, 360)
|
||||||
|
|
||||||
|
// Ask MovementParameters which command to issue (RunForward/WalkForward/Backwards/none)
|
||||||
|
// var_c = command, var_4 = holdKey, var_8 = movingAway
|
||||||
|
MovementParameters::get_command(arg3, dist, delta, &var_c, &var_4, &var_8);
|
||||||
|
|
||||||
|
// If we need to move at all, queue: TurnToHeading(headingToTarget) → MoveToPosition node
|
||||||
|
if (var_c != 0) {
|
||||||
|
float h = Position::heading(&this->physics_obj->m_position, arg2);
|
||||||
|
AddTurnToHeadingNode(this, h); // opcode 9
|
||||||
|
AddMoveToPositionNode(this); // opcode 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// If "use final heading" flag (bit 0x40) is set, queue a final TurnToHeading(desired_heading)
|
||||||
|
if ((arg3->__inner0 & 0x40) != 0)
|
||||||
|
AddTurnToHeadingNode(this, arg3->desired_heading);
|
||||||
|
|
||||||
|
// Snapshot positions
|
||||||
|
this->sought_position = *arg2;
|
||||||
|
this->starting_position = this->physics_obj->m_position;
|
||||||
|
this->movement_type = 7;
|
||||||
|
this->movement_params = *arg3; // field-by-field copy
|
||||||
|
this->movement_params.__inner0 &= 0xffffff7f; // clear bit 7 (sticky-after-arrive flag) — that's only valid for MoveToObject
|
||||||
|
|
||||||
|
BeginNextNode(this); // pop the head and dispatch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pending-action queue ops
|
||||||
|
|
||||||
|
- **`AddMoveToPositionNode`** @ 0x00529580: appends `{opcode=7, value=undefined}`.
|
||||||
|
- **`AddTurnToHeadingNode(float h)`** @ 0x00529530: appends `{opcode=9, value=h}`.
|
||||||
|
- **`RemovePendingActionsHead`** @ 0x00529380: pop head, free node.
|
||||||
|
|
||||||
|
### `MoveToManager::BeginNextNode` @ 0x00529cb0 (lines 307123-307171)
|
||||||
|
|
||||||
|
Dispatch loop:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (head_ != nullptr) {
|
||||||
|
int op = head_->opcode; // offset +8
|
||||||
|
if (op == 7) tailcall BeginMoveForward(this);
|
||||||
|
if (op == 9) tailcall BeginTurnToHeading(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue empty.
|
||||||
|
head_ = (uint8_t)this->movement_params.__inner0; // recycle head_ as a register…
|
||||||
|
if (head_ < 0) { // i.e., bit 7 set on __inner0 == sticky-after-arrive
|
||||||
|
float radius = this->sought_object_radius;
|
||||||
|
float height = this->sought_object_height;
|
||||||
|
uint32_t topId = this->top_level_object_id;
|
||||||
|
CleanUp(this);
|
||||||
|
if (physics_obj != 0) StopCompletely(physics_obj);
|
||||||
|
PositionManager::StickTo(get_position_manager(physics_obj), topId, radius, height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue empty, no sticky → done. CleanUp + StopCompletely.
|
||||||
|
CleanUp(this); StopCompletely(physics_obj);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `MoveToManager::BeginMoveForward` @ 0x00529a00 (lines 306957-307042) — opcode 7 dispatch
|
||||||
|
|
||||||
|
1. Sanity: physics_obj null → CancelMoveTo with err 8 (NotInitialized).
|
||||||
|
2. `var_3c = GetCurrentDistance(this)` → distance to current target.
|
||||||
|
3. Compute heading-to-target delta in `var_40_1`, normalize like in `MoveToPosition`.
|
||||||
|
4. `MovementParameters::get_command(&this->movement_params, var_3c, var_40_1, &var_38, &var_34, &var_30)` →
|
||||||
|
`var_38=command`, `var_34=holdKey`, `var_30=movingAway`.
|
||||||
|
5. **If `command == 0` (already arrived)**: `RemovePendingActionsHead`, then
|
||||||
|
`BeginNextNode` (advance to next pending node — typically a final TurnToHeading).
|
||||||
|
6. **Else**: `_DoMotion(this, command, &local_movement_params_clone)`.
|
||||||
|
On non-zero return → `CancelMoveTo(this, retval)`.
|
||||||
|
On success: stash `current_command = var_38`, `moving_away = var_30`,
|
||||||
|
`movement_params.hold_key_to_apply = var_34`, snapshot
|
||||||
|
`previous_distance/_time` and `original_distance/_time`.
|
||||||
|
|
||||||
|
### `MoveToManager::BeginTurnToHeading` @ 0x00529b90 (lines 307046-307120) — opcode 9 dispatch
|
||||||
|
|
||||||
|
1. Need head_ + physics_obj; otherwise CancelMoveTo err 8.
|
||||||
|
2. **If motions are pending in the body** (`CPhysicsObj::motions_pending != 0`),
|
||||||
|
skip — wait for body to settle.
|
||||||
|
3. Get target heading from `head_->value` (offset +0xc).
|
||||||
|
4. `st0 = heading_diff(target, current, 0x6500000d)` — 0x6500000d is
|
||||||
|
TurnLeft, so `heading_diff` returns "how much left-turn from current to
|
||||||
|
target".
|
||||||
|
5. **If diff ≈ 180** (within EPSILON of being directly behind): pop head and
|
||||||
|
advance via `BeginNextNode`. Avoids ambiguous turn direction.
|
||||||
|
6. **If diff ≈ 0** (within EPSILON of already aligned): pop head and advance.
|
||||||
|
7. **Else** decide direction: `edi = (st0 > 180) ? 0x6500000e (TurnRight) : 0x6500000d (TurnLeft)`.
|
||||||
|
8. `_DoMotion(this, edi, &local_params)`. On failure → CancelMoveTo. On
|
||||||
|
success: `current_command = edi`, `previous_heading = st0`.
|
||||||
|
|
||||||
|
### `MoveToManager::HandleMoveToPosition` @ 0x00529d80 — **the per-tick driver** (lines 307187-307438)
|
||||||
|
|
||||||
|
This is what `RemoteMoveToDriver.cs` is named after. Called every physics
|
||||||
|
frame from `UseTime` while the head pending node is opcode 7.
|
||||||
|
|
||||||
|
**Plain-language flow:**
|
||||||
|
|
||||||
|
1. **Aux turn correction** (lines 307213-307287). If body has motions pending,
|
||||||
|
stop the aux turn (we let body finish first). Otherwise:
|
||||||
|
- Compute `worldHeading = Position::heading(my_pos, current_target_position)`.
|
||||||
|
- `desiredHeading = worldHeading + MovementParameters::get_desired_heading(current_command, moving_away)`
|
||||||
|
— `get_desired_heading` returns 0 for forward/180 for backward when chasing,
|
||||||
|
swapped when fleeing.
|
||||||
|
- Normalize to `[0, 360)`.
|
||||||
|
- `delta = desiredHeading - currentHeading`, normalized to `[0, 360)`.
|
||||||
|
- **If `delta ≤ 20°` OR `delta ≥ 340°`** (within 20° of correct facing):
|
||||||
|
stop the aux turn (`_StopMotion(aux_command)`, `aux_command = 0`).
|
||||||
|
- **Else** pick direction: `edi_1 = (delta > 180) ? TurnRight : TurnLeft`.
|
||||||
|
If different from `aux_command`, `_DoMotion(edi_1, ...)` and
|
||||||
|
`aux_command = edi_1`.
|
||||||
|
|
||||||
|
2. **Distance check** (line 307289 `GetCurrentDistance` → `var_88_3`).
|
||||||
|
|
||||||
|
3. **Progress check** (line 307294 `CheckProgressMade(this, var_88_3) == 0`):
|
||||||
|
- **If no progress**: if not interpolating and no motions pending,
|
||||||
|
`fail_progress_count += 1`. (Body might be wedged on geometry; the
|
||||||
|
counter is read by external code to decide when to give up.)
|
||||||
|
- **If progress**: reset `fail_progress_count = 0`. Then check arrival:
|
||||||
|
- **Chase (`moving_away == 0`)**: arrived when `dist ≤ DistanceToObject`
|
||||||
|
(line 307323 — `fcomp st0, [esi+0xe4]` is `movement_params.distance_to_object`).
|
||||||
|
- **Flee (`moving_away == 1`)**: arrived when `dist ≤ MinDistance`
|
||||||
|
(line 307309 — `[esi+0xe8]`).
|
||||||
|
- **If arrived**: `RemovePendingActionsHead`, `_StopMotion(current_command)`,
|
||||||
|
`current_command = 0`, stop aux too, `BeginNextNode`.
|
||||||
|
- **If past fail_distance**: `dist_from_start = Position::distance(starting_position, my_pos)`,
|
||||||
|
and if `dist_from_start > fail_distance` → `CancelMoveTo` with err 0x3D
|
||||||
|
(`ObjectGone` / "fail-distance exceeded").
|
||||||
|
|
||||||
|
4. **Adaptive quantum tuning** (lines 307376-307437). If we have a tracked
|
||||||
|
target (`top_level_object_id != 0`):
|
||||||
|
- `velocity = CPhysicsObj::get_velocity(this->physics_obj)`.
|
||||||
|
- `speed_sq = vx² + vy² + vz²`. `speed = sqrt(speed_sq)`.
|
||||||
|
- **If `speed > 0.1`** (line 307400 `0.1` const) — body actually moving:
|
||||||
|
- `quantum = var_88_3 / speed` — projected time-to-arrival.
|
||||||
|
- `if |target_quantum - quantum| > 1.0` →
|
||||||
|
`CPhysicsObj::set_target_quantum(quantum)`.
|
||||||
|
- This is how the engine speeds up the next physics tick when we're
|
||||||
|
close to arrival, so we don't overshoot.
|
||||||
|
|
||||||
|
**ACE divergence note**: ACE swaps the chase/flee predicates (uses
|
||||||
|
`DistanceToObject` for chase arrival vs retail's `min_distance`). The retail
|
||||||
|
field naming and the physical meaning agree though — the arrival condition
|
||||||
|
is "distance shrunk past the threshold I asked for". `RemoteMoveToDriver.cs`
|
||||||
|
already follows retail correctly here (see its line 50-57 doc-comment).
|
||||||
|
|
||||||
|
### `MoveToManager::HandleTurnToHeading` @ 0x0052a0c0 (lines 307442-307517) — opcode 9 driver
|
||||||
|
|
||||||
|
1. If `current_command` isn't TurnLeft/TurnRight, fall through to
|
||||||
|
`BeginTurnToHeading` (re-pick direction).
|
||||||
|
2. Get current heading. Test
|
||||||
|
`heading_greater(curHeading, targetHeading, current_command)` —
|
||||||
|
"have we passed the target".
|
||||||
|
- If yes: snap heading via `CPhysicsObj::set_heading(physics_obj_1, target, true)`,
|
||||||
|
pop pending head, `_StopMotion(current_command)`, `current_command = 0`,
|
||||||
|
`BeginNextNode`.
|
||||||
|
3. Else compute `delta_per_tick = heading_diff(curHeading, previous_heading, current_command)`:
|
||||||
|
- If `delta_per_tick ≥ 180` (turning the wrong way through the long arc):
|
||||||
|
skip the no-progress increment.
|
||||||
|
- If `delta_per_tick ≥ 0.0002f`: progress → `fail_progress_count = 0`,
|
||||||
|
`previous_heading = curHeading`.
|
||||||
|
4. Else: `previous_heading = curHeading`, and if neither interpolating nor
|
||||||
|
motions pending: `fail_progress_count++`.
|
||||||
|
|
||||||
|
### `MoveToManager::CheckProgressMade(float dist)` @ 0x005290f0 (lines 306385-306431)
|
||||||
|
|
||||||
|
Implements the "moved ≥ 0.25 m/s averaged over the last 1 s AND the last sample-interval" test.
|
||||||
|
|
||||||
|
```c
|
||||||
|
double elapsed_since_last_sample = cur_time - previous_distance_time;
|
||||||
|
if (elapsed_since_last_sample > 1.0) {
|
||||||
|
float instantaneous_speed = moving_away ? (dist - previous_distance) : (previous_distance - dist);
|
||||||
|
instantaneous_speed /= elapsed_since_last_sample;
|
||||||
|
if (instantaneous_speed > 0.25f) {
|
||||||
|
previous_distance = dist;
|
||||||
|
previous_distance_time = cur_time;
|
||||||
|
// ALSO check long-window speed
|
||||||
|
float total = moving_away ? (dist - original_distance) : (original_distance - dist);
|
||||||
|
total /= (cur_time - original_distance_time);
|
||||||
|
if (total > 0.25f) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1; // not enough time elapsed yet to judge
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns 1 = "making progress, don't increment fail counter"; 0 = "stuck".
|
||||||
|
|
||||||
|
### `MoveToManager::CancelMoveTo(WeenieError)` @ 0x00529930 (lines 306886-306940)
|
||||||
|
|
||||||
|
Drains `pending_actions` (free each node), `CleanUp(this)`,
|
||||||
|
`StopCompletely(physics_obj)`. Then the WeenieError is sent up to the weenie
|
||||||
|
object via the parent's CleanUpAndCallWeenie path.
|
||||||
|
|
||||||
|
### `MoveToManager::CleanUp` @ 0x005295c0 (lines 306710-306736)
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (current_command != 0) _StopMotion(current_command, &local_params);
|
||||||
|
if (aux_command != 0) _StopMotion(aux_command, &local_params);
|
||||||
|
if (top_level_object_id != 0 && movement_type != Invalid)
|
||||||
|
CPhysicsObj::clear_target(physics_obj);
|
||||||
|
InitializeLocalVariables(this);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `MoveToManager::HandleUpdateTarget(TargetInfo)` @ 0x0052a7d0 (lines 307802-307867)
|
||||||
|
|
||||||
|
Server-driven callback. When a tracked target's position updates:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (top_level_object_id != arg2->object_id) return; // not our target
|
||||||
|
if (initialized == 0) { // first snapshot
|
||||||
|
if (top_level_object_id == physics_obj->id) { // tracked self → done
|
||||||
|
sought_position = physics_obj->m_position;
|
||||||
|
CleanUpAndCallWeenie(this, current_target_position = physics_obj->m_position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arg2->status != Ok) { // bad snapshot
|
||||||
|
CancelMoveTo(this, 0x38);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (movement_type == MoveToObject) // first valid snapshot → build queue
|
||||||
|
MoveToObject_Internal(this, &arg2->target_position, &arg2->interpolated_position);
|
||||||
|
else if (movement_type == TurnToObject)
|
||||||
|
TurnToObject_Internal(this, &arg2->target_position);
|
||||||
|
} else { // ongoing
|
||||||
|
if (arg2->status != Ok) { CancelMoveTo(0x37); return; }
|
||||||
|
if (movement_type == MoveToObject) {
|
||||||
|
sought_position = arg2->interpolated_position;
|
||||||
|
current_target_position = arg2->target_position;
|
||||||
|
// RESET progress windows — target moved, so the old samples don't count
|
||||||
|
previous_distance = +inf;
|
||||||
|
previous_distance_time = cur_time;
|
||||||
|
original_distance = +inf;
|
||||||
|
original_distance_time = cur_time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **the critical hookup for chase-AI**: every server `UpdateTarget`
|
||||||
|
shoves the pursuer's current_target forward and resets progress sampling.
|
||||||
|
|
||||||
|
### `MoveToManager::HitGround` @ 0x00529d70 (lines 307175-307183)
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (movement_type != Invalid)
|
||||||
|
BeginNextNode(this);
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger from the body's contact-restored event. Used by AI move sequences
|
||||||
|
that start mid-air (knockback recovery, falling onto a target).
|
||||||
|
|
||||||
|
### `MoveToManager::UseTime` @ 0x0052a780 (lines 307776-307798) — **the tick entry point**
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) { // Contact bit
|
||||||
|
head_ = pending_actions.head_;
|
||||||
|
if (head_ != nullptr &&
|
||||||
|
(top_level_object_id == 0 || movement_type == Invalid || initialized != 0)) {
|
||||||
|
if (head_->opcode == 7) tailcall HandleMoveToPosition(this);
|
||||||
|
if (head_->opcode == 9) tailcall HandleTurnToHeading(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Branch summary:**
|
||||||
|
- Must have Contact (touching ground).
|
||||||
|
- Must have a pending action.
|
||||||
|
- Either there's no tracked target, OR movement is Invalid, OR we've been
|
||||||
|
initialized (got the first target snapshot). I.e., for `MoveToObject`,
|
||||||
|
`UseTime` is a no-op until `HandleUpdateTarget` flips `initialized=1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MovementParameters
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
`__inner0` is a packed bitfield (uint32, but the meaningful flags live in the
|
||||||
|
low 16 bits — we see `(int16_t)__inner0`). Confirmed bits:
|
||||||
|
|
||||||
|
| Bit | Mask | ACE name | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | 0x0001 | CanWalk | Allow WalkForward speed. |
|
||||||
|
| 1 | 0x0002 | CanRun | Allow RunForward speed. |
|
||||||
|
| 2 | 0x0004 | CanSidestep | |
|
||||||
|
| 3 | 0x0008 | CanWalkBackwards | |
|
||||||
|
| 4 | 0x0010 | CanCharge | (Holds key to apply) |
|
||||||
|
| 5 | 0x0020 | FailWalk | |
|
||||||
|
| 6 | 0x0040 | UseFinalHeading | Append `TurnToHeading(desired_heading)` after MoveToPosition. (Line 307571.) |
|
||||||
|
| 7 | 0x0080 | Sticky | After arrival, `StickTo(top_level_object_id, ...)` instead of stopping. (Line 307145 reads this as the high bit of `__inner0` low-byte and treats `< 0` as "set".) |
|
||||||
|
| 8 | 0x0100 | MoveAway | Used by `towards_and_away` to flip arrival/turn-direction conventions. |
|
||||||
|
| 9 | 0x0200 | MoveTowards | |
|
||||||
|
| 10 | 0x0400 | UseSpheres | |
|
||||||
|
| 11 | 0x0800 | SetHoldKey | |
|
||||||
|
| 12 | 0x1000 | Autonomous | |
|
||||||
|
| 13 | 0x2000 | ModifyRawState | |
|
||||||
|
| 14 | 0x4000 | ModifyInterpretedState | |
|
||||||
|
| 15 | 0x8000 | CancelMoveTo | line 307208 `& 0xffff7fff` — mask out before sending to inner motion. |
|
||||||
|
| 16 | 0x10000 | StopCompletely | |
|
||||||
|
| 17 | 0x20000 | DisableJumpDuringLink | |
|
||||||
|
|
||||||
|
(Bit 7 ≥ "stick after arrive" is the load-bearing one for sticky-from-MoveTo; bit 6
|
||||||
|
is load-bearing for "turn to face X after arriving".)
|
||||||
|
|
||||||
|
### `MovementParameters::UnPackNet(MovementType type, void** stream, uint32_t bytes)` @ 0x0052ac50 (lines 308118-308190)
|
||||||
|
|
||||||
|
The wire format the **client receives** (vs `Pack`/`UnPack` which is the local-save format).
|
||||||
|
|
||||||
|
```c
|
||||||
|
size_required = (type == MoveToObject || type == MoveToPosition) ? 0x1c : 0x0c;
|
||||||
|
if (bytes < size_required || (type - 6) > 3) return 0;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case MoveToObject:
|
||||||
|
case MoveToPosition:
|
||||||
|
// 0x1c bytes: __inner0, distance_to_object, min_distance, fail_distance, speed, walk_run_threshold, desired_heading
|
||||||
|
this->__inner0 = read_uint32();
|
||||||
|
this->distance_to_object = read_float();
|
||||||
|
this->min_distance = read_float();
|
||||||
|
this->fail_distance = read_float();
|
||||||
|
this->speed = read_float();
|
||||||
|
this->walk_run_threshhold= read_float();
|
||||||
|
// desired_heading written below (shared)
|
||||||
|
break;
|
||||||
|
case TurnToObject:
|
||||||
|
case TurnToHeading:
|
||||||
|
// 0x0c bytes: __inner0, speed, desired_heading
|
||||||
|
this->__inner0 = read_uint32();
|
||||||
|
this->speed = read_float();
|
||||||
|
// desired_heading written below
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this->desired_heading = read_float();
|
||||||
|
return 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: **UnPackNet is shorter than UnPack** (0x1c/0x0c vs 0x28). The wire format
|
||||||
|
omits `context_id`, `hold_key_to_apply`, and `action_stamp` — those are
|
||||||
|
local-only (server tracks them, doesn't ship them). Compare to the full
|
||||||
|
`UnPack` @ 0x0052abc0 which reads all 9 fields × 4 bytes = 0x28 bytes.
|
||||||
|
|
||||||
|
### `MovementParameters::get_command(dist, heading_delta, &cmd, &holdKey, &movingAway)` @ 0x0052aa00 (lines 307946-308012)
|
||||||
|
|
||||||
|
Decides which motion-command to issue based on flags + distance.
|
||||||
|
|
||||||
|
Pseudocode (cleaner than the FP-mangled extract, validated against ACE port):
|
||||||
|
|
||||||
|
```c
|
||||||
|
inner0 = this->__inner0;
|
||||||
|
if (inner0 & 0x0200) { // MoveTowards
|
||||||
|
if (inner0 & 0x0100) // MoveTowards AND MoveAway → use towards_and_away
|
||||||
|
towards_and_away(this, dist, heading, &cmd, &movingAway);
|
||||||
|
else if (dist > distance_to_object) { // Towards-only, still far → walk forward
|
||||||
|
cmd = 0x45000005; // WalkForward
|
||||||
|
movingAway = 0;
|
||||||
|
} else cmd = 0;
|
||||||
|
} else if (inner0 & 0x0100) { // MoveAway only
|
||||||
|
if (dist - min_distance < EPSILON) { // too close → walk back
|
||||||
|
cmd = 0x45000005;
|
||||||
|
movingAway = 1;
|
||||||
|
} else cmd = 0;
|
||||||
|
} else { // neither
|
||||||
|
if (dist > distance_to_object) {
|
||||||
|
cmd = 0x45000005; movingAway = 0;
|
||||||
|
} else cmd = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold key: pick Run vs None based on CanRun, CanWalk, walk_run_threshhold
|
||||||
|
if (inner0 & 0x10) { // CanCharge / SetHoldKey route
|
||||||
|
*holdKey = HoldKey_Run;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((inner0 & 0x02) == 0) { // !CanRun
|
||||||
|
*holdKey = HoldKey_None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inner0 & 0x01) { // CanWalk
|
||||||
|
if (dist - distance_to_object <= walk_run_threshhold) {
|
||||||
|
*holdKey = HoldKey_None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*holdKey = HoldKey_Run;
|
||||||
|
```
|
||||||
|
|
||||||
|
Magic numbers:
|
||||||
|
- `0x45000005` = WalkForward command
|
||||||
|
- `0x45000006` = WalkBackwards (used by `towards_and_away` when fleeing too close)
|
||||||
|
- `0x44000007` = RunForward (matched in `get_desired_heading`)
|
||||||
|
|
||||||
|
### `MovementParameters::get_desired_heading(motion, movingAway)` @ 0x0052aad0 (lines 308016-308033)
|
||||||
|
|
||||||
|
Returns 0 (chase forward) / 180 (chase backward / flee forward) / arg3 (other).
|
||||||
|
Used by `HandleMoveToPosition` to compute the desired heading offset from the
|
||||||
|
world-heading-to-target.
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (motion == 0x44000007 || motion == 0x45000005) return arg3; // forward → arg3=0 → face target
|
||||||
|
if (motion == 0x45000006) return arg3; // backward → arg3=180 → face target
|
||||||
|
return motion - 0x45000006; // (sentinel, only hit for non-forward/back)
|
||||||
|
```
|
||||||
|
|
||||||
|
(ACE port lines 186-198 hardcode this as `movingAway ? 180 : 0` for the
|
||||||
|
forward case, which is the same algebra after substituting in the actual
|
||||||
|
arg3 values the callers pass.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Interaction with PositionManager / InterpolationManager
|
||||||
|
|
||||||
|
### `PositionManager::adjust_offset(Frame*, double quantum)` @ 0x00555190 (lines 352090-352118)
|
||||||
|
|
||||||
|
Calls all three managers in sequence:
|
||||||
|
|
||||||
|
```c
|
||||||
|
1. interpolation_manager->adjust_offset(arg2, quantum); // smooths server → local position over a window
|
||||||
|
2. sticky_manager->adjust_offset(arg2, quantum); // stick to a target
|
||||||
|
3. constraint_manager->adjust_offset(arg2, quantum); // clamp to a leash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Order matters**:
|
||||||
|
- InterpolationManager runs first, baking server-driven catch-up into the offset.
|
||||||
|
- StickyManager then **reads from physics_obj's m_position** (which is already
|
||||||
|
interpolated by the previous step's commit — the offset hasn't been applied
|
||||||
|
yet, but m_position reflects last frame's solved state). Sticky overwrites
|
||||||
|
the offset if it has a target.
|
||||||
|
- ConstraintManager comes last, scaling-down or zeroing whatever the others
|
||||||
|
produced if it would push us past a leash radius.
|
||||||
|
|
||||||
|
Each manager **reads** from `physics_obj->m_position` and **writes** the
|
||||||
|
per-tick offset (translation + rotation delta) into `arg2`. The caller then
|
||||||
|
composes that offset onto `physics_obj->m_position` for the actual move.
|
||||||
|
|
||||||
|
### MoveToManager interaction
|
||||||
|
|
||||||
|
MoveToManager **does not** participate in `PositionManager::adjust_offset`. It
|
||||||
|
runs once per tick from a different entry point (`UseTime`, called from the
|
||||||
|
physics scheduler) and **issues motion commands** to `CMotionInterp` via
|
||||||
|
`_DoMotion` / `_StopMotion`. The body's velocity comes from
|
||||||
|
`CMotionInterp::apply_current_movement`, not from MoveToManager directly.
|
||||||
|
|
||||||
|
So the layering is:
|
||||||
|
1. Pending nodes → `_DoMotion(MoveTo*)` → `CMotionInterp::DoInterpretedMotion(RunForward + HoldKey.Run)`
|
||||||
|
2. `CMotionInterp` writes `InterpretedState.ForwardCommand=RunForward, HoldKey=Run`
|
||||||
|
3. Each tick, `apply_current_movement` reads InterpretedState and emits a body velocity
|
||||||
|
4. PositionManager (Interp+Sticky+Constraint) post-modifies the per-tick offset
|
||||||
|
|
||||||
|
When sticky activates from MoveToManager (after arrival), MoveToManager calls
|
||||||
|
`PositionManager::StickTo`, which creates the sticky and from then on the
|
||||||
|
sticky's `adjust_offset` overrides the body's natural velocity each tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Differences vs acdream `RemoteMoveToDriver.cs`
|
||||||
|
|
||||||
|
acdream's current port is intentionally minimal (header comment lines 44-57).
|
||||||
|
What it **DOES** correctly:
|
||||||
|
- Heading delta with 20° snap tolerance — line 307255-307287 of retail.
|
||||||
|
- Arrival predicate via `min_distance` for chase / `distance_to_object` for flee
|
||||||
|
— matches retail (lines 307309/307323), explicitly diverges from ACE.
|
||||||
|
- Stale-destination giveup at 1.5 s (acdream-specific safety net for our
|
||||||
|
streaming model).
|
||||||
|
|
||||||
|
What it **OMITS** vs retail (acceptable for a remote-observer of a server-
|
||||||
|
authored creature):
|
||||||
|
- Pending-action queue (TurnToHeading → MoveToPosition → final TurnToHeading).
|
||||||
|
Server re-emits the move; we don't need to schedule sub-nodes.
|
||||||
|
- Sticky-after-arrive (`__inner0 & 0x0080`). Server signals stick separately
|
||||||
|
via `PositionManager::StickTo` calls or via re-emitted MoveTos.
|
||||||
|
- `CheckProgressMade` / `fail_progress_count`. Server-side concern; if the
|
||||||
|
remote AI gives up, the server just stops sending MoveTo updates.
|
||||||
|
- `set_target_quantum` adaptive tick rate. We run at fixed 60 Hz.
|
||||||
|
- `HandleUpdateTarget` re-tracking. Server re-emits the full MoveTo when its
|
||||||
|
target moves; we re-parse and re-init.
|
||||||
|
- ConstraintManager and `transient_state & Contact` gating. We don't have a
|
||||||
|
real contact-plane test on remotes (we only do collision for the local
|
||||||
|
player). For remotes, we always assume "on ground".
|
||||||
|
- StickyManager altogether — there's no scenario where a remote needs to
|
||||||
|
follow a target the server hasn't already told us to face via UpdateMotion.
|
||||||
|
|
||||||
|
What's a **real port gap** worth filing for L.3 follow-up:
|
||||||
|
- `MovementParameters::__inner0` flag bit 0x40 (`UseFinalHeading`) — when the
|
||||||
|
packet's final-heading bit is set, the body should rotate to face
|
||||||
|
`desired_heading` after arrival. We currently ignore this and the remote
|
||||||
|
ends in whatever heading the last steering tick produced.
|
||||||
|
- `MovementParameters::__inner0` flag bit 0x80 (Sticky-after-arrive) — when
|
||||||
|
set, we should latch onto `top_level_object_id` instead of going idle. For
|
||||||
|
an adventuring monster following the player, this matters: today we'd let
|
||||||
|
the remote stand still for ~1 s (server's MoveTo re-emit cadence) then
|
||||||
|
steer again, instead of locking on smoothly. Worth filing as `#L.X`.
|
||||||
|
- `transient_state & 1` (Contact) gating. If a remote is mid-air (knocked
|
||||||
|
back, jumping), `RemoteMoveToDriver` shouldn't drive horizontal motion
|
||||||
|
toward the destination. We currently steer regardless of grounded state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: full retail line-citation index (this doc only)
|
||||||
|
|
||||||
|
| Function | Address | Pseudo-C lines |
|
||||||
|
|---|---|---|
|
||||||
|
| StickyManager::Create | 0x00555800 | 352620-352633 |
|
||||||
|
| StickyManager::Destroy | 0x00555650 | 352521-352540 |
|
||||||
|
| StickyManager::SetPhysicsObject | 0x005556e0 | 352544-352555 |
|
||||||
|
| StickyManager::StickTo | 0x00555710 | 352559-352578 |
|
||||||
|
| StickyManager::HandleUpdateTarget | 0x00555780 | 352582-352607 |
|
||||||
|
| StickyManager::UnStick | 0x00555400 | 352335-352346 |
|
||||||
|
| StickyManager::adjust_offset | 0x00555430 | 352351-352494 |
|
||||||
|
| StickyManager::UseTime | 0x00555610 | 352498-352517 |
|
||||||
|
| StickyManager::~StickyManager | 0x005557e0 | 352611-352616 |
|
||||||
|
| ConstraintManager::Create | 0x00556110 | 353442-353474 |
|
||||||
|
| ConstraintManager::SetPhysicsObject | 0x00556090 | 353388-353401 |
|
||||||
|
| ConstraintManager::ConstrainTo | 0x00556240 | 353528-353537 |
|
||||||
|
| ConstraintManager::UnConstrain | 0x005560c0 | 353405-353409 |
|
||||||
|
| ConstraintManager::IsFullyConstrained | 0x005560d0 | 353413-353427 |
|
||||||
|
| ConstraintManager::adjust_offset | 0x00556180 | 353479-353524 |
|
||||||
|
| ConstraintManager::~ConstraintManager | 0x005560f0 | 353431-353438 |
|
||||||
|
| MoveToManager::MoveToManager | 0x005293b0 | 306554-306593 |
|
||||||
|
| MoveToManager::Create | 0x00529470 | 306597-306614 |
|
||||||
|
| MoveToManager::Destroy | 0x005294b0 | 306618-306663 |
|
||||||
|
| MoveToManager::InitializeLocalVariables | 0x00529250 | 306490-306534 |
|
||||||
|
| MoveToManager::PerformMovement | 0x0052a900 | 307871-307904 |
|
||||||
|
| MoveToManager::MoveToObject | 0x00529680 | 306756-306817 |
|
||||||
|
| MoveToManager::TurnToObject | 0x005297d0 | 306820-306882 |
|
||||||
|
| MoveToManager::MoveToPosition | 0x0052a240 | 307521-307593 |
|
||||||
|
| MoveToManager::TurnToHeading | 0x0052a630 | 307706-307772 |
|
||||||
|
| MoveToManager::MoveToObject_Internal | 0x0052a400 | 307597-307663 |
|
||||||
|
| MoveToManager::TurnToObject_Internal | 0x0052a550 | 307667-307702 |
|
||||||
|
| MoveToManager::AddTurnToHeadingNode | 0x00529530 | 306667-306685 |
|
||||||
|
| MoveToManager::AddMoveToPositionNode | 0x00529580 | 306689-306706 |
|
||||||
|
| MoveToManager::RemovePendingActionsHead | 0x00529380 | 306538-306550 |
|
||||||
|
| MoveToManager::CleanUp | 0x005295c0 | 306710-306736 |
|
||||||
|
| MoveToManager::CleanUpAndCallWeenie | 0x00529650 | 306740-306752 |
|
||||||
|
| MoveToManager::CancelMoveTo | 0x00529930 | 306886-306940 |
|
||||||
|
| MoveToManager::~MoveToManager | 0x005299d0 | 306945-306953 |
|
||||||
|
| MoveToManager::BeginMoveForward | 0x00529a00 | 306957-307042 |
|
||||||
|
| MoveToManager::BeginTurnToHeading | 0x00529b90 | 307046-307120 |
|
||||||
|
| MoveToManager::BeginNextNode | 0x00529cb0 | 307123-307171 |
|
||||||
|
| MoveToManager::HitGround | 0x00529d70 | 307175-307183 |
|
||||||
|
| MoveToManager::HandleMoveToPosition | 0x00529d80 | 307187-307438 |
|
||||||
|
| MoveToManager::HandleTurnToHeading | 0x0052a0c0 | 307442-307517 |
|
||||||
|
| MoveToManager::UseTime | 0x0052a780 | 307776-307798 |
|
||||||
|
| MoveToManager::HandleUpdateTarget | 0x0052a7d0 | 307802-307867 |
|
||||||
|
| MoveToManager::CheckProgressMade | 0x005290f0 | 306385-306431 |
|
||||||
|
| MoveToManager::GetCurrentDistance | 0x005291b0 | 306435-306460 |
|
||||||
|
| MoveToManager::is_moving_to | 0x00529220 | 306464-306470 |
|
||||||
|
| MoveToManager::_DoMotion | 0x00529010 | 306351-306364 |
|
||||||
|
| MoveToManager::_StopMotion | 0x00529080 | 306368-306381 |
|
||||||
|
| MovementParameters::towards_and_away | 0x0052a9a0 | 307917-307942 |
|
||||||
|
| MovementParameters::get_command | 0x0052aa00 | 307946-308012 |
|
||||||
|
| MovementParameters::get_desired_heading | 0x0052aad0 | 308016-308033 |
|
||||||
|
| MovementParameters::Pack | 0x0052ab20 | 308037-308074 |
|
||||||
|
| MovementParameters::UnPack | 0x0052abc0 | 308078-308114 |
|
||||||
|
| MovementParameters::UnPackNet | 0x0052ac50 | 308118-308190 |
|
||||||
|
|
@ -0,0 +1,824 @@
|
||||||
|
# L.3 port — `update_object` substepping + `Frame` operations
|
||||||
|
|
||||||
|
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decompilation, BinaryNinja pseudo-C). Cross-checked against ACE's port (`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`, `PhysicsGlobals.cs`).
|
||||||
|
|
||||||
|
This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:
|
||||||
|
|
||||||
|
- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
|
||||||
|
- What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
|
||||||
|
- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop)
|
||||||
|
- What does `process_hooks` do? (iterates linked-list of `PhysicsObjHook`s + `anim_hooks` per frame, executing & removing finished ones)
|
||||||
|
- `Frame::combine` semantics: `out = a · b` — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product `a.q * b.q` for orientation).
|
||||||
|
|
||||||
|
The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some immediate floats to `0.0`. The constants are recovered cleanly from ACE's `PhysicsGlobals.cs`, which itself is a faithful port of the same retail binary. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from `MinQuantum`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 — `CPhysicsObj::update_object` (FUN_00515D10) — main per-frame entry
|
||||||
|
|
||||||
|
**Signature:** `void __fastcall CPhysicsObj::update_object(CPhysicsObj* this)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:283950-284055`
|
||||||
|
|
||||||
|
### Verbatim relevant pseudo-C (lines 283950-284055)
|
||||||
|
|
||||||
|
```
|
||||||
|
00515d10 void __fastcall CPhysicsObj::update_object(class CPhysicsObj* this) {
|
||||||
|
|
||||||
|
// Bail-out 1: parented (held by another obj), no cell, or hidden (state & 0x1000000)
|
||||||
|
if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) {
|
||||||
|
this->transient_state &= 0xffffff7f; // clear "active" flag
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player-distance update: if a player object exists, compute offset and
|
||||||
|
// toggle "active" transient flag based on 96.0f distance gate.
|
||||||
|
CPhysicsObj* player = CPhysicsObj::player_object;
|
||||||
|
if (player != 0) {
|
||||||
|
Vector3 offset;
|
||||||
|
Position::get_offset(&player->m_position, &offset, &this->m_position);
|
||||||
|
this->player_vector = offset;
|
||||||
|
this->player_distance = sqrtf(offset.x*offset.x + offset.y*offset.y + offset.z*offset.z);
|
||||||
|
// [actually plain |offset.x| in BN noise; ACE uses .Length()]
|
||||||
|
if (this->player_distance >= 96.0f) {
|
||||||
|
// beyond the active radius; deactivate
|
||||||
|
this = CPhysicsObj::obj_maint; // this overwritten — BN noise
|
||||||
|
}
|
||||||
|
if (this->player_distance >= 96.0f || this->part_array == 0) {
|
||||||
|
this = this_3;
|
||||||
|
CPhysicsObj::set_active(this, 1);
|
||||||
|
} else {
|
||||||
|
this_3->transient_state &= 0xffffff7f; // clear active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dt computation ────────────────────────────────────────────────────────
|
||||||
|
double dt = Timer::cur_time - this_3->update_time;
|
||||||
|
PhysicsTimer::curr_time = this_3->update_time; // seed phys clock with this obj's last-update
|
||||||
|
|
||||||
|
// ── Guard 1: dt < EPSILON (0.000199999995f ≈ 0.0002 s) ────────────────────
|
||||||
|
// Retail tolerance for "essentially zero" — NOT MinQuantum.
|
||||||
|
// If dt < EPSILON, bump update_time and return without any simulation.
|
||||||
|
if (dt < 0.000199999995f) { // line 283996
|
||||||
|
this_3->update_time = Timer::cur_time;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Guard 2: dt > HugeQuantum (2.0 s) — discard stale dt ─────────────────
|
||||||
|
// (Constant 2.0 visible at line 284009 — "long double temp1 = 2.0;")
|
||||||
|
if (dt > 2.0) {
|
||||||
|
this_3->update_time = Timer::cur_time;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
|
||||||
|
// BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure
|
||||||
|
// is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1.
|
||||||
|
if (dt > 0.0f /* MaxQuantum=0.1 */) {
|
||||||
|
do {
|
||||||
|
PhysicsTimer::curr_time += /* MaxQuantum */ 0.1;
|
||||||
|
CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f);
|
||||||
|
dt -= /* MaxQuantum */ 0.1;
|
||||||
|
} while (dt > /* MaxQuantum */ 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ────
|
||||||
|
// BN: line 284046 "if (!(p_1) || ... > 0.0) { ... UpdateObjectInternal(remainder)
|
||||||
|
// }" — the comparison constant should be MinQuantum=1/30=0.0333f per ACE.
|
||||||
|
if (dt > /* MinQuantum */ 0.0333f) {
|
||||||
|
PhysicsTimer::curr_time += dt;
|
||||||
|
CPhysicsObj::UpdateObjectInternal(this_3, (float)dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance update_time to the consumed phys clock time.
|
||||||
|
this_3->update_time = PhysicsTimer::curr_time;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants — recovered values
|
||||||
|
|
||||||
|
From `references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43`:
|
||||||
|
|
||||||
|
| Symbol | Hex (float32) | Value | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) |
|
||||||
|
| `MinQuantum` | `0x3D088889` | `1.0f / 30.0f ≈ 0.03333` s (30 fps) | minimum simulation step |
|
||||||
|
| `MaxQuantum` | `0x3DCCCCCD` | `0.1f` (10 fps) | substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE |
|
||||||
|
| `HugeQuantum` | `0x40000000` | `2.0f` (0.5 fps) | upper bound — beyond this, dt is discarded as stale (visible line 284009) |
|
||||||
|
|
||||||
|
**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail clearly reads non-zero immediates from `.rdata`. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard.
|
||||||
|
|
||||||
|
### `update_object_server` — does it exist?
|
||||||
|
|
||||||
|
**No.** Search of `acclient_2013_pseudo_c.txt` for `update_object_server` returns zero hits. There is only `CPhysicsObj::update_object` (the per-frame driver) and `CPhysicsObj::UpdateObjectInternal` (the per-substep worker). ACE introduced server-side variants that don't exist in retail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 — `CPhysicsObj::UpdateObjectInternal` (FUN_005156B0) — per-substep worker
|
||||||
|
|
||||||
|
**Signature:** `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float arg2)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:283611-283757`
|
||||||
|
|
||||||
|
This is the function called once per substep with `arg2 = dt` (≤ MaxQuantum). Two main branches based on `transient_state` sign bit (which is `Active`, 0x80):
|
||||||
|
|
||||||
|
```
|
||||||
|
005156b0 void UpdateObjectInternal(CPhysicsObj* this, float arg2) {
|
||||||
|
|
||||||
|
// Branch A: obj is INACTIVE (transient_state >= 0, i.e. high bit clear).
|
||||||
|
// Just tick particles + scripts; no movement.
|
||||||
|
if ((int16_t)this->transient_state >= 0) goto label_5159b8;
|
||||||
|
|
||||||
|
// Branch B: obj is ACTIVE.
|
||||||
|
if (this->cell == 0) return;
|
||||||
|
|
||||||
|
// ── Active-mover path ───────────────────────────────────────────────────
|
||||||
|
if ((this->transient_state & 0x100) != 0) // line 283631 — clears Sticky
|
||||||
|
CPhysicsObj::set_ethereal(this, 0, 0);
|
||||||
|
this->jumped_this_frame = 0;
|
||||||
|
|
||||||
|
// Build a local Frame (stack-allocated, identity quaternion).
|
||||||
|
Position offsetPos = { objcell_id=0x796910, qw=1, qx=0,qy=0,qz=0,
|
||||||
|
origin={0,0,0} };
|
||||||
|
Frame offsetFrame; // stack
|
||||||
|
Frame::cache(&offsetFrame); // line 283644
|
||||||
|
|
||||||
|
uint32_t cellId = this->m_position.objcell_id;
|
||||||
|
|
||||||
|
// ── 1) UpdatePositionInternal: integrates velocity/accel into offsetFrame
|
||||||
|
st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &offsetFrame);
|
||||||
|
// line 283646
|
||||||
|
|
||||||
|
CPartArray* parts = this->part_array;
|
||||||
|
uint32_t numSpheres = parts ? CPartArray::GetNumSphere(parts) : 0;
|
||||||
|
|
||||||
|
if (parts != 0 && numSpheres != 0) {
|
||||||
|
if (Vector3::operator==(&offsetFrame.origin, &this->m_position.frame.origin) == 0) {
|
||||||
|
// origin moved — need a transition (collision sweep)
|
||||||
|
uint32_t state = this->state;
|
||||||
|
if ((state & 0x100) != 0) { // line 283661
|
||||||
|
// facing-velocity heading mode
|
||||||
|
Vector3 dir;
|
||||||
|
AC1Legacy::Vector3::operator-(&offsetFrame.origin, &dir,
|
||||||
|
&this->m_position.frame.origin);
|
||||||
|
Vector3::Normalize(&dir);
|
||||||
|
Frame::set_vector_heading(&offsetFrame, &dir);
|
||||||
|
}
|
||||||
|
else if ((state & "activation type (%s) with '%s' b…" /* a high state-bit */) != 0
|
||||||
|
&& AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0) {
|
||||||
|
float heading = AC1Legacy::Vector3::get_heading(&this->m_velocityVector);
|
||||||
|
Frame::set_heading(&offsetFrame, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COLLISION SWEEP — port of FUN_005148A0 / Transition::FindTransitional… ──
|
||||||
|
CTransition* tx = CPhysicsObj::transition(this, &this->m_position,
|
||||||
|
&offsetPos /* desired */,
|
||||||
|
/*flags*/ 0);
|
||||||
|
|
||||||
|
if (tx == 0) {
|
||||||
|
// sweep failed — keep current position, snap to offsetFrame, zero velocity
|
||||||
|
CPhysicsObj::set_frame(this, &offsetFrame);
|
||||||
|
this->cached_velocity = {0,0,0};
|
||||||
|
} else {
|
||||||
|
// sweep succeeded — measured velocity = (curr_pos - new_pos)/dt
|
||||||
|
Vector3 deltaPos;
|
||||||
|
Position::get_offset(&this->m_position, &deltaPos, &tx->sphere_path.curr_pos);
|
||||||
|
Vector3 measuredVel;
|
||||||
|
Vector3::operator/(&deltaPos, &measuredVel, arg2);
|
||||||
|
this->cached_velocity = measuredVel;
|
||||||
|
CPhysicsObj::SetPositionInternal(this, tx); // commits new pos+cell
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// origin didn't move — just set frame and clear velocity
|
||||||
|
CPhysicsObj::set_frame(this, &offsetFrame);
|
||||||
|
this->cached_velocity = {0,0,0};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No part_array or no spheres — clear "stationary fall" flag if free, set frame, clear velocity
|
||||||
|
if (this->movement_manager == 0) {
|
||||||
|
uint32_t ts = this->transient_state;
|
||||||
|
if ((ts & 2) != 0) this->transient_state = ts & 0xffffff7f;
|
||||||
|
}
|
||||||
|
CPhysicsObj::set_frame(this, &offsetFrame);
|
||||||
|
this->cached_velocity = {0,0,0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) Per-frame ticks (managers + parts + position interp) ─────────────
|
||||||
|
if (this->detection_manager != 0)
|
||||||
|
DetectionManager::CheckDetection(this->detection_manager);
|
||||||
|
if (this->target_manager != 0)
|
||||||
|
TargetManager::HandleTargetting(this->target_manager);
|
||||||
|
if (this->movement_manager != 0)
|
||||||
|
MovementManager::UseTime(this->movement_manager); // animation tick
|
||||||
|
if (this->part_array != 0)
|
||||||
|
CPartArray::HandleMovement(this->part_array);
|
||||||
|
if (this->position_manager != 0)
|
||||||
|
PositionManager::UseTime(this->position_manager);
|
||||||
|
|
||||||
|
label_5159b8:
|
||||||
|
// ── 3) Particles + scripts (always, both Active and Inactive branches) ──
|
||||||
|
if (this->particle_manager != 0)
|
||||||
|
ParticleManager::UpdateParticles(this->particle_manager);
|
||||||
|
if (this->script_manager != 0)
|
||||||
|
ScriptManager::UpdateScripts(this->script_manager);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key sequencing per substep:**
|
||||||
|
|
||||||
|
1. Build identity local `Frame` (`offsetFrame`).
|
||||||
|
2. `UpdatePositionInternal(this, dt, &offsetFrame)` — integrate motion into the frame.
|
||||||
|
3. If origin changed and we have collidable spheres → run `transition()` (collision sweep).
|
||||||
|
4. If sweep succeeds → commit via `SetPositionInternal`; cached_velocity = (deltaPos / dt).
|
||||||
|
5. If sweep fails → snap to `offsetFrame` directly; zero velocity.
|
||||||
|
6. Tick managers (Detection, Target, Movement, PositionManager).
|
||||||
|
7. Tick CPartArray::HandleMovement (per-part frame propagation).
|
||||||
|
8. Tick particle_manager + script_manager.
|
||||||
|
|
||||||
|
`process_hooks` is NOT called here — it lives inside `UpdatePositionInternal` (see §3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 — `CPhysicsObj::UpdatePositionInternal` (FUN_00512C30)
|
||||||
|
|
||||||
|
**Signature:** `void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:280817-280866`
|
||||||
|
|
||||||
|
```
|
||||||
|
00512c30 void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame) {
|
||||||
|
|
||||||
|
// ── Step A: tabula-rasa local frame, zero translation ───────────────────
|
||||||
|
Frame localFrame;
|
||||||
|
localFrame.qw=1, localFrame.qx=0,qy=0,qz=0;
|
||||||
|
localFrame.origin = {0,0,0};
|
||||||
|
Frame::cache(&localFrame); // build l2gv basis from quat
|
||||||
|
|
||||||
|
// ── Step B: animation drives a delta-frame into localFrame ──────────────
|
||||||
|
// (Skipped if state & 0x4000 / "static decoration" bit.)
|
||||||
|
if ((this->state & 0x4000) == 0) {
|
||||||
|
if (this->part_array != 0) {
|
||||||
|
// CPartArray::Update walks the AnimSequencer, applies animFrame deltas,
|
||||||
|
// adds them onto var_c/var_8/var_4 (the local origin). It also pulls
|
||||||
|
// OmegaVector and applies it into the quaternion. After this returns,
|
||||||
|
// localFrame.origin holds the local-frame velocity*dt + omega-rotation.
|
||||||
|
CPartArray::Update(this->part_array, dt, &localFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale by m_scale if Sticky flag is set (riding a moving platform).
|
||||||
|
// Otherwise zero the local-origin (just keep rotation).
|
||||||
|
if ((this->transient_state & 2) /* HasContact */ == 0) {
|
||||||
|
localFrame.origin *= 0.0f; // zero translation
|
||||||
|
} else {
|
||||||
|
localFrame.origin *= this->m_scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step C: apply position_manager interpolation offset (smooth catch-up)
|
||||||
|
if (this->position_manager != 0)
|
||||||
|
PositionManager::adjust_offset(this->position_manager, &localFrame, dt);
|
||||||
|
|
||||||
|
// ── Step D: COMBINE — outFrame = m_position.frame * localFrame ──────────
|
||||||
|
// This rotates localFrame.origin by m_position.frame's basis, adds m_position.frame's
|
||||||
|
// origin, multiplies the quaternions: outFrame.q = m_position.q * localFrame.q.
|
||||||
|
Frame::combine(outFrame, &this->m_position.frame, &localFrame); // line 280860
|
||||||
|
|
||||||
|
// ── Step E: if not "static decoration", run physics (gravity, friction…)
|
||||||
|
if ((this->state & 0x4000) == 0)
|
||||||
|
CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame);
|
||||||
|
|
||||||
|
// ── Step F: dispatch hooks (per-frame scripted callbacks + anim hooks) ──
|
||||||
|
CPhysicsObj::process_hooks(this); // line 280865
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the function that produces the **desired post-tick world frame** in `outFrame`. The caller (`UpdateObjectInternal`) then routes that through the collision sweep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 — `CPhysicsObj::process_hooks` (FUN_00511550)
|
||||||
|
|
||||||
|
**Signature:** `void __fastcall CPhysicsObj::process_hooks(CPhysicsObj* this)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:279431-279486`
|
||||||
|
|
||||||
|
```
|
||||||
|
00511550 void process_hooks(CPhysicsObj* this) {
|
||||||
|
|
||||||
|
// ── Linked-list hooks (vtable->Execute) ─────────────────────────────────
|
||||||
|
// PhysicsObjHook is a polymorphic interface (translucency-fade, scale-fade,
|
||||||
|
// visibility-fade, FPHook, etc). When Execute returns nonzero, the hook is
|
||||||
|
// "done" — unlink and delete it.
|
||||||
|
PhysicsObjHook* h = this->hooks;
|
||||||
|
while (h != 0) {
|
||||||
|
PhysicsObjHook* next = h->next;
|
||||||
|
if (h->vtable->Execute(this) != 0) {
|
||||||
|
// unlink h from doubly-linked list
|
||||||
|
if (h->next != 0) h->next->prev = h->prev;
|
||||||
|
if (h->prev == 0) this->hooks = h->next;
|
||||||
|
else h->prev->next = h->next;
|
||||||
|
h->prev = h->next = 0;
|
||||||
|
h->vtable = (vtable_t*)0x7c6b20; // PhysicsObjHook base vtable
|
||||||
|
operator delete(h);
|
||||||
|
}
|
||||||
|
h = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anim hooks (one-shot bag, executed and cleared every frame) ─────────
|
||||||
|
uint32_t n = this->anim_hooks.m_num;
|
||||||
|
if (n > 0) {
|
||||||
|
for (uint32_t i = 0; i < this->anim_hooks.m_num; i++)
|
||||||
|
this->anim_hooks.m_data[i]->vtable->Execute(this);
|
||||||
|
AC1Legacy::SmartArray<CAnimHook*>::shrink(&this->anim_hooks);
|
||||||
|
this->anim_hooks.m_num = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
|
||||||
|
- `hooks` (linked list): persistent-until-done callbacks (translucency lerp, scale lerp, FPHook for fade events, etc). Each `Execute` returns done=1 → delete.
|
||||||
|
- `anim_hooks` (`SmartArray<CAnimHook*>`): one-shot per-frame anim events (sound triggers, particle spawns, attack-frame markers fired by AnimationSequencer). Always cleared every frame.
|
||||||
|
|
||||||
|
This is invoked once per substep at the END of `UpdatePositionInternal`. acdream's port has separate routers (`AnimationHookRouter`, `AnimationCommandRouter`) but no equivalent of the persistent `PhysicsObjHook` linked list yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 — `CPhysicsObj::calc_acceleration` (FUN_00510950)
|
||||||
|
|
||||||
|
**Signature:** `void __fastcall CPhysicsObj::calc_acceleration(CPhysicsObj* this)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:278533-278560`
|
||||||
|
|
||||||
|
```
|
||||||
|
00510950 void calc_acceleration(CPhysicsObj* this) {
|
||||||
|
uint8_t ts = (int8_t)this->transient_state;
|
||||||
|
|
||||||
|
// Special case: Active + HasContact + state-bit-?? → freeze (zero accel + omega).
|
||||||
|
// Used for "standing still on a surface" steady-state.
|
||||||
|
if ((ts & 1) != 0 && (ts & 2) != 0 && (this->state & 0x100 /* state's high mask */) == 0) {
|
||||||
|
this->m_accelerationVector = {0, 0, 0};
|
||||||
|
this->m_omegaVector = {0, 0, 0};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravity gate: state bit 0x4 (= GravityFlag).
|
||||||
|
if ((this->state & 0x400 /* gravity bit, 0x4 << 8 in BN ushort masking */) == 0) {
|
||||||
|
// Gravity OFF — zero acceleration (note: omega NOT zeroed)
|
||||||
|
this->m_accelerationVector = {0, 0, 0};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: gravity ON → vertical acceleration = PhysicsGlobals::gravity
|
||||||
|
this->m_accelerationVector = {0, 0, PhysicsGlobals::gravity}; // gravity ≈ -9.8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-frame called by `UpdatePhysicsInternal` (which is called from `UpdatePositionInternal` step E above)**. acdream's `PhysicsBody.calc_acceleration` matches this contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 — `CPhysicsObj::transition` (FUN_00512DC0)
|
||||||
|
|
||||||
|
**Signature:** `CTransition const* transition(CPhysicsObj* this, Position const* fromPos, Position const* toPos, int32_t flags)`
|
||||||
|
**Source:** `acclient_2013_pseudo_c.txt:280904-280957`
|
||||||
|
|
||||||
|
```
|
||||||
|
00512dc0 CTransition* transition(CPhysicsObj* this, Position* from, Position* to, int32_t flags) {
|
||||||
|
CTransition* tx = CTransition::makeTransition();
|
||||||
|
if (tx == 0) return 0;
|
||||||
|
|
||||||
|
// Init the object info struct (collidesWith, isMissile, etc) using flags arg
|
||||||
|
CTransition::init_object(tx, this, CPhysicsObj::get_object_info(this, tx, flags));
|
||||||
|
|
||||||
|
// Init sphere(s) to sweep — typically 1 humanoid sphere or N for parts
|
||||||
|
CPartArray* parts = this->part_array;
|
||||||
|
uint32_t n = parts ? CPartArray::GetNumSphere(parts) : 0;
|
||||||
|
if (parts == 0 || n == 0) {
|
||||||
|
CTransition::init_sphere(tx, 1, &dummy_sphere, 1.0f);
|
||||||
|
} else {
|
||||||
|
float scale = this->m_scale;
|
||||||
|
CSphere* spheres = CPartArray::GetSphere(parts);
|
||||||
|
uint32_t nSph = CPartArray::GetNumSphere(parts);
|
||||||
|
CTransition::init_sphere(tx, nSph, spheres, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path: from → to in cell `this->cell`
|
||||||
|
CTransition::init_path(tx, this->cell, from, to);
|
||||||
|
|
||||||
|
// Stationary-fall mask: tighter checks based on transient_state ContactPlane bits
|
||||||
|
uint8_t ts = (int8_t)this->transient_state;
|
||||||
|
if ((ts & 0x40) != 0) tx->collision_info.frames_stationary_fall = 3;
|
||||||
|
else if ((ts & 0x20) != 0) tx->collision_info.frames_stationary_fall = 2;
|
||||||
|
else if ((ts & 0x10) != 0) tx->collision_info.frames_stationary_fall = 1;
|
||||||
|
|
||||||
|
// Run the actual sweep — returns nonzero on success
|
||||||
|
int32_t ok = CTransition::find_valid_position(tx);
|
||||||
|
|
||||||
|
// NOTE: BN shows cleanupTransition(tx) BEFORE the success check — this
|
||||||
|
// looks wrong but BN's stack-frame analysis is unreliable here. ACE's
|
||||||
|
// port (PhysicsObj.transition) calls cleanup AFTER, conditionally.
|
||||||
|
CTransition::cleanupTransition(tx);
|
||||||
|
return (ok != 0) ? tx : 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`find_valid_position` is just an alias that calls `find_transitional_position` (line 273898). The actual sweep loop is `CTransition::find_transitional_position` (FUN_0050BDF0) at line 273613, which:
|
||||||
|
|
||||||
|
1. Computes step count (`calc_num_steps`) — `dt-derived` based on offset length and sphere radius.
|
||||||
|
2. Loops, advancing the sphere along the offset, calling `transitional_insert` each step.
|
||||||
|
3. Each step: cell list → BSP collision → step-up / edge-slide / contact-plane logic.
|
||||||
|
|
||||||
|
This is the heart of the "collision sweep" the env-var path currently bypasses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 — `CPhysicsObj::SetPositionInternal` overloads
|
||||||
|
|
||||||
|
Two overloads exist. The "post-sweep commit" form is FUN_00515BD0, called with `(this, ebp /*tx*/)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
00515bd0 SetPositionError SetPositionInternal(CPhysicsObj* this, Position* pos,
|
||||||
|
SetPositionStruct* sps, CTransition* tx) {
|
||||||
|
CSphere* localSph = tx->sphere_path.local_sphere;
|
||||||
|
if (this->cell == 0) CPhysicsObj::prepare_to_enter_world(this);
|
||||||
|
|
||||||
|
int32_t ecx_2 = (sps->flags >> 5) & 1; // "AdjustPosition" flag
|
||||||
|
CTransition* outTx = nullptr;
|
||||||
|
CPhysicsObj::AdjustPosition(pos, localSph, &outTx, ecx_2, 1);
|
||||||
|
|
||||||
|
if (outTx == 0) {
|
||||||
|
// Off the map — go to "lost cell"
|
||||||
|
CPhysicsObj::prepare_to_leave_visibility(this);
|
||||||
|
CPhysicsObj::store_position(this, pos);
|
||||||
|
CObjectMaint::GotoLostCell(CPhysicsObj::obj_maint, this, this->m_position.objcell_id);
|
||||||
|
this->transient_state &= 0xffffff7f;
|
||||||
|
} else {
|
||||||
|
// Hooks/Storage/Corpses go through ForceIntoCell
|
||||||
|
if (this->weenie_obj != 0) {
|
||||||
|
if (weenie_obj->IsHook()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
|
||||||
|
if (weenie_obj->IsStorage()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
|
||||||
|
if (weenie_obj->IsCorpse()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
|
||||||
|
}
|
||||||
|
// Honor "do_not_load_cells" sps flag
|
||||||
|
if ((sps->flags & 0x20) != 0) tx->cell_array.do_not_load_cells = 1;
|
||||||
|
|
||||||
|
if (CPhysicsObj::CheckPositionInternal(this, outTx, pos, tx, sps) == 0) {
|
||||||
|
int32_t r = CPhysicsObj::handle_all_collisions(this, &tx->collision_info, 0, 0);
|
||||||
|
return (-r ^ -r ... & 2) + 2; // BN noise — actually returns 2 or 3
|
||||||
|
}
|
||||||
|
if (tx->sphere_path.curr_cell == 0) return 3; // CELL_FAILED
|
||||||
|
CPhysicsObj::SetPositionInternal(this, tx); // 1-arg form: commit
|
||||||
|
}
|
||||||
|
return 0; // OK_SPE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The 1-arg form (`SetPositionInternal(this, tx)`) is the one that finally writes the new cell pointer + frame onto the object (it's not in this excerpt — it's the real "commit" routine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 — `CPhysicsObj::SetPosition` (FUN_005160C0)
|
||||||
|
|
||||||
|
External wrapper that builds a CTransition, runs SetPositionInternal, and returns. Used by NPC teleport / scatter, NOT by `update_object`.
|
||||||
|
|
||||||
|
```
|
||||||
|
005160c0 SetPositionError SetPosition(CPhysicsObj* this, SetPositionStruct* sps) {
|
||||||
|
CTransition* tx = CTransition::makeTransition();
|
||||||
|
if (tx == 0) return 1;
|
||||||
|
CTransition::init_object(tx, this, 0);
|
||||||
|
// Init sphere(s) — same pattern as transition()
|
||||||
|
CTransition::init_sphere(tx, n, spheres, scale);
|
||||||
|
SetPositionError r = CPhysicsObj::SetPositionInternal(this, sps, tx);
|
||||||
|
CTransition::cleanupTransition(tx);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9 — `CPhysicsObj::SetPositionSimple` (FUN_005162B0)
|
||||||
|
|
||||||
|
```
|
||||||
|
005162b0 SetPositionError SetPositionSimple(CPhysicsObj* this, Position* pos, int32_t teleport) {
|
||||||
|
uint32_t flags = teleport ? 0x1012 : 0x1002;
|
||||||
|
SetPositionStruct sps;
|
||||||
|
SetPositionStruct::SetPositionStruct(&sps);
|
||||||
|
SetPositionStruct::SetPosition(&sps, pos);
|
||||||
|
SetPositionStruct::SetFlags(&sps, flags);
|
||||||
|
SetPositionError r = CPhysicsObj::SetPosition(this, &sps);
|
||||||
|
SetPositionStruct::~SetPositionStruct(&sps);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10 — `CPhysicsObj::set_frame` (FUN_00514090)
|
||||||
|
|
||||||
|
The "no collision check" frame setter — used inside the substep when origin didn't move OR when the sweep failed and we just snap.
|
||||||
|
|
||||||
|
```
|
||||||
|
00514090 void set_frame(CPhysicsObj* this, Frame* arg2) {
|
||||||
|
Frame newFrame;
|
||||||
|
Frame::operator=(&newFrame, arg2);
|
||||||
|
if (Frame::IsValid(&newFrame) == 0 && Frame::IsValidExceptForHeading(&newFrame) != 0) {
|
||||||
|
// NaN-only-in-quaternion edge case → reset rotation (memset to 0)
|
||||||
|
newFrame.qw = 0; newFrame.qx = 0; newFrame.qy = 0; newFrame.qz = 0;
|
||||||
|
}
|
||||||
|
Frame::operator=(&this->m_position.frame, &newFrame); // store
|
||||||
|
if ((this->state & 0x1000 /* "no parts" */) == 0) {
|
||||||
|
if (this->part_array != 0)
|
||||||
|
CPartArray::SetFrame(this->part_array, &this->m_position.frame);
|
||||||
|
}
|
||||||
|
CPhysicsObj::UpdateChildrenInternal(this); // propagate to children
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11 — `Frame` operations
|
||||||
|
|
||||||
|
### Memory layout (verbatim from `acclient.h` / inferred from BN offsets)
|
||||||
|
|
||||||
|
```
|
||||||
|
class Frame {
|
||||||
|
Vector3 m_fOrigin; // +0x00 (12 bytes)
|
||||||
|
float qw, qx, qy, qz; // +0x0C (16 bytes)
|
||||||
|
float m_fl2gv[9]; // +0x1C (36 bytes) — 3x3 local-to-global rotation matrix cache
|
||||||
|
}; // total 0x40 = 64 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
The matrix cache `m_fl2gv` is the rotation matrix derived from the quaternion. It's recomputed by `Frame::cache` whenever the quaternion changes.
|
||||||
|
|
||||||
|
### `Frame::operator=` (FUN_00425C30) — line 39761
|
||||||
|
|
||||||
|
Plain memberwise copy of all 16 floats (origin + quat + 9 matrix entries):
|
||||||
|
|
||||||
|
```
|
||||||
|
00425c30 Frame& operator=(Frame* this, Frame const& src) {
|
||||||
|
this->m_fOrigin = src.m_fOrigin;
|
||||||
|
this->qw = src.qw; this->qx = src.qx; this->qy = src.qy; this->qz = src.qz;
|
||||||
|
for (int i = 0; i < 9; i++) this->m_fl2gv[i] = src.m_fl2gv[i];
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Frame::cache` (FUN_00534DF0) — line 319353
|
||||||
|
|
||||||
|
Rebuilds the `m_fl2gv[9]` rotation matrix from `(qw, qx, qy, qz)`. Standard quaternion-to-matrix:
|
||||||
|
|
||||||
|
```
|
||||||
|
00534df0 void Frame::cache(Frame* this) {
|
||||||
|
// Use temp doubles to preserve x87 precision
|
||||||
|
double tx = this->qx + this->qx; // 2qx
|
||||||
|
double ty = this->qy + this->qy; // 2qy
|
||||||
|
double tz = this->qz + this->qz; // 2qz
|
||||||
|
double wx = this->qw * tx; // 2qw·qx
|
||||||
|
double wy = this->qw * ty; // 2qw·qy
|
||||||
|
double wz = this->qw * tz; // 2qw·qz
|
||||||
|
double xx = this->qx * tx; // 2qx·qx
|
||||||
|
double xy = this->qx * ty; // 2qx·qy
|
||||||
|
double xz = this->qx * tz; // 2qx·qz
|
||||||
|
double yy = this->qy * ty; // 2qy·qy
|
||||||
|
double yz = this->qy * tz; // 2qy·qz
|
||||||
|
double zz = this->qz * tz; // 2qz·qz
|
||||||
|
|
||||||
|
// Column-major 3x3 stored row-by-row:
|
||||||
|
this->m_fl2gv[0] = 1.0 - yy - zz; // R00
|
||||||
|
this->m_fl2gv[1] = xy + wz; // R10
|
||||||
|
this->m_fl2gv[2] = xz - wy; // R20
|
||||||
|
this->m_fl2gv[3] = xy - wz; // R01
|
||||||
|
this->m_fl2gv[4] = 1.0 - xx - zz; // R11
|
||||||
|
this->m_fl2gv[5] = yz + wx; // R21
|
||||||
|
this->m_fl2gv[6] = xz + wy; // R02
|
||||||
|
this->m_fl2gv[7] = yz - wx; // R12
|
||||||
|
this->m_fl2gv[8] = 1.0 - xx - yy; // R22
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a standard XYZW-quaternion-to-3x3 matrix, but the layout here is **transpose** of typical glm/Silk row-major. acdream needs to be careful when consuming.
|
||||||
|
|
||||||
|
### `Frame::combine` (FUN_005122E0) — line 280355
|
||||||
|
|
||||||
|
**Most important — multiplication semantics: `out = a · b`** (compose b on top of a).
|
||||||
|
|
||||||
|
```
|
||||||
|
005122e0 void Frame::combine(Frame* out, Frame const* a, Frame const* b) {
|
||||||
|
// ── Origin: rotate b.origin by a's basis, then add a.origin ─────────────
|
||||||
|
out->m_fOrigin.x = a->m_fl2gv[0]*b->origin.x
|
||||||
|
+ a->m_fl2gv[3]*b->origin.y
|
||||||
|
+ a->m_fl2gv[6]*b->origin.z + a->m_fOrigin.x;
|
||||||
|
out->m_fOrigin.y = a->m_fl2gv[1]*b->origin.x
|
||||||
|
+ a->m_fl2gv[4]*b->origin.y
|
||||||
|
+ a->m_fl2gv[7]*b->origin.z + a->m_fOrigin.y;
|
||||||
|
out->m_fOrigin.z = a->m_fl2gv[2]*b->origin.x
|
||||||
|
+ a->m_fl2gv[5]*b->origin.y
|
||||||
|
+ a->m_fl2gv[8]*b->origin.z + a->m_fOrigin.z;
|
||||||
|
|
||||||
|
// ── Quaternion: a.q * b.q (Hamilton product) ────────────────────────────
|
||||||
|
// Note: BN swaps some operand orders, but this is the Hamilton product.
|
||||||
|
Frame::set_rotate(out,
|
||||||
|
a->qw*b->qw - b->qx*a->qx - b->qy*a->qy - b->qz*a->qz, // qw
|
||||||
|
a->qw*b->qx + b->qz*a->qy + b->qw*a->qx - b->qy*a->qz, // qx
|
||||||
|
b->qy*a->qw - b->qz*a->qx + a->qz*b->qx + a->qy*b->qw, // qy
|
||||||
|
b->qy*a->qx + b->qz*a->qw - a->qy*b->qx + a->qz*b->qw); // qz
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Frame::set_rotate` then normalizes the quaternion and re-runs `Frame::cache` to refresh `m_fl2gv`.
|
||||||
|
|
||||||
|
**Order:** `combine(out, a, b)` means `out = a ∘ b` — first apply b in local-frame coords, then rotate-and-translate by a. In `UpdatePositionInternal` step D, this means: `outFrame = m_position.frame * localDelta` — i.e. take the local-frame motion and lift it into world.
|
||||||
|
|
||||||
|
### `Frame::set_rotate` (FUN_00535080) — line 319453
|
||||||
|
|
||||||
|
```
|
||||||
|
00535080 void Frame::set_rotate(Frame* this, float qw, float qx, float qy, float qz) {
|
||||||
|
// Cache old quaternion in case new one is invalid
|
||||||
|
float oldQw=this->qw, oldQx=this->qx, oldQy=this->qy, oldQz=this->qz;
|
||||||
|
|
||||||
|
float invLen = 1.0 / sqrt(qw*qw + qx*qx + qy*qy + qz*qz);
|
||||||
|
this->qw = qw * invLen;
|
||||||
|
this->qx = qx * invLen;
|
||||||
|
this->qy = qy * invLen;
|
||||||
|
this->qz = qz * invLen;
|
||||||
|
|
||||||
|
if (Frame::IsValid(this) != 0) {
|
||||||
|
Frame::cache(this); // refresh l2gv matrix
|
||||||
|
} else {
|
||||||
|
// Restore — new quat had NaN
|
||||||
|
this->qw=oldQw; this->qx=oldQx; this->qy=oldQy; this->qz=oldQz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Frame::set_heading` (FUN_00535E40) — line 320049
|
||||||
|
|
||||||
|
Sets heading from a yaw angle (degrees):
|
||||||
|
|
||||||
|
```
|
||||||
|
00535e40 void Frame::set_heading(Frame* this, float degrees) {
|
||||||
|
// BN noise computes a vector from an existing matrix column — irrelevant
|
||||||
|
double rad = degrees * 0.017453292519943295; // π/180
|
||||||
|
float sinR = sin(rad);
|
||||||
|
float cosR = cos(rad);
|
||||||
|
Vector3 heading = { sinR, cosR, 0 }; // +Y is north; rotate CW
|
||||||
|
Frame::set_vector_heading(this, &heading);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Frame::set_vector_heading` (FUN_00535DB0) — line 320030
|
||||||
|
|
||||||
|
Sets heading to face a normalized 2D direction vector (rotation around Z-axis):
|
||||||
|
|
||||||
|
```
|
||||||
|
00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
|
||||||
|
Vector3 d = *dir;
|
||||||
|
if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return;
|
||||||
|
// Note: AC's heading convention — angle from north (+Y) measured clockwise.
|
||||||
|
// 450 - atan2(x, y) normalizes to [0, 360).
|
||||||
|
double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
|
||||||
|
yawDeg = fmod(yawDeg, 360.0);
|
||||||
|
Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Frame::rotate` (FUN_004525B0) — line 91477
|
||||||
|
|
||||||
|
Applies a small rotation increment in local space (used by omega integration):
|
||||||
|
|
||||||
|
```
|
||||||
|
004525b0 void Frame::rotate(Frame* this, Vector3 const* localOmegaTimesDt) {
|
||||||
|
// Lift the local-axis-angle vector to world by the current basis
|
||||||
|
Vector3 worldOmegaDt;
|
||||||
|
worldOmegaDt.x = this->m_fl2gv[0]*localOmegaTimesDt->x
|
||||||
|
+ this->m_fl2gv[3]*localOmegaTimesDt->y
|
||||||
|
+ this->m_fl2gv[6]*localOmegaTimesDt->z;
|
||||||
|
worldOmegaDt.y = this->m_fl2gv[1]*localOmegaTimesDt->x
|
||||||
|
+ this->m_fl2gv[4]*localOmegaTimesDt->y
|
||||||
|
+ this->m_fl2gv[7]*localOmegaTimesDt->z;
|
||||||
|
worldOmegaDt.z = this->m_fl2gv[2]*localOmegaTimesDt->x
|
||||||
|
+ this->m_fl2gv[5]*localOmegaTimesDt->y
|
||||||
|
+ this->m_fl2gv[8]*localOmegaTimesDt->z;
|
||||||
|
Frame::grotate(this, &worldOmegaDt); // global-frame rotation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Frame::set_origin` — does it exist?
|
||||||
|
|
||||||
|
**No** — search of `acclient_2013_pseudo_c.txt` finds zero hits for `Frame::set_origin`. Origin is set by direct member assignment (`f.m_fOrigin = newOrigin`) or via `Frame::operator=`. Note: the named PDB does not list a public mutator for origin alone.
|
||||||
|
|
||||||
|
### `Frame::is_zero` — does it exist?
|
||||||
|
|
||||||
|
**No** — search returns zero hits for `Frame::is_zero`. The `is_zero` method exists on `AC1Legacy::Vector3` (e.g. `Vector3::is_zero(&this->m_velocityVector)` at line 283667), and is applied to `Frame::m_fOrigin` indirectly via `Vector3::operator==(&zeroVec, &frame.origin)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12 — `Position::ctor`, `Position::distance`, `Position::get_offset`
|
||||||
|
|
||||||
|
### `Position::Position` (FUN_00424AB0) — default ctor
|
||||||
|
|
||||||
|
Sets vtable, zero objcell_id, identity Frame.
|
||||||
|
|
||||||
|
### `Position::Position(Position*, uint32_t cellId, Frame*)` — line 91542
|
||||||
|
|
||||||
|
```
|
||||||
|
00452780 void Position::Position(Position* this, uint32_t cellId, Frame* frame) {
|
||||||
|
this->vtable = 0x796910;
|
||||||
|
this->objcell_id = cellId;
|
||||||
|
Frame::operator=(&this->frame, frame);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Position::Position(Position*, Position const*)` — line 91655 (copy ctor)
|
||||||
|
|
||||||
|
Just calls `Frame::operator=` on the embedded frame and copies cellId.
|
||||||
|
|
||||||
|
### `Position::get_offset` — line 272088
|
||||||
|
|
||||||
|
**Cell-aware vector offset (this → arg3) in landblock-global coordinates.**
|
||||||
|
|
||||||
|
```
|
||||||
|
00509f60 Vector3* Position::get_offset(Position const* this, Vector3* out, Position const* other) {
|
||||||
|
Vector3 blockOffset;
|
||||||
|
// Compute the world offset between the two cell origins (uses landblock IDs).
|
||||||
|
LandDefs::get_block_offset(&blockOffset, this->objcell_id, other->objcell_id);
|
||||||
|
// out = (other.origin + blockOffset) - this.origin
|
||||||
|
out->x = (blockOffset.x + other->frame.origin.x) - this->frame.origin.x;
|
||||||
|
out->y = (blockOffset.y + other->frame.origin.y) - this->frame.origin.y;
|
||||||
|
out->z = (blockOffset.z + other->frame.origin.z) - this->frame.origin.z;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what acdream's `Position.GetOffset` mirrors. **Critical: cells in different landblocks must be reconciled via `LandDefs::get_block_offset` before subtracting origins.**
|
||||||
|
|
||||||
|
### `Position::distance` (FUN_005A94B0) — line 438258
|
||||||
|
|
||||||
|
```
|
||||||
|
005a94b0 Vector3* Position::distance(Position const* this, Position const* other) {
|
||||||
|
Vector3 r;
|
||||||
|
Position::get_offset(this, &r, other);
|
||||||
|
// (BN noise — actually returns sqrtf of the offset squared)
|
||||||
|
return r; // caller takes magnitude
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the BN decomp shows `result->z; result->y; result->x;` followed by `return result` — these dereferences load the floats but don't produce output here. The actual return value is the raw offset vector from `get_offset`; the caller computes `.Length()`. ACE's `Position.Distance` does this correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13 — Substepping algorithm summary (the key answer)
|
||||||
|
|
||||||
|
```
|
||||||
|
dt = currentTime - LastUpdateTime
|
||||||
|
|
||||||
|
if (dt < EPSILON) return; // < 0.0002s — too small, defer
|
||||||
|
if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time
|
||||||
|
|
||||||
|
while (dt > MaxQuantum): // 0.1 s
|
||||||
|
PhysicsTimer.curr_time += MaxQuantum
|
||||||
|
UpdateObjectInternal(MaxQuantum)
|
||||||
|
dt -= MaxQuantum
|
||||||
|
|
||||||
|
if (dt > MinQuantum): // 0.0333 s (1/30)
|
||||||
|
PhysicsTimer.curr_time += dt
|
||||||
|
UpdateObjectInternal(dt) // remainder, anywhere in (1/30, 0.1]
|
||||||
|
|
||||||
|
LastUpdateTime = PhysicsTimer.curr_time
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
|
||||||
|
1. **Retail processes every frame**, not just every 30 Hz. The first guard is `EPSILON`, not `MinQuantum`. ACE flipped this to `< TickRate` (= 1/30) for server CPU savings — that's a divergence, not retail-faithful.
|
||||||
|
2. **dt below MinQuantum but above EPSILON → no simulation that frame, but `update_time` IS advanced** to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue.
|
||||||
|
3. **Between MinQuantum and MaxQuantum: a single substep** for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) **Actually at 60 fps the second guard (`> MinQuantum`) is also FALSE, so nothing runs.** This is consistent with retail running its physics tick at 30 Hz in `MainProc`. Retail's `MainProc` ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through.
|
||||||
|
4. **Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum.** This handles frame-stutter / lag spikes (e.g. world load).
|
||||||
|
5. **`process_hooks` runs once per substep** (inside `UpdatePositionInternal`), so its rate scales with substep count — important for FPHook fade timers.
|
||||||
|
6. **The collision sweep (`transition`) runs once per substep**, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes.
|
||||||
|
|
||||||
|
### What this means for `LastUpdateTime` advancement
|
||||||
|
|
||||||
|
After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. `PhysicsTimer.curr_time` was incremented inside the loop by `MaxQuantum` per substep + the final remainder. **It accumulates ONLY consumed time** — if the early-`< EPSILON` guard fires, `update_time` is set to `Timer::cur_time` directly, dropping any unconsumed micro-fragment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14 — Cross-check against acdream's port
|
||||||
|
|
||||||
|
**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs:404-435`):**
|
||||||
|
|
||||||
|
acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. **But it gates on `dt < MinQuantum` for the early return**, not `< EPSILON`. This matches ACE's port (which uses TickRate=1/30) but **diverges from retail**, which uses EPSILON=0.0002.
|
||||||
|
|
||||||
|
**Practical effect of the divergence:**
|
||||||
|
|
||||||
|
At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) → fail the remainder gate (0.0167 < MinQuantum=0.0333) → bump `update_time` and return. **No simulation ran, but time was consumed.** Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop.
|
||||||
|
|
||||||
|
acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (`< MinQuantum` = 0.0333), returns immediately WITHOUT updating `LastUpdateTime`. **Next frame: dt = 0.0334 s**, passes the first gate, runs `UpdatePhysicsInternal(0.0334)` once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence.
|
||||||
|
|
||||||
|
Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (`if dVar1 < MinQuantum → return`) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization.
|
||||||
|
|
||||||
|
**`GameWindow.cs` per-tick remote motion path (line 6541-6553):**
|
||||||
|
|
||||||
|
The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps gate" is **accurate about acdream's port** but **slightly misleading about retail's behavior**. Retail does NOT have a 30 Hz gate in `update_object`; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch.
|
||||||
|
|
||||||
|
**The real bug (Commit B fix at line 6190):** the env-var path was missing the `ResolveWithTransition` call (port of `find_transitional_position`) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15 — Open questions / follow-ups
|
||||||
|
|
||||||
|
- **`Frame::set_origin`** and **`Frame::is_zero`** don't exist as named symbols. The conventions are: direct field write for origin, and `Vector3::is_zero` on `frame.m_fOrigin` for the test. Confirm acdream's port uses the same conventions (no need for these methods on the `Frame` type).
|
||||||
|
- **`update_object_server` does not exist.** ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one `update_object`.
|
||||||
|
- **The early-return gate divergence (EPSILON vs MinQuantum)** is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy.
|
||||||
|
- **`process_hooks` linked-list (`PhysicsObjHook`)** is not yet ported in acdream. acdream has anim-hook routing (`AnimationHookRouter`) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this.
|
||||||
526
docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md
Normal file
526
docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
# 09 — CPartArray::Update + CSequence::update / update_internal / apply_physics / add_motion
|
||||||
|
|
||||||
|
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||||
|
build, Binary Ninja pseudo-C with PDB names applied).
|
||||||
|
|
||||||
|
> **PDB symbol-name collisions you will see in the pseudo-C below.** Several
|
||||||
|
> AnimSequenceNode getters were name-collapsed into mangled stubs. From the
|
||||||
|
> retail header `acclient.h` the struct is `{ CAnimation* anim; float framerate;
|
||||||
|
> int low_frame; int high_frame; }`. The Binary-Ninja pseudo-C shows them as:
|
||||||
|
>
|
||||||
|
> | Pseudo-C name | Real symbol |
|
||||||
|
> |-------------------------------------|------------------------------------------|
|
||||||
|
> | `MD_Data_Fade::GetDuration(node)` | `AnimSequenceNode::get_framerate(node)` |
|
||||||
|
> | `EffectInfoRegion::GetStat(node)` | `AnimSequenceNode::get_high_frame(node)` |
|
||||||
|
> | `Attribute2ndInfoRegion::GetStat(node)` | `AnimSequenceNode::get_low_frame(node)` |
|
||||||
|
>
|
||||||
|
> Verified by struct layout match (`framerate` is the first float field,
|
||||||
|
> `low_frame` then `high_frame`) and by the call-site context (these are read
|
||||||
|
> exactly where the per-keyframe loop needs the framerate, end frame, start
|
||||||
|
> frame).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CPartArray::Update — `0x00517DB0`
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 00517db0
|
||||||
|
void __thiscall CPartArray::Update(class CPartArray* this, float arg2, class Frame* arg3)
|
||||||
|
{
|
||||||
|
// 00517dc2
|
||||||
|
CSequence::update(&this->sequence, (double)arg2, arg3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is literally a one-line forwarder. **All animation behavior lives in
|
||||||
|
`CSequence`.** The caller is `CPhysicsObj::UpdatePositionInternal` at
|
||||||
|
`0x00512c95`, passing `dt` as `arg2` and a `Frame*` to receive root-motion
|
||||||
|
displacement (`pos_frames` deltas + `velocity*dt`). The same forwarder is also
|
||||||
|
called from `0x00513e97` inside `set_state_to_starting_frame` for placement.
|
||||||
|
|
||||||
|
There is no per-part loop here — `CPartArray` keeps a single shared `CSequence`
|
||||||
|
that drives the root frame; per-part bone frames come from
|
||||||
|
`CSequence::get_curr_animframe()` later, applied in
|
||||||
|
`Frame::combine(this->parts[esi]->pos.frame, parentFrame, anim_frame->frame[i])`
|
||||||
|
inside `CPartArray::DoLocalToParent` (different code path).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CSequence::update — `0x00525B80`
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 00525b80
|
||||||
|
void __thiscall CSequence::update(class CSequence* this, double arg2 /*dt*/, class Frame* arg3)
|
||||||
|
{
|
||||||
|
// 00525b88
|
||||||
|
if (this->anim_list.head_ != nullptr) {
|
||||||
|
// 00525ba3
|
||||||
|
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
|
||||||
|
// 00525baa
|
||||||
|
CSequence::apricot(this); // garbage-collect drained link nodes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No animations on the sequence — degenerate path: still integrate the
|
||||||
|
// raw velocity * dt onto the frame. (Used by physics-only objects with a
|
||||||
|
// CSequence carrying only velocity/omega and no animation list.)
|
||||||
|
// 00525bb9
|
||||||
|
if (arg3 != nullptr) {
|
||||||
|
// 00525bca
|
||||||
|
CSequence::apply_physics(this, arg3, /*dt=*/arg2, /*sign=*/arg2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two key observations:
|
||||||
|
|
||||||
|
1. The "no animations" branch (`anim_list.head_ == 0`) is the only path that
|
||||||
|
calls `apply_physics` with the **whole `dt`**. With animations present, the
|
||||||
|
per-keyframe path scales by `1/framerate` (see §4 below).
|
||||||
|
2. `apricot()` walks the doubly-linked list, deletes any link node prior to
|
||||||
|
`curr_anim` that has been fully drained, and trims the list down to the
|
||||||
|
cyclic head. This is how transition links (e.g. WalkForward → RunForward)
|
||||||
|
"fall off" once consumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CSequence::update_internal — `0x005255D0` (the per-keyframe loop)
|
||||||
|
|
||||||
|
This is the heart of retail animation. It is the function whose
|
||||||
|
literal-pseudocode reading is required for L.3 of the motion port. The
|
||||||
|
disassembly is x87-FPU-heavy with synthesised compare flags; the algorithm
|
||||||
|
below is the reconstructed control flow.
|
||||||
|
|
||||||
|
### Signature
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 005255d0
|
||||||
|
void __thiscall CSequence::update_internal(
|
||||||
|
class CSequence const* this,
|
||||||
|
double arg2, // dt (seconds)
|
||||||
|
class AnimSequenceNode** arg3, // in/out: &this->curr_anim
|
||||||
|
double* arg4, // in/out: &this->frame_number (fractional)
|
||||||
|
class Frame* arg5 // out: accumulator for root-motion displacement
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Algorithm (reconstructed)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
while (true) { // 005255e8
|
||||||
|
// -- per-tick framerate scaling ----------------------------------------
|
||||||
|
framerate = AnimSequenceNode::get_framerate(*arg3); // 005255e8 (PDB-collision: MD_Data_Fade::GetDuration)
|
||||||
|
delta = framerate * arg2; // signed; negative = reverse play // 005255f1
|
||||||
|
int start_frame_int = (int)floor(*arg4); // 00525607
|
||||||
|
bool wrapped = false; // var_30_1 // 005255ff
|
||||||
|
int ebx = start_frame_int; // 0052561b
|
||||||
|
|
||||||
|
*arg4 = *arg4 + delta; // advance fractional frame_number // 00525635 (fadd / fstp)
|
||||||
|
|
||||||
|
if (delta >= 0.0) { // forward play branch // 00525646
|
||||||
|
// ----- FORWARD --------------------------------------------------
|
||||||
|
end_int = AnimSequenceNode::get_high_frame(*arg3); // 005257f5 (PDB-collision: EffectInfoRegion::GetStat)
|
||||||
|
if (*arg4 > (double)end_int) { // 00525806
|
||||||
|
// overflowed past the cycle's end — clamp + record overflow time
|
||||||
|
float left_in_cycle = (float)end_int - (float)start_frame_int; // 0052580f / 00525817
|
||||||
|
if (fabs(framerate) >= F_EPSILON) { // 00525841
|
||||||
|
overflow_time = left_in_cycle / framerate; // 00525843
|
||||||
|
// var_18_1
|
||||||
|
} else {
|
||||||
|
overflow_time = 0.0; // 0052584d
|
||||||
|
}
|
||||||
|
*arg4 = (double)AnimSequenceNode::get_high_frame(*arg3); // 00525866 / fild / fstp
|
||||||
|
wrapped = true; // 0052586e (var_30_1 = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- per-keyframe loop: walk every integer frame just stepped on --
|
||||||
|
// ebx is the frame we are LEAVING; it is decremented in the loop
|
||||||
|
// body BEFORE the iteration cmp. (Retail counts down because a
|
||||||
|
// forward step crossing N integer frames must apply the deltas in
|
||||||
|
// reverse: subtract1(prev_frame's posFrame) to back out, then
|
||||||
|
// combine(curr_frame's posFrame) to step in. See loop body.)
|
||||||
|
while ((double)ebx > *arg4 - 1.0) { /* equivalent to: while floor(*arg4) > ebx */
|
||||||
|
if (arg5 != nullptr) {
|
||||||
|
node = *arg3;
|
||||||
|
if (node->anim->pos_frames != 0) { // 005258b1
|
||||||
|
Frame::subtract1(arg5, arg5,
|
||||||
|
AnimSequenceNode::get_pos_frame(node, ebx)); // 005258be
|
||||||
|
}
|
||||||
|
if (fabs(framerate) >= F_EPSILON) { // 005258d3
|
||||||
|
// apply_physics with dt = (1.0 / framerate) and same sign
|
||||||
|
// as outer dt — i.e. EXACTLY ONE keyframe duration of
|
||||||
|
// velocity*dt is integrated per crossed integer.
|
||||||
|
CSequence::apply_physics(this, arg5,
|
||||||
|
/*frame_dt =*/ 1.0 / framerate, // 005258d8 / 005258e1
|
||||||
|
/*sign =*/ arg2); // 005258f8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CSequence::execute_hooks(this,
|
||||||
|
AnimSequenceNode::get_part_frame(*arg3, ebx),
|
||||||
|
/*direction=*/ 0xFFFFFFFF); // 0052590c (-1 = forward)
|
||||||
|
ebx -= 1; // 00525916
|
||||||
|
}
|
||||||
|
} else { // reverse play branch // 00525646 else (negative delta)
|
||||||
|
// ----- REVERSE --------------------------------------------------
|
||||||
|
start_int = AnimSequenceNode::get_low_frame(*arg3); // 0052566a (PDB-collision: Attribute2ndInfoRegion::GetStat)
|
||||||
|
if (*arg4 <= (double)start_int - 1.0) { // 0052567b (fild + fsubr 1.0 + fcom 0.0)
|
||||||
|
float left_in_cycle = (float)start_int - (float)start_frame_int;
|
||||||
|
if (fabs(framerate) >= F_EPSILON) {
|
||||||
|
overflow_time = left_in_cycle / framerate; // 005256be
|
||||||
|
} else {
|
||||||
|
overflow_time = 0.0; // 005256c8
|
||||||
|
}
|
||||||
|
*arg4 = (double)AnimSequenceNode::get_low_frame(*arg3); // 005256e1
|
||||||
|
wrapped = true; // 005256e9
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- per-keyframe loop: same idea, opposite direction ------------
|
||||||
|
int ebx2 = ebx; /* var_2c_1 = ebx (saved at 0052561d) */
|
||||||
|
while (/* floor(*arg4) < ebx2 */) { // 00525714
|
||||||
|
if (arg5 != nullptr) {
|
||||||
|
node = *arg3;
|
||||||
|
if (node->anim->pos_frames != 0) { // 00525731
|
||||||
|
Frame::combine(arg5, arg5,
|
||||||
|
AnimSequenceNode::get_pos_frame(node, ebx2)); // 0052573e
|
||||||
|
}
|
||||||
|
if (fabs(framerate) >= F_EPSILON) { // 00525753
|
||||||
|
CSequence::apply_physics(this, arg5,
|
||||||
|
/*frame_dt=*/ 1.0 / framerate, // 00525758 / 00525761
|
||||||
|
/*sign =*/ arg2); // 00525778
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CSequence::execute_hooks(this,
|
||||||
|
AnimSequenceNode::get_part_frame(*arg3, ebx2),
|
||||||
|
/*direction=*/ 1); // 0052578c (+1 = backward)
|
||||||
|
ebx2 += 1; // 00525796
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- end-of-tick: did we wrap? ----------------------------------------
|
||||||
|
if (!wrapped) return; // 00525943 / 005259ca
|
||||||
|
|
||||||
|
// We hit the cycle boundary. Notify hook-target if we just consumed a
|
||||||
|
// non-cyclic link node, then advance to the next animation in the list.
|
||||||
|
if (this->hook_obj != nullptr) { // 0052594e
|
||||||
|
anim_list_head = ((char*)this->anim_list.head_) - 4;
|
||||||
|
if (anim_list_head != this->first_cyclic) {
|
||||||
|
CPhysicsObj::add_anim_hook(this->hook_obj, &anim_done_hook); // 00525968
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// arg2 (the outer dt) is rewritten here to "overflow_time" so the loop
|
||||||
|
// reschedules from the new node.
|
||||||
|
CSequence::advance_to_next_animation(this, arg2 /*now overflow_time*/,
|
||||||
|
arg3, arg4, arg5); // 0052597d
|
||||||
|
arg2 = 0; *(arg2 + 4) = 0; /* reset outer dt accumulator */ // 0052598a / 0052598d
|
||||||
|
// …then `while (true)` again and run another iteration of the chain.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### What that means in plain English
|
||||||
|
|
||||||
|
Per call to `update`:
|
||||||
|
|
||||||
|
1. Take the current dt and multiply by `framerate` (signed, decompresses to
|
||||||
|
the dat's `AnimData::framerate * speedMod` per `operator*` at `00525d00`).
|
||||||
|
2. Add to `frame_number` (the fractional cursor).
|
||||||
|
3. Walk every integer keyframe boundary the cursor just crossed:
|
||||||
|
- **Forward** crossing: subtract the *just-left* keyframe's `pos_frame`
|
||||||
|
out of the displacement accumulator (it's already been baked in the
|
||||||
|
animation pose, so don't double-count it as root motion). Run hooks
|
||||||
|
with direction `-1`.
|
||||||
|
- **Reverse** crossing: combine (add) the *just-left* keyframe's
|
||||||
|
`pos_frame` back in. Run hooks with direction `+1`.
|
||||||
|
- In **both** directions, integrate `velocity * (1 / framerate) * sign(dt)`
|
||||||
|
onto the displacement frame via `apply_physics`. This is **one
|
||||||
|
keyframe's worth of velocity per crossed boundary** — not `velocity * dt`.
|
||||||
|
4. If the cursor went past the cycle's end (forward) or before its start
|
||||||
|
(reverse), clamp to the boundary, compute the leftover dt, advance to the
|
||||||
|
next AnimSequenceNode in the linked list, and loop again with the leftover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. CSequence::apply_physics — `0x00524AB0`
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 00524ab0
|
||||||
|
void __thiscall CSequence::apply_physics(class CSequence const* this,
|
||||||
|
class Frame* arg2 /*frame*/,
|
||||||
|
double arg3 /*frame_dt = 1.0/framerate*/,
|
||||||
|
double arg4 /*sign carrier = outer dt*/)
|
||||||
|
{
|
||||||
|
// 00524ab7..00524ac1
|
||||||
|
long double dt = fabs((long double)arg3);
|
||||||
|
|
||||||
|
// 00524ac8..00524aca: if (sign(arg4) < 0) dt = -dt;
|
||||||
|
// I.e. the magnitude comes from arg3 (1/framerate, always positive),
|
||||||
|
// the sign comes from arg4 (outer dt). Reverse playback flips the sign.
|
||||||
|
if (arg4 < 0.0) dt = -dt;
|
||||||
|
|
||||||
|
// 00524af1..00524b05: integrate world-space velocity onto the frame.
|
||||||
|
arg2->m_fOrigin.x += (float)(dt * (long double)this->velocity.x);
|
||||||
|
arg2->m_fOrigin.y += (float)(dt * (long double)this->velocity.y);
|
||||||
|
arg2->m_fOrigin.z += (float)(dt * (long double)this->velocity.z);
|
||||||
|
|
||||||
|
// 00524b0f..00524b29: build (omega.x*dt, omega.y*dt, omega.z*dt) on stack.
|
||||||
|
Vector3 axis = { (float)(dt * (long double)this->omega.x),
|
||||||
|
(float)(dt * (long double)this->omega.y),
|
||||||
|
(float)(dt * (long double)this->omega.z) };
|
||||||
|
// 00524b2d
|
||||||
|
Frame::rotate(arg2, &axis);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So `apply_physics` integrates **the CSequence's stored velocity/omega**, which
|
||||||
|
were set by `add_motion` (§5 below). The crucial structural detail repeated:
|
||||||
|
**`arg3` is `1.0 / framerate`, NOT `dt`.** `update_internal` calls
|
||||||
|
`apply_physics` once per crossed integer keyframe; over a full cycle this
|
||||||
|
sums to exactly `velocity * (cycle_duration_in_seconds)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. add_motion — `0x005224B0` (the velocity producer)
|
||||||
|
|
||||||
|
`add_motion` is the writer that populates `CSequence::velocity` and
|
||||||
|
`CSequence::omega`. Called from the motion-table machinery
|
||||||
|
(`get_seq_animations` chain) when a new `MotionData` is enqueued.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 005224b0
|
||||||
|
void add_motion(class CSequence* arg1, class MotionData* arg2, float arg3 /*speedMod*/)
|
||||||
|
{
|
||||||
|
if (arg2 == nullptr) return;
|
||||||
|
|
||||||
|
// Velocity = MotionData.velocity * speedMod (componentwise)
|
||||||
|
// 005224d1..005224f8
|
||||||
|
Vector3 v = { arg2->velocity.x * arg3,
|
||||||
|
arg2->velocity.y * arg3,
|
||||||
|
arg2->velocity.z * arg3 };
|
||||||
|
CSequence::set_velocity(arg1, &v);
|
||||||
|
|
||||||
|
// Omega = MotionData.omega * speedMod
|
||||||
|
// 0052250f..0052252f
|
||||||
|
Vector3 w = { arg2->omega.x * arg3,
|
||||||
|
arg2->omega.y * arg3,
|
||||||
|
arg2->omega.z * arg3 };
|
||||||
|
CSequence::set_omega(arg1, &w);
|
||||||
|
|
||||||
|
// For every AnimData in MotionData.anims, append it to the sequence
|
||||||
|
// with framerate scaled by speedMod (operator*(AnimData, float, AnimData)
|
||||||
|
// at 00525d00 builds the scaled copy: low/high frame copied verbatim,
|
||||||
|
// framerate multiplied, anim_id copied).
|
||||||
|
// 00522537..00522573
|
||||||
|
for (int i = 0; i < arg2->num_anims; ++i) {
|
||||||
|
AnimData scaled;
|
||||||
|
operator*(&scaled, arg3, &arg2->anims[i]);
|
||||||
|
CSequence::append_animation(arg1, &scaled);
|
||||||
|
SetPositionStruct::~SetPositionStruct(&scaled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_velocity` (0x00524880) and `set_omega` (0x005248A0) are 3-float
|
||||||
|
overwrites — **assignment, not accumulation**. So whenever a new MotionData
|
||||||
|
is added, prior velocity/omega are clobbered.
|
||||||
|
|
||||||
|
(`combine_motion` at 0x00522580 / `subtract_motion` at 0x00522600 are the
|
||||||
|
accumulating variants — they call `combine_physics` / `subtract_physics`
|
||||||
|
instead — used elsewhere for additive blends.)
|
||||||
|
|
||||||
|
### What this produces for Humanoid Walk/Run
|
||||||
|
|
||||||
|
This is the dispositive answer to the L.3 mystery:
|
||||||
|
|
||||||
|
> *"For Humanoid Walk/Run cycles where dat ships zero baked velocity, what
|
||||||
|
> does add_motion produce?"*
|
||||||
|
|
||||||
|
**Zero.** `MotionData.velocity` for the Humanoid Walk and Run motion-table
|
||||||
|
entries is `(0,0,0)` (verified by acdream's own
|
||||||
|
`AnimationSequencer.SetCycle()` comment block at
|
||||||
|
`src/AcDream.Core/Physics/AnimationSequencer.cs:579-613`). `add_motion`
|
||||||
|
multiplies that zero by `speedMod` and writes zero into
|
||||||
|
`CSequence::velocity`. `apply_physics` therefore integrates zero translation
|
||||||
|
per keyframe step. **The dat-baked `pos_frames` array on each animation
|
||||||
|
also has zero translation per frame** for the Humanoid run cycle (cycles
|
||||||
|
in place — root motion is synthesised, not baked).
|
||||||
|
|
||||||
|
So `update_internal`'s root-motion accumulator (`arg5`) ends the call
|
||||||
|
unchanged for a Humanoid run/walk cycle. **Retail does NOT produce body
|
||||||
|
translation from `CSequence::update`.** Body translation comes from a
|
||||||
|
SEPARATE source: `CMotionInterp::get_state_velocity` at `0x00528960`,
|
||||||
|
which returns `RunAnimSpeed × ForwardSpeed` (or `WalkAnimSpeed`,
|
||||||
|
`SidestepAnimSpeed`) as a hard-coded constant looked up from
|
||||||
|
`_DAT_007c96e0/e4/e8`. That value is fed into `CPhysicsObj::set_velocity`
|
||||||
|
upstream, then `CTransition::transitional_insert` integrates it across the
|
||||||
|
swept-sphere collision pipeline.
|
||||||
|
|
||||||
|
For non-locomotion cycles (emotes, attacks, idle, jump): `MotionData.velocity`
|
||||||
|
may be non-zero (e.g. jump's vertical impulse) AND/OR the animation's
|
||||||
|
`pos_frames` array may contain baked deltas (e.g. attack lunges). Both
|
||||||
|
sources flow through the same `update_internal` loop above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CSequence::velocity / omega / framerate accessors
|
||||||
|
|
||||||
|
There are no getter functions; the fields are read directly off the struct
|
||||||
|
(see `acclient.h` line 30751 / 30752 / 30754). For reference:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 00524880 set_velocity — pure assignment of 3 floats
|
||||||
|
// 005248a0 set_omega — pure assignment of 3 floats
|
||||||
|
// 005248c0 combine_physics (additive: velocity += rhs, omega += rhs)
|
||||||
|
// 00524900 subtract_physics (additive: velocity -= rhs, omega -= rhs)
|
||||||
|
// 00524940 multiply_cyclic_animation_fr — for-each-cyclic-node node->framerate *= arg2
|
||||||
|
// 00525be0 AnimSequenceNode::multiply_framerate — node.framerate *= arg2 (single)
|
||||||
|
```
|
||||||
|
|
||||||
|
`framerate` lives on each `AnimSequenceNode`, not on `CSequence`. It is
|
||||||
|
already pre-multiplied by `speedMod` at the time `add_motion` runs (via
|
||||||
|
the `operator*(AnimData, float)` constructor at `0x00525D00` —
|
||||||
|
`new_framerate = old_framerate * speedMod`). Negative speedMod produces
|
||||||
|
negative framerate, which `update_internal` reads as the reverse-play
|
||||||
|
branch — there is **no separate "play backward" flag**, just the sign of
|
||||||
|
`framerate`.
|
||||||
|
|
||||||
|
`get_starting_frame` (`0x00525C80`) and `get_ending_frame` (`0x00525CB0`)
|
||||||
|
encode this: when `framerate < 0`, "start" returns `high_frame + 1` and
|
||||||
|
"end" returns `low_frame`; when `framerate >= 0`, "start" returns
|
||||||
|
`low_frame` and "end" returns `high_frame + 1`. This is why the
|
||||||
|
forward-vs-reverse branches in `update_internal` use opposite boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frame::combine / Frame::subtract1 — the per-keyframe pos_frame applicator
|
||||||
|
|
||||||
|
`AnimSequenceNode::get_pos_frame(node, frame_index)` at `0x00525C10`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class AFrame* get_pos_frame(int frame_index) {
|
||||||
|
CAnimation* anim = this->anim;
|
||||||
|
if (anim != nullptr && frame_index >= 0 && frame_index < anim->num_frames)
|
||||||
|
return ((AFrame*)((char*)anim->pos_frames + 0x1C * frame_index)); // sizeof(AFrame)=0x1C
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Frame::combine(Frame* result, Frame const* lhs, AFrame const* rhs)`
|
||||||
|
at `0x00525180` (3-arg variant) — concatenates two transforms (`result =
|
||||||
|
lhs ∘ rhs`). `Frame::subtract1` at `0x00535520` is the inverse (`result =
|
||||||
|
lhs ∘ rhs⁻¹`). Both are pure 4×4-equivalent rigid-body composition; nothing
|
||||||
|
animation-specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. AnimSequenceNode::get_pos_frame — `0x00525C10`
|
||||||
|
|
||||||
|
(See §7 — same function, used as a getter.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Per-keyframe loop summary (the answer to the L.3 critical question)
|
||||||
|
|
||||||
|
> *"What does the per-keyframe loop look like exactly?"*
|
||||||
|
|
||||||
|
Per crossed integer frame boundary (forward branch shown; reverse mirrors):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 005258a5..00525917, per integer keyframe just crossed forward:
|
||||||
|
if (arg5 /*displacement frame*/) {
|
||||||
|
if (curr_node->anim->pos_frames) {
|
||||||
|
// Subtract the LEAVING keyframe's baked offset out of the running
|
||||||
|
// displacement accumulator. (We already advanced fractional frame
|
||||||
|
// past it; the pose for this frame is already where it should be in
|
||||||
|
// local space.)
|
||||||
|
Frame::subtract1(arg5, arg5,
|
||||||
|
AnimSequenceNode::get_pos_frame(curr_node, ebx));
|
||||||
|
}
|
||||||
|
if (fabs(framerate) >= F_EPSILON) {
|
||||||
|
// Integrate exactly ONE keyframe-duration of CSequence::velocity
|
||||||
|
// and CSequence::omega onto arg5.
|
||||||
|
CSequence::apply_physics(this, arg5, 1.0 / framerate, sign_of(outer_dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CSequence::execute_hooks(this,
|
||||||
|
AnimSequenceNode::get_part_frame(curr_node, ebx), -1);
|
||||||
|
ebx -= 1; // walk to next-older crossed frame
|
||||||
|
```
|
||||||
|
|
||||||
|
In plain English: **for every integer frame boundary the fractional cursor
|
||||||
|
just crossed, apply (velocity × keyframe_period) of "free" displacement and
|
||||||
|
also stitch baked posframe deltas to keep the cycle's per-frame in-place
|
||||||
|
loop registered.** Across a full cycle, the velocity contribution is
|
||||||
|
exactly `velocity * cycle_duration`. The framerate scaling is what keeps
|
||||||
|
the body moving the same world-space distance per real-time second
|
||||||
|
regardless of how the cycle is divided into frames.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-reference: acdream's port
|
||||||
|
|
||||||
|
`src/AcDream.Core/Physics/AnimationSequencer.cs` (1455 lines):
|
||||||
|
|
||||||
|
- **Per-keyframe loop is structurally correct** (lines 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).
|
||||||
693
docs/research/2026-05-04-l3-port/10-vector-update-jump.md
Normal file
693
docs/research/2026-05-04-l3-port/10-vector-update-jump.md
Normal file
|
|
@ -0,0 +1,693 @@
|
||||||
|
# L.3 — VectorUpdate (0xF74E) handler chain + jump pseudocode
|
||||||
|
|
||||||
|
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named).
|
||||||
|
|
||||||
|
All retail line numbers below refer to that file (`acclient_2013_pseudo_c.txt`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `CM_Physics::DispatchSB_VectorUpdate` — packet → handler
|
||||||
|
|
||||||
|
**Address:** `0x006acd20` (line 692119)
|
||||||
|
|
||||||
|
```
|
||||||
|
006acd20 enum NetBlobProcessedStatus CM_Physics::DispatchSB_VectorUpdate(
|
||||||
|
class SmartBox* arg1,
|
||||||
|
class NetBlob* arg2)
|
||||||
|
{
|
||||||
|
NetBlob* ebx = arg2;
|
||||||
|
if (ebx == 0 || arg1 == 0) return 3;
|
||||||
|
uint8_t* buf_ = ebx->buf_; // body bytes
|
||||||
|
uint32_t bufSize_ = ebx->bufSize_;
|
||||||
|
if (*(uint32_t*)buf_ != 0xf74e) return 3; // line 692130 — opcode gate
|
||||||
|
|
||||||
|
uint32_t guid = *(uint32_t*)(buf_ + 4); // line 692136 — object id
|
||||||
|
arg2 = &buf_[8];
|
||||||
|
|
||||||
|
Vector3 velocity; // line 692139
|
||||||
|
AC1Legacy::Vector3::UnPack(&velocity, &arg2, ...);
|
||||||
|
|
||||||
|
Vector3 omega; // line 692141
|
||||||
|
AC1Legacy::Vector3::UnPack(&omega, &arg2, ...);
|
||||||
|
|
||||||
|
PhysicsTimestampPack ts; // line 692143
|
||||||
|
PhysicsTimestampPack::UnPack(&ts, &arg2, ...);
|
||||||
|
|
||||||
|
return SmartBox::HandleVectorUpdate(arg1, ebx, guid, &velocity, &omega, &ts);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire layout: `[opcode:u32 0xF74E][guid:u32][velocity:Vector3][omega:Vector3][ts:PhysicsTimestampPack]`.
|
||||||
|
The PhysicsTimestampPack carries `ts1` (port-event sequence) and `ts2` (vector
|
||||||
|
event sequence, used in DoVectorUpdate as `update_times[3]`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `SmartBox::HandleVectorUpdate` — sequence gate
|
||||||
|
|
||||||
|
**Address:** `0x00453480` (line 92195)
|
||||||
|
|
||||||
|
```
|
||||||
|
00453480 enum NetBlobProcessedStatus SmartBox::HandleVectorUpdate(
|
||||||
|
SmartBox* this,
|
||||||
|
NetBlob* arg2, // raw blob (re-queued on early)
|
||||||
|
uint32_t arg3, // guid
|
||||||
|
Vector3 const* arg4, // velocity (world)
|
||||||
|
Vector3 const* arg5, // omega (world)
|
||||||
|
PhysicsTimestampPack const* arg6)
|
||||||
|
{
|
||||||
|
int32_t ebp = arg6->ts2; // vector event ts (line 92199)
|
||||||
|
int32_t esi = arg6->ts1; // port event ts (line 92201)
|
||||||
|
CPhysicsObj* obj = CObjectMaint::GetObjectA(this->m_pObjMaint, arg3);
|
||||||
|
|
||||||
|
if (obj != 0)
|
||||||
|
{
|
||||||
|
int32_t ebx = obj->update_times[8]; // last-seen port-event ts
|
||||||
|
// Signed compare across 16-bit wrap (lines 92210-92218 standard
|
||||||
|
// wrap-aware "newer?" macro): if ebx == esi → in sync, dispatch.
|
||||||
|
if (ebx == esi)
|
||||||
|
{
|
||||||
|
SmartBox::DoVectorUpdate(this, obj, arg4, arg5, ebp);
|
||||||
|
return 1; // PROCESSED
|
||||||
|
}
|
||||||
|
if (ebx != esi)
|
||||||
|
return 2; // OUT_OF_ORDER (newer port event hasn't arrived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object not yet known — queue the blob for later.
|
||||||
|
CObjectMaint::QueueBlobForObject(this->m_pObjMaint, arg3, arg2);
|
||||||
|
return 4; // DEFERRED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: VectorUpdate is gated against `update_times[8]` (the
|
||||||
|
**port-event timestamp**), not `update_times[3]`. This means a VectorUpdate
|
||||||
|
will *not* be applied unless the latest 0xF748 PortalCellUpdate / position
|
||||||
|
event has already been processed. This is how retail keeps the velocity
|
||||||
|
write coherent with the position the server intended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `SmartBox::DoVectorUpdate` — the actual write
|
||||||
|
|
||||||
|
**Address:** `0x004521c0` (line 91208)
|
||||||
|
|
||||||
|
```
|
||||||
|
004521c0 void SmartBox::DoVectorUpdate(
|
||||||
|
SmartBox* this,
|
||||||
|
CPhysicsObj* arg2,
|
||||||
|
Vector3 const* arg3, // velocity (world)
|
||||||
|
Vector3 const* arg4, // omega (world)
|
||||||
|
uint16_t arg5) // ts2 (vector-event ts)
|
||||||
|
{
|
||||||
|
int32_t esi = arg2->update_times[3]; // last vector ts on object
|
||||||
|
int32_t edi = arg5;
|
||||||
|
// Wrap-aware "edi newer than esi" check (lines 91217-91227).
|
||||||
|
// The decompiler's flag arithmetic collapses to 0 here; the real
|
||||||
|
// gate is `edi != esi`.
|
||||||
|
if (edi != esi)
|
||||||
|
{
|
||||||
|
arg2->update_times[3] = edi; // line 91229
|
||||||
|
|
||||||
|
if (arg2 != this->player)
|
||||||
|
{
|
||||||
|
CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91233 — REMOTE
|
||||||
|
CPhysicsObj::set_omega (arg2, arg4, 1);
|
||||||
|
}
|
||||||
|
else if (this->cmdinterp->vtable->UsePositionFromServer() != 0)
|
||||||
|
{
|
||||||
|
CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91238 — LOCAL only when server-driven
|
||||||
|
CPhysicsObj::set_omega (arg2, arg4, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical observations** (relevant to acdream's K-fix15 question):
|
||||||
|
|
||||||
|
1. **Retail does NOT set the Gravity flag here.** It does not touch
|
||||||
|
`state` at all. It only writes `m_velocityVector` (via set_velocity)
|
||||||
|
and `m_omegaVector` (via set_omega). Gravity is already-on for
|
||||||
|
creatures by default (set when the body enters the world via
|
||||||
|
`enter_default_state` → `LeaveGround`); VectorUpdate does not
|
||||||
|
need to re-set it.
|
||||||
|
2. **Retail does NOT clear Contact / OnWalkable here.** The
|
||||||
|
transient-state bits stay whatever they were. Clearing them is the
|
||||||
|
job of `set_on_walkable(false)` which fires from
|
||||||
|
`CMotionInterp::jump` (local jumps only), or from the next physics
|
||||||
|
tick when the sphere-sweep finds no contact plane.
|
||||||
|
3. **Retail just writes the velocity.** The next per-tick
|
||||||
|
`CPhysicsObj::UpdateObjectInternal` reads `m_velocityVector`,
|
||||||
|
adds gravity acceleration (computed by `calc_acceleration`),
|
||||||
|
integrates position, and the sphere-sweep handles contact.
|
||||||
|
|
||||||
|
So acdream's `OnLiveVectorUpdated` (GameWindow.cs:3246-3304) is doing
|
||||||
|
*more* than retail: it also clears Contact/OnWalkable + sets Gravity
|
||||||
|
when v.Z > 0.5. The reason this is necessary in acdream is that
|
||||||
|
acdream's per-tick path (`UpdatePhysicsInternal`) gates gravity on
|
||||||
|
`!OnWalkable` rather than reading the `state.Gravity` bit — see
|
||||||
|
`PhysicsBody.calc_acceleration` and the per-tick velocity integration.
|
||||||
|
Retail's per-tick reads `state & GRAVITY` (always set for creatures)
|
||||||
|
AND `transient_state & (CONTACT | ON_WALKABLE)` to short-circuit
|
||||||
|
acceleration to zero (see calc_acceleration below).
|
||||||
|
|
||||||
|
`set_velocity(arg3, /*arg3=*/1)` clamps |v| ≤ 50 (MaxVelocity) and
|
||||||
|
sets `transient_state.Active`. The `arg3=1` flag distinguishes
|
||||||
|
network-source (1) from local-source (0); only used to gate
|
||||||
|
update_time refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `CPhysicsObj::set_velocity`
|
||||||
|
|
||||||
|
**Address:** `0x005113f0` (line 279361)
|
||||||
|
|
||||||
|
```
|
||||||
|
005113f0 void CPhysicsObj::set_velocity(
|
||||||
|
CPhysicsObj* this, Vector3 const* arg2, int32_t arg3)
|
||||||
|
{
|
||||||
|
if (Vector3::operator!=(arg2, &this->m_velocityVector) != 0) // line 279364
|
||||||
|
{
|
||||||
|
this->m_velocityVector = *arg2; // 279366-368
|
||||||
|
// Magnitude clamp to 50 (MaxVelocity squared = 2500).
|
||||||
|
long double mag2 = vx*vx + vy*vy + vz*vz; // 279372
|
||||||
|
if (50f*50f < mag2) // 279377
|
||||||
|
{
|
||||||
|
AC1Legacy::Vector3::normalize(&this->m_velocityVector); // 279383
|
||||||
|
this->m_velocityVector.x *= 50f; // 279384
|
||||||
|
this->m_velocityVector.y *= 50f;
|
||||||
|
this->m_velocityVector.z *= 50f;
|
||||||
|
}
|
||||||
|
this->jumped_this_frame = 1; // 279389 ★
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Active transient flag (bit 7 = 0x80) when state.Gravity bit not set.
|
||||||
|
if ((this->state & 1) == 0) // line 279392 ★
|
||||||
|
{
|
||||||
|
if (this->transient_state >= 0)
|
||||||
|
{
|
||||||
|
this->update_time = Timer::cur_time; // 279398-399
|
||||||
|
}
|
||||||
|
this->transient_state |= 0x80; // 279402 — Active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two side effects beyond the velocity write:
|
||||||
|
- **`jumped_this_frame = 1`** (offset +0x9C). Skips contact resolution
|
||||||
|
this frame; gives gravity room to lift the body off the floor before
|
||||||
|
the sweep clamps it back. Critical for jump start — without this, the
|
||||||
|
+Z velocity gets immediately killed by the floor sweep on frame N+1.
|
||||||
|
- **`transient_state |= Active (0x80)`**. Marks the object for
|
||||||
|
per-tick processing (UpdateObjectInternal early-outs if Active is
|
||||||
|
not set).
|
||||||
|
|
||||||
|
Note line 279392: `(this->state & 1)` is testing bit 0, which is the
|
||||||
|
**Static** flag, not Gravity. Retail only blocks the Active bit-set
|
||||||
|
on static objects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `CPhysicsObj::set_local_velocity`
|
||||||
|
|
||||||
|
**Address:** `0x005114d0` (line 279408)
|
||||||
|
|
||||||
|
```
|
||||||
|
005114d0 void CPhysicsObj::set_local_velocity(
|
||||||
|
CPhysicsObj* this, Vector3 const* arg2, int32_t arg3)
|
||||||
|
{
|
||||||
|
// Multiply local-frame velocity by orientation matrix (frame.m_fl2gv,
|
||||||
|
// 9 floats at offset 0x...). worldX = m_fl2gv[0]*lx + m_fl2gv[3]*ly + m_fl2gv[6]*lz
|
||||||
|
Vector3 worldVel = orientation × arg2;
|
||||||
|
CPhysicsObj::set_velocity(this, &worldVel, arg3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by `CMotionInterp::LeaveGround` to convert the body-local launch
|
||||||
|
vector (forward/right/up relative to facing) into a world vector.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `OBJECTINFO::kill_velocity` (clear_velocity equivalent)
|
||||||
|
|
||||||
|
**Address:** `0x0050cfe0` (line 274467)
|
||||||
|
|
||||||
|
```
|
||||||
|
0050cfe0 void OBJECTINFO::kill_velocity(OBJECTINFO* this)
|
||||||
|
{
|
||||||
|
CPhysicsObj* obj = this->object;
|
||||||
|
Vector3 zero = (0, 0, 0);
|
||||||
|
CPhysicsObj::set_velocity(obj, &zero, 0); // arg3=0 (local source)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Retail does not have a separate `clear_velocity` — it just zeros via
|
||||||
|
set_velocity. Called from collision handlers when a wall hit kills
|
||||||
|
forward motion (lines 272567, 273237).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `CMotionInterp::LeaveGround` — outbound jump trigger
|
||||||
|
|
||||||
|
**Address:** `0x00528b00` (line 306022)
|
||||||
|
|
||||||
|
```
|
||||||
|
00528b00 void CMotionInterp::LeaveGround(CMotionInterp* this)
|
||||||
|
{
|
||||||
|
if (!this->physics_obj) return;
|
||||||
|
|
||||||
|
// Creature gate: only run for creatures (or when no weenie).
|
||||||
|
CWeenieObject* w = this->weenie_obj;
|
||||||
|
bool isCreature = w == 0 ? true : w->vtable->IsCreature();
|
||||||
|
if (!(w == 0 || isCreature)) return;
|
||||||
|
|
||||||
|
CPhysicsObj* po = this->physics_obj;
|
||||||
|
// Only if state.Gravity bit set (state & 4 — line 306037).
|
||||||
|
if ((po->state & 4) == 0) return; // ★ Gravity gate
|
||||||
|
|
||||||
|
Vector3 leaveVel; // line 306039
|
||||||
|
CMotionInterp::get_leave_ground_velocity(this, &leaveVel);
|
||||||
|
CPhysicsObj::set_local_velocity(po, &leaveVel, 1); // line 306041
|
||||||
|
|
||||||
|
this->standing_longjump = 0; // 306043
|
||||||
|
this->jump_extent = 0f; // 306044 ★ extent reset
|
||||||
|
|
||||||
|
CPhysicsObj::RemoveLinkAnimations(po); // 306045
|
||||||
|
CMotionInterp::apply_current_movement(this, 0, 0); // 306046
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Order matters:** `get_leave_ground_velocity` is called BEFORE
|
||||||
|
`jump_extent` is zeroed. After LeaveGround returns, calling
|
||||||
|
`get_jump_v_z()` returns 0 (because the extent gate at the top
|
||||||
|
fires). acdream's `PlayerMovementController` correctly captures
|
||||||
|
`jumpVz` before calling `LeaveGround` for that reason
|
||||||
|
(PlayerMovementController.cs:440).
|
||||||
|
|
||||||
|
LeaveGround does NOT clear Contact / OnWalkable — that already
|
||||||
|
happened upstream in `CMotionInterp::jump` via
|
||||||
|
`CPhysicsObj::set_on_walkable(po, 0)` (line 305811).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `CMotionInterp::HitGround` — landing trigger
|
||||||
|
|
||||||
|
**Address:** `0x00528ac0` (line 305996)
|
||||||
|
|
||||||
|
```
|
||||||
|
00528ac0 void CMotionInterp::HitGround(CMotionInterp* this)
|
||||||
|
{
|
||||||
|
if (!this->physics_obj) return;
|
||||||
|
|
||||||
|
CWeenieObject* w = this->weenie_obj;
|
||||||
|
bool isCreature = w == 0 ? true : w->vtable->IsCreature();
|
||||||
|
if (!(w == 0 || isCreature)) return;
|
||||||
|
|
||||||
|
CPhysicsObj* po = this->physics_obj;
|
||||||
|
if ((po->state & 4) == 0) return; // Gravity gate
|
||||||
|
|
||||||
|
CPhysicsObj::RemoveLinkAnimations(po); // 306013
|
||||||
|
CMotionInterp::apply_current_movement(this, 0, 0); // 306014 — re-pose
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HitGround does NOT touch velocity.** It only clears the link-anim
|
||||||
|
and re-applies the current motion (which routes back through
|
||||||
|
`apply_current_movement → get_state_velocity → set_local_velocity`,
|
||||||
|
which writes the new ground velocity back to the body).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `MovementManager::HitGround` / `LeaveGround` — wrappers
|
||||||
|
|
||||||
|
**HitGround:** `0x00524300` (line 300425)
|
||||||
|
**LeaveGround:** `0x00524320` (line 300444)
|
||||||
|
|
||||||
|
```
|
||||||
|
00524300 void MovementManager::HitGround(MovementManager* this)
|
||||||
|
{
|
||||||
|
if (this->motion_interpreter)
|
||||||
|
CMotionInterp::HitGround(this->motion_interpreter);
|
||||||
|
if (this->moveto_manager)
|
||||||
|
MoveToManager::HitGround(this->moveto_manager);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
LeaveGround mirrors this. These are the public entry points; the
|
||||||
|
**caller is `CPhysicsObj::set_on_walkable`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `CPhysicsObj::set_on_walkable` — the trigger source
|
||||||
|
|
||||||
|
**Address:** `0x00511310` (line 279287)
|
||||||
|
|
||||||
|
```
|
||||||
|
00511310 void CPhysicsObj::set_on_walkable(CPhysicsObj* this, int32_t arg2)
|
||||||
|
{
|
||||||
|
uint32_t ts = this->transient_state;
|
||||||
|
uint32_t newTs = (arg2 == 0) ? (ts & ~0x2) : (ts | 0x2);
|
||||||
|
this->transient_state = newTs;
|
||||||
|
|
||||||
|
if ((ts & 0x2) == 0) // was NOT on-walkable
|
||||||
|
{
|
||||||
|
if (arg2 != 0) // → becoming on-walkable
|
||||||
|
{
|
||||||
|
if (this->movement_manager)
|
||||||
|
MovementManager::HitGround(this->movement_manager); // ★ LANDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (arg2 == 0) // was on-walkable, becoming off
|
||||||
|
{
|
||||||
|
if (this->movement_manager)
|
||||||
|
{
|
||||||
|
MovementManager::LeaveGround(this->movement_manager); // ★ LAUNCH
|
||||||
|
CPhysicsObj::calc_acceleration(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CPhysicsObj::calc_acceleration(this);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So the **edge detector** lives here:
|
||||||
|
- `OnWalkable: 0 → 1` fires `HitGround` (landing).
|
||||||
|
- `OnWalkable: 1 → 0` fires `LeaveGround` (launch).
|
||||||
|
|
||||||
|
`set_on_walkable` itself is called from two places:
|
||||||
|
|
||||||
|
1. **`CMotionInterp::jump`** (line 305811): `set_on_walkable(po, 0)` —
|
||||||
|
forces the launch edge.
|
||||||
|
2. **`CPhysicsObj::set_frame`** ground-floor sphere-sweep result
|
||||||
|
(lines 283474-283509). After the per-tick sphere-sweep stores its
|
||||||
|
resulting collision_info, this block walks contact_plane.N.z vs
|
||||||
|
`PhysicsGlobals::floor_z` (the cosine of max walkable slope, ≈
|
||||||
|
0.66 → 49°). If the contact plane is steep enough, `set_on_walkable(0)`;
|
||||||
|
if walkable, `set_on_walkable(1)`. **This is how landing detection
|
||||||
|
fires automatically in retail** — the per-tick sweep finds a walkable
|
||||||
|
surface under the body, set_on_walkable flips 0→1, HitGround fires.
|
||||||
|
|
||||||
|
So the answer to "how does retail's HitGround fire" is: **from
|
||||||
|
`set_frame` (called by the per-tick sphere sweep)**, not from a
|
||||||
|
separate trigger. The sweep computes the new contact plane; if it
|
||||||
|
classifies as walkable, the edge fires HitGround.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `CMotionInterp::jump` — the entry point
|
||||||
|
|
||||||
|
**Address:** `0x00528780` (line 305792)
|
||||||
|
|
||||||
|
```
|
||||||
|
00528780 uint32_t CMotionInterp::jump(CMotionInterp* this, float arg2, int32_t* arg3)
|
||||||
|
{
|
||||||
|
CPhysicsObj* po = this->physics_obj;
|
||||||
|
if (po == 0) return 8;
|
||||||
|
|
||||||
|
CPhysicsObj::interrupt_current_movement(po); // 305800
|
||||||
|
uint32_t result = CMotionInterp::jump_is_allowed(this, arg2, arg3);
|
||||||
|
if (result != 0) {
|
||||||
|
this->standing_longjump = 0; // 305805
|
||||||
|
return result; // failure code
|
||||||
|
}
|
||||||
|
|
||||||
|
this->jump_extent = arg2; // 305810 ★ extent
|
||||||
|
CPhysicsObj::set_on_walkable(po, 0); // 305811 ★ launch
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The launch sequence is:
|
||||||
|
1. `jump_is_allowed` validates (returns 0 on success).
|
||||||
|
2. Store extent.
|
||||||
|
3. `set_on_walkable(false)` → triggers `LeaveGround` via the wrapper
|
||||||
|
chain in step 10 above. LeaveGround reads `jump_extent` to compose
|
||||||
|
`get_leave_ground_velocity` and writes it via `set_local_velocity`.
|
||||||
|
4. Returns to caller (PlayerInputControl::Event_Jump_NonAutonomous,
|
||||||
|
line 376264) which sends 0xF61C MoveToState + 0xF74E VectorUpdate
|
||||||
|
(via Event_Jump → JumpPack).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `CMotionInterp::get_jump_v_z` — vertical component
|
||||||
|
|
||||||
|
**Address:** `0x00527aa0` (line 304953)
|
||||||
|
|
||||||
|
```
|
||||||
|
00527aa0 float CMotionInterp::get_jump_v_z(CMotionInterp const* this)
|
||||||
|
{
|
||||||
|
float extent = this->jump_extent; // 304957
|
||||||
|
if (extent < 0.000199999995f) return 0.0f; // 304959 (epsilon)
|
||||||
|
if (extent > 1.0f) extent = 1.0f; // 304968-973 clamp
|
||||||
|
|
||||||
|
CWeenieObject* w = this->weenie_obj; // 304975
|
||||||
|
if (w == 0) return /* fallback */; // returns last-result reg (10.0f from DAT)
|
||||||
|
|
||||||
|
return w->vtable->InqJumpVelocity(extent, ...); // 304980
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InqJumpVelocity` is the per-character jump-skill curve. Returns
|
||||||
|
the actual launch v_z in m/s (e.g. extent=1.0 with jump-skill 300
|
||||||
|
→ ~7.8 m/s peak from PlayerWeenie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. `CMotionInterp::get_leave_ground_velocity` — full launch vector
|
||||||
|
|
||||||
|
**Address:** `0x005280c0` (line 305404)
|
||||||
|
|
||||||
|
```
|
||||||
|
005280c0 void CMotionInterp::get_leave_ground_velocity(
|
||||||
|
CMotionInterp* this, Vector3* arg2)
|
||||||
|
{
|
||||||
|
CMotionInterp::get_state_velocity(this, arg2); // 305408 — XY (body-local)
|
||||||
|
arg2->z = CMotionInterp::get_jump_v_z(this); // 305409+305411
|
||||||
|
|
||||||
|
// If all three components are |x| < 0.0002 (near-zero):
|
||||||
|
if (|arg2->x| < eps && |arg2->y| < eps && |arg2->z| < eps)
|
||||||
|
{
|
||||||
|
// Project current world velocity into body-local frame via the
|
||||||
|
// transposed orientation matrix m_fl2gv.
|
||||||
|
// (lines 305434-305440)
|
||||||
|
arg2->x = m_fl2gv[0]*velX + m_fl2gv[1]*velY + m_fl2gv[2]*velZ;
|
||||||
|
arg2->y = m_fl2gv[3]*velX + m_fl2gv[4]*velY + m_fl2gv[5]*velZ;
|
||||||
|
arg2->z = m_fl2gv[6]*velX + m_fl2gv[7]*velY + m_fl2gv[8]*velZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The fast-path is the common case: `get_state_velocity` writes (X=strafe,
|
||||||
|
Y=forward, Z=0); then we overwrite Z with v_z. The fallback only fires
|
||||||
|
when state-velocity AND jump_v_z are all near zero (e.g. zero-extent
|
||||||
|
jump while standing still); it preserves residual world velocity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. `CMotionInterp::jump_is_allowed`
|
||||||
|
|
||||||
|
**Address:** `0x005282b0` (line 305509)
|
||||||
|
|
||||||
|
```
|
||||||
|
005282b0 uint32_t CMotionInterp::jump_is_allowed(
|
||||||
|
CMotionInterp* this, float arg2, int32_t* arg3)
|
||||||
|
{
|
||||||
|
CPhysicsObj* po = this->physics_obj;
|
||||||
|
if (po == 0) return 0x24; // (no obj) GeneralFail
|
||||||
|
|
||||||
|
// Creature path:
|
||||||
|
CWeenieObject* w = this->weenie_obj;
|
||||||
|
bool wIsCreature = w ? w->vtable->IsCreature() : true;
|
||||||
|
if (w != 0 && !wIsCreature)
|
||||||
|
{
|
||||||
|
// Non-creature — always allowed-ish, fall through to weenie checks.
|
||||||
|
goto label_5282f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creature: require Gravity flag set AND grounded (Contact + OnWalkable).
|
||||||
|
if ((po->state & 4) == 0) return 0x24; // 305561 — no gravity, can't jump
|
||||||
|
uint8_t ts = po->transient_state;
|
||||||
|
if (!((ts & 0x1) && (ts & 0x2))) return 0x24; // 305566 — not grounded → 0x24
|
||||||
|
|
||||||
|
label_5282f6:
|
||||||
|
if (CPhysicsObj::IsFullyConstrained(po)) return 0x47; // 305524
|
||||||
|
|
||||||
|
// Pending-queue check.
|
||||||
|
LListData* head = this->pending_motions.head_;
|
||||||
|
uint32_t pendingErr = head ? head->jumpErr : 0;
|
||||||
|
if (head == 0 || pendingErr == 0)
|
||||||
|
{
|
||||||
|
pendingErr = CMotionInterp::jump_charge_is_allowed(this);
|
||||||
|
if (pendingErr == 0)
|
||||||
|
{
|
||||||
|
uint32_t mErr = CMotionInterp::motion_allows_jump(this,
|
||||||
|
this->interpreted_state.forward_command);
|
||||||
|
if (mErr != 0) return mErr;
|
||||||
|
if (this->weenie_obj == 0) return mErr; // 0
|
||||||
|
// Stamina cost check.
|
||||||
|
if (w->vtable->JumpStaminaCost(arg2, arg3) != 0)
|
||||||
|
return 0;
|
||||||
|
return 0x47; // not enough stamina
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pendingErr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error codes:
|
||||||
|
- `0x00` = success
|
||||||
|
- `0x24` = `CantJumpInAir` / general motion failure
|
||||||
|
- `0x47` = `CantJumpFromPosition` (constrained, weenie-blocked, or stamina)
|
||||||
|
- `0x48` = `CantJumpFromMotion` (motion command blocks jump — emote, etc.)
|
||||||
|
- `0x49` = weenie-blocked (load-down)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. `CMotionInterp::contact_allows_move`
|
||||||
|
|
||||||
|
**Address:** `0x00528240` (line 305471)
|
||||||
|
|
||||||
|
```
|
||||||
|
00528240 int32_t CMotionInterp::contact_allows_move(
|
||||||
|
CMotionInterp const* this, uint32_t arg2)
|
||||||
|
{
|
||||||
|
if (this->physics_obj == 0) return 0;
|
||||||
|
// Always allow these "anchor" commands regardless of grounding:
|
||||||
|
if (arg2 == 0x40000015 || arg2 == 0x40000011) return 1; // (305481-482)
|
||||||
|
if (arg2 >= 0x6500000d && arg2 <= 0x6500000e) return 1; // (305478) — sidestep cmds
|
||||||
|
// Non-creature → allow.
|
||||||
|
CWeenieObject* w = this->weenie_obj;
|
||||||
|
if (w != 0 && !w->vtable->IsCreature()) return 1; // (305490)
|
||||||
|
// No gravity → allow.
|
||||||
|
if ((this->physics_obj->state & 4) == 0) return 1; // (305495)
|
||||||
|
// Grounded (Contact + OnWalkable) → allow.
|
||||||
|
uint8_t ts = this->physics_obj->transient_state;
|
||||||
|
if ((ts & 0x1) && (ts & 0x2)) return 1; // (305500)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the per-command grounding check. Walk/run only succeed when
|
||||||
|
grounded; emotes/sidesteps always succeed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. `CPhysicsObj::calc_acceleration` — gravity application
|
||||||
|
|
||||||
|
**Address:** `0x00510950` (line 278533)
|
||||||
|
|
||||||
|
```
|
||||||
|
00510950 void CPhysicsObj::calc_acceleration(CPhysicsObj* this)
|
||||||
|
{
|
||||||
|
uint8_t ts = this->transient_state;
|
||||||
|
// Grounded: zero accel (and zero omega). Tests Contact && OnWalkable
|
||||||
|
// && (state & "Hooked-from-floor" bit). Real semantics: grounded creature
|
||||||
|
// gets no gravity acceleration.
|
||||||
|
if ((ts & 1) && (ts & 2) && (this->state & 0x???) == 0)
|
||||||
|
{
|
||||||
|
accel = (0, 0, 0);
|
||||||
|
omega = (0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Gravity flag NOT set: zero accel.
|
||||||
|
if ((this->state & 4) == 0) // line 278549 — Gravity bit (state & 4)
|
||||||
|
{
|
||||||
|
accel = (0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Airborne with gravity: apply downward gravity.
|
||||||
|
accel = (0, 0, PhysicsGlobals::gravity); // line 278559 (gravity = -9.8 m/s²)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So the **state.Gravity bit (0x4)** is what enables gravity application
|
||||||
|
in retail. It's set when the creature enters the world and stays set;
|
||||||
|
it does NOT need to be re-set on each jump. Acdream's `OnLiveVectorUpdated`
|
||||||
|
explicitly setting `State |= Gravity` is defensive — if your remote-tracking
|
||||||
|
code preserved the bit from the moment the body was created, this would
|
||||||
|
be a no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. State writes during a complete jump arc — answers to the brief
|
||||||
|
|
||||||
|
| Phase | What happens | Who writes what |
|
||||||
|
|---|---|---|
|
||||||
|
| **Pre-jump** | Standing, OnWalkable=1, Contact=1, Gravity=set, vel=0 | initial state |
|
||||||
|
| **Player jump start** | `MotionInterp::jump(extent)` succeeds | `jump_extent = extent`; `set_on_walkable(0)` (clears OnWalkable=2 bit) |
|
||||||
|
| ↳ via set_on_walkable | edge 1→0 fires `MovementManager::LeaveGround` | TransientState OnWalkable bit cleared; calc_acceleration recalcs (now (0,0,-9.8) since !grounded) |
|
||||||
|
| ↳ via LeaveGround | computes launch vector | `set_local_velocity(leaveVel)` → `set_velocity(worldVel)` writes m_velocityVector + sets Active + sets `jumped_this_frame=1`; clears `jump_extent=0` |
|
||||||
|
| **Per-tick airborne** | UpdateObjectInternal | reads m_velocityVector, integrates pos += vel·dt + ½·accel·dt²; vel.Z += accel.Z·dt |
|
||||||
|
| **Mid-arc** | sphere-sweep finds no walkable plane | OnWalkable stays 0 |
|
||||||
|
| **Server VectorUpdate** | `0xF74E` arrives | Only `m_velocityVector` + `m_omegaVector` overwritten. Contact/OnWalkable/Gravity untouched. |
|
||||||
|
| **Landing (sweep finds floor)** | `set_frame` block 283474-283509 reclassifies contact_plane | If N.z ≥ floor_z: `set_on_walkable(true)` |
|
||||||
|
| ↳ edge 0→1 | fires `MovementManager::HitGround` | TransientState OnWalkable bit set; calc_acceleration → (0,0,0); HitGround calls RemoveLinkAnimations + apply_current_movement (which re-poses + writes new ground velocity via get_state_velocity → set_local_velocity) |
|
||||||
|
|
||||||
|
**Mid-arc UPs from server:** retail does NOT have a separate "snap" or
|
||||||
|
"integrate" path. A server PortalCellUpdate (0xF748) during flight will
|
||||||
|
just write the new position via `CPhysicsObj::SetPositionInternal` (same
|
||||||
|
sequence-gate logic in `CObjectMaint::QueuePortalCellUpdate`), and the
|
||||||
|
sphere-sweep that next tick decides if landing happened. The arc is
|
||||||
|
purely client-integrated; server position events override it
|
||||||
|
authoritatively when they arrive.
|
||||||
|
|
||||||
|
**Cycle transition Falling → Ready/Walk/Run on landing:** retail does NOT
|
||||||
|
explicitly transition. `HitGround` calls `apply_current_movement` which
|
||||||
|
re-pushes the existing `interpreted_state.forward_command`. If the player
|
||||||
|
is still holding W, that's RunForward, and the cycle naturally reverts.
|
||||||
|
The Falling animation is layered as a link-animation that
|
||||||
|
`RemoveLinkAnimations` clears at HitGround. (acdream's
|
||||||
|
`SetCycle(landingCmd)` in GameWindow.cs:3487 is a more explicit
|
||||||
|
re-pose; equivalent effect.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Cross-check vs acdream
|
||||||
|
|
||||||
|
| acdream method | retail counterpart | Match? |
|
||||||
|
|---|---|---|
|
||||||
|
| `MotionInterpreter.jump` (line 691) | `CMotionInterp::jump` 0x00528780 | ✅ matches: jump_is_allowed, JumpExtent, set_on_walkable(false) |
|
||||||
|
| `MotionInterpreter.get_jump_v_z` (722) | 0x00527aa0 | ✅ matches: epsilon gate, clamp, weenie call |
|
||||||
|
| `MotionInterpreter.get_leave_ground_velocity` (759) | 0x005280c0 | ✅ matches incl. fallback projection |
|
||||||
|
| `MotionInterpreter.LeaveGround` (901) | 0x00528b00 | ✅ matches order (vel-before-extent-reset) |
|
||||||
|
| `MotionInterpreter.HitGround` (924) | 0x00528ac0 | ✅ matches |
|
||||||
|
| `MotionInterpreter.jump_is_allowed` | 0x005282b0 | ✅ matches |
|
||||||
|
| `MotionInterpreter.contact_allows_move` | 0x00528240 | ✅ matches |
|
||||||
|
| `PhysicsBody.set_velocity` (206) | 0x005113f0 | ⚠️ **MISSING `jumped_this_frame = 1`** + missing transient_state.Active gate on `state & 1` (Static) |
|
||||||
|
| `PhysicsBody.set_local_velocity` (236) | 0x005114d0 | ✅ matches (Quaternion equivalent of matrix multiply) |
|
||||||
|
| `GameWindow.OnLiveVectorUpdated` (3246) | `SmartBox::DoVectorUpdate` 0x004521c0 | ⚠️ **acdream sets Gravity / clears Contact+OnWalkable; retail does NOT.** Acdream's behavior is defensive; retail relies on the bits already being correct from launch. |
|
||||||
|
|
||||||
|
### Issues / divergences worth filing
|
||||||
|
|
||||||
|
1. **`PhysicsBody.set_velocity` is missing `jumped_this_frame = 1`.**
|
||||||
|
Without this flag the next-tick collision sweep clamps the +Z
|
||||||
|
velocity to zero before gravity can lift the body. Effective bug:
|
||||||
|
first-frame after launch the body may be re-clamped to floor.
|
||||||
|
Acdream may be papering over this elsewhere (e.g. by deferring
|
||||||
|
sweep until N+2) — worth verifying whether per-tick code reads a
|
||||||
|
`JumpedThisFrame` member.
|
||||||
|
|
||||||
|
2. **Acdream `OnLiveVectorUpdated` extra writes vs retail.** Acdream
|
||||||
|
sets `State |= Gravity` and clears Contact+OnWalkable when v.Z>0.5.
|
||||||
|
Retail's `SmartBox::DoVectorUpdate` does only set_velocity + set_omega.
|
||||||
|
The reason acdream needs the extra writes is that acdream's per-tick
|
||||||
|
integrator gates gravity on `!OnWalkable` instead of `state & Gravity`.
|
||||||
|
If we ported `calc_acceleration` faithfully (state.Gravity bit set
|
||||||
|
at body creation, persists across jumps), the OnLiveVectorUpdated
|
||||||
|
bit-setting would become unnecessary.
|
||||||
|
|
||||||
|
3. **K-fix15 question (airborne + IsOnGround + Velocity.Z<=0):** retail's
|
||||||
|
landing detection has nothing to do with Velocity.Z. It uses the
|
||||||
|
sphere-sweep's contact plane (per `CPhysicsObj::set_frame`
|
||||||
|
collision_info path) and compares `contact_plane.N.z` against
|
||||||
|
`floor_z`. Acdream's velocity-Z-based landing heuristic in
|
||||||
|
`OnLivePositionUpdated` is a pragmatic shortcut (we don't have a
|
||||||
|
full sphere-sweep on remotes), but is materially divergent from
|
||||||
|
retail. Long-term, when remote sphere-sweep lands, swap to the
|
||||||
|
N.z gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File written
|
||||||
|
|
||||||
|
`docs/research/2026-05-04-l3-port/10-vector-update-jump.md`
|
||||||
1029
docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md
Normal file
1029
docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md
Normal file
File diff suppressed because it is too large
Load diff
745
docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md
Normal file
745
docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md
Normal file
|
|
@ -0,0 +1,745 @@
|
||||||
|
# Hard-Teleport (Branch A) + Sequence-Number Plumbing — Retail Pseudo-C Extract
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C)
|
||||||
|
**Cross-reference:** `docs/research/named-retail/acclient.h`,
|
||||||
|
`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs`,
|
||||||
|
`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PositionPack.generated.cs`
|
||||||
|
**Companion:** `03-up-routing.md` (the standard tri-state router)
|
||||||
|
|
||||||
|
This note drills into Branch A (hard-teleport) of `MoveOrTeleport`, the
|
||||||
|
`newer_event` 16-bit-wrap helper, the `update_times[]` slot map, the
|
||||||
|
`SmartBox::HandleReceivedPosition` instance-stamp gate, the
|
||||||
|
`SetPosition`/`SetPositionInternal` flag-bit decoder used by Branch A,
|
||||||
|
the wire layout of the four PositionPack u16 stamps, and acdream's
|
||||||
|
current end-to-end gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `CPhysicsObj::newer_event` — the 16-bit-wrap stamp comparator
|
||||||
|
|
||||||
|
**File line:** 90712 — `0x00451b10`
|
||||||
|
|
||||||
|
Verbatim retail (decompiler abs-delta noise replaced with the
|
||||||
|
arithmetic that's actually emitted):
|
||||||
|
|
||||||
|
```c
|
||||||
|
90712 int32_t __thiscall CPhysicsObj::newer_event(
|
||||||
|
90712 class CPhysicsObj* this,
|
||||||
|
90712 enum PhysicsTimeStamp arg2, // slot index 0..8
|
||||||
|
90712 uint16_t arg3) // wire-side new stamp
|
||||||
|
90712 {
|
||||||
|
90716 esi = this->update_times[arg2]; // stored stamp
|
||||||
|
90718 edi = arg3; // received stamp
|
||||||
|
90721 // signed delta: diff = (int32)((uint32)edi - (uint32)esi)
|
||||||
|
90722 // abs(diff) → eax_4
|
||||||
|
90723 eax_4 = abs(diff);
|
||||||
|
90726 if (eax_4 > 0x7fff)
|
||||||
|
90727 c = (edi < esi); // wrapped: received is OLDER
|
||||||
|
90728 else
|
||||||
|
90729 c = (esi < edi); // not wrapped: received is NEWER if c
|
||||||
|
90731 if (diff == 0)
|
||||||
|
90732 return 0; // EQUAL → not newer
|
||||||
|
90734 this->update_times[arg2] = edi; // STORE new value
|
||||||
|
90735 return 1; // NEWER → 1
|
||||||
|
90712 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decoder note.** The Binary Ninja output expands the abs(delta) into
|
||||||
|
`HIGHD/LOWD ^ - -` salad (lines 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
|
||||||
|
```
|
||||||
598
docs/research/2026-05-04-l3-port/13-cycle-picker.md
Normal file
598
docs/research/2026-05-04-l3-port/13-cycle-picker.md
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
# 13 — Retail's cycle decision tree
|
||||||
|
|
||||||
|
**Question**: When `InterpretedMotionState` has simultaneous
|
||||||
|
`forward_command=RunForward` + `sidestep_command=SidestepRight` +
|
||||||
|
`turn_command=TurnLeft`, **what cycle plays in retail?**
|
||||||
|
|
||||||
|
**Answer (TL;DR)**: All three. Retail does **not** pick "the winning
|
||||||
|
substate" out of a 3-axis state. Instead, `apply_interpreted_movement`
|
||||||
|
issues **three separate `DoInterpretedMotion` calls** —
|
||||||
|
forward-cmd, then sidestep-cmd, then turn-cmd — each landing in
|
||||||
|
`CMotionTable::GetObjectSequence`, which dispatches by command **class
|
||||||
|
bits** (0x40000000 substate / 0x10000000 action / 0x20000000 modifier)
|
||||||
|
to **either replace the substate or attach a modifier**. Forward goes
|
||||||
|
into the substate slot; sidestep+turn go into the modifier list. The
|
||||||
|
`CSequence` is rebuilt with all three layers via `add_motion`.
|
||||||
|
|
||||||
|
This means **acdream's "priority winner" picker is wrong** — and so is
|
||||||
|
the `RunForward → WalkForward → Ready` fallback chain. Retail has no
|
||||||
|
fallback chain; it just calls `GetObjectSequence` per axis and ignores
|
||||||
|
NULL results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Top of the call tree — `CMotionInterp::apply_interpreted_movement`
|
||||||
|
|
||||||
|
Line **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) |
|
||||||
722
docs/research/2026-05-04-l3-port/14-local-player-audit.md
Normal file
722
docs/research/2026-05-04-l3-port/14-local-player-audit.md
Normal file
|
|
@ -0,0 +1,722 @@
|
||||||
|
# 14 — Acdream audit: LOCAL player motion (the actor side)
|
||||||
|
|
||||||
|
Date: 2026-05-04. Scope: every file that touches our own `+Acdream`
|
||||||
|
character's motion — the simulator that reads keyboard input, drives
|
||||||
|
`PhysicsBody` + `MotionInterpreter`, animates the player entity, and
|
||||||
|
broadcasts MoveToState / AutonomousPosition / JumpAction over the wire.
|
||||||
|
Counterpart to `06-acdream-audit.md` (remote / observed motion).
|
||||||
|
|
||||||
|
Inputs read in detail:
|
||||||
|
|
||||||
|
- `src/AcDream.App/Input/PlayerMovementController.cs` (885 LOC)
|
||||||
|
- `src/AcDream.App/Input/PlayerModeAutoEntry.cs`
|
||||||
|
- `src/AcDream.Core/Physics/PlayerWeenie.cs` (81 LOC)
|
||||||
|
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — local-player call
|
||||||
|
sites (`apply_current_movement`, `DoMotion`, `DoInterpretedMotion`,
|
||||||
|
`StopInterpretedMotion`, `LeaveGround`, `HitGround`, `jump`,
|
||||||
|
`get_jump_v_z`, `get_state_velocity`).
|
||||||
|
- `src/AcDream.Core.Net/WorldSession.cs` — outbound sequence counters,
|
||||||
|
`NextGameActionSequence`, `SendGameAction` plumbing.
|
||||||
|
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (165 LOC) — 0xF61C
|
||||||
|
packet builder.
|
||||||
|
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` (89 LOC) —
|
||||||
|
0xF753 packet builder.
|
||||||
|
- `src/AcDream.Core.Net/Messages/JumpAction.cs` — 0x... jump packet.
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs` — local-player wiring at
|
||||||
|
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`.
|
||||||
|
|
@ -7,32 +7,34 @@ namespace AcDream.Core.Physics;
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// InterpolationManager — retail CPhysicsObj interpolation queue.
|
// InterpolationManager — retail CPhysicsObj interpolation queue.
|
||||||
//
|
//
|
||||||
// Ports:
|
// Source spec: docs/research/2026-05-04-l3-port/04-interp-manager.md
|
||||||
// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
|
// Retail addresses (Sept-2013 EoR PDB):
|
||||||
// InterpolationManager::adjust_offset (acclient @ 0x00555D30)
|
// InterpolationManager::InterpolateTo acclient @ 0x00555B20
|
||||||
// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip
|
// 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
|
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
|
||||||
// passes current body position + max-speed from the motion table; we return
|
// current body position + max-speed from the motion table; we return the
|
||||||
// the delta vector to apply to the body for this frame.
|
// world-space delta vector to apply to the body for this frame.
|
||||||
//
|
//
|
||||||
// Queue semantics:
|
// Public C# API kept Vector3-based for compatibility with PositionManager and
|
||||||
// - Head = next target. Body walks toward head at catch-up speed.
|
// GameWindow callsites; retail-spec method names are documented inline. The
|
||||||
// - Tail = most-recent server position. On stall we blip directly to tail
|
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
|
||||||
// (retail UseTime @ 0x00555F20: copies tail_ position, calls
|
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
|
||||||
// CPhysicsObj::SetPositionSimple, then StopInterpolating).
|
// queue's purposes — see audit 04-interp-manager.md § 4.
|
||||||
//
|
//
|
||||||
// Constants verified from named binary at the addresses cited above (not
|
// Bug fixes applied vs prior port (audit § 7):
|
||||||
// guesses):
|
// #1: progress_quantum accumulates dt (not step magnitude).
|
||||||
// MAX_INTERPOLATED_VELOCITY_MOD = 2.0
|
// #3: far-branch Enqueue sets node_fail_counter = 4 → immediate next-tick
|
||||||
// MAX_INTERPOLATED_VELOCITY = 7.5
|
// blip-to-tail. Triggered by distance > AutonomyBlipDistance (100 m).
|
||||||
// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters)
|
// #4: secondary stall test ports the retail formula verbatim:
|
||||||
// DESIRED_DISTANCE = 0.05
|
// cumulative_progress / progress_quantum / dt < 0.30.
|
||||||
|
// #5: tail-prune is a tail-walking loop (collapses multiple stale entries).
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Internal queue node. type=1 = Position waypoint (only kind we use).</summary>
|
||||||
/// Waypoint used internally by <see cref="InterpolationManager"/>.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class InterpolationNode
|
internal sealed class InterpolationNode
|
||||||
{
|
{
|
||||||
public Vector3 TargetPosition;
|
public Vector3 TargetPosition;
|
||||||
|
|
@ -41,7 +43,7 @@ internal sealed class InterpolationNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-remote-entity position interpolation queue. Caller enqueues server
|
/// Per-remote-entity position interpolation queue. Caller enqueues server
|
||||||
/// position updates and calls <see cref="AdjustOffset"/> once per physics
|
/// position updates and calls <see cref="AdjustOffset"/> once per physics
|
||||||
/// tick to get the per-frame correction delta.
|
/// tick to get the per-frame correction delta.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -49,281 +51,339 @@ public sealed class InterpolationManager
|
||||||
{
|
{
|
||||||
// ── public constants (retail binary values) ───────────────────────────────
|
// ── public constants (retail binary values) ───────────────────────────────
|
||||||
|
|
||||||
/// <summary>Maximum waypoints held before oldest is dropped.</summary>
|
/// <summary>Maximum waypoints held before oldest (head) is dropped.</summary>
|
||||||
public const int QueueCap = 20;
|
public const int QueueCap = 20;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier.
|
/// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier.
|
||||||
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30).
|
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float MaxInterpolatedVelocityMod = 2.0f;
|
public const float MaxInterpolatedVelocityMod = 2.0f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fallback catch-up speed (m/s) when motion-table max speed is
|
/// Fallback catch-up speed (m/s) when motion-table max speed is
|
||||||
/// unavailable (zero/tiny).
|
/// unavailable. Retail MAX_INTERPOLATED_VELOCITY (@ 0x40f00000 line 353137).
|
||||||
/// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float MaxInterpolatedVelocity = 7.5f;
|
public const float MaxInterpolatedVelocity = 7.5f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-5-frame stall progress threshold (meters). Body must advance at
|
/// Per-5-frame stall progress threshold (meters).
|
||||||
/// least this far in <see cref="StallCheckFrameInterval"/> frames or
|
|
||||||
/// the window counts as a stall.
|
|
||||||
/// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
|
/// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float MinDistanceToReachPosition = 0.20f;
|
public const float MinDistanceToReachPosition = 0.20f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reach + duplicate-prune radius (meters). Node is popped when
|
/// Reach + duplicate-prune radius (meters).
|
||||||
/// distance to its target falls below this value; new enqueues within
|
|
||||||
/// this distance of the tail are ignored.
|
|
||||||
/// Retail DESIRED_DISTANCE (@ 0x00555D30).
|
/// Retail DESIRED_DISTANCE (@ 0x00555D30).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float DesiredDistance = 0.05f;
|
public const float DesiredDistance = 0.05f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of ticks between stall progress checks.
|
/// Number of ticks per stall progress check window.
|
||||||
/// Retail frame_counter threshold (@ 0x00555E14).
|
/// Retail frame_counter threshold (@ 0x00555E14).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int StallCheckFrameInterval = 5;
|
public const int StallCheckFrameInterval = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimum fraction of cumulative progress_quantum that counts as "real
|
/// Secondary stall ratio threshold — port verbatim from retail.
|
||||||
/// progress" in a stall check window. Below this fraction the window
|
/// Audit notes the formula has odd units (1/sec); not our bug to fix.
|
||||||
/// counts as a stall (secondary check, applies when progress_quantum > 0).
|
|
||||||
/// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
|
/// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float StallProgressMinFraction = 0.30f;
|
public const float StallProgressMinFraction = 0.30f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stall-fail counter threshold. The body is blipped to the tail of the
|
/// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this
|
||||||
/// queue when <c>node_fail_counter</c> EXCEEDS this value (i.e., on the
|
/// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail > 3.
|
||||||
/// 4th consecutive failed window, not the 3rd).
|
|
||||||
/// Retail: <c>node_fail_counter > 3</c> (@ 0x00555F39).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int StallFailCountThreshold = 3;
|
public const int StallFailCountThreshold = 3;
|
||||||
|
|
||||||
// ── internals ─────────────────────────────────────────────────────────────
|
/// <summary>
|
||||||
|
/// Distance threshold (meters) above which an Enqueue is treated as a far
|
||||||
private readonly LinkedList<InterpolationNode> _queue = new();
|
/// jump and pre-arms an immediate blip. Retail outdoor value; indoor is
|
||||||
|
/// 20 m. Bug #3 fix from audit § 7.
|
||||||
/// <summary>Frames elapsed since the last 5-frame stall-check window fired.</summary>
|
/// </summary>
|
||||||
private int _framesSinceLastStallCheck = 0;
|
public const float AutonomyBlipDistance = 100.0f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cumulative sum of per-frame <c>step</c> magnitudes within the current
|
/// Sentinel for original_distance before the first window baseline is
|
||||||
/// 5-frame window. Retail <c>progress_quantum</c>.
|
/// taken. Retail value (@ 0x00555D30 ctor) is 999999f.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private float _progressQuantum = 0f;
|
public const float OriginalDistanceSentinel = 999999f;
|
||||||
|
|
||||||
/// <summary>
|
private const float FEpsilon = 0.0002f;
|
||||||
/// Distance to the head node recorded at the START of the current
|
|
||||||
/// 5-frame window. Retail <c>original_distance</c>.
|
|
||||||
/// </summary>
|
|
||||||
private float _distanceAtWindowStart = 0f;
|
|
||||||
|
|
||||||
/// <summary>
|
// ── internals (retail field names in comments) ────────────────────────────
|
||||||
/// True once the first valid distance sample has been taken and
|
|
||||||
/// <c>_distanceAtWindowStart</c> is populated. Guards against the
|
|
||||||
/// first-window false-positive that occurs when the field defaults to 0.
|
|
||||||
/// </summary>
|
|
||||||
private bool _haveBaselineDistance = false;
|
|
||||||
|
|
||||||
/// <summary>
|
private readonly LinkedList<InterpolationNode> _queue = new(); // position_queue
|
||||||
/// Number of consecutive 5-frame windows that failed both the absolute
|
|
||||||
/// and ratio progress checks. Retail <c>node_fail_counter</c>.
|
private int _frameCounter = 0; // frame_counter
|
||||||
/// Blip fires when this EXCEEDS <see cref="StallFailCountThreshold"/>.
|
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
|
||||||
/// </summary>
|
private float _originalDistance = OriginalDistanceSentinel; // original_distance
|
||||||
private int _failCount = 0;
|
private int _failCount = 0; // node_fail_counter
|
||||||
|
|
||||||
// ── public API ────────────────────────────────────────────────────────────
|
// ── public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>True when the queue holds at least one waypoint.</summary>
|
/// <summary>True when the queue holds at least one waypoint.</summary>
|
||||||
public bool IsActive => _queue.Count > 0;
|
public bool IsActive => _queue.Count > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Current waypoint count (visible to tests for cap verification).</summary>
|
||||||
/// Current waypoint count (visible to the test assembly for cap verification).
|
|
||||||
/// </summary>
|
|
||||||
internal int Count => _queue.Count;
|
internal int Count => _queue.Count;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stop interpolating: clear the queue and reset all stall counters.
|
/// Stop interpolating: drain queue and reset all stall state to sentinel
|
||||||
/// Retail StopInterpolating / destructor cleanup.
|
/// values. Retail StopInterpolating (@ 0x00555950).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
_queue.Clear();
|
_queue.Clear();
|
||||||
_framesSinceLastStallCheck = 0;
|
_frameCounter = 0;
|
||||||
_progressQuantum = 0f;
|
_progressQuantum = 0f;
|
||||||
_distanceAtWindowStart = 0f;
|
_originalDistance = OriginalDistanceSentinel;
|
||||||
_haveBaselineDistance = false;
|
_failCount = 0;
|
||||||
_failCount = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enqueue a new server-authoritative position waypoint.
|
/// Enqueue a new server-authoritative waypoint. Implements retail
|
||||||
///
|
/// <c>InterpolateTo</c> branching:
|
||||||
/// <para>
|
/// <list type="bullet">
|
||||||
/// Step 1: Duplicate-prune — if the new target is within
|
/// <item><b>Already-close</b>: if distance(body, target) ≤
|
||||||
/// <see cref="DesiredDistance"/> of the current tail, ignore it.<br/>
|
/// <see cref="DesiredDistance"/>, queue is wiped (StopInterpolating)
|
||||||
/// Step 2: Cap — if the queue is already at <see cref="QueueCap"/>,
|
/// and no node is enqueued.</item>
|
||||||
/// drop the oldest (head) entry.<br/>
|
/// <item><b>Far</b>: if distance(reference, target) >
|
||||||
/// Step 3/4: Append a new <see cref="InterpolationNode"/>.
|
/// <see cref="AutonomyBlipDistance"/>, enqueue and set
|
||||||
/// </para>
|
/// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an
|
||||||
///
|
/// immediate blip on the next AdjustOffset call.</item>
|
||||||
/// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0).
|
/// <item><b>Near</b>: tail-prune loop collapses adjacent stale entries
|
||||||
|
/// within <see cref="DesiredDistance"/>; cap at 20 (head eviction);
|
||||||
|
/// enqueue.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="targetPosition">Server-reported world position.</param>
|
/// <param name="targetPosition">Server-reported world position.</param>
|
||||||
/// <param name="heading">Server-reported heading (radians, AC convention).</param>
|
/// <param name="heading">Server-reported heading (radians).</param>
|
||||||
/// <param name="isMovingTo">True when the body is in motion — gates heading validity.</param>
|
/// <param name="isMovingTo">True when body is currently following an MTP.</param>
|
||||||
public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo)
|
/// <param name="currentBodyPosition">
|
||||||
|
/// Body's current world position. Used for the already-close check (versus
|
||||||
|
/// body) and as the fallback distance reference when the queue is empty.
|
||||||
|
/// Pass <c>null</c> if not available — far/near classification falls back
|
||||||
|
/// to "near" (no pre-armed blip).
|
||||||
|
/// </param>
|
||||||
|
public void Enqueue(
|
||||||
|
Vector3 targetPosition,
|
||||||
|
float heading,
|
||||||
|
bool isMovingTo,
|
||||||
|
Vector3? currentBodyPosition = null)
|
||||||
{
|
{
|
||||||
// Step 1: duplicate-prune
|
// Retail compares dist against either the tail's stored position
|
||||||
if (_queue.Last is { } last)
|
// (if tail exists AND tail->type == 1) or the body's m_position.
|
||||||
|
Vector3 reference;
|
||||||
|
bool haveTail = _queue.Last is { } tail;
|
||||||
|
if (haveTail)
|
||||||
{
|
{
|
||||||
if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance)
|
reference = _queue.Last!.Value.TargetPosition;
|
||||||
return;
|
}
|
||||||
|
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)
|
if (_queue.Count >= QueueCap)
|
||||||
_queue.RemoveFirst();
|
_queue.RemoveFirst();
|
||||||
|
|
||||||
// Steps 3+4: add node
|
// 3. Append.
|
||||||
var node = new InterpolationNode
|
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
|
||||||
|
{
|
||||||
|
_queue.AddLast(new InterpolationNode
|
||||||
{
|
{
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = target,
|
||||||
Heading = heading,
|
Heading = heading,
|
||||||
IsHeadingValid = isMovingTo,
|
IsHeadingValid = isMovingTo,
|
||||||
};
|
});
|
||||||
_queue.AddLast(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute the per-frame position correction delta.
|
/// Compute the per-frame world-space correction delta. Combines the retail
|
||||||
|
/// <c>UseTime</c> blip-check (fail_count > 3 → snap to tail, clear queue)
|
||||||
|
/// with the per-frame <c>adjust_offset</c> step computation.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// Returns <see cref="Vector3.Zero"/> when:
|
||||||
/// Returns <see cref="Vector3.Zero"/> when the queue is empty or when
|
/// • queue is empty,
|
||||||
/// the head node has been reached. Returns a snap delta (tail −
|
/// • head reached (distance < <see cref="DesiredDistance"/>) — head pops,
|
||||||
/// currentBodyPosition) after <see cref="StallFailCountThreshold"/>
|
/// • dt is invalid (≤ 0 or NaN).
|
||||||
/// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
|
|
||||||
/// then clears the queue.
|
|
||||||
/// </para>
|
|
||||||
///
|
///
|
||||||
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
|
/// Returns the snap delta (tail − currentBodyPosition) when fail_count
|
||||||
/// UseTime stall/blip (@ 0x00555F20).
|
/// exceeds <see cref="StallFailCountThreshold"/>, then clears the queue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dt">Frame delta time (seconds).</param>
|
/// <param name="dt">Frame delta time (seconds).</param>
|
||||||
/// <param name="currentBodyPosition">Current world-space body position.</param>
|
/// <param name="currentBodyPosition">Current world-space body position.</param>
|
||||||
/// <param name="maxSpeedFromMinterp">
|
/// <param name="maxSpeedFromMinterp">
|
||||||
/// Max motion-table speed for this entity's current cycle (m/s), as
|
/// Max motion-table speed for this entity's current cycle (m/s).
|
||||||
/// reported by MotionInterpreter. Pass 0 if unavailable; the fallback
|
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
|
||||||
/// <see cref="MaxInterpolatedVelocity"/> will be used.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>World-space delta to apply to the body this frame.</returns>
|
|
||||||
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
|
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
|
||||||
{
|
{
|
||||||
// Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position.
|
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
|
||||||
if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero;
|
if (dt <= 0 || double.IsNaN(dt))
|
||||||
|
return Vector3.Zero;
|
||||||
|
|
||||||
// Step 1: empty queue → no correction
|
|
||||||
if (_queue.First is null)
|
if (_queue.First is null)
|
||||||
return Vector3.Zero;
|
return Vector3.Zero;
|
||||||
|
|
||||||
// Step 2: peek head
|
// Distance to head node (retail line 353083).
|
||||||
var headNode = _queue.First.Value;
|
var head = _queue.First.Value;
|
||||||
|
float dist = Vector3.Distance(head.TargetPosition, currentBodyPosition);
|
||||||
|
|
||||||
// Step 3: distance to head target
|
// Reach test (retail line 353089): dist ≤ DESIRED_DISTANCE → pop and
|
||||||
float dist = (headNode.TargetPosition - currentBodyPosition).Length();
|
// re-baseline. NodeCompleted(1) advances to next head, also resets the
|
||||||
|
// window state.
|
||||||
// Step 4: reached node
|
if (dist <= DesiredDistance)
|
||||||
if (dist < DesiredDistance)
|
|
||||||
{
|
{
|
||||||
_queue.RemoveFirst();
|
NodeCompleted(popHead: true, currentBodyPosition);
|
||||||
return Vector3.Zero;
|
return Vector3.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: compute catch-up speed
|
// Catch-up speed (retail line 353122 + 353128 fallback).
|
||||||
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
|
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
|
||||||
float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity;
|
float catchUp = scaled > FEpsilon ? scaled : MaxInterpolatedVelocity;
|
||||||
|
|
||||||
// Step 6: step magnitude (no overshoot)
|
// Accumulate progress_quantum (audit § 7 #1: SUM OF DT, not step).
|
||||||
float step = catchUpSpeed * (float)dt;
|
_progressQuantum += (float)dt;
|
||||||
if (step > dist)
|
_frameCounter++;
|
||||||
step = dist;
|
|
||||||
|
|
||||||
// Step 7: direction × step
|
// 5-frame stall window check (retail line 353146).
|
||||||
Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step;
|
if (_frameCounter >= StallCheckFrameInterval)
|
||||||
|
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
_distanceAtWindowStart = dist;
|
float cumulative = _originalDistance - dist;
|
||||||
_haveBaselineDistance = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressQuantum += step;
|
// Primary check (retail line 353150-353166):
|
||||||
_framesSinceLastStallCheck++;
|
// cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20)
|
||||||
|
bool primaryPass = cumulative >= MinDistanceToReachPosition;
|
||||||
|
|
||||||
if (_framesSinceLastStallCheck >= StallCheckFrameInterval)
|
// Secondary check (retail line 353169-353172, audit § 7 #4):
|
||||||
{
|
// cumulative > F_EPSILON
|
||||||
float cumulativeProgress = _distanceAtWindowStart - dist;
|
// 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;
|
if (!primaryPass && !secondaryPass)
|
||||||
bool secondaryFail = _progressQuantum > 0f &&
|
|
||||||
(cumulativeProgress / _progressQuantum) < StallProgressMinFraction;
|
|
||||||
|
|
||||||
if (primaryFail || secondaryFail)
|
|
||||||
{
|
{
|
||||||
_failCount++;
|
_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
|
else
|
||||||
{
|
{
|
||||||
_failCount = 0;
|
_failCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the 5-frame window regardless of pass/fail.
|
// Re-baseline window regardless of pass/fail.
|
||||||
_framesSinceLastStallCheck = 0;
|
_frameCounter = 0;
|
||||||
_progressQuantum = 0f;
|
_progressQuantum = 0f;
|
||||||
_distanceAtWindowStart = dist;
|
_originalDistance = dist;
|
||||||
|
}
|
||||||
|
else if (_originalDistance >= OriginalDistanceSentinel - 0.5f)
|
||||||
|
{
|
||||||
|
// First call after Clear / new motion: seed the baseline so the
|
||||||
|
// first 5-frame window's cumulative is computed against frame-0
|
||||||
|
// distance, not the 999999f sentinel. Retail handles this via
|
||||||
|
// the sentinel itself — the sentinel produces a huge cumulative
|
||||||
|
// that always passes — but we use a baseline-seeded approach so
|
||||||
|
// the secondary check has sane progress_quantum behavior.
|
||||||
|
_originalDistance = dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 9: return per-frame delta
|
// Retail UseTime blip check (@ 0x00555F39): fail_count > 3 → snap to
|
||||||
|
// tail, clear queue. Placed AFTER the stall window logic so it fires
|
||||||
|
// in the same tick as both:
|
||||||
|
// (a) the just-incremented fail_count from a stall window pass, AND
|
||||||
|
// (b) a far-branch Enqueue pre-arm (fail_count = 4 set externally).
|
||||||
|
// Retail splits this into a separate UseTime call; we collapse it.
|
||||||
|
if (_failCount > StallFailCountThreshold)
|
||||||
|
{
|
||||||
|
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
|
||||||
|
Clear();
|
||||||
|
return tailPos - currentBodyPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-frame step magnitude (retail line 353218).
|
||||||
|
float step = catchUp * (float)dt;
|
||||||
|
// No-overshoot scaling (retail line 353231): if step would overshoot
|
||||||
|
// dist, clamp to dist.
|
||||||
|
if (step > dist)
|
||||||
|
step = dist;
|
||||||
|
|
||||||
|
// Direction × step.
|
||||||
|
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
|
||||||
return delta;
|
return delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
|
||||||
|
/// popHead=false during stall fail (re-baseline only). For our collapsed
|
||||||
|
/// architecture we always re-baseline on pop.
|
||||||
|
/// </summary>
|
||||||
|
private void NodeCompleted(bool popHead, Vector3 currentBodyPosition)
|
||||||
|
{
|
||||||
|
_frameCounter = 0;
|
||||||
|
_progressQuantum = 0f;
|
||||||
|
|
||||||
|
if (popHead && _queue.First != null)
|
||||||
|
{
|
||||||
|
_queue.RemoveFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-baseline on the new head, or reset to sentinel if queue empty.
|
||||||
|
if (_queue.First is { } newHead)
|
||||||
|
{
|
||||||
|
_originalDistance = Vector3.Distance(newHead.Value.TargetPosition, currentBodyPosition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_originalDistance = OriginalDistanceSentinel;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,52 @@ public sealed class InterpolationManagerTests
|
||||||
"First stall window must NOT trigger a blip (would require > 3 consecutive failures).");
|
"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]
|
[Fact]
|
||||||
public void AdjustOffset_DtZeroOrNegative_ReturnsZero()
|
public void AdjustOffset_DtZeroOrNegative_ReturnsZero()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue