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.
This commit is contained in:
Erik 2026-07-20 23:33:50 +02:00
parent 68578fa5fa
commit a4ef57885c
4 changed files with 213 additions and 30 deletions

View file

@ -239,7 +239,8 @@ src/
Streaming/ Streaming/
StreamingController.cs -> done StreamingController.cs -> done
GpuWorldState.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/ Input/
PlayerMovementController.cs -> active movement driver PlayerMovementController.cs -> active movement driver
Plugins/ Plugins/
@ -708,14 +709,16 @@ partially hydrated cell membership or shell placement to the renderer.
### World-reveal readiness ownership ### World-reveal readiness ownership
`WorldRevealReadinessBarrier` is the single App-layer owner of the edge that `WorldRevealCoordinator` is the single App-layer owner of each login or portal
allows normal world geometry to become visible at initial login or after a reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with
portal. It joins `StreamingController`/`GpuWorldState` static-mesh publication, generation-scoped lifecycle observation, so begin, preparation, readiness,
`WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination materialization, world visibility, completion, and cancellation cannot be
residency. Login's sky-only gate and portal-space transit consume the same wired independently. The barrier joins `StreamingController`/`GpuWorldState`
predicate; they differ only in presentation. Composite preparation advances static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and
after `WbMeshAdapter.Tick` on the render thread, never from the streaming `PhysicsEngine` destination residency. Login's sky-only gate and portal-space
worker. This is acdream's asynchronous equivalent of retail 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 `SmartBox::UseTime` holding position completion while
`CellManager::blocking_for_cells` is set. `CellManager::blocking_for_cells` is set.

View file

@ -149,9 +149,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 AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private readonly AcDream.App.Streaming.WorldRevealLifecycleTelemetry _worldRevealTelemetry =
new(message => Console.WriteLine(message));
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)
@ -2287,8 +2285,8 @@ public sealed class GameWindow : IDisposable
UiProbeLog); UiProbeLog);
_worldLifecycleAutomation = _worldLifecycleAutomation =
new AcDream.App.Diagnostics.WorldLifecycleAutomationController( new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
() => _worldRevealTelemetry.Snapshot, () => _worldReveal?.Snapshot ?? default,
() => _worldRevealTelemetry.PortalMaterializationCount, () => _worldReveal?.PortalMaterializationCount ?? 0,
CaptureWorldLifecycleResourceSnapshot, CaptureWorldLifecycleResourceSnapshot,
_frameScreenshots, _frameScreenshots,
artifactDirectory, artifactDirectory,
@ -2867,7 +2865,7 @@ 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( _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady, isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident, isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
@ -2880,7 +2878,8 @@ public sealed class GameWindow : IDisposable
radius), radius),
invalidateCompositeTextures: () => invalidateCompositeTextures: () =>
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(), _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable); isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable,
log: message => Console.WriteLine(message));
// 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
@ -3637,8 +3636,7 @@ public sealed class GameWindow : IDisposable
// the same shared reveal lifetime used by portal arrivals: initial // the same shared reveal lifetime used by portal arrivals: initial
// login must not inherit the dispatcher's default/previous composite // login must not inherit the dispatcher's default/previous composite
// readiness and expose a terrain-only or partially uploaded world. // readiness and expose a terrain-only or partially uploaded world.
_worldRevealReadiness?.Begin(); _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Login);
_worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Login);
// 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
@ -7561,8 +7559,7 @@ public sealed class GameWindow : IDisposable
_pendingTeleportCell = p.LandblockId; _pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f; _teleportHoldSeconds = 0f;
_teleportForced = false; _teleportForced = false;
_worldRevealReadiness?.Begin(); _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
_worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
if (_streamingController is not null) if (_streamingController is not null)
{ {
_streamingController.PriorityLandblockId = _streamingController.PriorityLandblockId =
@ -7588,7 +7585,7 @@ public sealed class GameWindow : IDisposable
if (clearSession) if (clearSession)
{ {
_teleportTransit.ClearSession(); _teleportTransit.ClearSession();
_worldRevealTelemetry.Cancel(); _worldReveal?.Cancel();
} }
else else
_teleportTransit.EndActive(); _teleportTransit.EndActive();
@ -7647,9 +7644,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness( private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness(
uint destinationCell) uint destinationCell)
{ {
var snapshot = _worldRevealReadiness?.Evaluate(destinationCell) ?? default; return _worldReveal?.Evaluate(destinationCell) ?? default;
_worldRevealTelemetry.ObserveReadiness(snapshot);
return snapshot;
} }
private bool TryGetLoginWorldCell(out uint cell) private bool TryGetLoginWorldCell(out uint cell)
@ -9778,7 +9773,7 @@ public sealed class GameWindow : IDisposable
{ {
case AcDream.Core.World.TeleportAnimEvent.Place: case AcDream.Core.World.TeleportAnimEvent.Place:
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
_worldRevealTelemetry.ObserveMaterialized(); _worldReveal?.ObserveMaterialized();
if (_streamingController is not null) if (_streamingController is not null)
{ {
_streamingController.PriorityLandblockId = 0u; _streamingController.PriorityLandblockId = 0u;
@ -9798,7 +9793,7 @@ public sealed class GameWindow : IDisposable
// each portal transition. // each portal transition.
_liveSession?.SendGameAction( _liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_worldRevealTelemetry.Complete(); _worldReveal?.Complete();
ResetTeleportTransitState(); ResetTeleportTransitState();
break; break;
default: default:
@ -10157,8 +10152,7 @@ public sealed class GameWindow : IDisposable
: 0u; : 0u;
if (revealCell != 0) if (revealCell != 0)
{ {
_worldRevealReadiness?.Prepare(revealCell); _worldReveal?.PrepareAndEvaluate(revealCell);
EvaluateWorldRevealReadiness(revealCell);
} }
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
} }
@ -10219,8 +10213,8 @@ public sealed class GameWindow : IDisposable
if (!IsLiveModeWaitingForLogin) if (!IsLiveModeWaitingForLogin)
{ {
_particleVisibility.UseWorldView(); _particleVisibility.UseWorldView();
_worldRevealTelemetry.ObserveWorldViewportVisible(); _worldReveal?.ObserveWorldViewportVisible();
_worldRevealTelemetry.Complete(); _worldReveal?.Complete();
} }
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the // L.0 Audio tab: push the SettingsVM's live AudioDraft into the

View file

@ -0,0 +1,69 @@
namespace AcDream.App.Streaming;
/// <summary>
/// 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.
/// </summary>
internal sealed class WorldRevealCoordinator
{
private readonly WorldRevealReadinessBarrier _readiness;
private readonly WorldRevealLifecycleTelemetry _lifecycle;
public WorldRevealCoordinator(
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,
Action<string>? 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);
}
/// <summary>
/// Advances render-thread preparation and publishes the resulting
/// canonical readiness snapshot to lifecycle diagnostics.
/// </summary>
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();
}

View file

@ -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<string>? 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<string>(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<string>();
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);
}
}