diff --git a/AGENTS.md b/AGENTS.md
index 51b38ef1..80d040ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,7 +114,7 @@ book, effect indicators, shared main-panel switching, and positive/negative effe
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds
(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite
-is 6,452 passed / 5 skipped. Its unattended seven-destination
+is 6,457 passed / 5 skipped. Its unattended seven-destination
connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining
ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
locomotion/collision/projectile/teleport visual gate, then the final two-client
diff --git a/CLAUDE.md b/CLAUDE.md
index 4f02729d..9e6adcd8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,7 +112,7 @@ book, effect indicators, shared main-panel switching, and positive/negative effe
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds
(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite
-is 6,452 passed / 5 skipped. Its unattended seven-destination
+is 6,457 passed / 5 skipped. Its unattended seven-destination
connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining
ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
locomotion/collision/projectile/teleport visual gate, then the final two-client
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 94752ad6..a87fdd92 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -98,7 +98,7 @@ production runtime failure.
**Acceptance:** A non-incremental Release solution build reports zero warnings
and zero errors without suppressing analyzers or weakening nullable checking;
-the full 6,452-pass / 5-skip suite remains green.
+the full 6,457-pass / 5-skip suite remains green.
---
@@ -7431,6 +7431,28 @@ outdoors at the angle that previously erased it.
# Recently closed
+## #229 — [DONE 2026-07-20] Initial login revealed an incomplete world
+
+**Closed:** 2026-07-20
+**Resolution:** Login auto-entry waited only for the terrain height or EnvCell
+floor needed by local physics. It then removed the sky-only render gate while
+the destination's static meshes, nearby scenery, and composite textures could
+still be uploading. Portal travel appeared to repair the world because it
+already waited on all of those domains before revealing its destination.
+
+Login and portal arrival now share `WorldRevealReadinessBarrier`, the async
+equivalent of retail `SmartBox::UseTime` keeping position completion behind
+`CellManager::blocking_for_cells`. Both paths invalidate destination texture
+readiness, prepare composites after mesh publication, and require the same
+render-plus-collision predicate. Focused login/barrier tests and the complete
+Release suite pass; capped connected visual verification remains the final
+gate.
+
+**Research:**
+`docs/research/2026-07-16-portal-completion-pseudocode.md` §2.
+
+---
+
## #215 — [DONE 2026-07-13] Same-dungeon death respawn removed the dungeon floor physics
**Closed:** 2026-07-13
diff --git a/docs/README.md b/docs/README.md
index c6a643d5..2ae73f6b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,7 +20,7 @@ banner cannot silently override the current program state.
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
and UN 5 — plus the retained retired TS-37 history note.
- **Latest automated baseline:** Release build succeeds with 17 known
- test-project warnings (`#228`); 6,452 tests passed and 5 intentionally
+ test-project warnings (`#228`); 6,457 tests passed and 5 intentionally
skipped. The unattended connected R6 route
completed seven portal materializations, production input exercises, and a
graceful close. See
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 58785815..4476e988 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -239,6 +239,7 @@ src/
Streaming/
StreamingController.cs -> done
GpuWorldState.cs -> done
+ WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner
Input/
PlayerMovementController.cs -> active movement driver
Plugins/
@@ -705,6 +706,19 @@ replace state produced by another job. CPU mesh extraction may still be schedule
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
partially hydrated cell membership or shell placement to the renderer.
+### World-reveal readiness ownership
+
+`WorldRevealReadinessBarrier` is the single App-layer owner of the edge that
+allows normal world geometry to become visible at initial login or after a
+portal. It joins `StreamingController`/`GpuWorldState` static-mesh publication,
+`WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination
+residency. Login's sky-only gate and portal-space transit consume the same
+predicate; they differ only in presentation. Composite preparation advances
+after `WbMeshAdapter.Tick` on the render thread, never from the streaming
+worker. This is acdream's asynchronous equivalent of retail
+`SmartBox::UseTime` holding position completion while
+`CellManager::blocking_for_cells` is set.
+
### Teleport cell identity at the streaming boundary
The network and physics layers carry retail's complete `Position`: an
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 7ff19324..231a8856 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50).
|---|---|---|---|---|---|
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| 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 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-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains 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 portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (login auto-entry + 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 leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal an empty collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `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) |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 9a95b345..e2cdd444 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -920,7 +920,7 @@ diagnostic scaffolding, not yet the final collision system.
PositionManager facade remains the final R6 ownership cleanup.
Final 2026-07-20 gate: three independent retail/architecture/adversarial
re-reviews are clean; Release builds successfully with 17 known test-project
- warnings tracked by #228; the repository suite is 6,452 passed / 5 skipped.
+ warnings tracked by #228; the repository suite is 6,457 passed / 5 skipped.
The unattended connected Release rebaseline also
passes: seven portal materializations across Caul, Sawato, Rynthid,
Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn,
@@ -1446,7 +1446,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
## Cross-cutting work tracked in parallel
-- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,452 passed / 5 intentional skips (2026-07-20).
+- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,457 passed / 5 intentional skips (2026-07-20).
- **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues.
- **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 39b0e3cf..2ae4b3fa 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -19,7 +19,7 @@ Carried:
registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset
cutover is automated-test complete 2026-07-20; three independent re-reviews are
clean, Release builds with 17 known test-project warnings tracked by #228, and
-6,452 tests pass / 5 skip), and
+6,457 tests pass / 5 skip), and
the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha
implementation has passed connected lifetime/performance gates and awaits only
its lifestone/particle visual comparison.
diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md
index cbf0b501..bf1ae775 100644
--- a/docs/research/2026-07-16-portal-completion-pseudocode.md
+++ b/docs/research/2026-07-16-portal-completion-pseudocode.md
@@ -64,7 +64,7 @@ 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
+## 2. Login and portal exit wait for render publication as well as collision data
Named retail references:
@@ -82,6 +82,15 @@ 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
@@ -98,18 +107,28 @@ worker/register path
Required asynchronous equivalent:
```text
-TeleportWorldReady(destination):
+WorldRevealReady(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 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
diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
index 1c702c3f..2895bee2 100644
--- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs
+++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
@@ -11,8 +11,8 @@ namespace AcDream.App.Input;
/// Why is this its own class? The auto-entry has four independent
/// preconditions (live session reaches InWorld, the player
/// entity has been streamed into the world dictionary, the player
-/// movement controller is constructible, and the terrain under the
-/// spawn position has streamed in) plus a manual-override path
+/// movement controller is constructible, and the initial world is fully
+/// drawable and collidable) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires —
/// their choice wins). All five interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
@@ -44,7 +44,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func _isLiveInWorld;
private readonly Func _isPlayerEntityPresent;
private readonly Func _isPlayerControllerReady;
- private readonly Func _isSpawnGroundReady;
+ private readonly Func _isWorldReady;
private readonly Action _enterPlayerMode;
private bool _armed;
@@ -62,12 +62,10 @@ public sealed class PlayerModeAutoEntry
/// PlayerMovementController is set up. Stays true once player mode
/// is established; the auto-entry's job is to flip it from false
/// to true exactly once.
- /// True iff the terrain under the
- /// player's spawn position has streamed into the physics engine.
- /// #106 gate-2 (2026-06-09): entering player mode earlier integrates
- /// gravity against an empty world and free-falls the player into the
- /// void. Retail never has this state — it loads cells synchronously;
- /// this hold is the async-streaming equivalent of that invariant.
+ /// True iff the player's destination is
+ /// complete across render publication, composite textures, and collision.
+ /// Retail keeps position completion behind one blocking cell-load edge;
+ /// acdream's asynchronous domains must converge before entry.
/// Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
@@ -77,13 +75,13 @@ public sealed class PlayerModeAutoEntry
Func isLiveInWorld,
Func isPlayerEntityPresent,
Func isPlayerControllerReady,
- Func isSpawnGroundReady,
+ Func isWorldReady,
Action enterPlayerMode)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
- _isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady));
+ _isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
}
@@ -117,7 +115,7 @@ public sealed class PlayerModeAutoEntry
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
- if (!_isSpawnGroundReady()) return false;
+ if (!_isWorldReady()) return false;
_armed = false;
_enterPlayerMode();
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index c34f12bb..227a5cb3 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -147,6 +147,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
+ private AcDream.App.Streaming.WorldRevealReadinessBarrier? _worldRevealReadiness;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
@@ -1413,41 +1414,11 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Net.WorldSession.State.InWorld,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
- // #106 gate-2: hold player-mode entry until the terrain under
- // the spawn position has streamed in — entering earlier
- // integrates gravity against an empty world and free-falls
- // the player into the void (retail loads cells synchronously;
- // this is the async-streaming equivalent of that invariant).
- isSpawnGroundReady: () =>
- {
- if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
-
- // #107 / #135: spawn-ground readiness is spawn-claim aware. For an
- // INDOOR claim (sealed dungeon / building interior) the ground the
- // player lands on is the EnvCell FLOOR (its BSP), so gate on the
- // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
- // A dungeon's cells sit in their landblock at an arbitrary (often
- // negative) offset, so the spawn's WORLD position can fall in a
- // NEIGHBOUR terrain landblock that the #135 dungeon collapse
- // deliberately does not load; requiring terrain there hangs login
- // forever (cellReady true, SampleTerrainZ null). Retail loads the
- // cell synchronously and places the player on the cell floor —
- // cellReady is the faithful indoor equivalent (#106/#107, AD-2).
- // (Before #135 this only passed by accident: the 25×25 window
- // happened to stream the neighbour terrain.)
- if (LastSpawns.TryGetValue(_playerServerGuid, out var sp)
- && sp.Position is { } spawnClaim
- && spawnClaim.LandblockId != 0
- && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
- && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
- return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
-
- // Outdoor spawn, OR an unhydratable indoor claim that will demote to
- // an outdoor position: hold until the terrain under the spawn streams
- // (the original #106 gate — entering against an empty world free-falls
- // the player into the void).
- return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
- },
+ // Retail SmartBox::UseTime (0x00455410) completes the player
+ // position only after CellManager's blocking load clears. The
+ // shared acdream barrier joins collision, static-mesh upload,
+ // and composite-texture readiness for both login and portals.
+ isWorldReady: LoginWorldReady,
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@@ -2873,6 +2844,20 @@ public sealed class GameWindow : IDisposable
retirementCoordinator: _landblockRetirements);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
+ _worldRevealReadiness = new AcDream.App.Streaming.WorldRevealReadinessBarrier(
+ isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
+ isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
+ isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
+ areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true,
+ prepareCompositeTextures: (destinationCell, radius) =>
+ _wbDrawDispatcher?.PrepareCompositeTextures(
+ _worldState.Entities,
+ _worldState.FlatViewGeneration,
+ destinationCell,
+ radius),
+ invalidateCompositeTextures: () =>
+ _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
+ isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable);
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@@ -3624,6 +3609,13 @@ public sealed class GameWindow : IDisposable
isSealedDungeon: IsSealedDungeonCell(position.LandblockId));
}
+ // Retail SmartBox::UseTime (0x00455410) does not complete the player
+ // position while CellManager is blocking for destination cells. Begin
+ // the same shared reveal lifetime used by portal arrivals: initial
+ // login must not inherit the dispatcher's default/previous composite
+ // readiness and expose a terrain-only or partially uploaded world.
+ _worldRevealReadiness?.Begin();
+
// Streaming is normally gated until this point, so the next loaded-
// landblock callback will materialize deferred records. Also cover an
// already-resident block (tests, reconnect teardown overlap) without
@@ -7465,7 +7457,8 @@ public sealed class GameWindow : IDisposable
// standing on loaded ground with wall collision and a non-empty immediate view. The far
// ring (out to the streaming window) still drains at the per-frame budget after portal
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
- private const int TeleportNearRingRadius = 1;
+ private const int TeleportNearRingRadius =
+ AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
///
/// Bind a sequence-correlated teleport Position to presentation and
@@ -7544,7 +7537,7 @@ public sealed class GameWindow : IDisposable
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
- _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness();
+ _worldRevealReadiness?.Begin();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
@@ -7615,18 +7608,24 @@ public sealed class GameWindow : IDisposable
/// loudly rather than holding forever.
///
private bool TeleportWorldReady(uint destCell)
+ => _worldRevealReadiness?.IsReady(destCell) == true;
+
+ private bool LoginWorldReady()
{
- if (IsSpawnClaimUnhydratable(destCell)) return true;
- bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
- int renderRadius = indoor ? 0 : TeleportNearRingRadius;
- bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
- destCell,
- renderRadius) == true;
- renderReady &= _wbDrawDispatcher?.CompositeTexturesReady == true;
- return indoor
- ? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
- : renderReady
- && _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
+ return TryGetLoginWorldCell(out uint cell)
+ && _worldRevealReadiness?.IsReady(cell) == true;
+ }
+
+ private bool TryGetLoginWorldCell(out uint cell)
+ {
+ cell = 0;
+ if (_liveEntities is null
+ || !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
+ || playerSpawn.Position is not { } position)
+ return false;
+
+ cell = position.LandblockId;
+ return cell != 0;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@@ -10113,24 +10112,13 @@ public sealed class GameWindow : IDisposable
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
- if (_teleportTransit.IsActive && _pendingTeleportCell != 0)
- {
- int compositeRadius = (_pendingTeleportCell & 0xFFFFu) >= 0x0100u
- ? 0
- : TeleportNearRingRadius;
- bool destinationMeshesPublished =
- _streamingController?.IsRenderNeighborhoodResident(
- _pendingTeleportCell,
- compositeRadius) == true;
- if (destinationMeshesPublished)
- {
- _wbDrawDispatcher?.PrepareCompositeTextures(
- _worldState.Entities,
- _worldState.FlatViewGeneration,
- _pendingTeleportCell,
- compositeRadius);
- }
- }
+ uint revealCell = _teleportTransit.IsActive
+ ? _pendingTeleportCell
+ : IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
+ ? loginCell
+ : 0u;
+ if (revealCell != 0)
+ _worldRevealReadiness?.Prepare(revealCell);
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
}
if (_frameDiag)
diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs
new file mode 100644
index 00000000..102b88a0
--- /dev/null
+++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs
@@ -0,0 +1,96 @@
+namespace AcDream.App.Streaming;
+
+///
+/// Joins the render-publication, texture, and collision domains that must be
+/// complete before the normal world viewport is revealed. Retail keeps
+/// SmartBox::position_update_complete false while
+/// CellManager::blocking_for_cells is set
+/// (SmartBox::UseTime, 0x00455410). acdream loads those domains
+/// asynchronously, so login and portal arrival must share this explicit
+/// equivalent of retail's single blocking-cell edge.
+///
+internal sealed class WorldRevealReadinessBarrier
+{
+ internal const int OutdoorNeighborhoodRadius = 1;
+
+ private readonly Func _isRenderNeighborhoodReady;
+ private readonly Func _isSpawnCellReady;
+ private readonly Func _isTerrainNeighborhoodReady;
+ private readonly Func _areCompositeTexturesReady;
+ private readonly Action _prepareCompositeTextures;
+ private readonly Action _invalidateCompositeTextures;
+ private readonly Func _isSpawnClaimUnhydratable;
+
+ public WorldRevealReadinessBarrier(
+ Func isRenderNeighborhoodReady,
+ Func isSpawnCellReady,
+ Func isTerrainNeighborhoodReady,
+ Func areCompositeTexturesReady,
+ Action prepareCompositeTextures,
+ Action invalidateCompositeTextures,
+ Func isSpawnClaimUnhydratable)
+ {
+ _isRenderNeighborhoodReady = isRenderNeighborhoodReady
+ ?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady));
+ _isSpawnCellReady = isSpawnCellReady
+ ?? throw new ArgumentNullException(nameof(isSpawnCellReady));
+ _isTerrainNeighborhoodReady = isTerrainNeighborhoodReady
+ ?? throw new ArgumentNullException(nameof(isTerrainNeighborhoodReady));
+ _areCompositeTexturesReady = areCompositeTexturesReady
+ ?? throw new ArgumentNullException(nameof(areCompositeTexturesReady));
+ _prepareCompositeTextures = prepareCompositeTextures
+ ?? throw new ArgumentNullException(nameof(prepareCompositeTextures));
+ _invalidateCompositeTextures = invalidateCompositeTextures
+ ?? throw new ArgumentNullException(nameof(invalidateCompositeTextures));
+ _isSpawnClaimUnhydratable = isSpawnClaimUnhydratable
+ ?? throw new ArgumentNullException(nameof(isSpawnClaimUnhydratable));
+ }
+
+ ///
+ /// Starts a new reveal lifetime. Texture readiness is destination-scoped;
+ /// carrying the prior login/portal result across a new destination would
+ /// open the barrier before that destination's composites were uploaded.
+ ///
+ public void Begin() => _invalidateCompositeTextures();
+
+ ///
+ /// Advances render-thread texture preparation after all required static
+ /// meshes for the destination neighborhood have been published.
+ ///
+ public void Prepare(uint destinationCell)
+ {
+ if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell))
+ return;
+
+ int radius = RequiredRenderRadius(destinationCell);
+ if (_isRenderNeighborhoodReady(destinationCell, radius))
+ _prepareCompositeTextures(destinationCell, radius);
+ }
+
+ ///
+ /// True only when the same destination is drawable and collidable. An
+ /// impossible indoor claim intentionally crosses the barrier so the
+ /// existing loud forced-placement path can expose the invalid server data.
+ ///
+ public bool IsReady(uint destinationCell)
+ {
+ if (destinationCell == 0)
+ return false;
+ if (_isSpawnClaimUnhydratable(destinationCell))
+ return true;
+
+ int radius = RequiredRenderRadius(destinationCell);
+ if (!_isRenderNeighborhoodReady(destinationCell, radius)
+ || !_areCompositeTexturesReady())
+ return false;
+
+ return IsIndoor(destinationCell)
+ ? _isSpawnCellReady(destinationCell)
+ : _isTerrainNeighborhoodReady(destinationCell, radius);
+ }
+
+ private static int RequiredRenderRadius(uint destinationCell) =>
+ IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius;
+
+ private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u;
+}
diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs
new file mode 100644
index 00000000..52eb54b9
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs
@@ -0,0 +1,124 @@
+using AcDream.App.Streaming;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class WorldRevealReadinessBarrierTests
+{
+ private sealed class State
+ {
+ public bool RenderReady;
+ public bool SpawnCellReady;
+ public bool TerrainReady;
+ public bool CompositeReady;
+ public bool Unhydratable;
+ public int Invalidations;
+ public int Preparations;
+ public uint PreparedCell;
+ public int PreparedRadius = -1;
+ public int RenderRadius = -1;
+ public int TerrainRadius = -1;
+
+ public WorldRevealReadinessBarrier Build() => new(
+ isRenderNeighborhoodReady: (cell, radius) =>
+ {
+ RenderRadius = radius;
+ return RenderReady;
+ },
+ isSpawnCellReady: _ => SpawnCellReady,
+ isTerrainNeighborhoodReady: (cell, radius) =>
+ {
+ TerrainRadius = radius;
+ return TerrainReady;
+ },
+ areCompositeTexturesReady: () => CompositeReady,
+ prepareCompositeTextures: (cell, radius) =>
+ {
+ Preparations++;
+ PreparedCell = cell;
+ PreparedRadius = radius;
+ },
+ invalidateCompositeTextures: () => Invalidations++,
+ isSpawnClaimUnhydratable: _ => Unhydratable);
+ }
+
+ [Fact]
+ public void Begin_InvalidatesPriorDestinationTextureReadiness()
+ {
+ var state = new State();
+ var barrier = state.Build();
+
+ barrier.Begin();
+
+ Assert.Equal(1, state.Invalidations);
+ }
+
+ [Fact]
+ public void OutdoorReveal_JoinsNearRenderTexturesAndTerrain()
+ {
+ const uint outdoorCell = 0x11340021u;
+ var state = new State();
+ var barrier = state.Build();
+
+ Assert.False(barrier.IsReady(outdoorCell));
+ Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.RenderRadius);
+
+ state.RenderReady = true;
+ barrier.Prepare(outdoorCell);
+ Assert.Equal(1, state.Preparations);
+ Assert.Equal(outdoorCell, state.PreparedCell);
+ Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.PreparedRadius);
+
+ state.CompositeReady = true;
+ Assert.False(barrier.IsReady(outdoorCell));
+
+ state.TerrainReady = true;
+ Assert.True(barrier.IsReady(outdoorCell));
+ Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.TerrainRadius);
+ }
+
+ [Fact]
+ public void IndoorReveal_UsesCenterRenderAndExactEnvCellPhysics()
+ {
+ const uint indoorCell = 0x11340100u;
+ var state = new State
+ {
+ RenderReady = true,
+ CompositeReady = true,
+ TerrainReady = true,
+ };
+ var barrier = state.Build();
+
+ barrier.Prepare(indoorCell);
+ Assert.Equal(0, state.PreparedRadius);
+ Assert.False(barrier.IsReady(indoorCell));
+ Assert.Equal(-1, state.TerrainRadius);
+
+ state.SpawnCellReady = true;
+ Assert.True(barrier.IsReady(indoorCell));
+ Assert.Equal(0, state.RenderRadius);
+ }
+
+ [Fact]
+ public void Prepare_WaitsForStaticMeshPublication()
+ {
+ var state = new State();
+ var barrier = state.Build();
+
+ barrier.Prepare(0x11340021u);
+
+ Assert.Equal(0, state.Preparations);
+ }
+
+ [Fact]
+ public void ImpossibleClaim_CrossesExistingLoudRecoveryPath()
+ {
+ var state = new State { Unhydratable = true };
+ var barrier = state.Build();
+
+ barrier.Prepare(0x113401FFu);
+
+ Assert.True(barrier.IsReady(0x113401FFu));
+ Assert.Equal(0, state.Preparations);
+ Assert.Equal(-1, state.RenderRadius);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
index 86d4bb5d..4ba0fea6 100644
--- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
+++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
@@ -24,9 +24,9 @@ public sealed class AutoEnterPlayerModeTests
public bool LiveInWorld;
public bool PlayerEntityPresent;
public bool PlayerControllerReady;
- // Defaults TRUE: the hydration hold is not under test in the
- // original K.2 cases (see SpawnGroundNotReady test for it).
- public bool SpawnGroundReady = true;
+ // Defaults TRUE: the world-reveal hold is not under test in the
+ // original K.2 cases (see WorldNotReady test for it).
+ public bool WorldReady = true;
public int EnteredCount;
public PlayerModeAutoEntry Build() =>
@@ -34,23 +34,20 @@ public sealed class AutoEnterPlayerModeTests
isLiveInWorld: () => LiveInWorld,
isPlayerEntityPresent: () => PlayerEntityPresent,
isPlayerControllerReady: () => PlayerControllerReady,
- isSpawnGroundReady: () => SpawnGroundReady,
+ isWorldReady: () => WorldReady,
enterPlayerMode: () => EnteredCount++);
}
[Fact]
- public void TryEnter_Armed_SpawnGroundNotReady_DoesNotFire()
+ public void TryEnter_Armed_WorldNotReady_DoesNotFire()
{
- // #106 gate-2 (2026-06-09): player physics must not start before
- // the terrain under the spawn position has streamed in — entering
- // earlier free-falls the player into the void (gravity integrates
- // against an empty world; retail never has this state because it
- // loads cells synchronously). The guard holds until the streaming
- // pipeline registers the spawn landblock.
+ // #229 extends #106's ground-only hold to retail's complete blocking
+ // cell-load edge. Player mode must not expose the viewport until the
+ // render, composite-texture, and collision domains have all converged.
var s = new State
{
LiveInWorld = true, PlayerEntityPresent = true,
- PlayerControllerReady = true, SpawnGroundReady = false,
+ PlayerControllerReady = true, WorldReady = false,
};
var guard = s.Build();
guard.Arm();
@@ -59,8 +56,8 @@ public sealed class AutoEnterPlayerModeTests
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
- // Terrain hydrates → fires on the next tick.
- s.SpawnGroundReady = true;
+ // Render, texture, and collision domains converge → next tick fires.
+ s.WorldReady = true;
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}