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>
335 lines
15 KiB
Markdown
335 lines
15 KiB
Markdown
# 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.
|