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>
693 lines
27 KiB
Markdown
693 lines
27 KiB
Markdown
# 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`
|