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.
33 KiB
R12 — Weather System + Day/Night Cycle
Deep-dive research for the acdream C# .NET 10 AC client.
Goal: Map retail AC's sky, time-of-day, weather, and atmospheric
effects so we can port them faithfully. Primary oracles:
DatReaderWriter (MIT-licensed dat schema generated from the binary
format), WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs
(the one C# reference that actually renders a skybox from real region
data on our exact Silk.NET stack), ACE's DerethDateTime and Timers
(server-side Portal Year arithmetic), and ACE's EnvironChangeType
(the one opcode the server uses to poke at the client's sky).
The single biggest discovery in this research is a negative result:
retail AC weather is almost entirely client-side and dat-driven, not
server-synced. There is no general weather opcode. The server can only
force a colored fog or play an ambient sound via AdminEnvirons
(0xEA60). Everything else — the sky gradient, sun position, stars,
moons, fog density, cloud/star layer animations — is computed locally
from one shared Region record (0x13000000 "Dereth") and a
deterministic time-of-day derived from server-authoritative wall-clock.
1. Turbine world time (Portal Years and the 16-hour Derethian day)
1.1 The canonical source
Retail AC's calendar is defined in the Region dat object's GameTime
struct. See
references/DatReaderWriter/DatReaderWriter/Generated/Types/GameTime.generated.cs:
public partial class GameTime : IDatObjType {
public double ZeroTimeOfYear; // starting point in in-game ticks
public uint ZeroYear; // first P.Y. represented (10)
public float DayLength; // ticks per Derethian day = 7620
public uint DaysPerYear; // 360 (12 months of 30 days)
public AC1LegacyPStringBase<byte> YearSpec = new(); // "P.Y."
public List<TimeOfDay> TimesOfDay = []; // slice table: Begin, IsNight, Name
public List<AC1LegacyPStringBase<byte>> DaysOfWeek = []; // 6-day week
public List<Season> Seasons = []; // 4 seasons
}
The Region file also carries a TickSize and LightTickSize on the
SkyDesc itself (SkyDesc.generated.cs lines 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:
var connectRequest = new PacketOutboundConnectRequest(
Timers.PortalYearTicks, // <-- current game tick count (double, seconds)
session.Network.ConnectionData.ConnectionCookie,
...
);
and references/ACE/Source/ACE.Server/Entity/Timers.cs:47:
public static double PortalYearTicks { get; internal set; }
= Timers.WorldStartLoreTime.Ticks; // initial sync to real-world calendar
// advanced each tick of UpdateWorld():
Timers.PortalYearTicks += worldTickTimer.Elapsed.TotalSeconds;
So the "ticks" the server advances are simply seconds of wall-clock time, starting from a seed value chosen at server boot. The two standard seeds are:
UtcNowToLoreTime— lore-accurate (maps each real calendar year to a retail PY via a lookup table inDerethDateTime.ConvertFrom_RealWorld_to_Derethian_PY).UtcNowToEMUTime— offset from the literal last day of retail (Jan 31 2017), so the emulated world appears to keep ticking forward from where retail left off.UtcNowToGDLETime— GDLE's offset (epoch 1999-09-01).
The client receives this double-precision tick count in the connect
response and every subsequent TimeSync packet (NetworkSession.cs:938),
so the client knows the server's absolute PY time at millisecond
precision. Everything else — sun position, sky gradient, time-of-day
label — is then computed locally from ticks mod dayTicks and the
region's GameTime.
1.4 Local time-of-day derivation
Given ticks from the server:
dayFraction = (ticks mod 7620) / 7620 // 0..1 through one Derethian day
hourFloat = dayFraction × 16 // 0..16 over the 16 named hours
hourName = Hours.Darktide + (int)(round(hourFloat × 4) / 4)
WorldBuilder's SkyboxRenderManager.TimeOfDay is a float in
[0, 1) with this exact semantic: 0 is midnight/Darktide, 0.5 is
Midsong-and-Half, and SkyTimeOfDay.Begin values are normalized to
that range. The SkyDesc has TickSize in real seconds per in-game tick
(usually 1.0 for 1:1, but the dats let Turbine tune this), so:
clientDayFraction = ((serverPortalYearTicks − zeroTimeOfYear) / dayLength) mod 1
is the number to feed into SkyboxRenderManager.TimeOfDay.
2. Sky dome geometry: it's not a dome, it's sky objects
This is the deepest misconception to avoid. Retail AC does NOT have a
textured sphere or cube as the sky. The "sky" is a collection of
GfxObj meshes placed at huge distance, rendered with depth mask off,
each representing one celestial layer: the gradient background itself,
cloud sheets, the sun, the moon(s), the stars.
Look at references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObject.generated.cs:
public partial class SkyObject : IDatObjType {
public float BeginTime; // [0,1] when this object becomes visible
public float EndTime; // [0,1] when it disappears (wraps if End<Begin)
public float BeginAngle; // degrees: where on the sky arc at BeginTime
public float EndAngle; // degrees: where at EndTime (sweeps between)
public float TexVelocityX; // UV scroll rate — cloud drift, star twinkle
public float TexVelocityY; // (both in 1/s, applied each frame)
public QualifiedDataId<GfxObj> DefaultGfxObjectId; // the mesh
public QualifiedDataId<PhysicsScript> DefaultPesObjectId; // particle/emitter (unused at top)
public uint Properties; // flag bits (billboard? follow-camera?)
}
A sky object has:
- A visibility window in normalized day-time [0,1]. If Begin==End it's always visible (the sky gradient backdrop). If Begin<End it's a daytime object. If Begin>End it wraps at midnight — the nighttime star layer.
- A starting and ending angle. At BeginTime the object is at BeginAngle degrees, at EndTime it's at EndAngle. Linear interpolation in between. This is how the sun and moons trace arcs across the sky.
- Texture-space scroll velocity. Clouds drift, stars shimmer. These are applied to the texture coordinates each frame — the mesh doesn't physically move.
- A GfxObj mesh. Usually a large semi-hemispherical patch (for a cloud sheet), a small billboard quad (for the sun/moon), or a wrap-around hemisphere (for the star layer).
2.1 WorldBuilder's rendering approach (our template)
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs
is the canonical reference C# implementation. Key mechanics
(lines 115–274):
- 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.
- 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. - 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.
- 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:
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.transform = Matrix4x4.CreateScale(1.0f) * Matrix4x4.CreateRotationZ(-headingRad) * Matrix4x4.CreateRotationY(-rotationRad);
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.
public partial class SkyObjectReplace : IDatObjType {
public uint ObjectIndex; // which SkyObject to override
public QualifiedDataId<GfxObj> GfxObjId; // swap mesh
public float Rotate; // override heading
public float Transparent; // 0..1 fade
public float Luminosity; // emission strength
public float MaxBright; // cap
}
3. Sun direction
The Region's SkyTimeOfDay stores the light direction directly —
this is the world-space directional light that illuminates
everything outdoors:
public float Begin; // 0..1 day-fraction this keyframe takes effect
public float DirBright; // directional light intensity 0..N
public float DirHeading; // degrees; azimuth of the sun around Z (compass)
public float DirPitch; // degrees; elevation angle above horizon
public ColorARGB DirColor; // BGRA order (B,G,R,A) per byte layout
Conversion to a Silk.NET Vector3 light direction (Z-up, pointing FROM
the sun TOWARD the world):
float headingRad = DirHeading * MathF.PI / 180f;
float pitchRad = DirPitch * MathF.PI / 180f;
// Sun vector in AC's Z-up, right-handed, heading-from-north frame.
// Heading 0 = north, 90 = east. Pitch 0 = horizon, 90 = zenith.
var sunDir = new Vector3(
MathF.Sin(headingRad) * MathF.Cos(pitchRad),
MathF.Cos(headingRad) * MathF.Cos(pitchRad),
MathF.Sin(pitchRad)
);
// Shaders want light direction (from sun to world), so negate:
var lightDirection = -sunDir;
Between keyframes, linearly interpolate both the direction (as two
floats, not as a quaternion — AC's DirHeading/DirPitch are
independently lerped) and DirColor (byte-wise).
This is exactly what terrain.vert:98 already uses:
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
We currently hard-code (0.5, 0.3, -0.3) (the ACME constant); the port
replaces that uniform upload with the interpolated keyframe value.
4. Ambient + diffuse color, per time, per weather
Each SkyTimeOfDay keyframe also stores:
public float AmbBright; // ambient intensity 0..N
public ColorARGB AmbColor; // ambient tint (BGRA)
The per-frame lighting uniforms for the mesh/terrain shaders are:
uSunColor = DirColor.RGB * DirBright (lerped between keyframes)
uSunDir = (above sunDir formula) (lerped between keyframes)
uAmbientColor = AmbColor.RGB * AmbBright (lerped between keyframes)
Lerp math between keyframes: the keyframes are sorted by Begin.
Given t = clientDayFraction in [0, 1):
var k1 = last keyframe with Begin <= t;
var k2 = next keyframe (wraps: k1 is the last → k2 is the first);
float span = k2.Begin - k1.Begin;
if (span <= 0) span += 1f; // wrap
float local = t - k1.Begin;
if (local < 0) local += 1f; // wrap
float alpha = span > 0 ? local / span : 0f;
Then interpolate each scalar field (DirBright, DirHeading,
DirPitch, AmbBright) and each ARGB color channel independently.
Do not interpolate angles as plain scalars without wrap handling —
if keyframe 1 has DirHeading = 350° and keyframe 2 has
DirHeading = 10°, the naïve lerp sweeps backwards across the sky.
Use the shorter arc:
float ShortestAngleLerp(float a, float b, float t) {
float delta = ((b - a + 540f) % 360f) - 180f;
return a + delta * t;
}
5. Fog
5.1 Dat-driven atmospheric fog
Each SkyTimeOfDay keyframe specifies:
public float MinWorldFog; // distance where fog starts (meters)
public float MaxWorldFog; // distance where fog saturates (meters)
public ColorARGB WorldFogColor;
public uint WorldFog; // fog mode flag: 0 = off, 1 = D3DFOG_LINEAR, etc
The AC client used D3D7/D3D8 fixed-function fog (D3DRS_FOGENABLE,
D3DRS_FOGCOLOR, D3DRS_FOGSTART, D3DRS_FOGEND, D3DRS_FOGTABLEMODE).
Under modern Silk.NET/OpenGL we replicate this in the fragment shader.
Distance falloff formula (linear mode, which is what retail uses):
uniform float uFogStart;
uniform float uFogEnd;
uniform vec3 uFogColor;
uniform int uFogMode; // 0 off, 1 linear, 2 exp, 3 exp2
float d = length(worldPos - uCameraPos);
float fogAmount = clamp((d - uFogStart) / (uFogEnd - uFogStart), 0.0, 1.0);
vec3 lit = sceneColor; // already shaded by sun + ambient
finalColor = mix(lit, uFogColor, fogAmount);
Retail's values on a clear day are roughly MinWorldFog ≈ 120 meters,
MaxWorldFog ≈ 350 meters, fog color close to the horizon sky band so
the distance fade blends into the skybox. On overcast or rainy
keyframes those distances collapse to ~40 → ~150 to produce the classic
AC "low visibility storm" feel.
5.2 Server-forced colored fog (the weather override)
references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs enumerates
the server → client environment opcode (delivered via AdminEnvirons,
message ID 0xEA60). The fog variants are:
| Value | Name |
|---|---|
| 0x00 | Clear |
| 0x01 | RedFog |
| 0x02 | BlueFog |
| 0x03 | WhiteFog |
| 0x04 | GreenFog |
| 0x05 | BlackFog |
| 0x06 | BlackFog2 |
ACE drives these via LandblockManager.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
SkyObjReplaceswapping 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(thePhysicsScriptreference 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
EnvironChangeTypefog overrides +PlayScriptIdfor 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 |
| Birthrate | ~0.01 s |
| Lifespan | ~6 s |
| A (velocity) | (0, 0, -2) |
| B (drift) | (1, 0, 0) |
| C (turbulence) | (0, 1, 0) |
| MinA/MaxA | 0.5 / 1.5 |
| StartScale | 0.05 m |
| FinalScale | 0.05 m |
| StartTrans / FinalTrans | 0.0 / 0.3 |
Snow is lighter, slower, wider drift — the B and C vectors create a
per-particle wobble that avoids the "straight-down rain" look.
9. Lightning flashes
Storms trigger lightning via client-side random timers (every 8–30 seconds while a storm keyframe is active). The effect is a brief full-scene brightness spike:
// In the main lighting fragment shader:
uniform float uLightningFlash; // 0..1, decays exponentially after spike
vec3 lit = sceneColor * (uSunBrightness + uAmbientBrightness);
lit += uLightningFlash * vec3(1.5, 1.5, 1.8); // additive cold-white pulse
The spike rises to 1.0 in ~50 ms and decays with a time constant of ~200 ms. Retail uses the existing directional/ambient uniforms; we layer a tiny lightning uniform on top to avoid touching the lerp path.
10. Thunder audio
Thunder is an ambient sound triggered via EnvironChangeType.Thunder1Sound
through Thunder6Sound (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.DayGroupsis a list of DAY GROUPS — each with aChanceOfOccurweight, a mesh override table, and a time-of-day schedule. The client picks one group per in-game day usingChanceOfOccuras 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
RegionalBiasmultiplier 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
tickson 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
ticksand seeded RNG, all players in the same server-second see identical weather. - Fog override is a one-flag sticky state: remember the last
EnvironChangeTypefrom the server; if non-Clear, it overrides the dat-driven fog entirely until the next AdminEnvirons(Clear).
13. Port plan
13.1 New classes
src/AcDream.Core/World/Time/
WorldClock.cs — PortalYearTicks, dayFraction, DerethDateTime
DerethDateTime.cs — lore calendar (port of ACE's class)
src/AcDream.Core/World/Sky/
SkyDescData.cs — loaded from Region dat, cached at startup
DayGroupResolver.cs — rolls the active DayGroup from ChanceOfOccur
SkyKeyframeLerper.cs — interpolates SkyTimeOfDay between Begin points
LightingSample.cs — output struct: SunDir, SunColor, AmbColor,
FogStart, FogEnd, FogColor
src/AcDream.Core/World/Weather/
WeatherState.cs — enum Clear/Overcast/Rain/Snow/Storm
WeatherSystem.cs — derives state from keyframe+seeded RNG,
handles 10s transitions, drives particles
FogSettings.cs — per-frame fog uniforms
EnvironOverride.cs — sticky EnvironChangeType from server
src/AcDream.App/Rendering/Sky/
SkyRenderer.cs — port of WorldBuilder SkyboxRenderManager
SkyGradient.cs — helper for the background mesh (first SkyObject)
ParticleEmitterRenderer.cs — ported from DatReaderWriter ParticleEmitter
src/AcDream.App/Rendering/Shaders/
sky.vert, sky.frag — unlit, depth-mask-off, sample tex arrays
mesh.frag/terrain.frag — ADD uSunDir, uSunColor, uAmbientColor,
uFogStart, uFogEnd, uFogColor, uFogMode,
uLightningFlash uniforms
13.2 Shader uniforms (added to existing shaders)
// Common "SceneLighting" UBO, shared by terrain, mesh, mesh_instanced:
layout(std140, binding = 1) uniform SceneLighting {
vec3 uSunDirection; // world-space, Z-up
float uSunBrightness;
vec3 uSunColor;
float uAmbientBrightness;
vec3 uAmbientColor;
float uFogStart;
vec3 uFogColor;
float uFogEnd;
int uFogMode; // 0 off, 1 linear
float uLightningFlash; // 0..1
vec2 _pad;
};
One UBO update per frame from WorldClock.Update →
SkyKeyframeLerper.Sample(clientDayFraction) →
WeatherSystem.Override → the UBO. Both terrain and all meshes read
from the same UBO.
13.3 Integration order
- Port
DerethDateTime+WorldClockstandalone. Unit-test that PY 10 Morningthaw 1 Midsong matches hourOneTicks = 210. Feed fake Portal Year ticks to confirm the day-fraction math. - Load
Regionfrom client_portal.dat via DatReaderWriter; cache SkyDesc + GameTime in aWorldState. - 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 inTerrainChunkRenderer.csandInstancedMeshRenderer.csgoes away. - Add fog uniforms to existing shaders. Linear-mode only initially. Verify one keyframe transition visually — sunset should tint the terrain correctly.
- Port
SkyboxRenderManagerfrom WorldBuilder. OurTerrainAtlasalready supports the array-texture mechanics; reuse theInstancedMeshRenderer/static pipeline for actual sky meshes, but withdepthMask=falseand the 1 000 000 far plane. - Particle emitter. Port the
ParticleEmitterschema + the Maelstrom/Static particle-type rendering. Attach rain/snow emitters to the FlyCamera/ChaseCamera follow target. - Weather state machine + RNG. Seed from
(day_index, region_id)so it's deterministic across all clients on the same server day. - AdminEnvirons opcode handler in
AcDream.Core.Net. MapsEnvironChangeTypeto either a fog override or a sound play. Hooks intoFogSettings. - 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.Countis in the 4–6 range andSkyTime.Countis 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
WorldFogenum. The fields are D3DFOG_NONE/LINEAR/EXP/EXP2 — map them straight through. - Do not interpolate
DirHeadingorDirPitchas naive scalars; short-arc wrap is mandatory. - Do not skip the
SkyObjReplaceoverride 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
PlayScriptIdthing we'll cover in a future dive.