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