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 <codex@openai.com>
This commit is contained in:
parent
6c3bd4ce4b
commit
b60cb67009
14 changed files with 360 additions and 102 deletions
|
|
@ -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;
|
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
|
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
|
(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
|
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
|
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
|
locomotion/collision/projectile/teleport visual gate, then the final two-client
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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
|
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
|
(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
|
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
|
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
|
locomotion/collision/projectile/teleport visual gate, then the final two-client
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ production runtime failure.
|
||||||
|
|
||||||
**Acceptance:** A non-incremental Release solution build reports zero warnings
|
**Acceptance:** A non-incremental Release solution build reports zero warnings
|
||||||
and zero errors without suppressing analyzers or weakening nullable checking;
|
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
|
# 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
|
## #215 — [DONE 2026-07-13] Same-dungeon death respawn removed the dungeon floor physics
|
||||||
|
|
||||||
**Closed:** 2026-07-13
|
**Closed:** 2026-07-13
|
||||||
|
|
|
||||||
|
|
@ -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,
|
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
|
||||||
and UN 5 — plus the retained retired TS-37 history note.
|
and UN 5 — plus the retained retired TS-37 history note.
|
||||||
- **Latest automated baseline:** Release build succeeds with 17 known
|
- **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
|
skipped. The unattended connected R6 route
|
||||||
completed seven portal materializations, production input exercises, and a
|
completed seven portal materializations, production input exercises, and a
|
||||||
graceful close. See
|
graceful close. See
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,7 @@ src/
|
||||||
Streaming/
|
Streaming/
|
||||||
StreamingController.cs -> done
|
StreamingController.cs -> done
|
||||||
GpuWorldState.cs -> done
|
GpuWorldState.cs -> done
|
||||||
|
WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner
|
||||||
Input/
|
Input/
|
||||||
PlayerMovementController.cs -> active movement driver
|
PlayerMovementController.cs -> active movement driver
|
||||||
Plugins/
|
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
|
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
|
||||||
partially hydrated cell membership or shell placement to the renderer.
|
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
|
### Teleport cell identity at the streaming boundary
|
||||||
|
|
||||||
The network and physics layers carry retail's complete `Position`: an
|
The network and physics layers carry retail's complete `Position`: an
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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-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) |
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -920,7 +920,7 @@ diagnostic scaffolding, not yet the final collision system.
|
||||||
PositionManager facade remains the final R6 ownership cleanup.
|
PositionManager facade remains the final R6 ownership cleanup.
|
||||||
Final 2026-07-20 gate: three independent retail/architecture/adversarial
|
Final 2026-07-20 gate: three independent retail/architecture/adversarial
|
||||||
re-reviews are clean; Release builds successfully with 17 known test-project
|
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
|
The unattended connected Release rebaseline also
|
||||||
passes: seven portal materializations across Caul, Sawato, Rynthid,
|
passes: seven portal materializations across Caul, Sawato, Rynthid,
|
||||||
Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn,
|
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
|
## 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.
|
- **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.
|
- **`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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ Carried:
|
||||||
registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset
|
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
|
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
|
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
|
the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha
|
||||||
implementation has passed connected lifetime/performance gates and awaits only
|
implementation has passed connected lifetime/performance gates and awaits only
|
||||||
its lifestone/particle visual comparison.
|
its lifestone/particle visual comparison.
|
||||||
|
|
|
||||||
|
|
@ -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
|
Ready motion, recognizing recall by id, or freezing the previously published
|
||||||
part pose would all diverge from this general retail mechanism.
|
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:
|
Named retail references:
|
||||||
|
|
||||||
|
|
@ -82,6 +82,15 @@ that teleport is no longer in progress,
|
||||||
`EndTeleportAnimation` begins `TAS_TUNNEL_CONTINUE`; the normal world is not
|
`EndTeleportAnimation` begins `TAS_TUNNEL_CONTINUE`; the normal world is not
|
||||||
revealed before the blocking load edge has completed.
|
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:
|
acdream's load is split into two independent readiness domains:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -98,18 +107,28 @@ worker/register path
|
||||||
Required asynchronous equivalent:
|
Required asynchronous equivalent:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TeleportWorldReady(destination):
|
WorldRevealReady(destination):
|
||||||
if destination claim cannot be hydrated:
|
if destination claim cannot be hydrated:
|
||||||
return true so the existing loud forced-placement path diagnoses it
|
return true so the existing loud forced-placement path diagnoses it
|
||||||
|
|
||||||
if destination is indoor:
|
if destination is indoor:
|
||||||
return render center landblock Near-tier and ready
|
return render center landblock Near-tier and ready
|
||||||
|
AND destination composite textures ready
|
||||||
AND destination EnvCell collision data ready
|
AND destination EnvCell collision data ready
|
||||||
|
|
||||||
return every landblock in the priority near ring Near-tier and render-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
|
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,
|
Streaming completion is generation- and tier-aware. Every hard recenter,
|
||||||
dungeon collapse, and dungeon expansion advances a token carried by Load,
|
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
|
Promote, Unload, and their results. This rejects an old build even when its id
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ namespace AcDream.App.Input;
|
||||||
/// Why is this its own class? The auto-entry has four independent
|
/// Why is this its own class? The auto-entry has four independent
|
||||||
/// preconditions (live session reaches <c>InWorld</c>, the player
|
/// preconditions (live session reaches <c>InWorld</c>, the player
|
||||||
/// entity has been streamed into the world dictionary, the player
|
/// entity has been streamed into the world dictionary, the player
|
||||||
/// movement controller is constructible, and the terrain under the
|
/// movement controller is constructible, and the initial world is fully
|
||||||
/// spawn position has streamed in) plus a manual-override path
|
/// drawable and collidable) plus a manual-override path
|
||||||
/// (the user can flip into fly mode before the auto-entry fires —
|
/// (the user can flip into fly mode before the auto-entry fires —
|
||||||
/// their choice wins). All five interact with each other in a way
|
/// their choice wins). All five interact with each other in a way
|
||||||
/// that's painful to test through GameWindow but trivial here against
|
/// that's painful to test through GameWindow but trivial here against
|
||||||
|
|
@ -44,7 +44,7 @@ public sealed class PlayerModeAutoEntry
|
||||||
private readonly Func<bool> _isLiveInWorld;
|
private readonly Func<bool> _isLiveInWorld;
|
||||||
private readonly Func<bool> _isPlayerEntityPresent;
|
private readonly Func<bool> _isPlayerEntityPresent;
|
||||||
private readonly Func<bool> _isPlayerControllerReady;
|
private readonly Func<bool> _isPlayerControllerReady;
|
||||||
private readonly Func<bool> _isSpawnGroundReady;
|
private readonly Func<bool> _isWorldReady;
|
||||||
private readonly Action _enterPlayerMode;
|
private readonly Action _enterPlayerMode;
|
||||||
|
|
||||||
private bool _armed;
|
private bool _armed;
|
||||||
|
|
@ -62,12 +62,10 @@ public sealed class PlayerModeAutoEntry
|
||||||
/// PlayerMovementController is set up. Stays true once player mode
|
/// PlayerMovementController is set up. Stays true once player mode
|
||||||
/// is established; the auto-entry's job is to flip it from false
|
/// is established; the auto-entry's job is to flip it from false
|
||||||
/// to true exactly once.</param>
|
/// to true exactly once.</param>
|
||||||
/// <param name="isSpawnGroundReady">True iff the terrain under the
|
/// <param name="isWorldReady">True iff the player's destination is
|
||||||
/// player's spawn position has streamed into the physics engine.
|
/// complete across render publication, composite textures, and collision.
|
||||||
/// #106 gate-2 (2026-06-09): entering player mode earlier integrates
|
/// Retail keeps position completion behind one blocking cell-load edge;
|
||||||
/// gravity against an empty world and free-falls the player into the
|
/// acdream's asynchronous domains must converge before entry.</param>
|
||||||
/// void. Retail never has this state — it loads cells synchronously;
|
|
||||||
/// this hold is the async-streaming equivalent of that invariant.</param>
|
|
||||||
/// <param name="enterPlayerMode">Action invoked on the firing
|
/// <param name="enterPlayerMode">Action invoked on the firing
|
||||||
/// tick. The same routine the manual Tab handler invokes (fly →
|
/// tick. The same routine the manual Tab handler invokes (fly →
|
||||||
/// player transition). Must construct the controller + chase
|
/// player transition). Must construct the controller + chase
|
||||||
|
|
@ -77,13 +75,13 @@ public sealed class PlayerModeAutoEntry
|
||||||
Func<bool> isLiveInWorld,
|
Func<bool> isLiveInWorld,
|
||||||
Func<bool> isPlayerEntityPresent,
|
Func<bool> isPlayerEntityPresent,
|
||||||
Func<bool> isPlayerControllerReady,
|
Func<bool> isPlayerControllerReady,
|
||||||
Func<bool> isSpawnGroundReady,
|
Func<bool> isWorldReady,
|
||||||
Action enterPlayerMode)
|
Action enterPlayerMode)
|
||||||
{
|
{
|
||||||
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
|
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
|
||||||
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
|
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
|
||||||
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
|
_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));
|
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +115,7 @@ public sealed class PlayerModeAutoEntry
|
||||||
if (!_isLiveInWorld()) return false;
|
if (!_isLiveInWorld()) return false;
|
||||||
if (!_isPlayerEntityPresent()) return false;
|
if (!_isPlayerEntityPresent()) return false;
|
||||||
if (!_isPlayerControllerReady()) return false;
|
if (!_isPlayerControllerReady()) return false;
|
||||||
if (!_isSpawnGroundReady()) return false;
|
if (!_isWorldReady()) return false;
|
||||||
|
|
||||||
_armed = false;
|
_armed = false;
|
||||||
_enterPlayerMode();
|
_enterPlayerMode();
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
|
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
|
||||||
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
||||||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
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 _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 _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)
|
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,
|
AcDream.Core.Net.WorldSession.State.InWorld,
|
||||||
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
|
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
|
||||||
isPlayerControllerReady: () => true,
|
isPlayerControllerReady: () => true,
|
||||||
// #106 gate-2: hold player-mode entry until the terrain under
|
// Retail SmartBox::UseTime (0x00455410) completes the player
|
||||||
// the spawn position has streamed in — entering earlier
|
// position only after CellManager's blocking load clears. The
|
||||||
// integrates gravity against an empty world and free-falls
|
// shared acdream barrier joins collision, static-mesh upload,
|
||||||
// the player into the void (retail loads cells synchronously;
|
// and composite-texture readiness for both login and portals.
|
||||||
// this is the async-streaming equivalent of that invariant).
|
isWorldReady: LoginWorldReady,
|
||||||
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;
|
|
||||||
},
|
|
||||||
enterPlayerMode: EnterPlayerModeFromAutoEntry);
|
enterPlayerMode: EnterPlayerModeFromAutoEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2873,6 +2844,20 @@ public sealed class GameWindow : IDisposable
|
||||||
retirementCoordinator: _landblockRetirements);
|
retirementCoordinator: _landblockRetirements);
|
||||||
// A.5 T22.5: apply max-completions from resolved quality.
|
// A.5 T22.5: apply max-completions from resolved quality.
|
||||||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
_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,
|
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
|
||||||
// enter the world as the first character on the account, and stream
|
// 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));
|
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-
|
// Streaming is normally gated until this point, so the next loaded-
|
||||||
// landblock callback will materialize deferred records. Also cover an
|
// landblock callback will materialize deferred records. Also cover an
|
||||||
// already-resident block (tests, reconnect teardown overlap) without
|
// 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
|
// 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
|
// 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.
|
// 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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind a sequence-correlated teleport Position to presentation and
|
/// Bind a sequence-correlated teleport Position to presentation and
|
||||||
|
|
@ -7544,7 +7537,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_pendingTeleportCell = p.LandblockId;
|
_pendingTeleportCell = p.LandblockId;
|
||||||
_teleportHoldSeconds = 0f;
|
_teleportHoldSeconds = 0f;
|
||||||
_teleportForced = false;
|
_teleportForced = false;
|
||||||
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness();
|
_worldRevealReadiness?.Begin();
|
||||||
if (_streamingController is not null)
|
if (_streamingController is not null)
|
||||||
{
|
{
|
||||||
_streamingController.PriorityLandblockId =
|
_streamingController.PriorityLandblockId =
|
||||||
|
|
@ -7615,18 +7608,24 @@ public sealed class GameWindow : IDisposable
|
||||||
/// loudly rather than holding forever.
|
/// loudly rather than holding forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TeleportWorldReady(uint destCell)
|
private bool TeleportWorldReady(uint destCell)
|
||||||
|
=> _worldRevealReadiness?.IsReady(destCell) == true;
|
||||||
|
|
||||||
|
private bool LoginWorldReady()
|
||||||
{
|
{
|
||||||
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
return TryGetLoginWorldCell(out uint cell)
|
||||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
&& _worldRevealReadiness?.IsReady(cell) == true;
|
||||||
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
|
}
|
||||||
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
|
|
||||||
destCell,
|
private bool TryGetLoginWorldCell(out uint cell)
|
||||||
renderRadius) == true;
|
{
|
||||||
renderReady &= _wbDrawDispatcher?.CompositeTexturesReady == true;
|
cell = 0;
|
||||||
return indoor
|
if (_liveEntities is null
|
||||||
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
|
|| !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
|
||||||
: renderReady
|
|| playerSpawn.Position is not { } position)
|
||||||
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
return false;
|
||||||
|
|
||||||
|
cell = position.LandblockId;
|
||||||
|
return cell != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
// 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))
|
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
|
||||||
{
|
{
|
||||||
_wbMeshAdapter?.Tick();
|
_wbMeshAdapter?.Tick();
|
||||||
if (_teleportTransit.IsActive && _pendingTeleportCell != 0)
|
uint revealCell = _teleportTransit.IsActive
|
||||||
{
|
? _pendingTeleportCell
|
||||||
int compositeRadius = (_pendingTeleportCell & 0xFFFFu) >= 0x0100u
|
: IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
|
||||||
? 0
|
? loginCell
|
||||||
: TeleportNearRingRadius;
|
: 0u;
|
||||||
bool destinationMeshesPublished =
|
if (revealCell != 0)
|
||||||
_streamingController?.IsRenderNeighborhoodResident(
|
_worldRevealReadiness?.Prepare(revealCell);
|
||||||
_pendingTeleportCell,
|
|
||||||
compositeRadius) == true;
|
|
||||||
if (destinationMeshesPublished)
|
|
||||||
{
|
|
||||||
_wbDrawDispatcher?.PrepareCompositeTextures(
|
|
||||||
_worldState.Entities,
|
|
||||||
_worldState.FlatViewGeneration,
|
|
||||||
_pendingTeleportCell,
|
|
||||||
compositeRadius);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||||
}
|
}
|
||||||
if (_frameDiag)
|
if (_frameDiag)
|
||||||
|
|
|
||||||
96
src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs
Normal file
96
src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins the render-publication, texture, and collision domains that must be
|
||||||
|
/// complete before the normal world viewport is revealed. Retail keeps
|
||||||
|
/// <c>SmartBox::position_update_complete</c> false while
|
||||||
|
/// <c>CellManager::blocking_for_cells</c> is set
|
||||||
|
/// (<c>SmartBox::UseTime</c>, 0x00455410). acdream loads those domains
|
||||||
|
/// asynchronously, so login and portal arrival must share this explicit
|
||||||
|
/// equivalent of retail's single blocking-cell edge.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldRevealReadinessBarrier
|
||||||
|
{
|
||||||
|
internal const int OutdoorNeighborhoodRadius = 1;
|
||||||
|
|
||||||
|
private readonly Func<uint, int, bool> _isRenderNeighborhoodReady;
|
||||||
|
private readonly Func<uint, bool> _isSpawnCellReady;
|
||||||
|
private readonly Func<uint, int, bool> _isTerrainNeighborhoodReady;
|
||||||
|
private readonly Func<bool> _areCompositeTexturesReady;
|
||||||
|
private readonly Action<uint, int> _prepareCompositeTextures;
|
||||||
|
private readonly Action _invalidateCompositeTextures;
|
||||||
|
private readonly Func<uint, bool> _isSpawnClaimUnhydratable;
|
||||||
|
|
||||||
|
public WorldRevealReadinessBarrier(
|
||||||
|
Func<uint, int, bool> isRenderNeighborhoodReady,
|
||||||
|
Func<uint, bool> isSpawnCellReady,
|
||||||
|
Func<uint, int, bool> isTerrainNeighborhoodReady,
|
||||||
|
Func<bool> areCompositeTexturesReady,
|
||||||
|
Action<uint, int> prepareCompositeTextures,
|
||||||
|
Action invalidateCompositeTextures,
|
||||||
|
Func<uint, bool> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Begin() => _invalidateCompositeTextures();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances render-thread texture preparation after all required static
|
||||||
|
/// meshes for the destination neighborhood have been published.
|
||||||
|
/// </summary>
|
||||||
|
public void Prepare(uint destinationCell)
|
||||||
|
{
|
||||||
|
if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell))
|
||||||
|
return;
|
||||||
|
|
||||||
|
int radius = RequiredRenderRadius(destinationCell);
|
||||||
|
if (_isRenderNeighborhoodReady(destinationCell, radius))
|
||||||
|
_prepareCompositeTextures(destinationCell, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,9 @@ public sealed class AutoEnterPlayerModeTests
|
||||||
public bool LiveInWorld;
|
public bool LiveInWorld;
|
||||||
public bool PlayerEntityPresent;
|
public bool PlayerEntityPresent;
|
||||||
public bool PlayerControllerReady;
|
public bool PlayerControllerReady;
|
||||||
// Defaults TRUE: the hydration hold is not under test in the
|
// Defaults TRUE: the world-reveal hold is not under test in the
|
||||||
// original K.2 cases (see SpawnGroundNotReady test for it).
|
// original K.2 cases (see WorldNotReady test for it).
|
||||||
public bool SpawnGroundReady = true;
|
public bool WorldReady = true;
|
||||||
public int EnteredCount;
|
public int EnteredCount;
|
||||||
|
|
||||||
public PlayerModeAutoEntry Build() =>
|
public PlayerModeAutoEntry Build() =>
|
||||||
|
|
@ -34,23 +34,20 @@ public sealed class AutoEnterPlayerModeTests
|
||||||
isLiveInWorld: () => LiveInWorld,
|
isLiveInWorld: () => LiveInWorld,
|
||||||
isPlayerEntityPresent: () => PlayerEntityPresent,
|
isPlayerEntityPresent: () => PlayerEntityPresent,
|
||||||
isPlayerControllerReady: () => PlayerControllerReady,
|
isPlayerControllerReady: () => PlayerControllerReady,
|
||||||
isSpawnGroundReady: () => SpawnGroundReady,
|
isWorldReady: () => WorldReady,
|
||||||
enterPlayerMode: () => EnteredCount++);
|
enterPlayerMode: () => EnteredCount++);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
// #229 extends #106's ground-only hold to retail's complete blocking
|
||||||
// the terrain under the spawn position has streamed in — entering
|
// cell-load edge. Player mode must not expose the viewport until the
|
||||||
// earlier free-falls the player into the void (gravity integrates
|
// render, composite-texture, and collision domains have all converged.
|
||||||
// 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.
|
|
||||||
var s = new State
|
var s = new State
|
||||||
{
|
{
|
||||||
LiveInWorld = true, PlayerEntityPresent = true,
|
LiveInWorld = true, PlayerEntityPresent = true,
|
||||||
PlayerControllerReady = true, SpawnGroundReady = false,
|
PlayerControllerReady = true, WorldReady = false,
|
||||||
};
|
};
|
||||||
var guard = s.Build();
|
var guard = s.Build();
|
||||||
guard.Arm();
|
guard.Arm();
|
||||||
|
|
@ -59,8 +56,8 @@ public sealed class AutoEnterPlayerModeTests
|
||||||
Assert.Equal(0, s.EnteredCount);
|
Assert.Equal(0, s.EnteredCount);
|
||||||
Assert.True(guard.IsArmed);
|
Assert.True(guard.IsArmed);
|
||||||
|
|
||||||
// Terrain hydrates → fires on the next tick.
|
// Render, texture, and collision domains converge → next tick fires.
|
||||||
s.SpawnGroundReady = true;
|
s.WorldReady = true;
|
||||||
Assert.True(guard.TryEnter());
|
Assert.True(guard.TryEnter());
|
||||||
Assert.Equal(1, s.EnteredCount);
|
Assert.Equal(1, s.EnteredCount);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue