acdream/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md
Erik a2fd61684a fix(animation): retain trailing PartArray poses
Port CPartArray::UpdateParts minimum-count behavior: preserve the prior visual and rigid transforms for setup parts absent from a short authored AnimFrame, including the initial hydrated rest pose.

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-21 09:41:15 +02:00

21 KiB

R6 complete root Frame cutover — retail pseudocode

Date: 2026-07-19

Scope: the translation and orientation emitted by CSequence and consumed by CPhysicsObj::UpdatePositionInternal. This note replaces the former acdream split between animation root translation and command-derived/manual rotation.

Named retail oracle

  • Frame::combine 0x005122E0 (acclient_2013_pseudo_c.txt:280355)
  • Frame::set_rotate 0x00535080
  • Frame::IsValid 0x00534ED0
  • Position::subtract2 (complete relative Position/Frame)
  • CPhysicsObj::UpdatePositionInternal 0x00512C30 (:280817)
  • CPhysicsObj::UpdateObjectInternal 0x005156B0
  • CPhysicsObj::SetPositionInternal(CTransition const*) 0x00515330
  • CPhysicsObj::change_cell 0x00513390
  • CPhysicsObj::remove_shadows_from_cells 0x00511230
  • CPhysicsObj::add_shadows_to_cells 0x00514AE0
  • CPhysicsObj::update_object 0x00515D10
  • CPhysicsObj::prepare_to_enter_world 0x00511FA0
  • CPhysicsObj::set_active 0x0050FC40
  • CPhysicsObj::animate_static_object 0x00513DF0
  • CPhysics::UseTime 0x00509950 (separate static-object loop)
  • CPartArray::Update 0x00517DB0 (:285883)
  • add_motion 0x005224B0 (:298437)
  • CSequence::apply_physics 0x00524AB0 (:300955)
  • InterpolationManager::InterpolateTo 0x00555B20
  • InterpolationManager::adjust_offset 0x00555D30

The installed retail portal DAT is a second executable fixture. Motion table 0x09000001 (Humanoid), global TurnRight modifier 0x0000000D, has HasOmega and literal omega (0, 0, -1.5) radians/second. It is not -pi/2, and it is not absent. The fixture is pinned by HumanoidMotionTableRootMotionTests.

ACE cross-reference: docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md records the ACE MotionTable/Sequence implementation: modifier lookup tries the styled key then the unstyled key, and add_motion writes MotionData.Velocity * speed plus MotionData.Omega * speed. ACE agrees with the named client here. Retail remains the ordering and transform oracle.

Pseudocode

add_motion(sequence, motionData, speed):
    if motionData is null:
        return

    sequence.velocity = motionData.velocity * speed
    sequence.omega    = motionData.omega    * speed
    for animation in motionData.animations:
        sequence.append_animation(animation * speed)

apply_physics(sequence, frame, magnitudeQuantum, signSource):
    signedQuantum = abs(magnitudeQuantum)
    if signSource < 0:
        signedQuantum = -signedQuantum

    frame.origin += sequence.velocity * signedQuantum
    frame.rotate(sequence.omega * signedQuantum)

CPartArray.Update(dt, localFrame):
    sequence.update(dt, localFrame)

CPhysicsObj.prepare_to_enter_world(now):
    update_time = now
    remove object and children from lost/destroy queues
    if not Static:
        transient_state.Active = true

CPhysicsObj.update_object(now):
    if parent exists or cell is null or Frozen:
        transient_state.Active = false
        return without consuming update_time

    if player_object exists:
        player_vector = offset(player.position, object.position)
        player_distance = length(player_vector)
        if player_distance <= 96 world units or partArray is null:
            set_active(true)
        else:
            transient_state.Active = false
    // With no player_object, retain the prior Active state.

    elapsed = now - update_time
    if elapsed <= F_EPSILON or elapsed > HugeQuantum:
        update_time = now
        return

    while elapsed > MaxQuantum:
        UpdateObjectInternal(MaxQuantum)
        update_time += MaxQuantum
        elapsed -= MaxQuantum

    if elapsed > MinQuantum:
        UpdateObjectInternal(elapsed)
        update_time = now
    // A remainder at or below MinQuantum stays pending.

CPartArray.InitDefaults(setup):
    if setup.DefaultAnimation != 0:
        sequence.clear_animations()
        sequence.append_animation(
            animation = setup.DefaultAnimation,
            low = 0,
            high = -1,
            framerate = 30)
    owner.InitDefaults(setup)

CPhysicsObj.InitDefaults(setup):
    if setup.DefaultScript != 0:
        play_script_internal(setup.DefaultScript)
    install setup default motion/sound/physics-script tables
    if state has Static:
        if setup.DefaultAnimation != 0:
            state |= HasDefaultAnim
        if setup.DefaultScript != 0:
            state |= HasDefaultScript
        if state has HasDefaultAnim or HasDefaultScript:
            CPhysics.AddStaticAnimatingObject(this)

CPhysicsObj.animate_static_object(now):
    if cell is null:
        return
    elapsed = now - update_time
    if elapsed <= F_EPSILON or elapsed > 2 seconds:
        update_time = now
        return
    if partArray exists:
        if state has HasDefaultAnim:
            partArray.Update(elapsed, null)
            // Retail passes the stored vector directly; no elapsed multiply.
            position.frame.grotate(angularVelocity)
            UpdatePartsInternal()
            UpdateChildrenInternal()
        if state has HasDefaultScript and scriptManager exists:
            scriptManager.UpdateScripts()
        if particleManager exists:
            particleManager.UpdateParticles()
        process_hooks()
    update_time = now

CPhysics.UseTime(now):
    for object in ordinary object table:
        object.update_object(now)
    for object in static_animating_objects:
        object.animate_static_object(now)
    // These are separate ownership/workset paths. InitDefaults registers any
    // physics-Static object carrying Setup DefaultAnimation/DefaultScript,
    // including a server-created live object.

CPhysicsObj.UpdatePositionInternal(dt, outputWorldFrame):
    localFrame = identity

    if not Hidden:
        if partArray exists:
            partArray.Update(dt, localFrame)

        if OnWalkable:
            localFrame.origin *= objectScale
        else:
            localFrame.origin *= 0
        // Orientation is never cleared or scaled by this gate.

    if positionManager exists:
        positionManager.adjust_offset(localFrame, dt)

    outputWorldFrame = combine(position.frame, localFrame)

    if not Hidden:
        UpdatePhysicsInternal(dt, outputWorldFrame)

    process_hooks()

Frame.combine(output, current, delta):
    output.origin = current.origin + rotate(current.orientation, delta.origin)
    output.set_rotate(current.orientation * delta.orientation)

Frame.set_rotate(candidate):
    previous = orientation
    orientation = normalize(candidate)
    if not Frame.IsValid():
        orientation = previous

InterpolationManager.adjust_offset(delta, dt):
    if no Position node or body is not in Contact:
        return without mutating delta
    relative = Position.subtract2(target, current)
    relative.origin = clamp_to_catchup_distance(relative.origin, dt)
    if manager.keep_heading:
        relative.frame.set_heading(0)
    delta = relative.frame // replaces both origin and orientation

## Part-count retention (`CPartArray::UpdateParts` @ `0x005190F0`)

```text
count = min(partArray.num_parts, animationFrame.num_parts)
for i in 0 .. count - 1:
    part[i].frame = combine_scaled(animationFrame.part[i], partArray.scale)

// No loop touches [count .. partArray.num_parts).
// Those CPhysicsPart frames therefore retain their complete prior state.

The retained state has two presentation channels in acdream:

  • the visible mesh transform retains Setup DefaultScale, animated frame, and object ObjScale;
  • the indexed rigid/effect pose retains animated orientation and the ObjScale-adjusted origin, without Setup DefaultScale.

On the first presentation, "prior" means the already-hydrated Setup/rest frames installed by PartArray construction. A short later AnimFrame retains the most recently presented transform in both channels. Resetting missing parts to origin/identity is not retail behavior.

CPhysicsObj.SetPositionInternal(transition): if transition.current_cell is null: prepare_to_leave_visibility() store_position(transition.current_position) object_maintenance.goto_lost_cell() clear Active return

if current cell differs:
    change_cell(transition.current_cell)
else:
    commit the new full objcell id to the object, PartArray, and children
set_frame(transition.current_position.frame)
commit contact plane, OnWalkable, sliding normal, and collision callbacks
if transition has a non-empty crossed-cell array:
    remove_shadows_from_cells()
    add_shadows_to_cells(transition.cell_array)

The ordering consequence is important: a delta translates through the old
orientation and only then applies its relative rotation. A large elapsed frame
must run multiple complete object quanta in time order. A render fragment below
the strict minimum does not advance CSequence at all; it stays in the object
clock until a later admitted quantum.

## Port mapping

- `MotionDeltaFrame.Combine` implements the retail composition and scales only
  the incoming origin.
- One incarnation-stable `RetailObjectQuantumClock` is shared by local,
  remote, Hidden, and projectile components of each live object. It admits
  complete object quanta; the local animation callback writes one reusable
  `MotionDeltaFrame` only after admission. `prepare_to_enter_world` clears the
  retained fragment and immediately restores Active only for non-Static
  objects, so their next elapsed frame can run; Static objects retain the
  inactive state established by initialization/leave-visibility. It does not
  invent a second reactivation frame.
- `CPartArray::InitDefaults` installs Setup DefaultAnimation for every
  setup-backed PartArray, independent of Static. Static controls only the
  additional `static_animating_objects -> animate_static_object` workset.
  Both DAT-hydrated statics and server-created objects whose final physics
  state is Static enter that workset when their Setup carries a
  DefaultAnimation. Default animation bypasses the MotionTable and installs
  the Setup animation directly over `0..last` at 30 frames/second. A short
  cell-less interval remains on the owner's clock and catches up after
  re-entry; an interval above two seconds is rebased and discarded. Script-only
  static owners still use acdream's shared PhysicsScriptRunner and are covered
  by TS-51 rather than entering an empty animation scan.
  A live static workset entry binds the exact PartArray and PhysicsBody already
  owned by its `LiveEntityRecord`; it never constructs a second sequencer or
  body. The raw angular-velocity vector is applied without multiplying by
  elapsed time, exactly as the named function does. A changed root commits the
  entity, effect pose, and multipart collision shadow together. Zero omega does
  not trigger a collision reflood, and a Hidden/nonresident object cannot have
  its suspended shadow restored by this animation path.
- Animation hooks are captured after `UpdatePhysicsInternal` and before the
  transition/manager tail. Semantic `AnimationDone` executes there, matching
  `CPhysicsObj::process_hooks`. Other pose-dependent router sinks remain
  deferred until final root/part/equipped-child pose publication; that known
  residual is registered as TS-50 rather than claimed as exact ordering.
  Static owners retain their semantic hook tail until final root, part, and
  attached-child publication, matching `animate_static_object`'s later
  `process_hooks` call. A residency change invalidates both the prepared pose
  and that pending tail.
- Retail session dispatch and `CPhysics::UseTime` are single-threaded FIFO
  operations. App therefore wraps complete packet and object-frame operations
  in `RetailInboundEventDispatcher`: a callback-triggered packet queues behind
  the current operation instead of recursively interleaving with its tail.
  Position, State, Vector, and Movement keep separate authority versions;
  Position/Vector/Movement additionally share only a velocity-write version.
  Never replace these with one global body-mutation token: that incorrectly
  discards independent accepted channels (for example State during Position).
  The direct packet/frame path carries state into a cached static callback and
  allocates nothing; only genuinely nested work receives a retained FIFO node.
- Projectile `BeginQuantum` is candidate-only. It restores the canonical begin
  frame/dynamics before returning; `CompleteQuantum` may commit only while the
  exact live record, projectile runtime, entity, spatial owner, and prediction
  version remain current. Any accepted Position, Vector, or State invalidates
  the candidate without rolling back the authoritative mutation.
- Animation-hook batches capture the pose owner's monotonic lifetime before
  running semantic AnimationDone. Capture rechecks it before every completion,
  and drain rechecks that lifetime and root pose before every routed hook,
  because either AnimationDone or an earlier sink can delete the owner and
  reuse the same local ID synchronously.
- Remote animation advances into one reusable DAT `Frame`; both components are
  passed to `RemotePhysicsUpdater` and applied through the same
  `PositionManager` frame.
- A body-backed animated object without a MovementManager now commits its
  complete Frame through `LiveEntityOrdinaryPhysicsUpdater`: root translation
  is admitted only while OnWalkable, orientation remains live, the resulting
  candidate runs through Transition/SetPositionInternal, and the same record is
  rebucketed without recreating resources. Callback deletion/replacement is
  revalidated before every commit.
- The resolved body and `WorldEntity` root/full-cell frame are committed before
  the canonical rebucket callback, matching SetPositionInternal's frame/contact
  commit before its final shadow replacement. A loaded-to-pending rebucket (or
  same-GUID replacement during its visibility callback) invalidates the caller
  before shadow or manager-tail publication. Non-projectile shadow residency is
  symmetric in `LiveEntityPresentationController`, including the pending-first
  case whose initial false edge precedes collision registration; projectiles
  retain their dedicated residency owner.
- Local projection and both authoritative remote UpdatePosition tails use the
  same post-rebucket exact-owner gate. Remote collision refresh is forced by a
  cell change or by a changed complete pose (translation or sign-invariant
  quaternion), so offset/multipart Setup shapes rotate in place and a same-pose
  cell crossing cannot retain an old flood seed.
- A retained projectile remains the same CPhysicsObj when Missile clears. With
  no MovementManager it continues through the ordinary one-sphere transition
  owner; with a MovementManager the owner transfers exactly once to remote
  motion. Projection re-entry restores its retained collision shadow on the
  visibility edge, before any later simulation quantum.
- Active interpolation preserves the target's complete quaternion and replaces
  the PartArray Frame. The already-close `InterpolateTo` branch applies only
  the target heading, matching `CPhysicsObj::set_heading`. `keep_heading` is
  manager-wide, not a per-node flag; each near enqueue replaces it.
- `FrameOps.SetRotate` rejects zero, non-finite, and otherwise invalid
  quaternion candidates by restoring the prior orientation.
- Spawn, teleport, correction, and outbound movement/position serialization
  read the authoritative body quaternion directly; none round-trips through
  the horizontal `Yaw` projection.
- `MotionTableDispatchSink` only dispatches motion-table commands. It no longer
  reports turn callbacks.
- `RemoteMotion.ObservedOmega`, the player/NPC multiplication-order fork, and
  `AnimationSequencer.SetCycle`'s synthetic `pi/2` turn omega are deleted.
- A controller deliberately constructed without a `PartArray` retains a
  Humanoid-only fallback at the DAT-pinned `1.5` radians/second. A normal GUI
  client does not enter that path.

## Conformance gates

- `MotionDeltaFrameTests`: exact `FrameOps.Combine` equivalence, scale affects
  origin only, and temporal turn-then-move composition.
- `RetailObjectQuantumClockTests`: strict bounds, retained remainder,
  MaxQuantum subdivision, HugeQuantum discard, `set_active` rebasing, and
  Static-aware `prepare_to_enter_world` reset.
- `RetailObjectActivityGateTests` and `LiveEntityRuntimeTests`: the 96-unit
  PartArray gate, parent/cell/Frozen suspension, loaded rebucket retention,
  pending re-entry rebasing, body/clock Active synchronization, and GUID-reuse
  clock isolation.
- `LiveEntityAnimationSchedulerTests`: the canonical live-object workset also
  advances objects without a render animation, consumes their queued near
  UpdatePosition correction, applies complete generic root
  Frames, transfers a retained missile body exactly once when classification
  clears, and stops catch-up immediately when a callback deletes, withdraws,
  or replaces an incarnation.
- `RetailStaticAnimatingObjectSchedulerTests` and
  `AnimationSequencerTests`: unconditional Setup DefaultAnimation installation,
  DAT/live physics-Static workset membership, direct 30-fps playback,
  short-residency-gap catch-up, canonical PartArray/body reuse, raw (not
  elapsed-scaled) omega, synchronized multipart shadow rotation, zero-omega
  no-op commits, callback-safe removal, and the retail greater-than-two-second
  discard. `StaticLiveRootCommitter` coverage proves Hidden omega cannot
  resurrect a suspended shadow.
- `RemoteInboundMotionDispatcherTests`: one animation-optional owner preserves
  retail packet-head/style/type-0/sticky ordering with a null PartArray sink;
  unknown movement types execute the head and never enter the case-0 funnel;
  supersession during interrupt prevents every later packet side effect.
- `LiveEntityOrdinaryPhysicsUpdaterTests`: complete root Frames cross cells and
  landblocks through Transition, while hook-side deletion prevents stale body,
  shadow, pose, or spatial commits.
- `ProjectileControllerTests`: pending-cell hydration restores the same body's
  Active/InWorld/shadow residency immediately and does not replay logical
  creation; the destination cell is canonical before suspension and after
  hydration, and authoritative Position/Vector/State between prediction halves
  discards only the stale candidate.
- `RetailInboundEventDispatcherTests`, `LiveEntityPresentationControllerTests`,
  `RemoteTeleportControllerTests`, and `EntityEffectPoseRegistryTests`: nested
  packet arrival drains after the complete older tail, independent State and
  Position both survive resolver callbacks, newer Position supersedes older
  placement, Hidden-to-Visible recursion remains FIFO, and hook batches cannot
  cross deletion/local-ID reuse during capture or drain. The FIFO's nonnested
  state path is pinned at zero allocations after warmup.
- `PlayerMovementControllerTests`: full-frame local composition, large-frame
  equivalence, hidden/airborne gates, hook cadence, and proof that
  animation-backed turns are not integrated twice.
- `InterpolationManagerTests`: complete relative target orientation,
  keep-heading, and already-close heading-only behavior.
- `RemotePhysicsUpdaterTests` and `LiveEntityPresentationControllerTests`:
  translation uses the pre-turn orientation and the same frame then rotates the
  body; cross-cell loaded-to-pending commits the destination root before the
  callback boundary, suspends rather than re-adds collision, restores on
  hydration, and isolates old shadows across callback GUID reuse. Direct
  authoritative rebuckets and initial-pending materialization cover the same
  symmetric collision-residency contract; same-position rotation and cell-only
  changes refresh collision while unchanged/sign-flipped quaternion poses do
  not reflood.
- `LiveEntityRuntimeTests`: update iteration and render-ID classification copy
  only concrete spatial animation owners through reusable storage with zero
  steady-state allocations.
- `MotionTableDispatchSinkTests`: Branch-4 turn preserves the locomotion cycle,
  contributes literal DAT omega, and unwinds on stop.
- `RemoteChaseEndToEndHarnessTests`: turn, chase, re-arm, action completion, and
  sticky following run through the complete Frame plus retail manager tail.
- `HumanoidMotionTableRootMotionTests`: installed retail walk displacement and
  exact Humanoid `TurnRight` omega `-1.5` radians/second.
- `MovementManagerTests` and `PlayerMovementControllerTests`:
  `PerformMovement` invokes `set_active(1)` before request validation, while a
  Static physics object preserves retail's no-op activation behavior.

## Registered residual

The ordinary/static object worksets and their root/animation clocks are now
retail-shaped, but acdream's shared particle and PhysicsScript managers still
advance once per render frame after every owner has run. Retail advances those
tails inside each admitted object update (and inside
`animate_static_object`). This timing difference is deliberately registered as
TS-51; it is not part of the R6 conformance claim.

Final automated gate (2026-07-20): three independent retail-conformance,
architecture/integration, and adversarial re-reviews reported no actionable
finding after correction; Release build completed with zero warnings; the full
solution passed 6,446 tests with 5 intentional skips.