228 lines
10 KiB
Markdown
228 lines
10 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
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::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.
|
|
|
|
acdream's load is split into two independent readiness domains:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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*)` at `0x00515330`
|
|
- `CPhysicsObj::UpdateObjectInternal` at `0x005156B0`
|
|
- `CommandInterpreter::SendMovementEvent` at `0x006B4680`
|
|
- `CommandInterpreter::SendPositionEvent` at `0x006B4770`
|
|
- `CommandInterpreter::ShouldSendPositionEvent` at `0x006B45E0`
|
|
|
|
Retail pseudocode:
|
|
|
|
```text
|
|
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.
|