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

@ -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)
if (zeroTimeOfYear.HasValue)
{
// 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})");
}
_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();
$"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} "
+ $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
}
WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
RuntimeWorldDayGroupDefinition[] groups =
loadedSkyDesc?.DayGroups.Select(
group => new RuntimeWorldDayGroupDefinition(
group.Name,
group.ChanceOfOccur,
group.SkyObjects.Count,
new SkyStateProvider(
group.SkyTimes.Select(value => value.Keyframe).ToList())))
.ToArray()
?? [];
var definition = new RuntimeWorldEnvironmentDefinition(
origin,
loadedSkyDesc?.TickSize ?? 1.0,
loadedSkyDesc?.LightTickSize ?? 1.0,
groups,
_forcedDayGroupIndex);
Runtime.Initialize(definition);
_loadedSkyDesc = loadedSkyDesc;
}
public void SynchronizeFromServer(double ticks)
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}
public void SynchronizeFromServer(double ticks) =>
Runtime.SynchronizeFromServer(ticks);
/// <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;
}
public void ApplyAdminEnvirons(uint environChangeType) =>
_ = Runtime.ApplyAdminEnvirons(environChangeType);
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})",
};
public void RefreshSkyForCurrentDay() => Runtime.RefreshDayGroup();
_log(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
public string CycleTimeOfDay() => Runtime.CycleTimeOfDay();
/// <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(
new SkyStateProvider(
group.SkyTimes.Select(s => s.Keyframe).ToList()));
Weather.SetKindFromDayGroupName(group.Name);
_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})");
}
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,
};
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);
return $"Weather = {kind}";
}
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}";
}
}