acdream/docs/research/2026-04-23-sky-fog.md
Erik 53608e77e3 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>
2026-04-24 11:04:36 +02:00

335 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.