test(streaming): expose world reveal lifecycle

This commit is contained in:
Erik 2026-07-20 22:32:23 +02:00
parent b60cb67009
commit db45b81f75
6 changed files with 532 additions and 11 deletions

View file

@ -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.
/// </summary>
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

View 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}");
}
}

View file

@ -1,5 +1,29 @@
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>
/// 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.
/// </summary>
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)
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) =>