acdream/docs/research/2026-07-16-portal-completion-pseudocode.md
Erik 7aa9618136 fix(portal): commit destination projection before reveal
Retail installs the destination CObjCell before hidden world simulation resumes. Rebucket the retained local-player projection inside the Place transaction so Hidden/UnHide scripts and particles settle behind the portal viewport instead of firing after reveal. Preserve the existing unloaded-cell scheduler gate and pin the ordering with a cross-landblock placement test.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-25 10:32:21 +02:00

14 KiB

Portal completion: hidden pose, destination residency, and indoor position

This note records the retail mechanisms behind three connected portal defects observed on 2026-07-16. The named Sept-2013 retail client is the behavioral oracle. The render-residency portion is an acdream adaptation because retail loads cells synchronously while acdream publishes streamed landblocks asynchronously.

1. Hidden transition samples the post-link cyclic pose

Named retail references:

  • CPhysicsObj::set_hidden at 0x00514C60
  • CPartArray::HandleEnterWorld at 0x00517D70
  • MotionTableManager::HandleEnterWorld at 0x0051BDD0
  • CSequence::remove_all_link_animations at 0x00524CA0
  • CPhysicsObj::UpdateObjectInternal at 0x005156B0
  • CPhysicsObj::UpdatePositionInternal at 0x00512C30
  • CPhysicsObj::set_frame at 0x00514090
  • CPartArray::SetFrame at 0x00519310
  • CPartArray::UpdateParts at 0x005190F0

Installed retail DAT confirmation:

  • Lifestone Recall 0x10000153 resolves to animation 0x030009BF.
  • Its ACE-duration boundary is (150 - 0) / 9.96 = 15.06024096 s.
  • At that exact boundary the non-looping recall node remains at its final authored frame (approximately frame 150); it has not advanced the cyclic Ready node yet.
  • Hidden PES 0x33000331 contains fourteen CreateParticle hooks and one translucency hook, all at time zero. The pose published when Hidden starts therefore determines the purple player silhouette.

Retail pseudocode:

SetState(visible -> Hidden):
    set_hidden(true)
    play typed Hidden PES
    part_array.HandleEnterWorld()

MotionTableManager.HandleEnterWorld(sequence):
    sequence.remove_all_link_animations()

sequence.remove_all_link_animations():
    discard link/action nodes
    if the current node was the final link before the cyclic tail:
        current = first_cyclic
        frame = first_cyclic.starting_frame

next hidden CPhysicsObj.UpdateObjectInternal:
    do not advance PartArray animation time
    UpdatePositionInternal(...)
    later set_frame(current physics frame)
        PartArray.SetFrame(frame)
            PartArray.UpdateParts(frame)
                sample sequence.get_curr_animframe()
    ScriptManager executes the zero-time Hidden PES hooks

Port rule: Hidden suppresses time advance, not pose composition. After HandleEnterWorld changes the sequence cursor, acdream must sample the current sequence without advancing time and republish all part transforms. Forcing a Ready motion, recognizing recall by id, or freezing the previously published part pose would all diverge from this general retail mechanism.

2. Login and portal exit wait for render publication as well as collision data

Named retail references:

  • gmSmartBoxUI::BeginTeleportAnimation at 0x004D6300
  • gmSmartBoxUI::EndTeleportAnimation at 0x004D65A0
  • gmSmartBoxUI::UseTime at 0x004D6E30
  • SmartBox::teleport_in_progress at 0x00451C20
  • SmartBox::UseTime at 0x00455410

Retail performs the destination load behind the portal-space viewport. SmartBox::teleport_in_progress remains true while the player exists and position_update_complete == 0; SmartBox::UseTime only advances that position completion when blocking_for_cells == 0. When the SmartBox reports that teleport is no longer in progress, EndTeleportAnimation begins TAS_TUNNEL_CONTINUE; the normal world is not revealed before the blocking load edge has completed.

The same SmartBox::UseTime edge governs initial position completion. It does not distinguish “enough collision to stand” from “enough render state to show the world”: while CellManager::blocking_for_cells is set it only checks prefetch status, and only after the block clears does it change the player's position, set position_update_complete, advance object/physics/landscape time, and draw the normal scene. Therefore acdream's login auto-entry cannot gate on one sampled terrain height while portal arrival uses a complete render barrier. Both presentations must consume one shared asynchronous equivalent.

acdream's load is split into two independent readiness domains:

worker parses/builds landblock
    -> render thread uploads terrain/entities
    -> GpuWorldState.AddLandblock(canonical id)
    -> WbMeshAdapter.Tick uploads every required static/EnvCell mesh
       and flushes its staged texture-array updates // render readiness

worker/register path
    -> PhysicsEngine registers terrain or EnvCell // collision publication

Required asynchronous equivalent:

WorldRevealReady(destination):
    if destination claim cannot be hydrated:
        return true so the loud unhydratable-placement path diagnoses it

    if destination is indoor:
        return render center landblock Near-tier and ready
           AND destination composite textures ready
           AND destination EnvCell collision data ready

    return every landblock in the priority near ring Near-tier and render-ready
       AND destination composite textures ready
       AND every landblock in that ring collision-resident

WorldRevealReadinessBarrier.Begin invalidates destination-scoped composite readiness for both the first accepted player position and each accepted portal destination. After the render-thread mesh tick, Prepare advances composite uploads only when the required static meshes are published. Login remains behind its sky-only render gate and portal travel remains in its tunnel until the same IsReady predicate opens. This prevents the two paths from drifting apart again while preserving their different presentations.

Streaming completion is generation- and tier-aware. Every hard recenter, dungeon collapse, and dungeon expansion advances a token carried by Load, Promote, Unload, and their results. This rejects an old build even when its id overlaps the replacement region, and rejects a completed old unload that would otherwise tear down new state. Within one generation, a stale Far completion cannot overwrite a published or pending Near landblock. If an initial Near load finishes only after the region has demoted that still-unpublished landblock to Far, its already-built heightmap and mesh are published through a new terrain-only LandblockBuild (empty entity list, no EnvCell transaction, empty physics bundle). This is byte-for-behavior the payload a fresh LoadFar would build, without a second DAT read or a missing terrain slot.

GPU upload existence is not logical ownership. Landblock registrations pin static GfxObjs; EnvCell transactions use a distinct no-decode pin for their synthetic shell ids. Upload completion preserves that owner count rather than incrementing it. Unload releases the matching per-landblock snapshots, and a late upload whose owners already released enters the evictable LRU immediately. Because an unowned EnvCell mesh can be evicted between the worker's first schedule and landblock publication, publication pins every synthetic id first and then replays the immutable environment/cell-structure/surface request. The second schema-aware call is a no-op while data remains resident and a correct CPU-cache restage or specialized decode when it does not. PromoteToNear carries a complete build and terrain mesh because the streamer allows it to supersede a queued Far load. It therefore publishes directly as a real Near landblock when no base exists, installing EnvCells, collision, lights, registries, entities, and render pins in one transaction. If a Far load had already started, its later completion cannot overwrite the published Near tier. Normal demotion and unload then own the same teardown path as any other Near landblock. Demotion explicitly retires EnvCell visibility/rendering, indoor cell and building physics, static shadow objects, lights, translucency owners, static entities, and mesh pins while preserving the terrain render slot and terrain physics surface. Static shadow owners are deregistered by their seed landblock before cell-prefix removal, so footprints flooded across a landblock seam cannot survive as invisible collision; dynamic owners remain refloodable. Full unload uses the same owner teardown. For owners seeded in an adjacent resident block, the registry remembers which streamed-out prefix withdrew their cross-seam rows and includes them when that prefix refloods, restoring collision on promotion without resurrecting a removed owner. Duplicate same-generation Near completions are ignored at the publication owner, so they cannot append statics or replay scripts. There is no provisional side state waiting for a base that may never arrive.

GpuWorldState.IsRenderReady joins state publication with LandblockSpawnAdapter's required-mesh set. That set contains atlas-tier GfxObjs and the synthetic geometry ids used by EnvCell shells, and it remains false until WbMeshAdapter.TryGetRenderData succeeds for every id after a render-thread tick and texture flush. A timer or black fade is not a substitute.

GPU eviction retains a bounded re-uploadable CPU mesh payload. A cache hit re-stages that payload exactly once, preserving texture bytes, so revisit and portal churn cannot strand the readiness gate behind a CPU-only cache entry. Failed uploads retain queue ownership only through their bounded loud retry sequence; they cannot create duplicate uploads or a false-ready landblock.

The 2026-07-16 trace also found that dense outdoor landblocks failed before publication: the procedural scenery id allocator reserved only 256 ids per landblock although the retail DAT generator can produce more. The stable local id namespace therefore uses the same collision-free field allocation already used by interior statics:

procedural scenery id = 0x8XXYYIII
    class prefix: 4 bits (0x8)
    landblock X: 8 bits
    landblock Y: 8 bits
    per-landblock counter: 12 bits (0..4095)

All scenery consumers classify on bit 31 and no consumer decodes the old byte layout. Overflow must fail before aliasing the next landblock.

WorldBuilder cross-check: its upload queue similarly distinguishes generated CPU scenery from render-thread GPU publication (GameScene.ProcessUploads), which confirms that worker completion alone is not draw readiness.

2.1. Destination placement enters the spatial cell before simulation resumes

Named retail references:

  • CPhysicsObj::change_cell at 0x00513390
  • CPhysicsObj::update_object at 0x00515D10
  • CPhysicsObj::enter_world at 0x00516170
  • CPhysicsObj::prepare_to_enter_world at 0x00511FA0
  • CPhysicsObj::set_hidden at 0x00514C60

Retail does not separate an accepted destination Position from the object's live cell pointer. enter_world runs SetPosition, which installs the object in its destination CObjCell, before the PartArray and MovementManager enter-world boundaries complete. update_object then rejects only a parented object, a null cell, or a Frozen object; Hidden is not a reason to skip the ScriptManager/ParticleManager tail.

accepted portal destination becomes ready:
    player.enter_world(destination)
        SetPosition(destination)
            change_cell(destination CObjCell)
        PartArray.HandleEnterWorld()
        MovementManager.HandleEnterWorld()

    destination simulation resumes behind the portal viewport
        update_object()
            require cell != null and not Frozen
            UpdateObjectInternal()
                advance ScriptManager and ParticleManager

acdream keeps authoritative full-cell identity and render-bucket residency as separate facts because its world streams asynchronously. The portal arrival transaction must therefore commit both facts before releasing destination simulation:

LocalPlayerTeleportPlacement.Place:
    resolve and set controller/body position
    update retained WorldEntity root transform
    RebucketLiveEntity(destination full cell)
    reconcile child/effect/light poses
    signal materialized and release world simulation

A 2026-07-25 connected trace proved the missing rebucket was the cause of the spell-recall exit regression. The local record had destination FullCellId, resources, and a logical projection, but remained non-resident in its old or pending GPU bucket. The queued Hidden PES 0x33000331 consequently stayed paused until the normal world was already visible, then fired immediately before UnHide stopped it. That produced both the sudden opaque character and the late purple/recall tail. The correct fix is the missing destination spatial commit, not a recall classifier, timer, or relaxation of the legitimate unloaded-cell script gate.

3. Successful indoor transitions commit the canonical outbound Position

Named retail references:

  • CPhysicsObj::SetPositionInternal(CTransition const*) at 0x00515330
  • CPhysicsObj::UpdateObjectInternal at 0x005156B0
  • CommandInterpreter::SendMovementEvent at 0x006B4680
  • CommandInterpreter::SendPositionEvent at 0x006B4770
  • CommandInterpreter::ShouldSendPositionEvent at 0x006B45E0

Retail pseudocode:

after a successful transition:
    if transition.curr_cell == current cell:
        m_position.objcell_id = transition.curr_pos.objcell_id
    else:
        change_cell(transition.curr_cell)
    set_frame(transition.curr_pos.frame)

SendMovementEvent:
    serialize MoveToStatePack(..., player.m_position, ...)

SendPositionEvent:
    serialize AutonomousPositionPack(player.m_position, ...)

This applies equally to outdoor position cells and indoor EnvCells. The local frame origin advances by the resolved world displacement, and a successful transition adopts the resolver's exact destination cell id. Outdoor positions then use LandDefs.AdjustToOutside to canonicalize landblock/cell boundaries; indoor positions retain their EnvCell-relative frame and adopt the resolved EnvCell id.

The previous acdream behavior changed only the controller's visible CellId for indoor motion. PhysicsBody.CellPosition, which is what outbound movement serializes, stayed at the dungeon portal-entry frame. ACE could predict motion while commands continued, but the next authoritative idle position returned the retail observer to that stale frame. The correct port commits the resolved cell and frame into the one canonical PhysicsBody.CellPosition; it does not add resends, grace periods, or observer-specific correction.