acdream/docs/research/2026-04-23-sky-material-state.md
Erik d5e37694ed docs(sky): port plan for PhysicsScript/fog/lightning/crossfade
Captures where we stand after Phase 4b and lays out the remaining
retail-faithful port work across four phases (5-8):

- Phase 5: PhysicsScript loader + runtime + sky lifecycle. Replaces
  WeatherSystem's crude "DayGroup name contains Rainy → spawn rain"
  shortcut with retail's actual PES-driven particle emission.
- Phase 6: Fog on sky meshes. The sky frag currently ignores fog
  uniforms; retail's D3D fog applies to sky.
- Phase 7: Lightning flash trigger + thunder audio for storm keyframes.
- Phase 8: Weather / DayGroup crossfade (DAT_008427a9 / _DAT_008427b8
  lerp) + AdminEnvirons override → fog crossfade.

User observation 2026-04-23 during Phase 4b verification: "Now it is
raining when it should not be." Root cause traced to the
SetKindFromDayGroupName string match firing rain particles on a "Rainy"
DayGroup regardless of whether that DayGroup actually has a visible
rain-emitting SkyObject. Proper fix requires porting PhysicsScript.

Also commits the earlier research from agent Q1-Q6:
`docs/research/2026-04-23-sky-material-state.md`.

Four parallel decompile agents are in flight as of this commit:
- PhysicsScript dat + runtime
- Sky↔PES wiring + emitter lifecycle
- Lightning + weather crossfade
- Fog on sky + vertex distance

Phase 5 implementation starts once those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:46 +02:00

21 KiB
Raw Blame History

Sky Material/D3D State — Retail Decompile Trace

Date: 2026-04-23 Scope: Q1Q6 of the material/state hunt. Pins exactly what retail writes per-mesh when rendering a sky GfxObj, and what stays inherited from scene state.

TL;DR — the retail sky fragment formula

Retail D3D fixed-function lighting is the sky's colour source. Per-sky-mesh, the retail client writes a D3DMATERIAL9 with fields populated from the mesh's Surface (the per-Surface luminosity/maxBright/transparency). Sky meshes do NOT get a special state pass — they ride the normal mesh pipeline.

Per-fragment (fixed-function pseudocode):

material.Diffuse   = (0, 0, 0, 1)                    if Surface.Luminous else (from FUN_0059da60)
material.Ambient   = (0, 0, 0, 1)                    if Surface.Luminous else (from FUN_0059da60)
material.Emissive  = (Lum, Lum, Lum, 1)              where Lum = Surface.Luminosity or 0
vertex.diffuse.rgb = <per-vertex lit by AdjustPlanes>
vertex.diffuse.a   = 1 - Surface.Transparency        (for each of 4 corners)

if D3DRS_LIGHTING:
    # D3D fixed-function lighting:
    litColor = material.Emissive
             + material.Ambient  * (D3DRS_AMBIENT + sum_of_light.ambient)
             + material.Diffuse  * sum_of_light.diffuse * dot(N, L)
             + material.Specular * ...
else:
    # Lighting OFF — vertex.diffuse is used directly.
    litColor = vertex.diffuse

fragment.rgb = texture.rgb * litColor.rgb
fragment.a   = texture.a   * litColor.a

if D3DRS_FOGENABLE and z > FOGSTART:
    fragment.rgb = lerp(fragment.rgb, D3DRS_FOGCOLOR,
                        clamp((z - FOGSTART)/(FOGEND - FOGSTART), 0, 1))

Key facts:

  1. No sky-specific render-state toggles. Sky meshes render with whatever D3DRS_LIGHTING, D3DRS_FOGENABLE, D3DRS_AMBIENT were last set. The per-mesh writer FUN_0059da60 MAY flip LIGHTING on/off based on a global flag.
  2. Luminous flag (piVar6[5] < 0) zeroes Diffuse+Ambient, effectively making the mesh render as Emissive-only * texture. Non-luminous uses the full lighting equation.
  3. Surface.Luminosity is written to D3DMATERIAL9.Emissive.rgb. Confirmed at chunk_00590000.c:10669-10674.
  4. Surface.Transparency is written to 4 per-vertex alpha slots (one per corner of a quad Surface), via FUN_0053a430 at chunk_00530000.c:7706-7715.
  5. Fog stays ENABLED during the sky render. The keyframe fog range (MinWorldFog → MaxWorldFog) is likely tuned so sky geometry at its rendered distance is not heavily fogged.

Q1 — Fog state during sky render

Answer: Fog stays ENABLED. Retail does not toggle fog around the sky pass.

Evidence: I searched every call to FUN_005a3f90 (D3DRS_FOGENABLE writer). All call sites:

chunk_00500000.c:6293   FUN_005a3f90(DAT_0081dbf8);           # FUN_005062e0 per-frame master gate
chunk_00500000.c:7270   FUN_005a3f90(DAT_008427a9 != '\0');   # FUN_00507a50 weather-volume pass
chunk_00500000.c:7295   FUN_005a3f90(cVar4 != '\0');          # FUN_00507a50 restore
chunk_005A0000.c:707    FUN_005a3f90(0);                      # device-init default
chunk_005A0000.c:1344   FUN_005a3f90(DAT_008ee545);           # device-reset

FUN_00508010 (sky render) does NOT call FUN_005a3f90. The per-frame master gate at FUN_005062e0:6291 fires BEFORE the sky render inside the same function:

// chunk_00500000.c:6235-6333  FUN_005062e0
if (*(int *)(param_1 + 0x10) != 0) {
    if (*(int *)(param_1 + 0x20) != 0) {
      FUN_00508010();          // sky render
    }
    ...
    FUN_005a4010(DAT_0081dbf8 == '\0');          // master fog gate, NOT disable
    if (DAT_0081dbf8 != '\0') {
      FUN_005a3f90(DAT_0081dbf8);                 // FOG = ON if master flag set
      ...lerp fog...
      FUN_005a41b0(&fogColor, fogNear, fogFar);   // write FOGCOLOR/START/END
    }
}

The fog is master-controlled by DAT_0081dbf8 (application-level toggle). When outdoors it is typically ON.

The sky meshes render THROUGH fog. If the sky GfxObj's far-placement distance exceeds FOGEND, the fog color will dominate. This is why retail keys MinWorldFog/MaxWorldFog per-SkyTimeOfDay — to tune how fog bleeds into the sky.

Q2 — What FUN_0059da60 writes per-mesh (the real per-Surface state setter)

FUN_00514b90 is only a transform-enqueue wrapper. The real per-Surface material/D3D state writer is FUN_0059da60 at chunk_00590000.c:10586-10795, called downstream by the scene-graph flush. Critical region:

// chunk_00590000.c:10641-10689
FUN_005a3d80((DAT_008ee070 == 0) + '\x01');       // D3DRS_CULLMODE

if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) {
    uVar12 = 1;
} else {
    uVar12 = 0;
}
FUN_005a41f0(uVar12);                              // D3DRS_LIGHTING ← uVar12

if ((char)piVar6[5] < '\0') {                      // Surface.Luminous flag
    FUN_005a4310(1);
    if (*(int *)(DAT_00870340 + 0x7e4) == 0) {
      _DAT_008ee03c = DAT_00821e38;                // D3DMATERIAL9.Diffuse.A = 0
      _DAT_008ee044 = 0x3f800000;                  // D3DMATERIAL9.Ambient.A = 1.0f
      _DAT_008ee038 = DAT_00821e38;                // D3DMATERIAL9.Ambient.R = 0
      _DAT_008ee040 = DAT_00821e38;                // D3DMATERIAL9.Ambient.B = 0
      _DAT_008ee02c = DAT_00821e38;                // D3DMATERIAL9.Diffuse.G = 0
      _DAT_008ee028 = DAT_00821e38;                // D3DMATERIAL9.Diffuse.R = 0
      _DAT_008ee030 = DAT_00821e38;                // D3DMATERIAL9.Diffuse.B = 0
      _DAT_008ee034 = 0x3f800000;                  // D3DMATERIAL9.Diffuse.A = 1.0f (overwrite)
      (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4))
                (*(int **)(DAT_00870340 + 0x468), &DAT_008ee028);   // SetMaterial
      FUN_005a3ef0(0);                             // D3DRS_COLORVERTEX = 0  (ignore vertex colour)
      FUN_005a3f40(0);                             // (state 0x93)
    }
}
else if (DAT_00796344 < *(float *)(param_2 + 0x78)) {  // Surface.Luminosity > 0
    iVar8 = *(int *)(DAT_00870340 + 0x7e4);
    if (iVar8 == 0) {
      DAT_008ee058 = *(undefined4 *)(param_2 + 0x78);   // Emissive.R = Luminosity
      DAT_008ee064 = 0x3f800000;                        // Emissive.A = 1.0f
      DAT_008ee05c = DAT_008ee058;                      // Emissive.G = Luminosity
      DAT_008ee060 = DAT_008ee058;                      // Emissive.B = Luminosity
      (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4))
                (*(int **)(DAT_00870340 + 0x468), &DAT_008ee028);   // SetMaterial
    }
}

Material-block global at DAT_008ee028 — mapped byte-for-byte to D3DMATERIAL9:

Offset from 0x008ee028 Global D3DMATERIAL9 field
+0x00 DAT_008ee028 Diffuse.R
+0x04 DAT_008ee02c Diffuse.G
+0x08 DAT_008ee030 Diffuse.B
+0x0c DAT_008ee034 Diffuse.A
+0x10 DAT_008ee038 Ambient.R
+0x14 DAT_008ee03c Ambient.G
+0x18 DAT_008ee040 Ambient.B
+0x1c DAT_008ee044 Ambient.A
+0x20..0x2c DAT_008ee048..054 Specular.RGBA (not touched in this hunt)
+0x30 DAT_008ee058 Emissive.R = Luminosity
+0x34 DAT_008ee05c Emissive.G = Luminosity
+0x38 DAT_008ee060 Emissive.B = Luminosity
+0x3c DAT_008ee064 Emissive.A = 1.0f

Verification of offsets: luminous path sets +0x0c (Diffuse.A) to 0 via _DAT_008ee03c = DAT_00821e38. Wait — that's at +0x0c from 0x008ee028 = 0x008ee034. Let me re-read: line 10652 sets _DAT_008ee03c; line 10659 sets _DAT_008ee034 to 1.0f. The former is 0x14 bytes in (Ambient.G); the latter is 0x0c (Diffuse.A).

Reconciling: the luminous path sets Diffuse R=G=B=0, A=1 (via DAT_008ee02c, 028, 030, 034 all at +0x00..0x0c), Ambient R=G=B=0, A=1 (DAT_008ee038, 03c, 040, 044 at +0x10..0x1c). Then SetMaterial pushes the whole block — but crucially Emissive at +0x30..0x3c is UNCHANGED from whatever the previous caller left it at for luminous meshes. This is a subtle retail bug/feature: if the preceding draw set Emissive to some value, the next luminous draw inherits it.

For non-luminous with Luminosity > 0 (the "else if" branch, line 10666), only Emissive is updated — Diffuse/Ambient are left from the prior FUN_0059d520 call or from some other writer.

Referenced writer FUN_0059d520 at line 10636 is where Diffuse/Ambient get set for normal rendering (texture-modulated). Not fully traced here — but confirmed: Diffuse/Ambient are NOT zero for non-luminous meshes.

Q3 — FUN_00512360/124b0/120c0 + FUN_00518e70/ee0/f50 + FUN_0050f040/0c0/140

These are the PhysicsPart per-part setters called by the sky render loop. Each is a "set or enqueue-animation" pair. Chain:

FUN_00508010 (sky object render loop)
  → FUN_00512360(part, Luminosity, 0, 0)        # "set or animate Luminosity"
       ├── [animated] FUN_0051c580(3, ...)       # animation keyframe schedule
       └── [immediate] FUN_00518ee0(Luminosity)
                 → foreach Surface in part: FUN_0050f0c0(Surface, Luminosity)
                     → writes Surface.offset_0xd4 = Luminosity       (PhysicsPart +0xd4)
                     → if active: FUN_0053a460(material_cache, Luminosity)
                         → writes cache +0x3c, +0x40, +0x44 = Luminosity, Luminosity, Luminosity

  → FUN_005124b0(part, MaxBright, 0, 0)         # same pattern for MaxBright → +0xd0 → FUN_0053a490 → cache +0x0c, +0x10, +0x14
  → FUN_005120c0(part, Transparency, 0, 0)      # same pattern for Transparency → +0xcc → FUN_0053a430 → cache +0x18, +0x28, +0x38, +0x48 (alpha for 4 verts, stored as 1-Transparency)

File:line evidence:

// chunk_00510000.c:2267-2298  FUN_00512360 (Luminosity set-or-animate)
if (_DAT_007c78bc <= (float)(double)CONCAT44(param_5,param_4)) {
    // animation branch — enqueue keyframe
    iVar3 = FUN_0051c580(3, ...);
    ...
}
else if (*(int *)(param_1 + 0x10) != 0) {
    FUN_00518ee0(param_3);                     // immediate apply
}

// chunk_00510000.c:7901-7915  FUN_00518ee0 (Luminosity broadcast to Surfaces)
void FUN_00518ee0(int param_1, undefined4 param_2) {
  if ((*(int *)(param_1 + 0x54) != 0) && (uVar1 = 0, *(int *)(param_1 + 0x58) != 0)) {
    do {
      if (*(int *)(*(int *)(param_1 + 0x5c) + uVar1 * 4) != 0) {
        FUN_0050f0c0(param_2);                 // per-Surface Luminosity set
      }
      uVar1 = uVar1 + 1;
    } while (uVar1 < *(uint *)(param_1 + 0x58));
  }
}

// chunk_00500000.c:13557-13582  FUN_0050f0c0 (PhysicsPart.Luminosity write)
if (param_2 != *(float *)(param_1 + 0xd4)) {
    *(float *)(param_1 + 0xd4) = param_2;       // PhysicsPart +0xd4 = Luminosity
    ...
    iVar2 = FUN_0050e100();
    if (iVar2 != 0) {
        FUN_0053a460(param_2);                  // material cache broadcast
    }
}

// chunk_00530000.c:7732-7741  FUN_0053a460 (material cache: 3-float slot)
void FUN_0053a460(int param_1, undefined4 param_2) {
  *(undefined4 *)(param_1 + 0x3c) = param_2;
  *(undefined4 *)(param_1 + 0x40) = param_2;
  *(undefined4 *)(param_1 + 0x44) = param_2;
}

Same chain for MaxBright (FUN_005124b0 → FUN_00518f50 → FUN_0050f040 → +0xd0 → FUN_0053a490) and Transparency (FUN_005120c0 → FUN_00518e70 → FUN_0050f140 → +0xcc → FUN_0053a430). The Transparency writer applies alpha = 1 - Transparency to FOUR alpha slots at +0x18, +0x28, +0x38, +0x48 (one per corner of a quad-Surface's 4 vertices).

Interpretation: FUN_0053a4b0 initializes this cache struct with eight consecutive 1.0f values at param_1[3..10] (offsets +0x0c..+0x28). This is a per-Surface fixed-function render cache holding material-like data for 4 vertices. The fields:

Offset Field Set by
+0x0c, +0x10, +0x14 MaxBright R, G, B (3 floats) FUN_0053a490
+0x18, +0x28, +0x38, +0x48 vertex alpha v0, v1, v2, v3 (1-Transparency) FUN_0053a430
+0x3c, +0x40, +0x44 Luminosity R, G, B FUN_0053a460

This is NOT a D3DMATERIAL9. It's retail's bespoke per-Surface colour cache. The Surface.Luminosity/MaxBright/Transparency set on PhysicsPart via FUN_00512360/124b0/120c0 gets stored in:

  1. PhysicsPart struct (+0xcc, 0xd0, 0xd4) — persistent part state.
  2. Per-Surface material cache (+0x3c.., +0x0c.., +0x18..) — render-time values.

Then when FUN_0059da60 builds the actual D3DMATERIAL9 to submit to D3D, it reads param_2 + 0x78 = Surface.Luminosity — this is the Surface-level Luminosity (from the dat), NOT the animated PhysicsPart Luminosity. The cache struct's Luminosity (+0x3c..) is for a different purpose — likely per-vertex colour modulation when COLORVERTEX is on (see Q5). I did NOT find the exact consumer of cache +0x3c within the 60-minute budget — it may flow into vertex colour on the vertex-fill path.

Plainly: retail sky's per-mesh luminosity overrides are stored in two places and consumed by two different stages (material push for non-luminous meshes, per-vertex colour cache for others).

Q4 — D3DRS_LIGHTING during sky pass

D3DRS_LIGHTING is ON for normal meshes (including sky), OFF for the weather-volume overlay (rain/snow/fog cells).

Evidence: FUN_0059da60 at chunk_00590000.c:10642-10648 sets LIGHTING ON unless a global override forces it off:

if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) {
    uVar12 = 1;                                   // ← LIGHTING = ON
} else {
    uVar12 = 0;                                   // ← LIGHTING = OFF
}
FUN_005a41f0(uVar12);                             // D3DRS_LIGHTING ← uVar12

DAT_008ee06c is the "rendering flag" set in various places — when its value is 0 (default), LIGHTING = 1. The DAT_00870340 + 0x7e0 flag is a secondary override. Practically: lighting is ON for all visible mesh draws.

Corollary: Since LIGHTING is ON, the material fields (Diffuse, Ambient, Emissive) drive the output. With Diffuse=0 and Emissive=Luminosity (the luminous branch), output = texture × Luminosity. With Diffuse!=0 and Emissive=Luminosity (non-luminous branch with Surface.Luminosity), output = texture × (Emissive + Diffuse × dot(N, L) × sunLight + Ambient × AMBIENT).

Device-init default at chunk_005A0000.c:709 sets FUN_005a41f0(0) (LIGHTING OFF), but this is the startup state; scene render flips it per-mesh.

Q5 — Sky-pass vs terrain-pass render state diff

Retail does NOT distinguish sky from terrain at the render-state level. Both go through FUN_0059da60 (per-mesh state setter). Per-draw state that CAN differ, all driven by Surface flags or globals:

D3D state Who flips it per draw Varies per-sky-mesh?
CULLMODE (0x16) FUN_005a3d80 at 10641 No — all meshes same (DAT_008ee070 global)
LIGHTING (0x89) FUN_005a41f0 at 10648 No — driven by DAT_008ee06c global
COLORVERTEX (0x91) FUN_005a3ef0 at 10662 (luminous path only) Yes — luminous sky meshes set COLORVERTEX=0
Material (SetMaterial, not a RS) (vtable+0xc4) at 10660, 10673, 10686 Yes — per-Surface Luminosity/flag
FOGENABLE (0x1c) Only FUN_005062e0 (per-frame gate) No — set once per frame
AMBIENT (0x8b) Only init (FUN_005a3eb0(0)) No — always 0
ZFUNC/ZWRITE (0x17/0x0e) Only FUN_00507a50 weather volume No for sky proper

Conclusion for Q5: sky and terrain share state. The ONLY per-draw divergence for sky is via Surface.Luminous flag, which (a) zeroes Diffuse+Ambient, (b) sets COLORVERTEX=0. Non-luminous sky meshes render identically to terrain except for the material Emissive field.

This means: in retail, a cloud mesh (non-luminous) gets the same lighting treatment as a grass vertex — Emissive + Diffuse*dot(N,L)*sunColor + Ambient*D3DRS_AMBIENT. Since D3DRS_AMBIENT=0, the Ambient term drops; the output is Emissive + Diffuse × dot(N, L) × sunColor — i.e. per-vertex directional lighting.

A dome mesh (luminous) with Surface.Luminosity = X renders as Emissive(X,X,X) * texture (no diffuse, no ambient) — essentially a fade between off (X=0) and full-texture (X=1).

Q6 — Verbatim formula for C# port

The retail sky fragment equation, per GfxObj Surface:

# Stage 1: Material + vertex-colour build
if Surface.Luminous:
    material.Diffuse  = (0, 0, 0, 1)
    material.Ambient  = (0, 0, 0, 1)
    material.Emissive = (Lum, Lum, Lum, 1)         # Lum = Surface.Luminosity
    vertexColour = white                           # COLORVERTEX = 0
else:
    material.Diffuse  = surfaceBaseDiffuse         # from Surface texture modulate
    material.Ambient  = surfaceBaseAmbient         # likely (1,1,1,1) default
    material.Emissive = (Lum, Lum, Lum, 1)         # Lum = Surface.Luminosity (≥ 0)
    vertexColour = <per-vertex AdjustPlanes output>   # pre-lit per-vertex

# Stage 2: D3D fixed-function lighting (LIGHTING = ON; AMBIENT = 0)
litColour = material.Emissive
          + material.Diffuse  * D3DLight.Diffuse * dot(N, -sunDir)    # sunDir from FUN_00501600
          + material.Ambient  * 0                                     # AMBIENT=0, drops out
          # Specular ignored (0)

# Stage 3: Texture modulate + vertex colour
fragment.rgb = texture.rgb * litColour.rgb * vertexColour.rgb
fragment.a   = texture.a   * litColour.a   * vertexColour.a

# Stage 4: Fog blend (FOGENABLE = ON per master)
if z > FOGSTART:
    t = clamp((z - FOGSTART) / (FOGEND - FOGSTART), 0, 1)
    fragment.rgb = lerp(fragment.rgb, FOGCOLOR, t)

For the acdream sky shader, this reduces to:

// For LUMINOUS sky sub-meshes (dome, sun, moon, stars if Luminous=true):
fragment = texture(uSky, uv) * vec4(uLuminosity, uLuminosity, uLuminosity, 1.0) * uTransparency;
// where uLuminosity = Surface.Luminosity (0..1 fraction)
// and uTransparency is the keyframe-override-animated 1-Transparency.
// NO ambient multiplication. NO sun-direction. No fog.

// For NON-LUMINOUS sky sub-meshes (typical clouds):
vec3 diffuseTerm = diffuseColour * sunColour * max(0, dot(N, -sunDir));
vec3 emissiveTerm = vec3(uLuminosity);                // usually 0 for clouds
vec3 lit = emissiveTerm + diffuseTerm;                // D3DRS_AMBIENT=0 drops that term
fragment.rgb = texture(uSky, uv).rgb * lit;
fragment.a   = texture(uSky, uv).a * (1 - transparency);
// Optional fog: retail leaves fog ENABLED, but sky distance vs FOGEND
// determines whether fog contribution is visible. For acdream, first port
// assume sky is rendered NEAR clip so fog doesn't dominate.

Immediate actionable change for acdream:

  1. Our current fragment = texture × uLuminosity × uTint (uTint=white) matches retail for luminous sub-meshes. Correct behaviour — the over-bright observation is NOT from tinting.
  2. The over-bright problem is almost certainly that our Luminosity values are wrong. Previous fix scaled dat values / 100 (percent→fraction). Retail does Surface.Luminosity × _DAT_007a1870. If _DAT_007a1870 = 1.0f (strong evidence: it's used as the "default/identity" return in FUN_00518c00/c20), AND the dat values are in [0..1], retail renders texture × dat_luminosity with NO /100 scaling. Our /100 would then be UNDER-bright. But user says we're OVER-bright — so the dat values ARE in percent, 0..100, and our /100 scaling is correct.
  3. However, we may be applying Luminosity twice, or not applying it to the right meshes. Dome at dusk has Luminosity that INTERPOLATES (from the SkyObjectReplace keyframe) — currently a constant 1.0 in our renderer would render too bright.
  4. Non-luminous clouds should get texture × (Emissive + Diffuse × dot(N, -sun) × sunColour) — not texture × 1. Our clouds being "too bright" is consistent with us skipping the diffuse-dot-sun shading entirely.

Remaining uncertainty

  • _DAT_007a1870 exact value — evidence leans to 1.0f (identity), so our C# port should treat dat Luminosity/Transparency/MaxBright as already in the right units (no /100) and feed them directly. But user observation requires a /100 to look less bright, so either (a) dat values are in percent and _DAT_007a1870 = 0.01f, or (b) our shader is applying Luminosity in an additional place it shouldn't.
  • The role of the per-Surface material cache struct (FUN_0053a4b0 constructed, +0x0c..+0x48 fields) in the final fragment colour. It's written by the PhysicsPart L/MB/T animation setters, but I didn't track its consumer to D3D. Likely feeds COLORVERTEX-ON vertex alpha/RGB for non-luminous meshes.
  • Whether param_2 + 0x78 (Surface.Luminosity in FUN_0059da60) is the same float as PhysicsPart +0xd4 (Luminosity set by FUN_0050f0c0). The dual-path suggests they're distinct — one is Surface-level (from the dat), one is PhysicsPart-level (animated override).

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:13524-13617 FUN_0050f040/0c0/140 (PhysicsPart T/L/MB fields)
  • chunk_00510000.c:2115-2376 FUN_005120c0/12360/124b0 (set-or-animate entry)
  • chunk_00510000.c:4563-4591 FUN_00514b90 (transform enqueue — NOT the material writer)
  • chunk_00510000.c:7865-7963 FUN_00518e70/ee0/f50 (Surface broadcast)
  • chunk_00530000.c:7702-7764 FUN_0053a430/460/490 (per-Surface material cache fill)
  • chunk_00590000.c:10586-10795 FUN_0059da60 (the real per-mesh D3DMATERIAL9 + LIGHTING + COLORVERTEX writer)
  • chunk_005A0000.c:687-740 FUN_005a10f0 (device-init default state: LIGHTING=0, AMBIENT=0, FOG=0)