refactor(runtime): own world environment state

This commit is contained in:
Erik 2026-07-26 16:45:04 +02:00
parent b972f539f7
commit 902076c0a4
27 changed files with 886 additions and 295 deletions

View file

@ -89,7 +89,7 @@ void __fastcall FUN_00501990(uint *param_1)
| Symbol | Bytes | Role |
|---|---|---|
| `0x6a42fdb2` | 1,782,399,410 | LCG multiplier (AC-specific, not a named-PRNG constant). |
| `0x6a42fdb2` | 1,782,775,218 | LCG multiplier (AC-specific, not a named-PRNG constant). |
| `-0x7541e9ae` | `0x8ABE1652` unsigned | LCG increment. |
| `_DAT_0079920c` | ~4.29497e9f (2^32) | Standard signed-int→unsigned-float fixup constant. |
| `_DAT_007c6f10` | (see §2.4) | Scale factor; interpreted as `1.0 / 2^32 ≈ 2.32830644e-10f`. Same constant is reused by all deterministic AC LCG picks (e.g. `FUN_00504060` at `chunk_00500000.c:4042` for hash-chain lookup). |

View file

@ -879,6 +879,7 @@ internal sealed class SessionPlayerCompositionPhase
d.Communication,
d.Actions,
d.PlayerController,
d.WorldEnvironment.Runtime,
worldReveal,
d.UpdateClock,
live.SelectionInteractions);

View file

@ -116,8 +116,7 @@ public sealed class GameWindow :
_renderResourceLifetime = new();
private readonly AcDream.App.Rendering.GlConstructionCleanupLedger
_glConstructionCleanup = new();
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
new(Console.WriteLine);
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
private readonly GameWindowLifetime _lifetime = new();
private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
@ -566,6 +565,13 @@ public sealed class GameWindow :
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_worldEnvironment = new AcDream.App.World.WorldEnvironmentController(
new AcDream.Runtime.World.RuntimeWorldEnvironmentState(
log: Console.WriteLine,
timeSyncDiagnostic:
options.DumpSky ? Console.WriteLine : null),
options.ForcedDayGroupIndex,
Console.WriteLine);
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeCharacter = new RuntimeCharacterState();
_runtimeActions = new RuntimeActionState(

View file

@ -29,7 +29,7 @@ internal sealed class RuntimeRenderFrameTitleFactsSource : IRenderFrameTitleFact
public RenderFrameTitleFacts Capture() => new(
_world.Entities.Count,
_animations.Count,
DerethDateTime.ToCalendar(_time.NowTicks),
_time.CurrentCalendar,
_time.DayFraction);
}

View file

@ -7,6 +7,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
namespace AcDream.App.Runtime;
@ -38,6 +39,7 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeWorldEnvironmentState environment,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
SelectionInteractionController selection)
@ -51,6 +53,7 @@ internal sealed class CurrentGameRuntimeAdapter
communication,
actions,
movement,
environment,
worldReveal,
clock);
entityObjects.BindEventContext(
@ -84,6 +87,7 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeChatView Chat => _view.Chat;
public IRuntimeActionView Actions => _view.Actions;
public IRuntimeMovementView Movement => _view.Movement;
public IRuntimeWorldEnvironmentView Environment => _view.Environment;
public IRuntimePortalView Portal => _view.Portal;
public IRuntimeSessionCommands Session => _commands;

View file

@ -7,6 +7,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.App.Runtime;
@ -27,6 +28,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
private readonly IRuntimeMovementView _movementView;
private readonly IRuntimeWorldEnvironmentView _environmentView;
private readonly PortalView _portalView;
private bool _active = true;
@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeWorldEnvironmentState environment,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
{
@ -61,6 +64,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = (
movement ?? throw new ArgumentNullException(nameof(movement))).View;
_environmentView = environment
?? throw new ArgumentNullException(nameof(environment));
_portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
}
@ -102,6 +107,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
public IRuntimeChatView Chat => _chatView;
public IRuntimeActionView Actions => _actionView;
public IRuntimeMovementView Movement => _movementView;
public IRuntimeWorldEnvironmentView Environment => _environmentView;
public IRuntimePortalView Portal => _portalView;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
@ -120,6 +126,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_chatView.Count,
_actionView.Snapshot,
_movementView.Snapshot,
_environmentView.Snapshot,
_portalView.Snapshot);
internal void Deactivate() => _active = false;

View file

@ -38,6 +38,7 @@ public sealed record RuntimeOptions(
bool DevTools,
bool UncappedRendering,
bool DumpMoveTruth,
bool DumpSky,
bool NoAudio,
bool EnableSkyPesDebug,
int HidePartIndex,
@ -51,6 +52,7 @@ public sealed record RuntimeOptions(
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory,
int? ForcedDayGroupIndex,
float FogStartMultiplier,
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
@ -91,6 +93,7 @@ public sealed record RuntimeOptions(
// sole way to measure truly uncapped renderer throughput.
UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")),
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")),
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
@ -110,6 +113,8 @@ public sealed record RuntimeOptions(
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
AutomationArtifactDirectory:
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
ForcedDayGroupIndex:
TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),

View file

@ -1,61 +1,59 @@
using AcDream.App.Rendering;
using AcDream.Core.World;
using AcDream.Runtime.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.World;
/// <summary>
/// Owns the live Dereth clock, selected retail day group, weather state, and
/// server environment overrides as one coherent environment lifetime.
/// Graphical DAT/presentation adapter over the one
/// <see cref="RuntimeWorldEnvironmentState"/> owner. Runtime owns clock,
/// weather, day selection, and server overrides; App retains raw sky DAT
/// objects because they are renderer inputs.
/// </summary>
internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
{
private static readonly WeatherKind[] DebugWeatherKinds =
[
WeatherKind.Clear,
WeatherKind.Overcast,
WeatherKind.Rain,
WeatherKind.Snow,
WeatherKind.Storm,
];
private readonly Action<string> _log;
private readonly int? _forcedDayGroupIndex;
private LoadedSkyDesc? _loadedSkyDesc;
private long _loadedSkyDayIndex = long.MinValue;
private int _timeDebugStep;
private int _weatherDebugStep;
private bool _initializationClaimed;
public WorldEnvironmentController(Action<string>? log = null)
: this(
new WorldTimeService(SkyStateProvider.Default()),
new WeatherSystem(),
new RuntimeWorldEnvironmentState(log: log),
forcedDayGroupIndex: null,
log)
{
}
internal WorldEnvironmentController(
WorldTimeService worldTime,
WeatherSystem weather,
RuntimeWorldEnvironmentState runtime,
int? forcedDayGroupIndex = null,
Action<string>? log = null)
{
WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
Weather = weather ?? throw new ArgumentNullException(nameof(weather));
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_forcedDayGroupIndex = forcedDayGroupIndex;
_log = log ?? (_ => { });
}
public WorldTimeService WorldTime { get; }
public RuntimeWorldEnvironmentState Runtime { get; }
public WorldTimeService WorldTime => Runtime.WorldTime;
public WeatherSystem Weather => Runtime.Weather;
public WeatherSystem Weather { get; }
public DayGroupData? ActiveDayGroup { get; private set; }
public DayGroupData? ActiveDayGroup
{
get
{
int index = Runtime.ActiveDayGroupIndex;
return _loadedSkyDesc is not null
&& index >= 0
&& index < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[index]
: null;
}
}
public float DayFraction => (float)WorldTime.DayFraction;
/// <summary>
/// Loads the Region DAT environment and seeds the pre-session clock exactly
/// as the former <c>GameWindow.OnLoad</c> body did.
/// </summary>
public void Initialize(Region region)
{
ArgumentNullException.ThrowIfNull(region);
@ -68,182 +66,52 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
LoadedSkyDesc? loadedSkyDesc,
double? zeroTimeOfYear)
{
if (_initializationClaimed)
if (Runtime.IsInitialized)
{
throw new InvalidOperationException(
"The world environment is a one-shot GameWindow lifetime owner.");
"The world environment is a one-shot Runtime lifetime owner.");
}
_initializationClaimed = true;
_loadedSkyDesc = loadedSkyDesc;
_loadedSkyDayIndex = long.MinValue;
ActiveDayGroup = null;
// Region GameTime is authoritative when present. A Region without it
// gets the documented offline fallback rather than inheriting mutable
// process-global state from an earlier window or test lifetime.
double origin = zeroTimeOfYear
?? DerethDateTime.DayFractionOriginOffsetTicks;
DerethDateTime.SetOriginOffsetFromDat(origin);
if (_loadedSkyDesc is not null)
{
// SkyDesc.TickSize is retail's next-sky-update throttle, not the
// rate of PortalYearTicks. ACE advances the server clock at one
// tick per real second, so client extrapolation must remain 1.0.
WorldTime.TickSize = 1.0;
if (zeroTimeOfYear.HasValue)
{
_log(
$"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} " +
$"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
$"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} "
+ $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
}
_log(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
// The initial roll intentionally precedes the offline noon seed,
// preserving the accepted OnLoad order. The first server sync
// below will select the authoritative day.
RefreshSkyForCurrentDay();
}
WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
}
public void SynchronizeFromServer(double ticks)
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}
/// <summary>
/// Environment packet bridge researched from
/// <c>CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20</c>. The current
/// fog approximation and missing centered UI-sound playback remain
/// explicitly registered as TS-55 and TS-54 respectively.
/// </summary>
public void ApplyAdminEnvirons(uint environChangeType)
{
if (environChangeType <= 0x06u)
{
Weather.Override = (EnvironOverride)environChangeType;
_log(
$"live: AdminEnvirons fog override = " +
$"{(EnvironOverride)environChangeType}");
return;
}
string name = environChangeType switch
{
0x65u => "RoarSound",
0x66u => "BellSound",
0x67u => "Chant1Sound",
0x68u => "Chant2Sound",
0x69u => "DarkWhispers1Sound",
0x6Au => "DarkWhispers2Sound",
0x6Bu => "DarkLaughSound",
0x6Cu => "DarkWindSound",
0x6Du => "DarkSpeechSound",
0x6Eu => "DrumsSound",
0x6Fu => "GhostSpeakSound",
0x70u => "BreathingSound",
0x71u => "HowlSound",
0x72u => "LostSoulsSound",
0x75u => "SquealSound",
0x76u => "Thunder1Sound",
0x77u => "Thunder2Sound",
0x78u => "Thunder3Sound",
0x79u => "Thunder4Sound",
0x7Au => "Thunder5Sound",
0x7Bu => "Thunder6Sound",
_ => $"Unknown(0x{environChangeType:X2})",
};
_log(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
/// <summary>
/// Selects the active DAT day group when the authoritative Dereth day
/// changes. The picker itself is the retail-verbatim
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> port (older-build
/// cross-reference <c>FUN_00501990</c>).
/// </summary>
public void RefreshSkyForCurrentDay()
{
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
double ticks = WorldTime.NowTicks;
int absYear = DerethDateTime.AbsoluteYear(ticks);
int dayOfYear = DerethDateTime.DayOfYear(ticks);
int daysPerYear = DerethDateTime.DaysInAMonth
* DerethDateTime.MonthsInAYear;
long dayIndex = (long)absYear * 360 + dayOfYear;
int idx = _loadedSkyDesc.SelectDayGroupIndex(
absYear,
daysPerYear,
dayOfYear);
DayGroupData? group = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[idx]
: null;
bool dayChanged = dayIndex != _loadedSkyDayIndex;
bool groupChanged = !ReferenceEquals(group, ActiveDayGroup);
if (!dayChanged && !groupChanged)
return;
_loadedSkyDayIndex = dayIndex;
ActiveDayGroup = group;
if (group is null || group.SkyTimes.Count == 0)
return;
WorldTime.SetProvider(
RuntimeWorldDayGroupDefinition[] groups =
loadedSkyDesc?.DayGroups.Select(
group => new RuntimeWorldDayGroupDefinition(
group.Name,
group.ChanceOfOccur,
group.SkyObjects.Count,
new SkyStateProvider(
group.SkyTimes.Select(s => s.Keyframe).ToList()));
Weather.SetKindFromDayGroupName(group.Name);
group.SkyTimes.Select(value => value.Keyframe).ToList())))
.ToArray()
?? [];
_log(
$"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{group.Name}\" " +
$"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjects.Count} objects, " +
$"{group.SkyTimes.Count} keyframes, weather={Weather.Kind})");
var definition = new RuntimeWorldEnvironmentDefinition(
origin,
loadedSkyDesc?.TickSize ?? 1.0,
loadedSkyDesc?.LightTickSize ?? 1.0,
groups,
_forcedDayGroupIndex);
Runtime.Initialize(definition);
_loadedSkyDesc = loadedSkyDesc;
}
public string CycleTimeOfDay()
{
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? selection = _timeDebugStep switch
{
0 => null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
public void SynchronizeFromServer(double ticks) =>
Runtime.SynchronizeFromServer(ticks);
if (selection.HasValue)
{
WorldTime.SetDebugTime(selection.Value);
return $"Time override = {selection.Value:F2}";
}
public void ApplyAdminEnvirons(uint environChangeType) =>
_ = Runtime.ApplyAdminEnvirons(environChangeType);
WorldTime.ClearDebugTime();
return "Time override cleared";
}
public void RefreshSkyForCurrentDay() => Runtime.RefreshDayGroup();
public string CycleWeather()
{
_weatherDebugStep = (_weatherDebugStep + 1) % DebugWeatherKinds.Length;
WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
Weather.ForceWeather(kind);
return $"Weather = {kind}";
}
public string CycleTimeOfDay() => Runtime.CycleTimeOfDay();
public string CycleWeather() => Runtime.CycleWeather();
}

View file

@ -0,0 +1,103 @@
namespace AcDream.Core.World;
/// <summary>
/// Instance-scoped Dereth calendar projection. Retail stores the Region
/// <c>GameTime.ZeroTimeOfYear</c> beside the active world rather than in a
/// process-global calendar. Keeping that origin on this object lets multiple
/// Runtime sessions use different Region definitions without corrupting one
/// another.
/// </summary>
public sealed class DerethCalendar
{
public DerethCalendar(
double originOffsetTicks = DerethDateTime.DayFractionOriginOffsetTicks)
{
SetOriginOffset(originOffsetTicks);
}
public double OriginOffsetTicks { get; private set; }
/// <summary>
/// Installs the Region DAT origin for this world lifetime.
/// </summary>
public void SetOriginOffset(double originOffsetTicks)
{
if (!double.IsFinite(originOffsetTicks))
throw new ArgumentOutOfRangeException(nameof(originOffsetTicks));
OriginOffsetTicks = originOffsetTicks;
}
public double DayFraction(double ticks)
{
double shifted = Shift(ticks);
double rem = shifted
- Math.Floor(shifted / DerethDateTime.DayTicks)
* DerethDateTime.DayTicks;
return rem / DerethDateTime.DayTicks;
}
public DerethDateTime.HourName CurrentHour(double ticks)
{
int slot = (int)Math.Floor(
DayFraction(ticks) * DerethDateTime.HoursInADay);
slot = Math.Clamp(slot, 0, DerethDateTime.HoursInADay - 1);
return (DerethDateTime.HourName)slot;
}
public bool IsDaytime(double ticks)
{
int hour = (int)CurrentHour(ticks);
return hour >= (int)DerethDateTime.HourName.Dawnsong
&& hour <= (int)DerethDateTime.HourName.WarmtideAndHalf;
}
public DerethDateTime.Calendar ToCalendar(double ticks)
{
double shifted = Shift(ticks);
int relativeYear = (int)(shifted / DerethDateTime.YearTicks);
double withinYear =
shifted - relativeYear * DerethDateTime.YearTicks;
int month = Math.Min(
DerethDateTime.MonthsInAYear - 1,
(int)(withinYear / DerethDateTime.MonthTicks));
double withinMonth =
withinYear - month * DerethDateTime.MonthTicks;
int day = Math.Min(
DerethDateTime.DaysInAMonth,
(int)(withinMonth / DerethDateTime.DayTicks) + 1);
return new DerethDateTime.Calendar(
relativeYear + DerethDateTime.ZeroYear,
(DerethDateTime.MonthName)month,
day,
CurrentHour(ticks));
}
public int Year(double ticks) =>
(int)(Shift(ticks) / DerethDateTime.YearTicks);
public int AbsoluteYear(double ticks) =>
Year(ticks) + DerethDateTime.ZeroYear;
public int DayOfYear(double ticks)
{
double shifted = Shift(ticks);
int year = (int)(shifted / DerethDateTime.YearTicks);
double withinYear =
shifted - year * DerethDateTime.YearTicks;
return Math.Clamp(
(int)(withinYear / DerethDateTime.DayTicks),
0,
DerethDateTime.DaysInAMonth
* DerethDateTime.MonthsInAYear
- 1);
}
private double Shift(double ticks)
{
if (!double.IsFinite(ticks))
throw new ArgumentOutOfRangeException(nameof(ticks));
return Math.Max(0d, ticks) + OriginOffsetTicks;
}
}

View file

@ -123,18 +123,17 @@ public static class DerethDateTime
/// Morntide-and-Half". This is a fallback — retail reads
/// <c>GameTime.ZeroTimeOfYear</c> from the Region dat (verified
/// <c>3600</c> in Dereth, 2026-04-23 live dump) and uses that as
/// the additive offset. We override <see cref="OriginOffsetTicks"/>
/// once the dat loads; this constant is only for offline tests.
/// the additive offset. Production installs that value on its
/// instance <see cref="DerethCalendar"/>; this constant remains the
/// offline/static-helper default.
/// </summary>
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75
/// <summary>
/// Additive tick offset applied before every calendar extraction
/// (DayFraction / Year / DayOfYear / AbsoluteYear). Populated from
/// the Region dat's <c>GameTime.ZeroTimeOfYear</c> via
/// <see cref="SetOriginOffsetFromDat"/> at Region load. Defaults to
/// <see cref="DayFractionOriginOffsetTicks"/> for offline tests and
/// for the boot window before the dat parses.
/// Compatibility origin used by the pure static helpers. Production
/// worlds use an instance <see cref="DerethCalendar"/> carrying their
/// Region DAT origin, so multiple sessions never share mutable calendar
/// state.
///
/// <para>
/// Live Dereth dat value: <c>3600</c>. Retail's
@ -148,17 +147,7 @@ public static class DerethDateTime
/// corrected and broke DG selection; reverted in the same commit.)
/// </para>
/// </summary>
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
/// <summary>
/// Adopt the Region dat's <c>GameTime.ZeroTimeOfYear</c> as the
/// calendar-extraction offset. Idempotent; safe to call on every
/// Region reload (though in practice Dereth is the only region).
/// </summary>
public static void SetOriginOffsetFromDat(double zeroTimeOfYear)
{
OriginOffsetTicks = zeroTimeOfYear;
}
public const double OriginOffsetTicks = DayFractionOriginOffsetTicks;
/// <summary>
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =

View file

@ -0,0 +1,40 @@
namespace AcDream.Core.World;
/// <summary>
/// Retail-verbatim day-group selection from
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c>.
/// </summary>
public static class SkyDayGroupSelector
{
public static int SelectIndex(
int dayGroupCount,
int absoluteYear,
int daysPerYear,
int dayOfYear,
int? forcedIndex = null)
{
if (dayGroupCount <= 0)
return 0;
if (forcedIndex is >= 0 && forcedIndex < dayGroupCount)
return forcedIndex.Value;
if (dayGroupCount == 1)
return 0;
int seed = unchecked(absoluteYear * daysPerYear + dayOfYear);
int mixed = unchecked(
seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
float hash = mixed;
if (mixed < 0)
hash += 4294967296.0f;
const float inverseTwoTo32 = 1.0f / 4294967296.0f;
int index = (int)MathF.Floor(
(float)dayGroupCount * hash * inverseTwoTo32);
// Retail snaps the floating-point upper-edge overflow to zero.
return index < 0 || index >= dayGroupCount ? 0 : index;
}
}

View file

@ -221,48 +221,22 @@ public sealed class LoadedSkyDesc
/// </summary>
public int SelectDayGroupIndex(int year, int secondsPerDay, int dayOfYear)
{
if (DayGroups.Count == 0) return 0;
// Env-var override has absolute priority.
var env = System.Environment.GetEnvironmentVariable("ACDREAM_DAY_GROUP");
int? forcedIndex = null;
if (int.TryParse(env, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var forced)
&& forced >= 0 && forced < DayGroups.Count)
{
return forced;
forcedIndex = forced;
}
if (DayGroups.Count == 1) return 0;
// --- Retail SkyDesc::CalcPresentDayGroup @ 0x00500E10 ---
// Step 1: deterministic per-day seed.
int seed = unchecked(year * secondsPerDay + dayOfYear);
// Step 2: 32-bit signed LCG (retail uses x86 silent wrap; force
// unchecked in C#). `0x8ABE1652` is stored as `-0x7541E9AE` in the
// decompile — same bit pattern.
int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
// Step 3: signed-int → float with +2^32 fixup for negative values
// (retail x87 converts `int` to `float` and then adds
// `_DAT_0079920c` ≈ 4294967296.0f when `iVar4 < 0`).
float hashF = (float)mixed;
if (mixed < 0) hashF += 4294967296.0f;
// Step 4: scale to [0, dayGroupCount). `_DAT_007c6f10` is
// `1.0f / 2^32` per reuse pattern (see research doc §2.4).
const float kInv2Pow32 = 1.0f / 4294967296.0f;
float countF = (float)DayGroups.Count;
int index = (int)System.MathF.Floor(countF * hashF * kInv2Pow32);
// Step 5: safety clamp for float rounding at the upper edge.
// Retail does `if (count <= index) index = 0;`. Using the same
// "snap to 0 on overflow" instead of clamping to count-1.
if (index >= DayGroups.Count) index = 0;
if (index < 0) index = 0;
return index;
return SkyDayGroupSelector.SelectIndex(
DayGroups.Count,
year,
secondsPerDay,
dayOfYear,
forcedIndex);
}
/// <summary>

View file

@ -372,7 +372,9 @@ public sealed class WorldTimeService
{
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
private DateTimeOffset _lastSyncedWallClockUtc;
private readonly TimeProvider _timeProvider;
private readonly Action<string>? _synchronizationDiagnostic;
private float? _debugDayFractionOverride;
@ -384,10 +386,25 @@ public sealed class WorldTimeService
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"/>.
@ -404,14 +421,14 @@ public sealed class WorldTimeService
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = System.DateTime.UtcNow;
_lastSyncedWallClockUtc = _timeProvider.GetUtcNow();
_debugDayFractionOverride = null;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
if (_synchronizationDiagnostic is not null)
{
var df = DerethDateTime.DayFraction(serverTicks);
var cal = DerethDateTime.ToCalendar(serverTicks);
System.Console.WriteLine(
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}");
}
@ -430,14 +447,17 @@ public sealed class WorldTimeService
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
/// 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 = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
double elapsed =
(_timeProvider.GetUtcNow() - _lastSyncedWallClockUtc)
.TotalSeconds;
return _lastSyncedTicks + elapsed * TickSize;
}
}
@ -449,7 +469,7 @@ public sealed class WorldTimeService
{
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return DerethDateTime.DayFraction(NowTicks);
return Calendar.DayFraction(NowTicks);
}
}
@ -461,7 +481,7 @@ public sealed class WorldTimeService
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
DerethDateTime.ToCalendar(NowTicks);
Calendar.ToCalendar(NowTicks);
public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks);
public bool IsDaytime => Calendar.IsDaytime(NowTicks);
}

View file

@ -188,6 +188,10 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.Magic.Revision}:" +
$"{checkpoint.Actions.Magic.LastRequestedSpellId:X8}:" +
$"{checkpoint.Actions.Magic.LastRequestedTargetId:X8};" +
$"environment={checkpoint.Environment.Revision}:" +
$"{checkpoint.Environment.ActiveDayGroupIndex}:" +
$"{(int)checkpoint.Environment.Weather}:" +
$"{(int)checkpoint.Environment.EnvironOverride};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Physics;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
@ -121,6 +122,7 @@ public readonly record struct RuntimeStateCheckpoint(
int ChatCount,
RuntimeActionSnapshot Actions,
RuntimeMovementSnapshot Movement,
RuntimeWorldEnvironmentSnapshot Environment,
RuntimePortalSnapshot Portal);
public interface IGameRuntimeView
@ -147,6 +149,8 @@ public interface IGameRuntimeView
IRuntimeMovementView Movement { get; }
IRuntimeWorldEnvironmentView Environment { get; }
IRuntimePortalView Portal { get; }
RuntimeStateCheckpoint CaptureCheckpoint();

View file

@ -0,0 +1,313 @@
using AcDream.Core.World;
namespace AcDream.Runtime.World;
public sealed record RuntimeWorldDayGroupDefinition(
string Name,
float ChanceOfOccur,
int SkyObjectCount,
SkyStateProvider Sky)
{
public string Name { get; } = Name ?? string.Empty;
public SkyStateProvider Sky { get; } =
Sky ?? throw new ArgumentNullException(nameof(Sky));
}
public sealed class RuntimeWorldEnvironmentDefinition
{
private readonly RuntimeWorldDayGroupDefinition[] _dayGroups;
public RuntimeWorldEnvironmentDefinition(
double originOffsetTicks,
double sourceTickSize,
double lightTickSize,
IEnumerable<RuntimeWorldDayGroupDefinition>? dayGroups,
int? forcedDayGroupIndex = null)
{
if (!double.IsFinite(originOffsetTicks))
throw new ArgumentOutOfRangeException(nameof(originOffsetTicks));
if (!double.IsFinite(sourceTickSize))
throw new ArgumentOutOfRangeException(nameof(sourceTickSize));
if (!double.IsFinite(lightTickSize))
throw new ArgumentOutOfRangeException(nameof(lightTickSize));
OriginOffsetTicks = originOffsetTicks;
SourceTickSize = sourceTickSize;
LightTickSize = lightTickSize;
_dayGroups = dayGroups?.ToArray() ?? [];
if (forcedDayGroupIndex is < 0
|| forcedDayGroupIndex >= _dayGroups.Length)
{
forcedDayGroupIndex = null;
}
ForcedDayGroupIndex = forcedDayGroupIndex;
}
public double OriginOffsetTicks { get; }
public double SourceTickSize { get; }
public double LightTickSize { get; }
public IReadOnlyList<RuntimeWorldDayGroupDefinition> DayGroups => _dayGroups;
public int? ForcedDayGroupIndex { get; }
}
public enum RuntimeEnvironmentEffectKind
{
Unknown,
FogOverride,
SoundCue,
}
public enum RuntimeEnvironmentSoundCue
{
Roar = 0x65,
Bell = 0x66,
Chant1 = 0x67,
Chant2 = 0x68,
DarkWhispers1 = 0x69,
DarkWhispers2 = 0x6A,
DarkLaugh = 0x6B,
DarkWind = 0x6C,
DarkSpeech = 0x6D,
Drums = 0x6E,
GhostSpeak = 0x6F,
Breathing = 0x70,
Howl = 0x71,
LostSouls = 0x72,
Squeal = 0x75,
Thunder1 = 0x76,
Thunder2 = 0x77,
Thunder3 = 0x78,
Thunder4 = 0x79,
Thunder5 = 0x7A,
Thunder6 = 0x7B,
}
public readonly record struct RuntimeEnvironmentEffect(
RuntimeEnvironmentEffectKind Kind,
uint RawValue,
EnvironOverride FogOverride = EnvironOverride.None,
RuntimeEnvironmentSoundCue? SoundCue = null);
public readonly record struct RuntimeWorldEnvironmentSnapshot(
long Revision,
bool IsInitialized,
int ActiveDayGroupIndex,
long ActiveDayIndex,
WeatherKind Weather,
EnvironOverride EnvironOverride);
public interface IRuntimeWorldEnvironmentView
{
RuntimeWorldEnvironmentSnapshot Snapshot { get; }
}
/// <summary>
/// Instance-scoped owner of the retail world clock, deterministic day-group
/// choice, weather label, and AdminEnvirons state. Raw DAT objects and sky
/// drawing remain graphical-host projections.
/// </summary>
public sealed class RuntimeWorldEnvironmentState
: IRuntimeWorldEnvironmentView
{
private static readonly WeatherKind[] DebugWeatherKinds =
[
WeatherKind.Clear,
WeatherKind.Overcast,
WeatherKind.Rain,
WeatherKind.Snow,
WeatherKind.Storm,
];
private readonly Action<string> _log;
private RuntimeWorldEnvironmentDefinition? _definition;
private long _activeDayIndex = long.MinValue;
private int _timeDebugStep;
private int _weatherDebugStep;
private long _revision;
public RuntimeWorldEnvironmentState(
TimeProvider? timeProvider = null,
Action<string>? log = null,
Action<string>? timeSyncDiagnostic = null)
{
WorldTime = new WorldTimeService(
SkyStateProvider.Default(),
new DerethCalendar(),
timeProvider,
timeSyncDiagnostic);
Weather = new WeatherSystem();
_log = log ?? (_ => { });
}
public WorldTimeService WorldTime { get; }
public WeatherSystem Weather { get; }
public int ActiveDayGroupIndex { get; private set; } = -1;
public bool IsInitialized => _definition is not null;
public RuntimeWorldEnvironmentSnapshot Snapshot => new(
_revision,
IsInitialized,
ActiveDayGroupIndex,
_activeDayIndex,
Weather.Kind,
Weather.Override);
public void Initialize(RuntimeWorldEnvironmentDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
if (_definition is not null)
{
throw new InvalidOperationException(
"The world environment is a one-shot Runtime lifetime owner.");
}
_definition = definition;
WorldTime.Calendar.SetOriginOffset(definition.OriginOffsetTicks);
WorldTime.TickSize = 1.0;
ActiveDayGroupIndex = -1;
_activeDayIndex = long.MinValue;
_revision++;
if (definition.DayGroups.Count > 0)
{
_log(
$"sky: loaded Region 0x13000000 — {definition.DayGroups.Count} day groups, "
+ $"SkyDesc.TickSize={definition.SourceTickSize} (throttle, not rate), "
+ $"LightTickSize={definition.LightTickSize}");
RefreshDayGroup();
}
// Preserve the accepted retail-port initialization order: select the
// initial group, then seed the offline clock at noon.
WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
_revision++;
}
public void SynchronizeFromServer(double ticks)
{
WorldTime.SyncFromServer(ticks);
_revision++;
if (IsInitialized)
RefreshDayGroup();
}
/// <summary>
/// Port of the state distinction in
/// <c>CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20</c>.
/// Graphical audio playback consumes the returned typed sound cue.
/// </summary>
public RuntimeEnvironmentEffect ApplyAdminEnvirons(uint changeType)
{
if (changeType <= 0x06u)
{
var value = (EnvironOverride)changeType;
Weather.Override = value;
_revision++;
_log($"live: AdminEnvirons fog override = {value}");
return new RuntimeEnvironmentEffect(
RuntimeEnvironmentEffectKind.FogOverride,
changeType,
value);
}
RuntimeEnvironmentSoundCue? cue =
Enum.IsDefined(typeof(RuntimeEnvironmentSoundCue), (int)changeType)
? (RuntimeEnvironmentSoundCue)(int)changeType
: null;
if (cue is null)
{
_log(
$"live: AdminEnvirons sound cue = Unknown(0x{changeType:X2}) "
+ $"(0x{changeType:X2}) — audio binding pending");
return new RuntimeEnvironmentEffect(
RuntimeEnvironmentEffectKind.Unknown,
changeType);
}
_log(
$"live: AdminEnvirons sound cue = {cue}Sound "
+ $"(0x{changeType:X2}) — audio binding pending");
return new RuntimeEnvironmentEffect(
RuntimeEnvironmentEffectKind.SoundCue,
changeType,
SoundCue: cue);
}
/// <summary>
/// Selects the ordered day group with the exact
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> integer/float
/// algorithm.
/// </summary>
public void RefreshDayGroup()
{
RuntimeWorldEnvironmentDefinition? definition = _definition;
if (definition is null)
return;
if (definition.DayGroups.Count == 0)
return;
double ticks = WorldTime.NowTicks;
DerethCalendar calendar = WorldTime.Calendar;
int absoluteYear = calendar.AbsoluteYear(ticks);
int dayOfYear = calendar.DayOfYear(ticks);
int daysPerYear =
DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear;
long dayIndex = (long)absoluteYear * daysPerYear + dayOfYear;
int index = SkyDayGroupSelector.SelectIndex(
definition.DayGroups.Count,
absoluteYear,
daysPerYear,
dayOfYear,
definition.ForcedDayGroupIndex);
if (dayIndex == _activeDayIndex && index == ActiveDayGroupIndex)
return;
_activeDayIndex = dayIndex;
ActiveDayGroupIndex = index;
RuntimeWorldDayGroupDefinition group = definition.DayGroups[index];
WorldTime.SetProvider(group.Sky);
Weather.SetKindFromDayGroupName(group.Name);
_revision++;
_log(
$"sky: PY{absoluteYear} day{dayOfYear} → DayGroup[{index}] \"{group.Name}\" "
+ $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjectCount} objects, "
+ $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})");
}
public string CycleTimeOfDay()
{
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? selection = _timeDebugStep switch
{
0 => null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
_revision++;
if (selection.HasValue)
{
WorldTime.SetDebugTime(selection.Value);
return $"Time override = {selection.Value:F2}";
}
WorldTime.ClearDebugTime();
return "Time override cleared";
}
public string CycleWeather()
{
_weatherDebugStep =
(_weatherDebugStep + 1) % DebugWeatherKinds.Length;
WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
Weather.ForceWeather(kind);
_revision++;
return $"Weather = {kind}";
}
}

View file

@ -236,6 +236,8 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeChatView Chat => null!;
public IRuntimeActionView Actions => null!;
public IRuntimeMovementView Movement => null!;
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;

View file

@ -209,6 +209,8 @@ public sealed class GameplayInputCommandControllerTests
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeActionView Actions => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() =>
throw new NotSupportedException();

View file

@ -169,7 +169,7 @@ public sealed class GameWindowSlice8BoundaryTests
"WorldRenderComposition.cs"));
Assert.Contains(
"private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =",
"private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;",
source,
StringComparison.Ordinal);
Assert.Contains(

View file

@ -23,6 +23,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -783,6 +784,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
MovementState = new RuntimeLocalPlayerMovementState();
Environment = new RuntimeWorldEnvironmentState();
MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
@ -833,6 +835,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication,
Actions,
MovementState,
Environment,
WorldReveal,
Clock,
selectionController);
@ -853,6 +856,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public RuntimeLocalPlayerMovementState MovementState { get; }
public RuntimeWorldEnvironmentState Environment { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; }

View file

@ -338,6 +338,26 @@ public sealed class RuntimeOptionsTests
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);
}
[Fact]
public void DiagnosticFlags_RespectExactValueOne()
{
@ -346,6 +366,7 @@ public sealed class RuntimeOptionsTests
["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",
@ -354,6 +375,7 @@ public sealed class RuntimeOptionsTests
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);

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.World;
using AcDream.Runtime.World;
namespace AcDream.App.Tests.World;
@ -57,25 +58,23 @@ public sealed class WorldEnvironmentControllerTests
}
[Fact]
public void Initialize_WithoutGameTimeRestoresDocumentedFallbackOrigin()
public void Initialize_WithoutGameTimeUsesInstanceFallbackOrigin()
{
double previous = DerethDateTime.OriginOffsetTicks;
try
{
DerethDateTime.SetOriginOffsetFromDat(3600.0);
var controller = CreateController([]);
var first = CreateController([]);
var second = CreateController([]);
controller.Initialize(SingleGroupSky("Sunny", begin: 0f), zeroTimeOfYear: null);
first.Initialize(
SingleGroupSky("Sunny", begin: 0f),
zeroTimeOfYear: 3600.0);
second.Initialize(
SingleGroupSky("Sunny", begin: 0f),
zeroTimeOfYear: null);
Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(
DerethDateTime.DayFractionOriginOffsetTicks,
DerethDateTime.OriginOffsetTicks);
Assert.InRange(controller.DayFraction, 0.499f, 0.501f);
}
finally
{
DerethDateTime.SetOriginOffsetFromDat(previous);
}
second.WorldTime.Calendar.OriginOffsetTicks);
Assert.InRange(second.DayFraction, 0.499f, 0.501f);
}
[Theory]
@ -143,8 +142,8 @@ public sealed class WorldEnvironmentControllerTests
private static WorldEnvironmentController CreateController(List<string> log) =>
new(
new WorldTimeService(SkyStateProvider.Default()),
new WeatherSystem(),
new RuntimeWorldEnvironmentState(log: log.Add),
forcedDayGroupIndex: null,
log.Add);
private static LoadedSkyDesc SingleGroupSky(string name, float begin)
@ -180,8 +179,8 @@ public sealed class WorldEnvironmentControllerTests
line.Contains("→ DayGroup[", StringComparison.Ordinal);
}
[CollectionDefinition(Name, DisableParallelization = true)]
[CollectionDefinition(Name)]
public sealed class WorldEnvironmentControllerCollection
{
public const string Name = "World environment process-global calendar";
public const string Name = "World environment";
}

View file

@ -115,17 +115,10 @@ public sealed class DerethDateTimeTests
// 291,411,660 - 3600 = 291,408,060
// Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow,
// day 24 1-indexed.
DerethDateTime.SetOriginOffsetFromDat(3600.0);
try
{
var cal = DerethDateTime.ToCalendar(291_408_060.0);
var calendar = new DerethCalendar(3600.0);
var cal = calendar.ToCalendar(291_408_060.0);
Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
Assert.Equal(24, cal.Day);
}
finally
{
DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks);
}
}
}

View file

@ -10,6 +10,39 @@ namespace AcDream.Core.Tests.World;
public sealed class SkyDescLoaderTests
{
[Theory]
[InlineData(20, 10, 0, 16)]
[InlineData(20, 10, 83, 5)]
[InlineData(20, 116, 83, 18)]
[InlineData(20, 127, 359, 4)]
public void DayGroupSelector_MatchesNamedRetailGoldenValues(
int count,
int absoluteYear,
int dayOfYear,
int expected)
{
Assert.Equal(
expected,
SkyDayGroupSelector.SelectIndex(
count,
absoluteYear,
daysPerYear: 360,
dayOfYear));
}
[Fact]
public void DayGroupSelector_ForcedIndexIsExplicitInput()
{
Assert.Equal(
7,
SkyDayGroupSelector.SelectIndex(
20,
absoluteYear: 10,
daysPerYear: 360,
dayOfYear: 0,
forcedIndex: 7));
}
/// <summary>
/// Hand-build a Region with a minimal sky descriptor to feed the
/// loader without needing real dat bytes. The LoadFromRegion

View file

@ -29,10 +29,11 @@ public sealed class WorldTimeDebugTests
// fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D
// → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D.
double targetFraction = 1.0 / 16.0; // Darktide-and-Half
double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks;
var service = new WorldTimeService(SkyStateProvider.Default());
double syncTick = targetFraction * DerethDateTime.DayTicks
- service.Calendar.OriginOffsetTicks;
while (syncTick < 0) syncTick += DerethDateTime.DayTicks;
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(syncTick);
service.SetDebugTime(0.5f);
service.ClearDebugTime();

View file

@ -182,6 +182,7 @@ public sealed class GameRuntimeContractTests
InteractionSourceObjectId: 0u,
InteractionTransactions: default),
default,
default,
default);
recorder.AddCheckpoint(stamp, checkpoint);

View file

@ -0,0 +1,196 @@
using System.Numerics;
using AcDream.Core.World;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests.World;
public sealed class RuntimeWorldEnvironmentStateTests
{
[Fact]
public void Initialize_OwnsRetailDayGroupAndOfflineNoon()
{
List<string> log = [];
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider(),
log.Add);
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
Assert.True(state.IsInitialized);
Assert.Equal(0, state.ActiveDayGroupIndex);
Assert.Equal(WeatherKind.Clear, state.Weather.Kind);
Assert.InRange(state.WorldTime.DayFraction, 0.499, 0.501);
Assert.Contains(
log,
value => value.Contains(
"SkyDesc.TickSize=0.8",
StringComparison.Ordinal));
}
[Fact]
public void TwoInstances_IsolateCalendarClockWeatherAndDebugState()
{
var time = new ManualTimeProvider();
var first = new RuntimeWorldEnvironmentState(time);
var second = new RuntimeWorldEnvironmentState(time);
first.Initialize(Definition(3600.0, ("Sunny", Sky(0f))));
second.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Cloudy", Sky(0f))));
first.SynchronizeFromServer(0.0);
second.SynchronizeFromServer(0.0);
_ = first.ApplyAdminEnvirons(0x01u);
_ = first.CycleTimeOfDay();
Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(
DerethDateTime.DayFractionOriginOffsetTicks,
second.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(0f, first.WorldTime.DayFraction);
Assert.InRange(
second.WorldTime.DayFraction,
7.0 / 16.0 - 0.001,
7.0 / 16.0 + 0.001);
Assert.Equal(EnvironOverride.RedFog, first.Weather.Override);
Assert.Equal(EnvironOverride.None, second.Weather.Override);
Assert.Equal(WeatherKind.Clear, first.Weather.Kind);
Assert.Equal(WeatherKind.Overcast, second.Weather.Kind);
}
[Fact]
public void SynchronizeFromServer_ClearsDebugOverrideAndAdvancesClock()
{
var time = new ManualTimeProvider();
var state = new RuntimeWorldEnvironmentState(time);
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
_ = state.CycleTimeOfDay();
state.SynchronizeFromServer(0.0);
time.Advance(TimeSpan.FromSeconds(12));
Assert.Equal(12.0, state.WorldTime.NowTicks, 5);
Assert.NotEqual(0.0, state.WorldTime.DayFraction);
}
[Theory]
[InlineData(0x00u, EnvironOverride.None)]
[InlineData(0x01u, EnvironOverride.RedFog)]
[InlineData(0x06u, EnvironOverride.BlackFog2)]
public void ApplyAdminEnvirons_FogIsCanonicalRuntimeState(
uint raw,
EnvironOverride expected)
{
var state = Initialized();
RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(raw);
Assert.Equal(RuntimeEnvironmentEffectKind.FogOverride, effect.Kind);
Assert.Equal(expected, effect.FogOverride);
Assert.Equal(expected, state.Snapshot.EnvironOverride);
}
[Fact]
public void ApplyAdminEnvirons_SoundDoesNotMutateFogState()
{
var state = Initialized();
_ = state.ApplyAdminEnvirons(0x04u);
RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(0x78u);
Assert.Equal(RuntimeEnvironmentEffectKind.SoundCue, effect.Kind);
Assert.Equal(RuntimeEnvironmentSoundCue.Thunder3, effect.SoundCue);
Assert.Equal(EnvironOverride.GreenFog, state.Weather.Override);
}
[Fact]
public void ForcedDayGroup_IsDefinitionScoped()
{
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider());
state.Initialize(new RuntimeWorldEnvironmentDefinition(
DerethDateTime.DayFractionOriginOffsetTicks,
sourceTickSize: 0.8,
lightTickSize: 0.2,
[
Group("Sunny", Sky(0f)),
Group("Cloudy", Sky(0f)),
],
forcedDayGroupIndex: 1));
Assert.Equal(1, state.ActiveDayGroupIndex);
Assert.Equal(WeatherKind.Overcast, state.Weather.Kind);
}
[Fact]
public void Initialize_IsOneShot()
{
var state = Initialized();
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(
() => state.Initialize(Definition(
3600.0,
("Cloudy", Sky(0f)))));
Assert.Contains("one-shot", error.Message, StringComparison.Ordinal);
Assert.Equal(0, state.ActiveDayGroupIndex);
}
private static RuntimeWorldEnvironmentState Initialized()
{
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider());
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
return state;
}
private static RuntimeWorldEnvironmentDefinition Definition(
double origin,
params (string Name, SkyStateProvider Sky)[] groups) =>
new(
origin,
sourceTickSize: 0.8,
lightTickSize: 0.2,
groups.Select(value => Group(value.Name, value.Sky)));
private static RuntimeWorldDayGroupDefinition Group(
string name,
SkyStateProvider sky) =>
new(
name,
ChanceOfOccur: 100f,
SkyObjectCount: 1,
sky);
private static SkyStateProvider Sky(float begin) =>
new(
[
new SkyKeyframe(
Begin: begin,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.Zero,
FogDensity: 0f),
]);
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now =
new(2026, 7, 26, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan elapsed) => _now += elapsed;
}
}