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