THE ROUTE'S TIME PIN NEVER HELD, AND EVERY V7 NUMBER SO FAR WAS TAKEN THROUGH IT.
connected-backend-differential.route.txt opened by pressing
AcdreamCycleTimeOfDay three times, on the stated theory that the cycle walks
live -> 0.00 -> 0.25 -> 0.50 and lands on noon. The mechanism underneath is
WorldTimeService.SetDebugTime, and SyncFromServer clears it -- deliberately,
because that setter is the /time slash command and the command is meant to be a
look-at-dusk-for-a-moment affordance rather than a mode. There is even a test
pinning that behaviour: WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride.
ACE sends TimeSync every few seconds. The clock was therefore un-pinned again
long before the route reached its first stop, on every run this campaign has
taken, including V6m's smoke pair.
The Dereth clock does not only move the sky. It moves the SUN, so it moves the
directional term of every lit surface in the scene.
MEASURED, rather than argued. A probe route captured each stop TWICE, 45 seconds
apart, in the same run on the same backend:
GL, Holtburg, capture 1 vs capture 2: 205,772 px 22.33%
Vulkan, Holtburg, capture 1 vs capture 2: 218,732 px 23.73%
GL, Facility Hub, capture 1 vs capture 2: 108,795 px 11.81%
Vulkan, Facility Hub, capture 1 vs capture 2: 130,206 px 14.13%
One backend, one stop, nothing moving, and a fifth of the frame changes while
you watch. No cross-backend number means anything against that noise floor, and
the cross-backend numbers taken during that probe run were duly absurd -- 56% at
Holtburg, where the two launches happened to be at different times of Dereth day.
THE FIX IS A PIN THAT OUTRANKS THE SERVER CLOCK AND SURVIVES SYNC.
WorldTimeService.PinnedDayFraction is a nullable day fraction that wins over both
Calendar.DayFraction(NowTicks) and SetDebugTime, and that SyncFromServer does not
touch. ACDREAM_WORLD_TIME -> RuntimeOptions.PinnedWorldDayFraction ->
WorldEnvironmentController, which writes it once: the Runtime environment owner
and its clock are session-scoped, so one write outlives every teleport and every
reveal generation. Values outside [0, 1) are REJECTED rather than clamped -- a
day fraction of 12.5 is a typo, and silently pinning the world at it would be
worse than ignoring it.
Unset is the default and every ordinary run. The calendar DATE still advances,
which is intentional: the date drives day-group selection, and ACDREAM_DAY_GROUP
already pins that. The differential gate forces the pin at 0.5 -- noon, which is
what the three presses were aiming at -- on both launches, and the route's
presses are deleted rather than left in as decoration.
This is instrument determinism on the footing of ACDREAM_DAY_GROUP and V7's
ACDREAM_SKY_PHASE_SECONDS, not a workaround: it is off by default, nothing in the
shipping client reads it, and the alternative was to keep measuring two backends
through a fifth of a frame of sunlight.
WHAT IT MOVED. The same three-stop route, same commit otherwise, before and after:
holtburg_town 9.05% -> 2.86% (83,438 -> 26,330 px)
facility_hub_interior 12.16% -> 0.78% (112,075 -> 7,176 px)
aerlinthe_island 23.09% -> 6.82% (212,824 -> 62,892 px)
The interior stop is the headline. V6m recorded it as a route defect on the
theory that the indoor spring-arm camera settles to different distances in two
runs; that theory is now refuted. The camera was fine. The interior was lit
differently because the sun had moved, and with the sun held still the stop drops
by a factor of 15 to 0.78% -- close enough to the 0.001 threshold that its
remaining population is worth naming rather than guessing at. No route change was
needed and none was made.
WHAT REMAINS, per the difference maps, all of it now attributable by eye:
the animated portal beside the Holtburg stop; distant scenery foliage; wandering
NPCs and a chimney smoke plume, which are animation and emitter phase; the vitals
readouts, whose stamina and mana genuinely regenerate at different rates across
two logins minutes apart; and, at Aerlinthe, a dense low-magnitude speckle in a
scene whose mean luminance is 28/255 -- half of its differing pixels are exactly
delta 3, one step over a tolerance that is absolute rather than relative.
Gates. Release build green. App tests 4,134 passed / 3 skipped (one new: the
day-fraction range check); AcDream.Core.Tests WorldTimeDebugTests 6/6, including
the two new ones that assert the pin survives a sync and outranks the transient
override. GL offline pixel gate against the pre-slice tree: 2.66e-05, 15 pixels
of 563,200, inside the documented 9-31 band -- GL did not move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
559 lines
20 KiB
C#
559 lines
20 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 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);
|
|
}
|
|
}
|