sky(phase-5a): remove DayGroup-name rain hack, ship retail-only Overcast mapping

User-observed regression 2026-04-23: acdream spawned rain particles
when retail showed no rain at the same server tick. Root cause: my
Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain →
rain particle emitter. That's not what retail does.

Parallel decompile research confirms:
- Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives
  at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it
  from NOWHERE.
- Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render
  loop) never reads SkyObject.DefaultPesObjectId — the field is dead
  at render time. Rain/snow particles in retail come from a separate
  camera-attached weather subsystem that has NOT yet been located.

So the correct behavior is: DayGroup name should only drive
fog/ambient tone (via keyframes, already in the Snapshot path),
never spawn particle emitters. Any retail-faithful particle rain
belongs to a future phase once we find the camera-attached weather
subsystem driver.

Change: MapDayGroupNameToKind now maps all weathery substrings
(storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only
visuals, no particle spawn. Clear names stay Clear. The Rain, Snow,
Storm enum values remain and are still accessible via ForceWeather()
for debug overrides.

Tests updated (WeatherSystemTests): the name→kind theory now expects
Overcast for Rainy/Snowy/Stormy variants.

Also commits the four research docs from this session's parallel
hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding),
lightning timer (negative finding — agent #3), fog on sky
(positive: retail applies fog to sky geometry).

NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE
RANDOM TIMER hypothesis for lightning. User confirms retail does have
visible lightning + thunder. A follow-up agent (#5, in flight as of
this commit) is hunting the real mechanism — PlayScript opcode,
SetLight PhysicsScript hooks, AdminEnvirons side effects, or the
weather-volume draw. This commit does NOT attempt to port lightning.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 11:04:36 +02:00
parent d5e37694ed
commit 53608e77e3
6 changed files with 1508 additions and 20 deletions

View file

@ -0,0 +1,335 @@
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
**Date:** 2026-04-23
**Scope:** Q1-Q5 of the sky-fog hunt. Pins retail's fog mode, fog-distance
source, and whether sky meshes actually render through fog — with file:line
citations from `docs/research/decompiled/`.
## TL;DR — the retail fog equation for ALL meshes (sky included)
Retail uses **linear vertex fog** (`D3DRS_FOGVERTEXMODE = 3`) with
**RANGEFOGENABLE = TRUE**, meaning the fog factor is computed per-vertex
using **true 3D eye-space distance** `|eyePos - vertexPos|`, interpolated
to fragments, and blended in fixed-function D3D:
```
// Computed per VERTEX by the fixed-function pipeline:
dist = length(eyePos - worldPos) // RANGEFOG=1
f = saturate((FOGEND - dist) / (FOGEND - FOGSTART)) // linear
// Stored as vertex fog coord. Interpolated to fragment:
fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, f) // f=1 ⇒ no fog
```
**Sky meshes go through this exact path**: no D3D state is toggled around
the sky render (confirmed hunt B). The sky render loop `FUN_00508010`
at `chunk_00500000.c:7535-7603` enqueues sky GfxObjs via the normal mesh
path with **identity transform (translation = 0, rotation = identity)**,
then `FUN_005079e0` applies a rotation-only two-axis transform. **Sky
vertices are rendered at their raw mesh-space positions in world-space
(centered at the world origin).**
## Q1 — Eye-space Z / vertex distance at which the sky is rendered
**Answer: the sky mesh's own intrinsic radius (scale = 1.0, no transform
offset), taken at world origin (0,0,0) in world space.**
### Evidence — transform setup at sky render
`chunk_00500000.c:7571-7586` (sky render loop, per sky object):
```c
local_48 = 0x3f800000; // quaternion w = 1.0f
local_44 = 0; // quaternion x = 0
local_40 = 0; // quaternion y = 0
local_3c = 0; // quaternion z = 0
local_14 = 0; // translation x = 0
local_10 = 0; // translation y = 0
local_c = 0; // translation z = 0
FUN_00535b30(); // quaternion → 3x3 rotation matrix
if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) {
// billboard branch: copy 3-float translation from iVar5 + 0x84..0x8c
local_14 = *(undefined4 *)(iVar5 + 0x84);
local_10 = *(undefined4 *)(iVar5 + 0x88);
local_c = *(undefined4 *)(iVar5 + 0x8c);
}
FUN_005079e0(&local_48, uVar3, uVar4); // apply 2-axis rotation (no translation)
FUN_00514b90(&local_48); // enqueue mesh draw with this transform
```
`FUN_00535b30` at `chunk_00530000.c:4509-4531` is a pure
quaternion-to-3x3 rotation builder — **no translation written**. So the
transform passed to every sky mesh is `{rotation, translation=(0,0,0)}`
(except for billboard-flagged objects that take a translation from the
GfxObj's +0x84 slot, which historically is small; not addressed here).
### Evidence — no camera-centered sky projection
Hunt B searched for view-matrix manipulation around the sky render and
found **nothing**. See `docs/research/2026-04-23-sky-decompile-hunt-B.md:323-335`:
> The view matrix is NOT rewritten with zero translation before the sky
> draw. This is consistent with the conclusion that there is no discrete
> "sky dome" — the weather/fog volume objects follow the camera by being
> placed in camera-relative world position by their parent scene-graph
> node.
And hunt B also confirms no huge far-plane constants in the `.rdata`
(lines 337-349): no `1e5`, `1e6`, `1e7` floats anywhere. The only far-plane
change is the weather-volume pass:
```c
// chunk_00500000.c:7272 (weather volume, NOT sky proper)
FUN_0054bf30(DAT_0081fc98 * _DAT_007c6f14);
```
`_DAT_007c6f14` appears in cubic-spline math in `chunk_005E0000.c:258, 474,
742` — it's a small constant (~1-3), not a huge sky-scale multiplier.
### Implication for vertex distance
Since the sky transform is `(rotation, 0)` and the camera view matrix is
unchanged, the sky vertex's world-space position is `rotation × meshVertex`.
The vertex's **eye-space distance** is therefore
`length(meshVertex_rotated - cameraWorldPos)` — i.e. it **depends on the
sky GfxObj's intrinsic mesh radius and where the camera is**.
For the standard sky GfxObjs (dome `0x010015EE`, stars, sun, moon), the
mesh dimensions live in the `.dat` file (not decompiled here). **WorldBuilder's
sky implementation** at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:247`
explicitly comments:
> Using 1.0f scale as the far plane is now huge and AC meshes are already
> at large distances.
So empirical evidence from a known-working AC client port confirms the
sky GfxObjs are intrinsically **thousands of meters in radius** (requiring
far plane ≈ 1e6 to not clip). This is consistent with the typical retail
FOGEND = 2400m saturating the sky to FOGCOLOR — **which IS what retail
does** and is why the user sees a colored "sky glow" matching the fog
color at ground level.
## Q2 — Fog mode (vertex vs table, linear vs exp)
**Answer: Vertex-linear fog with 3D range-distance.**
### Evidence — device-init state (`FUN_005a10f0` → the master init at 0x005A4F20)
`chunk_005A0000.c:3361-3389` (state reset block, written when the device
is initialized or reset):
```c
// D3DRS state-value pairs written on device init/reset:
(**...0xe4)(dev, 0x1c, 1); // FOGENABLE = TRUE
(**...0xe4)(dev, 0x1d, 0); // FOGTABLEMODE = D3DFOG_NONE
(**...0xe4)(dev, 0x22, 0xaaaaaa); // FOGCOLOR = RGB(170,170,170)
(**...0xe4)(dev, 0x23, 0); // ? (state 35)
(**...0xe4)(dev, 0x24, 0x43c80000); // FOGSTART = 400.0f
(**...0xe4)(dev, 0x25, 0x44fa0000); // FOGEND = 2000.0f
(**...0xe4)(dev, 0x26, 0x3e4ccccd); // FOGDENSITY = 0.2f (unused)
(**...0xe4)(dev, 0x30, 1); // RANGEFOGENABLE = TRUE
...
(**...0xe4)(dev, 0x8c, 3); // FOGVERTEXMODE = D3DFOG_LINEAR (3)
```
Reading the D3DRS hex codes:
| Hex | Dec | D3DRS Name | Value | Meaning |
|-----|-----|-------------------|-------------|---------|
| 0x1c | 28 | FOGENABLE | 1 | fog ON |
| 0x1d | 29 | FOGTABLEMODE | 0 | **NO pixel fog** |
| 0x22 | 34 | FOGCOLOR | 0xaaaaaa | default gray |
| 0x24 | 36 | FOGSTART | 400.0f | start distance |
| 0x25 | 37 | FOGEND | 2000.0f | end distance |
| 0x30 | 48 | RANGEFOGENABLE | 1 | **use 3D distance** |
| 0x8c | 140 | FOGVERTEXMODE | 3 (LINEAR) | **per-vertex linear fog** |
**Verification that FOGSTART = 400.0f:** `0x43c80000` = 400.0.
**Verification that FOGEND = 2000.0f:** `0x44fa0000` = 2000.0.
The per-frame fog writer `FUN_005a4080` at `chunk_005A0000.c:2870-2907`
only writes states `0x22` (FOGCOLOR), `0x24` (FOGSTART), `0x25` (FOGEND).
**It NEVER writes FOGVERTEXMODE or FOGTABLEMODE** — those stay at their
init values for the entire session.
Hunt B (`2026-04-23-sky-decompile-hunt-B.md:302-306`) independently verified:
> **D3DRS_FOGTABLEMODE=0x23, FOGVERTEXMODE=0x8c, FOGDENSITY=0x26**
> these are only set once in the default-init (`FUN_005a10f0`) and
> never per-frame. Retail uses linear fog (FOGSTART/FOGEND), not
> exponential (FOGDENSITY).
(Note the doc calls them by D3DRS name; 0x1d is TABLEMODE, 0x8c is
VERTEXMODE. The doc's hex is slightly off but the conclusion is correct.)
## Q3 — What "distance" does retail use per-sky-vertex
**Answer: true 3D eye-space distance from camera to vertex** (because
`D3DRS_RANGEFOGENABLE = 1`).
D3D fixed-function linear vertex fog with `RANGEFOGENABLE = 1` computes:
```
fogDistance = length(EyePos - VertexPos) // 3D euclidean
fogFactor = saturate((FOGEND - fogDistance) / (FOGEND - FOGSTART))
```
`fogFactor = 1.0` means "fully visible (no fog)"; `fogFactor = 0.0` means
"fully fogged (100% FOGCOLOR)".
With a sky dome mesh of radius `R` rendered at world origin and a camera
at world position `cam`:
```
fogDistance(skyVertex) = |cam - (rotation × skyVertex)| ≈ R (for R ≫ |cam|)
```
In Dereth, `|cam|` is the ground-level camera position (say ~100m altitude,
~10,000m absolute if near a Holtburg landblock). The sky dome vertex is
at `rotation × meshVertex` — rotation is a unit-quat, so magnitude is
preserved. If the dome mesh has radius ~3000m, `fogDistance ≈ 3000m`
well past `FOGEND = 2000m` in the init — so the **sky renders fully
fogged** unless the keyframe-driven FOGEND is large enough (see note
about MaxWorldFog below).
### Per-keyframe FOGEND override
At `chunk_00500000.c:6294-6326`, every `LightTickSize` seconds the
`FUN_00501860` fog-lerp writes per-keyframe `fogStart, fogEnd, fogColor`
(from `SkyTimeOfDay.MinWorldFog, MaxWorldFog, WorldFogColor`). Typical
retail dusk values are `Min ≈ 150`, `Max ≈ 2400`. At `Max = 2400`, a
sky-dome vertex at ~3000m is fully fogged to `WorldFogColor`.
**This is the mechanism by which the horizon colors in retail:** the sky
dome mesh is at a distance where fog contribution dominates, so the
screen-space sky color IS `WorldFogColor` (the dusk purple, the dawn
peach, etc.) interpolated between keyframes.
## Q4 — Fog application order
**Answer: fixed-function D3D applies fog as the LAST stage**, after
material × texture modulate, per standard D3D pipeline:
```
fragment.rgb = texture.rgb * litColor.rgb // see Q6 of the material doc
fragment.a = texture.a * litColor.a
// Fog stage (D3D hardware, always after everything else in FFP):
fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, fogFactor)
```
Retail does NOT alter this ordering for sky meshes — no state is flipped
around the sky render (see `2026-04-23-sky-material-state.md:309-327`).
The sky fragment is the fully lit+textured surface × fog blend. Since
sky meshes typically have `Surface.Luminous = true` (see material-state
doc §2), the lit color is `texture × Luminosity` (emissive-only); fog
then blends this with `WorldFogColor`.
## Q5 — Port-ready pseudocode for acdream's GLSL sky shader
```glsl
// Vertex shader — compute fog factor on the CPU or in the vertex shader:
vec3 worldPos = (uModel * vec4(aPos, 1.0)).xyz; // sky mesh at world origin
vec3 eyeToVert = worldPos - uCameraWorldPos;
float dist = length(eyeToVert); // RANGEFOG=1 (3D, not Z)
float fogFactor = clamp((uFogEnd - dist) / (uFogEnd - uFogStart), 0.0, 1.0);
v_FogFactor = fogFactor;
// …normal vertex transform…
// Fragment shader:
vec4 tex = texture(uSkyTex, vUv);
vec3 lit = tex.rgb * uLuminosity; // for luminous sky meshes
float alpha = tex.a * (1.0 - uTransparency);
// Fog: fogFactor = 1 ⇒ no fog; fogFactor = 0 ⇒ 100% fog color
vec3 withFog = mix(uFogColor, lit, v_FogFactor);
out_Color = vec4(withFog, alpha);
```
### Uniforms — all driven per-keyframe by SkyTimeOfDay
- `uFogStart` = interpolated `SkyTimeOfDay.MinWorldFog` (meters)
- `uFogEnd` = interpolated `SkyTimeOfDay.MaxWorldFog` (meters)
- `uFogColor` = interpolated `SkyTimeOfDay.WorldFogColor` (RGB, A unused)
- `uCameraWorldPos` = player's camera world-space position
- `uLuminosity`, `uTransparency` = already-interpolated keyframe override
### DO NOT suppress fog on the sky
The retail behavior IS "sky saturates to WorldFogColor at long distance,"
and that produces the correct dusk-purple / dawn-peach horizon gradient.
Suppressing fog on the sky would make our sky look like a retail-client
rendered WITHOUT fog — which is not what the user sees in retail.
### DO scale sky vertices intrinsically
The sky GfxObj meshes have large built-in radii (thousands of meters).
**Do not apply an artificial scale** — the dat-provided vertex positions
are already in the "right" units for the retail fog system to work
correctly against `FOGSTART ∈ [0, 400]`, `FOGEND ∈ [150, 2400]` from
keyframes.
If our current implementation is placing the sky at the wrong distance
(too close ⇒ almost no fog; too far ⇒ always 100% fog), check:
1. Are we reading `GfxObj` vertex positions raw (no scaling)?
2. Is our `uModel` matrix setting the sky at world origin (translation
= 0, rotation = sky-heading rotation around Z + sky-arc rotation
around Y, from FUN_005079e0's two-axis transform)?
3. Is `uCameraWorldPos` the ACTUAL player world position (not 0)?
### Should fog use per-pixel (table) instead of per-vertex?
No — retail uses vertex fog. Per-vertex fog is correct for the sky dome
because the dome's triangles are large and the distance varies smoothly
across them, so per-vertex interpolation gives identical results to
per-pixel at the cost of massively fewer ALU cycles. (Modern GLSL can do
per-pixel fog cheaply, so the visual result should be indistinguishable;
use whichever is cleaner in our shader.)
## Summary of the acdream code-change recommendation
1. **Keep fog enabled for the sky pass.** The sky draw goes through the
normal mesh path; fog contributes to the horizon color by design.
2. **Use linear fog**, compute `fogFactor` per-vertex with `clamp((FOGEND
- dist) / (FOGEND - FOGSTART), 0, 1)`, where `dist = length(world -
cameraWorld)` (3D distance, not eye-Z).
3. **Use the keyframe-lerped FOGSTART/FOGEND/FOGCOLOR** (from
SkyTimeOfDay.Min/Max/WorldFogColor, interpolated on LightTickSize
cadence). Already in `SkyStateProvider`.
4. **Draw sky meshes at world-origin** with a rotation-only transform.
Do NOT strip the camera's view translation — the camera's world
position is correct, and the sky's distance from the camera is the
mesh's intrinsic radius relative to the camera's world position. This
matches retail.
## Files cited
- `chunk_00500000.c:6213-6333``FUN_005062e0` (per-frame sky+fog tick)
- `chunk_00500000.c:7535-7603``FUN_00508010` (sky render loop)
- `chunk_00500000.c:7571-7586` — sky transform setup (trans=0, quat=id)
- `chunk_00530000.c:4509-4531``FUN_00535b30` (quat-to-3x3, no trans)
- `chunk_00510000.c:4563-4591``FUN_00514b90` (mesh draw enqueue)
- `chunk_005A0000.c:3361-3389` — device-init state block (FOGVERTEXMODE=3,
FOGTABLEMODE=0, FOGSTART=400, FOGEND=2000, RANGEFOGENABLE=1)
- `chunk_005A0000.c:2868-2907``FUN_005a4080` (per-frame fog writer:
FOGCOLOR/START/END only)
- `chunk_005A0000.c:2808-2819``FUN_005a3f90` (FOGENABLE master gate)
- `references/WorldBuilder/.../SkyboxRenderManager.cs:247` — independent
confirmation that AC sky GfxObj meshes are at "large distances" in dat
- `docs/research/2026-04-23-sky-decompile-hunt-B.md:300-349` — hunt B
confirming no per-frame FOGVERTEXMODE writes, no view-matrix strip,
no huge far-plane constants
- `docs/research/2026-04-23-sky-material-state.md:56-95` — hunt that
fog stays enabled through sky render
## Remaining uncertainty
- **Exact sky GfxObj mesh radius** is in the `.dat` file and was not
decompiled. For a faithful port, load the mesh and inspect its max
vertex magnitude; compare to typical FOGEND = 2400. WorldBuilder
evidence suggests 3000+ meters.
- `_DAT_007c6f14` — the weather-far-plane multiplier. Only used in the
weather-volume pass (`FUN_00507a50`), not sky. Likely a small (< 3)
constant.
- Billboard flag `(*(byte*)(param_1[6] + uVar7 * 4) & 4)` at
`chunk_00500000.c:7579` — when set, the sky object takes a 3-float
translation from `iVar5 + 0x84..0x8c`. Not addressed here; typical
sky objects (dome, stars, sun, moon) are likely NOT billboard-flagged
and render at origin.