diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ceb3c5a..44359db 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1084,6 +1084,14 @@ public sealed class GameWindow : IDisposable
// flashes during stormy weather.
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
+ // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
+ // cues. Fog types (0x00..0x06) set WeatherSystem.Override;
+ // sound types (0x65..0x7B) play a one-shot audio cue.
+ // Lightning flashes arrive as a PAIRED PlayScript (the
+ // visual) + AdminEnvirons ThunderXSound (the audio) — both
+ // are handled here and in OnPlayScriptReceived respectively.
+ _liveSession.EnvironChanged += OnEnvironChanged;
+
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
@@ -2274,6 +2282,61 @@ public sealed class GameWindow : IDisposable
_scriptRunner.Play(scriptId, guid, camWorldPos);
}
+ ///
+ /// Phase 5d — retail AdminEnvirons (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
+ /// EnvironChangeType → wave asset, which we don't yet
+ /// have dat-indexed; follow-up will wire the thunder wave ids.
+ ///
+ 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");
+ }
+
///
/// Phase A.1: streaming load delegate, runs on the worker thread.
/// Reads the landblock from the dats, hydrates its stab entities (same
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 1a9fd87..9e85456 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -132,6 +132,33 @@ public sealed class WorldSession : IDisposable
///
public event Action? PlayScriptReceived;
+ ///
+ /// Phase 5d — retail's AdminEnvirons packet (opcode
+ /// 0xEA60) — the one-and-only channel retail's server uses
+ /// for weather environment changes. Wire format:
+ /// [u32 opcode][u32 environChangeType]. The payload enum is
+ /// retail's EnvironChangeType:
+ ///
+ /// -
+ /// 0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/
+ /// Black/Black2). Subscribers route these to a
+ /// .
+ ///
+ /// -
+ /// 0x65..0x75 — one-shot ambient sound cues
+ /// (Roar / Bell / Chant / etc).
+ ///
+ /// -
+ /// 0x76..0x7B — Thunder1..Thunder6 sounds. Paired with
+ /// a separate from the server
+ /// carrying the lightning-flash PhysicsScript.
+ ///
+ ///
+ /// See docs/research/2026-04-23-lightning-crossfade.md +
+ /// 2026-04-23-lightning-real.md.
+ ///
+ public event Action? EnvironChanged;
+
///
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
@@ -571,6 +598,19 @@ public sealed class WorldSession : IDisposable
var env = GameEventEnvelope.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value);
}
+ else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
+ {
+ // Phase 5d: wire format `[u32 opcode][u32 environChangeType]`
+ // per chunk_006A0000.c. Dispatch the event; GameWindow
+ // subscribers route fog presets into WeatherSystem.Override
+ // and sound cues (thunder, roar, etc) into the audio engine.
+ if (body.Length >= 8)
+ {
+ uint envType = System.Buffers.Binary.BinaryPrimitives
+ .ReadUInt32LittleEndian(body.AsSpan(4, 4));
+ EnvironChanged?.Invoke(envType);
+ }
+ }
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
{
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`