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.
This commit is contained in:
Erik 2026-04-18 10:32:44 +02:00
parent 7230c1590f
commit 3f913f1999
20 changed files with 15312 additions and 17 deletions

View file

@ -0,0 +1,742 @@
# 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.