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>
15 KiB
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= interpolatedSkyTimeOfDay.MinWorldFog(meters)uFogEnd= interpolatedSkyTimeOfDay.MaxWorldFog(meters)uFogColor= interpolatedSkyTimeOfDay.WorldFogColor(RGB, A unused)uCameraWorldPos= player's camera world-space positionuLuminosity,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:
- Are we reading
GfxObjvertex positions raw (no scaling)? - Is our
uModelmatrix 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)? - Is
uCameraWorldPosthe 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
- Keep fog enabled for the sky pass. The sky draw goes through the normal mesh path; fog contributes to the horizon color by design.
- Use linear fog, compute
fogFactorper-vertex with `clamp((FOGEND- dist) / (FOGEND - FOGSTART), 0, 1)
, wheredist = length(world - cameraWorld)` (3D distance, not eye-Z).
- dist) / (FOGEND - FOGSTART), 0, 1)
- Use the keyframe-lerped FOGSTART/FOGEND/FOGCOLOR (from
SkyTimeOfDay.Min/Max/WorldFogColor, interpolated on LightTickSize
cadence). Already in
SkyStateProvider. - 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 datdocs/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 constantsdocs/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
.datfile 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)atchunk_00500000.c:7579— when set, the sky object takes a 3-float translation fromiVar5 + 0x84..0x8c. Not addressed here; typical sky objects (dome, stars, sun, moon) are likely NOT billboard-flagged and render at origin.