fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
228
docs/research/2026-07-16-portal-completion-pseudocode.md
Normal file
228
docs/research/2026-07-16-portal-completion-pseudocode.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue