acdream/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
Erik ad5f8b68dc fix(render): Campaign V slice V7 commit 1 - world anisotropy, and the sky's second clock
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at
Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same
stop on the same instrument now measures 9.05%, and the two populations these
address are gone from the difference map rather than merely smaller.

1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE
DEVICE MAXIMUM ON GL.

RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and
the only IWorldTextureArray the Vulkan arm ever constructs -- registered its
clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as
written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own
GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets
GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident
bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler)
set it again, which is the one that actually wins.

V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that
draws through these arrays is the next slice, and it is the one that can gate a
filtering change visually." That slice was V6j, the gate is V7, and this is it.

Retail settles the question rather than the GL arm settling it.
RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler
stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at
0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's
reported cap, not a setting -- so "as much anisotropy as this device has" is
retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from
retail as well as from the shipping backend. No divergence-register row is owed
in either direction: this retires a Vulkan-only gap and lands on retail's value.

The fix asks for a ceiling rather than reading a limit back, because the pinned
RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It
does not need one: VulkanGpuSampler already clamps MaxAnisotropy to
VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at
least 16 wherever the samplerAnisotropy feature is supported -- which this
backend requires -- and 16 is where every desktop driver caps. The request and
the GL arm's read therefore land on the same number.

What it was worth, from the difference map at the same stop: the roof shingles
of both Holtburg cottages, which had been dense hatching across the whole
surface, and the stone courses of the near building are now black. Measured as
high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the
right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to
1.023; every other textured region in the frame is between 0.99 and 1.02.
Grazing-angle surfaces are where anisotropy is the whole difference, which is
why a roof was the loudest thing in the frame.

2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE.

ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth
date, which chooses the day group, the keyframe and the sun angle. The cloud
sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against
DateTime.UtcNow minus its own construction time, by design, because retail's
clouds drift with real time regardless of the date. Two launches minutes apart
therefore cannot agree about where the clouds are no matter what the route does,
and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows.

ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds ->
SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value
with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall
clock, so nothing a user or the offline gate sees changes. The differential gate
forces it on both launches alongside MSAA and the day group; the offline gate
keeps its top-280 mask, because a same-commit GL pair still has the sun to
disagree about.

This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a
workaround: it is one input to a UV offset, it is off by default, and no shipping
path reads it. The alternative on the table was -MaskTopPixels, which would have
permanently blinded the campaign's strictest instrument to the entire sky -- one
of the five surfaces the offline gate already cannot see. Rows 0-32 of the
Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up
there is roof and portal rather than cloud.

WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the
distant treeline, the player and the NPCs, and the animated portal. The portal is
phase and expected. The treeline is not filtering -- sharpness now matches within
5% and a shift search finds no sub-pixel offset -- and the two runs entered the
world at different last-logout positions (0xC95B0001 versus 0x09040008), so the
far-tier streaming history differed. That is the next thing to prove or refute.

Gates. Release build green. App tests 4,133 passed / 3 skipped against the
4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against
the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31
band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings. Full
three-stop differential recorded at artifacts/v7-diff-c1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:14:08 +02:00

526 lines
19 KiB
C#

using System.Collections.Generic;
using AcDream.App;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
namespace AcDream.App.Tests;
/// <summary>
/// Unit tests for <see cref="RuntimeOptions"/> startup-time parsing.
/// Behavior-preservation only: every assertion locks in the exact
/// boolean / numeric / nullability semantics from the env reads that
/// previously lived in <c>GameWindow.cs</c>.
/// </summary>
public sealed class RuntimeOptionsTests
{
[Fact]
public void ResidencyBudgetsPreserveCurrentProductionDefaults()
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
_ => null);
Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets);
Assert.Equal(
StreamingWorkBudgetOptions.Default,
options.StreamingWorkBudgets);
}
[Fact]
public void StreamingWorkBudgetOverridesAreOneTypedProfile()
{
var values = new Dictionary<string, string?>
{
["ACDREAM_STREAM_WORK_MS"] = "1.75",
["ACDREAM_STREAM_WORK_COMPLETIONS"] = "31",
["ACDREAM_STREAM_WORK_CPU_MIB"] = "6",
["ACDREAM_STREAM_WORK_ENTITY_OPS"] = "144",
["ACDREAM_STREAM_WORK_GPU_MIB"] = "5",
["ACDREAM_STREAM_WORK_GL_RETIRE_OPS"] = "23",
["ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"] = "60",
};
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => values.GetValueOrDefault(name));
Assert.Equal(1.75, options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(31, options.StreamingWorkBudgets.MaxCompletionAdmissions);
Assert.Equal(
6 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxAdoptedCpuBytes);
Assert.Equal(144, options.StreamingWorkBudgets.MaxEntityOperations);
Assert.Equal(
5 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxGpuUploadBytes);
Assert.Equal(23, options.StreamingWorkBudgets.MaxGlRetireOperations);
Assert.Equal(0.60f, options.StreamingWorkBudgets.DestinationReserveFraction);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("NaN")]
[InlineData("Infinity")]
[InlineData("bad")]
public void InvalidStreamingWorkValuesFallBackIndependently(string value)
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => name switch
{
"ACDREAM_STREAM_WORK_MS" => value,
"ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT" => value,
_ => null,
});
Assert.Equal(
StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds,
options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(
StreamingWorkBudgetOptions.Default.DestinationReserveFraction,
options.StreamingWorkBudgets.DestinationReserveFraction);
}
[Fact]
public void ResidencyBudgetOverridesAreTypedMebibytesAndCounts()
{
var values = new Dictionary<string, string?>
{
["ACDREAM_RESIDENCY_MESH_GPU_MIB"] = "768",
["ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"] = "72",
["ACDREAM_RESIDENCY_ANIMATION_MIB"] = "48",
["ACDREAM_RESIDENCY_ANIMATION_ENTRIES"] = "300",
["ACDREAM_RESIDENCY_AUDIO_MIB"] = "24",
};
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => values.GetValueOrDefault(name));
Assert.Equal(
768 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.ObjectMeshGpuBytes);
Assert.Equal(
72,
options.ResidencyBudgets.ObjectMeshUnownedEntries);
Assert.Equal(
48 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AnimationBytes);
Assert.Equal(300, options.ResidencyBudgets.AnimationEntries);
Assert.Equal(
24 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AudioBytes);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("bad")]
[InlineData("8796093022208")]
public void InvalidResidencyBudgetFallsBackToCurrentDefault(string value)
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => name == "ACDREAM_RESIDENCY_MESH_GPU_MIB"
? value
: null);
Assert.Equal(
ResidencyBudgetOptions.Default.ObjectMeshGpuBytes,
options.ResidencyBudgets.ObjectMeshGpuBytes);
}
private const string AnyDatDir = "C:/Users/test/dats";
private static Func<string, string?> Env(Dictionary<string, string?> values)
=> name => values.TryGetValue(name, out var v) ? v : null;
private static Func<string, string?> EmptyEnv() => _ => null;
[Fact]
public void Defaults_AllSafeOff_WhenEnvironmentIsEmpty()
{
var opts = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Equal(AnyDatDir, opts.DatDir);
Assert.Equal(
Path.Combine(AnyDatDir, "acdream.pak"),
opts.PreparedAssetPath);
Assert.False(opts.LiveMode);
Assert.Equal("127.0.0.1", opts.LiveHost);
Assert.Equal(9000, opts.LivePort);
Assert.Null(opts.LiveUser);
Assert.Null(opts.LivePass);
Assert.False(opts.DevTools);
Assert.False(opts.UncappedRendering);
Assert.False(opts.DumpMoveTruth);
Assert.False(opts.NoAudio);
Assert.False(opts.EnableSkyPesDebug);
Assert.Equal(-1, opts.HidePartIndex);
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
Assert.True(opts.RetailCloseDegrades);
Assert.False(opts.DumpSceneryZ);
Assert.False(opts.DumpClothing);
Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript);
Assert.Null(opts.AutomationArtifactDirectory);
Assert.Equal(0.7f, opts.FogStartMultiplier);
Assert.Equal(0.95f, opts.FogEndMultiplier);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
}
[Fact]
public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
{
Assert.Equal(
Path.Combine(AnyDatDir, "acdream.pak"),
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).PreparedAssetPath);
var overridden = RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_PAK_PATH"] = "D:/prepared/acdream.pak" }));
Assert.Equal("D:/prepared/acdream.pak", overridden.PreparedAssetPath);
}
[Fact]
public void LiveMode_Set_ExactlyByValue1()
{
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "1" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "0" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "true" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "" })).LiveMode);
}
[Fact]
public void LiveHostAndPort_FallBackToDefaults_WhenUnsetOrInvalid()
{
var withDefaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Equal("127.0.0.1", withDefaults.LiveHost);
Assert.Equal(9000, withDefaults.LivePort);
var withOverrides = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_HOST"] = "play.example.com",
["ACDREAM_TEST_PORT"] = "9123",
}));
Assert.Equal("play.example.com", withOverrides.LiveHost);
Assert.Equal(9123, withOverrides.LivePort);
// Non-numeric port falls back to default; we don't throw at parse time.
var withBadPort = RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_TEST_PORT"] = "abc" }));
Assert.Equal(9000, withBadPort.LivePort);
}
[Fact]
public void LiveUserPass_NullWhenEmptyOrUnset()
{
var emptyValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "",
["ACDREAM_TEST_PASS"] = "",
}));
Assert.Null(emptyValues.LiveUser);
Assert.Null(emptyValues.LivePass);
var realValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "testaccount",
["ACDREAM_TEST_PASS"] = "testpassword",
}));
Assert.Equal("testaccount", realValues.LiveUser);
Assert.Equal("testpassword", realValues.LivePass);
}
[Fact]
public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass()
{
// Live mode off → no credentials regardless of user/pass.
var noLive = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "u",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.False(noLive.HasLiveCredentials);
// Live mode on but missing user → no credentials.
var missingUser = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.False(missingUser.HasLiveCredentials);
// Live mode on but missing pass → no credentials.
var missingPass = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "u",
}));
Assert.False(missingPass.HasLiveCredentials);
// All three present → credentials available.
var ok = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "u",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.True(ok.HasLiveCredentials);
}
[Fact]
public void HidePartIndex_MinusOneWhenUnset_ParsesIntegers()
{
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).HidePartIndex);
Assert.Equal(7, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "7" })).HidePartIndex);
// Invalid → fall back to -1 (preserves the int.TryParse failure semantics).
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "abc" })).HidePartIndex);
}
[Fact]
public void RetailCloseDegrades_DefaultOn_ExceptWhenValueIsExactlyZero()
{
// Unset → on.
Assert.True(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RetailCloseDegrades);
// Exactly "0" → off. Matches the pre-refactor semantics:
// !string.Equals(env, "0", StringComparison.Ordinal)
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "0",
})).RetailCloseDegrades);
// Any other value → on (including "1", "false", "True"). The original
// code only checked for the literal "0"; preserve that.
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "1",
})).RetailCloseDegrades);
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "false",
})).RetailCloseDegrades);
}
[Fact]
public void LegacyStreamRadius_NullWhenUnsetOrInvalid_ParsesNonNegativeIntegers()
{
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).LegacyStreamRadius);
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "abc" })).LegacyStreamRadius);
// Negative values are filtered out by the pre-refactor `sr >= 0` guard.
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "-3" })).LegacyStreamRadius);
Assert.Equal(0, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "0" })).LegacyStreamRadius);
Assert.Equal(5, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "5" })).LegacyStreamRadius);
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
}
[Fact]
public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently()
{
var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "0.625",
["ACDREAM_FOG_END_MULT"] = "1.125",
}));
Assert.Equal(0.625f, parsed.FogStartMultiplier);
Assert.Equal(1.125f, parsed.FogEndMultiplier);
var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "not-a-number",
["ACDREAM_FOG_END_MULT"] = "",
}));
Assert.Equal(0.7f, invalid.FogStartMultiplier);
Assert.Equal(0.95f, invalid.FogEndMultiplier);
}
[Fact]
public void DayGroupOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
EmptyEnv()).ForcedDayGroupIndex);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "-1" }))
.ForcedDayGroupIndex);
Assert.Equal(
7,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "7" }))
.ForcedDayGroupIndex);
}
/// <summary>
/// Campaign V slice V7. The sky has two clocks and this pins the one
/// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which
/// accumulates against real elapsed time. Unset must stay unset: every
/// ordinary run keeps the wall clock, and only the backend differential gate
/// asks for a pin.
/// </summary>
[Fact]
public void SkyPhaseOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).SkyAnimationPhaseSeconds);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "not-a-number" }))
.SkyAnimationPhaseSeconds);
// Zero is a real request, not "unset": it is the cloud sheet's authored
// origin and the value the gate script pins by default.
Assert.Equal(
0f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "0" }))
.SkyAnimationPhaseSeconds);
Assert.Equal(
12.5f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "12.5" }))
.SkyAnimationPhaseSeconds);
}
[Fact]
public void DiagnosticFlags_RespectExactValueOne()
{
var allOn = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "1",
["ACDREAM_UNCAPPED_RENDER"] = "1",
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
["ACDREAM_DUMP_SKY"] = "1",
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
["ACDREAM_DUMP_CLOTHING"] = "1",
}));
Assert.True(allOn.DevTools);
Assert.True(allOn.UncappedRendering);
Assert.True(allOn.DumpMoveTruth);
Assert.True(allOn.DumpSky);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
Assert.True(allOn.DumpSceneryZ);
Assert.True(allOn.DumpClothing);
// Any non-"1" value leaves them off, matching the
// string.Equals(env, "1", StringComparison.Ordinal) check.
var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "true",
["ACDREAM_UNCAPPED_RENDER"] = "true",
["ACDREAM_DUMP_MOVE_TRUTH"] = "yes",
["ACDREAM_NO_AUDIO"] = "2",
["ACDREAM_ENABLE_SKY_PES"] = "on",
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
["ACDREAM_DUMP_CLOTHING"] = "true",
}));
Assert.False(anyOther.DevTools);
Assert.False(anyOther.UncappedRendering);
Assert.False(anyOther.DumpMoveTruth);
Assert.False(anyOther.NoAudio);
Assert.False(anyOther.EnableSkyPesDebug);
Assert.False(anyOther.DumpSceneryZ);
Assert.False(anyOther.DumpClothing);
}
[Fact]
public void Parse_RejectsNullDatDirOrEnv()
{
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(null!, EmptyEnv()));
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
}
/// <summary>
/// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so
/// the default must be GL and a typo must never silently start the dark
/// Vulkan host.
/// </summary>
[Fact]
public void RenderBackend_DefaultsToGl()
{
Assert.Equal(
RenderBackendKind.Gl,
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);
}
[Theory]
[InlineData("gl")]
[InlineData("opengl")]
[InlineData("")]
[InlineData("vulcan")]
[InlineData("vk")]
[InlineData(" vulkan")]
public void RenderBackend_AnythingElseStaysOnGl(string value)
{
Assert.Equal(
RenderBackendKind.Gl,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
[Fact]
public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
{
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanDeviceOverride);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = "" })).VulkanDeviceOverride);
}
/// <summary>
/// The override is carried verbatim — index or name substring — because the
/// capability report records exactly what the operator asked for, matched or
/// not.
/// </summary>
[Theory]
[InlineData("1")]
[InlineData("Radeon")]
public void VulkanDeviceOverride_IsCarriedVerbatim(string value)
{
Assert.Equal(
value,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = value })).VulkanDeviceOverride);
}
[Fact]
public void VulkanForcedUnsupportedFeature_DrivesTheExitFourGateKnob()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanForcedUnsupportedFeature);
Assert.Equal(
"timelineSemaphore",
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
.VulkanForcedUnsupportedFeature);
}
}