acdream/docs/research/2026-05-04-l3-port/10-vector-update-jump.md
Erik de129bc164 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>
2026-05-05 14:56:42 +02:00

693 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`