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

@ -1,4 +1,7 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.Tests.Architecture;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Content.Vfx;
@ -12,6 +15,30 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PortalTunnelAssetTests
{
[Fact]
public void PortalWorldHandoff_HidesTunnelBeforePublishingWorldFadeInProjection()
{
MethodInfo tick = typeof(LocalPlayerTeleportPresentation).GetMethod(
nameof(LocalPlayerTeleportPresentation.Tick),
BindingFlags.Instance | BindingFlags.Public)
?? throw new InvalidOperationException("Presentation Tick method missing.");
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(tick);
int hide = CompiledCallGraph.IndexOf(
calls,
typeof(PortalTunnelPresentation),
nameof(PortalTunnelPresentation.Exit));
int publish = CompiledCallGraph.IndexOf(
calls,
typeof(TeleportViewPlaneController),
nameof(TeleportViewPlaneController.Update));
Assert.True(hide >= 0, "Presentation Tick no longer retires portal space.");
Assert.True(publish >= 0, "Presentation Tick no longer publishes its view plane.");
Assert.True(
hide < publish,
"Portal space must retire before the WorldFadeIn projection is visible to rendering.");
}
/// <summary>
/// Campaign V slice V6m. The RHI arm cannot inherit a bound framebuffer, so
/// its pass re-establishes the colour the frame already cleared. That is
@ -189,7 +216,7 @@ public sealed class PortalTunnelAssetTests
TeleportViewPlaneController.TransitionViewPlaneDistance,
wideProjection.M22,
precision: 5);
Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5);
Assert.Equal(0.00025f, wideProjection.M43 / wideProjection.M33, precision: 7);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.Off,
@ -201,6 +228,37 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(baseProjection, controller.Apply(baseProjection));
}
[Fact]
public void TeleportViewPlane_TunnelKeepsRetailNearPlaneWhileWorldUsesCoverageNearPlane()
{
var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f,
16f / 9f,
0.1f,
5000f);
var controller = new TeleportViewPlaneController();
controller.Begin(baseProjection);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelFadeOut,
ViewPlaneBlend: 1f,
ShowTunnel: true,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 tunnelProjection = controller.Apply(baseProjection);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeIn,
ViewPlaneBlend: 1f,
ShowTunnel: false,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 worldProjection = controller.Apply(baseProjection);
Assert.Equal(0.1f, tunnelProjection.M43 / tunnelProjection.M33, precision: 5);
Assert.Equal(0.00025f, worldProjection.M43 / worldProjection.M33, precision: 7);
Assert.Equal(tunnelProjection.M11, worldProjection.M11);
Assert.Equal(tunnelProjection.M22, worldProjection.M22);
}
[Fact]
public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer()
{

View file

@ -468,7 +468,7 @@ public sealed class LocalPlayerTeleportControllerTests
}
[Fact]
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
public void ExitSound_HidesTunnelBeforeReleasingDestinationAndKeepsProtocolActive()
{
var order = new List<string>();
var harness = new Harness(order: order);
@ -481,6 +481,8 @@ public sealed class LocalPlayerTeleportControllerTests
harness.Controller.Tick(0.016f);
Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end"));
Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue"));
Assert.False(harness.Presentation.IsPortalViewportVisible);
Assert.True(harness.Controller.IsActive);
Assert.False(harness.Reveal.Snapshot.Completed);
@ -1589,10 +1591,13 @@ public sealed class LocalPlayerTeleportControllerTests
harness.Controller.Tick(0.016f);
Assert.False(harness.Placement.Called);
// Viewport swap: reservation release + exit cue + tunnel retire
// Viewport swap: tunnel retire, reservation release, then exit cue
// (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405).
order.Clear();
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
harness.Controller.Tick(0.016f);
Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end"));
Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue"));
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
Assert.False(harness.Presentation.IsPortalViewportVisible);
Assert.Single(harness.Streaming.ReservationEnds);
@ -2126,9 +2131,17 @@ public sealed class LocalPlayerTeleportControllerTests
public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
public readonly List<string> Cues = new();
public void PlayEnterCue() => Cues.Add("enter");
public void PlayExitCue() => Cues.Add("exit");
public void PlayExitCue()
{
Cues.Add("exit");
_order.Add("exit-cue");
}
public void EnterTunnel() => IsPortalViewportVisible = true;
public void ExitTunnel() => IsPortalViewportVisible = false;
public void ExitTunnel()
{
IsPortalViewportVisible = false;
_order.Add("presentation-exit");
}
public void SetWaitCue(bool visible) => WaitCueValues.Add(visible);
public void Reset()

View file

@ -0,0 +1,129 @@
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.Runtime;
namespace AcDream.App.Tests.Streaming;
public sealed class RevealTimingProbeTests
{
[Fact]
public void ResourceSampling_IsLowFrequencyAndCoalescesSamePollEdges()
{
var resources = new CountingResourceSource();
var probe = new RevealTimingProbe(
loadedLandblockCount: static () => 4,
renderResources: resources);
probe.Begin(
kind: "Login",
generation: 2,
destinationCell: 0x1234_0001u,
window: new StreamingRevealWindow(NearRadius: 1, FarRadius: 2));
probe.Observe(
default,
RuntimePortalSnapshot.Idle with { Generation = 2 });
probe.Observe(
new WorldRevealReadinessSnapshot(
DestinationCell: 0x1234_0001u,
IsIndoor: false,
IsUnhydratable: false,
RequiredRenderRadius: 2,
RequiredNearRadius: 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true),
RuntimePortalSnapshot.Idle with
{
Generation = 2,
Materialized = true,
WorldViewportObserved = true,
});
// Begin, one coalesced readiness-edge snapshot, and summary. The
// ordinary false poll does not touch the renderer resource owners.
Assert.Equal(3, resources.CaptureCount);
}
[Fact]
public void ResourceLineCarriesColdMeshTextureAndProcessAttribution()
{
MeshStreamResourceDiagnostics mesh =
default(MeshStreamResourceDiagnostics) with
{
RenderData = 12,
AtlasArrays = 3,
EstimatedBytes = 4_000,
GlobalUploadCount = 7,
GlobalUploadedBytes = 8_000,
FrameUploadCount = 2,
FrameUploadBytes = 900,
StagedUploadBacklog = 5,
StagedUploadBytes = 6_000,
GlobalCapacityBytes = 10_000,
GlobalPhysicalCapacityBytes = 20_000,
GlobalMigrationInProgress = true,
PreparedReads = 21,
PreparedLoaded = 20,
};
TextureStreamResourceDiagnostics textures =
default(TextureStreamResourceDiagnostics) with
{
OwnedBindlessTextures = 17,
TextureOwners = 15,
CachedCompositeTextures = 9,
CachedUnownedComposites = 2,
CachedUnownedCompositeBytes = 700,
CompositeAtlases = 2,
CompositeAtlasBytes = 30_000,
CompositeWarmupPending = 4,
FrameCompositeUploadCount = 3,
FrameCompositeUploadBytes = 1_200,
};
ProcessResourceDiagnostics process = new(
ManagedBytes: 40_000,
ManagedCommittedBytes: 50_000,
TrackedGpuBytes: 60_000,
TrackedGpuBuffers: 11,
TrackedGpuTextures: 13);
var snapshot = new RenderFrameResourceDiagnosticsSnapshot(
default,
default,
mesh,
textures,
process);
string line = RevealTimingProbe.FormatResourceLine(
"render-ready",
"Login",
generation: 2,
elapsedMilliseconds: 3456,
snapshot);
Assert.StartsWith(
"[reveal-resource] checkpoint=render-ready kind=Login gen=2 elapsedMs=3456",
line);
Assert.Contains("meshData=12", line);
Assert.Contains("globalUploads=7", line);
Assert.Contains("staged=5", line);
Assert.Contains("arenaMigrating=1", line);
Assert.Contains("prepared=0/21/20/0/0", line);
Assert.Contains("ownedTextures=17", line);
Assert.Contains("unownedCompositeBytes=700", line);
Assert.Contains("compositePending=4", line);
Assert.Contains("frameCompositeUploadBytes=1200", line);
Assert.Contains("managedBytes=40000", line);
Assert.Contains("trackedGpuBytes=60000", line);
}
private sealed class CountingResourceSource :
IRenderFrameResourceDiagnosticsSource
{
public int CaptureCount { get; private set; }
public RenderFrameResourceDiagnosticsSnapshot Capture()
{
CaptureCount++;
return default;
}
}
}

View file

@ -0,0 +1,23 @@
using AcDream.App.Streaming;
namespace AcDream.App.Tests.Streaming;
public sealed class StreamingDiagnosticsTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("0", null)]
[InlineData("garbage", null)]
[InlineData("1", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
[InlineData("true", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
[InlineData("TRUE", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
[InlineData("2", 2)]
[InlineData("72", 72)]
[InlineData("120", 120)]
[InlineData("121", null)]
public void TunnelFreezeParser_AcceptsTheDefaultAliasOrAnAuthoredFrame(
string? raw,
int? expected) =>
Assert.Equal(expected, StreamingDiagnostics.ParseTunnelFreezeFrame(raw));
}

View file

@ -168,6 +168,31 @@ public sealed class TeleportAnimSequencerTests
Assert.Contains(TeleportAnimEvent.Place, evts);
}
[Fact]
public void DiagnosticHold_KeepsReadyPortalInStableTunnelUntilReleased()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
var (_, heldEvents) = seq.Tick(
dt: 30f,
worldReady: true,
tunnelAnimationFrame: 72,
holdInTunnel: true);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.DoesNotContain(TeleportAnimEvent.Place, heldEvents);
var (_, releasedEvents) = seq.Tick(
dt: 0f,
worldReady: true,
tunnelAnimationFrame: 72,
holdInTunnel: false);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
Assert.Contains(TeleportAnimEvent.Place, releasedEvents);
}
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
[Fact]
@ -301,6 +326,44 @@ public sealed class TeleportAnimSequencerTests
framesPerSecond);
}
[Fact]
public void TunnelFadeOut_SwapsAfterLastFullViewportCaptureLevel()
{
Assert.Equal((short)1001, TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true);
seq.Tick(
TeleportAnimSequencer.MinContinue,
worldReady: true,
tunnelAnimationFrame: 72);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
float lastSafeTime = Enumerable.Range(0, 1001)
.Select(static i => i / 1000f)
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
== TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
float firstUnsafeTime = Enumerable.Range(0, 1001)
.Select(static i => i / 1000f)
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
> TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
var (lastSafe, _) = seq.Tick(lastSafeTime, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelFadeOut, lastSafe.State);
Assert.Equal(
TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f,
lastSafe.ViewPlaneBlend);
var (swapped, events) = seq.Tick(
firstUnsafeTime - lastSafeTime,
worldReady: true);
Assert.Equal(TeleportAnimState.WorldFadeIn, swapped.State);
Assert.False(swapped.ShowTunnel);
Assert.Contains(TeleportAnimEvent.PlayExitSound, events);
}
private static void AssertOutgoingViewportRetiresBeforeTerminal(
TeleportAnimSequencer seq,
TeleportAnimState outgoingState,
@ -314,7 +377,8 @@ public sealed class TeleportAnimSequencerTests
if (snapshot.State == outgoingState)
{
Assert.True(
snapshot.ViewPlaneBlend <= 1022f / 1024f,
snapshot.ViewPlaneBlend
<= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f,
$"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}.");
continue;
}
@ -384,8 +448,13 @@ public sealed class TeleportAnimSequencerTests
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false);
float lastSafeTime = Enumerable.Range(0, 1001)
.Select(static i => i / 1000f)
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
== TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
// Drive directly to the last full-viewport quantized table level.
seq.Tick(lastSafeTime, worldReady: false);
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
@ -393,7 +462,9 @@ public sealed class TeleportAnimSequencerTests
Assert.True(
snap.ViewPlaneBlend > 0.95f,
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f);
Assert.True(
snap.ViewPlaneBlend
<= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f);
}
[Fact]