fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -44,6 +44,76 @@ Copy this block when adding a new issue:
---
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate
**Severity:** HIGH
**Filed:** 2026-07-16
**Component:** portal VFX / streaming / physics / outbound movement
**Description:** The purple player silhouette in portal space sampled the tail
of the recall action instead of the finished Ready pose. Some outdoor exits
briefly revealed grey/unloaded landblocks during the view-plane zoom. A retail
observer watching acdream enter a dungeon saw movement while commands were
active, but stopping snapped acdream back to the dungeon entrance.
**Root cause / status:** These were three lifecycle truths hidden by the same
portal gate. Hidden entities skipped part-pose composition entirely, so
`HandleEnterWorld` changed the sequence cursor to Ready without publishing that
pose before the zero-time Hidden PES emitted the silhouette. Portal readiness
checked collision residency but not actual mesh/texture render readiness; the
captured destination additionally failed whole landblock builds when dense
procedural scenery exceeded the obsolete 256-id namespace. Finally,
`PhysicsBody.CellPosition` advanced only outdoors: indoor resolves updated the
controller cell but left the canonical outbound frame at the portal entrance.
The implementation now samples Hidden sequence poses without advancing time,
joins render and collision readiness before world reveal, uses a collision-free
`0x8XXYYIII` 4,096-entry scenery namespace, and commits every successful indoor
transition's full cell/frame before movement serialization. Render readiness
tracks both static GfxObjs and EnvCell shell geometry through completed WB
uploads and requires the complete priority ring to be Near-tier. Stale worker
completions carry a hard-recenter generation, so even overlapping old loads and
unloads cannot overwrite the replacement window. A Near load demoted before
its first publication is converted to the equivalent terrain-only Far payload
instead of leaving a hole. Mesh upload no longer manufactures a second logical
owner; static and synthetic EnvCell geometry pins release symmetrically, while
zero-owner late uploads stay evictable. EnvCell publication replays its
schema-aware preparation request after those pins are installed, closing an
eviction race without routing synthetic ids through generic GfxObj decode. The
bounded CPU cache retains texture payloads and re-stages evicted meshes exactly
once instead of returning a never-uploaded or blank cache hit. A self-contained
promotion that supersedes a queued Far load now publishes directly as a real
Near landblock, so readiness cannot wait forever for a base the streamer
intentionally cancelled; later Far completion cannot overwrite that Near tier.
Near-to-Far now tears down the full Near-only App/Core layer while preserving
terrain, and repeated current-generation Near completions are ignored before
they can duplicate statics, scripts, pins, or callbacks. Static collision is
removed by owner before prefix cleanup, including footprints flooded across a
landblock seam, while server-live/dynamic registrations remain refloodable.
Full unload uses the same owner rule; surviving owners seeded across the seam
track withdrawn prefixes so a later reload restores their missing collision
rows.
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`;
`src/AcDream.App/Streaming/StreamingController.cs`;
`src/AcDream.App/Streaming/GpuWorldState.cs`;
`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`;
`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`;
`src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs`;
`src/AcDream.Core/Physics/PhysicsBody.cs`;
`src/AcDream.App/Input/PlayerMovementController.cs`.
**Research:** `docs/research/2026-07-16-portal-completion-pseudocode.md`.
**Acceptance:** Recall/portal Hidden particles outline the finished standing
pose; outdoor exits reveal only fully rendered terrain/scenery; and a retail
observer sees acdream run and stop repeatedly inside a dungeon without any
snap back to the entrance.
---
## #217 — Character windows did not receive live 64-bit experience updates
**Status:** DONE — 2026-07-13, connected gate user-confirmed

View file

@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail portal/view-plane lifecycle, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. Login placement keeps #135's split: a hydratable indoor claim gates on its EnvCell floor (`IsSpawnCellReady`) rather than meaningless dungeon terrain; outdoor placement gates on terrain. **#218 refinement (2026-07-16):** F751 portal-space exit joins BOTH publication domains in `TeleportWorldReady`: indoor requires the center landblock's Near-tier static/EnvCell mesh set uploaded plus the EnvCell in physics, while outdoor requires that Near-tier render readiness plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Rendering/GameWindow.cs` (`TeleportWorldReady` + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail completing its blocking cell load before `EndTeleportAnimation`: neither an empty collision world, a terrain-only Far shell, nor a published-but-not-drawable GPU landblock may be revealed. Indoor does not require a terrain heightmap, only the owning render landblock and the exact EnvCell. | Gate opens early → grey/untextured reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → portal transit reaches its existing loud timeout/forced-placement diagnostic instead of silently hanging forever. | retail synchronous cell load before SetPosition / `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |

View file

@ -125,6 +125,27 @@ before installing retail's distant light. The lifecycle, camera, animation,
and draw ordering are acdream-owned ports of `gmSmartBoxUI`; WorldBuilder never
implemented this UI viewport.
**Portal destination render-readiness seam (2026-07-16).**
`GpuWorldState.IsRenderReady` does not treat dictionary publication as a draw
barrier. `LandblockSpawnAdapter` retains the complete required-id set for each
landblock: atlas-tier GfxObjs plus the synthetic geometry ids prepared by the
independent EnvCell shell pipeline. `WbMeshAdapter.IsRenderDataReady` opens the
gate only after `ObjectMeshManager` has real GPU render data. Its bounded CPU
cache retains texture payloads and stages a missing GPU object through a
deduplicated upload queue on cache hit, covering eviction and revisit churn.
GPU upload is deliberately not an ownership acquire: atlas GfxObjs use their
landblock/entity pins, while synthetic EnvCell geometry uses a no-generic-decode
pin balanced from each landblock snapshot. A late upload after all owners have
released enters the evictable LRU instead of resurrecting a reference. After
publication pins the synthetic ids, the controller replays their immutable
environment/cell-structure/surface preparation descriptors; if a formerly
unowned mesh was evicted between worker scheduling and publication, this
schema-aware replay re-stages it without a generic GfxObj lookup.
Near-to-Far demotion is a separate App transaction: it releases EnvCell
rendering and landblock mesh pins while retaining the terrain slot. Core's
matching physics demotion preserves the terrain surface but removes indoor
cells, portals, buildings, and static shadow registrations.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
@ -194,6 +215,13 @@ Retail `TexMerge::CopyAndTile` (`0x00503580`) and `TexMerge::Merge`
| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
| `SceneryInstance` | Per-spawn instance data |
acdream's streamed projection assigns generated instances local runtime IDs
through `AcDream.Core.World.ProceduralSceneryIdAllocator`. The namespace is
`0x8XXYYIII` (full X/Y bytes plus a 12-bit counter); bit 31 remains the stable
scenery classification seam. This is projection identity, not placement
behavior. The former 8-bit counter rejected dense retail-DAT landblocks before
their render transaction could publish (#218).
### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc)
| Component | What it does |

View file

@ -27,7 +27,8 @@ active enchantment, then recall through the DAT-driven portal presentation.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
complete retained magic UI interaction checklist.
4. **Pending user visual gate:** two-client portal-out/materialization observer
presentation before M3 closes.
presentation before M3 closes, including #218's finished Hidden silhouette,
fully rendered destination reveal, and no indoor stop-time position snap.
Opportunistic UI/physics fixes are no longer the work-order driver. They enter
the active slice only when they block this demo or represent a severe regression.

View file

@ -332,6 +332,37 @@ the Lifestone recall boundary and a MagicPortal casting timeline. The user
confirmed `/ls` and spell-recall arrivals no longer replay their animation
tails; additional portal visual polish is explicitly deferred.
**2026-07-16 corrective portal gate (#218).** A side-by-side retail observer
check found three distinct state-publication defects after the arrival-tail
fix. Hidden now recomposes the post-`HandleEnterWorld` cyclic pose without
advancing animation time before its zero-time purple PES hooks run. Portal
space now waits for both completed static/EnvCell mesh uploads (including the
texture flush) and collision residency; dense
outdoor landblocks no longer abort at the obsolete 256-entry procedural
scenery id boundary (`0x8XXYYIII`, 4,096 entries). Finally, successful indoor
transitions commit the full resolved EnvCell/frame into the canonical outbound
Position, preventing stop-time snaps to the dungeon entrance on retail
observers. GPU-evicted meshes are re-staged from a bounded texture-complete CPU
cache instead of becoming false-ready or permanently absent. Portal readiness
also requires Near-tier ownership throughout the ring; stale worker completions
carry a hard-recenter generation and cannot overwrite a newer tier/window, and
a first Near completion demoted in flight is published as its equivalent
terrain-only Far payload rather than dropped. Upload completion is separate
from logical mesh ownership; landblock/static/EnvCell pins now balance across
unload and zero-owner late uploads remain evictable. A self-contained promotion
that supersedes a queued Far load publishes directly as a real Near landblock;
there is no provisional transaction waiting indefinitely for a base that the
streamer intentionally cancelled. Near-to-Far now has an explicit cross-layer
demotion transaction that preserves terrain while retiring EnvCells, indoor
physics/buildings, static collision, lights, translucency state, entities, and
render pins. Cross-landblock static shadow footprints are removed by logical
owner while dynamic registrations remain refloodable; duplicate Near
completions are publication-idempotent. Full unload shares the static-owner
teardown, and surviving adjacent owners remember withdrawn prefixes so their
cross-seam rows return when a landblock is promoted again.
Automated gates are green; the connected single-/two-client visual gate is
pending.
---
#### (historical M1.5 working notes below)

View file

@ -24,7 +24,7 @@ OUTSIDE SLICE — DrawLandscapeThroughOutsideView (RetailPViewRenderer.cs:214-23
SKYRENDERER — src/AcDream.App/Rendering/Sky/SkyRenderer.cs (WB SkyboxRenderManager port): RenderSky draws the pre-scene partition (Properties bit 0x01 clear), RenderWeather the post-scene partition (bit set) (:106-144, 219-235 — correct retail bit semantics, citing MakeObject 0x00506ee0). GL state: depth test OFF + depth mask OFF + cull off + per-submesh additive/alpha blend (:194-210) — equivalent to retail's DEPTHTEST_ALWAYS/no-write. View translation zeroed = camera-centred sky (:175-178). Weather 120 offset applied as a CAMERA-RELATIVE model translation for bit4 && !bit8 objects (:307-308). Fog-override bit 0x02 skip implemented (:240-241). sky.vert writes gl_ClipDistance from the terrain-clip UBO (src/AcDream.App/Rendering/Shaders/sky.vert:153) so the doorway slices genuinely clip sky and rain.
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x80XXYYII with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x8XXYYIII (bit-31 class + X8/Y8/counter12) with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
## DIVERGENCES

View 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.