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

15 KiB
Raw Permalink Blame History

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):

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:

// 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):

// 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

// 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-6333FUN_005062e0 (per-frame sky+fog tick)
  • chunk_00500000.c:7535-7603FUN_00508010 (sky render loop)
  • chunk_00500000.c:7571-7586 — sky transform setup (trans=0, quat=id)
  • chunk_00530000.c:4509-4531FUN_00535b30 (quat-to-3x3, no trans)
  • chunk_00510000.c:4563-4591FUN_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-2907FUN_005a4080 (per-frame fog writer: FOGCOLOR/START/END only)
  • chunk_005A0000.c:2808-2819FUN_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.