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

@ -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}";
}
}