acdream/docs/research/deepdives/r12-weather-daynight.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

33 KiB
Raw Blame History

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:

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 2426), so the calendar rate and the lighting animation rate are independent.

1.2 The calendar constants

ACE's DerethDateTime.cs lines 1133 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:99117). Day is Dawnsong through Warmtide-and-Half (hours 512); 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:

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:

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:

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 115274):

  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 137141). 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:
      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 46 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.

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:

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):

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:

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:

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):

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:

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:

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):

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.DoEnvironChangeSetGlobalFogColor (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 20005000
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
Birthrate ~0.01 s
Lifespan ~6 s
A (velocity) (0, 0, -2)
B (drift) (1, 0, 0)
C (turbulence) (0, 1, 0)
MinA/MaxA 0.5 / 1.5
StartScale 0.05 m
FinalScale 0.05 m
StartTrans / FinalTrans 0.0 / 0.3

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 830 seconds while a storm keyframe is active). The effect is a brief full-scene brightness spike:

// 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 (0x760x7B). 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)

// 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.UpdateSkyKeyframeLerper.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 46 range and SkyTime.Count is 48.

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.