diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b5cd7916..45aa9451 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -635,7 +635,8 @@ public sealed class GameWindow : _worldEnvironment = new AcDream.App.World.WorldEnvironmentController( _runtime.EnvironmentOwner, options.ForcedDayGroupIndex, - Console.WriteLine); + Console.WriteLine, + options.PinnedWorldDayFraction); var alphaScratchBudgets = AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( _options.ResidencyBudgets.AlphaScratchBytes); diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index d6cc0e38..724654c9 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -53,6 +53,7 @@ public sealed record RuntimeOptions( string? UiProbeScript, string? AutomationArtifactDirectory, int? ForcedDayGroupIndex, + float? PinnedWorldDayFraction, float? SkyAnimationPhaseSeconds, float FogStartMultiplier, float FogEndMultiplier, @@ -120,6 +121,15 @@ public sealed record RuntimeOptions( NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), ForcedDayGroupIndex: TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")), + // Campaign V slice V7 instrument determinism: pins the Dereth day + // fraction, and therefore the sun direction, the sky keyframe and + // every lit surface. Distinct from the /time slash command, which is + // deliberately transient (the next TimeSync clears it) and so cannot + // hold a connected route still. Accepted only inside [0, 1); + // anything else -- unset, unparseable, negative, >= 1 -- leaves the + // server clock alone, which is every ordinary run. + PinnedWorldDayFraction: + TryParseDayFraction(env("ACDREAM_WORLD_TIME")), // Campaign V slice V7 instrument determinism: pins the sky's UV // scroll phase — the cloud sheet — to a fixed elapsed-seconds value // instead of the wall clock, so two launches of the differential @@ -187,6 +197,9 @@ public sealed record RuntimeOptions( private static int? TryParseNonNegativeInt(string? s) => TryParseInt(s) is { } v && v >= 0 ? v : null; + private static float? TryParseDayFraction(string? s) + => TryParseFloat(s) is { } value && value >= 0f && value < 1f ? value : null; + private static float? TryParseFloat(string? s) => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) ? value diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs index 0e127200..d4030acb 100644 --- a/src/AcDream.App/World/WorldEnvironmentController.cs +++ b/src/AcDream.App/World/WorldEnvironmentController.cs @@ -28,11 +28,18 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource internal WorldEnvironmentController( RuntimeWorldEnvironmentState runtime, int? forcedDayGroupIndex = null, - Action? log = null) + Action? log = null, + float? pinnedDayFraction = null) { Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _forcedDayGroupIndex = forcedDayGroupIndex; _log = log ?? (_ => { }); + // Campaign V slice V7 instrument determinism. Applied here rather than + // per generation because the Runtime environment owner and its clock are + // session-scoped: one write outlives every teleport and reveal. + Runtime.WorldTime.PinnedDayFraction = pinnedDayFraction; + if (pinnedDayFraction.HasValue) + _log($"sky: world time PINNED at day fraction {pinnedDayFraction.Value:F4}"); } public RuntimeWorldEnvironmentState Runtime { get; } diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 4cbe2608..f38e4a19 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -378,6 +378,33 @@ public sealed class WorldTimeService private float? _debugDayFractionOverride; + /// + /// Campaign V slice V7: a day fraction in [0, 1) that outranks BOTH the + /// server clock and , and that + /// does not clear. null — the default, + /// and every ordinary run — leaves the clock alone entirely. + /// + /// Why the transient override is not enough for a gate. + /// is the /time slash command's mechanism + /// and is deliberately transient: the next TimeSync packet clears it, which + /// is what makes the command a "look at dusk for a moment" affordance rather + /// than a mode. ACE sends TimeSync every few seconds, so a route that presses + /// AcdreamCycleTimeOfDay at startup is un-pinned again before it + /// reaches its first stop. The V7 differential measured what that costs + /// directly: two captures 45 seconds apart at ONE stop on ONE backend + /// differed in 22.3% of the frame, because the sun had moved. No + /// cross-backend number means anything against that. + /// + /// Pinning the day fraction freezes the sun direction, the sky + /// keyframe and therefore every lit surface in the scene. It is instrument + /// determinism on the footing of ACDREAM_DAY_GROUP: off by default, + /// set only by a gate script, and read by nothing in the shipping client. + /// The calendar DATE still advances, which is intentional — day-group + /// selection is what the date drives, and ACDREAM_DAY_GROUP already + /// pins that. + /// + public float? PinnedDayFraction { get; set; } + /// /// Rate at which in-game time advances relative to real time. Retail /// default is 1.0 (one wall-clock second = one in-game tick). Server @@ -467,6 +494,8 @@ public sealed class WorldTimeService { get { + if (PinnedDayFraction.HasValue) + return PinnedDayFraction.Value; if (_debugDayFractionOverride.HasValue) return _debugDayFractionOverride.Value; return Calendar.DayFraction(NowTicks); diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index aec07424..641d2066 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -358,6 +358,39 @@ public sealed class RuntimeOptionsTests .ForcedDayGroupIndex); } + /// + /// 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. + /// + [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); + } + } + /// /// 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 diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs index 4e3db2f4..bf7269e1 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -51,6 +51,52 @@ public sealed class WorldTimeDebugTests Assert.InRange(service.DayFraction, 7.0 / 16.0 - 0.01, 7.0 / 16.0 + 0.01); } + /// + /// Campaign V slice V7. The transient override above is exactly what a + /// connected gate cannot use: SyncFromServer_ClearsDebugOverride is + /// the documented behaviour, and ACE sends TimeSync every few seconds, so a + /// route that pins the clock at startup is un-pinned before its first stop. + /// The pin has to outrank both the server clock and the slash command, and + /// survive every sync -- that is the whole property, so it is asserted + /// against a sync AND against a competing SetDebugTime. + /// + [Fact] + public void PinnedDayFraction_SurvivesServerSyncAndOutranksTheDebugOverride() + { + var service = new WorldTimeService(SkyStateProvider.Default()) + { + PinnedDayFraction = 0.5f, + }; + + service.SyncFromServer(0); // tick 0 = fraction 7/16 + Assert.InRange(service.DayFraction, 0.499, 0.501); + + service.SetDebugTime(0.75f); // the /time command loses + Assert.InRange(service.DayFraction, 0.499, 0.501); + + service.SyncFromServer(DerethDateTime.DayTicks / 4.0); + Assert.InRange(service.DayFraction, 0.499, 0.501); + } + + /// + /// Unset is the default and must leave the clock completely alone, including + /// after the pin has been set and cleared again. + /// + [Fact] + public void PinnedDayFraction_UnsetLeavesTheServerClockAlone() + { + var service = new WorldTimeService(SkyStateProvider.Default()); + service.SyncFromServer(0); + Assert.Null(service.PinnedDayFraction); + Assert.InRange(service.DayFraction, 7.0 / 16.0 - 0.01, 7.0 / 16.0 + 0.01); + + service.PinnedDayFraction = 0.125f; + Assert.InRange(service.DayFraction, 0.124, 0.126); + + service.PinnedDayFraction = null; + Assert.InRange(service.DayFraction, 7.0 / 16.0 - 0.01, 7.0 / 16.0 + 0.01); + } + [Fact] public void SetProvider_AcceptsNewKeyframes() { diff --git a/tools/connected-backend-differential.route.txt b/tools/connected-backend-differential.route.txt index dfb2e6ba..995decbe 100644 --- a/tools/connected-backend-differential.route.txt +++ b/tools/connected-backend-differential.route.txt @@ -7,11 +7,15 @@ # # * Every teleloc carries the identity quaternion, so the heading at each stop # is the same on both runs rather than whatever the previous heading was. -# * The world clock is pinned by the client-only time-of-day override -# (AcdreamCycleTimeOfDay cycles live -> 0.00 -> 0.25 -> 0.50, so three -# presses land on noon). Without it the sky gradient, the sun angle and -# every lit surface drift with wall time between the two launches, which is -# the same effect that makes the offline gate mask its top 280 rows. +# * The world clock is pinned by ACDREAM_WORLD_TIME, which the gate script +# forces on both launches. It used to be pinned here instead, by pressing +# AcdreamCycleTimeOfDay three times (live -> 0.00 -> 0.25 -> 0.50), and +# THAT DID NOT WORK: SetDebugTime is transient by design -- it is the /time +# slash command's mechanism, and WorldTimeService.SyncFromServer clears it +# -- so ACE's next TimeSync un-pinned the clock seconds later, long before +# the first stop. V7 measured the consequence directly: two captures 45 s +# apart at ONE stop on ONE backend differ in 22.3% of the frame outdoors and +# 11.8% indoors, because the sun keeps moving. The presses are gone. # * Nothing moves the character. The only thing between arrival and capture is # a settle window, because a timed turn cannot be relied on to stop at the # same angle twice. @@ -40,10 +44,6 @@ wait world-ready 90000 wait world-visible 30000 sleep 8000 -input press AcdreamCycleTimeOfDay -input press AcdreamCycleTimeOfDay -input press AcdreamCycleTimeOfDay -sleep 5000 # 1. Holtburg: the dense outdoor town. Terrain, terrain blending, the road # overlay, static world meshes, scenery and the whole retained UI in one diff --git a/tools/run-backend-differential-gate.ps1 b/tools/run-backend-differential-gate.ps1 index ed041d48..0b46cb7b 100644 --- a/tools/run-backend-differential-gate.ps1 +++ b/tools/run-backend-differential-gate.ps1 @@ -27,9 +27,16 @@ Note that it must be forced on BOTH -- matching a GL run at 4x against a Vulkan run at 0 would be worse than either. - 2. ACDREAM_DAY_GROUP pinned. The sky animates and the Dereth clock advances - with wall time; two launches minutes apart cannot agree about either. - The route additionally pins the client-only time-of-day override. + 2. ACDREAM_DAY_GROUP and ACDREAM_WORLD_TIME pinned. The Dereth clock + advances with wall time, and it does not only move the sky: it moves the + SUN, so every lit surface in the scene drifts with it. The route used to + press AcdreamCycleTimeOfDay three times instead, and that was measured + NOT to hold -- SetDebugTime is transient by design and ACE's next + TimeSync clears it within seconds. What that cost, measured at V7 on two + captures 45 s apart at ONE stop on ONE backend: 22.3% of the frame at + Holtburg and 11.8% inside the Facility Hub. A cross-backend number taken + against that noise floor means nothing, which is why the day fraction is + now pinned by env var and the route no longer presses anything. 3. ACDREAM_SKY_PHASE_SECONDS pinned ON BOTH LAUNCHES. The day group and the time-of-day override pin ONE of the sky's two clocks -- the one that @@ -84,6 +91,12 @@ .PARAMETER DayGroup Sky day-group index pinned on both launches. Default 0. +.PARAMETER WorldDayFraction + Dereth day fraction pinned on both launches, in [0, 1). Default 0.5 -- noon, + the brightest and flattest lighting the day has, which is what the route's + three AcdreamCycleTimeOfDay presses were aiming at before it was measured + that they do not hold. See forced item 2 above. + .PARAMETER SkyPhaseSeconds Elapsed-seconds value the sky's UV scroll is pinned to on both launches. Default 0 -- the cloud sheet's authored origin. Any finite value works: the @@ -130,6 +143,7 @@ param( [int]$Tolerance = 2, [double]$MaxDifferentFraction = 0.001, [int]$DayGroup = 0, + [double]$WorldDayFraction = 0.5, [double]$SkyPhaseSeconds = 0, [int]$MaskTopPixels = 0, [int]$MinRenderedBytes = 500000, @@ -214,6 +228,8 @@ function Invoke-BackendRun([string]$Backend) { $env:ACDREAM_MSAA_SAMPLES = '0' $env:ACDREAM_SKY_PHASE_SECONDS = $SkyPhaseSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture) + $env:ACDREAM_WORLD_TIME = + $WorldDayFraction.ToString([System.Globalization.CultureInfo]::InvariantCulture) $env:ACDREAM_RENDER_BACKEND = $Backend $env:ACDREAM_UI_PROBE_SCRIPT = $effectiveRoute $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir @@ -275,6 +291,7 @@ function Invoke-BackendRun([string]$Backend) { Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue + Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue $strayInput = @(Select-String -Path $log -Pattern 'ScrollUp|ScrollDown|ZoomIn|ZoomOut|CameraZoom' ` -CaseSensitive -ErrorAction SilentlyContinue) @@ -384,6 +401,7 @@ $report = [pscustomobject][ordered]@{ MaxDifferentPixelFraction = $MaxDifferentFraction MsaaSamples = 0 DayGroup = $DayGroup + WorldDayFraction = $WorldDayFraction SkyPhaseSeconds = $SkyPhaseSeconds MaskTopPixels = $MaskTopPixels Runs = @($gl, $vk)