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

742 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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`:
```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 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:
```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 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.
```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 | 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 | 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 830
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` (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)
```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 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.