From 2cca994b9fb0283ac91aa1dc85f086e726818e02 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 16:08:03 +0200 Subject: [PATCH] fix(streaming): block login on complete world reveal Make initial login and portal arrival consume one WorldRevealReadinessBarrier that joins near-tier mesh publication, destination composite uploads, and collision residency before normal world geometry becomes visible. This ports retail SmartBox's blocking-cell completion edge into the asynchronous client instead of exposing a ground-only login. Add focused outdoor, indoor, texture, and invalid-claim tests; update the retail pseudocode, architecture, divergence record, issue ledger, roadmap baseline, and synchronized agent guidance. Co-authored-by: OpenAI Codex --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/ISSUES.md | 24 +++- docs/README.md | 2 +- docs/architecture/acdream-architecture.md | 14 ++ .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 4 +- docs/plans/2026-05-12-milestones.md | 2 +- ...2026-07-16-portal-completion-pseudocode.md | 23 +++- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 22 ++-- src/AcDream.App/Rendering/GameWindow.cs | 120 ++++++++--------- .../Streaming/WorldRevealReadinessBarrier.cs | 96 ++++++++++++++ .../WorldRevealReadinessBarrierTests.cs | 124 ++++++++++++++++++ .../Input/AutoEnterPlayerModeTests.cs | 25 ++-- 14 files changed, 360 insertions(+), 102 deletions(-) create mode 100644 src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs 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); }