From a4ef57885c9d5baff6cb86279a993f1e0a199666 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 23:33:50 +0200 Subject: [PATCH] refactor(streaming): centralize world reveal lifetime Compose destination readiness and lifecycle telemetry behind one App-layer coordinator so login and portal begin, preparation, materialization, visibility, completion, and cancellation cannot drift apart in GameWindow wiring. --- docs/architecture/acdream-architecture.md | 21 ++-- src/AcDream.App/Rendering/GameWindow.cs | 36 +++--- .../Streaming/WorldRevealCoordinator.cs | 69 +++++++++++ .../Streaming/WorldRevealCoordinatorTests.cs | 117 ++++++++++++++++++ 4 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 src/AcDream.App/Streaming/WorldRevealCoordinator.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs 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); + } +}