test(streaming): expose world reveal lifecycle
This commit is contained in:
parent
b60cb67009
commit
db45b81f75
6 changed files with 532 additions and 11 deletions
142
docs/plans/2026-07-20-automated-world-lifecycle-gate.md
Normal file
142
docs/plans/2026-07-20-automated-world-lifecycle-gate.md
Normal file
|
|
@ -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 <occurrence> [timeout-ms]` waits for the authoritative
|
||||||
|
portal completion count;
|
||||||
|
- `checkpoint <name>` emits one JSON record containing reveal state,
|
||||||
|
landblock/entity/animation and resource-owner counts, managed memory, and
|
||||||
|
the latest frame-profiler summary;
|
||||||
|
- `screenshot <name> [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.
|
||||||
|
|
@ -148,6 +148,8 @@ public sealed class GameWindow : IDisposable
|
||||||
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.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 _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)
|
||||||
|
|
@ -3615,6 +3617,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// 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();
|
_worldRevealReadiness?.Begin();
|
||||||
|
_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
|
||||||
|
|
@ -7538,6 +7541,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_teleportHoldSeconds = 0f;
|
_teleportHoldSeconds = 0f;
|
||||||
_teleportForced = false;
|
_teleportForced = false;
|
||||||
_worldRevealReadiness?.Begin();
|
_worldRevealReadiness?.Begin();
|
||||||
|
_worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
|
||||||
if (_streamingController is not null)
|
if (_streamingController is not null)
|
||||||
{
|
{
|
||||||
_streamingController.PriorityLandblockId =
|
_streamingController.PriorityLandblockId =
|
||||||
|
|
@ -7561,7 +7565,10 @@ public sealed class GameWindow : IDisposable
|
||||||
private void ResetTeleportTransitState(bool clearSession = false)
|
private void ResetTeleportTransitState(bool clearSession = false)
|
||||||
{
|
{
|
||||||
if (clearSession)
|
if (clearSession)
|
||||||
|
{
|
||||||
_teleportTransit.ClearSession();
|
_teleportTransit.ClearSession();
|
||||||
|
_worldRevealTelemetry.Cancel();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
_teleportTransit.EndActive();
|
_teleportTransit.EndActive();
|
||||||
_pendingTeleportPos = default;
|
_pendingTeleportPos = default;
|
||||||
|
|
@ -7608,12 +7615,20 @@ 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;
|
=> EvaluateWorldRevealReadiness(destCell).IsReady;
|
||||||
|
|
||||||
private bool LoginWorldReady()
|
private bool LoginWorldReady()
|
||||||
{
|
{
|
||||||
return TryGetLoginWorldCell(out uint cell)
|
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)
|
private bool TryGetLoginWorldCell(out uint cell)
|
||||||
|
|
@ -9742,6 +9757,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();
|
||||||
if (_streamingController is not null)
|
if (_streamingController is not null)
|
||||||
{
|
{
|
||||||
_streamingController.PriorityLandblockId = 0u;
|
_streamingController.PriorityLandblockId = 0u;
|
||||||
|
|
@ -9761,6 +9777,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();
|
||||||
ResetTeleportTransitState();
|
ResetTeleportTransitState();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -10118,7 +10135,10 @@ public sealed class GameWindow : IDisposable
|
||||||
? loginCell
|
? loginCell
|
||||||
: 0u;
|
: 0u;
|
||||||
if (revealCell != 0)
|
if (revealCell != 0)
|
||||||
|
{
|
||||||
_worldRevealReadiness?.Prepare(revealCell);
|
_worldRevealReadiness?.Prepare(revealCell);
|
||||||
|
EvaluateWorldRevealReadiness(revealCell);
|
||||||
|
}
|
||||||
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||||
}
|
}
|
||||||
if (_frameDiag)
|
if (_frameDiag)
|
||||||
|
|
@ -10176,7 +10196,11 @@ public sealed class GameWindow : IDisposable
|
||||||
_particleVisibility.BeginFrame(camPos);
|
_particleVisibility.BeginFrame(camPos);
|
||||||
_terrain?.BeginVisibilityFrame();
|
_terrain?.BeginVisibilityFrame();
|
||||||
if (!IsLiveModeWaitingForLogin)
|
if (!IsLiveModeWaitingForLogin)
|
||||||
|
{
|
||||||
_particleVisibility.UseWorldView();
|
_particleVisibility.UseWorldView();
|
||||||
|
_worldRevealTelemetry.ObserveWorldViewportVisible();
|
||||||
|
_worldRevealTelemetry.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
|
||||||
// engine each frame, so volume sliders preview audibly while
|
// engine each frame, so volume sliders preview audibly while
|
||||||
|
|
|
||||||
158
src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs
Normal file
158
src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldRevealLifecycleTelemetry
|
||||||
|
{
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
private WorldRevealLifecycleSnapshot _snapshot;
|
||||||
|
private long _nextGeneration;
|
||||||
|
private int _portalMaterializationCount;
|
||||||
|
|
||||||
|
public WorldRevealLifecycleTelemetry(Action<string>? 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,29 @@
|
||||||
namespace AcDream.App.Streaming;
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One evaluation of the destination domains guarded by
|
||||||
|
/// <see cref="WorldRevealReadinessBarrier"/>. Keeping the individual facts in
|
||||||
|
/// the canonical owner lets diagnostics and automation observe readiness
|
||||||
|
/// without duplicating (and eventually drifting from) the reveal predicate.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins the render-publication, texture, and collision domains that must be
|
/// Joins the render-publication, texture, and collision domains that must be
|
||||||
/// complete before the normal world viewport is revealed. Retail keeps
|
/// 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.
|
/// existing loud forced-placement path can expose the invalid server data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsReady(uint destinationCell)
|
public bool IsReady(uint destinationCell)
|
||||||
|
=> Evaluate(destinationCell).IsReady;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates each readiness domain once and returns the complete decision.
|
||||||
|
/// Consumers must use this snapshot rather than reimplementing the join.
|
||||||
|
/// </summary>
|
||||||
|
public WorldRevealReadinessSnapshot Evaluate(uint destinationCell)
|
||||||
{
|
{
|
||||||
if (destinationCell == 0)
|
if (destinationCell == 0)
|
||||||
return false;
|
return default;
|
||||||
if (_isSpawnClaimUnhydratable(destinationCell))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
|
bool isIndoor = IsIndoor(destinationCell);
|
||||||
int radius = RequiredRenderRadius(destinationCell);
|
int radius = RequiredRenderRadius(destinationCell);
|
||||||
if (!_isRenderNeighborhoodReady(destinationCell, radius)
|
if (_isSpawnClaimUnhydratable(destinationCell))
|
||||||
|| !_areCompositeTexturesReady())
|
{
|
||||||
return false;
|
return new WorldRevealReadinessSnapshot(
|
||||||
|
destinationCell,
|
||||||
|
isIndoor,
|
||||||
|
IsUnhydratable: true,
|
||||||
|
radius,
|
||||||
|
IsRenderNeighborhoodReady: false,
|
||||||
|
AreCompositeTexturesReady: false,
|
||||||
|
IsCollisionReady: false);
|
||||||
|
}
|
||||||
|
|
||||||
return IsIndoor(destinationCell)
|
bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius);
|
||||||
|
bool compositesReady = renderReady && _areCompositeTexturesReady();
|
||||||
|
bool collisionReady = renderReady && compositesReady
|
||||||
|
&& (isIndoor
|
||||||
? _isSpawnCellReady(destinationCell)
|
? _isSpawnCellReady(destinationCell)
|
||||||
: _isTerrainNeighborhoodReady(destinationCell, radius);
|
: _isTerrainNeighborhoodReady(destinationCell, radius));
|
||||||
|
|
||||||
|
return new WorldRevealReadinessSnapshot(
|
||||||
|
destinationCell,
|
||||||
|
isIndoor,
|
||||||
|
IsUnhydratable: false,
|
||||||
|
radius,
|
||||||
|
renderReady,
|
||||||
|
compositesReady,
|
||||||
|
collisionReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int RequiredRenderRadius(uint destinationCell) =>
|
private static int RequiredRenderRadius(uint destinationCell) =>
|
||||||
|
|
|
||||||
|
|
@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -121,4 +121,46 @@ public sealed class WorldRevealReadinessBarrierTests
|
||||||
Assert.Equal(0, state.Preparations);
|
Assert.Equal(0, state.Preparations);
|
||||||
Assert.Equal(-1, state.RenderRadius);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue