feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend

Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 02:19:53 +02:00
parent b70b9832ff
commit 8a7a0837e1
121 changed files with 1243 additions and 19840 deletions

View file

@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Audio;
using AcDream.App.Composition;
@ -19,7 +19,6 @@ using AcDream.Runtime.Gameplay;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Composition;
@ -253,7 +252,7 @@ public sealed class ContentEffectsAudioCompositionTests
Poses = new EntityEffectPoseRegistry();
Factory = new Factory();
Publication = new Publication();
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!);
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
typeof(HostInputCameraResult));
Dependencies = new ContentEffectsAudioDependencies(

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Input;
@ -8,7 +8,6 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Composition;
@ -95,7 +94,7 @@ public sealed class HostInputCameraCompositionTests
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
Input = new InputContext(keyboard, mouse);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, Input);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, Input);
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();

View file

@ -1,61 +1,30 @@
using AcDream.App;
using AcDream.App.Composition;
using Silk.NET.Core.Contexts;
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Vk;
namespace AcDream.App.Tests.Composition;
/// <summary>
/// Campaign V slice V6h: the graphics handle composition tests hand to a phase.
/// The graphics handle composition tests hand to a phase.
///
/// <para>The phases now select their backend arm from
/// <see cref="GameWindowGraphics"/> rather than from a bare <c>GL</c> reference,
/// so a test that means "compose the OpenGL arm" has to say so. The GL instance
/// is never called: every composition test supplies a stub factory that ignores
/// its context argument, and the loader below would fault if anything did — which
/// is the point. It is a token identifying the arm, not a driver.</para>
/// <para>Campaign V slice V11 deleted the raw-GL arm — <c>GameWindowGraphics</c>
/// no longer has a <c>Backend</c> or <c>Gl</c> member to select between, so this
/// is now a single Vulkan-shaped token: a real <see cref="VulkanWorldPassScope"/>
/// (composition phases require one, and it needs no live surface — just a
/// sample count), no live <see cref="VulkanGraphicsContext"/> because tests never
/// dereference it.</para>
/// </summary>
internal sealed class TestGameWindowGraphics : GameWindowGraphics
{
private readonly GL? _gl;
public static TestGameWindowGraphics Instance { get; } = new();
private TestGameWindowGraphics(RenderBackendKind backend, GL? gl)
private TestGameWindowGraphics()
{
Backend = backend;
_gl = gl;
}
/// <summary>Selects the OpenGL arm, with a context token no test dereferences.</summary>
public static TestGameWindowGraphics OpenGl { get; } =
new(RenderBackendKind.Gl, new GL(new UnusableNativeContext()));
/// <summary>Selects the Vulkan arm: no GL context exists.</summary>
public static TestGameWindowGraphics Vulkan { get; } =
new(RenderBackendKind.Vulkan, null);
public override RenderBackendKind Backend { get; }
public override GL? Gl => _gl;
public override IWorldPassScope? WorldPassScope { get; } = new VulkanWorldPassScope(sampleCount: 1);
public override void Dispose()
{
}
private sealed class UnusableNativeContext : INativeContext
{
public nint GetProcAddress(string proc, int? slot = null) =>
throw new InvalidOperationException(
$"A composition test called GL entry point '{proc}'. " +
"Test graphics are a backend token, not a driver.");
public bool TryGetProcAddress(string proc, out nint addr, int? slot = null)
{
addr = 0;
return false;
}
public void Dispose()
{
}
}
}

View file

@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using AcDream.App.Composition;
using AcDream.App.Rendering;
@ -14,7 +14,6 @@ using AcDream.UI.Abstractions.Settings;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Shader = AcDream.App.Rendering.Shader;
namespace AcDream.App.Tests.Composition;
@ -90,15 +89,12 @@ public sealed class WorldRenderCompositionTests
}
[Theory]
[InlineData("terrain shader", "terrain shader")]
[InlineData("scene lighting", "scene lighting")]
[InlineData("debug lines", "debug lines")]
[InlineData("HUD", "text renderer|debug font")]
[InlineData("terrain", "terrain")]
[InlineData("mesh shader", "mesh shader")]
[InlineData("WB mesh adapter", "WB mesh adapter")]
[InlineData("texture cache", "texture cache")]
[InlineData("sampler cache", "sampler cache")]
public void FailedPublicationRollsBackOnlyItsUnpublishedResourcePrefix(
string publication,
string expectedReleaseOrder)
@ -193,7 +189,7 @@ public sealed class WorldRenderCompositionTests
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High)));
@ -224,8 +220,6 @@ public sealed class WorldRenderCompositionTests
public ResidencyBudgetOptions? TextureBudgets { get; private set; }
public ResidencyManager? RegisteredResidency { get; private set; }
public void InitializeGlState(GL gl) { }
public WorldRegionData LoadRegion(IDatReaderWriter dats) =>
new(Stub<Region>(), new float[256]);
@ -233,16 +227,6 @@ public sealed class WorldRenderCompositionTests
WorldEnvironmentController environment,
Region region) { }
public BindlessSupport RequireBindless(GL gl, Action<string> log) =>
Stub<BindlessSupport>();
public TerrainAtlas AcquireTerrainAtlas(
IGameRenderResourceLifetime lifetime,
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
/// <summary>
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
/// Returns the same stub atlas through the same lifetime owner, so the
@ -258,7 +242,7 @@ public sealed class WorldRenderCompositionTests
/// Recorded rather than run: the exercise needs a real
/// <see cref="IGpuDevice"/> to create images through. Its behaviour is
/// covered by <c>RhiWorldTextureArrayTests</c> and by the Vulkan
/// composition-host run — see plan §5.5.13.
/// composition-host run — see plan §5.5.13.
/// </summary>
public void ExerciseBackendNeutralWorldTextures(
IGpuDevice device,
@ -270,12 +254,6 @@ public sealed class WorldRenderCompositionTests
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
Resource<Shader>("terrain shader");
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource<SceneLightingUboBinding>("scene lighting");
public SceneLightingUboBinding CreateBackendNeutralSceneLighting(
ICurrentGpuFrameSource frameSource,
IWorldPassScope scope) =>
@ -294,15 +272,6 @@ public sealed class WorldRenderCompositionTests
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
Resource<TextRenderer>("text renderer");
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuDevice gpuDevice,
IGpuResourceRetirementQueue retirement) =>
Resource<TerrainModernRenderer>("terrain");
public TerrainModernRenderer CreateBackendNeutralTerrain(
IGpuDevice gpuDevice,
ICurrentGpuFrameSource frameSource,
@ -323,9 +292,6 @@ public sealed class WorldRenderCompositionTests
Stub<TerrainBlendingContext>(),
new ConcurrentDictionary<uint, SurfaceInfo>());
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
Resource<Shader>("mesh shader");
public WbMeshAdapter CreateMeshAdapter(
GL? gl,
IGpuDevice device,
@ -339,10 +305,8 @@ public sealed class WorldRenderCompositionTests
}
public TextureCache CreateTextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets)
@ -362,9 +326,6 @@ public sealed class WorldRenderCompositionTests
RegisteredResidency = manager;
}
public SamplerCache CreateSamplerCache(GL gl) =>
Resource<SamplerCache>("sampler cache");
public void Release(IDisposable resource)
{
Releases.Add(_names[resource]);
@ -385,10 +346,6 @@ public sealed class WorldRenderCompositionTests
public WbMeshAdapter? MeshAdapter { get; private set; }
public TextureCache? TextureCache { get; private set; }
public void PublishBindlessSupport(BindlessSupport value) =>
Fail("bindless");
public void PublishTerrainShader(Shader value) =>
Fail("terrain shader");
public void PublishSceneLighting(SceneLightingUboBinding value) =>
Fail("scene lighting");
public void PublishDebugLines(DebugLineRenderer value) =>
@ -407,7 +364,6 @@ public sealed class WorldRenderCompositionTests
TerrainBlendingContext blending,
ConcurrentDictionary<uint, SurfaceInfo> surfaceCache) =>
Fail("terrain build state");
public void PublishMeshShader(Shader value) => Fail("mesh shader");
public void PublishWbMeshAdapter(WbMeshAdapter value)
{
@ -421,9 +377,6 @@ public sealed class WorldRenderCompositionTests
TextureCache = value;
}
public void PublishSamplerCache(SamplerCache value) =>
Fail("sampler cache");
private void Fail(string point)
{
if (string.Equals(failure, point, StringComparison.Ordinal))

View file

@ -1,261 +0,0 @@
using System.Text.Json;
using AcDream.App.Platform;
namespace AcDream.App.Tests.Platform;
public sealed class GraphicalCapabilityRequirementsTests
{
[Fact]
public void SupportedModernContextPasses()
{
GraphicalCapabilityRecord capabilities = CreateSupported();
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Theory]
[InlineData("bindless")]
[InlineData("draw-parameters")]
[InlineData("mdi")]
[InlineData("ssbo")]
[InlineData("timer")]
[InlineData("depth")]
[InlineData("stencil")]
[InlineData("srgb")]
[InlineData("keyboard")]
[InlineData("mouse")]
public void MissingMandatoryCapabilityIsRejected(string missing)
{
GraphicalCapabilityRecord capabilities = CreateSupported();
capabilities = missing switch
{
"bindless" => capabilities with
{
HasBindlessTexture = false,
},
"draw-parameters" => capabilities with
{
HasShaderDrawParameters = false,
},
"mdi" => capabilities with
{
HasMultiDrawIndirect = false,
},
"ssbo" => capabilities with
{
HasShaderStorageBuffer = false,
},
"timer" => capabilities with
{
HasTimerQuery = false,
},
"depth" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
DepthBits = 16,
},
},
"stencil" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
StencilBits = 0,
},
},
"srgb" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
FramebufferSrgbApi = false,
},
},
"keyboard" => capabilities with
{
Input = capabilities.Input with
{
KeyboardCount = 0,
},
},
"mouse" => capabilities with
{
Input = capabilities.Input with
{
MouseCount = 0,
},
},
_ => throw new ArgumentOutOfRangeException(nameof(missing)),
};
Assert.NotEmpty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Fact]
public void AdvertisedBufferStorageRequiresSuccessfulPersistentProbe()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
FunctionProbe = CreateSupported().FunctionProbe with
{
PersistentBufferStorage = false,
},
};
Assert.Contains(
GraphicalCapabilityRequirements.Evaluate(capabilities),
failure => failure.Contains(
"persistent mapping",
StringComparison.Ordinal));
}
[Fact]
public void MissingOptionalBufferStorageDoesNotRequireProbe()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
HasBufferStorage = false,
FunctionProbe = CreateSupported().FunctionProbe with
{
PersistentBufferStorage = null,
},
};
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Fact]
public void UnsupportedMessageNamesDriverProtocolFailureAndReport()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
SupportFailures = ["GL_ARB_bindless_texture is required."],
};
string message = GraphicalCapabilityGuard.FormatUnsupportedMessage(
capabilities,
"capabilities.json");
Assert.Contains("Mesa", message);
Assert.Contains("RadeonSI", message);
Assert.Contains("Wayland", message);
Assert.Contains("GL_ARB_bindless_texture", message);
Assert.Contains(
Path.GetFullPath("capabilities.json"),
message);
}
[Fact]
public void ReportWriterAtomicallyOverwritesJson()
{
string directory = Path.Combine(
Path.GetTempPath(),
"acdream-capability-tests",
Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "capabilities.json");
try
{
GraphicalCapabilityReportWriter.Write(path, CreateSupported());
GraphicalCapabilityReportWriter.Write(
path,
CreateSupported() with
{
GlRenderer = "second renderer",
});
using JsonDocument report = JsonDocument.Parse(
File.ReadAllText(path));
Assert.Equal(
"second renderer",
report.RootElement
.GetProperty(nameof(GraphicalCapabilityRecord.GlRenderer))
.GetString());
Assert.Equal(
"Wayland",
report.RootElement
.GetProperty(nameof(
GraphicalCapabilityRecord.ActiveDisplayProtocol))
.GetString());
Assert.False(File.Exists(path + ".tmp"));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
private static GraphicalCapabilityRecord CreateSupported() => new(
DateTimeOffset.UnixEpoch,
"linux-x64",
GraphicalHostOperatingSystem.Linux,
GraphicalDisplayProtocol.Wayland,
GraphicalDisplayProtocol.Wayland,
"test",
"Silk.NET.Windowing.Glfw",
"3.4.0",
"Mesa",
"RadeonSI",
"4.6",
"4.60",
4,
6,
1,
1,
HasBindlessTexture: true,
HasShaderDrawParameters: true,
HasMultiDrawIndirect: true,
HasShaderStorageBuffer: true,
HasBufferStorage: true,
HasTimerQuery: true,
MaximumShaderStorageBufferBindings: 16,
MaximumUniformBufferBindings: 72,
MaximumTextureSize: 16_384,
MaximumArrayTextureLayers: 2_048,
MaximumCombinedTextureImageUnits: 192,
new GraphicalFramebufferCapabilities(
8,
8,
8,
8,
24,
8,
1,
4,
FramebufferSrgbApi: true),
new GraphicalInputCapabilities(
KeyboardCount: 1,
MouseCount: 1,
GamepadCount: 0,
JoystickCount: 0),
new GraphicalWindowCapabilities(
1280,
720,
2560,
1440,
"test monitor",
144,
VSync: false),
new GraphicalAudioCapabilities(
Requested: false,
Available: false,
PlaybackSubmitted: false,
DisposalComplete: true,
Backend: "not requested"),
new GraphicalSmokeLifecycleCapabilities(
OwnedWindowCount: 1,
OwnedGlApiCount: 1,
OwnedInputContextCount: 1,
OwnedAudioEngineCount: 0,
ShutdownComplete: false),
[],
new GraphicalFunctionProbeResult(
BindlessTexture: true,
ShaderDrawParameters: true,
MultiDrawIndirect: true,
ShaderStorageBuffer: true,
TimerQuery: true,
SrgbFramebuffer: true,
PersistentBufferStorage: true,
Failures: []),
SupportFailures: []);
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
@ -41,7 +42,11 @@ public class ClipFrameLayoutTests
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Equal(144, ClipFrame.TerrainUboBytes);
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
Assert.Equal(2u, ClipFrame.MeshClipSsboBinding);
// The mesh side's binding index moved off ClipFrame at Campaign V slice
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc
// comment); the terrain UBO binding is still genuinely shared, so it stays.
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}

View file

@ -1,130 +0,0 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public sealed class ClipFrameUploadTests
{
[Theory]
[InlineData(1, 144)]
[InlineData(16, 144)]
[InlineData(64, 192)]
[InlineData(256, 256)]
[InlineData(512, 512)]
public void TerrainArena_RecordStride_RespectsDriverAlignment(
int alignment,
int expectedStride)
{
Assert.Equal(expectedStride, ClipFrameArenaLayout.RecordStride(alignment));
}
[Fact]
public void TerrainArena_AssignsEverySliceAUniqueOrderedRange()
{
const int stride = 256;
Assert.Equal(0, ClipFrameArenaLayout.RecordOffset(0, stride));
Assert.Equal(256, ClipFrameArenaLayout.RecordOffset(1, stride));
Assert.Equal(512, ClipFrameArenaLayout.RecordOffset(2, stride));
Assert.Equal(1280, ClipFrameArenaLayout.RequiredBytes(5, stride));
}
[Fact]
public void UploadState_RegionsOnce_AndTerrainRangesInSubmissionOrder()
{
var state = new ClipFrameUploadState();
state.BeginFrame();
state.ValidateRegionsNotUploaded();
state.MarkRegionsUploaded();
Assert.True(state.RegionsUploaded);
Assert.Throws<InvalidOperationException>(state.ValidateRegionsNotUploaded);
state.ValidateTerrainReservation(4);
state.CommitTerrainReservation(4);
Assert.Equal(new[] { 0, 1, 2, 3 }, new[]
{
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
});
Assert.Equal(4, state.TerrainUploaded);
Assert.Throws<InvalidOperationException>(() => state.NextTerrainRecord());
state.BeginFrame();
Assert.False(state.RegionsUploaded);
Assert.Equal(0, state.TerrainReserved);
Assert.Equal(0, state.TerrainUploaded);
}
[Fact]
public void ResourceRing_RetainsAtMostOneResourcePerFencedFrameSlot()
{
var ring = new ClipFrameResourceRing<object>(ClipFrame.FrameSlotCount);
for (int slot = 0; slot < ClipFrame.FrameSlotCount; slot++)
ring.Set(slot, new object());
// Pathological slice counts reuse ranges in the frame slot's one arena;
// they cannot add another GL object to the ring.
for (int slice = 0; slice < 10_000; slice++)
Assert.True(ring.TryGet(slice % ClipFrame.FrameSlotCount, out _));
Assert.Equal(ClipFrame.FrameSlotCount, ring.Count);
Assert.Throws<InvalidOperationException>(() => ring.Set(0, new object()));
}
[Fact]
public void CapacityPolicy_ShrinksOneOffPathologicalPeakAfterHysteresis()
{
var policy = new ClipBufferCapacityPolicy();
int peak = policy.SelectCapacity(0, 1_000_000);
Assert.True(peak >= 1_000_000);
int first = policy.SelectCapacity(peak, 4_096);
int second = policy.SelectCapacity(first, 4_096);
int third = policy.SelectCapacity(second, 4_096);
Assert.Equal(peak, first);
Assert.Equal(peak, second);
Assert.Equal(4_096, third);
}
[Fact]
public void CapacityPolicy_OrdinaryDemandJitterCancelsPendingShrink()
{
var policy = new ClipBufferCapacityPolicy();
int capacity = policy.SelectCapacity(0, 65_536);
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 20_000); // above 25% utilization
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 4_096);
Assert.Equal(65_536, capacity);
}
[Fact]
public void CapacityTransaction_PublishesOnlySuccessfulResize()
{
int capacity = 4_096;
Assert.Throws<InvalidOperationException>(() =>
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(_, _) => throw new InvalidOperationException("BufferData failed")));
Assert.Equal(4_096, capacity);
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(previous, next) =>
{
Assert.Equal(4_096, previous);
Assert.Equal(8_192, next);
});
Assert.Equal(8_192, capacity);
// A later stage failure must not roll accounting back to the old store.
Action laterFailure = () => throw new InvalidOperationException("later bind failed");
Assert.Throws<InvalidOperationException>(laterFailure);
Assert.Equal(8_192, capacity);
}
}

View file

@ -138,7 +138,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"new ResourceShutdownStage(\"render frontends\"",
"Hard(\"portal tunnel\"",
"Hard(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
"new ResourceShutdownStage(\"graphics API context\"");
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
@ -155,7 +155,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"input context\"",
"platform.Input?.Dispose()",
"new ResourceShutdownStage(\"OpenGL context\"");
"new ResourceShutdownStage(\"graphics API context\"");
}
[Fact]

View file

@ -18,7 +18,10 @@ public sealed class GameWindowSlice8BoundaryTests
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
"VSync = startupPacing.UseVSync",
"Samples = startup.Quality.MsaaSamples",
// Campaign V slice V11: the raw-GL "Samples = ..." window option
// is gone — Vulkan takes MSAA as an RHI attachment property, not
// a window attribute. _startupQuality carries it forward instead.
"_startupQuality = startup.Quality;",
"Window.Create(options)",
"_displayFramePacing.BindSurface(",
"_windowCallbacks = SilkWindowCallbackBinding.Create(",
@ -332,7 +335,13 @@ public sealed class GameWindowSlice8BoundaryTests
run,
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
"Samples = startup.Quality.MsaaSamples",
// Campaign V slice V11: Vulkan needs a client-API-less window and
// takes neither MSAA nor the stencil bit count as a window
// attribute (both are RHI attachment properties instead), so the
// raw-GL "Samples = ..." window option this used to assert is
// gone. _startupQuality carries MsaaSamples forward instead, into
// CreateGraphics' VulkanGraphicsContext.Acquire call.
"_startupQuality = startup.Quality;",
"Window.Create(options)");
AssertAppearsInOrder(
load,
@ -372,9 +381,9 @@ public sealed class GameWindowSlice8BoundaryTests
"WorldRenderComposition.cs"));
AssertAppearsInOrder(
worldPhase,
"TerrainAtlas.Build(gl, dats, bindless)",
"TerrainAtlas.BuildBackendNeutral(device, dats)",
"settings.ResolvedQuality.AnisotropicLevel",
"_factory.CreateTerrain(");
"_factory.CreateBackendNeutralTerrain(");
AssertAppearsInOrder(
shutdown,
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
@ -471,7 +480,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"frame flight owner\"",
"new ResourceShutdownStage(\"content mappings\"",
"new ResourceShutdownStage(\"input context\"",
"new ResourceShutdownStage(\"OpenGL context\"",
"new ResourceShutdownStage(\"graphics API context\"",
];
AssertAppearsInOrder(manifest, stages);
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
@ -563,7 +572,6 @@ public sealed class GameWindowSlice8BoundaryTests
livePhase,
"d.PortalTunnelFallback.AcquirePrepared(",
"static tunnel => tunnel.PrepareResources());",
"d.RenderResourceLifetime.AcquireSkyShader(",
"new SkyRenderer(");
AssertAppearsInOrder(
load,
@ -584,13 +592,14 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
worldPhase,
"lifetime.AcquireTerrainAtlas(",
"TerrainAtlas.Build(gl, dats, bindless)",
"TerrainModernRenderer CreateTerrain(",
"TerrainAtlas.BuildBackendNeutral(device, dats)",
"TerrainModernRenderer CreateBackendNeutralTerrain(",
// Campaign V slice V6j: terrain is composed on both arms, so its
// acquisition is unconditional. The boundary this test pins — that
// the atlas is acquired, then the factory names the renderer, then
// the renderer is acquired AND published in one step — is unchanged.
"TerrainModernRenderer? terrain = AcquireAndPublish(");
// The raw-GL arm (CreateTerrain) was deleted at slice V11.
"TerrainModernRenderer terrain = AcquireAndPublish(");
AssertAppearsInOrder(
shutdown,
"frame.FrameGraphPublication?.Dispose()",
@ -600,7 +609,6 @@ public sealed class GameWindowSlice8BoundaryTests
"render.PortalTunnelFallback.ReleaseFallback();",
"render.Sky?.Dispose()",
"render.Terrain?.Dispose()",
"render.DedicatedResources.ReleaseSkyShader",
"render.DedicatedResources.ReleaseTerrainAtlas",
"render.ConstructionCleanup.Dispose",
"platform.Graphics?.Dispose()");
@ -620,11 +628,11 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
source,
"_window.Run();",
"_glConstructionCleanup.RetainFrom(failure);",
"_constructionCleanup.RetainFrom(failure);",
"private GameWindowShutdownRoots CaptureShutdownRoots()",
"_glConstructionCleanup)");
"_constructionCleanup)");
Assert.Contains(
"Hard(\"GL construction ledger\", render.ConstructionCleanup.Dispose)",
"Hard(\"resource construction ledger\", render.ConstructionCleanup.Dispose)",
shutdown,
StringComparison.Ordinal);
}

View file

@ -1,417 +0,0 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GlTextureOwnershipTests
{
[Fact]
public void ConstructionRollbackDeletesAllNamesInReverseOrder()
{
var api = new FakeTextureNameApi();
var transaction = new GlTextureConstructionTransaction(api);
Assert.Equal(1u, transaction.Allocate());
Assert.Equal(2u, transaction.Allocate());
Assert.Equal(3u, transaction.Allocate());
transaction.Rollback();
transaction.Rollback();
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
}
[Fact]
public void ConstructionRollbackAttemptsEveryNameAndReportsFailures()
{
var api = new FakeTextureNameApi { FailingDelete = 2 };
var transaction = new GlTextureConstructionTransaction(api);
_ = transaction.Allocate();
_ = transaction.Allocate();
_ = transaction.Allocate();
AggregateException failure = Assert.Throws<AggregateException>(transaction.Rollback);
Assert.Single(failure.InnerExceptions);
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
Assert.Equal([3u, 1u], api.Deleted);
api.FailingDelete = null;
transaction.RetryCleanup();
Assert.True(transaction.IsCleanupComplete);
Assert.Equal([3u, 2u, 1u, 2u], api.DeleteAttempts);
Assert.Equal([3u, 1u, 2u], api.Deleted);
}
[Fact]
public void CommittedNamesAreNeverDeletedByConstructionTransaction()
{
var api = new FakeTextureNameApi();
var transaction = new GlTextureConstructionTransaction(api);
_ = transaction.Allocate();
_ = transaction.Allocate();
transaction.Commit();
transaction.Rollback();
Assert.Empty(api.DeleteAttempts);
Assert.Throws<InvalidOperationException>(() => transaction.Allocate());
}
[Fact]
public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction()
{
var api = new FakeTextureNameApi();
var transaction = new GlTextureConstructionTransaction(api);
uint first = TrackedTextureConstruction.Create(transaction, _ => { });
Assert.Throws<InvalidOperationException>(() =>
TrackedTextureConstruction.Create(
transaction,
_ => throw new InvalidOperationException("upload failed")));
transaction.Rollback();
Assert.Equal(1u, first);
Assert.Equal([2u, 1u], api.DeleteAttempts);
}
[Fact]
public void TerrainAtlasRoutesEveryTerrainAlphaAndFallbackUploadThroughTracker()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"TerrainAtlas.cs"));
Assert.Equal(5, CountOccurrences(source, "TrackedTextureConstruction.Create("));
Assert.Equal(
5,
System.Text.RegularExpressions.Regex.Matches(
source,
"TrackedTextureConstruction\\.Create\\(\\s*textures,\\s*gl,\\s*\\\"").Count);
Assert.DoesNotContain("textures.Allocate()", source, StringComparison.Ordinal);
}
[Fact]
public void ProductionGlResourcePathsUseAlwaysOnCheckedCommitBoundaries()
{
string root = FindRepoRoot();
string textureNames = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "GlTextureConstructionTransaction.cs"));
string shaderPrograms = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs"));
string terrain = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs"));
// Campaign V slice V4a: TextRenderer's shader/texture creation moved
// from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/
// CreateTexture, whose own checked-commit construction
// (GlResourceCommand.CreateName / ShaderProgramConstruction.Build,
// predating this slice) is what those now delegate to. Slice V6d then
// removed the white fill texture, so TextRenderer's constructor owns a
// single resource and holds no GL name of any kind — every checked
// commit boundary it depends on lives behind the RHI.
string text = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "TextRenderer.cs"));
string bindless = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Wb", "BindlessSupport.cs"));
Assert.Contains("GlResourceCommand.CreateTexture", textureNames, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteTexture", textureNames, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.CreateName", shaderPrograms, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteShader", shaderPrograms, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal);
Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal);
Assert.Contains("_pipeline = device.CreatePipeline(", text, StringComparison.Ordinal);
Assert.DoesNotContain("GlResourceCommand", text, StringComparison.Ordinal);
Assert.DoesNotContain("GlName", text, StringComparison.Ordinal);
Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal);
}
private static void AssertAppearsInOrder(string source, params string[] needles)
{
int cursor = -1;
foreach (string needle in needles)
{
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
cursor = next;
}
}
[Fact]
public void SecondBindlessAcquireFailureRollsBackFirstHandle()
{
var residency = new FakeResidency { FailingAcquireTexture = 20 };
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
Assert.Throws<InvalidOperationException>(() => pair.Acquire());
Assert.False(pair.IsFullyResident);
Assert.Equal([10u, 20u], residency.AcquireAttempts);
Assert.Equal([1010ul], residency.ReleaseAttempts);
}
[Fact]
public void FailedPrefixRollbackRemainsOwnedAndRetryDoesNotReacquireIt()
{
var residency = new FakeResidency
{
FailingAcquireTexture = 20,
FailingReleaseHandle = 1010,
};
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
Assert.Throws<AggregateException>(() => pair.Acquire());
residency.FailingAcquireTexture = null;
residency.FailingReleaseHandle = null;
Assert.Equal((1010ul, 1020ul), pair.Acquire());
Assert.True(pair.IsFullyResident);
Assert.Equal([10u, 20u, 20u], residency.AcquireAttempts);
Assert.Equal([1010ul], residency.ReleaseAttempts);
}
[Fact]
public void BindlessReleaseAttemptsBothAndRetriesOnlyPendingHandle()
{
var residency = new FakeResidency();
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
_ = pair.Acquire();
residency.FailingReleaseHandle = 1010;
Assert.Throws<AggregateException>(pair.Release);
Assert.False(pair.IsFullyResident);
Assert.True(pair.HasAnyResident);
Assert.Equal([1010ul, 1020ul], residency.ReleaseAttempts);
residency.FailingReleaseHandle = null;
pair.Release();
pair.Release();
Assert.False(pair.HasAnyResident);
Assert.Equal([1010ul, 1020ul, 1010ul], residency.ReleaseAttempts);
}
[Fact]
public void MutationFailureStillRestoresThePreviouslyResidentPair()
{
var residency = new FakeResidency();
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
var guard = new BindlessTextureMutationGuard(pair);
_ = pair.Acquire();
Assert.Throws<InvalidOperationException>(() =>
guard.Execute(() => throw new InvalidOperationException("mutation failed")));
Assert.True(pair.IsFullyResident);
Assert.False(guard.RestoreRequired);
Assert.Equal([10u, 20u, 10u, 20u], residency.AcquireAttempts);
}
[Fact]
public void FailedReacquireKeepsRestoreIntentUntilALaterMutationRetry()
{
var residency = new FakeResidency();
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
var guard = new BindlessTextureMutationGuard(pair);
_ = pair.Acquire();
residency.FailingAcquireTexture = 20;
Assert.Throws<InvalidOperationException>(() => guard.Execute(() => { }));
Assert.True(guard.RestoreRequired);
Assert.False(pair.HasAnyResident);
residency.FailingAcquireTexture = null;
guard.Execute(() => { });
Assert.False(guard.RestoreRequired);
Assert.True(pair.IsFullyResident);
Assert.Equal([10u, 20u, 10u, 20u, 10u, 20u], residency.AcquireAttempts);
}
[Fact]
public void PartialReleaseFailurePreventsMutationAndRestoresPairBeforeThrowing()
{
var residency = new FakeResidency();
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
var guard = new BindlessTextureMutationGuard(pair);
_ = pair.Acquire();
residency.FailingReleaseHandle = 1010;
int mutations = 0;
Assert.Throws<AggregateException>(() => guard.Execute(() => mutations++));
Assert.Equal(0, mutations);
Assert.True(pair.IsFullyResident);
Assert.False(guard.RestoreRequired);
}
[Fact]
public void FailedTextureMutationRestoresTheExactPriorBinding()
{
var bindings = new List<uint>();
var owner = new RestoredTextureBindingMutation();
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
owner.Execute(
() => 77,
bindings.Add,
42,
() => throw new InvalidOperationException("mutation failed")));
Assert.Equal("mutation failed", failure.Message);
Assert.Equal([42u, 77u], bindings);
}
[Fact]
public void TextureMutationReportsBothMutationAndBindingRestoreFailure()
{
int bindCalls = 0;
var owner = new RestoredTextureBindingMutation();
AggregateException failure = Assert.Throws<AggregateException>(() =>
owner.Execute(
() => 77,
_ =>
{
bindCalls++;
if (bindCalls == 2)
throw new InvalidOperationException("restore failed");
},
42,
() => throw new InvalidOperationException("mutation failed")));
Assert.Equal(2, failure.InnerExceptions.Count);
Assert.Equal(2, bindCalls);
Assert.True(owner.HasPendingRestore);
}
[Fact]
public void FailedBindingRestoreRetriesTheOriginalBindingBeforeAnotherMutation()
{
var owner = new RestoredTextureBindingMutation();
var bindings = new List<uint>();
int restoreFailures = 1;
int mutations = 0;
Assert.Throws<InvalidOperationException>(() =>
owner.Execute(
() => 77,
binding =>
{
bindings.Add(binding);
if (binding == 77 && restoreFailures-- > 0)
throw new InvalidOperationException("restore failed");
},
42,
() => mutations++));
Assert.True(owner.HasPendingRestore);
owner.Execute(
() => 77,
bindings.Add,
42,
() => mutations++);
Assert.False(owner.HasPendingRestore);
Assert.Equal([42u, 77u, 77u, 42u, 77u], bindings);
Assert.Equal(2, mutations);
}
[Fact]
public void ConstructionCleanupLedgerRetainsNestedFailureUntilRetryCompletes()
{
var api = new FakeTextureNameApi { FailingDelete = 1 };
var transaction = new GlTextureConstructionTransaction(api);
_ = transaction.Allocate();
AggregateException cleanupFailure = Assert.Throws<AggregateException>(
transaction.Rollback);
var constructionFailure = new GlResourceConstructionException(
"synthetic construction failure",
transaction,
[new InvalidOperationException("build failed"), cleanupFailure]);
var ledger = new GlConstructionCleanupLedger();
Assert.True(ledger.RetainFrom(constructionFailure));
Assert.Throws<AggregateException>(ledger.Dispose);
Assert.False(ledger.IsComplete);
api.FailingDelete = null;
ledger.Dispose();
Assert.True(ledger.IsComplete);
Assert.True(constructionFailure.IsCleanupComplete);
}
private sealed class FakeTextureNameApi : IGlTextureNameApi
{
private uint _nextName = 1;
public uint? FailingDelete { get; set; }
public List<uint> DeleteAttempts { get; } = [];
public List<uint> Deleted { get; } = [];
public uint GenTexture() => _nextName++;
public void DeleteTexture(uint texture)
{
DeleteAttempts.Add(texture);
if (FailingDelete == texture)
throw new InvalidOperationException("delete failed");
Deleted.Add(texture);
}
}
private static int CountOccurrences(string source, string value)
{
int count = 0;
int cursor = 0;
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
{
count++;
cursor += value.Length;
}
return count;
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
private sealed class FakeResidency
{
public uint? FailingAcquireTexture { get; set; }
public ulong? FailingReleaseHandle { get; set; }
public List<uint> AcquireAttempts { get; } = [];
public List<ulong> ReleaseAttempts { get; } = [];
public ulong Acquire(uint texture)
{
AcquireAttempts.Add(texture);
if (FailingAcquireTexture == texture)
throw new InvalidOperationException("acquire failed");
return 1000ul + texture;
}
public void Release(ulong handle)
{
ReleaseAttempts.Add(handle);
if (FailingReleaseHandle == handle)
throw new InvalidOperationException("release failed");
}
}
}

View file

@ -1,198 +0,0 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
/// <summary>
/// The texture table's flush is mapped with GL_MAP_UNSYNCHRONIZED_BIT and
/// GL_MAP_INVALIDATE_RANGE_BIT, so a run this tracker reports must contain
/// nothing but slots that were actually written. A run that swallowed a clean
/// slot in between would let the driver discard a live bindless handle while a
/// submitted draw was reading it — invisible in any pixel gate, and exactly the
/// class of fault the ring rewrite exists to remove. These tests pin that.
/// </summary>
public sealed class GlDirtySlotRunsTests
{
[Fact]
public void NothingMarkedYieldsNoRuns()
{
var runs = new GlDirtySlotRuns(16);
Assert.False(runs.HasDirtySlots);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void OneMarkedSlotIsOneRunOfOne()
{
var runs = new GlDirtySlotRuns(16);
runs.Mark(7);
Assert.True(runs.HasDirtySlots);
Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
Assert.Equal(7u, first);
Assert.Equal(1u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void ConsecutiveSlotsMergeIntoOneRun()
{
var runs = new GlDirtySlotRuns(16);
runs.Mark(4);
runs.Mark(5);
runs.Mark(6);
Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
Assert.Equal(4u, first);
Assert.Equal(3u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void AGapBetweenMarkedSlotsSplitsTheRuns()
{
var runs = new GlDirtySlotRuns(64);
runs.Mark(5);
runs.Mark(50);
Assert.True(runs.TryTakeNextRun(out uint firstStart, out uint firstCount));
Assert.Equal(5u, firstStart);
Assert.Equal(1u, firstCount);
Assert.True(runs.TryTakeNextRun(out uint secondStart, out uint secondCount));
Assert.Equal(50u, secondStart);
Assert.Equal(1u, secondCount);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void RunsComeBackInAscendingSlotOrderRegardlessOfMarkOrder()
{
var runs = new GlDirtySlotRuns(64);
runs.Mark(40);
runs.Mark(1);
runs.Mark(41);
runs.Mark(20);
runs.Mark(0);
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
Assert.Equal(0u, start);
Assert.Equal(2u, count);
Assert.True(runs.TryTakeNextRun(out start, out count));
Assert.Equal(20u, start);
Assert.Equal(1u, count);
Assert.True(runs.TryTakeNextRun(out start, out count));
Assert.Equal(40u, start);
Assert.Equal(2u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void MarkingTheSameSlotTwiceStillYieldsOneRun()
{
var runs = new GlDirtySlotRuns(16);
runs.Mark(3);
runs.Mark(3);
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
Assert.Equal(3u, start);
Assert.Equal(1u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void DrainingLeavesTheTrackerCleanForTheNextFlush()
{
var runs = new GlDirtySlotRuns(16);
runs.Mark(2);
runs.Mark(9);
while (runs.TryTakeNextRun(out _, out _))
{
}
Assert.False(runs.HasDirtySlots);
runs.Mark(11);
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
Assert.Equal(11u, start);
Assert.Equal(1u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void SlotsMarkedAfterAPartialDrainAreStillReported()
{
var runs = new GlDirtySlotRuns(32);
runs.Mark(4);
runs.Mark(20);
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
Assert.Equal(4u, start);
Assert.Equal(1u, count);
// A draw between two flushes can register another texture.
runs.Mark(21);
Assert.True(runs.TryTakeNextRun(out start, out count));
Assert.Equal(20u, start);
Assert.Equal(2u, count);
Assert.False(runs.TryTakeNextRun(out _, out _));
}
[Fact]
public void TheLastSlotInTheTableIsAddressable()
{
var runs = new GlDirtySlotRuns(8);
runs.Mark(7);
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
Assert.Equal(7u, start);
Assert.Equal(1u, count);
}
[Fact]
public void MarkingBeyondCapacityThrows()
{
var runs = new GlDirtySlotRuns(8);
Assert.Throws<ArgumentOutOfRangeException>(() => runs.Mark(8));
}
[Fact]
public void ZeroCapacityIsRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GlDirtySlotRuns(0));
}
/// <summary>
/// The whole point, stated as one property: every slot a run covers was
/// marked, and every marked slot is covered exactly once.
/// </summary>
[Fact]
public void EveryReportedSlotWasMarkedAndEveryMarkedSlotIsReportedOnce()
{
const int Capacity = 200;
var random = new Random(20260727);
var expected = new HashSet<uint>();
var runs = new GlDirtySlotRuns(Capacity);
for (int i = 0; i < 60; i++)
{
uint slot = (uint)random.Next(Capacity);
expected.Add(slot);
runs.Mark(slot);
}
var reported = new List<uint>();
while (runs.TryTakeNextRun(out uint start, out uint count))
{
for (uint slot = start; slot < start + count; slot++)
reported.Add(slot);
}
Assert.Equal(reported.Count, reported.Distinct().Count());
Assert.Equal(expected.OrderBy(slot => slot), reported);
}
}

View file

@ -1,79 +0,0 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlEnumMappingTests
{
[Fact]
public void VertexShapesMatchTheDeclaredComponentCounts()
{
Assert.Equal(new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float1));
Assert.Equal(new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float2));
Assert.Equal(new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float3));
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float4));
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true), GlEnumMapping.VertexShapeOf(GpuVertexFormat.UByte4Normalized));
}
[Fact]
public void IndexTypesMapToTheirGlSizesAndEnums()
{
Assert.Equal(DrawElementsType.UnsignedShort, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt16));
Assert.Equal(2, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt16));
Assert.Equal(DrawElementsType.UnsignedInt, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt32));
Assert.Equal(4, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt32));
}
[Fact]
public void BlendFactorsMatchTheDocumentedFormulas()
{
(BlendingFactor source, BlendingFactor destination) straight = GlEnumMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha);
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), straight);
(BlendingFactor source, BlendingFactor destination) additive = GlEnumMapping.BlendFactorsOf(GpuBlendMode.Additive);
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.One), additive);
}
[Fact]
public void BlendNoneHasNoGlFactors()
{
Assert.Throws<NotSupportedException>(() => GlEnumMapping.BlendFactorsOf(GpuBlendMode.None));
}
[Fact]
public void CullNoneHasNoGlCullFaceMode()
{
Assert.Throws<NotSupportedException>(() => GlEnumMapping.CullFaceModeOf(GpuCullMode.None));
}
[Fact]
public void DepthCompareOpsMapOneToOne()
{
Assert.Equal(DepthFunction.Never, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Never));
Assert.Equal(DepthFunction.Less, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Less));
Assert.Equal(DepthFunction.Lequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.LessOrEqual));
Assert.Equal(DepthFunction.Equal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Equal));
Assert.Equal(DepthFunction.Greater, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Greater));
Assert.Equal(DepthFunction.Gequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.GreaterOrEqual));
Assert.Equal(DepthFunction.Always, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Always));
}
[Fact]
public void FilterCombinationsProduceTheExpectedMinFilter()
{
Assert.Equal(TextureMinFilter.Nearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.None));
Assert.Equal(TextureMinFilter.Linear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.None));
Assert.Equal(TextureMinFilter.LinearMipmapLinear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.Linear));
Assert.Equal(TextureMinFilter.NearestMipmapNearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.Nearest));
}
[Fact]
public void EveryNonNoneEnumValueResolvesForFrontFaceAndWrapMode()
{
Assert.Equal(FrontFaceDirection.Ccw, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.CounterClockwise));
Assert.Equal(FrontFaceDirection.CW, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.Clockwise));
Assert.Equal(TextureWrapMode.Repeat, GlEnumMapping.WrapModeOf(GpuAddressMode.Repeat));
Assert.Equal(TextureWrapMode.ClampToEdge, GlEnumMapping.WrapModeOf(GpuAddressMode.ClampToEdge));
}
}

View file

@ -1,75 +0,0 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlGpuTextureFormatMappingTests
{
[Fact]
public void Rgba8AndItsRenderTargetVariantShareTheSameGlShape()
{
GlTextureFormatInfo plain = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8Unorm);
GlTextureFormatInfo target = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8UnormRenderTarget);
Assert.Equal(plain, target);
Assert.Equal(SizedInternalFormat.Rgba8, plain.SizedInternalFormat);
Assert.Equal(PixelFormat.Rgba, plain.UploadPixelFormat);
Assert.Equal(PixelType.UnsignedByte, plain.UploadPixelType);
Assert.False(plain.IsCompressed);
Assert.Equal(4, plain.LayerByteCount(1, 1));
Assert.Equal(4 * 16 * 16, plain.LayerByteCount(16, 16));
}
[Fact]
public void R8IsOneByteUncompressed()
{
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.R8Unorm);
Assert.Equal(SizedInternalFormat.R8, info.SizedInternalFormat);
Assert.False(info.IsCompressed);
Assert.Equal(1, info.LayerByteCount(1, 1));
Assert.Equal(64, info.LayerByteCount(8, 8));
}
[Fact]
public void Bc1IsCompressedFourByFourEightByteBlocks() =>
AssertBcFormat(GpuTextureFormat.Bc1Unorm, expectedBlockBytes: 8);
[Fact]
public void Bc2IsCompressedFourByFourSixteenByteBlocks() =>
AssertBcFormat(GpuTextureFormat.Bc2Unorm, expectedBlockBytes: 16);
[Fact]
public void Bc3IsCompressedFourByFourSixteenByteBlocks() =>
AssertBcFormat(GpuTextureFormat.Bc3Unorm, expectedBlockBytes: 16);
private static void AssertBcFormat(GpuTextureFormat format, int expectedBlockBytes)
{
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(format);
Assert.True(info.IsCompressed);
Assert.Equal(4, info.BlockDimension);
Assert.Equal(expectedBlockBytes, info.BlockOrTexelBytes);
// A single 4x4 block for a 4x4 texture.
Assert.Equal(expectedBlockBytes, info.LayerByteCount(4, 4));
// Partial blocks round up: a 5x5 texture needs a 2x2 block grid.
Assert.Equal(expectedBlockBytes * 4, info.LayerByteCount(5, 5));
}
[Fact]
public void Depth24Stencil8IsAttachmentShapedNotCompressed()
{
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Depth24Stencil8);
Assert.Equal(SizedInternalFormat.Depth24Stencil8, info.SizedInternalFormat);
Assert.Equal(PixelFormat.DepthStencil, info.UploadPixelFormat);
Assert.Equal(PixelType.UnsignedInt248, info.UploadPixelType);
Assert.False(info.IsCompressed);
}
[Fact]
public void EveryGpuTextureFormatValueResolvesWithoutThrowing()
{
foreach (GpuTextureFormat format in Enum.GetValues<GpuTextureFormat>())
GlGpuTextureFormatMapping.Resolve(format);
}
}

View file

@ -1,123 +0,0 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlGpuTimerPoolTests
{
[Fact]
public void UnsupportedPoolReturnsNoOpScopesAndNeverResolves()
{
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: false);
using (pool.BeginScope("world"))
{
}
Assert.False(pool.TryResolve("world", out _));
}
[Fact]
public void ScopeNamesGetIndependentDoubleBufferedQueries()
{
var api = new FakeTimerQueryApi();
var pool = new GlGpuTimerPool(api, isSupported: true);
using (pool.BeginScope("world"))
{
}
using (pool.BeginScope("ui"))
{
}
Assert.Equal(4, api.CreatedQueryCount);
}
[Fact]
public void NestingAScopeBeforeDisposingThePreviousOneThrows()
{
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: true);
pool.BeginScope("world");
Assert.Throws<InvalidOperationException>(() => pool.BeginScope("ui"));
}
[Fact]
public void AResolvedResultIsPromotedTheNextTimeTheSameScopeBegins()
{
var api = new FakeTimerQueryApi();
var pool = new GlGpuTimerPool(api, isSupported: true);
using (pool.BeginScope("world"))
{
}
// No result available yet — the first slot hasn't been revisited.
Assert.False(pool.TryResolve("world", out _));
api.MakeNextResultReady(milliseconds: 1.5);
using (pool.BeginScope("world"))
{
}
Assert.True(pool.TryResolve("world", out double milliseconds));
Assert.Equal(1.5, milliseconds);
}
[Fact]
public void DisposeQueriesDeletesEveryCreatedQuery()
{
var api = new FakeTimerQueryApi();
var pool = new GlGpuTimerPool(api, isSupported: true);
using (pool.BeginScope("world"))
{
}
pool.DisposeQueries();
Assert.Equal(2, api.DeletedQueryCount);
}
private sealed class FakeTimerQueryApi : IGlTimerQueryApi
{
private uint _nextQuery = 1;
private bool _nextResultReady;
private double _nextResultMilliseconds;
public int CreatedQueryCount { get; private set; }
public int DeletedQueryCount { get; private set; }
public uint CreateQuery()
{
CreatedQueryCount++;
return _nextQuery++;
}
public void DeleteQuery(uint query) => DeletedQueryCount++;
public void Begin(uint query)
{
}
public void End()
{
}
public void MakeNextResultReady(double milliseconds)
{
_nextResultReady = true;
_nextResultMilliseconds = milliseconds;
}
public bool TryGetResult(uint query, out double milliseconds)
{
if (_nextResultReady)
{
milliseconds = _nextResultMilliseconds;
_nextResultReady = false;
return true;
}
milliseconds = 0;
return false;
}
}
}

View file

@ -1,35 +0,0 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlPushConstantUniformNamesTests
{
[Fact]
public void EveryDocumentedFieldMapsToItsDocumentedUniformName()
{
Assert.Equal("uViewProjection", GlPushConstantUniformNames.ByFieldName["ViewProjection"]);
Assert.Equal("uDrawIDOffset", GlPushConstantUniformNames.ByFieldName["DrawIdOffset"]);
Assert.Equal("uLightingMode", GlPushConstantUniformNames.ByFieldName["LightingMode"]);
Assert.Equal("uRenderPass", GlPushConstantUniformNames.ByFieldName["RenderPass"]);
Assert.Equal("uLightDebug", GlPushConstantUniformNames.ByFieldName["LightDebug"]);
Assert.Equal("uTextureIndexA", GlPushConstantUniformNames.ByFieldName["TextureIndexA"]);
Assert.Equal("uTextureIndexB", GlPushConstantUniformNames.ByFieldName["TextureIndexB"]);
Assert.Equal("uParamA", GlPushConstantUniformNames.ByFieldName["ParamA"]);
Assert.Equal("uParamB", GlPushConstantUniformNames.ByFieldName["ParamB"]);
}
[Fact]
public void TableCoversEveryFieldOnTheSharedStructWithNoStaleEntries()
{
// This is the drift guard: if a later slice adds/removes/renames a
// GpuPushConstants field without updating the mapping table, this
// test fails instead of the GL backend silently skipping a uniform.
GlPushConstantUniformNames.AssertMapsEveryField();
}
[Fact]
public void TableHasExactlyNineEntries()
{
Assert.Equal(9, GlPushConstantUniformNames.ByFieldName.Count);
}
}

View file

@ -1,140 +0,0 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlRenderStateCacheTests
{
private static GlRenderStateSnapshot Default(uint program = 1) => new(
program,
GpuBlendMode.None,
DepthTest: true,
DepthWrite: true,
GpuCompareOp.LessOrEqual,
GpuCullMode.Back,
GpuFrontFace.CounterClockwise,
AlphaToCoverage: false,
ColorWrite: true,
// Slice V6l: the stencil dimension. Off is what every pipeline in the
// tree but the portal depth mask asks for.
StencilTest: false,
GpuStencilState.Default);
[Fact]
public void FirstApplyReportsEveryDimensionChanged()
{
var cache = new GlRenderStateCache();
GlRenderStateChanges changes = cache.Apply(Default());
Assert.Equal(GlRenderStateChanges.All, changes);
Assert.True(changes.AnyChange);
}
[Fact]
public void ReapplyingTheIdenticalSnapshotReportsNoChanges()
{
var cache = new GlRenderStateCache();
GlRenderStateSnapshot snapshot = Default();
cache.Apply(snapshot);
GlRenderStateChanges changes = cache.Apply(snapshot);
Assert.False(changes.AnyChange);
}
[Fact]
public void ChangingOnlyCullModeReportsOnlyThatDimension()
{
var cache = new GlRenderStateCache();
cache.Apply(Default());
GlRenderStateChanges changes = cache.Apply(Default() with { Cull = GpuCullMode.None });
Assert.True(changes.Cull);
Assert.True(changes.AnyChange);
Assert.False(changes.Program);
Assert.False(changes.Blend);
Assert.False(changes.DepthTest);
Assert.False(changes.DepthWrite);
Assert.False(changes.DepthCompare);
Assert.False(changes.FrontFace);
Assert.False(changes.AlphaToCoverage);
Assert.False(changes.ColorWrite);
Assert.False(changes.StencilTest);
Assert.False(changes.Stencil);
}
[Fact]
public void ChangingOnlyTheStencilValuesReportsOnlyThatDimension()
{
// Slice V6l. #117's portal punch changes compare, ops, reference and
// masks between its mark pass and its punch pass while the test stays
// enabled, so the two stencil dimensions have to move independently.
var cache = new GlRenderStateCache();
cache.Apply(Default() with { StencilTest = true });
GlRenderStateChanges changes = cache.Apply(Default() with
{
StencilTest = true,
Stencil = GpuStencilState.Default with
{
Compare = GpuCompareOp.Equal,
Pass = GpuStencilOp.Zero,
Reference = 1,
},
});
Assert.True(changes.Stencil);
Assert.False(changes.StencilTest);
Assert.False(changes.DepthTest);
Assert.False(changes.ColorWrite);
}
[Fact]
public void ChangingTheProgramReportsProgramChangedEvenWhenEveryOtherFieldMatches()
{
var cache = new GlRenderStateCache();
cache.Apply(Default(program: 1));
GlRenderStateChanges changes = cache.Apply(Default(program: 2));
Assert.True(changes.Program);
Assert.False(changes.Blend);
}
[Fact]
public void ResetForcesTheNextApplyToReportEveryDimensionAgain()
{
var cache = new GlRenderStateCache();
GlRenderStateSnapshot snapshot = Default();
cache.Apply(snapshot);
cache.Reset();
GlRenderStateChanges changes = cache.Apply(snapshot);
Assert.Equal(GlRenderStateChanges.All, changes);
}
[Fact]
public void MultipleDimensionsChangingAreAllReported()
{
var cache = new GlRenderStateCache();
cache.Apply(Default());
GlRenderStateChanges changes = cache.Apply(Default() with
{
Blend = GpuBlendMode.StraightAlpha,
DepthWrite = false,
FrontFace = GpuFrontFace.Clockwise,
});
Assert.True(changes.Blend);
Assert.True(changes.DepthWrite);
Assert.True(changes.FrontFace);
Assert.False(changes.Cull);
Assert.False(changes.DepthTest);
Assert.False(changes.DepthCompare);
Assert.False(changes.AlphaToCoverage);
Assert.False(changes.ColorWrite);
Assert.False(changes.Program);
}
}

View file

@ -1,182 +0,0 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlRingBufferStateTests
{
[Fact]
public void FirstAllocationStartsAtZero()
{
var state = new GlRingBufferState(1024);
uint offset = state.Allocate(64, alignmentBytes: 16);
Assert.Equal(0u, offset);
Assert.Equal(64u, state.AllocatedBytes);
}
[Fact]
public void SubsequentAllocationsAlignUpToTheRequestedBoundary()
{
var state = new GlRingBufferState(1024);
state.Allocate(12, alignmentBytes: 4);
uint second = state.Allocate(64, alignmentBytes: 256);
Assert.Equal(0u, second % 256);
Assert.True(second >= 12);
}
[Fact]
public void AllocationsMergeIntoOneDirtyRange()
{
var state = new GlRingBufferState(1024);
state.Allocate(16, alignmentBytes: 4);
state.Allocate(32, alignmentBytes: 4);
(int start, int length) = state.TakeDirtyRange();
Assert.Equal(0, start);
Assert.Equal(48, length);
}
[Fact]
public void TakingTheDirtyRangeClearsItUntilTheNextWrite()
{
var state = new GlRingBufferState(1024);
state.Allocate(16, alignmentBytes: 4);
state.TakeDirtyRange();
(int start, int length) = state.TakeDirtyRange();
Assert.Equal(0, start);
Assert.Equal(0, length);
Assert.False(state.HasDirtyBytes);
}
[Fact]
public void WritesAfterAFlushExtendANewDirtyRangeWithoutRewindingTheCursor()
{
var state = new GlRingBufferState(1024);
state.Allocate(16, alignmentBytes: 4);
state.TakeDirtyRange();
uint secondOffset = state.Allocate(8, alignmentBytes: 4);
Assert.Equal(16u, secondOffset);
(int start, int length) = state.TakeDirtyRange();
Assert.Equal(16, start);
Assert.Equal(8, length);
}
/// <summary>
/// The precondition for issuing the ring's writes with
/// GL_MAP_UNSYNCHRONIZED_BIT: within one frame, no flush may cover a byte an
/// earlier flush already handed to the GPU. This walks a frame's worth of
/// mixed-alignment allocations and asserts the ranges are disjoint and
/// ascending.
/// </summary>
[Fact]
public void SuccessiveDirtyRangesWithinAFrameNeverOverlap()
{
var state = new GlRingBufferState(64 * 1024);
int previousEnd = 0;
foreach (int size in new[] { 100, 4, 1024, 36, 7, 512, 3 })
{
state.Allocate(size, alignmentBytes: 256);
state.Allocate(size, alignmentBytes: 4);
(int start, int length) = state.TakeDirtyRange();
Assert.True(
start >= previousEnd,
$"flush [{start}, {start + length}) reaches below the already-flushed {previousEnd} bytes");
previousEnd = start + length;
Assert.Equal(previousEnd, state.FlushedEndBytes);
}
}
[Fact]
public void AWriteBelowTheFlushedHighWaterMarkIsRefused()
{
var state = new GlRingBufferState(1024);
state.Allocate(128, alignmentBytes: 4);
state.TakeDirtyRange();
Assert.Equal(128, state.FlushedEndBytes);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => state.MarkDirty(64, 96));
Assert.Contains("GL_MAP_UNSYNCHRONIZED_BIT", error.Message, StringComparison.Ordinal);
}
[Fact]
public void FlushedHighWaterMarkOnlyAdvancesWhenSomethingWasFlushed()
{
var state = new GlRingBufferState(1024);
state.Allocate(48, alignmentBytes: 4);
state.TakeDirtyRange();
Assert.Equal(48, state.FlushedEndBytes);
// A draw with nothing newly written must not move the mark, or the next
// allocation's guard would compare against a number no flush produced.
state.TakeDirtyRange();
Assert.Equal(48, state.FlushedEndBytes);
}
[Fact]
public void ResetClearsTheFlushedHighWaterMarkSoTheSlotIsWritableAgain()
{
var state = new GlRingBufferState(1024);
state.Allocate(256, alignmentBytes: 4);
state.TakeDirtyRange();
Assert.Equal(256, state.FlushedEndBytes);
// BeginFrame has waited on this slot's fence by the time Reset runs, so
// the whole slot is writable from zero again.
state.Reset();
Assert.Equal(0, state.FlushedEndBytes);
Assert.Equal(0u, state.Allocate(64, alignmentBytes: 4));
}
[Fact]
public void ResetRewindsTheCursorAndClearsDirtyState()
{
var state = new GlRingBufferState(1024);
state.Allocate(64, alignmentBytes: 4);
state.Reset();
Assert.Equal(0u, state.AllocatedBytes);
Assert.False(state.HasDirtyBytes);
Assert.Equal(0u, state.Allocate(4, alignmentBytes: 4));
}
[Fact]
public void OverCapacityRequestThrowsRatherThanTruncating()
{
var state = new GlRingBufferState(64);
Assert.Throws<InvalidOperationException>(() => state.Allocate(128, alignmentBytes: 4));
}
[Fact]
public void AlignmentPushingPastCapacityAlsoThrows()
{
var state = new GlRingBufferState(64);
state.Allocate(60, alignmentBytes: 4);
Assert.Throws<InvalidOperationException>(() => state.Allocate(8, alignmentBytes: 4));
}
[Theory]
[InlineData(0u, 256u, 0u)]
[InlineData(1u, 256u, 256u)]
[InlineData(255u, 256u, 256u)]
[InlineData(256u, 256u, 256u)]
[InlineData(10u, 1u, 10u)]
public void AlignUpMatchesStandardAlignmentArithmetic(uint value, uint alignment, uint expected)
{
Assert.Equal(expected, GlRingBufferState.AlignUp(value, alignment));
}
[Fact]
public void ZeroByteAllocationDoesNotMarkAnythingDirty()
{
var state = new GlRingBufferState(1024);
state.Allocate(0, alignmentBytes: 4);
Assert.False(state.HasDirtyBytes);
}
}

View file

@ -1,60 +0,0 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
public sealed class GlTextureSlotAllocatorTests
{
[Fact]
public void AllocationsBumpSequentiallyFromZero()
{
var allocator = new GlTextureSlotAllocator(capacity: 4);
Assert.Equal(0u, allocator.Allocate());
Assert.Equal(1u, allocator.Allocate());
Assert.Equal(2u, allocator.Allocate());
Assert.Equal(3, allocator.LiveCount);
}
[Fact]
public void ExhaustingCapacityThrows()
{
var allocator = new GlTextureSlotAllocator(capacity: 2);
allocator.Allocate();
allocator.Allocate();
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
}
[Fact]
public void ReleasedSlotsAreReusedBeforeBumpingFurther()
{
var allocator = new GlTextureSlotAllocator(capacity: 4);
uint first = allocator.Allocate();
allocator.Allocate();
allocator.Release(first);
Assert.Equal(1, allocator.LiveCount);
uint reused = allocator.Allocate();
Assert.Equal(first, reused);
Assert.Equal(2, allocator.LiveCount);
}
[Fact]
public void ReleasingAnUnallocatedSlotThrows()
{
var allocator = new GlTextureSlotAllocator(capacity: 4);
allocator.Allocate();
Assert.Throws<ArgumentOutOfRangeException>(() => allocator.Release(3));
}
[Fact]
public void FreeingThenExhaustingTheBumpRangeStillThrowsPastCapacity()
{
var allocator = new GlTextureSlotAllocator(capacity: 2);
uint first = allocator.Allocate();
allocator.Allocate();
allocator.Release(first);
allocator.Allocate();
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
}
}

View file

@ -2,7 +2,6 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
@ -192,40 +191,6 @@ public sealed class GpuResourceRetirementTransactionTests
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void GlQueue_PersistentNextPassRetryDoesNotStarveOrdinaryWork()
{
var device = new QueueOnlyGraphicsDevice();
int retryCalls = 0;
int ordinaryCalls = 0;
Action<GL>? retry = null;
retry = _ =>
{
retryCalls++;
device.QueueGLActionForNextPass(retry!);
};
device.QueueGLActionForNextPass(retry);
device.QueueGLAction(_ => ordinaryCalls++);
device.ProcessGLQueue();
Assert.Equal(1, retryCalls);
Assert.Equal(1, ordinaryCalls);
Assert.True(device.HasPendingGLWork);
}
[Fact]
public void GlQueue_ReportsPendingWorkUntilBothGenerationsDrain()
{
var device = new QueueOnlyGraphicsDevice();
device.QueueGLAction(_ => { });
Assert.True(device.HasPendingGLWork);
device.ProcessGLQueue();
Assert.False(device.HasPendingGLWork);
}
/// <summary>
/// Campaign V slice V4b: the mesh arena's staged store is an
/// <see cref="IGpuBuffer"/> rather than a raw GL name, so the abort ticket
@ -309,46 +274,6 @@ public sealed class GpuResourceRetirementTransactionTests
Assert.Equal(1, accountingCalls);
}
[Fact]
public void GlobalMeshVaoAccounting_CreateAndRetryableDeleteBalanceExactlyOnce()
{
int baseline = GpuMemoryTracker.VaoCount;
bool allocationOutstanding = true;
GlobalMeshVaoAccounting.TrackAllocation();
try
{
Assert.Equal(baseline + 1, GpuMemoryTracker.VaoCount);
var release = new RetryableGpuResourceRelease(
() => { },
() =>
{
GlobalMeshVaoAccounting.TrackDeallocation();
allocationOutstanding = false;
});
release.Run();
release.Run();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
finally
{
if (allocationOutstanding)
GlobalMeshVaoAccounting.TrackDeallocation();
}
}
[Fact]
public void GlobalMeshVaoAccounting_InitializationRollbackReturnsToBaseline()
{
int baseline = GpuMemoryTracker.VaoCount;
GlobalMeshVaoAccounting.TrackAllocation();
GlobalMeshVaoAccounting.TrackDeallocation();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
private sealed class FailBeforeAcceptQueue : IGpuResourceRetirementQueue
{
private bool _fail = true;
@ -378,11 +303,4 @@ public sealed class GpuResourceRetirementTransactionTests
}
}
private sealed class QueueOnlyGraphicsDevice : OpenGLGraphicsDevice
{
public QueueOnlyGraphicsDevice()
: base()
{
}
}
}

View file

@ -1,79 +0,0 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V6l: the portal depth mask is the one renderer whose two arms
/// do NOT share a shader source, and this is the tripwire that stops them
/// drifting.
///
/// <para>Every other RHI arm in the campaign compiles the same GLSL file the GL
/// arm does. This one cannot: its clip planes would have to travel in the
/// <c>TerrainClip</c> uniform block at binding 2, and on GL that binding is held
/// globally by <c>ClipFrame</c> for terrain — a portal draw that rebound it would
/// leave every later terrain draw in the frame reading the wrong region. So the
/// GL arm keeps its inline program, the RHI arm compiles
/// <c>Rendering/Shaders/portal_depth.vert</c>, and the numbers that decide where
/// depth lands are asserted to appear in both.</para>
///
/// <para>Deleted with the GL arm at V11, when the inline string goes.</para>
/// </summary>
public class PortalDepthShaderParityTests
{
private static string PortalDepthVertexSource() =>
File.ReadAllText(Path.Combine(
AppContext.BaseDirectory,
"Rendering",
"Shaders",
"portal_depth.vert"));
[Fact]
public void BothArmsPunchWithRetailsFarZConstant()
{
// 0.99999988 is retail's own value at the tail of
// D3DPolyRender::DrawPortalPolyInternal (0x0059bc90). It decides where
// the punched depth lands, so the two arms agreeing on it is the whole
// point of this file.
const string FarZ = "0.99999988";
Assert.Contains(FarZ, PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains(FarZ, PortalDepthVertexSource(), StringComparison.Ordinal);
}
[Fact]
public void BothArmsApplyTheSameEyeCappedMarkBias()
{
// #129's capped bias: min(bias, cap / max(w*w, 1e-6)), then z -= bias*w.
// Spelled with different identifier names on the two arms — the RHI arm
// reads the shared push block's uParamA/uParamB — so the assertion is on
// the SHAPE that decides the result rather than on the text.
string rhi = PortalDepthVertexSource();
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", rhi, StringComparison.Ordinal);
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", rhi, StringComparison.Ordinal);
}
[Fact]
public void BothArmsClipAgainstEightHalfPlanes()
{
// The plane budget is ClipFrame.MaxPlanes and GL's guaranteed
// GL_MAX_CLIP_DISTANCES; a shader that looped to a different number
// would clip a differently-shaped region than the one the CPU published.
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthVertexSource(), StringComparison.Ordinal);
}
[Fact]
public void TheRhiArmReadsTheClipPlanesFromTheSharedTerrainClipBlock()
{
string rhi = PortalDepthVertexSource();
Assert.Contains("uniform TerrainClip", rhi, StringComparison.Ordinal);
Assert.Contains("binding = 2", rhi, StringComparison.Ordinal);
// And the block is the one ClipFrame already packs for terrain and sky:
// an int count padded to 16 bytes, then eight vec4 planes.
Assert.Equal(144, ClipFrame.TerrainUboBytes);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}
}

View file

@ -13,17 +13,6 @@ 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
@ -40,19 +29,18 @@ public sealed class PortalTunnelAssetTests
}
/// <summary>
/// Campaign V slice V6m. A backend with no GL context draws portal space
/// Campaign V slice V6m (the raw-GL arm deleted at V11): portal space draws
/// 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()
public void PortalSpace_RequiresAPassScope()
{
var error = Assert.Throws<ArgumentNullException>(() =>
PortalTunnelPresentation.CreateRequired(
gl: null,
scope: null,
frames: null,
scope: null!,
frames: null!,
dats: null!,
animationLoader: null!,
hookSink: null!,
@ -61,7 +49,6 @@ public sealed class PortalTunnelAssetTests
meshAdapter: null!));
Assert.Equal("scope", error.ParamName);
Assert.Contains("world pass scope", error.Message);
}
[Fact]

View file

@ -1,106 +0,0 @@
using AcDream.App.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFrameGlStateControllerTests
{
[Fact]
public void RestoreFrameDefaults_ReestablishesTheCompleteFrameGlobalContract()
{
var api = new RecordingApi();
var state = new RenderFrameGlStateController(api);
state.RestoreFrameDefaults();
Assert.Equal(
[
"cap:ScissorTest:False",
"cap:StencilTest:False",
"cap:Blend:False",
"cap:SampleAlphaToMaskSgis:False",
"cap:ClipDistance0:False",
"cap:ClipDistance1:False",
"cap:ClipDistance2:False",
"cap:ClipDistance3:False",
"cap:ClipDistance4:False",
"cap:ClipDistance5:False",
"cap:ClipDistance6:False",
"cap:ClipDistance7:False",
"color-mask:True:True:True:True",
"stencil-mask:255",
"depth-mask:True",
"depth-func:Less",
"cap:DepthTest:True",
"cap:CullFace:False",
"cull:Back",
"front:CW",
"program:0",
"vao:0",
"buffer:ArrayBuffer:0",
],
api.Calls);
}
[Fact]
public void PortalDepthMask_SuccessExitRestoresTheSameCullOffConvention()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"PortalDepthMaskRenderer.cs"));
int restore = source.IndexOf(
"// ---- restore the frame-global convention ----",
StringComparison.Ordinal);
Assert.True(restore >= 0, "Missing portal-depth state restore block.");
string restoreBlock = source[restore..];
Assert.Contains("_gl.Disable(EnableCap.CullFace);", restoreBlock);
Assert.DoesNotContain("_gl.Enable(EnableCap.CullFace);", restoreBlock);
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
private sealed class RecordingApi : IRenderFrameGlStateApi
{
public List<string> Calls { get; } = [];
public void SetCapability(EnableCap capability, bool enabled) =>
Calls.Add($"cap:{capability}:{enabled}");
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
Calls.Add($"color-mask:{red}:{green}:{blue}:{alpha}");
public void StencilMask(uint mask) => Calls.Add($"stencil-mask:{mask}");
public void DepthMask(bool enabled) => Calls.Add($"depth-mask:{enabled}");
public void DepthFunc(DepthFunction function) =>
Calls.Add($"depth-func:{function}");
public void CullFace(TriangleFace face) => Calls.Add($"cull:{face}");
public void FrontFace(FrontFaceDirection direction) =>
Calls.Add($"front:{direction}");
public void UseProgram(uint program) => Calls.Add($"program:{program}");
public void BindVertexArray(uint vertexArray) => Calls.Add($"vao:{vertexArray}");
public void BindBuffer(BufferTargetARB target, uint buffer) =>
Calls.Add($"buffer:{target}:{buffer}");
}
}

View file

@ -58,8 +58,8 @@ public sealed class ResourceCleanupGroupTests
throw new InvalidOperationException("release failed");
});
GlResourceConstructionException failure =
Assert.Throws<GlResourceConstructionException>(() =>
ResourceConstructionException failure =
Assert.Throws<ResourceConstructionException>(() =>
resources.RollbackConstructionAndThrow(
"construction failed",
new InvalidOperationException("original failure")));
@ -74,62 +74,6 @@ public sealed class ResourceCleanupGroupTests
Assert.Equal(2, releases);
}
[Fact]
public void WorldRenderFoundationConstructorsPublishEveryOwnedPrefix()
{
string root = FindRepoRoot();
string graphicsDevice = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"OpenGLGraphicsDevice.cs"));
string shader = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"GLSLShader.cs"));
string uniformBuffer = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"ManagedGLUniformBuffer.cs"));
AssertAppearsInOrder(
graphicsDevice,
"var resources = new ResourceCleanupGroup();",
"InstanceVBO = CreateConstructionBuffer(resources",
"WrapSampler = CreateConstructionSampler(",
"ClampSampler = CreateConstructionSampler(",
"_sceneDataBuffer = new ManagedGLUniformBuffer(",
"resources.Add(",
"\"WB scene-data uniform buffer\"",
"InitializeSharedDebugResources(resources);",
"resources.TransferAll();",
"resources.RollbackConstructionAndThrow(");
AssertAppearsInOrder(
shader,
"ShaderProgramConstruction.Build(",
"resources.Add(\"WB shader program\"",
"configure shader {Name} SceneData binding",
"resources.TransferAll();",
"resources.RollbackConstructionAndThrow(");
AssertAppearsInOrder(
uniformBuffer,
"TrackedGlResource.CreateBuffer(",
"resources.Add(",
"TrackedGlResource.AllocateBufferStorage(",
"resources.TransferAll();",
"resources.RollbackConstructionAndThrow(");
Assert.Contains("internal void DisposeImmediately()", uniformBuffer,
StringComparison.Ordinal);
}
/// <summary>
/// Campaign V slice V4a moved TextRenderer's constructor off raw
/// VAO/VBO/texture GL names and onto two device-owned resources — a
@ -176,49 +120,6 @@ public sealed class ResourceCleanupGroupTests
return count;
}
[Fact]
public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail()
{
bool deleteFails = true;
int deleteCalls = 0;
GlResourceConstructionException failure =
Assert.Throws<GlResourceConstructionException>(() =>
GlResourceCommand.CreateNameCore(
"synthetic buffer",
static () => { },
() => 42,
() => throw new InvalidOperationException("postcondition failed"),
name =>
{
Assert.Equal(42u, name);
deleteCalls++;
if (deleteFails)
throw new InvalidOperationException("delete failed");
}));
Assert.False(failure.IsCleanupComplete);
Assert.Equal(1, deleteCalls);
deleteFails = false;
failure.RetryCleanup();
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal(2, deleteCalls);
}
private static void AssertAppearsInOrder(string source, params string[] values)
{
int cursor = -1;
foreach (string value in values)
{
int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal);
Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);

View file

@ -1,197 +0,0 @@
using AcDream.App.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class ShaderProgramConstructionTests
{
[Theory]
[InlineData("ShaderSource:VertexShader", 1, 0)]
[InlineData("CompileShader:VertexShader", 1, 0)]
[InlineData("CreateShader:FragmentShader", 1, 0)]
[InlineData("ShaderSource:FragmentShader", 2, 0)]
[InlineData("CompileShader:FragmentShader", 2, 0)]
[InlineData("CreateProgram", 2, 0)]
[InlineData("AttachShader:VertexShader", 2, 1)]
[InlineData("AttachShader:FragmentShader", 2, 1)]
[InlineData("LinkProgram", 2, 1)]
public void ThrowAfterEachAllocatedNameRollsBackEveryPublishedName(
string failingOperation,
int expectedShaderDeletes,
int expectedProgramDeletes)
{
var api = new FakeShaderApi { ThrowOn = failingOperation };
Assert.Throws<InvalidOperationException>(() =>
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
Assert.Equal(expectedShaderDeletes, api.DeletedShaders.Count);
Assert.Equal(expectedProgramDeletes, api.DeletedPrograms.Count);
Assert.Equal(api.CreatedShaders, api.DeletedShaders.Order().ToArray());
Assert.Equal(api.CreatedPrograms, api.DeletedPrograms.Order().ToArray());
}
[Fact]
public void CompileFailurePreservesOriginalFailureWhenRollbackIsClean()
{
var api = new FakeShaderApi { VertexCompiles = false };
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
Assert.Contains("VertexShader compile failed", failure.Message);
Assert.Equal([1u], api.DeletedShaders);
Assert.Empty(api.CreatedPrograms);
}
[Fact]
public void LinkFailureDetachesAndDeletesBothShadersAndProgram()
{
var api = new FakeShaderApi { ProgramLinks = false };
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
Assert.Contains("program link failed", failure.Message);
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
Assert.Equal([1u, 2u], api.DeletedShaders);
Assert.Equal([3u], api.DeletedPrograms);
}
[Fact]
public void SuccessfulBuildCommitsOnlyProgramAndReleasesTemporaryShaders()
{
var api = new FakeShaderApi();
uint program = ShaderProgramConstruction.Build(api, "vertex", "fragment");
Assert.Equal(3u, program);
Assert.Equal([1u, 2u], api.DeletedShaders);
Assert.Empty(api.DeletedPrograms);
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
}
[Fact]
public void CleanupFailureStillAttemptsEveryOtherReleaseAndProgramRollback()
{
var api = new FakeShaderApi { ThrowOn = "DetachShader:VertexShader" };
AggregateException failure = Assert.Throws<AggregateException>(() =>
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
Assert.Single(failure.InnerExceptions);
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
Assert.Equal([1u, 2u], api.DeletedShaders);
Assert.Equal([3u], api.DeletedPrograms);
}
[Fact]
public void ConstructionAndRollbackFailuresAreReportedTogether()
{
var api = new FakeShaderApi
{
ProgramLinks = false,
ThrowOn = "DeleteShader:VertexShader",
};
GlResourceConstructionException failure = Assert.Throws<GlResourceConstructionException>(() =>
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
Assert.Equal(2, failure.InnerExceptions.Count);
Assert.Contains("program link failed", failure.InnerExceptions[0].Message);
Assert.Equal([1u, 2u], api.DeleteShaderAttempts);
Assert.Equal([2u], api.DeletedShaders);
Assert.Equal([3u], api.DeletedPrograms);
api.ThrowOn = null;
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal([2u, 1u], api.DeletedShaders);
Assert.Equal([1u, 2u, 1u], api.DeleteShaderAttempts);
}
private sealed class FakeShaderApi : IShaderProgramBuildApi
{
private readonly Dictionary<uint, ShaderType> _shaderTypes = [];
private uint _nextName = 1;
public string? ThrowOn { get; set; }
public bool VertexCompiles { get; init; } = true;
public bool FragmentCompiles { get; init; } = true;
public bool ProgramLinks { get; init; } = true;
public List<uint> CreatedShaders { get; } = [];
public List<uint> CreatedPrograms { get; } = [];
public List<uint> DeleteShaderAttempts { get; } = [];
public List<uint> DeletedShaders { get; } = [];
public List<uint> DeletedPrograms { get; } = [];
public List<string> DetachCalls { get; } = [];
public uint CreateShader(ShaderType type)
{
Fail($"CreateShader:{type}");
uint name = _nextName++;
CreatedShaders.Add(name);
_shaderTypes.Add(name, type);
return name;
}
public void ShaderSource(uint shader, string source) =>
Fail($"ShaderSource:{TypeOf(shader)}");
public void CompileShader(uint shader) =>
Fail($"CompileShader:{TypeOf(shader)}");
public int GetShaderCompileStatus(uint shader) => TypeOf(shader) switch
{
ShaderType.VertexShader => VertexCompiles ? 1 : 0,
ShaderType.FragmentShader => FragmentCompiles ? 1 : 0,
_ => 0,
};
public string GetShaderInfoLog(uint shader) => $"bad {TypeOf(shader)}";
public uint CreateProgram()
{
Fail("CreateProgram");
uint name = _nextName++;
CreatedPrograms.Add(name);
return name;
}
public void AttachShader(uint program, uint shader) =>
Fail($"AttachShader:{TypeOf(shader)}");
public void LinkProgram(uint program) => Fail("LinkProgram");
public int GetProgramLinkStatus(uint program) => ProgramLinks ? 1 : 0;
public string GetProgramInfoLog(uint program) => "bad link";
public void DetachShader(uint program, uint shader)
{
string operation = $"DetachShader:{TypeOf(shader)}";
DetachCalls.Add(operation);
Fail(operation);
}
public void DeleteShader(uint shader)
{
DeleteShaderAttempts.Add(shader);
Fail($"DeleteShader:{TypeOf(shader)}");
DeletedShaders.Add(shader);
}
public void DeleteProgram(uint program)
{
Fail("DeleteProgram");
DeletedPrograms.Add(program);
}
private ShaderType TypeOf(uint shader) => _shaderTypes[shader];
private void Fail(string operation)
{
if (ThrowOn == operation)
throw new InvalidOperationException(operation + " failed");
}
}
}

View file

@ -1,337 +0,0 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The retained UI's whole draw is one RHI pass, and a failure anywhere inside
/// it must not leave GL state behind for the raw-GL world renderers that run in
/// the next frame. Until Campaign V slice V6d, TextRenderer owned a private
/// state scope for that; V6d made the renderer backend-neutral and moved the
/// guarantee onto <see cref="GlAmbientCapabilityState"/>, which every RHI pass
/// gets. These tests follow it there.
/// </summary>
public sealed class TextRendererFailureSafetyTests
{
[Fact]
public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers()
{
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
MethodBody body = flush.GetMethodBody()!;
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"TextRenderer.cs"));
// The encoder's `using` is the finally: disposing it closes the pass,
// which is what restores the ambient capability state the pass changed.
Assert.Contains(
body.ExceptionHandlingClauses,
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
AssertAppearsInOrder(
source,
"using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription",
"encoder.BindPipeline(_pipeline);",
"DrawLayer(_spriteSegs,",
"DrawLayer(_overlaySpriteSegs,");
// And no raw GL of its own is left: the renderer draws on both backends.
Assert.DoesNotContain("Silk.NET.OpenGL", source, StringComparison.Ordinal);
Assert.DoesNotContain("_gl.", source, StringComparison.Ordinal);
}
[Fact]
public void FailedDraw_RestoresEveryGlValueMutatedByThePass()
{
var gl = new RecordingGlState
{
DepthWrite = false,
DepthFuncValue = DepthFunction.Greater,
BlendSourceRgb = BlendingFactor.DstAlpha,
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
BlendSourceAlpha = BlendingFactor.One,
BlendDestinationAlpha = BlendingFactor.Zero,
CullFaceMode = TriangleFace.Front,
FrontFaceDirection = FrontFaceDirection.CW,
Program = 17,
VertexArray = 23,
ArrayBuffer = 31,
ActiveUnit = TextureUnit.Texture2,
};
gl.SetCapability(EnableCap.DepthTest, enabled: true);
gl.SetCapability(EnableCap.Blend, enabled: false);
gl.SetCapability(EnableCap.CullFace, enabled: true);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
// Enabled on entry — the world's MSAA. The UI pass turns it off and the
// restore has to put it back, which is the exact dimension the V4a
// revert lost and which nothing covered before this test.
gl.SetCapability(EnableCap.Multisample, enabled: true);
gl.TextureBindings[TextureUnit.Texture0] = 41;
gl.TextureBindings[TextureUnit.Texture2] = 43;
StateSnapshot expected = gl.Capture();
Action failedDraw = () =>
{
GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl);
try
{
gl.SetCapability(EnableCap.DepthTest, enabled: false);
gl.SetCapability(EnableCap.Blend, enabled: true);
gl.SetCapability(EnableCap.CullFace, enabled: false);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
gl.SetCapability(EnableCap.Multisample, enabled: false);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Lequal);
gl.BlendFuncSeparate(
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha,
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha);
gl.CullFace(TriangleFace.Back);
gl.FrontFace(FrontFaceDirection.Ccw);
gl.UseProgram(101);
gl.BindVertexArray(103);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, 109);
throw new InvalidOperationException("draw upload");
}
finally
{
ambient.Restore(gl);
}
};
Assert.Throws<InvalidOperationException>(failedDraw);
Assert.Equal(expected, gl.Capture());
}
private readonly record struct StateSnapshot(
bool DepthTest,
bool Blend,
bool Cull,
bool AlphaToCoverage,
bool Multisample,
bool DepthWrite,
DepthFunction DepthFunc,
BlendingFactor BlendSourceRgb,
BlendingFactor BlendDestinationRgb,
BlendingFactor BlendSourceAlpha,
BlendingFactor BlendDestinationAlpha,
TriangleFace CullFaceMode,
FrontFaceDirection FrontFaceDirection,
uint Program,
uint VertexArray,
uint ArrayBuffer,
TextureUnit ActiveUnit,
uint Texture0,
uint Texture2,
bool StencilTest,
StencilFunction StencilFunc,
int StencilReference,
uint StencilValueMask,
uint StencilWriteMask,
Silk.NET.OpenGL.StencilOp StencilFail,
Silk.NET.OpenGL.StencilOp StencilDepthFail,
Silk.NET.OpenGL.StencilOp StencilPass,
bool ColorMaskRed,
bool ColorMaskGreen,
bool ColorMaskBlue,
bool ColorMaskAlpha);
private sealed class RecordingGlState : IGlAmbientStateApi
{
private readonly Dictionary<EnableCap, bool> _capabilities = [];
public bool DepthWrite { get; set; }
public DepthFunction DepthFuncValue { get; set; }
public BlendingFactor BlendSourceRgb { get; set; }
public BlendingFactor BlendDestinationRgb { get; set; }
public BlendingFactor BlendSourceAlpha { get; set; }
public BlendingFactor BlendDestinationAlpha { get; set; }
public TriangleFace CullFaceMode { get; set; }
public FrontFaceDirection FrontFaceDirection { get; set; }
public uint Program { get; set; }
public uint VertexArray { get; set; }
public uint ArrayBuffer { get; set; }
public TextureUnit ActiveUnit { get; set; }
public Dictionary<TextureUnit, uint> TextureBindings { get; } = [];
// Slice V6l added the stencil and colour-mask dimensions to the ambient
// save/restore, for the portal depth mask's sake. GL's own defaults.
public StencilFunction StencilFuncValue { get; set; } = StencilFunction.Always;
public int StencilReference { get; set; }
public uint StencilValueMask { get; set; } = 0xFFFFFFFFu;
public uint StencilWriteMaskValue { get; set; } = 0xFFFFFFFFu;
public Silk.NET.OpenGL.StencilOp StencilFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public Silk.NET.OpenGL.StencilOp StencilDepthFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public Silk.NET.OpenGL.StencilOp StencilPassOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public bool[] ColorMaskValue { get; } = [true, true, true, true];
public StateSnapshot Capture() => new(
IsEnabled(EnableCap.DepthTest),
IsEnabled(EnableCap.Blend),
IsEnabled(EnableCap.CullFace),
IsEnabled(EnableCap.SampleAlphaToCoverage),
IsEnabled(EnableCap.Multisample),
DepthWrite,
DepthFuncValue,
BlendSourceRgb,
BlendDestinationRgb,
BlendSourceAlpha,
BlendDestinationAlpha,
CullFaceMode,
FrontFaceDirection,
Program,
VertexArray,
ArrayBuffer,
ActiveUnit,
TextureBindings.GetValueOrDefault(TextureUnit.Texture0),
TextureBindings.GetValueOrDefault(TextureUnit.Texture2),
IsEnabled(EnableCap.StencilTest),
StencilFuncValue,
StencilReference,
StencilValueMask,
StencilWriteMaskValue,
StencilFailOp,
StencilDepthFailOp,
StencilPassOp,
ColorMaskValue[0],
ColorMaskValue[1],
ColorMaskValue[2],
ColorMaskValue[3]);
public bool IsEnabled(EnableCap capability) =>
_capabilities.GetValueOrDefault(capability);
public int GetInteger(GetPName parameter) => parameter switch
{
GetPName.BlendSrcRgb => (int)BlendSourceRgb,
GetPName.BlendDstRgb => (int)BlendDestinationRgb,
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
GetPName.DepthFunc => (int)DepthFuncValue,
GetPName.CullFaceMode => (int)CullFaceMode,
GetPName.FrontFace => (int)FrontFaceDirection,
GetPName.CurrentProgram => (int)Program,
GetPName.VertexArrayBinding => (int)VertexArray,
GetPName.ArrayBufferBinding => (int)ArrayBuffer,
GetPName.ActiveTexture => (int)ActiveUnit,
GetPName.TextureBinding2D =>
(int)TextureBindings.GetValueOrDefault(ActiveUnit),
GetPName.StencilFunc => (int)StencilFuncValue,
GetPName.StencilRef => StencilReference,
GetPName.StencilValueMask => (int)StencilValueMask,
GetPName.StencilWritemask => (int)StencilWriteMaskValue,
GetPName.StencilFail => (int)StencilFailOp,
GetPName.StencilPassDepthFail => (int)StencilDepthFailOp,
GetPName.StencilPassDepthPass => (int)StencilPassOp,
_ => throw new ArgumentOutOfRangeException(nameof(parameter)),
};
public bool[] GetColorMask() => (bool[])ColorMaskValue.Clone();
public void StencilFunc(StencilFunction function, int reference, uint mask)
{
StencilFuncValue = function;
StencilReference = reference;
StencilValueMask = mask;
}
public void StencilOp(
Silk.NET.OpenGL.StencilOp fail,
Silk.NET.OpenGL.StencilOp depthFail,
Silk.NET.OpenGL.StencilOp pass)
{
StencilFailOp = fail;
StencilDepthFailOp = depthFail;
StencilPassOp = pass;
}
public void StencilMask(uint mask) => StencilWriteMaskValue = mask;
public void ColorMask(bool red, bool green, bool blue, bool alpha)
{
ColorMaskValue[0] = red;
ColorMaskValue[1] = green;
ColorMaskValue[2] = blue;
ColorMaskValue[3] = alpha;
}
public bool GetBoolean(GetPName parameter) =>
parameter == GetPName.DepthWritemask
? DepthWrite
: throw new ArgumentOutOfRangeException(nameof(parameter));
public void SetCapability(EnableCap capability, bool enabled) =>
_capabilities[capability] = enabled;
public void DepthMask(bool enabled) => DepthWrite = enabled;
public void DepthFunc(DepthFunction function) => DepthFuncValue = function;
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha)
{
BlendSourceRgb = sourceRgb;
BlendDestinationRgb = destinationRgb;
BlendSourceAlpha = sourceAlpha;
BlendDestinationAlpha = destinationAlpha;
}
public void CullFace(TriangleFace face) => CullFaceMode = face;
public void FrontFace(FrontFaceDirection direction) => FrontFaceDirection = direction;
public void UseProgram(uint program) => Program = program;
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;
public void BindBuffer(BufferTargetARB target, uint buffer)
{
Assert.Equal(BufferTargetARB.ArrayBuffer, target);
ArrayBuffer = buffer;
}
public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit;
public void BindTexture(TextureTarget target, uint texture)
{
Assert.Equal(TextureTarget.Texture2D, target);
TextureBindings[ActiveUnit] = texture;
}
}
private static void AssertAppearsInOrder(string source, params string[] needles)
{
int cursor = -1;
foreach (string needle in needles)
{
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -1,18 +1,78 @@
// Tests for EnvCellRenderer (Phase A8, 2026-05-28).
// These cover the pure data-handling portions of EnvCellRenderer.
// The GL-dependent Render() and RenderModernMDIInternal() paths require a
// GL context and are visual-verified at the render frame (Task 10).
// The draw-recording Render() and RenderModernMDIInternal() paths require a
// live GPU device and are visual-verified at the render frame (Task 10).
//
// Campaign V slice V11: the raw-GL constructor (which stored a possibly-null
// GL reference and did no other work) was deleted along with the GL arm. The
// sole remaining constructor builds three real pipelines against IGpuDevice,
// so these "no GL calls" tests now construct through the RHI arm with the
// same lightweight, no-hardware-required fakes the composition tests use:
// RecordingGpuDevice (records but does no driver work), GpuDeviceFrameLifetime
// (never began, so CurrentFrame stays null — fine, since these tests never
// draw), and VulkanWorldPassScope (needs only a sample count, no live surface).
// meshManager can no longer be null either — the RHI constructor throws on a
// null ObjectMeshManager, so tests that don't care about mesh-manager
// behavior get a real one built the same way MeshPipelineDeviceSeamTests
// does: VulkanMeshPipelineDevice (the production Vulkan seam implementation)
// over the same RecordingGpuDevice, plus a no-op IPreparedAssetSource.
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests
{
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private static ObjectMeshManager CreateMeshManager(RecordingGpuDevice device) =>
new(
new VulkanMeshPipelineDevice(device.Retirement),
device,
new NullPreparedAssetSource(),
NullLogger<ObjectMeshManager>.Instance);
private static EnvCellRenderer CreateRenderer(ObjectMeshManager? meshManager = null)
{
var device = new RecordingGpuDevice();
return new EnvCellRenderer(
device,
new GpuDeviceFrameLifetime(device),
new VulkanWorldPassScope(sampleCount: 1),
meshManager ?? CreateMeshManager(device),
new WbFrustum());
}
[Fact]
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
{
@ -57,14 +117,14 @@ public class EnvCellRendererTests
}
// -----------------------------------------------------------------------
// GetEnvCellGeomId verbatim port of WB EnvCellRenderManager.cs:94-103
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
// -----------------------------------------------------------------------
[Fact]
public void GetEnvCellGeomId_DedupBitSet()
{
var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 3 });
// Bit 33 (0x2_0000_0000) must be set distinguishes dedup geom from per-cell ids.
// Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids.
Assert.NotEqual(0UL, id & 0x2_0000_0000UL);
}
@ -102,40 +162,40 @@ public class EnvCellRendererTests
}
// -----------------------------------------------------------------------
// Constructor pure data, no GL
// Constructor — pure data, no GL
// -----------------------------------------------------------------------
[Fact]
public void NewRenderer_NeedsPrepareIsTrue()
{
// GL and meshManager are null only valid for pure-data tests (no
// GL and meshManager are null — only valid for pure-data tests (no
// Initialize() is called, so no GL calls are made).
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
Assert.True(r.NeedsPrepare);
}
[Fact]
public void NewRenderer_NotDisposed()
{
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
Assert.False(r.IsDisposed);
}
// -----------------------------------------------------------------------
// RemoveLandblock pure data path
// RemoveLandblock — pure data path
// -----------------------------------------------------------------------
[Fact]
public void RemoveLandblock_NonExistent_DoesNotThrow()
{
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
// Should silently no-op.
r.RemoveLandblock(0xA9B40000u);
Assert.True(r.NeedsPrepare);
}
// -----------------------------------------------------------------------
// GetEnvCellGeomId additional edge cases
// GetEnvCellGeomId — additional edge cases
// -----------------------------------------------------------------------
[Fact]
@ -157,7 +217,7 @@ public class EnvCellRendererTests
Assert.NotEqual(a, b);
}
// (Render() requires a GL context visual-verified in Task 10.)
// (Render() requires a GL context — visual-verified in Task 10.)
[Fact]
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
@ -212,13 +272,13 @@ public class EnvCellRendererTests
{
// The bug: WB's GetPooledList clears the list before returning so
// the merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
// populates fresh data. The original port omitted Clear() each
// populates fresh data. The original port omitted Clear() — each
// frame's lists grew unbounded with stale data layered on top.
//
// Reflection-based test that drives the private GetPooledList +
// _poolIndex/_listPool fields. If a future refactor removes the
// Clear() call, this test fails.
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
var type = typeof(EnvCellRenderer);
var getPooledListMethod = type.GetMethod("GetPooledList",
@ -228,16 +288,16 @@ public class EnvCellRendererTests
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(poolIndexField);
// First call — creates _listPool[0], _poolIndex 0 → 1.
// First call — creates _listPool[0], _poolIndex 0 → 1.
var first = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
first.Add(new InstanceData());
first.Add(new InstanceData());
Assert.Equal(2, first.Count);
// Reset cursor to 0 simulates the start of the next prepare cycle.
// Reset cursor to 0 — simulates the start of the next prepare cycle.
poolIndexField!.SetValue(r, 0);
// Second call returns _listPool[0] (same as first). With the fix
// Second call — returns _listPool[0] (same as first). With the fix
// it should be cleared. Without the fix the list still has 2 items.
var second = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
Assert.Same(first, second); // reuses the same instance
@ -249,7 +309,7 @@ public class EnvCellRendererTests
{
// Sanity check for the fresh-list branch. _poolIndex past _listPool.Count
// should produce a brand-new empty list and grow the pool.
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
var type = typeof(EnvCellRenderer);
var getPooledListMethod = type.GetMethod("GetPooledList",
@ -264,8 +324,8 @@ public class EnvCellRendererTests
}
// -----------------------------------------------------------------------
// Prepare gate (2026-07-24) pure camera-tolerance half.
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
// Prepare gate (2026-07-24) — pure camera-tolerance half.
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
// R-A2 note) but never survive real camera motion.
// -----------------------------------------------------------------------
@ -288,7 +348,7 @@ public class EnvCellRendererTests
[Fact]
public void CameraApproximatelyEqual_RestJitter_True()
{
// The ~36 µm eye rest jitter must NOT dirty the gate.
// The ~36 µm eye rest jitter must NOT dirty the gate.
var eye = new Vector3(120.34f, 87.91f, 10.2f);
var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f);
var a = ViewProjectionFor(eye, Vector3.UnitX);
@ -299,7 +359,7 @@ public class EnvCellRendererTests
[Fact]
public void CameraApproximatelyEqual_SmallRealRotation_False()
{
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
var eye = new Vector3(120.34f, 87.91f, 10.2f);
float yaw = 0.05f * MathF.PI / 180f;
var a = ViewProjectionFor(eye, Vector3.UnitX);
@ -322,7 +382,7 @@ public class EnvCellRendererTests
public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties()
{
// AC world coordinates reach ~5e4. A relative tolerance applied to the
// matrix translation row would mask sub-meter motion at that scale
// matrix translation row would mask sub-meter motion at that scale —
// the eye epsilon is absolute precisely so this case stays sharp.
var eye = new Vector3(40120.34f, 45087.91f, 110.2f);
var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame
@ -338,7 +398,7 @@ public class EnvCellRendererTests
[Fact]
public void NewRenderer_SnapshotGenerationStartsAtZero()
{
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
var r = CreateRenderer();
Assert.Equal(0, r.SnapshotGeneration);
}
}

View file

@ -100,23 +100,6 @@ public sealed class MeshPipelineDeviceSeamTests
Assert.False(manager.IsDisposed);
}
/// <summary>
/// The GL handle table is the emulation the Vulkan backend replaces with set
/// 2, so asking a non-GL pipeline for it is a programming error — and it says
/// which backend it was composed against rather than reporting a cast.
/// </summary>
[Fact]
public void TheGlHandleTableIsRefusedByNameRatherThanCast()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device);
InvalidOperationException failure =
Assert.Throws<InvalidOperationException>(() => manager.WorldTextureTable);
Assert.Contains("GL-only", failure.Message, StringComparison.Ordinal);
Assert.Contains(GpuBackendKind.Recording.ToString(), failure.Message, StringComparison.Ordinal);
}
/// <summary>
/// The one branch the texture stack keeps: a GL pair yields the GL arm, and
/// anything else yields the RHI arm. Selection happens once, at construction.
@ -203,9 +186,6 @@ public sealed class MeshPipelineDeviceSeamTests
using ObjectMeshManager manager = Build(device, modernPath: true);
GlobalMeshBuffer arena = Assert.IsType<GlobalMeshBuffer>(manager.GlobalBuffer);
Assert.Equal(0u, arena.VAO);
Assert.Equal(0u, arena.VBO);
Assert.Equal(0u, arena.IBO);
Assert.True(arena.HasStores);
Assert.NotNull(arena.VertexStore);
Assert.NotNull(arena.IndexStore);

View file

@ -32,7 +32,7 @@ public sealed class TextureAtlasCapacityTests
[Fact]
public void DirectUploadAcceptsExactRgbaPayload()
{
ManagedGLTextureArray.ValidateUploadPayload(
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.UnsignedByte);
}
@ -42,7 +42,7 @@ public sealed class TextureAtlasCapacityTests
public void DirectUploadRejectsNonExactPayloadLength(int bytes)
{
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
}
@ -50,27 +50,27 @@ public sealed class TextureAtlasCapacityTests
public void DirectUploadRejectsMismatchedTransferTuple()
{
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgb, PixelType.UnsignedByte));
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.Float));
}
[Fact]
public void CompressedUploadRejectsUncompressedDescriptorOverride()
{
int bytes = ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
int bytes = RhiWorldTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.DXT1, 4, 4, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
}
[Fact]
public void RgbPayloadUsesTightlyPackedRows()
{
Assert.Equal(18, ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
ManagedGLTextureArray.ValidateUploadPayload(
Assert.Equal(18, RhiWorldTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
RhiWorldTextureArray.ValidateUploadPayload(
TextureFormat.RGB8, 3, 2, 18, PixelFormat.Rgb, PixelType.UnsignedByte);
}
}

View file

@ -475,75 +475,6 @@ public sealed class RuntimeOptionsTests
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
}
/// <summary>
/// Campaign V slice V10 flipped the default: Vulkan is the shipping backend
/// and an unset variable must select it. Awaiting the user's cutover
/// sign-off; the one-line rollback restores GL here and in
/// <c>RuntimeOptions.ParseRenderBackend</c> together.
/// </summary>
[Fact]
public void RenderBackend_DefaultsToVulkan()
{
Assert.Equal(
RenderBackendKind.Vulkan,
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
}
[Theory]
[InlineData("vulkan")]
[InlineData("Vulkan")]
[InlineData("VULKAN")]
public void RenderBackend_SelectsVulkanCaseInsensitively(string value)
{
Assert.Equal(
RenderBackendKind.Vulkan,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
/// <summary>
/// The V10 escape hatch, and the whole of it. Both spellings are honoured
/// because a near-miss here strands the operator on the backend they asked
/// to leave.
/// </summary>
[Theory]
[InlineData("gl")]
[InlineData("GL")]
[InlineData("Gl")]
[InlineData("opengl")]
[InlineData("OpenGL")]
public void RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens(string value)
{
Assert.Equal(
RenderBackendKind.Gl,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
/// <summary>
/// The mirror image of the pre-V10 rule. A typo used to have to land on GL
/// because Vulkan was dark; it now has to land on Vulkan because GL is the
/// backend slice V11 deletes, and a misspelled fallback that silently works
/// is how a process ends up pinned to it.
/// </summary>
[Theory]
[InlineData("")]
[InlineData("vulcan")]
[InlineData("vk")]
[InlineData(" vulkan")]
[InlineData(" gl")]
[InlineData("ogl")]
public void RenderBackend_AnythingElseStaysOnVulkan(string value)
{
Assert.Equal(
RenderBackendKind.Vulkan,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
[Fact]
public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
{

View file

@ -1,32 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using DatReaderWriter;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
/// <summary>
/// Lightweight unit tests for <see cref="TextureCache"/>'s bindless path.
/// We can't construct a real TextureCache in a headless test (it requires a
/// live GL context), so this file documents contracts that future engineers
/// should preserve. Real bindless integration is verified at Task 14's
/// visual gate.
/// </summary>
public sealed class TextureCacheBindlessTests
{
[Fact]
public void Contract_BindlessMethodsThrowWithoutBindlessSupport()
{
// The actual throw lives in TextureCache.EnsureBindlessAvailable
// and is reached only via GL-bound Bindless* method calls. The
// contract is: if the dispatcher (which requires bindless) ever
// gets a TextureCache constructed without BindlessSupport, it
// should fail-fast with InvalidOperationException — NOT silently
// route a draw to handle 0 (which would produce a non-resident
// GPU fault).
//
// This test is a marker. Future engineers: do not weaken
// EnsureBindlessAvailable to swallow the missing dependency.
Assert.True(true, "Contract documented in TextureCache.EnsureBindlessAvailable");
}
}