refactor(world): own live environment state
Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
557eb7ef6b
commit
d09e246d3a
19 changed files with 606 additions and 368 deletions
|
|
@ -101,7 +101,8 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
||||
private AcDream.App.Rendering.RenderFrameOrchestrator?
|
||||
_renderFrameOrchestrator;
|
||||
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
|
||||
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
|
||||
new(Console.WriteLine);
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private readonly DisplayFramePacingController _displayFramePacing;
|
||||
|
||||
|
|
@ -380,13 +381,12 @@ public sealed class GameWindow : IDisposable
|
|||
// DevToolsEnabled reads through typed RuntimeOptions.
|
||||
private bool DevToolsEnabled => _options.DevTools;
|
||||
|
||||
// Phase G.1-G.2 world lighting/time state.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||
new AcDream.Core.World.WorldTimeService(
|
||||
AcDream.Core.World.SkyStateProvider.Default());
|
||||
// Phase G.1-G.2 world lighting/time state. The environment owner keeps
|
||||
// the clock, selected day group, and weather transitions coherent.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime;
|
||||
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
|
||||
|
||||
public readonly AcDream.Core.World.WeatherSystem Weather = new();
|
||||
public readonly AcDream.Core.World.WeatherSystem Weather;
|
||||
// Wired into the hook router in OnLoad so SetLightHook fires
|
||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
|
|
@ -407,22 +407,6 @@ public sealed class GameWindow : IDisposable
|
|||
// sun / ambient / fog / flash data per frame.
|
||||
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
|
||||
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
||||
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
|
||||
|
||||
// Phase 3a — retail-faithful per-Dereth-day weather roll. The active
|
||||
// DayGroup is re-picked deterministically whenever the server clock
|
||||
// crosses a DayTicks boundary. <c>long.MinValue</c> sentinel means
|
||||
// "no day rolled yet" so the first RefreshSkyForCurrentDay call
|
||||
// unconditionally installs a provider. See r12 §11 for the roller
|
||||
// semantics.
|
||||
private long _loadedSkyDayIndex = long.MinValue;
|
||||
|
||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
||||
// range of the real values so the first press hits index 0 of the
|
||||
// cycle table cleanly.
|
||||
private int _timeDebugStep = 0;
|
||||
private int _weatherDebugStep = 0;
|
||||
|
||||
// Phase B.2: player movement mode.
|
||||
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
|
||||
private AcDream.App.Input.PlayerMovementController? _playerController
|
||||
|
|
@ -587,6 +571,8 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
WorldTime = _worldEnvironment.WorldTime;
|
||||
Weather = _worldEnvironment.Weather;
|
||||
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
|
||||
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
|
||||
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
|
||||
|
|
@ -685,8 +671,6 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLoad()
|
||||
{
|
||||
_worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState(
|
||||
WorldTime);
|
||||
// Task 7: wire the physics data cache into the engine so Transition can
|
||||
// run narrow-phase BSP tests during FindObjCollisions.
|
||||
_physicsEngine.DataCache = _physicsDataCache;
|
||||
|
|
@ -1276,79 +1260,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (heightTable is null || heightTable.Length < 256)
|
||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||
|
||||
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
|
||||
// celestial mesh layers) and swap WorldTime's provider over to the
|
||||
// dat-backed keyframes. The stub default provider is only used if
|
||||
// the Region lacks HasSkyInfo.
|
||||
if (region is not null)
|
||||
{
|
||||
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
|
||||
if (_loadedSkyDesc is not null)
|
||||
{
|
||||
// Phase 3d: do NOT assign WorldTime.TickSize from
|
||||
// SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241
|
||||
// FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick
|
||||
// deadline" period — a throttle — NOT a game-time
|
||||
// advancement rate. ACE's server advances PortalYearTicks at
|
||||
// 1.0 ticks per real-second (Timers.cs: `PortalYearTicks +=
|
||||
// worldTickTimer.Elapsed.TotalSeconds`). Our client
|
||||
// extrapolation between TimeSyncs must match: 1.0.
|
||||
//
|
||||
// Previous behavior: WorldTime.TickSize = 0.8 (from the live
|
||||
// SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4
|
||||
// ticks behind the server, producing a visible "acdream sky
|
||||
// is behind retail" time-of-day mismatch (user-verified
|
||||
// 2026-04-23).
|
||||
WorldTime.TickSize = 1.0;
|
||||
|
||||
// Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
|
||||
// calendar-extraction offset. Dereth's dat value is 3600
|
||||
// (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
|
||||
// comment that "tick 0 = Morntide-and-Half" (3333.75
|
||||
// offset = +7/16) is WRONG by 266.25 ticks against the
|
||||
// authoritative dat. The mismatch cascaded into both the
|
||||
// wrong hour label AND the wrong DayOfYear at boundary
|
||||
// times (different LCG seed → different DayGroup roll),
|
||||
// which explained the user's observation of "acdream
|
||||
// clear night, retail stormy pre-dawn" at the same
|
||||
// server PortalYearTicks.
|
||||
if (region.GameTime is not null)
|
||||
{
|
||||
AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
|
||||
region.GameTime.ZeroTimeOfYear);
|
||||
Console.WriteLine(
|
||||
$"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
|
||||
$"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
|
||||
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
|
||||
|
||||
// Initial DayGroup roll using whatever WorldTime currently
|
||||
// has (either the hardcoded boot seed or a pre-arrived
|
||||
// server sync). RefreshSkyForCurrentDay will re-roll when
|
||||
// ServerTimeUpdated delivers the real ConnectRequest tick.
|
||||
RefreshSkyForCurrentDay();
|
||||
}
|
||||
}
|
||||
|
||||
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
|
||||
// the server sends its first TimeSync packet (offline rendering in
|
||||
// particular never receives one).
|
||||
//
|
||||
// "Noon" here means sun at zenith — dayFraction = 0.5. Because
|
||||
// DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half,
|
||||
// hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 =
|
||||
// Midsong / Hour 9, which is what retail considers noon).
|
||||
//
|
||||
// Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were
|
||||
// zero, but with our 3333.75-tick shift it lands on dayFraction
|
||||
// 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight),
|
||||
// producing a dim orange sky with the sun below the horizon until
|
||||
// TimeSync arrives.
|
||||
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
|
||||
// Parse and install the DAT-backed sky, retail day-group state, and
|
||||
// offline clock seed through their single environment owner.
|
||||
_worldEnvironment.Initialize(region!);
|
||||
|
||||
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
|
||||
// building the terrain atlas / renderer — both consume BindlessSupport
|
||||
|
|
@ -2872,7 +2786,7 @@ public sealed class GameWindow : IDisposable
|
|||
var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
|
||||
renderFrameResources,
|
||||
renderLoginState,
|
||||
_worldSceneSkyState!,
|
||||
_worldEnvironment,
|
||||
worldRenderFrameBuilder,
|
||||
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
|
||||
_retailSelectionScene,
|
||||
|
|
@ -3120,12 +3034,8 @@ public sealed class GameWindow : IDisposable
|
|||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
_worldEnvironment.ApplyAdminEnvirons,
|
||||
_worldEnvironment.SynchronizeFromServer),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
|
|
@ -3251,60 +3161,6 @@ public sealed class GameWindow : IDisposable
|
|||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
/// <summary>
|
||||
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
|
||||
/// Routes fog presets into the weather system's sticky override
|
||||
/// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc)
|
||||
/// for now — actual sound playback needs a lookup table from
|
||||
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
|
||||
/// have dat-indexed; follow-up will wire the thunder wave ids.
|
||||
/// </summary>
|
||||
private void OnEnvironChanged(uint environChangeType)
|
||||
{
|
||||
// Fog presets — values match AcDream.Core.World.EnvironOverride
|
||||
// byte-for-byte (we deliberately mirrored retail's enum).
|
||||
if (environChangeType <= 0x06u)
|
||||
{
|
||||
Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType;
|
||||
Console.WriteLine(
|
||||
$"live: AdminEnvirons fog override = " +
|
||||
$"{(AcDream.Core.World.EnvironOverride)environChangeType}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sound cues 0x65..0x7B. Log by retail name for now; audio
|
||||
// binding is a separate follow-up (needs sound-table indexing
|
||||
// plus a PlaySound API on OpenAlAudioEngine that takes a
|
||||
// retail sound enum → wave-id mapping).
|
||||
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})",
|
||||
};
|
||||
Console.WriteLine(
|
||||
$"live: AdminEnvirons sound cue = {name} " +
|
||||
$"(0x{environChangeType:X2}) — audio binding pending");
|
||||
}
|
||||
private void OnUpdate(double dt)
|
||||
{
|
||||
using var _updStage = _frameProfiler.BeginStage(
|
||||
|
|
@ -3413,92 +3269,6 @@ public sealed class GameWindow : IDisposable
|
|||
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
|
||||
// always played (w6-cutover-map.md R3).
|
||||
|
||||
/// <summary>
|
||||
/// Phase 3a — re-roll the active DayGroup whenever the current
|
||||
/// Dereth-day index differs from what we last installed. Idempotent
|
||||
/// within the same server-day. Swaps both the
|
||||
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
|
||||
/// <see cref="WorldTime"/> (for lighting interp) and the cached
|
||||
/// <see cref="WorldSceneSkyState.ActiveDayGroup"/> (for the sky-object
|
||||
/// render loop).
|
||||
///
|
||||
/// <para>
|
||||
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
|
||||
/// group N regardless of day index. Useful for A/B testing each
|
||||
/// weather preset against retail. See
|
||||
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
|
||||
/// for the roller.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void RefreshSkyForCurrentDay()
|
||||
{
|
||||
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
|
||||
return;
|
||||
|
||||
// Retail FUN_00501990 seeds the LCG with the triple stored in
|
||||
// TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear)
|
||||
//
|
||||
// The decompile agent labeled +0x10 "SecondsPerDay (int copy)"
|
||||
// but a live memory probe of retail's acclient.exe (2026-04-23,
|
||||
// tools/RetailTimeProbe) shows the value is actually **360** —
|
||||
// semantically DaysPerYear, not seconds. So the LCG seed is
|
||||
// seed = Year × DaysPerYear + DayOfYear
|
||||
// which is literally "total days since epoch" (a flat day index),
|
||||
// confirmed against retail's Year=116, DayOfYear=47, seed=41807.
|
||||
//
|
||||
// Previously we passed 7620 (DayTicks), producing seed 883967 —
|
||||
// a completely different LCG output → wrong DayGroup pick →
|
||||
// user-observed weather mismatch (acdream clear while retail
|
||||
// stormy, 2026-04-23). The live probe nailed the fix.
|
||||
double ticks = WorldTime.NowTicks;
|
||||
int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks);
|
||||
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
|
||||
int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth
|
||||
* AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360
|
||||
|
||||
// Composite day key for change-detection and logging only; the
|
||||
// LCG seed is computed inside SelectDayGroupIndex from (absYear,
|
||||
// secondsPerDay, dayOfYear).
|
||||
long dayIndex = (long)absYear * 360 + dayOfYear;
|
||||
|
||||
int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
|
||||
var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
|
||||
? _loadedSkyDesc.DayGroups[idx]
|
||||
: null;
|
||||
|
||||
bool dayChanged = dayIndex != _loadedSkyDayIndex;
|
||||
bool groupChanged = !ReferenceEquals(
|
||||
grp,
|
||||
_worldSceneSkyState?.ActiveDayGroup);
|
||||
|
||||
if (!dayChanged && !groupChanged) return;
|
||||
|
||||
_loadedSkyDayIndex = dayIndex;
|
||||
(_worldSceneSkyState
|
||||
??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime))
|
||||
.ActiveDayGroup = grp;
|
||||
|
||||
if (grp is not null && grp.SkyTimes.Count > 0)
|
||||
{
|
||||
WorldTime.SetProvider(
|
||||
new AcDream.Core.World.SkyStateProvider(
|
||||
grp.SkyTimes.Select(s => s.Keyframe).ToList()));
|
||||
|
||||
// Phase 3e: drive the atmospheric weather (rain/snow emitters,
|
||||
// fog-override categories, lightning strobe) from the retail
|
||||
// DayGroup name. Stops the legacy WeatherSystem.RollKind hash
|
||||
// from spawning rain particles on a "Sunny" day (user-observed
|
||||
// rain regression 2026-04-23 after the retail picker landed on
|
||||
// DayGroup[6] "Sunny" but the internal hash picked Rain).
|
||||
Weather.SetKindFromDayGroupName(grp.Name);
|
||||
|
||||
Console.WriteLine(
|
||||
$"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " +
|
||||
$"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " +
|
||||
$"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
||||
//
|
||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
||||
|
|
@ -3569,27 +3339,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void CycleTimeOfDay()
|
||||
{
|
||||
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none
|
||||
_timeDebugStep = (_timeDebugStep + 1) % 5;
|
||||
float? pick = _timeDebugStep switch
|
||||
{
|
||||
0 => (float?)null,
|
||||
1 => 0.0f,
|
||||
2 => 0.25f,
|
||||
3 => 0.5f,
|
||||
4 => 0.75f,
|
||||
_ => null,
|
||||
};
|
||||
if (pick.HasValue)
|
||||
{
|
||||
WorldTime.SetDebugTime(pick.Value);
|
||||
_debugVm?.AddToast($"Time override = {pick.Value:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
WorldTime.ClearDebugTime();
|
||||
_debugVm?.AddToast("Time override cleared");
|
||||
}
|
||||
string message = _worldEnvironment.CycleTimeOfDay();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -3597,17 +3348,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void CycleWeather()
|
||||
{
|
||||
var kinds = new[]
|
||||
{
|
||||
AcDream.Core.World.WeatherKind.Clear,
|
||||
AcDream.Core.World.WeatherKind.Overcast,
|
||||
AcDream.Core.World.WeatherKind.Rain,
|
||||
AcDream.Core.World.WeatherKind.Snow,
|
||||
AcDream.Core.World.WeatherKind.Storm,
|
||||
};
|
||||
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
|
||||
Weather.ForceWeather(kinds[_weatherDebugStep]);
|
||||
_debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}");
|
||||
string message = _worldEnvironment.CycleWeather();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4380,7 +4122,6 @@ public sealed class GameWindow : IDisposable
|
|||
new("world frame composition", () =>
|
||||
{
|
||||
_renderFrameOrchestrator = null;
|
||||
_worldSceneSkyState = null;
|
||||
}),
|
||||
]),
|
||||
new ResourceShutdownStage("session dependents",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue