acdream/docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md
Erik 124e046976 fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 08:48:27 +02:00

3.7 KiB

Local-player root-motion start — retail pseudocode (2026-07-17)

Symptom

A short forward-key tap moved the local acdream body immediately while the Ready-to-locomotion animation was still starting. The character therefore glided before its legs began the corresponding movement.

Retail oracle

Named Sept-2013 client:

  • CMotionInterp::DoInterpretedMotion @ 0x00528360
  • CMotionInterp::apply_interpreted_movement @ 0x00528600
  • CMotionInterp::apply_current_movement @ 0x00528870
  • CMotionInterp::get_state_velocity @ 0x00527D50
  • CPhysicsObj::UpdatePositionInternal @ 0x00512C30
  • CPhysicsObj::UpdateObjectInternal @ 0x005156B0
  • CPhysicsObj::get_velocity @ 0x005113C0

Cross-checks:

  • docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md, which records ACE's PartArray.Update into the shared offset frame before PositionManager.AdjustOffset.
  • docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md, which independently shows apply_interpreted_movement dispatching motions rather than installing ordinary grounded velocity.

Pseudocode

on input motion edge(command, params):
    CMotionInterp updates raw/interpreted state
    apply_interpreted_movement dispatches style, forward, sidestep, and turn
    CPhysicsObj::DoInterpretedMotion forwards those operations to MotionTableManager
    // No ordinary grounded set_local_velocity occurs here.

CPhysicsObj::UpdatePositionInternal(dt):
    offset = identity Frame

    if visible and part_array exists:
        part_array.Update(dt, offset)

    if OnWalkable:
        offset.origin *= object_scale
    else:
        offset.origin = zero

    position_manager.adjust_offset(offset, dt)

    candidate = current_position.frame combined with offset
    candidate.origin += m_velocityVector * dt + acceleration * dt^2 / 2
    m_velocityVector += acceleration * dt
    return candidate

CPhysicsObj::UpdateObjectInternal(dt):
    old = current_position
    candidate = UpdatePositionInternal(dt)
    transition = sweep(old, candidate)
    if transition succeeds:
        cached_velocity = offset(old, resolved_position) / dt
        SetPositionInternal(transition)
    else:
        cached_velocity = zero

get_state_velocity is not the ordinary grounded locomotion driver. Retail uses it for leave-ground/jump velocity and related state queries. Grounded translation comes from the complete CSequence::update Frame, including the authored Ready-to-Walk/Run link timing. CPhysicsObj::get_velocity returns the resolved cached_velocity, distinct from the integrator's m_velocityVector.

Port shape

  1. The local player's input edge must reach MotionTableManager before the current object's animation advance.
  2. AnimationSequencer.Advance(dt, rootFrame) runs exactly once for the local owner and publishes both the pose and rootFrame.Origin.
  3. PlayerMovementController accumulates that local displacement until the existing 30 Hz physics quantum runs.
  4. While grounded, scale it by the server object scale and seed the same MotionDeltaFrame passed through PositionManager.AdjustOffset.
  5. The transition sweep resolves the combined delta and computes cached velocity from the realized displacement.
  6. Do not install get_state_velocity() as ordinary grounded body velocity when a retail root-motion source is attached. Keep that function for leave-ground/jump behavior.
  7. The animation pass consumes the already-advanced pose instead of advancing the local sequence a second time.

The existing manual yaw path remains separate; this slice consumes the root Frame's translation only. Full root-orientation ownership remains part of the registered turn/omega divergence and is not guessed here.