From 1f25a609996fa78b5e8f7e92d76b747ead56c53a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 19:34:13 +0200 Subject: [PATCH] fix(diag): Campaign V slice V7 commit 2 - pin the world clock, because the route never did 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 --- src/AcDream.App/Rendering/GameWindow.cs | 3 +- src/AcDream.App/RuntimeOptions.cs | 13 ++++++ .../World/WorldEnvironmentController.cs | 9 +++- src/AcDream.Core/World/SkyState.cs | 29 ++++++++++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 33 +++++++++++++ .../World/WorldTimeDebugTests.cs | 46 +++++++++++++++++++ .../connected-backend-differential.route.txt | 18 ++++---- tools/run-backend-differential-gate.ps1 | 24 ++++++++-- 8 files changed, 161 insertions(+), 14 deletions(-) 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)