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

27 KiB
Raw Blame History

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_stateLeaveGround); 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