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

441 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```c
// 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:
```c
// 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:
```c
// 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:
```c
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:**
```glsl
// 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)