# R12 — Weather System + Day/Night Cycle **Deep-dive research for the acdream C# .NET 10 AC client.** **Goal:** Map retail AC's sky, time-of-day, weather, and atmospheric effects so we can port them faithfully. Primary oracles: `DatReaderWriter` (MIT-licensed dat schema generated from the binary format), `WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` (the one C# reference that actually renders a skybox from real region data on our exact Silk.NET stack), ACE's `DerethDateTime` and `Timers` (server-side Portal Year arithmetic), and ACE's `EnvironChangeType` (the one opcode the server uses to poke at the client's sky). The single biggest discovery in this research is a negative result: **retail AC weather is almost entirely client-side and dat-driven**, not server-synced. There is no general weather opcode. The server can only force a colored fog or play an ambient sound via `AdminEnvirons` (0xEA60). Everything else — the sky gradient, sun position, stars, moons, fog density, cloud/star layer animations — is computed locally from one shared Region record (`0x13000000` "Dereth") and a deterministic time-of-day derived from server-authoritative wall-clock. ## 1. Turbine world time (Portal Years and the 16-hour Derethian day) ### 1.1 The canonical source Retail AC's calendar is defined in the `Region` dat object's `GameTime` struct. See `references/DatReaderWriter/DatReaderWriter/Generated/Types/GameTime.generated.cs`: ```csharp public partial class GameTime : IDatObjType { public double ZeroTimeOfYear; // starting point in in-game ticks public uint ZeroYear; // first P.Y. represented (10) public float DayLength; // ticks per Derethian day = 7620 public uint DaysPerYear; // 360 (12 months of 30 days) public AC1LegacyPStringBase YearSpec = new(); // "P.Y." public List TimesOfDay = []; // slice table: Begin, IsNight, Name public List> DaysOfWeek = []; // 6-day week public List Seasons = []; // 4 seasons } ``` The Region file also carries a `TickSize` and `LightTickSize` on the `SkyDesc` itself (`SkyDesc.generated.cs` lines 24–26), so the calendar rate and the lighting animation rate are independent. ### 1.2 The calendar constants ACE's `DerethDateTime.cs` lines 11–33 pin down exactly what the dats mean. **These values are retail-faithful — changing them breaks the calendar panel in the retail client**: | Constant | Value | Note | |----------|-------|------| | hoursInADay | 16 | Darktide → Gloaming-and-Half | | daysInAMonth | 30 | uniform, unlike Earth months | | monthsInAYear | 12 | Snowreap..Frostfell | | dayTicks | 7620 | Ticks per Derethian day (matches `GameTime.DayLength`) | | hourTicks | 7620/16 = 476.25 | | | monthTicks | 228 600 | = dayTicks × 30 | | yearTicks | 2 743 200 | = monthTicks × 12 | | MaxValue | ≈ 1 073 741 828 | PY 401 Thistledown 2 Morntide-and-Half. Above this the **acclient crashes on connect.** | The 16 hours use named slots with "Darktide, Darktide-and-Half, Foredawn, Foredawn-and-Half, Dawnsong, Dawnsong-and-Half, Morntide, Morntide-and-Half, Midsong, Midsong-and-Half, Warmtide, Warmtide-and-Half, Evensong, Evensong-and-Half, Gloaming, Gloaming-and-Half" (DerethDateTime.cs:99–117). Day is Dawnsong through Warmtide-and-Half (hours 5–12); night is everything else. ### 1.3 Wire format: time as a double, delivered at login ACE transports the current in-game tick count inside `PacketOutboundConnectRequest` during the handshake. See `references/ACE/Source/ACE.Server/Network/Managers/NetworkManager.cs:181`: ```csharp var connectRequest = new PacketOutboundConnectRequest( Timers.PortalYearTicks, // <-- current game tick count (double, seconds) session.Network.ConnectionData.ConnectionCookie, ... ); ``` and `references/ACE/Source/ACE.Server/Entity/Timers.cs:47`: ```csharp public static double PortalYearTicks { get; internal set; } = Timers.WorldStartLoreTime.Ticks; // initial sync to real-world calendar // advanced each tick of UpdateWorld(): Timers.PortalYearTicks += worldTickTimer.Elapsed.TotalSeconds; ``` So **the "ticks" the server advances are simply seconds of wall-clock time, starting from a seed value chosen at server boot**. The two standard seeds are: - `UtcNowToLoreTime` — lore-accurate (maps each real calendar year to a retail PY via a lookup table in `DerethDateTime.ConvertFrom_RealWorld_to_Derethian_PY`). - `UtcNowToEMUTime` — offset from the literal last day of retail (Jan 31 2017), so the emulated world appears to keep ticking forward from where retail left off. - `UtcNowToGDLETime` — GDLE's offset (epoch 1999-09-01). The client receives this double-precision tick count in the connect response and every subsequent `TimeSync` packet (NetworkSession.cs:938), so **the client knows the server's absolute PY time at millisecond precision**. Everything else — sun position, sky gradient, time-of-day label — is then computed locally from `ticks mod dayTicks` and the region's `GameTime`. ### 1.4 Local time-of-day derivation Given `ticks` from the server: ``` dayFraction = (ticks mod 7620) / 7620 // 0..1 through one Derethian day hourFloat = dayFraction × 16 // 0..16 over the 16 named hours hourName = Hours.Darktide + (int)(round(hourFloat × 4) / 4) ``` WorldBuilder's `SkyboxRenderManager.TimeOfDay` is a float in [0, 1) with this exact semantic: 0 is midnight/Darktide, 0.5 is Midsong-and-Half, and `SkyTimeOfDay.Begin` values are normalized to that range. The SkyDesc has `TickSize` in real seconds per in-game tick (usually 1.0 for 1:1, but the dats let Turbine tune this), so: ``` clientDayFraction = ((serverPortalYearTicks − zeroTimeOfYear) / dayLength) mod 1 ``` is the number to feed into `SkyboxRenderManager.TimeOfDay`. ## 2. Sky dome geometry: it's not a dome, it's sky **objects** This is the deepest misconception to avoid. Retail AC does NOT have a textured sphere or cube as the sky. The "sky" is a **collection of `GfxObj` meshes** placed at huge distance, rendered with depth mask off, each representing one celestial layer: the gradient background itself, cloud sheets, the sun, the moon(s), the stars. Look at `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObject.generated.cs`: ```csharp public partial class SkyObject : IDatObjType { public float BeginTime; // [0,1] when this object becomes visible public float EndTime; // [0,1] when it disappears (wraps if End DefaultGfxObjectId; // the mesh public QualifiedDataId DefaultPesObjectId; // particle/emitter (unused at top) public uint Properties; // flag bits (billboard? follow-camera?) } ``` A sky object has: - **A visibility window** in normalized day-time [0,1]. If Begin==End it's always visible (the sky gradient backdrop). If BeginEnd it wraps at midnight — the nighttime star layer. - **A starting and ending angle**. At BeginTime the object is at BeginAngle degrees, at EndTime it's at EndAngle. Linear interpolation in between. This is how the sun and moons trace arcs across the sky. - **Texture-space scroll velocity.** Clouds drift, stars shimmer. These are applied to the texture coordinates each frame — the mesh doesn't physically move. - **A GfxObj mesh.** Usually a large semi-hemispherical patch (for a cloud sheet), a small billboard quad (for the sun/moon), or a wrap-around hemisphere (for the star layer). ### 2.1 WorldBuilder's rendering approach (our template) `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` is the canonical reference C# implementation. Key mechanics (lines 115–274): 1. **Separate sky-only projection matrix** with a near plane of 0.1 and a far plane of 1 000 000, so the celestial objects never clip. The regular scene projection stops at a few thousand meters; the sky punches through with its own oversized far plane. 2. **View matrix with translation zeroed out.** `M41 = M42 = M43 = 0` (lines 137–141). The sky is always drawn centered at the camera origin, so the sun stays at "infinity" — moving the camera never gets you any closer. 3. **Depth mask off, depth test off, cull face off, fully unlit.** Drawn before the rest of the scene to fill the background; scene objects overwrite it naturally because they pass depth testing. 4. **Object-by-object loop.** For each `SkyObject i`: - Visibility check using the Begin/End rules above. - Look up `SkyTimeOfDay.SkyObjReplace[i]` — a per-time-slice override that can swap the GfxObj or flip its heading. - Rotation computation: - `headingDeg` — a Z-axis spin (matches AC's set_heading convention where Z is up and heading rotates in the XY plane). - `rotationDeg` — the arc position (0→360 over a day for the sun). - Final transform: ```csharp transform = Matrix4x4.CreateScale(1.0f) * Matrix4x4.CreateRotationZ(-headingRad) * Matrix4x4.CreateRotationY(-rotationRad); ``` The Z rotation is the local "pitch" of the cloud/sun on the sky dome, and the Y rotation is the global arc sweep across the sky. Negative signs are because AC's coordinate system is Z-up, right-handed, with heading measured clockwise from north — the Y-axis rotation needs to go in the opposite direction from a standard right-handed rotation. ### 2.2 Why not a textured sphere? The retail approach is flexible in ways a single textured cube/sphere can't match: each layer has its own mesh, its own texture scroll velocity, and its own visibility window. Stars are a separate mesh drawn only at night. The moon is a third mesh whose shader sample follows a texture scroll that rotates the phase. A single skybox texture would couple all of those. **Easy mistake to avoid (ultrathinking the gradient direction):** the sky gradient is the mesh texture itself on the background sky object, **not** a vertex color gradient or a shader-synthesized ramp. If you render it on a sphere with the wrong UVs, you'll get horizon-at-top. The retail sky meshes have correct UVs baked in; our job is just to render the GfxObj with the sampler + scroll velocity uniforms applied. Specifically the gradient "top of sky" → "horizon" is encoded in the texture V coordinate, and V=0 is at the **top** of the texture (AC uses the D3D convention where V grows downward). Miss this and you'll render the sunset band at the zenith. ### 2.3 How many sky objects typically exist? WorldBuilder iterates `dayGroup.SkyObjects` — retail Dereth's primary day group contains roughly 4–6 sky objects (one background, one cloud sheet, one sun, one moon, one star sheet). The per-time-slice `SkyObjReplace` lets the cloud mesh be swapped out for a stormier version at dusk, or the sun's intensity (via `Transparent`) be dimmed during dawn/sunset. ```csharp public partial class SkyObjectReplace : IDatObjType { public uint ObjectIndex; // which SkyObject to override public QualifiedDataId GfxObjId; // swap mesh public float Rotate; // override heading public float Transparent; // 0..1 fade public float Luminosity; // emission strength public float MaxBright; // cap } ``` ## 3. Sun direction The Region's `SkyTimeOfDay` stores the light direction directly — this is the **world-space directional light** that illuminates everything outdoors: ```csharp public float Begin; // 0..1 day-fraction this keyframe takes effect public float DirBright; // directional light intensity 0..N public float DirHeading; // degrees; azimuth of the sun around Z (compass) public float DirPitch; // degrees; elevation angle above horizon public ColorARGB DirColor; // BGRA order (B,G,R,A) per byte layout ``` Conversion to a Silk.NET `Vector3` light direction (Z-up, pointing FROM the sun TOWARD the world): ```csharp float headingRad = DirHeading * MathF.PI / 180f; float pitchRad = DirPitch * MathF.PI / 180f; // Sun vector in AC's Z-up, right-handed, heading-from-north frame. // Heading 0 = north, 90 = east. Pitch 0 = horizon, 90 = zenith. var sunDir = new Vector3( MathF.Sin(headingRad) * MathF.Cos(pitchRad), MathF.Cos(headingRad) * MathF.Cos(pitchRad), MathF.Sin(pitchRad) ); // Shaders want light direction (from sun to world), so negate: var lightDirection = -sunDir; ``` Between keyframes, linearly interpolate both the direction (as two floats, not as a quaternion — AC's `DirHeading`/`DirPitch` are independently lerped) and `DirColor` (byte-wise). **This is exactly what `terrain.vert:98` already uses:** ```glsl vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection))); ``` We currently hard-code `(0.5, 0.3, -0.3)` (the ACME constant); the port replaces that uniform upload with the interpolated keyframe value. ## 4. Ambient + diffuse color, per time, per weather Each `SkyTimeOfDay` keyframe also stores: ```csharp public float AmbBright; // ambient intensity 0..N public ColorARGB AmbColor; // ambient tint (BGRA) ``` The per-frame lighting uniforms for the mesh/terrain shaders are: ``` uSunColor = DirColor.RGB * DirBright (lerped between keyframes) uSunDir = (above sunDir formula) (lerped between keyframes) uAmbientColor = AmbColor.RGB * AmbBright (lerped between keyframes) ``` **Lerp math between keyframes:** the keyframes are sorted by `Begin`. Given `t = clientDayFraction` in [0, 1): ```csharp var k1 = last keyframe with Begin <= t; var k2 = next keyframe (wraps: k1 is the last → k2 is the first); float span = k2.Begin - k1.Begin; if (span <= 0) span += 1f; // wrap float local = t - k1.Begin; if (local < 0) local += 1f; // wrap float alpha = span > 0 ? local / span : 0f; ``` Then interpolate each scalar field (`DirBright`, `DirHeading`, `DirPitch`, `AmbBright`) and each ARGB color channel independently. **Do not interpolate angles as plain scalars without wrap handling** — if keyframe 1 has `DirHeading = 350°` and keyframe 2 has `DirHeading = 10°`, the naïve lerp sweeps backwards across the sky. Use the shorter arc: ```csharp float ShortestAngleLerp(float a, float b, float t) { float delta = ((b - a + 540f) % 360f) - 180f; return a + delta * t; } ``` ## 5. Fog ### 5.1 Dat-driven atmospheric fog Each `SkyTimeOfDay` keyframe specifies: ```csharp public float MinWorldFog; // distance where fog starts (meters) public float MaxWorldFog; // distance where fog saturates (meters) public ColorARGB WorldFogColor; public uint WorldFog; // fog mode flag: 0 = off, 1 = D3DFOG_LINEAR, etc ``` The AC client used D3D7/D3D8 fixed-function fog (`D3DRS_FOGENABLE`, `D3DRS_FOGCOLOR`, `D3DRS_FOGSTART`, `D3DRS_FOGEND`, `D3DRS_FOGTABLEMODE`). Under modern Silk.NET/OpenGL we replicate this in the fragment shader. **Distance falloff formula (linear mode, which is what retail uses):** ```glsl uniform float uFogStart; uniform float uFogEnd; uniform vec3 uFogColor; uniform int uFogMode; // 0 off, 1 linear, 2 exp, 3 exp2 float d = length(worldPos - uCameraPos); float fogAmount = clamp((d - uFogStart) / (uFogEnd - uFogStart), 0.0, 1.0); vec3 lit = sceneColor; // already shaded by sun + ambient finalColor = mix(lit, uFogColor, fogAmount); ``` Retail's values on a clear day are roughly `MinWorldFog ≈ 120` meters, `MaxWorldFog ≈ 350` meters, fog color close to the horizon sky band so the distance fade blends into the skybox. On overcast or rainy keyframes those distances collapse to ~40 → ~150 to produce the classic AC "low visibility storm" feel. ### 5.2 Server-forced colored fog (the weather override) `references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs` enumerates the server → client environment opcode (delivered via `AdminEnvirons`, message ID `0xEA60`). The fog variants are: | Value | Name | |-------|-----------| | 0x00 | Clear | | 0x01 | RedFog | | 0x02 | BlueFog | | 0x03 | WhiteFog | | 0x04 | GreenFog | | 0x05 | BlackFog | | 0x06 | BlackFog2 | ACE drives these via `LandblockManager.DoEnvironChange` → `SetGlobalFogColor` (LandblockManager.cs:793): the server globally overrides the client's computed fog color with a hard-coded tint. This is the mechanic behind the "portal storm" and "shadow invasion" global events — the server can push red/black fog to every connected client. When `EnvironChangeType.Clear` arrives, the client reverts to the dat-driven `WorldFogColor` lerp. ## 6. Weather states Retail AC has NO general `SetWeather` opcode. Protocol search in `Chorizite.ACProtocol/protocol.xml` and `holtburger/crates/holtburger-protocol/` turns up: - **Command `DisableWeather = 0x15C`** (an in-game slash-command that stops particle effects for the invoker). - **Option flag `DisableMostWeatherEffects = 0x00010000`** (a player preference in the character options bitfield). - **Option flag `AlwaysDaylightOutdoors`** (another preference). Plus the `AdminEnvirons` fog opcode above. That's it. ### 6.1 Client-local weather model Since there is no server weather, the canonical behavior is: - **Weather is an emergent property of the time-of-day keyframes.** A keyframe with dim DirBright, dense MinWorldFog/MaxWorldFog, gray AmbColor, and a `SkyObjReplace` swapping the cloud sheet for a dark variant IS an overcast state. - **Rain/snow particles** are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the sky object) that attaches a particle emitter to the camera. This emitter fires rain/snow particles regardless of the server. - **Portal storm / shadow invasion** — the two named world events — flow through `EnvironChangeType` fog overrides + `PlayScriptId` for flash effects. The practical port strategy: our `WeatherState` enum describes the client's currently active atmospheric regime (Clear, Overcast, Rain, Snow, Storm), and it's chosen by a **pseudo-random seed derived from the current in-game day + the region's `DayGroup.ChanceOfOccur` weights**. All clients in the same server second roll the same state — so the weather is visually synchronized without any packets. ### 6.2 Transitions Transitions between weather states take ~10 seconds. The cheapest retail-faithful implementation: when the DayGroup resolves to a new state, lerp the target fog distance, fog color, and cloud-mesh override over 10 s. Acclient also fades the rain emitter in/out via the `ParticleEmitter.LifespanRand` and the `FinalTrans` (alpha-fade-out) fields. ## 7. Rain particles Rain in AC is a `ParticleEmitter` attached to the camera at an offset of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing streak-style particles downward. See `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs` for the full schema. Relevant fields for rain: | Field | Typical rain value | Meaning | |------------------|--------------------|---------| | EmitterType | Maelstrom | continuous, volumetric | | ParticleType | Static | one quad per drop | | GfxObjId | rain_streak.gfx | long skinny alpha-blended quad | | Birthrate | ~0.002 s | ≈500 drops/sec | | MaxParticles | 2000–5000 | | | InitialParticles | 0 | | | TotalSeconds | 0 | infinite | | Lifespan | ~1.2 s | ≈60 m fall at 50 m/s | | OffsetDir | (0, 0, -1) | emit below origin | | MinOffset/MaxOffset | 0 / 50 m | fills a column | | A (velocity) | (0, 0, -50) | straight down Z | | MinA/MaxA | 0.95 / 1.05 | ±5% speed jitter | | B (secondary) | (0, 2, 0) | wind bias (tunable) | | StartScale / FinalScale | 1.0 / 1.0 | constant size | | StartTrans / FinalTrans | 0.0 / 0.0 | no fade (streak is already alpha-keyed) | | IsParentLocal | true | tracks the camera | The emitter follows the camera, so you never leave the rain volume. Streaks render as screen-aligned quads with an alpha-gradient texture. **The streak orientation comes from the particle's velocity vector** — OpenGL geometry shader or a CPU-side rebuild per frame can produce the axis-aligned stretching. ## 8. Snow particles Same `ParticleEmitter` machinery, different tuning: | Field | Typical snow value | |------------------|--------------------| | GfxObjId | snowflake.gfx | billboard sprite | | Birthrate | ~0.01 s | ~100 flakes/sec | | Lifespan | ~6 s | slow drift | | A (velocity) | (0, 0, -2) | slow fall | | B (drift) | (1, 0, 0) | sideways drift | | C (turbulence) | (0, 1, 0) | tumble | | MinA/MaxA | 0.5 / 1.5 | big speed variance | | StartScale | 0.05 m | tiny | | FinalScale | 0.05 m | | | StartTrans / FinalTrans | 0.0 / 0.3 | fade out on melt | Snow is lighter, slower, wider drift — the `B` and `C` vectors create a per-particle wobble that avoids the "straight-down rain" look. ## 9. Lightning flashes Storms trigger lightning via client-side random timers (every 8–30 seconds while a storm keyframe is active). The effect is **a brief full-scene brightness spike**: ```glsl // In the main lighting fragment shader: uniform float uLightningFlash; // 0..1, decays exponentially after spike vec3 lit = sceneColor * (uSunBrightness + uAmbientBrightness); lit += uLightningFlash * vec3(1.5, 1.5, 1.8); // additive cold-white pulse ``` The spike rises to 1.0 in ~50 ms and decays with a time constant of ~200 ms. Retail uses the existing directional/ambient uniforms; we layer a tiny lightning uniform on top to avoid touching the lerp path. ## 10. Thunder audio Thunder is an ambient sound triggered via `EnvironChangeType.Thunder1Sound` through `Thunder6Sound` (0x76–0x7B). The **server CAN play thunder on all connected clients** by broadcasting one of those values — that's how portal storms and raid events announce themselves. Locally, for client-triggered storms, the client pairs each lightning flash with a thunder cue. **Speed-of-sound delay:** the distance from player to strike is rolled with the flash. Delay = distance / 343 m/s before the audio engine fires the `Thunder[1..6]Sound` WAVE. For the closest strikes (distance < 50 m) the delay is imperceptible and flash+clap are simultaneous. ## 11. Weather per region Retail has only ONE region: `0x13000000` "Dereth" — the whole of Dereth shares one `GameTime` and one `SkyDesc`. See `references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/RegionTests.cs:30`: `Assert.AreEqual(1u, region.RegionNumber);`. **How do deserts feel different from forests, then?** The per-keyframe weather is the same across Dereth, but: - `SkyDesc.DayGroups` is a list of DAY GROUPS — each with a `ChanceOfOccur` weight, a mesh override table, and a time-of-day schedule. The client picks one group per in-game day using `ChanceOfOccur` as a PDF. "DayGroup 0" might be clear, "DayGroup 1" overcast, "DayGroup 2" rainy — the client rolls one for the whole world each day. - For per-region feel (desert vs forest), acdream can extend the port with a `RegionalBias` multiplier on the DayGroup weights indexed by landblock origin — e.g. Gharu'ndim landblocks (SW quadrant) get weight 1.5 on clear day groups and 0 on rainy ones, while Aluvian landblocks (central/forest) weight rain up. This is an acdream ADDITION — retail always rolled Dereth-wide. ## 12. Server sync: one opcode, plus absolute clock Summary of everything the network protocol actually does for weather/time: | Mechanism | Opcode | What it syncs | |-----------|--------|---------------| | Login handshake | ConnectRequest | current `PortalYearTicks` double | | Periodic TimeSync | Header flag 0x1000000 | fresh `PortalYearTicks` double | | AdminEnvirons | 0xEA60 | colored fog override or thunder sound | | PlayScriptId | 0xF754 | one-shot effect (lightning bolt near location) | **There is no `SetWeather`, no `SetTimeOfDay`, no `SetSkyDesc`.** The client does all the interpretation from the dat + local tick. The only thing the server can say about the sky is "paint it all red" or "play thunder #3". For acdream this means: - **Time-of-day sync is trivial:** read `ticks` on login, advance locally by Stopwatch/Environment.TickCount delta, re-seed on each TimeSync. - **Weather sync is automatic:** since both server and all clients compute from the same `ticks` and seeded RNG, all players in the same server-second see identical weather. - **Fog override is a one-flag sticky state:** remember the last `EnvironChangeType` from the server; if non-Clear, it overrides the dat-driven fog entirely until the next AdminEnvirons(Clear). ## 13. Port plan ### 13.1 New classes ``` src/AcDream.Core/World/Time/ WorldClock.cs — PortalYearTicks, dayFraction, DerethDateTime DerethDateTime.cs — lore calendar (port of ACE's class) src/AcDream.Core/World/Sky/ SkyDescData.cs — loaded from Region dat, cached at startup DayGroupResolver.cs — rolls the active DayGroup from ChanceOfOccur SkyKeyframeLerper.cs — interpolates SkyTimeOfDay between Begin points LightingSample.cs — output struct: SunDir, SunColor, AmbColor, FogStart, FogEnd, FogColor src/AcDream.Core/World/Weather/ WeatherState.cs — enum Clear/Overcast/Rain/Snow/Storm WeatherSystem.cs — derives state from keyframe+seeded RNG, handles 10s transitions, drives particles FogSettings.cs — per-frame fog uniforms EnvironOverride.cs — sticky EnvironChangeType from server src/AcDream.App/Rendering/Sky/ SkyRenderer.cs — port of WorldBuilder SkyboxRenderManager SkyGradient.cs — helper for the background mesh (first SkyObject) ParticleEmitterRenderer.cs — ported from DatReaderWriter ParticleEmitter src/AcDream.App/Rendering/Shaders/ sky.vert, sky.frag — unlit, depth-mask-off, sample tex arrays mesh.frag/terrain.frag — ADD uSunDir, uSunColor, uAmbientColor, uFogStart, uFogEnd, uFogColor, uFogMode, uLightningFlash uniforms ``` ### 13.2 Shader uniforms (added to existing shaders) ```glsl // Common "SceneLighting" UBO, shared by terrain, mesh, mesh_instanced: layout(std140, binding = 1) uniform SceneLighting { vec3 uSunDirection; // world-space, Z-up float uSunBrightness; vec3 uSunColor; float uAmbientBrightness; vec3 uAmbientColor; float uFogStart; vec3 uFogColor; float uFogEnd; int uFogMode; // 0 off, 1 linear float uLightningFlash; // 0..1 vec2 _pad; }; ``` One UBO update per frame from `WorldClock.Update` → `SkyKeyframeLerper.Sample(clientDayFraction)` → `WeatherSystem.Override` → the UBO. Both terrain and all meshes read from the same UBO. ### 13.3 Integration order 1. **Port `DerethDateTime` + `WorldClock`** standalone. Unit-test that PY 10 Morningthaw 1 Midsong matches hourOneTicks = 210. Feed fake Portal Year ticks to confirm the day-fraction math. 2. **Load `Region` from client_portal.dat** via DatReaderWriter; cache SkyDesc + GameTime in a `WorldState`. 3. **Port the lighting-keyframe lerp.** Add UBO, wire the terrain and mesh shaders to read `uSunDirection`/`uSunColor`/`uAmbientColor`. The hard-coded `(0.5, 0.3, -0.3)` light in `TerrainChunkRenderer.cs` and `InstancedMeshRenderer.cs` goes away. 4. **Add fog uniforms to existing shaders.** Linear-mode only initially. Verify one keyframe transition visually — sunset should tint the terrain correctly. 5. **Port `SkyboxRenderManager`** from WorldBuilder. Our `TerrainAtlas` already supports the array-texture mechanics; reuse the `InstancedMeshRenderer`/static pipeline for actual sky meshes, but with `depthMask=false` and the 1 000 000 far plane. 6. **Particle emitter.** Port the `ParticleEmitter` schema + the Maelstrom/Static particle-type rendering. Attach rain/snow emitters to the FlyCamera/ChaseCamera follow target. 7. **Weather state machine + RNG.** Seed from `(day_index, region_id)` so it's deterministic across all clients on the same server day. 8. **AdminEnvirons opcode handler** in `AcDream.Core.Net`. Maps `EnvironChangeType` to either a fog override or a sound play. Hooks into `FogSettings`. 9. **Lightning + thunder pair.** Simple — one random timer, one uniform spike, one delayed SoundPool trigger. ### 13.4 Testing - Conformance test for `DerethDateTime.ConvertRealWorldToLoreDateTime`: feed a few real DateTimes, check the PY/month/hour matches ACE's table. - Golden-image test for the sky at four canonical times: Dawnsong (t=0.3125), Midsong (t=0.5625), Gloaming (t=0.875), Darktide (t=0.0625). Compare shape of sun arc + fog tint. - Dat-load test: load real client_portal.dat, confirm `SkyDesc.DayGroups[0].SkyObjects.Count` is in the 4–6 range and `SkyTime.Count` is 4–8. ### 13.5 What to NOT do - Do not hard-code a cube/sphere sky with a gradient ramp shader. That's the #1 mistake from the "ultrathink the sky dome" note — the sky is geometry, not a shader ramp. - Do not treat time-of-day as a client setting. It's always derived from server ticks. Exposing a player UI slider is fine IF it's a debug override that gets reset on the next TimeSync packet. - Do not try to "improve" the `WorldFog` enum. The fields are D3DFOG_NONE/LINEAR/EXP/EXP2 — map them straight through. - Do not interpolate `DirHeading` or `DirPitch` as naive scalars; short-arc wrap is mandatory. - Do not skip the `SkyObjReplace` override table. The sun's luminosity fade across dawn/dusk lives there, and the cloud mesh swap for overcast keyframes lives there. Without it the "weather changes" look completely wrong. ## 14. Key file references | Purpose | Path | |---------|------| | Region DB object | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Region.generated.cs` | | SkyDesc schema | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyDesc.generated.cs` | | DayGroup schema | `references/DatReaderWriter/DatReaderWriter/Generated/Types/DayGroup.generated.cs` | | SkyTimeOfDay (lighting keyframe) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs` | | SkyObject (celestial mesh) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObject.generated.cs` | | SkyObjectReplace (per-time override) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObjectReplace.generated.cs` | | GameTime (calendar) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/GameTime.generated.cs` | | ParticleEmitter schema | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs` | | ColorARGB (BGRA byte order) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/ColorARGB.generated.cs` | | SkyboxRenderManager (our port template) | `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` | | DerethDateTime (lore calendar math) | `references/ACE/Source/ACE.Common/DerethDateTime.cs` | | Timers.PortalYearTicks | `references/ACE/Source/ACE.Server/Entity/Timers.cs` | | EnvironChangeType enum | `references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs` | | GameMessageAdminEnvirons | `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageAdminEnvirons.cs` | | LandblockManager.SetGlobalFogColor | `references/ACE/Source/ACE.Server/Managers/LandblockManager.cs:793` | | DisableWeather slash command | `references/Chorizite.ACProtocol/Chorizite.ACProtocol/protocol.xml:2300` | | DisableMostWeatherEffects option | `references/holtburger/crates/holtburger-common/src/character.rs:122` | | Existing light uniform (to be replaced) | `src/AcDream.App/Rendering/TerrainChunkRenderer.cs:222` | | Existing terrain shader | `src/AcDream.App/Rendering/Shaders/terrain.vert:98` | ## 15. Open questions / follow-ups - **TickSize vs LightTickSize.** SkyDesc has both; we assume LightTickSize is the rate at which lighting keyframes animate independently of the calendar. Need to inspect retail Dereth's actual values. - **Sky texture UV V-convention.** Needs a visual probe once we have the first skybox rendering. If the horizon appears at the zenith, flip V. - **The 10-second transition duration** for weather states is a folklore number — needs confirmation against a retail playthrough. - **Per-region weather bias** is an acdream extension, not retail. If fidelity is paramount, drop it and accept world-wide synchronous weather like retail had. - **PES (PhysicsScript) on SkyObject.** Each sky object can attach one. We don't yet know if any retail sky object uses this — need to inspect the unpacked Region. - **Portal storm visuals.** The red-fog global override exists; the crackling-purple-sphere-on-ground effect is a separate `PlayScriptId` thing we'll cover in a future dive.