diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 4476e988..ad765447 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -239,7 +239,8 @@ src/
Streaming/
StreamingController.cs -> done
GpuWorldState.cs -> done
- WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner
+ WorldRevealCoordinator.cs -> shared login/portal lifetime owner
+ WorldRevealReadinessBarrier.cs -> canonical destination predicate
Input/
PlayerMovementController.cs -> active movement driver
Plugins/
@@ -708,14 +709,16 @@ 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
+`WorldRevealCoordinator` is the single App-layer owner of each login or portal
+reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with
+generation-scoped lifecycle observation, so begin, preparation, readiness,
+materialization, world visibility, completion, and cancellation cannot be
+wired independently. The barrier 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.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 548eec6e..281e6978 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -149,9 +149,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 readonly AcDream.App.Streaming.WorldRevealLifecycleTelemetry _worldRevealTelemetry =
- new(message => Console.WriteLine(message));
+ private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
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)
@@ -2287,8 +2285,8 @@ public sealed class GameWindow : IDisposable
UiProbeLog);
_worldLifecycleAutomation =
new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
- () => _worldRevealTelemetry.Snapshot,
- () => _worldRevealTelemetry.PortalMaterializationCount,
+ () => _worldReveal?.Snapshot ?? default,
+ () => _worldReveal?.PortalMaterializationCount ?? 0,
CaptureWorldLifecycleResourceSnapshot,
_frameScreenshots,
artifactDirectory,
@@ -2867,7 +2865,7 @@ 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(
+ _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
@@ -2880,7 +2878,8 @@ public sealed class GameWindow : IDisposable
radius),
invalidateCompositeTextures: () =>
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
- isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable);
+ isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable,
+ log: message => Console.WriteLine(message));
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@@ -3637,8 +3636,7 @@ public sealed class GameWindow : IDisposable
// 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();
- _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Login);
+ _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Login);
// Streaming is normally gated until this point, so the next loaded-
// landblock callback will materialize deferred records. Also cover an
@@ -7561,8 +7559,7 @@ public sealed class GameWindow : IDisposable
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
- _worldRevealReadiness?.Begin();
- _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
+ _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
@@ -7588,7 +7585,7 @@ public sealed class GameWindow : IDisposable
if (clearSession)
{
_teleportTransit.ClearSession();
- _worldRevealTelemetry.Cancel();
+ _worldReveal?.Cancel();
}
else
_teleportTransit.EndActive();
@@ -7647,9 +7644,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness(
uint destinationCell)
{
- var snapshot = _worldRevealReadiness?.Evaluate(destinationCell) ?? default;
- _worldRevealTelemetry.ObserveReadiness(snapshot);
- return snapshot;
+ return _worldReveal?.Evaluate(destinationCell) ?? default;
}
private bool TryGetLoginWorldCell(out uint cell)
@@ -9778,7 +9773,7 @@ public sealed class GameWindow : IDisposable
{
case AcDream.Core.World.TeleportAnimEvent.Place:
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
- _worldRevealTelemetry.ObserveMaterialized();
+ _worldReveal?.ObserveMaterialized();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId = 0u;
@@ -9798,7 +9793,7 @@ public sealed class GameWindow : IDisposable
// each portal transition.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
- _worldRevealTelemetry.Complete();
+ _worldReveal?.Complete();
ResetTeleportTransitState();
break;
default:
@@ -10157,8 +10152,7 @@ public sealed class GameWindow : IDisposable
: 0u;
if (revealCell != 0)
{
- _worldRevealReadiness?.Prepare(revealCell);
- EvaluateWorldRevealReadiness(revealCell);
+ _worldReveal?.PrepareAndEvaluate(revealCell);
}
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
}
@@ -10219,8 +10213,8 @@ public sealed class GameWindow : IDisposable
if (!IsLiveModeWaitingForLogin)
{
_particleVisibility.UseWorldView();
- _worldRevealTelemetry.ObserveWorldViewportVisible();
- _worldRevealTelemetry.Complete();
+ _worldReveal?.ObserveWorldViewportVisible();
+ _worldReveal?.Complete();
}
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the
diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
new file mode 100644
index 00000000..555e1feb
--- /dev/null
+++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
@@ -0,0 +1,69 @@
+namespace AcDream.App.Streaming;
+
+///
+/// Owns one login/portal reveal lifetime across readiness and lifecycle
+/// diagnostics. This keeps the destination barrier and its observations on a
+/// single seam so callers cannot begin, prepare, or evaluate one owner while
+/// forgetting the other.
+///
+internal sealed class WorldRevealCoordinator
+{
+ private readonly WorldRevealReadinessBarrier _readiness;
+ private readonly WorldRevealLifecycleTelemetry _lifecycle;
+
+ public WorldRevealCoordinator(
+ Func isRenderNeighborhoodReady,
+ Func isSpawnCellReady,
+ Func isTerrainNeighborhoodReady,
+ Func areCompositeTexturesReady,
+ Action prepareCompositeTextures,
+ Action invalidateCompositeTextures,
+ Func isSpawnClaimUnhydratable,
+ Action? log = null)
+ {
+ _readiness = new WorldRevealReadinessBarrier(
+ isRenderNeighborhoodReady,
+ isSpawnCellReady,
+ isTerrainNeighborhoodReady,
+ areCompositeTexturesReady,
+ prepareCompositeTextures,
+ invalidateCompositeTextures,
+ isSpawnClaimUnhydratable);
+ _lifecycle = new WorldRevealLifecycleTelemetry(log);
+ }
+
+ public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
+ public int PortalMaterializationCount => _lifecycle.PortalMaterializationCount;
+
+ public long Begin(WorldRevealKind kind)
+ {
+ _readiness.Begin();
+ return _lifecycle.Begin(kind);
+ }
+
+ ///
+ /// Advances render-thread preparation and publishes the resulting
+ /// canonical readiness snapshot to lifecycle diagnostics.
+ ///
+ public WorldRevealReadinessSnapshot PrepareAndEvaluate(uint destinationCell)
+ {
+ _readiness.Prepare(destinationCell);
+ return Evaluate(destinationCell);
+ }
+
+ public WorldRevealReadinessSnapshot Evaluate(uint destinationCell)
+ {
+ WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell);
+ _lifecycle.ObserveReadiness(snapshot);
+ return snapshot;
+ }
+
+ public void ObserveMaterialized() => _lifecycle.ObserveMaterialized();
+
+ public void ObserveWorldViewportVisible() =>
+ _lifecycle.ObserveWorldViewportVisible();
+
+ public void Complete() => _lifecycle.Complete();
+
+ public void Cancel() => _lifecycle.Cancel();
+}
diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs
new file mode 100644
index 00000000..8ddc0791
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs
@@ -0,0 +1,117 @@
+using AcDream.App.Streaming;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class WorldRevealCoordinatorTests
+{
+ private sealed class State
+ {
+ public bool RenderReady { get; set; }
+ public bool CollisionReady { get; set; }
+ public bool CompositesReady { get; set; }
+ public int InvalidateCount { get; private set; }
+ public int PrepareCount { get; private set; }
+
+ public WorldRevealCoordinator Build(List? logs = null) => new(
+ isRenderNeighborhoodReady: (_, _) => RenderReady,
+ isSpawnCellReady: _ => CollisionReady,
+ isTerrainNeighborhoodReady: (_, _) => CollisionReady,
+ areCompositeTexturesReady: () => CompositesReady,
+ prepareCompositeTextures: (_, _) => PrepareCount++,
+ invalidateCompositeTextures: () =>
+ {
+ InvalidateCount++;
+ CompositesReady = false;
+ },
+ isSpawnClaimUnhydratable: _ => false,
+ log: logs is null ? null : new Action(logs.Add));
+ }
+
+ [Fact]
+ public void Begin_InvalidatesPriorDestinationAndStartsOneGeneration()
+ {
+ var state = new State { CompositesReady = true };
+ WorldRevealCoordinator coordinator = state.Build();
+
+ long generation = coordinator.Begin(WorldRevealKind.Login);
+
+ Assert.Equal(1, generation);
+ Assert.Equal(1, state.InvalidateCount);
+ Assert.False(state.CompositesReady);
+ Assert.Equal(WorldRevealKind.Login, coordinator.Snapshot.Kind);
+ }
+
+ [Fact]
+ public void PrepareAndEvaluate_UsesOneCanonicalSnapshotForLifecycle()
+ {
+ const uint outdoorCell = 0x11340021u;
+ var state = new State
+ {
+ RenderReady = true,
+ CollisionReady = true,
+ };
+ WorldRevealCoordinator coordinator = state.Build();
+ coordinator.Begin(WorldRevealKind.Login);
+
+ WorldRevealReadinessSnapshot first = coordinator.PrepareAndEvaluate(outdoorCell);
+ state.CompositesReady = true;
+ WorldRevealReadinessSnapshot ready = coordinator.PrepareAndEvaluate(outdoorCell);
+
+ Assert.False(first.IsReady);
+ Assert.True(ready.IsReady);
+ Assert.Equal(2, state.PrepareCount);
+ Assert.Equal(ready, coordinator.Snapshot.Readiness);
+ }
+
+ [Fact]
+ public void PortalLifecycle_CountsMaterializationAndCompletesVisibleGeneration()
+ {
+ const uint outdoorCell = 0x3032001Cu;
+ var logs = new List();
+ var state = new State
+ {
+ RenderReady = true,
+ CollisionReady = true,
+ CompositesReady = true,
+ };
+ WorldRevealCoordinator coordinator = state.Build(logs);
+ coordinator.Begin(WorldRevealKind.Portal);
+ state.CompositesReady = true;
+
+ Assert.True(coordinator.Evaluate(outdoorCell).IsReady);
+ coordinator.ObserveMaterialized();
+ coordinator.ObserveMaterialized();
+ coordinator.ObserveWorldViewportVisible();
+ coordinator.Complete();
+
+ Assert.Equal(1, coordinator.PortalMaterializationCount);
+ Assert.True(coordinator.Snapshot.Materialized);
+ Assert.True(coordinator.Snapshot.WorldViewportObserved);
+ Assert.True(coordinator.Snapshot.Completed);
+ Assert.Equal(0, coordinator.Snapshot.InvariantFailureCount);
+ Assert.Single(logs, line => line.Contains("event=materialized", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Cancel_PreventsLateDestinationWorkFromMutatingLifecycle()
+ {
+ var state = new State
+ {
+ RenderReady = true,
+ CollisionReady = true,
+ CompositesReady = true,
+ };
+ WorldRevealCoordinator coordinator = state.Build();
+ coordinator.Begin(WorldRevealKind.Portal);
+ coordinator.Cancel();
+
+ coordinator.PrepareAndEvaluate(0x11340021u);
+ coordinator.ObserveMaterialized();
+ coordinator.ObserveWorldViewportVisible();
+ coordinator.Complete();
+
+ Assert.True(coordinator.Snapshot.Cancelled);
+ Assert.Equal(default, coordinator.Snapshot.Readiness);
+ Assert.Equal(0, coordinator.PortalMaterializationCount);
+ }
+}