10 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_hiddenat0x00514C60CPartArray::HandleEnterWorldat0x00517D70MotionTableManager::HandleEnterWorldat0x0051BDD0CSequence::remove_all_link_animationsat0x00524CA0CPhysicsObj::UpdateObjectInternalat0x005156B0CPhysicsObj::UpdatePositionInternalat0x00512C30CPhysicsObj::set_frameat0x00514090CPartArray::SetFrameat0x00519310CPartArray::UpdatePartsat0x005190F0
Installed retail DAT confirmation:
- Lifestone Recall
0x10000153resolves to animation0x030009BF. - 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
0x33000331contains fourteenCreateParticlehooks 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. Portal exit waits for render publication as well as collision data
Named retail references:
gmSmartBoxUI::BeginTeleportAnimationat0x004D6300gmSmartBoxUI::EndTeleportAnimationat0x004D65A0gmSmartBoxUI::UseTimeat0x004D6E30SmartBox::teleport_in_progressat0x00451C20SmartBox::UseTimeat0x00455410
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.
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:
TeleportWorldReady(destination):
if destination claim cannot be hydrated:
return true so the existing loud forced-placement path diagnoses it
if destination is indoor:
return render center landblock Near-tier and ready
AND destination EnvCell collision data ready
return every landblock in the priority near ring Near-tier and render-ready
AND every landblock in that ring collision-resident
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.
3. Successful indoor transitions commit the canonical outbound Position
Named retail references:
CPhysicsObj::SetPositionInternal(CTransition const*)at0x00515330CPhysicsObj::UpdateObjectInternalat0x005156B0CommandInterpreter::SendMovementEventat0x006B4680CommandInterpreter::SendPositionEventat0x006B4770CommandInterpreter::ShouldSendPositionEventat0x006B45E0
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.