Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
16 KiB
Markdown
334 lines
16 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. 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:
|
|
|
|
```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
|
|
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:
|
|
|
|
```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.
|
|
|
|
### 2.1. Destination placement enters the spatial cell before simulation resumes
|
|
|
|
> **2026-08-04 correction (C4 route 3, D-T9), itself corrected 2026-08-05
|
|
> (R6 retail review):** the listing below attributes portal arrival to
|
|
> `player.enter_world(destination)`. That is wrong — a caller sweep of the
|
|
> named retail decomp
|
|
> (`docs/research/named-retail/acclient_2013_pseudo_c.txt:93770-93828`) shows
|
|
> both `CPhysicsObj::enter_world` call sites (pseudo-C `:93797` @0x004550EC
|
|
> and `:93824` @0x00455095) living inside **`SmartBox::HandleCreateObject`
|
|
> @0x00454C80** — `CObjectMaint::CreateObject` @0x00454FD8 is merely a
|
|
> *callee* it invokes partway through, not the enclosing function the first
|
|
> correction pass named. The two call sites are also **not both in the
|
|
> player branch**: @0x004550EC sits in the `if (arg3 != this->player_id)`
|
|
> NON-player branch (`PhysicsDesc::get_position` → `enter_world` for a
|
|
> newly-created REMOTE object); only @0x00455095 sits in the player branch,
|
|
> after `SmartBox::init_player` + `CellManager::ChangePosition`. Both sites
|
|
> are the LOGIN/CreateObject path that creates a physics object for the
|
|
> first time — neither is portal arrival. Portal arrival is
|
|
> `SmartBox::TeleportPlayer` (`0x00453910`) → `CPhysicsObj::SetPositionSimple`
|
|
> (`0x00453924`/`0x005162B0`) — confirmed by C4 route 3's own §1 citations
|
|
> and grep at `acclient_2013_pseudo_c.txt:92514-92521`. The conclusion below
|
|
> (commit the cell before releasing simulation) is unaffected —
|
|
> `SetPositionSimple` reaches the identical `change_cell`/`update_object`
|
|
> machinery this section describes — only the entry-point name and
|
|
> pseudocode's `enter_world` call are wrong; read `SetPositionSimple(destination)`
|
|
> wherever this section says `enter_world(destination)`.
|
|
>
|
|
> This routing is `SmartBox::TeleportPlayer` → `SetPositionSimple`
|
|
> everywhere; nothing in the passages below distinguishes retail's specific
|
|
> Recall/Lifestone/GM-teleport CAUSES, since they all funnel through the same
|
|
> accepted-destination Position at this layer.
|
|
|
|
Named retail references:
|
|
|
|
- `CPhysicsObj::change_cell` at `0x00513390`
|
|
- `CPhysicsObj::update_object` at `0x00515D10`
|
|
- `SmartBox::TeleportPlayer` at `0x00453910`
|
|
- `CPhysicsObj::SetPositionSimple` at `0x005162B0`
|
|
- `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. `SetPositionSimple` 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.
|
|
|
|
```text
|
|
accepted portal destination becomes ready:
|
|
SmartBox.TeleportPlayer(destination)
|
|
SetPositionSimple(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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```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.
|