acdream/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
Erik 122fe8a7e2 feat(render): Campaign V slice V10 — Vulkan becomes the default backend
THIS CUTOVER AWAITS THE USER'S VISUAL SIGN-OFF. It is not complete. Section 7
of the campaign plan names the V10 sign-off as the only required user stop
besides gate failures, and it has not been given. This commit flips the default
and runs the battery so that the sign-off has evidence in front of it.

ROLLBACK, one line: `git revert` of this commit. It restores the GL default,
the pre-V10 escape-hatch polarity and the gate scripts' inherited backend
together; nothing else has to move with it.

An unset, empty or unrecognised ACDREAM_RENDER_BACKEND now yields
RenderBackendKind.Vulkan. Only `gl` or `opengl`, case-insensitive, selects
OpenGL. The polarity of the typo case flipped with the default and on purpose:
before V10 an unrecognised token had to land on GL because Vulkan was dark and a
typo must never silently start a backend that cannot draw; after V10 it has to
land on Vulkan for the same reason read the other way, because GL is the backend
V11 deletes. `opengl` is honoured beside `gl` because an escape hatch exists to
be found.

Three gate scripts follow the flip. run-offline-pixel-gate.ps1 gains -Backend
(default vulkan) and now FORCES all four determinism levers — backend, day
group, world day fraction, sky phase — plus ACDREAM_MSAA_SAMPLES=0, instead of
inheriting any of them. run-repeat-connected-gate.ps1 and
run-connected-world-lifecycle-gate.ps1 CLEAR ACDREAM_RENDER_BACKEND rather than
setting it, so what they exercise is the process default and an ambient override
in a caller's shell cannot make a GL run wear the default's report.

TEST PIN UPDATED, flagged as required: RenderBackend_DefaultsToGl becomes
RenderBackend_DefaultsToVulkan, and RenderBackend_AnythingElseStaysOnGl splits
into RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens and
RenderBackend_AnythingElseStaysOnVulkan. Five cases replace two. No other test
is touched, weakened or deleted.

AD-46's divergence-register row moves from "dormant until the V10 cutover" to
live, in this commit, per the same-commit register rule.

Battery, all on the new default:

  complete Release suite    9,222 passed / 5 skipped / 0 failed (9 projects)
                            +5 against the pre-flip 9,217; the +5 are this
                            slice's own escape-hatch cases
  #250 family, singly       4/4 pass (none failed in the whole-suite run)
  repeat connected gate     PASS 3/3 on both columns
  world-lifecycle route     PASS, 0 failures, both sessions graceful at exit 0
  validation layer          inserted at instance AND device level by the loader,
                            zero errors and zero warnings, real frame captured
  GL escape hatch           verified by two offline launches: 4.3.0 Core Profile
                            Context, bindless present, exit 0

Every connected launch in the battery reached Vulkan with no environment
variable set, which is the flip itself under test rather than an assertion
about it.

THE PIXEL GATE IS NOT MET, AND WAS NOT RELAXED. Vulkan against a GL-era capture
taken at this commit through the escape hatch, MSAA off and both clocks pinned:
1.099e-03 masked / 3.764e-02 whole-frame, against a 0.001 threshold. 97.9% of
the difference is in the treeline band, and the masked residual of 619 px — set
against a same-backend control of 10 px — sits entirely on the silhouettes of
distant alpha-blended scenery. That is AD-46's registered population; section
5.5.19 measured the same quantity at 497 px / 8.8e-04. Below the band the two
backends are photometrically identical: mean luminance differs by 0.01 of 255.
No baseline was regenerated and no mask or tolerance was widened.

Two instrument findings are recorded in section 5.5.23. The offline gate's sky
mask is still load-bearing — this slice tried retiring it on the reasoning that
V7's clock pins had made it obsolete, and the control refuted that: two launches
of the same binary still differ by 1,011 px on GL and 482 px on Vulkan, almost
all of it in the band. The default went back to 280 with the measurement written
into the script's help. And the repeat gate's desktop witness needs an
uncontested primary monitor: a first attempt reported 1/3, and the two failing
grabs turn out to be a web browser and Discord composited over the client rect,
not a blank frame — the client's Vulkan capture rendered in all six runs.

Nothing GL, ImGui or Studio is deleted. That is V11's scope and it is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:32:25 +02:00

636 lines
23 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. Pins the Dereth day fraction -- the sun, the
/// keyframe, and every lit surface. Range-checked rather than merely parsed:
/// a day fraction outside [0, 1) is not a clamp candidate, it is a typo, and
/// silently pinning the world at 12.5 would be worse than ignoring it.
/// </summary>
[Fact]
public void WorldTimeOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).PinnedWorldDayFraction);
Assert.Equal(
0.5f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = "0.5" }))
.PinnedWorldDayFraction);
Assert.Equal(
0f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = "0" }))
.PinnedWorldDayFraction);
foreach (string rejected in new[] { "1", "1.5", "-0.1", "midnight", "" })
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = rejected }))
.PinnedWorldDayFraction);
}
}
/// <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 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()
{
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);
}
/// <summary>
/// Campaign V slice V9. The frame budget is what lets the probe harness run
/// unattended in CI. Zero is the interactive default, so every invocation
/// that predates the slice keeps presenting until its window closes.
/// </summary>
[Fact]
public void VulkanCapabilityProbeFrames_DefaultsToUnbounded()
{
Assert.Equal(
0,
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanCapabilityProbeFrames);
}
[Theory]
[InlineData("1", 1)]
[InlineData("30", 30)]
[InlineData("0", 0)]
public void VulkanCapabilityProbeFrames_ParsesANonNegativeBudget(
string value,
int expected)
{
Assert.Equal(
expected,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
.VulkanCapabilityProbeFrames);
}
/// <summary>
/// A malformed budget falls back to the interactive default rather than to
/// some invented number: a CI step that meant to bound the run and mistyped
/// it should hang and be noticed, not silently capture at a frame count
/// nobody asked for.
/// </summary>
[Theory]
[InlineData("")]
[InlineData("-1")]
[InlineData("many")]
[InlineData("30.5")]
public void VulkanCapabilityProbeFrames_RejectsMalformedValues(string value)
{
Assert.Equal(
0,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
.VulkanCapabilityProbeFrames);
}
}