fix(render): harden portal exit handoff
Some checks failed
CI / linux-portable (push) Successful in 3m19s
CI / windows-gate (push) Failing after 6m43s
CI / release (push) Has been skipped

This commit is contained in:
Erik 2026-08-25 17:39:44 +02:00
parent ddbd7e4096
commit 82e4b4cb6d
17 changed files with 896 additions and 119 deletions

View file

@ -555,7 +555,20 @@ internal sealed class LocalPlayerTeleportPresentation
var (snapshot, events) = _animation.Tick(
deltaSeconds,
worldReady,
CurrentTunnelFrame);
CurrentTunnelFrame,
holdInTunnel: StreamingDiagnostics.TunnelFreezeFrame.HasValue);
// Retail hides portal space before publishing the WorldFadeIn view
// plane (gmSmartBoxUI::UseTime @ 0x004D73D3). Keep that viewport
// swap inside the presentation update: the controller's exit event
// performs host work, and allowing the terminal projection to escape
// while the tunnel remains visible exposes the finite portal mesh as
// a one-frame faceted disk. Hiding first is also safe if a render
// boundary lands between these two writes: the destination world is
// already materialized and receives the old outgoing projection.
if (!snapshot.ShowTunnel && _tunnel.IsVisible)
_tunnel.Exit();
_viewPlane.Update(snapshot);
return (snapshot, events);
}
@ -1228,16 +1241,19 @@ internal sealed class LocalPlayerTeleportController
return;
break;
case TeleportAnimEvent.PlayExitSound:
// gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination
// cell blocking at the exact portal/world viewport swap.
// LoginComplete remains one WorldFadeIn second later.
// Retail first hides portal space, then shows the world,
// and only afterwards plays Sound_UI_ExitPortal
// (gmSmartBoxUI::UseTime @ 0x004D73D3..0x004D7405).
// ExitTunnel is idempotent: the production presentation
// normally retired it before publishing this snapshot,
// while this call enforces the same order for every host.
_presentation.ExitTunnel();
if (!IsCurrentLifetime(generation, sequence))
return;
_worldReveal.RevealWorldViewport();
if (!IsCurrentLifetime(generation, sequence))
return;
_presentation.PlayExitCue();
if (!IsCurrentLifetime(generation, sequence))
return;
_presentation.ExitTunnel();
if (!IsCurrentLifetime(generation, sequence))
return;
break;
@ -1707,17 +1723,16 @@ internal sealed class LocalPlayerTeleportController
return;
break;
case TeleportAnimEvent.PlayExitSound:
// Release destination cell blocking at the exact
// portal/world viewport swap — same edge as the teleport
// pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with
// Sound_UI_ExitPortal @ 0x004D7405.
// Identical retail viewport order to the teleport pump:
// hide portal, show/release the destination world, then
// play Sound_UI_ExitPortal @ 0x004D7405.
_presentation.ExitTunnel();
if (!IsCurrentLoginLifetime(generation, revealGeneration))
return;
_worldReveal.RevealWorldViewport();
if (!IsCurrentLoginLifetime(generation, revealGeneration))
return;
_presentation.PlayExitCue();
if (!IsCurrentLoginLifetime(generation, revealGeneration))
return;
_presentation.ExitTunnel();
if (!IsCurrentLoginLifetime(generation, revealGeneration))
return;
break;

View file

@ -1,4 +1,5 @@
using System.Diagnostics;
using AcDream.App.Rendering;
using AcDream.Runtime;
namespace AcDream.App.Streaming;
@ -20,7 +21,9 @@ namespace AcDream.App.Streaming;
/// shape.</item>
/// <item>1 Hz progress — elapsed, the three flags, and the resident
/// landblock count, so a budget-paced linear drip is visually obvious in the
/// log.</item>
/// log. A paired <c>[reveal-resource]</c> snapshot attributes the cold render
/// barrier to mesh preparation/upload, arena growth, texture composites, or
/// process/GPU residency without sampling those owners every frame.</item>
/// <item><c>SUMMARY</c> once at the viewport reveal — the per-edge
/// timeline on one line.</item>
/// </list>
@ -31,6 +34,7 @@ namespace AcDream.App.Streaming;
internal sealed class RevealTimingProbe
{
private readonly Func<int>? _loadedLandblockCount;
private readonly IRenderFrameResourceDiagnosticsSource? _renderResources;
private readonly Stopwatch _clock = new();
private long _generation;
private string _kind = "";
@ -49,8 +53,13 @@ internal sealed class RevealTimingProbe
private long _lastProgressMs;
private int _framesSinceProgress;
public RevealTimingProbe(Func<int>? loadedLandblockCount) =>
public RevealTimingProbe(
Func<int>? loadedLandblockCount,
IRenderFrameResourceDiagnosticsSource? renderResources = null)
{
_loadedLandblockCount = loadedLandblockCount;
_renderResources = renderResources;
}
public void Begin(
string kind,
@ -80,6 +89,7 @@ internal sealed class RevealTimingProbe
+ $"cell=0x{destinationCell:X8} window={window.NearRadius}/"
+ $"{window.FarRadius} landblocks={_windowLandblocks} "
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}");
EmitResourceSnapshot("begin", elapsedMilliseconds: 0);
}
public void Observe(
@ -91,37 +101,56 @@ internal sealed class RevealTimingProbe
_framesSinceProgress++;
long elapsed = _clock.ElapsedMilliseconds;
string? resourceCheckpoint = null;
if (!_render && readiness.IsRenderNeighborhoodReady)
{
_render = true;
_renderMs = elapsed;
Edge("render-ready", elapsed);
resourceCheckpoint = AppendCheckpoint(
resourceCheckpoint,
"render-ready");
}
if (!_composites && readiness.AreCompositeTexturesReady)
{
_composites = true;
_compositesMs = elapsed;
Edge("composites-ready", elapsed);
resourceCheckpoint = AppendCheckpoint(
resourceCheckpoint,
"composites-ready");
}
if (!_collision && readiness.IsCollisionReady)
{
_collision = true;
_collisionMs = elapsed;
Edge("collision-ready", elapsed);
resourceCheckpoint = AppendCheckpoint(
resourceCheckpoint,
"collision-ready");
}
if (!_gateReady && readiness.IsReady)
{
_gateReady = true;
_gateReadyMs = elapsed;
Edge("gate-ready", elapsed);
resourceCheckpoint = AppendCheckpoint(
resourceCheckpoint,
"gate-ready");
}
if (!_materialized && portal.Materialized)
{
_materialized = true;
_materializedMs = elapsed;
Edge("materialized", elapsed);
resourceCheckpoint = AppendCheckpoint(
resourceCheckpoint,
"materialized");
}
if (resourceCheckpoint is not null)
EmitResourceSnapshot(resourceCheckpoint, elapsed);
if (!_summarized && portal.WorldViewportObserved)
{
_summarized = true;
@ -132,6 +161,7 @@ internal sealed class RevealTimingProbe
+ $"gateReadyMs={_gateReadyMs} "
+ $"materializedMs={_materializedMs} "
+ $"landblocks={_windowLandblocks}");
EmitResourceSnapshot("summary", elapsed);
return;
}
@ -151,6 +181,7 @@ internal sealed class RevealTimingProbe
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"
+ $"/{_windowLandblocks} "
+ $"frames={_framesSinceProgress}");
EmitResourceSnapshot("progress", elapsed);
PublicationTimingProbe.EmitStreamingTickWindow();
_framesSinceProgress = 0;
}
@ -162,4 +193,77 @@ internal sealed class RevealTimingProbe
+ $"elapsedMs={elapsed} "
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"
+ $"/{_windowLandblocks}");
private void EmitResourceSnapshot(string checkpoint, long elapsedMilliseconds)
{
if (_renderResources is null)
return;
RenderFrameResourceDiagnosticsSnapshot snapshot =
_renderResources.Capture();
Console.WriteLine(FormatResourceLine(
checkpoint,
_kind,
_generation,
elapsedMilliseconds,
snapshot));
}
private static string AppendCheckpoint(string? current, string next) =>
current is null ? next : current + "+" + next;
internal static string FormatResourceLine(
string checkpoint,
string kind,
long generation,
long elapsedMilliseconds,
in RenderFrameResourceDiagnosticsSnapshot snapshot)
{
MeshStreamResourceDiagnostics mesh = snapshot.Mesh;
TextureStreamResourceDiagnostics textures = snapshot.Textures;
ProcessResourceDiagnostics process = snapshot.Process;
return $"[reveal-resource] checkpoint={checkpoint} kind={kind} "
+ $"gen={generation} elapsedMs={elapsedMilliseconds} "
+ $"meshData={mesh.RenderData} meshAtlases={mesh.AtlasArrays} "
+ $"meshUnusedLru={mesh.UnusedLru} meshBytes={mesh.EstimatedBytes} "
+ $"globalUploads={mesh.GlobalUploadCount} "
+ $"globalUploadBytes={mesh.GlobalUploadedBytes} "
+ $"frameUploads={mesh.FrameUploadCount} "
+ $"frameUploadBytes={mesh.FrameUploadBytes} "
+ $"frameArrayBytes={mesh.FrameArrayAllocationBytes} "
+ $"frameMipmapBytes={mesh.FrameMipmapBytes} "
+ $"frameBufferUploadBytes={mesh.FrameBufferUploadBytes} "
+ $"frameBufferAllocationBytes={mesh.FrameBufferAllocationBytes} "
+ $"frameBufferCopyBytes={mesh.FrameBufferCopyBytes} "
+ $"frameNewArrays={mesh.FrameNewArrayCount} "
+ $"frameNewBuffers={mesh.FrameNewBufferCount} "
+ $"frameStaleDiscards={mesh.FrameStaleDiscardCount} "
+ $"frameMipmapArrays={mesh.FrameMipmapArrayCount} "
+ $"staged={mesh.StagedUploadBacklog} "
+ $"stagedBytes={mesh.StagedUploadBytes} "
+ $"stagingHighWater={(mesh.StagingAtHighWater ? 1 : 0)} "
+ $"cpuMeshCache={mesh.CpuMeshCacheCount} "
+ $"cpuMeshCacheBytes={mesh.CpuMeshCacheBytes} "
+ $"arenaCapacityBytes={mesh.GlobalCapacityBytes} "
+ $"arenaPhysicalBytes={mesh.GlobalPhysicalCapacityBytes} "
+ $"arenaMigrating={(mesh.GlobalMigrationInProgress ? 1 : 0)} "
+ $"prepared={mesh.PreparedProbes}/{mesh.PreparedReads}/"
+ $"{mesh.PreparedLoaded}/{mesh.PreparedMissing}/"
+ $"{mesh.PreparedCorrupt} "
+ $"ownedTextures={textures.OwnedBindlessTextures} "
+ $"textureOwners={textures.TextureOwners} "
+ $"composites={textures.CachedCompositeTextures} "
+ $"unownedComposites={textures.CachedUnownedComposites} "
+ $"unownedCompositeBytes={textures.CachedUnownedCompositeBytes} "
+ $"compositeAtlases={textures.CompositeAtlases} "
+ $"compositeAtlasBytes={textures.CompositeAtlasBytes} "
+ $"compositePending={textures.CompositeWarmupPending} "
+ $"frameCompositeUploads={textures.FrameCompositeUploadCount} "
+ $"frameCompositeUploadBytes={textures.FrameCompositeUploadBytes} "
+ $"managedBytes={process.ManagedBytes} "
+ $"managedCommittedBytes={process.ManagedCommittedBytes} "
+ $"trackedGpuBytes={process.TrackedGpuBytes} "
+ $"trackedGpuBuffers={process.TrackedGpuBuffers} "
+ $"trackedGpuTextures={process.TrackedGpuTextures}";
}
}

View file

@ -1,5 +1,6 @@
using System.Globalization;
using System;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -11,6 +12,8 @@ namespace AcDream.App.Streaming;
/// </summary>
internal static class StreamingDiagnostics
{
internal const int DefaultTunnelFreezeFrame = 72;
/// <summary>
/// #280 A/B measurement probe. When set, the outdoor reveal gate uses this
/// landblock radius instead of the derived streaming window, so the same
@ -65,6 +68,20 @@ internal static class StreamingDiagnostics
public static bool ProbeRevealTiming { get; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1";
/// <summary>
/// #419 RenderDoc apparatus. When present, the teleport sequencer remains
/// in retail's stable <c>Tunnel</c> state even after the destination is
/// ready, and the portal-space animation stops once it reaches this frame.
/// <c>ACDREAM_PROBE_TUNNEL_FREEZE=1</c> selects the representative frame
/// <see cref="DefaultTunnelFreezeFrame"/>; an explicit frame in the range
/// 2..<see cref="TeleportAnimSequencer.TunnelEndFrame"/> may be supplied
/// instead. This intentionally prevents placement and viewport reveal
/// until the transition is cancelled or the process exits. It is
/// inspection apparatus, not a user setting.
/// </summary>
public static int? TunnelFreezeFrame { get; } = ParseTunnelFreezeFrame(
Environment.GetEnvironmentVariable("ACDREAM_PROBE_TUNNEL_FREEZE"));
/// <summary>
/// The floor is 1, not 0. An outdoor destination's acknowledgement must
/// carry <c>RequiredRenderRadius &gt;= 1</c> or
@ -78,4 +95,23 @@ internal static class StreamingDiagnostics
&& value >= 1
? value
: null;
internal static int? ParseTunnelFreezeFrame(string? raw)
{
if (string.Equals(raw, "1", StringComparison.Ordinal)
|| string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase))
{
return DefaultTunnelFreezeFrame;
}
return int.TryParse(
raw,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int frame)
&& frame >= 2
&& frame <= TeleportAnimSequencer.TunnelEndFrame
? frame
: null;
}
}

View file

@ -1,3 +1,4 @@
using AcDream.App.Rendering;
using AcDream.Runtime;
using AcDream.Runtime.World;
@ -85,7 +86,8 @@ internal sealed class WorldRevealCoordinator
WorldGenerationQuiescence? quiescence = null,
IWorldRevealStreamingScheduler? streaming = null,
IWorldRevealRenderResourceScheduler? renderResources = null,
Func<int>? loadedLandblockCount = null)
Func<int>? loadedLandblockCount = null,
IRenderFrameResourceDiagnosticsSource? renderResourceDiagnostics = null)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_readiness = new WorldRevealReadinessBarrier(
@ -101,7 +103,11 @@ internal sealed class WorldRevealCoordinator
_streaming = streaming;
_renderResources = renderResources;
if (StreamingDiagnostics.ProbeRevealTiming)
_timing = new RevealTimingProbe(loadedLandblockCount);
{
_timing = new RevealTimingProbe(
loadedLandblockCount,
renderResourceDiagnostics);
}
}
public RuntimePortalSnapshot Snapshot => _transit.Snapshot;