From db45b81f7586f747b937fe6bc1560a665938a310 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 22:32:23 +0200 Subject: [PATCH] test(streaming): expose world reveal lifecycle --- ...26-07-20-automated-world-lifecycle-gate.md | 142 ++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 28 +++- .../WorldRevealLifecycleTelemetry.cs | 158 ++++++++++++++++++ .../Streaming/WorldRevealReadinessBarrier.cs | 68 +++++++- .../WorldRevealLifecycleTelemetryTests.cs | 105 ++++++++++++ .../WorldRevealReadinessBarrierTests.cs | 42 +++++ 6 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-07-20-automated-world-lifecycle-gate.md create mode 100644 src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs diff --git a/docs/plans/2026-07-20-automated-world-lifecycle-gate.md b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md new file mode 100644 index 00000000..0f04441b --- /dev/null +++ b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md @@ -0,0 +1,142 @@ +# Automated world-lifecycle gate + +**Status:** In progress (2026-07-20) +**Owner:** M3 stabilization / R6 rendering-lifecycle rebaseline +**Depends on:** `WorldRevealReadinessBarrier`, retained-UI automation, +`FrameProfiler`, connected local ACE + +## Outcome + +Create a deterministic connected Release gate that catches the structural +failures which previously required someone to stare at the client for several +minutes: + +- the first login frame cannot expose world geometry before the destination's + render meshes, composite textures, and collision data are all ready; +- every portal/recall lifetime crosses the same readiness edge, materializes + once, and returns to the normal viewport once; +- dungeon entry and exit use the same lifecycle without grey frames, + stale-source geometry, or an uncollidable destination; +- graceful logout followed by a fresh reconnect starts a new reveal lifetime + and cannot inherit readiness or resources from the previous process; +- repeated destinations do not cause cumulative memory, owner-count, + allocation, update-time, or GPU-time growth; +- each stable checkpoint produces machine-readable state plus a screenshot for + later human comparison. + +This is a correctness and resource-lifetime gate. Screenshots preserve visual +evidence, but automation does **not** claim retail visual equivalence. Color, +composition, animation feel, and subtle geometry artifacts remain user gates. + +## Canonical runtime contract + +`WorldRevealReadinessBarrier` remains the only definition of destination +readiness. It will expose a structured snapshot rather than forcing telemetry +to repeat the readiness formula. A snapshot records the destination cell, +indoor/outdoor classification, required radius, render publication, composite +texture readiness, collision readiness, an impossible/unhydratable claim, and +the final `Ready` result. + +A focused App-layer `WorldRevealLifecycleTelemetry` owns diagnostic reveal +generations. It has no GL calls and no knowledge of `GameWindow`. For each +login or portal generation it records: + +1. begin; +2. destination acquisition; +3. each distinct readiness transition; +4. the first frame where the normal world viewport is actually eligible to + draw; +5. completion or cancellation. + +Making the normal viewport visible before `Ready` is a hard invariant failure, +not a warning. A newer generation retires the older one, so stale asynchronous +completion cannot satisfy the new destination. + +`GameWindow` only supplies facts and invokes the controller at existing +lifecycle seams. It does not gain a new feature body. + +## Production automation seam + +The retained-UI automation runner gains runtime commands through an injected +interface: + +- `wait world-ready [timeout-ms]` waits on canonical lifecycle state instead + of a guessed sleep; +- `wait materialized [timeout-ms]` waits for the authoritative + portal completion count; +- `checkpoint ` emits one JSON record containing reveal state, + landblock/entity/animation and resource-owner counts, managed memory, and + the latest frame-profiler summary; +- `screenshot [timeout-ms]` queues a default-framebuffer capture after + the complete world and retained UI have drawn, then waits for the render + thread to finish the PNG; +- existing `input` commands perform deterministic turns and movement through + `InputDispatcher`. + +All state mutation and GL readback stay on the update/render thread. The +external gate only launches the process, observes files/logs and OS process +metrics, and requests normal `WM_CLOSE` shutdown. + +## Connected route + +The gate runs two capped/uncapped-aware sessions against local ACE: + +1. fresh login → wait for canonical readiness → checkpoint + screenshot; +2. outdoor dense scene → turn → stable checkpoint + screenshot; +3. world-edge scene → turn → stable checkpoint; +4. known dungeon destination → checkpoint + screenshot → local movement; +5. return outdoors → checkpoint + screenshot; +6. revisit the first dense scene → stable resource comparison; +7. graceful close; +8. reconnect the same account → repeat the fresh-login readiness and + screenshot gate → graceful close. + +The existing seven-destination R6 soak remains the longer performance route. +The lifecycle gate is shorter and more diagnostic; it composes with that soak +rather than replacing it. + +## Hard failures + +- world viewport observed before the active reveal snapshot is ready; +- destination never reaches readiness or materialization within the bounded + connected test timeout; +- missing or duplicate reveal/materialization/completion edges; +- missing checkpoint JSON or screenshot, zero-sized/corrupt screenshot; +- absent required destination landblock/EnvCell/collision readiness; +- retained per-owner resources after delete/session teardown in deterministic + tests; +- same-location resource counts or memory/per-frame allocation grow beyond the + route's documented relative tolerance; +- unhandled exception, access violation, OOM, GL/device loss, disconnect, + unexpected WeenieError, nonzero exit, or failure of graceful shutdown. + +Absolute FPS is recorded, never compared across local and RDP sessions. The +gate compares like-for-like samples within one process and reports CPU and GPU +frame time separately. + +## Implementation sequence + +1. Add a structured readiness snapshot and exhaustive barrier tests. +2. Add lifecycle telemetry as a pure App owner with stale-generation, + cancellation, duplicate, and early-visible tests. +3. Add the automation runtime interface, deterministic waits, checkpoints, and + screenshot request/completion protocol. +4. Add the render-thread screenshot owner and structured resource snapshots. +5. Wire the owners at login, F751 portal start, TAS placement/completion, world + draw eligibility, and session teardown. +6. Add the connected route and PowerShell report/gate orchestration. +7. Run capped and uncapped Release gates, fix root causes, and retain artifacts. +8. Extract the now-protected streaming/portal/reveal frame orchestration from + `GameWindow` without changing the accepted lifecycle trace. +9. Run focused tests, full Release build/test, connected gates, graceful-close + verification, and documentation reconciliation. + +## Completion evidence + +- focused App tests for every lifecycle invariant; +- full Release solution build and test suite; +- two-process connected lifecycle report with screenshots and JSONL timeline; +- accepted capped and uncapped same-session metrics; +- `GameWindow` extraction retains the exact accepted lifecycle trace; +- roadmap, issues, architecture, divergence register, memory, `AGENTS.md`, and + `CLAUDE.md` agree on the resulting ownership and remaining visual gates. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 227a5cb3..1ffb0a79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -148,6 +148,8 @@ public sealed class GameWindow : IDisposable 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 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) @@ -3615,6 +3617,7 @@ public sealed class GameWindow : IDisposable // 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); // Streaming is normally gated until this point, so the next loaded- // landblock callback will materialize deferred records. Also cover an @@ -7538,6 +7541,7 @@ public sealed class GameWindow : IDisposable _teleportHoldSeconds = 0f; _teleportForced = false; _worldRevealReadiness?.Begin(); + _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal); if (_streamingController is not null) { _streamingController.PriorityLandblockId = @@ -7561,7 +7565,10 @@ public sealed class GameWindow : IDisposable private void ResetTeleportTransitState(bool clearSession = false) { if (clearSession) + { _teleportTransit.ClearSession(); + _worldRevealTelemetry.Cancel(); + } else _teleportTransit.EndActive(); _pendingTeleportPos = default; @@ -7608,12 +7615,20 @@ public sealed class GameWindow : IDisposable /// loudly rather than holding forever. /// private bool TeleportWorldReady(uint destCell) - => _worldRevealReadiness?.IsReady(destCell) == true; + => EvaluateWorldRevealReadiness(destCell).IsReady; private bool LoginWorldReady() { return TryGetLoginWorldCell(out uint cell) - && _worldRevealReadiness?.IsReady(cell) == true; + && EvaluateWorldRevealReadiness(cell).IsReady; + } + + private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness( + uint destinationCell) + { + var snapshot = _worldRevealReadiness?.Evaluate(destinationCell) ?? default; + _worldRevealTelemetry.ObserveReadiness(snapshot); + return snapshot; } private bool TryGetLoginWorldCell(out uint cell) @@ -9742,6 +9757,7 @@ public sealed class GameWindow : IDisposable { case AcDream.Core.World.TeleportAnimEvent.Place: PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); + _worldRevealTelemetry.ObserveMaterialized(); if (_streamingController is not null) { _streamingController.PriorityLandblockId = 0u; @@ -9761,6 +9777,7 @@ public sealed class GameWindow : IDisposable // each portal transition. _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + _worldRevealTelemetry.Complete(); ResetTeleportTransitState(); break; default: @@ -10118,7 +10135,10 @@ public sealed class GameWindow : IDisposable ? loginCell : 0u; if (revealCell != 0) + { _worldRevealReadiness?.Prepare(revealCell); + EvaluateWorldRevealReadiness(revealCell); + } _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } if (_frameDiag) @@ -10176,7 +10196,11 @@ public sealed class GameWindow : IDisposable _particleVisibility.BeginFrame(camPos); _terrain?.BeginVisibilityFrame(); if (!IsLiveModeWaitingForLogin) + { _particleVisibility.UseWorldView(); + _worldRevealTelemetry.ObserveWorldViewportVisible(); + _worldRevealTelemetry.Complete(); + } // L.0 Audio tab: push the SettingsVM's live AudioDraft into the // engine each frame, so volume sliders preview audibly while diff --git a/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs new file mode 100644 index 00000000..95e0e018 --- /dev/null +++ b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs @@ -0,0 +1,158 @@ +using System.Globalization; + +namespace AcDream.App.Streaming; + +internal enum WorldRevealKind +{ + Login, + Portal, +} + +internal readonly record struct WorldRevealLifecycleSnapshot( + long Generation, + WorldRevealKind Kind, + WorldRevealReadinessSnapshot Readiness, + bool Materialized, + bool Completed, + bool Cancelled, + bool WorldViewportObserved, + int InvariantFailureCount) +{ + public bool IsActive => Generation != 0 && !Cancelled; + public bool IsReady => Readiness.IsReady; +} + +/// +/// Observes logical login/portal reveal generations without owning rendering or +/// changing gameplay state. The owner turns the canonical readiness decision +/// into deterministic lifecycle markers for connected automation and records a +/// hard diagnostic failure if normal world geometry becomes visible early. +/// +internal sealed class WorldRevealLifecycleTelemetry +{ + private readonly Action _log; + private WorldRevealLifecycleSnapshot _snapshot; + private long _nextGeneration; + private int _portalMaterializationCount; + + public WorldRevealLifecycleTelemetry(Action? log = null) + { + _log = log ?? (_ => { }); + } + + public WorldRevealLifecycleSnapshot Snapshot => _snapshot; + public int PortalMaterializationCount => _portalMaterializationCount; + + public long Begin(WorldRevealKind kind) + { + if (_snapshot.Generation != 0 + && !_snapshot.Cancelled + && !_snapshot.WorldViewportObserved) + { + Log("superseded", _snapshot); + } + + long generation = checked(++_nextGeneration); + _snapshot = new WorldRevealLifecycleSnapshot( + generation, + kind, + default, + Materialized: false, + Completed: false, + Cancelled: false, + WorldViewportObserved: false, + InvariantFailureCount: _snapshot.InvariantFailureCount); + Log("begin", _snapshot); + return generation; + } + + public void ObserveReadiness(WorldRevealReadinessSnapshot readiness) + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled) + return; + + if (_snapshot.Readiness == readiness) + return; + + if (_snapshot.Readiness.DestinationCell != 0u + && readiness.DestinationCell != 0u + && _snapshot.Readiness.DestinationCell != readiness.DestinationCell) + { + FailInvariant( + "destination-changed", + $"from=0x{_snapshot.Readiness.DestinationCell:X8} to=0x{readiness.DestinationCell:X8}"); + } + + _snapshot = _snapshot with { Readiness = readiness }; + Log("readiness", _snapshot); + } + + public void ObserveMaterialized() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Materialized) + return; + + _snapshot = _snapshot with { Materialized = true }; + if (_snapshot.Kind == WorldRevealKind.Portal) + _portalMaterializationCount++; + Log("materialized", _snapshot); + } + + public void ObserveWorldViewportVisible() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.WorldViewportObserved) + return; + + if (!_snapshot.Readiness.IsReady) + FailInvariant("viewport-before-ready", null); + + _snapshot = _snapshot with { WorldViewportObserved = true }; + Log("world-visible", _snapshot); + } + + public void Complete() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Completed) + return; + + _snapshot = _snapshot with { Completed = true }; + Log("complete", _snapshot); + } + + public void Cancel() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled) + return; + + _snapshot = _snapshot with { Cancelled = true }; + Log("cancel", _snapshot); + } + + private void FailInvariant(string reason, string? detail) + { + _snapshot = _snapshot with + { + InvariantFailureCount = checked(_snapshot.InvariantFailureCount + 1), + }; + string suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $" {detail}"; + _log($"[world-reveal] event=invariant-failure reason={reason}{suffix} {Describe(_snapshot)}"); + } + + private void Log(string eventName, WorldRevealLifecycleSnapshot snapshot) => + _log($"[world-reveal] event={eventName} {Describe(snapshot)}"); + + private static string Describe(WorldRevealLifecycleSnapshot snapshot) + { + WorldRevealReadinessSnapshot ready = snapshot.Readiness; + return string.Create( + CultureInfo.InvariantCulture, + $"generation={snapshot.Generation} kind={snapshot.Kind} " + + $"cell=0x{ready.DestinationCell:X8} indoor={ready.IsIndoor} " + + $"radius={ready.RequiredRenderRadius} unhydratable={ready.IsUnhydratable} " + + $"render={ready.IsRenderNeighborhoodReady} composites={ready.AreCompositeTexturesReady} " + + $"collision={ready.IsCollisionReady} ready={ready.IsReady} " + + $"materialized={snapshot.Materialized} completed={snapshot.Completed} " + + $"cancelled={snapshot.Cancelled} visible={snapshot.WorldViewportObserved} " + + $"failures={snapshot.InvariantFailureCount}"); + } +} diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs index 102b88a0..428cf912 100644 --- a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -1,5 +1,29 @@ namespace AcDream.App.Streaming; +/// +/// One evaluation of the destination domains guarded by +/// . Keeping the individual facts in +/// the canonical owner lets diagnostics and automation observe readiness +/// without duplicating (and eventually drifting from) the reveal predicate. +/// +internal readonly record struct WorldRevealReadinessSnapshot( + uint DestinationCell, + bool IsIndoor, + bool IsUnhydratable, + int RequiredRenderRadius, + bool IsRenderNeighborhoodReady, + bool AreCompositeTexturesReady, + bool IsCollisionReady) +{ + public bool HasDestination => DestinationCell != 0u; + + public bool IsReady => HasDestination + && (IsUnhydratable + || (IsRenderNeighborhoodReady + && AreCompositeTexturesReady + && IsCollisionReady)); +} + /// /// Joins the render-publication, texture, and collision domains that must be /// complete before the normal world viewport is revealed. Retail keeps @@ -73,20 +97,46 @@ internal sealed class WorldRevealReadinessBarrier /// existing loud forced-placement path can expose the invalid server data. /// public bool IsReady(uint destinationCell) + => Evaluate(destinationCell).IsReady; + + /// + /// Evaluates each readiness domain once and returns the complete decision. + /// Consumers must use this snapshot rather than reimplementing the join. + /// + public WorldRevealReadinessSnapshot Evaluate(uint destinationCell) { if (destinationCell == 0) - return false; - if (_isSpawnClaimUnhydratable(destinationCell)) - return true; + return default; + bool isIndoor = IsIndoor(destinationCell); int radius = RequiredRenderRadius(destinationCell); - if (!_isRenderNeighborhoodReady(destinationCell, radius) - || !_areCompositeTexturesReady()) - return false; + if (_isSpawnClaimUnhydratable(destinationCell)) + { + return new WorldRevealReadinessSnapshot( + destinationCell, + isIndoor, + IsUnhydratable: true, + radius, + IsRenderNeighborhoodReady: false, + AreCompositeTexturesReady: false, + IsCollisionReady: false); + } - return IsIndoor(destinationCell) - ? _isSpawnCellReady(destinationCell) - : _isTerrainNeighborhoodReady(destinationCell, radius); + bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius); + bool compositesReady = renderReady && _areCompositeTexturesReady(); + bool collisionReady = renderReady && compositesReady + && (isIndoor + ? _isSpawnCellReady(destinationCell) + : _isTerrainNeighborhoodReady(destinationCell, radius)); + + return new WorldRevealReadinessSnapshot( + destinationCell, + isIndoor, + IsUnhydratable: false, + radius, + renderReady, + compositesReady, + collisionReady); } private static int RequiredRenderRadius(uint destinationCell) => diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs new file mode 100644 index 00000000..a318c720 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs @@ -0,0 +1,105 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class WorldRevealLifecycleTelemetryTests +{ + private static WorldRevealReadinessSnapshot Ready(uint cell = 0x11340021u) => new( + cell, + IsIndoor: false, + IsUnhydratable: false, + RequiredRenderRadius: 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true); + + [Fact] + public void Login_RecordsReadyThenFirstVisibleFrameExactlyOnce() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + long generation = telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveWorldViewportVisible(); + telemetry.ObserveWorldViewportVisible(); + telemetry.Complete(); + + Assert.Equal(generation, telemetry.Snapshot.Generation); + Assert.True(telemetry.Snapshot.IsReady); + Assert.True(telemetry.Snapshot.WorldViewportObserved); + Assert.True(telemetry.Snapshot.Completed); + Assert.Equal(0, telemetry.Snapshot.InvariantFailureCount); + Assert.Single(logs, line => line.Contains("event=world-visible", StringComparison.Ordinal)); + } + + [Fact] + public void EarlyWorldViewport_IsARecordedHardInvariantFailure() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready() with { AreCompositeTexturesReady = false }); + telemetry.ObserveWorldViewportVisible(); + + Assert.Equal(1, telemetry.Snapshot.InvariantFailureCount); + Assert.Contains(logs, line => line.Contains("reason=viewport-before-ready", StringComparison.Ordinal)); + } + + [Fact] + public void NewGeneration_SupersedesOldStateAndRejectsDestinationMutation() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + long first = telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready(0x11340021u)); + long second = telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready(0x3032001Cu)); + telemetry.ObserveReadiness(Ready(0xC95B0001u)); + + Assert.True(second > first); + Assert.Equal(WorldRevealKind.Portal, telemetry.Snapshot.Kind); + Assert.Equal(0xC95B0001u, telemetry.Snapshot.Readiness.DestinationCell); + Assert.Equal(1, telemetry.Snapshot.InvariantFailureCount); + Assert.Contains(logs, line => line.Contains("event=superseded", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("reason=destination-changed", StringComparison.Ordinal)); + } + + [Fact] + public void PortalMaterialization_IsCountedOncePerGeneration() + { + var telemetry = new WorldRevealLifecycleTelemetry(); + + telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveMaterialized(); + telemetry.ObserveMaterialized(); + telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready(0x3032001Cu)); + telemetry.ObserveMaterialized(); + + Assert.Equal(2, telemetry.PortalMaterializationCount); + } + + [Fact] + public void CancelledGeneration_IgnoresLateAsyncObservations() + { + var telemetry = new WorldRevealLifecycleTelemetry(); + + telemetry.Begin(WorldRevealKind.Portal); + telemetry.Cancel(); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveMaterialized(); + telemetry.ObserveWorldViewportVisible(); + telemetry.Complete(); + + Assert.True(telemetry.Snapshot.Cancelled); + Assert.False(telemetry.Snapshot.IsReady); + Assert.False(telemetry.Snapshot.Materialized); + Assert.False(telemetry.Snapshot.WorldViewportObserved); + Assert.False(telemetry.Snapshot.Completed); + Assert.Equal(0, telemetry.PortalMaterializationCount); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs index 52eb54b9..8ac3e9a0 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs @@ -121,4 +121,46 @@ public sealed class WorldRevealReadinessBarrierTests Assert.Equal(0, state.Preparations); Assert.Equal(-1, state.RenderRadius); } + + [Fact] + public void Evaluate_ExposesTheCanonicalOutdoorDecisionWithoutRepeatingDomains() + { + const uint outdoorCell = 0x11340021u; + var state = new State + { + RenderReady = true, + CompositeReady = true, + TerrainReady = true, + }; + + WorldRevealReadinessSnapshot snapshot = state.Build().Evaluate(outdoorCell); + + Assert.Equal(outdoorCell, snapshot.DestinationCell); + Assert.False(snapshot.IsIndoor); + Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, snapshot.RequiredRenderRadius); + Assert.True(snapshot.IsRenderNeighborhoodReady); + Assert.True(snapshot.AreCompositeTexturesReady); + Assert.True(snapshot.IsCollisionReady); + Assert.True(snapshot.IsReady); + Assert.Equal(-1, state.PreparedRadius); + } + + [Fact] + public void Evaluate_ShortCircuitsDownstreamDomainsUntilRenderPublication() + { + var state = new State + { + CompositeReady = true, + TerrainReady = true, + SpawnCellReady = true, + }; + + WorldRevealReadinessSnapshot snapshot = state.Build().Evaluate(0x11340100u); + + Assert.False(snapshot.IsRenderNeighborhoodReady); + Assert.False(snapshot.AreCompositeTexturesReady); + Assert.False(snapshot.IsCollisionReady); + Assert.False(snapshot.IsReady); + Assert.Equal(-1, state.TerrainRadius); + } }