refactor(runtime): own world environment state
This commit is contained in:
parent
b972f539f7
commit
902076c0a4
27 changed files with 886 additions and 295 deletions
|
|
@ -879,6 +879,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Communication,
|
||||
d.Actions,
|
||||
d.PlayerController,
|
||||
d.WorldEnvironment.Runtime,
|
||||
worldReveal,
|
||||
d.UpdateClock,
|
||||
live.SelectionInteractions);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
103
src/AcDream.Core/World/DerethCalendar.cs
Normal file
103
src/AcDream.Core/World/DerethCalendar.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
40
src/AcDream.Core/World/SkyDayGroupSelector.cs
Normal file
40
src/AcDream.Core/World/SkyDayGroupSelector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
313
src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs
Normal file
313
src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs
Normal 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}";
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue