PortalTunnelPresentation was the last raw-GL world-adjacent renderer. It now
draws on both arms, and the composition that used to hand the Vulkan arm a
portal-less teleport presentation is gone with it.
Nothing about the scene changed. Same synthetic DAT Setup resolved through the
same client-enum mapping, same 40 fps CSequence, same retail rotation cadence,
same distant light, drawn through the same already-dual-arm WbDrawDispatcher.
What forked is only where the draw is recorded:
* GL keeps its GLStateScope, its viewport/scissor/depth/cull/blend statements
and its depth-only glClear, untouched.
* The RHI arm opens a backbuffer pass of its own and publishes it on
IWorldPassScope for the span of the draw - the shape V6l gave the two
offscreen viewports, and required for the same reason: the dispatcher's RHI
arm borrows its pass rather than opening one. Publication comes after
BeginPass and before UploadRetailLight, because publishing resets the
frame-global sections and this scene wants its own light, not the world's.
The one substantive decision is the pass's COLOUR load op, and it is a Clear
rather than a Load. Retail preserves the colour target and only clears depth
(UIViewportObject::DrawContent @ 0x006950A5 -> Clear(4) = D3DCLEAR_ZBUFFER), and
so does the GL arm. A Vulkan pass cannot inherit an image the way a bound
framebuffer can: under MSAA the frame's world pass RESOLVES into the swapchain
image and stores DontCare into the multisampled scratch, so a second
multisampled pass declaring Load would load undefined contents - plan section
5.5.12 item 5, the same hazard that merged the clear into the world pass.
Re-clearing is exact rather than approximate because of an invariant the frame
graph already enforces. RenderFrameFoundation.PortalViewportVisible and this
scene's IsVisible are the same value, read once at the top of the frame, and
WorldSceneRenderer returns without drawing when it is set. So whenever portal
space draws, the backbuffer holds exactly the opaque black
SceneTool::BeginScene @ 0x0043DAD0 establishes and nothing else, and clearing to
that same black changes no pixel. The alternative - a single-sampled Load pass
over the resolved image - would have been both a silent MSAA divergence and
invalid, since the backbuffer's depth attachment is multisampled.
The pass takes IWorldPassScope.SampleCount, so WbDrawDispatcher's sample-count
pipeline variants (V6l) select the backbuffer set, and depth matches the
attachment.
CreateRequired becomes internal: its two new seams are internal RHI contracts
and composition is its only caller. The TYPE keeps its visibility - plan section
7.1 rule 3.
Gates. Release build green. App tests 4,132 / 3 skips against the 4,129
baseline (three new: the retail black constant, the RHI arm's composition
precondition, and the both-arms composition assertion). Complete Release suite
9,195 / 5; one AcDream.Content failure in the solution-wide run that passes
124/124 rerun alone - the documented rerun-singly flake class, not carried
forward as a claim. Strict GL offline pixel gate against 280f3b3f: 28 px of
563,200, fraction 4.97e-05, inside the documented 9-31 band, with a same-commit
control pair at 20 px / 3.55e-05 taken immediately afterwards. GL connected
-Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One
offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the
loader: zero validation errors, zero warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
356 lines
14 KiB
C#
356 lines
14 KiB
C#
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.Content;
|
|
using AcDream.Content.Vfx;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Options;
|
|
using Xunit.Sdk;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class PortalTunnelAssetTests
|
|
{
|
|
[Fact]
|
|
public void PortalViewport_PreservesColorWhileClearingDepthLikeRetail()
|
|
{
|
|
Assert.Equal(
|
|
Silk.NET.OpenGL.ClearBufferMask.DepthBufferBit,
|
|
PortalTunnelPresentation.RetailViewportClearMask);
|
|
Assert.False(
|
|
(PortalTunnelPresentation.RetailViewportClearMask
|
|
& Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0);
|
|
}
|
|
|
|
/// <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
|
|
/// only exact if the colour is retail's opaque portal-space black — the same
|
|
/// value <c>SceneTool::BeginScene @ 0x0043DAD0</c> puts there and the same
|
|
/// one the clear phase computes when the portal viewport is visible.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PortalSpace_RhiPassReclearsTheSameOpaqueBlackTheFrameEstablished()
|
|
{
|
|
Assert.Equal(
|
|
new System.Numerics.Vector4(0f, 0f, 0f, 1f),
|
|
PortalTunnelPresentation.RetailPortalSpaceClearColor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6m. A backend with no GL context draws portal space
|
|
/// through a pass of its own, so it must be given somewhere to open one.
|
|
/// Composition failing loudly here beats a null dereference at the first
|
|
/// portal transit, which is minutes into a connected run.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PortalSpace_BackendWithoutGlRequiresAPassScopeAndFrameSource()
|
|
{
|
|
var error = Assert.Throws<ArgumentNullException>(() =>
|
|
PortalTunnelPresentation.CreateRequired(
|
|
gl: null,
|
|
scope: null,
|
|
frames: null,
|
|
dats: null!,
|
|
animationLoader: null!,
|
|
hookSink: null!,
|
|
dispatcher: null!,
|
|
lightUbo: null!,
|
|
meshAdapter: null!));
|
|
|
|
Assert.Equal("scope", error.ParamName);
|
|
Assert.Contains("world pass scope", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstalledDat_ResolvesRetailPortalSetupAndAnimation()
|
|
{
|
|
string? datDir = ResolveDatDir();
|
|
if (datDir is null)
|
|
throw SkipException.ForSkip(
|
|
"Installed client_portal.dat is required for the portal-space asset gate.");
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
uint setupDid = RetailDataIdResolver.Resolve(
|
|
dats,
|
|
PortalTunnelPresentation.SetupClientEnum,
|
|
PortalTunnelPresentation.ClientEnumCategory);
|
|
uint animationDid = RetailDataIdResolver.Resolve(
|
|
dats,
|
|
PortalTunnelPresentation.AnimationClientEnum,
|
|
PortalTunnelPresentation.ClientEnumCategory);
|
|
|
|
Assert.NotEqual(0u, setupDid);
|
|
Assert.NotEqual(0u, animationDid);
|
|
Assert.Equal(0x02000306u, setupDid);
|
|
Assert.Equal(0x030005ACu, animationDid);
|
|
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(setupDid));
|
|
Animation animation = Assert.IsType<Animation>(
|
|
new RetailAnimationLoader(dats).LoadAnimation(animationDid));
|
|
|
|
Assert.NotEmpty(setup.Parts);
|
|
Assert.True(
|
|
animation.PartFrames.Count >= TeleportAnimSequencer.TunnelEndFrame,
|
|
$"Retail tunnel timing samples frame {TeleportAnimSequencer.TunnelEndFrame}, "
|
|
+ $"but animation 0x{animationDid:X8} has only {animation.PartFrames.Count} frames.");
|
|
Assert.All(
|
|
setup.Parts,
|
|
part => Assert.Equal(0x01u, ((uint)part >> 24) & 0xFFu));
|
|
Assert.Equal(0u, (uint)setup.DefaultAnimation);
|
|
Assert.Equal(0u, (uint)setup.DefaultScript);
|
|
Assert.Equal(0u, (uint)setup.DefaultScriptTable);
|
|
var hook = Assert.Single(animation.PartFrames.SelectMany(frame => frame.Hooks));
|
|
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
|
|
}
|
|
|
|
[Fact]
|
|
public void RequiredAssets_MissingSetupOrAnimationFailsWithActionableIds()
|
|
{
|
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
|
PortalTunnelPresentation.EnsureRequiredAssets(
|
|
setupDid: 0u,
|
|
setupLoaded: false,
|
|
animationDid: 0x030005ACu,
|
|
animationLoaded: true));
|
|
|
|
Assert.Contains("required retail DAT assets unavailable", error.Message);
|
|
Assert.Contains("setup=0x00000000 (missing)", error.Message);
|
|
Assert.Contains("animation=0x030005AC (ok)", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
|
|
{
|
|
var camera = new PortalTunnelCamera
|
|
{
|
|
DirectionDegrees = 0f,
|
|
Aspect = 1f,
|
|
};
|
|
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var world));
|
|
Assert.Equal(PortalTunnelCamera.RetailEye.X, world.Translation.X, precision: 5);
|
|
Assert.Equal(PortalTunnelCamera.RetailEye.Y, world.Translation.Y, precision: 5);
|
|
Assert.Equal(PortalTunnelCamera.RetailEye.Z, world.Translation.Z, precision: 5);
|
|
|
|
camera.DirectionDegrees = 90f;
|
|
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var rotatedWorld));
|
|
System.Numerics.Vector3 forward = System.Numerics.Vector3.Normalize(
|
|
System.Numerics.Vector3.TransformNormal(-System.Numerics.Vector3.UnitZ, rotatedWorld));
|
|
System.Numerics.Vector3 up = System.Numerics.Vector3.Normalize(
|
|
System.Numerics.Vector3.TransformNormal(System.Numerics.Vector3.UnitY, rotatedWorld));
|
|
|
|
// Frame::rotate(identity, (0, pi/2, 0)): local +Y remains the
|
|
// camera's forward direction; local +Z rolls onto global +X.
|
|
Assert.Equal(0f, forward.X, precision: 5);
|
|
Assert.Equal(1f, forward.Y, precision: 5);
|
|
Assert.Equal(0f, forward.Z, precision: 5);
|
|
Assert.Equal(1f, up.X, precision: 5);
|
|
Assert.Equal(0f, up.Y, precision: 5);
|
|
Assert.Equal(0f, up.Z, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void TeleportViewPlane_FadeOutPortsRetailDistanceFovAndNearPlane()
|
|
{
|
|
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: 0.5f,
|
|
ShowTunnel: true,
|
|
ShowPleaseWait: false));
|
|
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
|
|
|
|
float gameDistance = baseProjection.M22;
|
|
float expectedDistance = gameDistance
|
|
+ (TeleportViewPlaneController.TransitionViewPlaneDistance - gameDistance) * 0.5f;
|
|
float expectedNear = MathF.Max(0.1f, expectedDistance * 0.25f);
|
|
float actualNear = projection.M43 / projection.M33;
|
|
|
|
Assert.True(controller.Enabled);
|
|
Assert.Equal(expectedDistance, controller.CurrentViewPlaneDistance, precision: 5);
|
|
Assert.Equal(expectedDistance, projection.M22, precision: 5);
|
|
Assert.Equal(expectedNear, actualNear, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void TeleportViewPlane_WorldFadeInStartsAtRetailWideProjectionAndRestoresGameProjection()
|
|
{
|
|
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.WorldFadeIn,
|
|
ViewPlaneBlend: 1f,
|
|
ShowTunnel: false,
|
|
ShowPleaseWait: false));
|
|
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
|
|
|
|
Assert.Equal(
|
|
TeleportViewPlaneController.TransitionViewPlaneDistance,
|
|
wideProjection.M22,
|
|
precision: 5);
|
|
Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5);
|
|
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.Off,
|
|
ViewPlaneBlend: 0f,
|
|
ShowTunnel: false,
|
|
ShowPleaseWait: false));
|
|
|
|
Assert.False(controller.Enabled);
|
|
Assert.Equal(baseProjection, controller.Apply(baseProjection));
|
|
}
|
|
|
|
[Fact]
|
|
public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer()
|
|
{
|
|
var source = new PortalTunnelCamera
|
|
{
|
|
Aspect = 16f / 9f,
|
|
FovRadians = MathF.PI / 3f,
|
|
Near = 0.1f,
|
|
Far = 5000f,
|
|
};
|
|
var controller = new TeleportViewPlaneController();
|
|
controller.Begin(source.Projection);
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.WorldFadeIn,
|
|
ViewPlaneBlend: 1f,
|
|
ShowTunnel: false,
|
|
ShowPleaseWait: false));
|
|
|
|
ICamera renderCamera = controller.ApplyTo(source);
|
|
|
|
Assert.Equal(source.View, renderCamera.View);
|
|
Assert.Equal(
|
|
TeleportViewPlaneController.TransitionViewPlaneDistance,
|
|
renderCamera.Projection.M22,
|
|
precision: 5);
|
|
renderCamera.Aspect = 4f / 3f;
|
|
Assert.Equal(4f / 3f, source.Aspect, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void TeleportViewPlane_TunnelPreservesLogoutOverrideUntilTunnelContinue()
|
|
{
|
|
var capturedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
|
MathF.PI / 3f,
|
|
16f / 9f,
|
|
0.1f,
|
|
5000f);
|
|
var changedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
|
MathF.PI / 2f,
|
|
16f / 9f,
|
|
0.1f,
|
|
5000f);
|
|
var controller = new TeleportViewPlaneController();
|
|
controller.Begin(capturedProjection);
|
|
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.WorldFadeOut,
|
|
ViewPlaneBlend: 1f,
|
|
ShowTunnel: false,
|
|
ShowPleaseWait: false));
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.TunnelFadeIn,
|
|
ViewPlaneBlend: 0f,
|
|
ShowTunnel: true,
|
|
ShowPleaseWait: false));
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.Tunnel,
|
|
ViewPlaneBlend: 0f,
|
|
ShowTunnel: true,
|
|
ShowPleaseWait: false));
|
|
|
|
Assert.True(controller.Enabled);
|
|
Assert.Equal(capturedProjection.M22, controller.Apply(changedProjection).M22, precision: 5);
|
|
|
|
controller.Update(new TeleportAnimSnapshot(
|
|
TeleportAnimState.TunnelContinue,
|
|
ViewPlaneBlend: 0f,
|
|
ShowTunnel: true,
|
|
ShowPleaseWait: false));
|
|
|
|
Assert.False(controller.Enabled);
|
|
Assert.Equal(changedProjection, controller.Apply(changedProjection));
|
|
}
|
|
|
|
[Fact]
|
|
public void TeleportViewPlane_NormalPortalTunnelDoesNotEnableOverride()
|
|
{
|
|
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.Tunnel,
|
|
ViewPlaneBlend: 0f,
|
|
ShowTunnel: true,
|
|
ShowPleaseWait: false));
|
|
|
|
Assert.False(controller.Enabled);
|
|
Assert.Equal(baseProjection, controller.Apply(baseProjection));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(45f)]
|
|
[InlineData(60f)]
|
|
[InlineData(90f)]
|
|
public void PortalTunnelCamera_UsesActiveSmartBoxProjectionClipRange(float degrees)
|
|
{
|
|
float expected = degrees * (MathF.PI / 180f);
|
|
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
|
expected,
|
|
16f / 9f,
|
|
0.35f,
|
|
5000f);
|
|
var camera = new PortalTunnelCamera();
|
|
|
|
camera.UseSmartBoxFov(smartBoxProjection);
|
|
|
|
Assert.Equal(expected, camera.FovRadians, precision: 5);
|
|
Assert.Equal(0.35f, camera.Near, precision: 5);
|
|
// Recovering a far plane from a float projection matrix loses a
|
|
// small amount of precision because M33 is extremely close to -1.
|
|
// The presentation must nevertheless inherit the active SmartBox
|
|
// range rather than fall back to its former private 50 m clip.
|
|
Assert.InRange(camera.Far, 4995f, 5005f);
|
|
}
|
|
|
|
private static string? ResolveDatDir()
|
|
{
|
|
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
|
if (!string.IsNullOrWhiteSpace(configured)
|
|
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
|
|
return configured;
|
|
|
|
const string installed = @"C:\Turbine\Asheron's Call";
|
|
if (File.Exists(Path.Combine(installed, "client_portal.dat")))
|
|
return installed;
|
|
|
|
string conventional = Path.Combine(
|
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
|
"Documents",
|
|
"Asheron's Call");
|
|
return File.Exists(Path.Combine(conventional, "client_portal.dat"))
|
|
? conventional
|
|
: null;
|
|
}
|
|
}
|