# 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<byte> YearSpec = new();   // "P.Y."
    public List<TimeOfDay> TimesOfDay = [];  // slice table: Begin, IsNight, Name
    public List<AC1LegacyPStringBase<byte>> DaysOfWeek = [];  // 6-day week
    public List<Season> 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<Begin)
    public float BeginAngle;    // degrees: where on the sky arc at BeginTime
    public float EndAngle;      // degrees: where at EndTime (sweeps between)
    public float TexVelocityX;  // UV scroll rate — cloud drift, star twinkle
    public float TexVelocityY;  // (both in 1/s, applied each frame)
    public QualifiedDataId<GfxObj> DefaultGfxObjectId;  // the mesh
    public QualifiedDataId<PhysicsScript> 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 Begin<End it's a
  daytime object. If Begin>End 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<GfxObj> 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.
