acdream/docs/research/2026-07-16-portal-completion-pseudocode.md
Erik e0f96a55bf fix(physics): C4 route 3 — portal placement authority (local player)
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>
2026-08-05 03:57:37 +02:00

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.