# Sky Lighting Formula Analysis: Retail vs acdream

**Date:** 2026-04-22  
**Status:** DECOMPILE-INCOMPLETE — retail D3D render code not located  
**Finding:** Formula inferred from observed behavior + r12 architecture docs + test evidence

## Executive Summary

Retail AC sky meshes are tinted by the ambient color from the current SkyTimeOfDay keyframe.

**Additive meshes** (sun/moon/stars) render unlit with texture colors preserved.  
**Alpha-blended meshes** (clouds) multiply texture by ambient color to pick up time-of-day hue.

At midnight: AmbientColor = (0.05, 0.05, 0.12) → clouds appear **purple**  
At dusk: AmbientColor = (0.35, 0.25, 0.25) → clouds appear **pink/orange**  
At noon: AmbientColor = (0.5, 0.5, 0.55) → clouds stay **light gray**

**Our bug:** SkyRenderer.cs:200 always sets uTint = Vector4.One (white), so clouds stay neutral gray.

## 1. Sky Mesh Draw Entry Point

### Retail decompile search: NOT FOUND

Searched chunk_00400000.c through chunk_007F0000.c for:
- String literals: "sky", "Sky", "SkyObject"
- D3D constants: D3DRS_LIGHTING, D3DRS_AMBIENT, SetMaterial, SetLight
- Environment/weather functions

No matches. Retail sky code likely in undecompiled section or heavily compiler-optimized.

### Retail reference: WorldBuilder SkyboxRenderManager

File: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:115-274

Entry: Render() method, lines 183-264 iterate each SkyObject:
1. Visibility check: timeOfDay >= BeginTime && <= EndTime
2. GfxObj override lookup: t1.SkyObjReplace[i]
3. Arc angle: rotationDeg = BeginAngle + (EndAngle - BeginAngle) * progress
4. Transform: scale * RotZ(-heading) * RotY(-rotation)
5. Draw: RenderObjectBatches(renderData, [transform])

Draw state (lines 177-180):
- DepthMask(false)
- Disable(DepthTest)
- Disable(CullFace)
- Blend per batch

No per-material tinting visible in this OpenGL code.

## 2. Lighting State for Sky Meshes (Retail)

### Decompile status: NOT FOUND

### Inference from r12 and observed behavior:

Retail (D3D7/D3D8 era) likely set:
- D3DRS_LIGHTING = FALSE (fixed-function disabled)
- D3DRS_AMBIENT = (0, 0, 0) or from keyframe
- No dynamic lights

Sky meshes ARE the gradient; they don't receive sun illumination. Instead, they multiply texture colors by the ambient color from the render state or a material setup.

## 3. Keyframe Data Flow into Render State

### Retail decompile: NOT FOUND

### Documented (r12 §3-4):

Each SkyTimeOfDay keyframe carries:
- AmbBright: ambient light intensity (0..N)
- AmbColor: BGRA ambient RGB
- DirBright: sun intensity
- DirHeading/DirPitch: sun position
- DirColor: BGRA sun RGB

Between keyframes: linear lerp (shortest-arc for angles).

**Flow (inferred):**
1. dayFraction = ticks mod 7620 / 7620
2. Pick keyframes k1, k2; compute blend weight u
3. AmbientColor_now = lerp(k1.AmbColor, k2.AmbColor, u) * u_brightness
4. SetRenderState(D3DRS_AMBIENT, ...) or bake into material
5. Draw sky

## 4. Vertex Format for Sky Meshes

Sky mesh vertices carry:
- Position (3×float)
- Normal (3×float) — may be used for billboard orientation
- UV (2×float)
- **No pre-lit diffuse color**

Meshes are unlit. Tinting comes from render state ambient + material.

## 5. Exact Lighting Formula (Retail)

### For Additive Meshes (sun, moon, stars):
```
blend: GL_SRC_ALPHA, GL_ONE

fragment = texture * luminosity_override
(ambient ignored; blend preserves bright body, makes black transparent)
```

### For Alpha-Blended Meshes (clouds, sky dome):
```
blend: GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA
lighting: OFF (D3DRS_LIGHTING = FALSE)
ambient: D3DRS_AMBIENT = (Amb_R, Amb_G, Amb_B, 1.0) from keyframe

fragment.rgb = texture.rgb * ambient * luminosity_override
fragment.a   = texture.a * (1 - transparency)
```

**Examples:**
- Midnight (dayFraction=0.0): ambient=(0.05,0.05,0.12) → gray texture × deep blue = **purple**
- Dusk (0.75): ambient=(0.35,0.25,0.25) → gray texture × reddish gray = **pink**
- Noon (0.5): ambient=(0.5,0.5,0.55) → gray texture × pale blue = **light gray**

Clouds receive NO directional sun color — they are purely ambient-lit.

## 6. Our Code vs Retail

### acdream current (WRONG):

File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:175-210

```csharp
_shader.SetVec4("uTint", Vector4.One);  // Line 200: always white

// Shader (sky.frag:41):
// vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity
//          = sampled.rgb * (1,1,1) * uLuminosity
//          = neutral gray at all times
```

Result: Clouds never pick up purple/pink/orange hue from keyframe.

### Retail (CORRECT):

Per formula above: tint = keyframe.AmbientColor

## 7. Proposed Implementation

### Change 1: Extract keyframe in Render()

File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:85-95

Keyframe already passed as parameter. Extract AmbientColor:
```csharp
var ambientTint = new Vector4(keyframe.AmbientColor, 1.0f);
```

### Change 2: Conditionally set uTint per submesh

File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:188-210

Replace line 200:
```csharp
foreach (var sub in subMeshes)
{
    if (sub.IsAdditive)
    {
        _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
        _shader.SetVec4("uTint", Vector4.One);  // sun: keep texture color
    }
    else
    {
        _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
        _shader.SetVec4("uTint", ambientTint);  // clouds: time-of-day tint
    }
    
    // ... draw ...
}
```

### Change 3: Shader unchanged

sky.frag:36-59 already correct. With new uTint logic:
- Clouds at midnight: sampled × (0.05,0.05,0.12) × lum → **purple**
- Sun always: sampled × (1,1,1) × lum → **sun color**

## 8. Differences: Retail vs acdream

| Aspect | Retail | acdream (current) | Diff |
|--------|--------|-------------------|------|
| Non-additive tint | AmbientColor | Always white (1,1,1) | **BUG** |
| Additive tint | White | White | ✓ |
| Ambient lerp | Between keyframes | Available, unused | Data OK |
| Blend mode | Per-surface flags | Per-submesh IsAdditive | ✓ |

Root cause: Line 200 unconditionally sets uTint = Vector4.One instead of AmbientColor for non-additive.

## 9. Acceptance Test

At dayFraction=0.0 (midnight), gray texture (0.5,0.5,0.5):

**Expected (retail):**
```
AmbientColor = (0.05, 0.05, 0.12)
output = (0.5,0.5,0.5) × (0.05,0.05,0.12) × 1.0
       = (0.025, 0.025, 0.06)
       ≈ dark purple
```

**Current (bug):**
```
output = (0.5,0.5,0.5) × (1,1,1) × 1.0
       = (0.5, 0.5, 0.5)
       = neutral gray
```

**Test:**
1. /time 0.0 (midnight)
2. Look at cloud layer
3. Should be visibly purple (high blue, low red/green)
4. /time 0.75 (dusk) → should be pink/orange
5. /time 0.5 (noon) → should be pale gray

If cloud stays gray at all times, fix not applied.

## References

| Source | Citation |
|--------|----------|
| SkyKeyframe struct | src/AcDream.Core/World/SkyState.cs:44-54 |
| SkyRenderer.Render | src/AcDream.App/Rendering/Sky/SkyRenderer.cs:85-217 |
| sky.frag | src/AcDream.App/Rendering/Shaders/sky.frag:36-59 |
| SkyState defaults | src/AcDream.Core/World/SkyState.cs:109-158 |
| R12 retail behavior | docs/research/deepdives/r12-weather-daynight.md:§2-4 |
| WorldBuilder port | references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:115-274 |
| Prior audit | docs/research/2026-04-21-sky-deep-audit.md |

## Verified Formula

For non-additive (alpha-blended) sky meshes:
```
output.rgb = texture.rgb * AmbientColor * luminosity_override
output.a   = texture.a * (1 - transparency)
```

For additive (sun/moon) meshes:
```
output.rgb = texture.rgb * luminosity_override
output.a   = texture.a * luminosity_override
```

**Diff:** SkyRenderer.cs lines 175-210 conditionally set uTint: white for additive, keyframe.AmbientColor for alpha-blended. No shader changes.

**Implementation time:** ~1 hour.

