acdream/src/AcDream.Core/World/SkyState.cs
Erik 1f25a60999 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 <noreply@anthropic.com>
2026-07-28 19:34:13 +02:00

516 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
/// supported by the dat schema but never appear in shipped data. See r12
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
/// </summary>
public enum FogMode
{
Off = 0,
Linear = 1,
Exp = 2,
Exp2 = 3,
}
/// <summary>
/// One sky keyframe — the full lighting + fog state for a specific
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
/// the current sky state.
///
/// <para>
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
/// belong to the renderer. This record exposes the shader-relevant
/// subset — sun direction, sun color, ambient color, linear fog. See
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
/// and r12 §4 + §5.
/// </para>
///
/// <para>
/// Colors are stored RAW (NOT pre-multiplied by brightness) in
/// <see cref="DirColor"/> / <see cref="AmbColor"/> with the brightness
/// scalars in <see cref="DirBright"/> / <see cref="AmbBright"/>. Retail's
/// <c>SkyDesc::GetLighting</c> at <c>0x00500ac9</c> (decomp lines
/// 261317-261331) lerps each channel separately and lerps brightness
/// separately, then multiplies post-lerp. Lerping the pre-multiplied
/// product gives mathematically different results when both color and
/// brightness change between adjacent keyframes — the cause of subtle
/// brightness discrepancies vs retail observed in dual-client
/// comparisons (Issue #3 visual sub-bug, 2026-04-27).
/// </para>
/// <para>
/// The computed properties <see cref="SunColor"/> and
/// <see cref="AmbientColor"/> return the post-multiplied product, so
/// downstream shader uniform plumbing (sky.vert / mesh.vert /
/// SceneLightingUbo) is unchanged.
/// </para>
/// </summary>
public readonly record struct SkyKeyframe(
float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 DirColor, // RGB linear, RAW (NOT × DirBright)
float DirBright, // sun brightness multiplier
Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright)
float AmbBright, // ambient brightness multiplier
Vector3 FogColor,
float FogDensity, // retained for tests; derive from FogStart/End
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
FogMode FogMode = FogMode.Linear)
{
/// <summary>
/// Final directional sun color the shader feeds into N·L lighting.
/// Retail-faithful magnitude formula:
/// <code>SunColor = DirColor × |sunVec|</code>
/// where <c>sunVec</c> is retail's heading+pitch+brightness vector
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
///
/// <para>
/// <c>|sunVec|</c> is retail's <c>D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)</c>
/// scaling (<c>PrimD3DRender::UpdateLightsInternal</c> 0x0059b57c, decomp
/// 424118-424119) of the WORLD-space sun vector (<c>LScape::sunlight</c>).
/// Because <see cref="SkyStateProvider.RetailSunVector"/> is now the
/// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
/// see that method), <c>|sunVec| == DirBright</c>, so this is effectively
/// <c>SunColor = DirColor × DirBright</c>. (A prior bug used the un-transformed
/// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~45× too bright at dawn/dusk;
/// [[reference-retail-ambient-values]].)
/// </para>
/// </summary>
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
/// <summary>
/// Final ambient color the shader feeds into the per-vertex tint.
/// Retail-faithful magnitude formula:
/// <code>AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)</code>
/// matching <c>SmartBox::SetWorldAmbientLight</c> as called at
/// <c>0x0050560b</c> (decomp line 267117):
/// <code>SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)</code>
/// Retail boosts the ambient brightness by 20% of the sun-vector
/// magnitude — i.e. ambient feels warmer when the sun is up, cooler
/// at night. acdream previously used <c>AmbBright</c> alone, which
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
/// bias because the warm fill was missing.
/// </summary>
public Vector3 AmbientColor =>
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
}
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
/// the blended lighting state between the surrounding keyframes.
///
/// <para>
/// Math (r12 §4):
/// <list type="number">
/// <item><description>
/// Pick the two keyframes bracketing <c>t</c>: <c>k1</c> = last
/// keyframe with <c>Begin &lt;= t</c>, <c>k2</c> = next keyframe
/// (wraps: if <c>k1</c> is last, <c>k2</c> is first).
/// </description></item>
/// <item><description>
/// Local blend <c>u = (t - k1.Begin) / (k2.Begin - k1.Begin)</c>
/// with wrap handling.
/// </description></item>
/// <item><description>
/// Lerp every vector component; use shortest-arc lerp for the sun
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class SkyStateProvider
{
private readonly List<SkyKeyframe> _keyframes;
public SkyStateProvider(IReadOnlyList<SkyKeyframe> keyframes)
{
if (keyframes is null || keyframes.Count == 0)
throw new ArgumentException("At least one keyframe required", nameof(keyframes));
// Sort by Begin so the walk is deterministic regardless of input order.
var sorted = new List<SkyKeyframe>(keyframes);
sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin));
_keyframes = sorted;
}
public int KeyframeCount => _keyframes.Count;
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
/// <summary>
/// Default keyframe set based on retail observations — sunrise at 6am,
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
/// available yet or the player is in a region whose Region dat
/// doesn't override it.
///
/// <para>
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
/// linear fog with color matching the horizon band so mountains at
/// distance fade into the sky instead of popping at the clip plane.
/// See r12 §5.1.
/// </para>
/// </summary>
public static SkyStateProvider Default()
{
// Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk.
return new SkyStateProvider(new[]
{
// Default factory: brightness scalars are 1.0 here — the colors
// ARE the final intended values. Live Dereth keyframes loaded
// from the dat have separate non-1.0 DirBright/AmbBright values
// and the renderer multiplies them post-lerp.
new SkyKeyframe(
Begin: 0.0f,
SunHeadingDeg: 0f, // below horizon (north)
SunPitchDeg: -30f,
DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
DirBright: 1.0f,
AmbColor: new Vector3(0.05f, 0.05f, 0.12f),
AmbBright: 1.0f,
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f,
FogStart: 30f,
FogEnd: 180f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
SunPitchDeg: 0f,
DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
DirBright: 1.0f,
AmbColor: new Vector3(0.4f, 0.35f, 0.3f),
AmbBright: 1.0f,
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
SunPitchDeg: 70f,
DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
DirBright: 1.0f,
AmbColor: new Vector3(0.5f, 0.5f, 0.55f),
AmbBright: 1.0f,
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f,
FogStart: 120f,
FogEnd: 500f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
SunPitchDeg: 0f,
DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
DirBright: 1.0f,
AmbColor: new Vector3(0.35f, 0.25f, 0.25f),
AmbBright: 1.0f,
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
});
}
/// <summary>
/// Current interpolated sky state at day fraction <paramref name="t"/>.
/// Wraps correctly across the day boundary (1.0 → 0.0).
/// </summary>
public SkyKeyframe Interpolate(float t)
{
t = (float)(t - Math.Floor(t)); // wrap to [0, 1)
// Find k1: last keyframe with Begin <= t.
int k1Index = _keyframes.Count - 1;
for (int i = 0; i < _keyframes.Count; i++)
{
if (_keyframes[i].Begin <= t)
k1Index = i;
else
break;
}
int k2Index = (k1Index + 1) % _keyframes.Count;
var k1 = _keyframes[k1Index];
var k2 = _keyframes[k2Index];
// Compute blend weight, handling wrap (k1 is last, k2 is first).
float k1Begin = k1.Begin;
float k2Begin = k2.Begin;
if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap
float tWrapped = t;
if (tWrapped < k1Begin) tWrapped += 1.0f;
float span = Math.Max(1e-6f, k2Begin - k1Begin);
float u = (tWrapped - k1Begin) / span;
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
// Retail-faithful interpolation: lerp DirColor / DirBright /
// AmbColor / AmbBright as SEPARATE CHANNELS, not as the
// pre-multiplied product. Mirrors SkyDesc::GetLighting at
// 0x00500ac9 (decomp lines 261317-261331). The post-multiplied
// SunColor / AmbientColor are computed properties on the result.
// Fog mode doesn't interpolate — pick k1's mode (retail uses
// Linear everywhere).
return new SkyKeyframe(
Begin: t,
SunHeadingDeg: heading,
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u),
DirBright: Lerp(k1.DirBright, k2.DirBright, u),
AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u),
AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
FogMode: k1.FogMode);
}
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
/// <summary>
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
/// the lerp walks 20° forward through 0° rather than 340° backward.
/// </summary>
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
{
float delta = bDeg - aDeg;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
return aDeg + delta * u;
}
/// <summary>
/// Retail's world-space sun vector (NOT normalized): the standard
/// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
/// <c>DirBright</c>:
/// <code>
/// sunVec.x = DirBright × cos(P) × sin(H)
/// sunVec.y = DirBright × cos(P) × cos(H)
/// sunVec.z = DirBright × sin(P)
/// </code>
/// so <c>|sunVec| == DirBright</c> exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
///
/// <para>
/// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
/// retail's <c>LScape::sunlight</c> read at a dawn keyframe (H=90°, P=0.9°,
/// DirBright≈0.224) = <c>(0.2238, ~0, 0.00352)</c> — y≈0, magnitude 0.224 =
/// DirBright. That fed <c>level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
/// 0.40 = 0.445</c>, matching the captured <c>SetWorldAmbientLight</c> level.
/// </para>
/// <para>
/// PRIOR BUG: an earlier version returned <c>y = cos(P)</c> (≈1) — the raw
/// PRE-transform value the decomp's <c>SkyDesc::GetLighting</c> writes to its
/// <c>arg5</c> (0x00500ac9, before <c>LScape::set_sky_position</c>'s world
/// transform). Porting that un-transformed vector inflated <c>|sunVec|</c> to
/// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
/// (<see cref="SkyKeyframe.AmbientColor"/>) AND the sun colour
/// (<see cref="SkyKeyframe.SunColor"/>) by ~30% vs retail. The world-space
/// form above is what <c>LScape::sunlight</c> actually holds at runtime.
/// </para>
/// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
/// feeds the sun-colour intensity and the ambient brightness boost.
/// </summary>
public static Vector3 RetailSunVector(SkyKeyframe kf)
{
float h = kf.SunHeadingDeg * (MathF.PI / 180f);
float p = kf.SunPitchDeg * (MathF.PI / 180f);
float cosP = MathF.Cos(p);
float sinP = MathF.Sin(p);
float B = kf.DirBright;
return new Vector3(
B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
B * sinP); // z = DirBright × sin(P)
}
/// <summary>
/// World-space sun direction unit vector pointing FROM the surface
/// TOWARDS the sun, derived from <see cref="RetailSunVector"/> and
/// normalized. The shader sunDir uniform should use this directly
/// (or -this if the lighting math wants the L-vector pointing AT the
/// surface). The previous implementation used standard spherical
/// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match
/// retail's deliberate Y-decoupled-from-heading convention. Switching
/// to the retail vector subtly tilts the lighting on objects but
/// matches retail's screenshots when both clients view the same scene.
/// </summary>
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
{
var v = RetailSunVector(kf);
float len = v.Length();
return len > 1e-6f ? v / len : Vector3.UnitZ;
}
}
/// <summary>
/// Service that turns server-delivered tick counts into live sky state.
/// Owns the "current time" clock (seeded from server sync, advanced by
/// real-time elapsed between syncs).
///
/// <para>
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
/// forces a specific day fraction regardless of server sync — used for
/// screenshots and visual debugging. The override is transient and gets
/// cleared on the next TimeSync packet.
/// </para>
/// </summary>
public sealed class WorldTimeService
{
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTimeOffset _lastSyncedWallClockUtc;
private readonly TimeProvider _timeProvider;
private readonly Action<string>? _synchronizationDiagnostic;
private float? _debugDayFractionOverride;
/// <summary>
/// Campaign V slice V7: a day fraction in [0, 1) that outranks BOTH the
/// server clock and <see cref="SetDebugTime"/>, and that
/// <see cref="SyncFromServer"/> does not clear. <c>null</c> — the default,
/// and every ordinary run — leaves the clock alone entirely.
///
/// <para><b>Why the transient override is not enough for a gate.</b>
/// <see cref="SetDebugTime"/> is the <c>/time</c> 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
/// <c>AcdreamCycleTimeOfDay</c> 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.</para>
///
/// <para>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 <c>ACDREAM_DAY_GROUP</c>: 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 <c>ACDREAM_DAY_GROUP</c> already
/// pins that.</para>
/// </summary>
public float? PinnedDayFraction { get; set; }
/// <summary>
/// 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
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
/// </summary>
public double TickSize { get; set; } = 1.0;
public WorldTimeService(SkyStateProvider sky)
: this(sky, new DerethCalendar(), TimeProvider.System)
{
}
public WorldTimeService(
SkyStateProvider sky,
DerethCalendar calendar,
TimeProvider? timeProvider = null,
Action<string>? synchronizationDiagnostic = null)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
Calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
_timeProvider = timeProvider ?? TimeProvider.System;
_synchronizationDiagnostic = synchronizationDiagnostic;
_lastSyncedWallClockUtc = _timeProvider.GetUtcNow();
}
public DerethCalendar Calendar { get; }
/// <summary>
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
/// </summary>
public void SetProvider(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Set the authoritative tick count from a server TimeSync packet.
/// Clears any debug override.
/// </summary>
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = _timeProvider.GetUtcNow();
_debugDayFractionOverride = null;
if (_synchronizationDiagnostic is not null)
{
var df = Calendar.DayFraction(serverTicks);
var cal = Calendar.ToCalendar(serverTicks);
_synchronizationDiagnostic(
$"[sky-dump] SyncFromServer: ticks={serverTicks:F1} dayFraction={df:F4} " +
$"calendar=PY{cal.Year} {cal.Month} {cal.Day} {cal.Hour}");
}
}
/// <summary>
/// Debug-only: force a specific day fraction in [0, 1). Overrides
/// server-synced time until cleared by <see cref="SyncFromServer"/>
/// or <see cref="ClearDebugTime"/>.
/// </summary>
public void SetDebugTime(float dayFraction)
{
_debugDayFractionOverride = dayFraction;
}
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at the injected <see cref="TimeProvider"/>'s UTC time,
/// advanced from the last sync by real-time elapsed seconds times
/// <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed =
(_timeProvider.GetUtcNow() - _lastSyncedWallClockUtc)
.TotalSeconds;
return _lastSyncedTicks + elapsed * TickSize;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
public double DayFraction
{
get
{
if (PinnedDayFraction.HasValue)
return PinnedDayFraction.Value;
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return Calendar.DayFraction(NowTicks);
}
}
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
Calendar.ToCalendar(NowTicks);
public bool IsDaytime => Calendar.IsDaytime(NowTicks);
}