From aa94cedc38435a7618def818579ee777d29bbae1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:27:27 +0200 Subject: [PATCH 01/16] =?UTF-8?q?fix(render):=20A7=20point-light=20shape?= =?UTF-8?q?=20=E2=80=94=20per-vertex=20Gouraud=20+=20faithful=20calc=5Fpoi?= =?UTF-8?q?nt=5Flight=20(wrap=20+=20norm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The torch/point-light look was wrong two ways, both now fixed against the named retail decomp (calc_point_light 0x0059c8b0) via our verified LightBake.PointContribution port: 1. Per-PIXEL → per-VERTEX. accumulateLights moved from mesh_modern.frag to mesh_modern.vert so point lights Gouraud-interpolate across each triangle the way retail's fixed-function T&L does. The per-pixel eval made a tight, hard-edged "spotlight" pool on flat walls; per-vertex is a soft, broad gradient. frag now just consumes the interpolated vLit (+ fog + flash). 2. Simplified ramp → faithful calc_point_light shape. The live point/spot branch was max(0,N·L) × linear(1−d/range) × cap — missing two terms our LightBake.cs port already has: • half-Lambert WRAP (1/1.5)·(N·D + 0.5·d), D un-normalised — a face angled away from a torch still catches light (retail's soft terminator) instead of snapping to black. • distance-cube NORM branch norm = distsq>1 ? distsq·d : d — inverse- square-ish soft far halo + punchy near field, vs the flat linear ramp. Per-channel no-blowout cap (min(scale·color, color)) retained. The per-channel cap was also added to the legacy mesh.frag for consistency. A read-only retail-vs-acdream lighting audit (11-agent workflow) confirmed these two as the cause of the "better but a bit off" look and cleared the ambient/sun/terrain/color-space chain as already faithful. Remaining confirmed divergences (per-object light selection; dungeon static vertex bake) are filed as the next fixes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/Shaders/mesh.frag | 5 +- .../Rendering/Shaders/mesh_modern.frag | 48 ++--------- .../Rendering/Shaders/mesh_modern.vert | 81 +++++++++++++++++++ 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 45fe4e7f..f2e879ae 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); atten *= (cos_l > cos_edge) ? 1.0 : 0.0; } - lit += Lcol * ndl * atten; + // Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single + // point/spot light can't push a channel past its own colour, regardless of + // intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag. + lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz); } } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 040e15b2..4f344369 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -4,6 +4,7 @@ in vec3 vNormal; in vec2 vTexCoord; in vec3 vWorldPos; +in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in flat uvec2 vTextureHandle; in flat uint vTextureLayer; @@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { - vec3 lit = uCellAmbient.xyz; - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - vec3 toL = uLights[i].posAndKind.xyz - worldPos; - float d = length(toL); - float range = uLights[i].dirAndRange.w; - if (d < range && range > 1e-3) { - vec3 Ldir = toL / max(d, 1e-4); - float ndl = max(0.0, dot(N, Ldir)); - // Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0, - // line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a - // LINEAR fade to exactly 0 at the edge. That is what makes a torch a - // smooth glow that blends into the ambient instead of a flat disc with - // a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7). - // falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded - // into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp - // denominator is just Range and fades to 0 exactly at the cutoff. - float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0); - if (kind == 2) { - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - atten *= (cos_l > cos_edge) ? 1.0 : 0.0; - } - lit += Lcol * ndl * atten; - } - } - } - return lit; -} +// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match +// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight" +// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/ +// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed +// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload. vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); @@ -114,8 +82,8 @@ void main() { if (color.a < 0.05) discard; } - vec3 N = normalize(vNormal); - vec3 lit = accumulateLights(N, vWorldPos); + // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). + vec3 lit = vLit; // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index ce4378ac..fa150cbc 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -96,9 +96,89 @@ uniform mat4 uViewProjection; // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. uniform int uDrawIDOffset; +// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO +// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO +// above). IDENTICAL std140 layout to mesh_modern.frag. +// +// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so +// torch/point lights Gouraud-interpolate across each triangle the way retail's +// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for +// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls; +// per-vertex spreads it into a soft, broad gradient with no hard edge. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + if (kind == 0) { + // Directional (sun): forward points INTO the scene; N·(-forward) = light-facing. + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + // Point / spot — FAITHFUL port of calc_point_light (0x0059c8b0) via our + // verified LightBake.PointContribution (LightBake.cs:46-77). D = light − + // vertex, used UN-normalised (length = dist); N is the unit vertex normal. + // (A7 2026-06-15 #2: the prior model was a simplification — plain + // max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a + // flatter falloff than retail. The two terms below are the fix.) + vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised) + float distsq = dot(toL, toL); + float d = sqrt(distsq); + float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3 + if (d < range && range > 1e-4) { + // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so + // N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch + // still catch light — retail's soft terminator. wrap≤0 = fully shadowed + // (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5. + float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); + if (wrap > 0.0) { + // NORM branch (the distance-cube term): beyond 1 m, divide by + // distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by + // d only, to dodge a near singularity. This is the "punchy near, + // soft far" shape the flat linear ramp was flattening. + float norm = (distsq > 1.0) ? (distsq * d) : d; + float intensity = uLights[i].colorAndIntensity.w; + float scale = (1.0 - d / range) * intensity * (wrap / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // Per-channel no-blowout cap to the light's OWN colour (un-intensity- + // scaled): a single light can't push a channel past its colour + // (dat torch intensity ~100 would saturate). Summed lit clamped in frag. + vec3 baseCol = uLights[i].colorAndIntensity.xyz; + lit += min(scale * baseCol, baseCol); + } + } + } + } + return lit; +} + out vec3 vNormal; out vec2 vTexCoord; out vec3 vWorldPos; +out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out flat uvec2 vTextureHandle; out flat uint vTextureLayer; @@ -123,6 +203,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); + vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; From 4345e77d62d6385e9264326344cecad0dfd2c626 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:47:40 +0200 Subject: [PATCH 02/16] =?UTF-8?q?fix(render):=20A7=20Fix=20B=20=E2=80=94?= =?UTF-8?q?=20per-OBJECT=20point-light=20selection=20(minimize=5Fobject=5F?= =?UTF-8?q?lighting)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outdoor objects brightened as the camera approached: lighting selected the nearest 8 lights to the VIEWER and fed that one global set to everything (LightManager.Tick), so a building's wall torches only lit it once the camera got close enough for them to win the global top-8. Probe confirmed the scale of the problem: a single Holtburg view registers 129 point lights — the global cap of 8 was hopeless. Retail selects up to 8 lights PER OBJECT by the object's own position (minimize_object_lighting 0x0054d480), so a torch always lights the wall it sits on, camera-independent. Ported faithfully: - LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus BuildPointLightSnapshot for the per-frame stable-indexed light list. - mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the snapshot), binding=5 per-instance light SET (8 int indices into it, -1 = unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO (cleared as faithful by the lighting audit) and loops THIS instance's point lights. pointContribution factored out (same calc_point_light wrap+norm shape). - WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site (constant across the entity's parts), by the entity's AABB sphere; threaded into grp.LightSets parallel to grp.Matrices; global + per-instance buffers uploaded in Phase 5. Camera-independent ⇒ stable for static buildings. - GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame. Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green (parallel-array lockstep preserved). Visually gated: the meeting hall now holds steady as the camera approaches (was the popping symptom). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++ .../Rendering/Shaders/mesh_modern.vert | 131 +++++++++------ .../Rendering/Wb/WbDrawDispatcher.cs | 149 +++++++++++++++++- src/AcDream.Core/Lighting/LightManager.cs | 121 ++++++++++++++ .../Lighting/LightManagerTests.cs | 112 +++++++++++++ 5 files changed, 473 insertions(+), 50 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8f27733a..3735979e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7766,6 +7766,16 @@ public sealed class GameWindow : IDisposable // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); Lighting.Tick(camPos); + + // Fix B (A7 #3): build this frame's point-light snapshot and hand it to + // the entity dispatcher for per-OBJECT light selection + // (minimize_object_lighting). Replaces the single global nearest-8-to- + // camera UBO set for point/spot lights so a wall's torches stay tied to + // the wall as the camera moves. The SUN + ambient still flow through the + // SceneLighting UBO built below (binding=1) — terrain/sky read those. + Lighting.BuildPointLightSnapshot(camPos); + _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index fa150cbc..2efd4a96 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; +// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ===== +// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position +// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it +// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA +// UBO set (LightManager.Tick) made a wall brighten as the camera approached +// (its torches swapping into the global top-8). Two SSBOs replace that for +// point/spot lights (the SUN + ambient still come from the SceneLighting UBO): +// +// binding=4 — GLOBAL point/spot light array, uploaded once per frame from +// LightManager.PointSnapshot. The index of a light here is stable for the frame. +// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per +// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0 +// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills +// it once per entity (the set is constant across the entity's parts/tuples). +struct GlobalLight { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std430, binding = 4) readonly buffer GlobalLightBuf { + GlobalLight gLights[]; +}; +layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { + int instanceLightIdx[]; // 8 per instance; -1 = unused +}; + // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -119,58 +146,64 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { +// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light — +// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D = +// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal. +// Returns the RGB to ADD, already per-channel capped to the light's own colour. +vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { + int kind = int(L.posAndKind.w); + vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised) + float distsq = dot(toL, toL); + float d = sqrt(distsq); + float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 + if (d >= range || range <= 1e-4) return vec3(0.0); + // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). N·D = d·cosθ (D un-normalised); the + // +0.5·d bias lets a face angled AWAY from the torch still catch light (retail's + // soft terminator). wrap≤0 = fully shadowed. TwoLpr=1.5, WrapBias=0.5. + float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); + if (wrap <= 0.0) return vec3(0.0); + // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo; + // <1 m → just d (dodge the near singularity). "Punchy near, soft far." + float norm = (distsq > 1.0) ? (distsq * d) : d; + float intensity = L.colorAndIntensity.w; + float scale = (1.0 - d / range) * intensity * (wrap / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(L.coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, L.dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled): + // a single light can't push a channel past its colour. Summed lit clamped in frag. + vec3 baseCol = L.colorAndIntensity.xyz; + return min(scale * baseCol, baseCol); +} + +vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { vec3 lit = uCellAmbient.xyz; + + // SUN / directional — from the SceneLighting UBO (global; the audit cleared + // the ambient + sun chain as already faithful). Any point/spot entries still + // present in the UBO from LightManager.Tick are IGNORED here — point lights + // now come per-object from the SSBO below, so there's no double-count. int activeLights = int(uCellAmbient.w); for (int i = 0; i < 8; ++i) { if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - // Directional (sun): forward points INTO the scene; N·(-forward) = light-facing. - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - // Point / spot — FAITHFUL port of calc_point_light (0x0059c8b0) via our - // verified LightBake.PointContribution (LightBake.cs:46-77). D = light − - // vertex, used UN-normalised (length = dist); N is the unit vertex normal. - // (A7 2026-06-15 #2: the prior model was a simplification — plain - // max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a - // flatter falloff than retail. The two terms below are the fix.) - vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised) - float distsq = dot(toL, toL); - float d = sqrt(distsq); - float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3 - if (d < range && range > 1e-4) { - // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so - // N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch - // still catch light — retail's soft terminator. wrap≤0 = fully shadowed - // (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5. - float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); - if (wrap > 0.0) { - // NORM branch (the distance-cube term): beyond 1 m, divide by - // distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by - // d only, to dodge a near singularity. This is the "punchy near, - // soft far" shape the flat linear ramp was flattening. - float norm = (distsq > 1.0) ? (distsq * d) : d; - float intensity = uLights[i].colorAndIntensity.w; - float scale = (1.0 - d / range) * intensity * (wrap / norm); - if (kind == 2) { - // Spotlight: hard-edged cos-cone gate layered on the point ramp. - vec3 Ldir = toL / max(d, 1e-4); - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - if (cos_l <= cos_edge) scale = 0.0; - } - // Per-channel no-blowout cap to the light's OWN colour (un-intensity- - // scaled): a single light can't push a channel past its colour - // (dat torch intensity ~100 would saturate). Summed lit clamped in frag. - vec3 baseCol = uLights[i].colorAndIntensity.xyz; - lit += min(scale * baseCol, baseCol); - } - } - } + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; // forward points INTO the scene + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } + + // POINT / SPOT — THIS object's selected set (minimize_object_lighting): 8 int + // slots per instance into the global light buffer, -1 = unused. Camera- + // independent, so a wall's torches light it the same regardless of viewer pos. + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + lit += pointContribution(N, worldPos, gLights[gi]); } return lit; } @@ -203,7 +236,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); - vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting + vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights) vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e266be8c..6fbc3cd6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; +using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using AcDream.Core.Terrain; @@ -132,6 +133,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _clipSlotSsbo; private uint[] _clipSlotData = new uint[256]; + // Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two + // SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot + // lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4) + // holds the per-frame point-light snapshot (LightManager.PointSnapshot); + // _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per + // instance INTO it (-1 = unused), laid out parallel to _instanceSsbo. + private uint _globalLightsSsbo; + private uint _instLightSetSsbo; + private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; + private float[] _globalLightData = new float[16 * 16]; // 16 floats (4 vec4) per GlobalLight + // This frame's point-light snapshot, handed in by GameWindow before Draw via + // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). + private IReadOnlyList? _pointSnapshot; + // This entity's selected point/spot light set — computed ONCE per entity at + // the isNewEntity site (constant across the entity's parts/tuples), exactly + // like _currentEntitySlot. -1 = unused slot. + private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject]; + // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 // (not yet wired), we bind our OWN fallback no-clip region buffer below so the @@ -329,8 +348,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _batchSsbo = _gl.GenBuffer(); _indirectBuffer = _gl.GenBuffer(); _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 + _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 + _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 } + /// + /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot + /// (). Call once per frame BEFORE + /// . The dispatcher uploads it to binding=4 and selects each + /// object's up-to-8 lights from it () + /// by the object's bounding sphere — camera-independent. Pass null/empty to + /// disable per-object point lights (only ambient + sun render). + /// + public void SetSceneLights(IReadOnlyList? pointSnapshot) + => _pointSnapshot = pointSnapshot; + /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that created. The @@ -888,7 +920,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); } + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1053,6 +1085,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_currentEntityCulled) probeCulledEntities++; + // Fix B: select this entity's up-to-8 point/spot lights ONCE (the set + // is constant across the entity's parts/tuples), by the entity's + // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). + ComputeEntityLightSet(entity); + // #119 decisive probe: one-shot dump (+ change re-emission) for // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue // so a routed-out entity still reports its state. @@ -1350,6 +1387,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_clipSlotData.Length < totalInstances) _clipSlotData = new uint[totalInstances + 256]; + // Fix B: per-instance light-set buffer, MaxLightsPerObject ints per + // instance, laid out in the SAME group order / cursor as _instanceData + // so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks + // Instances[instanceIndex] (binding=0). + if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) + _lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1375,6 +1419,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Slots[] is parallel to Matrices[] within the group; write the // slot at the same cursor so binding=3 stays aligned with binding=0. _clipSlotData[cursor] = grp.Slots[i]; + // Fix B: LightSets[] holds 8 ints per instance, parallel to + // Matrices[]; copy this instance's block to the same cursor so + // binding=5 stays aligned with binding=0. + int lsDst = cursor * LightManager.MaxLightsPerObject; + int lsSrc = i * LightManager.MaxLightsPerObject; + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) + _lightSetData[lsDst + k] = grp.LightSets[lsSrc + k]; cursor++; } @@ -1460,6 +1511,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); + // Fix B: global point-light buffer (binding=4) + per-instance light-set + // buffer (binding=5). The global buffer is this frame's PointSnapshot; the + // per-instance buffer holds 8 int indices into it per instance, laid out + // parallel to _instanceData in Phase 3. Both bound with ≥1 element so the + // shader never reads an unbound SSBO on a no-lights frame. + UploadGlobalLights(); + fixed (int* lp = _lightSetData) + UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); + fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); @@ -1743,6 +1803,50 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } + /// + /// Fix B: pack into the binding=4 global light + /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, + /// matching mesh_modern.vert's GlobalLight). Always uploads ≥1 element + /// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is + /// a zeroed dummy that no instance set references (all sets are -1). + /// + private unsafe void UploadGlobalLights() + { + var snap = _pointSnapshot; + int n = snap?.Count ?? 0; + int count = n > 0 ? n : 1; // never zero-size + int floatsNeeded = count * 16; + if (_globalLightData.Length < floatsNeeded) + _globalLightData = new float[floatsNeeded + 16 * 16]; + Array.Clear(_globalLightData, 0, floatsNeeded); + + for (int i = 0; i < n; i++) + { + var L = snap![i]; + int o = i * 16; + // posAndKind (xyz world pos, w kind) + _globalLightData[o + 0] = L.WorldPosition.X; + _globalLightData[o + 1] = L.WorldPosition.Y; + _globalLightData[o + 2] = L.WorldPosition.Z; + _globalLightData[o + 3] = (int)L.Kind; + // dirAndRange (xyz forward, w range = Falloff×1.3) + _globalLightData[o + 4] = L.WorldForward.X; + _globalLightData[o + 5] = L.WorldForward.Y; + _globalLightData[o + 6] = L.WorldForward.Z; + _globalLightData[o + 7] = L.Range; + // colorAndIntensity (xyz linear colour, w intensity) + _globalLightData[o + 8] = L.ColorLinear.X; + _globalLightData[o + 9] = L.ColorLinear.Y; + _globalLightData[o + 10] = L.ColorLinear.Z; + _globalLightData[o + 11] = L.Intensity; + // coneAngleEtc (x cone radians; yzw reserved) + _globalLightData[o + 12] = L.ConeAngle; + } + + fixed (float* gp = _globalLightData) + UploadSsbo(_globalLightsSsbo, 4, gp, count * 16 * sizeof(float)); + } + /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -1936,6 +2040,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices + AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices + } + + /// + /// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result + /// reused by every part/instance of it), by the entity's world bounding + /// sphere. Camera-independent (), so + /// a static building's torches stay constant as the viewer moves. Fills + /// ; unused slots are -1. On the no-lights + /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + private void ComputeEntityLightSet(WorldEntity entity) + { + Array.Fill(_currentEntityLightSet, -1); + var snap = _pointSnapshot; + if (snap is null || snap.Count == 0) return; + + if (entity.AabbDirty) entity.RefreshAabb(); + Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; + float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; + LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); + } + + /// + /// Fix B: append the current entity's 8-slot light set to a group's + /// , parallel to its Matrices (one + /// 8-int block per instance), mirroring grp.Slots.Add. + /// + private void AppendCurrentLightSet(InstanceGroup grp) + { + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) + grp.LightSets.Add(_currentEntityLightSet[k]); } private void ClassifyBatches( @@ -1993,6 +2129,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices + AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -2072,6 +2209,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.DeleteBuffer(_indirectBuffer); if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3 if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 + if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 + if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 if (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2257,5 +2396,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // _clipSlotData at the same cursor it writes Matrices[i] into _instanceData, // so the binding=3 instanceClipSlot[] tracks the binding=0 instance. public readonly List Slots = new(); + + // Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per + // instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected + // light index block for the instance whose matrix is Matrices[i]). At + // layout time the dispatcher copies each block into _lightSetData at the + // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 + // instance. -1 = unused slot. + public readonly List LightSets = new(); } } diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 24769c6e..95ea1edf 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -157,4 +157,125 @@ public sealed class LightManager _activeCount = baseSlot + filled; } + + // ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ── + // + // The single global nearest-8-to-VIEWER set above (Tick) is camera-relative: + // a wall's brightness changes as the camera moves because the wall's torches + // swap in/out of that global top-8. Retail instead picks up-to-8 lights PER + // OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480), + // so a torch always lights the wall it sits on, camera-independent. The two + // members below feed the per-instance light path in WbDrawDispatcher; Tick + // remains the source of the legacy single-UBO path + the sun slot. + + /// Max point/spot lights any one object can be lit by — retail's + /// D3D fixed-function 8-light cap (minimize_object_lighting). The sun + /// is global, not part of an object's per-object set, so all 8 are point/spot. + public const int MaxLightsPerObject = 8; + + /// Hard cap on the per-frame global point-light snapshot the shader + /// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128 + /// is generous. If exceeded, the nearest-to-camera are kept (cold path). + public const int MaxGlobalLights = 128; + + private readonly List _pointSnapshot = new(); + + /// + /// Per-frame snapshot of lit point/spot lights, stable-indexed for the global + /// shader light buffer and for per-object selection: the index of a light here + /// IS the index the per-instance light-set SSBO references. Built by + /// . + /// + public IReadOnlyList PointSnapshot => _pointSnapshot; + + /// + /// Rebuild from the registered lit point/spot + /// lights. The sun and unlit lights are excluded (the sun is global ambient- + /// path; unlit torches contribute nothing). When more than + /// qualify, keeps the nearest the camera so the + /// most relevant lights survive the cap. Call once per frame before + /// per-object selection. + /// + public void BuildPointLightSnapshot(Vector3 cameraWorldPos) + { + _pointSnapshot.Clear(); + foreach (var light in _all) + { + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared(); + _pointSnapshot.Add(light); + } + if (_pointSnapshot.Count > MaxGlobalLights) + { + _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); + _pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); + } + } + + /// + /// Select up to point/spot lights from + /// that reach the object sphere + /// (, ), nearest-first. + /// Faithful to retail's minimize_object_lighting (0x0054d480): a light + /// is a candidate iff its falloff sphere overlaps the object sphere — + /// (light.pos − center)² < (light.Range + radius)² — and when more + /// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the + /// farthest fall off). already folds + /// static_light_factor (1.3), matching the per-vertex cutoff so a + /// selected light always actually contributes in the shader. + /// + /// Writes indices INTO to + /// (ascending by distance) and returns the count. + /// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a + /// static object's set is stable and may be computed once. Unit-testable + /// without GL. + /// + /// + public static int SelectForObject( + IReadOnlyList snapshot, + Vector3 center, + float radius, + Span outIndices) + { + int cap = Math.Min(outIndices.Length, MaxLightsPerObject); + if (cap <= 0) return 0; + + Span keptDistSq = stackalloc float[MaxLightsPerObject]; + int count = 0; + + for (int li = 0; li < snapshot.Count; li++) + { + var light = snapshot[li]; + float reach = light.Range + radius; + float dsq = (light.WorldPosition - center).LengthSquared(); + if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object + + if (count < cap) + { + int j = count; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + count++; + } + else if (dsq < keptDistSq[cap - 1]) + { + int j = cap - 1; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + } + } + return count; + } } diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 1bb225a2..264c498c 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -144,4 +144,116 @@ public sealed class LightManagerTests mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } + + // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── + + [Fact] + public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in + mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out + mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Single(mgr.PointSnapshot); + Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); + } + + [Fact] + public void BuildPointLightSnapshot_IndexStable_InBudget() + { + var mgr = new LightManager(); + // Registration order preserved when under MaxGlobalLights (no sort). + mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(2, mgr.PointSnapshot.Count); + Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered + Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); + } + + [Fact] + public void SelectForObject_EmptySnapshot_ReturnsZero() + { + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_InRange_Selected() + { + var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(1, n); + Assert.Equal(0, idx[0]); + } + + [Fact] + public void SelectForObject_OutOfRange_Excluded() + { + // dist 10, range 5, radius 0 → 10 >= 5 → excluded. + var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_ObjectRadiusExtendsReach() + { + // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere + // overlaps (7 < 5+3). The whole object catches the light — retail uses the + // object's bounding sphere, not its centre point. + var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + + Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); + Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); + } + + [Fact] + public void SelectForObject_MoreThan8_KeepsNearest8() + { + // 10 candidate lights all in range; expect the 8 nearest the object centre, + // ascending by distance, with the two farthest dropped. + var snapshot = new LightSource[10]; + for (int i = 0; i < 10; i++) + snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range + + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + + Assert.Equal(8, n); + // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest + // (indices 8,9 / dist 9,10) are evicted. + for (int k = 0; k < 8; k++) + Assert.Equal(k, idx[k]); + } + + [Fact] + public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() + { + // Same snapshot, same object centre → identical selection regardless of + // where any "camera" is (the method takes no camera). This is the property + // that kills the "lights up as I approach" popping. + var snapshot = new[] + { + MakePoint(new Vector3(2, 0, 0), range: 10f), + MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 + }; + Span a = stackalloc int[8]; + Span b = stackalloc int[8]; + int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); + int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); + + Assert.Equal(1, na); + Assert.Equal(na, nb); + Assert.Equal(a[0], b[0]); + } } From 57c11358b62d82ca72579b4d9bc5ed1eeab138ec Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 15:08:52 +0200 Subject: [PATCH 03/16] =?UTF-8?q?fix(sky):=20A7=20=E2=80=94=20correct=20su?= =?UTF-8?q?n-vector=20magnitude=20(ambient=20+=20sun=20were=20~32%=20too?= =?UTF-8?q?=20bright)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight, binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup, acdream's ambient COLOR matched retail exactly (the purple is correct, authored per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459. level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06 vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright, y≈0. RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc:: GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1 term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost (SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor = DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky) ~30% and also pointing the sun the wrong way. Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180, 0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected (was pointing ~North from the bad y term). Tests updated to the cdb-verified values (the prior tests pinned the inflated magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/World/SkyState.cs | 71 ++++++++++--------- .../World/SkyDescLoaderTests.cs | 17 ++--- .../AcDream.Core.Tests/World/SkyStateTests.cs | 41 ++++++----- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 5acf2d39..0120e84a 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe( /// (see ). /// /// - /// Why |sunVec| instead of DirBright directly: retail's - /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c - /// (decomp line 424118-424119) computes - /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) - /// from the sun vector SkyDesc::GetLighting built at - /// 0x00500ac9 (decomp lines 261343-261353): - /// - /// sunVec.x = sin(H) × DirBright × cos(P) - /// sunVec.y = cos(P) // NOT scaled by DirBright - /// sunVec.z = DirBright × sin(P) - /// - /// Because Y is unscaled by DirBright, |sunVec| ≠ - /// DirBright in general — it varies with sun pitch and heading. - /// Using DirBright alone underweighted the warm directional - /// term, letting the cool ambient/fog dominate ⇒ acdream rendered - /// blue-white at keyframes where retail looked warm-gray. + /// |sunVec| is retail's D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²) + /// scaling (PrimD3DRender::UpdateLightsInternal 0x0059b57c, decomp + /// 424118-424119) of the WORLD-space sun vector (LScape::sunlight). + /// Because is now the + /// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified — + /// see that method), |sunVec| == DirBright, so this is effectively + /// SunColor = DirColor × DirBright. (A prior bug used the un-transformed + /// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~4–5× too bright at dawn/dusk; + /// [[reference-retail-ambient-values]].) /// /// public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); @@ -301,21 +294,35 @@ public sealed class SkyStateProvider } /// - /// Retail's raw sun vector (NOT normalized) — the same vector - /// SkyDesc::GetLighting writes at 0x00500ac9 - /// (decomp lines 261343, 261352, 261353): + /// Retail's world-space sun vector (NOT normalized): the standard + /// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by + /// DirBright: /// - /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) - /// sunVec.y = cos(P_rad) // NOT scaled by DirBright - /// sunVec.z = DirBright × sin(P_rad) + /// sunVec.x = DirBright × cos(P) × sin(H) + /// sunVec.y = DirBright × cos(P) × cos(H) + /// sunVec.z = DirBright × sin(P) /// - /// Y is unscaled by brightness on purpose — that's what makes - /// |sunVec|DirBright in general (the magnitude varies - /// with pitch/heading, which is the basis for retail's "sun is brighter - /// in some configurations than others" lighting behavior). The shader's - /// uSunDir uniform uses the NORMALIZED vector for N·L; the - /// magnitude feeds intensity and - /// the ambient brightness boost in . + /// so |sunVec| == DirBright exactly (cos²P·(sin²H+cos²H)+sin²P = 1). + /// + /// + /// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]): + /// retail's LScape::sunlight read at a dawn keyframe (H=90°, P=0.9°, + /// DirBright≈0.224) = (0.2238, ~0, 0.00352) — y≈0, magnitude 0.224 = + /// DirBright. That fed level = 0.2·|sunlight| + ambient_level = 0.2·0.224 + + /// 0.40 = 0.445, matching the captured SetWorldAmbientLight level. + /// + /// + /// PRIOR BUG: an earlier version returned y = cos(P) (≈1) — the raw + /// PRE-transform value the decomp's SkyDesc::GetLighting writes to its + /// arg5 (0x00500ac9, before LScape::set_sky_position's world + /// transform). Porting that un-transformed vector inflated |sunVec| to + /// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost + /// () AND the sun colour + /// () by ~30% vs retail. The world-space + /// form above is what LScape::sunlight actually holds at runtime. + /// + /// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright) + /// feeds the sun-colour intensity and the ambient brightness boost. /// public static Vector3 RetailSunVector(SkyKeyframe kf) { @@ -325,9 +332,9 @@ public sealed class SkyStateProvider float sinP = MathF.Sin(p); float B = kf.DirBright; return new Vector3( - MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P) - cosP, // y = cos(P) ← unscaled by B - B * sinP); // z = B × sin(P) + B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H) + B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H) + B * sinP); // z = DirBright × sin(P) } /// diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index d07d0a64..4ceeddba 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests { // The loader stores DirColor and DirBright RAW. The SunColor property // composes them via |sunVec| per retail's UpdateLightsInternal at - // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) - // where the sun vector is built from heading/pitch/brightness with - // Y unscaled by brightness (decomp 261352). + // 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|. + // cdb-verified (reference-retail-ambient-values): |LScape::sunlight| == + // DirBright for every keyframe (world-space spherical vector, magnitude + // DirBright·sqrt(cos²P+sin²P) = DirBright). // // For this region: H=180°, P=70°, B=1.5 - // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) - // = (0, 0.342, 1.410) - // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 + // sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70)) + // = (0, -0.513, 1.410) + // |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright) // DirColor.X = 200/255 = 0.7843 - // SunColor.X = 0.7843 × 1.4509 = 1.138 + // SunColor.X = 0.7843 × 1.500 = 1.1765 var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var loaded = SkyDescLoader.LoadFromRegion(region); Assert.NotNull(loaded); var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; - Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); + Assert.InRange(kf.SunColor.X, 1.17f, 1.18f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 1c677204..3d87da00 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -66,24 +66,33 @@ public sealed class SkyStateTests } [Fact] - public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() + public void RetailSunVector_MagnitudeAlwaysEqualsDirBright() { - // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. - // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) - // |sunVec| = 1 regardless of B (because Y is unscaled by B) - var kf = new SkyKeyframe( - Begin: 0f, - SunHeadingDeg: 0f, - SunPitchDeg: 0f, - DirColor: Vector3.One, - DirBright: 2.0f, // anything - AmbColor: Vector3.One, - AmbBright: 1f, - FogColor: Vector3.One, - FogDensity: 0f); + // cdb-verified (2026-06-18, reference-retail-ambient-values): retail's + // world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP), + // whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright + // for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the + // horizon — that was the ~30% over-bright bug.) + // Horizon north (H=0°, P=0°): (0, B, 0), |.| = B. + var horizon = new SkyKeyframe( + Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f, + DirColor: Vector3.One, DirBright: 2.0f, + AmbColor: Vector3.One, AmbBright: 1f, + FogColor: Vector3.One, FogDensity: 0f); + Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f); - var v = SkyStateProvider.RetailSunVector(kf); - Assert.InRange(v.Length(), 0.99f, 1.01f); + // Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224 + // → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright. + var dawn = new SkyKeyframe( + Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f, + DirColor: Vector3.One, DirBright: 0.224f, + AmbColor: Vector3.One, AmbBright: 0.40f, + FogColor: Vector3.One, FogDensity: 0f); + var v = SkyStateProvider.RetailSunVector(dawn); + Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224 + Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1) + Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035 + Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright } [Fact] From f384d036a3e9da31a42e0230de954ce86a71c404 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 15:35:00 +0200 Subject: [PATCH 04/16] =?UTF-8?q?docs:=20A7=20lighting=20handoff=20?= =?UTF-8?q?=E2=80=94=20Fix=20A/B/C=20shipped,=20Fix=20D=20(object=20torch?= =?UTF-8?q?=20over-bright)=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session handoff: live-cdb grounding shipped Fix A (point-light shape), Fix B (per-object selection), Fix C (sun-vector magnitude / ~32% over-bright). Fix D (outdoor objects too bright near torches) is fully grounded but BLOCKED on one capture (the building's render path) — the D3D-FF math says it'd make objects brighter, so not ported. Full cdb cheat-sheet + the contradiction + the next capture in the doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lighting-a7-fixABC-shipped-fixD-handoff.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md diff --git a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md new file mode 100644 index 00000000..5af718f8 --- /dev/null +++ b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md @@ -0,0 +1,114 @@ +# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF + +**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main) +**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured +values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method). + +This session made acdream's outdoor + ambient lighting retail-faithful by grounding +everything in **live cdb on the retail client** (no guessing). Three fixes shipped; +a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but +**deliberately NOT implemented** because the math contradicts the observed result — +one more capture is needed first. + +## SHIPPED this session (all on `main`) + +| Fix | Commit | What | Result | +|---|---|---|---| +| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" | +| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 | +| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" | + +**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4 +GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int +light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` + +`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's +light set once per entity (like `_currentEntitySlot`), threads it parallel to the +matrices. + +**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the +PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before +`LScape::set_sky_position`'s world transform. cdb read retail's actual +`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the +world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`, +`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it +dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky +tests green (old tests pinned the inflated magnitude — updated to cdb-verified). + +## KEY LESSON: the "too purple" was NEVER a bug + +The user's side-by-side ("acdream too purple, retail neutral") was a comparison +**across different times of day**. Live cdb at the SAME game time + DayGroup proved +acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail +exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the +sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness* +was wrong (Fix C). Don't re-investigate the purple. + +--- + +## OPEN — Fix D: outdoor OBJECTS too bright near torches + +**Symptom (user, 2026-06-18):** the Holtburg meeting-hall walls blow out warm/bright +in acdream vs dim in retail. Fix A/B/C did NOT touch this. It's the per-object +point-light **contribution on objects**. + +### Grounded (cdb + decomp) — retail's object point-light path +`Render::config_hardware_light` (0x0059ad30) builds the `D3DLIGHT9`: +- `Diffuse = color × intensity` +- `Attenuation = (0, 1, 0)` ⇒ **1/d** (inverse-LINEAR; acdream's `calc_point_light` + is `~1/d²` via norm = distsq·d) +- `Range = falloff × rangeAdjust`, **`rangeAdjust = 1.5`** (0x00820cc4) ⇒ torch Range + = 6×1.5 = **9 m** (LARGER than acdream's falloff×1.3 = 7.8 m — range is NOT why + we're brighter) +- live `LIGHTINFO` captured: torch `type=0 intensity=100 falloff=6`; a 2nd light + `intensity=2.25 falloff=10` +- `d3d_material.Diffuse = (1,1,1)` white (decomp 0x00539774) + +### THE CONTRADICTION (resolve this FIRST next session) +By `mat(1)×color×100×(N·L)×(1/d)`, a torch 3 m away = `color×33` ⇒ retail's walls +SHOULD blow to **WHITE** — but they're **DIM**. Material diffuse, range, and +intensity are all captured and ruled out. So the scaling lives in the building's +**RENDER PATH**, which is unknown. **⚠ DO NOT port the D3D-FF model — by this math it +would make objects BRIGHTER (white), the opposite of the fix.** + +### The decisive next capture +Determine the static building's ACTUAL render path: +- **Hypothesis (a) — MOST LIKELY:** static buildings DON'T use D3D hardware lighting. + They use the `D3DPolyRender::SetStaticLightingVertexColors` BAKE (0x0059cfe0 → + `calc_point_light`), like EnvCells. The `config_hardware_light` lights I captured + were for a DIFFERENT object (player / creature / the purple PORTAL — note the + `intensity=100` could be the portal, not the wall torch). If (a) holds, acdream's + `calc_point_light` is the RIGHT model and the over-brightness is the **per-channel + cap** (`min(scale×col,col)` lets several torches each reach full colour and sum to + white) and/or **too many torches selected** per object and/or a missing clamp step. +- **Hypothesis (b):** `D3DRS_LIGHTING` off / lights not `LightEnable`'d for the + building draw. +- **How to capture:** break at `SetStaticLightingVertexColors` (0x0059cfe0) and see + whether it's called for the building's mesh (confirms the bake path); and/or + inspect the render state around the static-object `DrawIndexedPrimitive` + (`D3DRS_LIGHTING`, which lights are enabled). Also: at `config_hardware_light`, + dump WHICH object/owner the light is being configured for to identify whether the + `intensity=100` light is the torch or the portal. + +### acdream side — where the fix lands +- acdream runs `calc_point_light` (wrap/norm + per-channel cap) for ALL meshes via + `mesh_modern.vert` `pointContribution` (objects AND cells — Fix A). +- If buildings use the bake, the likely fix is in the **cap / sum / count**, not the + attenuation model. Files: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` + (`pointContribution` + `accumulateLights`), `src/AcDream.Core/Lighting/LightManager.cs` + (`SelectForObject`), `LightBake.cs` (verbatim calc_point_light, still unwired). + +--- + +## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb) +- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]` +- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group` +- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770 +- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color` +- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0 +- Pattern: `.formats poi()` for floats, `dwo()` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first. +- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup). + +## Build / run +`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard +`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks +the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green. From 6b562ad0774c5ff00730ee59201be779abe7f369 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 15:37:02 +0200 Subject: [PATCH 05/16] =?UTF-8?q?docs:=20file=20#140=20(Fix=20D=20?= =?UTF-8?q?=E2=80=94=20outdoor=20objects=20too=20bright=20near=20torches)?= =?UTF-8?q?=20+=20register=20UN-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A7 lighting Fix A/B/C shipped this session; Fix D (object torch over-brightness) grounded but blocked on the render-path capture. Filed as #140 + divergence register UN-7 (object point-light model unconfirmed). Detail in the 2026-06-18 handoff doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 19 +++++++++++++++++++ .../retail-divergence-register.md | 1 + 2 files changed, 20 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c8a0f65b..1685a925 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,25 @@ Copy this block when adding a new issue: --- +## #140 — A7 "Fix D": outdoor objects too bright near torches + +**Status:** OPEN +**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C) +**Filed:** 2026-06-18 +**Component:** render — point lighting on outdoor objects + +**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*. + +**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.** + +**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired). + +**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`. + +**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches). + +--- + ## #139 — D.2b retail UI polish: chat text colors + buttons **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1148560e..9fbfed32 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -194,6 +194,7 @@ equivalence argument (promote to AD/AP) or a fix. | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | +| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | --- From c407104ab937a4c037f228b8efe2570358f9d9d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:08:27 +0200 Subject: [PATCH 06/16] docs(lighting): A7 Fix D investigation RESOLVED + implementation spec (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the Fix D contradiction with decomp (workflow wf_f660eb88 + adversarial verify) + 4 live cdb captures. The D3D-FF model was the WRONG oracle: retail has TWO light systems — STATIC torches BAKE into wall vertices (calc_point_light, triple-clamped: range gate + per-channel min(scale*color,color) + per-vertex [0,1] from black), DYNAMIC lights go D3D hardware. The captured intensity=100 is the purple PORTAL (magenta, dynamic), not a wall torch. Ground truth: 38 static warm torches (orange (1,0.588,0.314)/cream, intensity=100, falloff 3-5) + 2 dynamic. acdream over-brightness = two confirmed bugs: D-1 mesh_modern.vert folds ambient+sun+torches into one UNCLAMPED accumulator (single frag clamp) -> warm blowout; D-2 EnvCellRenderer never binds SSBO 4/5 so the cell shell reads a leaked light set. Spec: D-1 in-shader clamp-split (clamp the torch sum on its own before ambient/sun); D-2 bind the shell's own per-cell light set (mirror WbDrawDispatcher); LightBake.cs is the C# conformance oracle. Adds the 4 reusable cdb capture scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lighting-a7-fixABC-shipped-fixD-handoff.md | 118 ++++++---- ...6-06-18-a7-fixd-torch-overbright-design.md | 211 ++++++++++++++++++ tools/cdb/a7-fixd-golden-probe.cdb | 15 ++ tools/cdb/a7-fixd-golden2-probe.cdb | 17 ++ tools/cdb/a7-fixd-lights-v2.cdb | 36 +++ tools/cdb/a7-fixd-lights.cdb | 50 +++++ tools/cdb/a7-fixd-numstatic-probe.cdb | 18 ++ 7 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md create mode 100644 tools/cdb/a7-fixd-golden-probe.cdb create mode 100644 tools/cdb/a7-fixd-golden2-probe.cdb create mode 100644 tools/cdb/a7-fixd-lights-v2.cdb create mode 100644 tools/cdb/a7-fixd-lights.cdb create mode 100644 tools/cdb/a7-fixd-numstatic-probe.cdb diff --git a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md index 5af718f8..381860de 100644 --- a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md +++ b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md @@ -45,57 +45,83 @@ was wrong (Fix C). Don't re-investigate the purple. --- -## OPEN — Fix D: outdoor OBJECTS too bright near torches +## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18) -**Symptom (user, 2026-06-18):** the Holtburg meeting-hall walls blow out warm/bright -in acdream vs dim in retail. Fix A/B/C did NOT touch this. It's the per-object -point-light **contribution on objects**. +**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream +vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE, +yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these +walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify + +4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** — +not because it'd be too bright, but because it's the wrong path entirely. -### Grounded (cdb + decomp) — retail's object point-light path -`Render::config_hardware_light` (0x0059ad30) builds the `D3DLIGHT9`: -- `Diffuse = color × intensity` -- `Attenuation = (0, 1, 0)` ⇒ **1/d** (inverse-LINEAR; acdream's `calc_point_light` - is `~1/d²` via norm = distsq·d) -- `Range = falloff × rangeAdjust`, **`rangeAdjust = 1.5`** (0x00820cc4) ⇒ torch Range - = 6×1.5 = **9 m** (LARGER than acdream's falloff×1.3 = 7.8 m — range is NOT why - we're brighter) -- live `LIGHTINFO` captured: torch `type=0 intensity=100 falloff=6`; a 2nd light - `intensity=2.25 falloff=10` -- `d3d_material.Diffuse = (1,1,1)` white (decomp 0x00539774) +### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems) +- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) → + `D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` + (0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex + colours. AC town buildings are EnvCell structures, so their walls take this path. +- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0) + → `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170) + enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware- + enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free + GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen — + carries DYNAMIC lights for cells, not the wall torches. -### THE CONTRADICTION (resolve this FIRST next session) -By `mat(1)×color×100×(N·L)×(1/d)`, a torch 3 m away = `color×33` ⇒ retail's walls -SHOULD blow to **WHITE** — but they're **DIM**. Material diffuse, range, and -intensity are all captured and ruled out. So the scaling lives in the building's -**RENDER PATH**, which is unknown. **⚠ DO NOT port the D3D-FF model — by this math it -would make objects BRIGHTER (white), the opposite of the fix.** +### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`) +Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`; +`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1−d/range)·intensity·(wrap/norm)`; +then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds +**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from +**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the +sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range +lights stacking past 1.0; a hall has a handful, each warm-capped. -### The decisive next capture -Determine the static building's ACTUAL render path: -- **Hypothesis (a) — MOST LIKELY:** static buildings DON'T use D3D hardware lighting. - They use the `D3DPolyRender::SetStaticLightingVertexColors` BAKE (0x0059cfe0 → - `calc_point_light`), like EnvCells. The `config_hardware_light` lights I captured - were for a DIFFERENT object (player / creature / the purple PORTAL — note the - `intensity=100` could be the portal, not the wall torch). If (a) holds, acdream's - `calc_point_light` is the RIGHT model and the over-brightness is the **per-channel - cap** (`min(scale×col,col)` lets several torches each reach full colour and sum to - white) and/or **too many torches selected** per object and/or a missing clamp step. -- **Hypothesis (b):** `D3DRS_LIGHTING` off / lights not `LightEnable`'d for the - building draw. -- **How to capture:** break at `SetStaticLightingVertexColors` (0x0059cfe0) and see - whether it's called for the building's mesh (confirms the bake path); and/or - inspect the render state around the static-object `DrawIndexedPrimitive` - (`D3DRS_LIGHTING`, which lights are enabled). Also: at `config_hardware_light`, - dump WHICH object/owner the light is being configured for to identify whether the - `intensity=100` light is the torch or the portal. +### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`) +`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104, +`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights` +@ +0x3588. Captured standing in Holtburg: +- **num_static_lights = 38**, **num_dynamic_lights = 2.** +- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 1–2): viewer light `intensity=2.25 falloff=10 + color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA. + → **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.** +- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange + `(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 3–5 + (→ bake range ~3.9–6.5 m). Torches DO carry intensity=100, but the per-channel cap + pins each to its warm colour ⇒ retail walls go warm, not white. -### acdream side — where the fix lands -- acdream runs `calc_point_light` (wrap/norm + per-channel cap) for ALL meshes via - `mesh_modern.vert` `pointContribution` (objects AND cells — Fix A). -- If buildings use the bake, the likely fix is in the **cap / sum / count**, not the - attenuation model. Files: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` - (`pointContribution` + `accumulateLights`), `src/AcDream.Core/Lighting/LightManager.cs` - (`SelectForObject`), `LightBake.cs` (verbatim calc_point_light, still unwired). +### acdream's actual bug — TWO real causes (both verified in source) +- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.** + `mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS + sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is + one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light + cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches + into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the + torch sum on its OWN (from black); ambient/sun are a separate term. +- **D-2 (state, compounding): EnvCell shell SSBO binding leak.** + `EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER + 4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at + :204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a + cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound — + a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls. +- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is + the in-shader version missing the clamp shape. + +### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule) +- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE + adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the + point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files: + `mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the + single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch + bakes warm-but-≤[0,1], NOT white. +- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set) + for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet` + (LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP + (needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal. +- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the + point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A + (`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap. +- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's + color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the + baked walls. --- diff --git a/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md new file mode 100644 index 00000000..1ad1a645 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md @@ -0,0 +1,211 @@ +# A7 Fix D — warm torch over-brightness on indoor walls (#140) + +**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting +**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan. +**Investigation source of truth:** +[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md) +(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`. + +## Problem + +The Holtburg meeting-hall walls (and outdoor objects near torches) blow out +**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this. + +The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE, +yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle** +for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous): + +- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) → + `SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its + SOLE caller). Wall torches are STATIC objects → baked into vertex colours. +- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light` → `config_hardware_light` + (0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic + subset for a cell. The previously-captured `intensity=100` light is on THIS path. + +`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the +decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own +sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to +`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights; +a hall has a handful, each warm-capped. + +### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0) + +Holtburg: **38 static + 2 dynamic** lights. + +| Light | path | type | intensity | falloff | colour (r,g,b) | +|---|---|---|---|---|---| +| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white | +| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch | +| 38× wall torch | static / **bake** | point | 100 | 3–5 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream | + +Torches carry `intensity=100` too, but the per-channel cap pins each to its warm +colour ⇒ retail walls go warm, never white. + +## Root cause in acdream (both verified in source) + +Two independent bugs, both touching the meeting-hall walls; this spec fixes both. + +**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.** +[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert) +`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each +capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in +`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful. +But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and +trimming only at the end overflows to warm-white. Retail clamps the torch sum on its +OWN (from black); ambient/sun are a separate material-lit term. + +**D-2 (state, compounding): EnvCell shell SSBO binding leak.** +[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs) +binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which +the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher` +binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever +LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's +torches, wrong per-instance indices) ⇒ wrong/over-bright walls. + +`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers). + +## Design + +Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake); +**D-1 + D-2 land together**, single visual verification. + +### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`) + +In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator, +saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and +`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`. + +```glsl +vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { + // ambient + sun = retail's material-lit term + vec3 lit = uCellAmbient.xyz; + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } + // point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive) + vec3 pointAcc = vec3(0.0); + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged + } + lit += min(pointAcc, vec3(1.0)); // <-- THE FIX + return lit; // frag still does final min(lit, 1.0) +} +``` + +Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 — +normally-lit surfaces are byte-identical (no regression). Shared by every mesh using +this shader (outdoor objects AND cell walls), matching the issue's scope. +`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the +retail FF pixel clamp). The lightning bump (frag:89) is unaffected. + +### D-2 — the EnvCell shell binds its OWN light set + +`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading +leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern +(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`): + +1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained + pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already + re-uploads its own `uViewProjection`; it now also uploads/binds its own lights. +2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed + identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout: + `posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot → + same indices both renderers reference. `BuildPointLightSnapshot` is already called + once per frame before rendering. **Extract the packing into a shared helper** so the + two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a + static on the snapshot type) — do not copy-paste the struct layout. +3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`), + compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter, + cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's + part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int + set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape + as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no + point light) and still bind a ≥1-element buffer so the SSBO is never unbound. +4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the + cell's existing visibility bound (the BSP/AABB sphere already computed for culling). + The exact field is pinned during planning by reading the cell-storage structs in + `EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform + translation, radius from the cell vertex AABB. **This is the one detail to confirm + against code in the plan.** + +Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can +be implemented in either order, but ship together. + +## Testing (TDD) + +`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped +(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum +reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp +mirrors `ComputeVertexColor`'s final clamp exactly. + +New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`): + +- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100` + `falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at + d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel + ≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate). +- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed + via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds). +- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not + desaturated toward white. + +These pin the contract the shader must match. GLSL is not unit-testable in-process +(standard for this project per the render digest); the shader `pointContribution` + +`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C# +oracle as the pinned reference (call it out in the implementation commit). + +## Bookkeeping — divergence register + +- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it + describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert + wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in + `mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp + makes the accumulator MORE faithful (no new deviation introduced). +- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing + per-object approximation (retail bakes per-VERTEX over the full static list; acdream + selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's + register row covers EnvCell shells; extend that row if needed — do NOT add a + contradicting row. + +## Files + +- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split. +- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct + (expected no change). +- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell + light sets, bind SSBO 4 + 5, per-instance light-set buffer. +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) — + extract the binding-4 global-lights packing so both renderers share it. +- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into + `EnvCellRenderer.Initialize` (minimal). +- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new. +- `docs/architecture/retail-divergence-register.md` — AP-35 update. + +## Acceptance criteria + +- `dotnet build` green; `dotnet test` green including the new conformance test. +- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved). +- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against + `LightBake` (cited in the commit). +- AP-35 corrected; any D-2 register note reconciled with Fix B's row. +- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the + Holtburg meeting-hall walls render warm-but-dim like retail. + +## Out of scope (explicit) + +- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s + `color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics, + not the baked walls. Wrong oracle (handoff warning stands). +- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen + approach is the in-shader clamp split. `LightBake.cs` stays the test oracle. +- Sun handling on indoor walls is unchanged (kept in the material-lit term as today); + any "should indoor walls receive sun at all" refinement is a separate question. +- The purple portal is correct — do not touch it. diff --git a/tools/cdb/a7-fixd-golden-probe.cdb b/tools/cdb/a7-fixd-golden-probe.cdb new file mode 100644 index 00000000..07627206 --- /dev/null +++ b/tools/cdb/a7-fixd-golden-probe.cdb @@ -0,0 +1,15 @@ +$$ A7 Fix D — GOLDEN: dump the nearest static lights (the meeting-hall wall torches) +$$ + the ambient/sun that acdream folds into its accumulator. Breakpoint-free, instant. +$$ Render::world_lights @ 0x008672a0; sorted_static_lights[] (RenderLight*) @ +0x3498 +$$ (verified: num_static_lights@+0x104=38, num_dynamic_lights@+0x3588=2). +$$ Stand near the meeting-hall torches so the nearest sorted lights ARE them. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === ambient_color / sunlight_color / sunlight (what acdream folds into the accumulator) === +dt -r1 acclient!Render::world_lights ambient_color sunlight_color sunlight num_static_lights num_dynamic_lights +.echo === nearest 10 sorted static lights (RenderLight.d3dLightIndex + info: type/intensity/falloff/color) === +.for (r $t0=0; @$t0 < 10; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RenderLight=%p ---\n", @$t0, @$t1; dt -r2 acclient!RenderLight @$t1 d3dLightIndex distancesq info } +.echo === END === +qd diff --git a/tools/cdb/a7-fixd-golden2-probe.cdb b/tools/cdb/a7-fixd-golden2-probe.cdb new file mode 100644 index 00000000..e4ed7d0e --- /dev/null +++ b/tools/cdb/a7-fixd-golden2-probe.cdb @@ -0,0 +1,17 @@ +$$ A7 Fix D — GOLDEN v2: explicit LIGHTINFO/RGBColor dump of the nearest static +$$ lights. info @ RenderLight+0x70 (LIGHTINFO); within info: color@+0x50, intensity@+0x5C, +$$ falloff@+0x60 -> absolute color@RL+0xC0, intensity@RL+0xCC, falloff@RL+0xD0. +$$ Characterizes the 38-light static set (warm town torches?) + golden for the fix. +$$ Breakpoint-free, instant, uses current scene. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden2-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === ambient_color (r,g,b) === +dt acclient!RGBColor acclient!Render::world_lights+0x0 +.echo === sunlight_color (r,g,b) === +dt acclient!RGBColor acclient!Render::world_lights+0xc +.echo === nearest 8 sorted static lights: type/intensity/falloff + color(r,g,b) + distsq === +.for (r $t0=0; @$t0 < 8; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RL=%p d3dIdx=%d ---\n", @$t0, @$t1, dwo(@$t1+0x68); dt acclient!LIGHTINFO @$t1+0x70 type intensity falloff; .echo color(r,g,b):; dt acclient!RGBColor @$t1+0xc0; .echo distancesq:; dd @$t1+0xd8 L1 } +.echo === END === +qd diff --git a/tools/cdb/a7-fixd-lights-v2.cdb b/tools/cdb/a7-fixd-lights-v2.cdb new file mode 100644 index 00000000..03345800 --- /dev/null +++ b/tools/cdb/a7-fixd-lights-v2.cdb @@ -0,0 +1,36 @@ +$$ +$$ A7 Fix D (#140) v2 — fills the two gaps v1 left: +$$ (1) light COLORS (v1's dt did not expand RGBColor); expanded here as a +$$ typed RGBColor dump + raw dd hex backup (reinterpret IEEE-754 if dt fails). +$$ (2) the STATIC wall torches (the lights that actually BAKE the walls) — these +$$ only re-register on a visible-cell-set change, so the player must MOVE +$$ (walk IN and OUT of the meeting hall, circle past the torches) to trigger +$$ Render::add_static_light. +$$ +$$ v1 already proved: intensity=100/falloff=6 light is DYNAMIC (add_dynamic_light, +$$ d3dIdx=2) = the portal/effect on the hardware path, NOT a baked wall torch. +$$ viewer light = intensity 2.25 / falloff 10 (dynamic, d3dIdx=1). +$$ +$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): +$$ LIGHTINFO* = poi(@esp+4). color@+0x50 (r/g/b floats), origin@+0x38, intensity@+0x5C, falloff@+0x60. +$$ +$$ Dynamic logging is limited to the first 8 hits (we already characterised them); +$$ ALL static hits log. qd when 12 static torches captured OR 1500 total hits (safety). + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-v2.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t2 = 0 +r $t3 = 0 + +$$ STATIC wall torches (baked path) — MOVE to trigger. Color (typed + hex) + origin. +bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff cone_angle; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3; .echo origin_hex(x,y,z):; dd poi(@esp+4)+0x38 L3; .if (@$t2 >= 12) { qd } .elsif (@$t0 >= 1500) { qd } .else { gc }" + +$$ DYNAMIC lights (portal/viewer) — log first 8 with color, then silent gc. +bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .if (@$t3 <= 8) { .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3 }; .if (@$t0 >= 1500) { qd } .else { gc }" + +.printf "v2 armed: STATIC=wall torches (MOVE in/out of hall to trigger), DYNAMIC=portal/viewer; colors expanded. qd at 12 statics or 1500 total.\\n" +g diff --git a/tools/cdb/a7-fixd-lights.cdb b/tools/cdb/a7-fixd-lights.cdb new file mode 100644 index 00000000..34d2558b --- /dev/null +++ b/tools/cdb/a7-fixd-lights.cdb @@ -0,0 +1,50 @@ +$$ +$$ A7 Fix D (#140) — wall-torch vs portal light OWNERSHIP + the actual LIGHTINFO +$$ values that feed the EnvCell wall bake. 2026-06-18. +$$ +$$ Decomp already settled the render path (workflow wf_f660eb88): +$$ STATIC lights -> CPU per-vertex bake (SetStaticLightingVertexColors -> +$$ calc_point_light), DOUBLE-clamped (per-light min(scale*color,color) + +$$ per-vertex [0,1]) -> walls stay DIM even at intensity=100. +$$ DYNAMIC lights -> D3D hardware FF (minimize_envcell_lighting). +$$ Render::insert_light copies intensity VERBATIM to BOTH paths, so the only +$$ open empirical question is: which light carries intensity=100, and what do +$$ the actual wall-torch LIGHTINFOs look like (intensity/falloff/color)? +$$ +$$ CLASSIFICATION via config_hardware_light's d3dLightIndex (arg1 @ [esp+4]): +$$ add_dynamic_light base index = 1 -> dynamic idx in [1..10] (viewer light / teleport PORTAL) +$$ add_static_light base index = 11 -> static idx in [11..70] (WALL TORCHES, baked) +$$ +$$ config_hardware_light(d3dIndex, _D3DLIGHT9* out, ulong cellID, LIGHTINFO* info): +$$ d3dIndex = dwo(@esp+4) ; LIGHTINFO* = poi(@esp+0x10) (PROVEN last session) +$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): +$$ LIGHTINFO* = poi(@esp+4) +$$ `dt acclient!LIGHTINFO type intensity falloff color` resolves the +$$ float fields symbolically (PDB types) -> readable values, no hex reinterp. +$$ +$$ USAGE: with retail in-world standing in/near the Holtburg meeting hall by a +$$ wall torch, WALK around the hall (and past the teleport portal if present) +$$ for ~15 s so static torch sets re-register. Auto-detaches (qd) after 600 +$$ total hits, leaving retail running. + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-capture.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t1 = 0 +r $t2 = 0 +r $t3 = 0 + +$$ BP1: config_hardware_light — EVERY light (static+dynamic); d3dIdx classifies. +bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d (1-10=DYNAMIC portal/viewer, 11+=STATIC torch)\\n\", @$t1, dwo(@esp+4); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +$$ BP2: add_static_light — every hit is a WALL TORCH (baked path). +bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +$$ BP3: add_dynamic_light — viewer light + teleport PORTAL (hardware path). +bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" + +.printf "a7-fixd-lights armed: BP1 CHL (classify via d3dIdx), BP2 STATIC=torch, BP3 DYNAMIC=portal/viewer. qd after 600 total hits.\\n" +g diff --git a/tools/cdb/a7-fixd-numstatic-probe.cdb b/tools/cdb/a7-fixd-numstatic-probe.cdb new file mode 100644 index 00000000..155bbbce --- /dev/null +++ b/tools/cdb/a7-fixd-numstatic-probe.cdb @@ -0,0 +1,18 @@ +$$ A7 Fix D — instant (breakpoint-free) read of how many STATIC lights the +$$ current scene bakes with. Confirms whether the meeting hall has static torches +$$ (-> D-1 summed-torches matters) or near-zero (-> D-2 leaked-SSBO is the cause). +$$ Stand where the meeting-hall walls are visible. No movement / no breakpoints. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-numstatic-probe.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +.echo === x acclient!*world_lights* === +x acclient!*world_lights* +.echo === x acclient!Render::world_lights === +x acclient!Render::world_lights +.echo === dt typed (num_static_lights / num_dynamic_lights / ambient_color) === +dt acclient!Render::world_lights num_static_lights num_dynamic_lights ambient_color sunlight_color +.echo === dt LightParms at symbol (fallback by explicit type) === +dt acclient!LightParms acclient!Render::world_lights num_static_lights num_dynamic_lights +.echo === END === +qd From ad53180190ad239c22cd2f3ed10fbd64798af3cb Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:15:36 +0200 Subject: [PATCH 07/16] =?UTF-8?q?docs(plan):=20A7=20Fix=20D=20implementati?= =?UTF-8?q?on=20plan=20=E2=80=94=205=20tasks=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task TDD plan: (1) extract GlobalLightPacker (Core, pure) + test + refactor WbDrawDispatcher; (2) lock the bake contract via LightBake conformance test on the captured golden torches; (3) D-1 clamp the point-light sum on its own in mesh_modern.vert; (4) D-2 EnvCellRenderer binds its own per-cell light set (SSBO 4+5) via SelectForObject over cell bounds; (5) correct register AP-35 + reconcile Fix B. Concrete code + exact insertion points; visual verification is the acceptance gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-18-a7-fixd-torch-overbright.md | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md diff --git a/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md b/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md new file mode 100644 index 00000000..6110f3e3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md @@ -0,0 +1,603 @@ +# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white. + +**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`. + +**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md) + +**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 3–5 → Range 3.9–6.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white. + +**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build. + +--- + +## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher` + +Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change. + +**Files:** +- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs` +- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs` +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`) + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`: + +```csharp +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public class GlobalLightPackerTests +{ + [Fact] + public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout() + { + var light = new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(10f, 20f, 30f), + WorldForward = new Vector3(0f, 0f, 1f), + ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), + Intensity = 100f, + Range = 5.2f, + ConeAngle = 0f, + }; + float[] buffer = System.Array.Empty(); + + int count = GlobalLightPacker.Pack(new[] { light }, ref buffer); + + Assert.Equal(1, count); + Assert.True(buffer.Length >= 16); + // posAndKind + Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]); + Assert.Equal((float)(int)LightKind.Point, buffer[3]); + // dirAndRange + Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]); + Assert.Equal(5.2f, buffer[7]); + // colorAndIntensity + Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]); + Assert.Equal(100f, buffer[11]); + // coneAngleEtc + Assert.Equal(0f, buffer[12]); + } + + [Fact] + public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot() + { + float[] buffer = System.Array.Empty(); + int count = GlobalLightPacker.Pack(null, ref buffer); + Assert.Equal(0, count); + Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests` +Expected: FAIL — `GlobalLightPacker` does not exist (compile error). + +- [ ] **Step 3: Implement `GlobalLightPacker`** + +Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Lighting; + +/// +/// Packs a point-light snapshot into the flat float layout the bindless mesh +/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): +/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, +/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and +/// EnvCellRenderer share ONE layout and cannot drift. +/// +public static class GlobalLightPacker +{ + public const int FloatsPerLight = 16; + + /// + /// Fill (grown + zero-cleared as needed) with the + /// packed snapshot; returns the light count n. The buffer always has at + /// least floats (so a zero-light frame still + /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. + /// + public static int Pack(IReadOnlyList? snapshot, ref float[] buffer) + { + int n = snapshot?.Count ?? 0; + int floatsNeeded = Math.Max(n, 1) * FloatsPerLight; + if (buffer.Length < floatsNeeded) + buffer = new float[floatsNeeded + FloatsPerLight * 16]; + Array.Clear(buffer, 0, floatsNeeded); + + for (int i = 0; i < n; i++) + { + var L = snapshot![i]; + int o = i * FloatsPerLight; + buffer[o + 0] = L.WorldPosition.X; + buffer[o + 1] = L.WorldPosition.Y; + buffer[o + 2] = L.WorldPosition.Z; + buffer[o + 3] = (int)L.Kind; + buffer[o + 4] = L.WorldForward.X; + buffer[o + 5] = L.WorldForward.Y; + buffer[o + 6] = L.WorldForward.Z; + buffer[o + 7] = L.Range; + buffer[o + 8] = L.ColorLinear.X; + buffer[o + 9] = L.ColorLinear.Y; + buffer[o + 10] = L.ColorLinear.Z; + buffer[o + 11] = L.Intensity; + buffer[o + 12] = L.ConeAngle; + } + return n; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer** + +In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with: + +```csharp + private unsafe void UploadGlobalLights() + { + int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int count = n > 0 ? n : 1; // never zero-size + fixed (float* gp = _globalLightData) + UploadSsbo(_globalLightsSsbo, 4, gp, + count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)); + } +``` + +Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it. + +- [ ] **Step 6: Build and run the full Core test suite** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: build green; all tests pass (no regression — the packing is byte-identical). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches + +`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check). + +**Files:** +- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` + +- [ ] **Step 1: Write the conformance test** + +Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp), +/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that +/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1) +/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md. +/// +public class LightBakeConformanceTests +{ + private static LightSource OrangeTorch(Vector3 pos) => new() + { + Kind = LightKind.Point, + WorldPosition = pos, + ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange + Intensity = 100f, + Range = 4f * 1.3f, // falloff 4 × static_light_factor + IsLit = true, + }; + + [Theory] + [InlineData(1f)] + [InlineData(2f)] + [InlineData(3f)] + [InlineData(4f)] + [InlineData(5f)] + public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist) + { + // Wall vertex at the origin, normal facing the torch (+X). Torch out along +X. + var vtx = Vector3.Zero; + var normal = Vector3.UnitX; + var torch = OrangeTorch(new Vector3(dist, 0f, 0f)); + + var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch }); + + // Every channel bounded to [0,1] — intensity=100 must NOT blow to white. + Assert.InRange(c.X, 0f, 1f); + Assert.InRange(c.Y, 0f, 1f); + Assert.InRange(c.Z, 0f, 1f); + // Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering. + if (c.X > 0f) + { + Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}"); + Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}"); + } + } + + [Fact] + public void BeyondRange_ContributesNothing() + { + var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range + var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch }); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void ManyOverlappingIntenseTorches_StillClampToOne() + { + // Eight near-white intensity-100 torches all 1.5 m from the vertex: the + // [0,1] saturate must hold (no overflow past 1.0 per channel). + var lights = new List(); + for (int i = 0; i < 8; i++) + lights.Add(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(1.5f, 0.1f * i, 0f), + ColorLinear = new Vector3(0.98f, 0.95f, 0.9f), + Intensity = 100f, + Range = 5.2f, + IsLit = true, + }); + + var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights); + Assert.InRange(c.X, 0f, 1f); + Assert.InRange(c.Y, 0f, 1f); + Assert.InRange(c.Z, 0f, 1f); + } +} +``` + +- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests` +Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.) + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs +git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert` + +Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check. + +**Files:** +- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`) + +- [ ] **Step 1: Apply the clamp split** + +Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`): + +```glsl +vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { + vec3 lit = uCellAmbient.xyz; + + // SUN / directional — material-lit term (added with ambient, NOT into the + // torch sum), unchanged from before. + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } + + // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's + // SetStaticLightingVertexColors sums the static point lights from BLACK and + // clamps the SUM to [0,1] before anything else (it is a baked emissive term), + // so a few warm intensity-100 torches can't push the whole pixel to white the + // way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor + // (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap + // inside pointContribution is unchanged. + vec3 pointAcc = vec3(0.0); + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + pointAcc += pointContribution(N, worldPos, gLights[gi]); + } + lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive) + + return lit; // frag still does the final min(lit, 1.0) +} +``` + +(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.) + +- [ ] **Step 2: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.) + +- [ ] **Step 3: Review the math against the oracle** + +Confirm by reading both side-by-side that the shader's point path now matches `LightBake`: +- `mesh_modern.vert` `pointContribution` ↔ `LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal. +- new `min(pointAcc, vec3(1.0))` ↔ `LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum. +No code change expected here — this is the verification step the commit message cites. + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert +git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140) + +accumulateLights folded ambient+sun+torches into one accumulator clamped only +in the frag, so a few warm intensity-100 torches blew walls/objects to white. +Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp +to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches +LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5) + +Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern. + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot) + +- [ ] **Step 1: Add fields + the per-frame snapshot setter** + +In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add: + +```csharp + // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, + // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last + // left bound. binding=4 = global point-light snapshot (same data/indices as the + // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. + private uint _globalLightsSsbo; // binding=4 + private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; + private uint _instLightSetSsbo; // binding=5 + private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + private System.Collections.Generic.IReadOnlyList? _pointSnapshot; + private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); +``` + +Near `SetClipRouting` (~262) add the per-frame setter: + +```csharp + /// + /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot + /// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside + /// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets + /// reference this snapshot, which is also uploaded to binding=4 here, so the + /// pass is self-contained. Null/empty ⇒ shells receive no point lights. + /// + public void SetPointSnapshot( + System.Collections.Generic.IReadOnlyList? snapshot) + => _pointSnapshot = snapshot; +``` + +- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`** + +In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add: + +```csharp + // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. + _gl.GenBuffers(1, out _globalLightsSsbo); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); + + _gl.GenBuffers(1, out _instLightSetSsbo); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), + null, GLEnum.DynamicDraw); +``` + +- [ ] **Step 3: Add the per-cell light-set helper** + +Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`: + +```csharp + // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world + // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). + // Cached per frame; unused slots are -1 (shader adds no point light there). + private int[] GetCellLightSet(uint cellId) + { + if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached; + + var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + System.Array.Fill(set, -1); + + var snap = _pointSnapshot; + if (snap is { Count: > 0 } && + _landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) && + lb.EnvCellBounds.TryGetValue(cellId, out var b)) + { + Vector3 center = (b.Min + b.Max) * 0.5f; + float radius = (b.Max - b.Min).Length() * 0.5f; + AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set); + } + _cellLightSetCache[cellId] = set; + return set; + } +``` + +(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.) + +- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`** + +(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache: + +```csharp + _cellLightSetCache.Clear(); +``` + +(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it: + +```csharp + // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, + // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). + int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; + if (_lightSetData.Length < uniqueInstanceCount * lightStride) + _lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; + for (int i = 0; i < uniqueInstanceCount; i++) + { + int[] cellSet = GetCellLightSet(allInstances[i].CellId); + System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); + } +``` + +(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads: + +```csharp + // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). + int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int glUploadCount = lightCount > 0 ? lightCount : 1; + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), + null, GLEnum.DynamicDraw); + fixed (float* gp = _globalLightData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); + + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); + fixed (int* lp = _lightSetData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); +``` + +(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add: + +```csharp + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); +``` + +- [ ] **Step 5: Wire the snapshot from GameWindow** + +In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add: + +```csharp + _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) +``` + +- [ ] **Step 6: Dispose the new buffers** + +In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add: + +```csharp + if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); + if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); +``` + +- [ ] **Step 7: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140) + +The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left +bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own +binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5 +per-instance set, computed per cell by LightManager.SelectForObject over the +cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet). + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Divergence register — correct AP-35, reconcile the Fix B row + +**Files:** +- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row) + +- [ ] **Step 1: Correct AP-35** + +Find the `AP-35` row. It currently describes the point-light path as per-pixel +`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to +reflect reality after Fix A + Fix D D-1: +- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`. +- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`). +- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`. +- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle. + +- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row** + +Find the row describing Fix B (per-object 8-light selection by sphere overlap vs +retail's per-vertex sum over the full static list — `minimize_object_lighting` +0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per +cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the +"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a +new contradicting row. + +- [ ] **Step 3: Commit** + +```bash +git add docs/architecture/retail-divergence-register.md +git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification (after all tasks) + +- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green. +- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions). +- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires. +- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually). + +## Notes for the implementer + +- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning). +- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach). +- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug. +- **Don't touch the purple portal** — confirmed correct. From 180b4af2a96a839913b6cf292ff4e5cd79d576af Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:19:27 +0200 Subject: [PATCH 08/16] =?UTF-8?q?refactor(lighting):=20extract=20GlobalLig?= =?UTF-8?q?htPacker=20(shared=20binding=3D4=20layout)=20=E2=80=94=20A7=20F?= =?UTF-8?q?ix=20D=20prep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 37 ++----------- .../Lighting/GlobalLightPacker.cs | 55 +++++++++++++++++++ .../Lighting/GlobalLightPackerTests.cs | 45 +++++++++++++++ 3 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 src/AcDream.Core/Lighting/GlobalLightPacker.cs create mode 100644 tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6fbc3cd6..fa686b3c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -142,7 +142,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _globalLightsSsbo; private uint _instLightSetSsbo; private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; - private float[] _globalLightData = new float[16 * 16]; // 16 floats (4 vec4) per GlobalLight + private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight // This frame's point-light snapshot, handed in by GameWindow before Draw via // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). private IReadOnlyList? _pointSnapshot; @@ -1812,39 +1812,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// private unsafe void UploadGlobalLights() { - var snap = _pointSnapshot; - int n = snap?.Count ?? 0; + int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int count = n > 0 ? n : 1; // never zero-size - int floatsNeeded = count * 16; - if (_globalLightData.Length < floatsNeeded) - _globalLightData = new float[floatsNeeded + 16 * 16]; - Array.Clear(_globalLightData, 0, floatsNeeded); - - for (int i = 0; i < n; i++) - { - var L = snap![i]; - int o = i * 16; - // posAndKind (xyz world pos, w kind) - _globalLightData[o + 0] = L.WorldPosition.X; - _globalLightData[o + 1] = L.WorldPosition.Y; - _globalLightData[o + 2] = L.WorldPosition.Z; - _globalLightData[o + 3] = (int)L.Kind; - // dirAndRange (xyz forward, w range = Falloff×1.3) - _globalLightData[o + 4] = L.WorldForward.X; - _globalLightData[o + 5] = L.WorldForward.Y; - _globalLightData[o + 6] = L.WorldForward.Z; - _globalLightData[o + 7] = L.Range; - // colorAndIntensity (xyz linear colour, w intensity) - _globalLightData[o + 8] = L.ColorLinear.X; - _globalLightData[o + 9] = L.ColorLinear.Y; - _globalLightData[o + 10] = L.ColorLinear.Z; - _globalLightData[o + 11] = L.Intensity; - // coneAngleEtc (x cone radians; yzw reserved) - _globalLightData[o + 12] = L.ConeAngle; - } - + // Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats. fixed (float* gp = _globalLightData) - UploadSsbo(_globalLightsSsbo, 4, gp, count * 16 * sizeof(float)); + UploadSsbo(_globalLightsSsbo, 4, gp, + count * GlobalLightPacker.FloatsPerLight * sizeof(float)); } /// diff --git a/src/AcDream.Core/Lighting/GlobalLightPacker.cs b/src/AcDream.Core/Lighting/GlobalLightPacker.cs new file mode 100644 index 00000000..9de709a5 --- /dev/null +++ b/src/AcDream.Core/Lighting/GlobalLightPacker.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Lighting; + +/// +/// Packs a point-light snapshot into the flat float layout the bindless mesh +/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): +/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, +/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and +/// EnvCellRenderer share ONE layout and cannot drift. +/// +public static class GlobalLightPacker +{ + public const int FloatsPerLight = 16; + + /// + /// Fill (grown + zero-cleared as needed) with the + /// packed snapshot; returns the light count n. The buffer always has at + /// least floats (so a zero-light frame still + /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. + /// + public static int Pack(IReadOnlyList? snapshot, ref float[] buffer) + { + int n = snapshot?.Count ?? 0; + int floatsNeeded = Math.Max(n, 1) * FloatsPerLight; + if (buffer.Length < floatsNeeded) + buffer = new float[floatsNeeded + FloatsPerLight * 16]; + Array.Clear(buffer, 0, floatsNeeded); + + for (int i = 0; i < n; i++) + { + var L = snapshot![i]; + int o = i * FloatsPerLight; + // posAndKind (xyz world pos, w kind) + buffer[o + 0] = L.WorldPosition.X; + buffer[o + 1] = L.WorldPosition.Y; + buffer[o + 2] = L.WorldPosition.Z; + buffer[o + 3] = (int)L.Kind; + // dirAndRange (xyz forward, w range) + buffer[o + 4] = L.WorldForward.X; + buffer[o + 5] = L.WorldForward.Y; + buffer[o + 6] = L.WorldForward.Z; + buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff + // colorAndIntensity (xyz linear colour, w intensity) + buffer[o + 8] = L.ColorLinear.X; + buffer[o + 9] = L.ColorLinear.Y; + buffer[o + 10] = L.ColorLinear.Z; + buffer[o + 11] = L.Intensity; + // coneAngleEtc (x cone radians; yzw reserved) + buffer[o + 12] = L.ConeAngle; + } + return n; + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs b/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs new file mode 100644 index 00000000..174c4c41 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs @@ -0,0 +1,45 @@ +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public class GlobalLightPackerTests +{ + [Fact] + public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout() + { + var light = new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(10f, 20f, 30f), + WorldForward = new Vector3(0f, 0f, 1f), + ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), + Intensity = 100f, + Range = 5.2f, + ConeAngle = 0f, + }; + float[] buffer = System.Array.Empty(); + + int count = GlobalLightPacker.Pack(new[] { light }, ref buffer); + + Assert.Equal(1, count); + Assert.True(buffer.Length >= 16); + Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]); + Assert.Equal((float)(int)LightKind.Point, buffer[3]); + Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]); + Assert.Equal(5.2f, buffer[7]); + Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]); + Assert.Equal(100f, buffer[11]); + Assert.Equal(0f, buffer[12]); + } + + [Fact] + public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot() + { + float[] buffer = System.Array.Empty(); + int count = GlobalLightPacker.Pack(null, ref buffer); + Assert.Equal(0, count); + Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight); + } +} From 39c70f00aa34cf7c13697f2b2c4129b1fe2397c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:26:52 +0200 Subject: [PATCH 09/16] test(lighting): lock the bake contract on golden torches (A7 Fix D oracle) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Lighting/LightBakeConformanceTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs new file mode 100644 index 00000000..ab3c0803 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp), +/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that +/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1) +/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md. +/// +public class LightBakeConformanceTests +{ + private static LightSource OrangeTorch(Vector3 pos) => new() + { + Kind = LightKind.Point, + WorldPosition = pos, + ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange + Intensity = 100f, + Range = 4f * 1.3f, // falloff 4 × static_light_factor + IsLit = true, + }; + + [Theory] + [InlineData(1f)] + [InlineData(2f)] + [InlineData(3f)] + [InlineData(4f)] + [InlineData(5f)] + public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist) + { + var vtx = Vector3.Zero; + var normal = Vector3.UnitX; + var torch = OrangeTorch(new Vector3(dist, 0f, 0f)); + + var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch }); + + Assert.InRange(c.X, 0f, 1f); + Assert.InRange(c.Y, 0f, 1f); + Assert.InRange(c.Z, 0f, 1f); + if (c.X > 0f) + { + Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}"); + Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}"); + } + } + + [Fact] + public void BeyondRange_ContributesNothing() + { + var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); + var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch }); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void ManyOverlappingIntenseTorches_StillClampToOne() + { + var lights = new List(); + for (int i = 0; i < 8; i++) + lights.Add(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(1.5f, 0.1f * i, 0f), + ColorLinear = new Vector3(0.98f, 0.95f, 0.9f), + Intensity = 100f, + Range = 5.2f, + IsLit = true, + }); + + var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights); + Assert.InRange(c.X, 0f, 1f); + Assert.InRange(c.Y, 0f, 1f); + Assert.InRange(c.Z, 0f, 1f); + } +} From cf62793304e58846df44cbf7486efe98a9738949 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:29:45 +0200 Subject: [PATCH 10/16] =?UTF-8?q?fix(render):=20A7=20Fix=20D=20D-1=20?= =?UTF-8?q?=E2=80=94=20clamp=20the=20point-light=20sum=20on=20its=20own=20?= =?UTF-8?q?(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accumulateLights folded ambient+sun+torches into one accumulator clamped only in the frag, so a few warm intensity-100 torches blew walls/objects to white. Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/Shaders/mesh_modern.vert | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 2efd4a96..667db74d 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -183,29 +183,33 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { vec3 lit = uCellAmbient.xyz; - // SUN / directional — from the SceneLighting UBO (global; the audit cleared - // the ambient + sun chain as already faithful). Any point/spot entries still - // present in the UBO from LightManager.Tick are IGNORED here — point lights - // now come per-object from the SSBO below, so there's no double-count. + // SUN / directional — material-lit term (added with ambient, NOT into the + // torch sum), unchanged. int activeLights = int(uCellAmbient.w); for (int i = 0; i < 8; ++i) { if (i >= activeLights) break; if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; // forward points INTO the scene + vec3 Ldir = -uLights[i].dirAndRange.xyz; float ndl = max(0.0, dot(N, Ldir)); lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; } - // POINT / SPOT — THIS object's selected set (minimize_object_lighting): 8 int - // slots per instance into the global light buffer, -1 = unused. Camera- - // independent, so a wall's torches light it the same regardless of viewer pos. + // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's + // SetStaticLightingVertexColors sums the static point lights from BLACK and + // clamps the SUM to [0,1] before anything else (a baked emissive term), so a + // few warm intensity-100 torches can't push the whole pixel to white the way + // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor + // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged. + vec3 pointAcc = vec3(0.0); int base = instanceIndex * 8; for (int k = 0; k < 8; ++k) { int gi = instanceLightIdx[base + k]; if (gi < 0) continue; - lit += pointContribution(N, worldPos, gLights[gi]); + pointAcc += pointContribution(N, worldPos, gLights[gi]); } - return lit; + lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive) + + return lit; // frag still does the final min(lit, 1.0) } out vec3 vNormal; From c62da825fef07ab0feefcc8a56431c169c586559 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:35:33 +0200 Subject: [PATCH 11/16] =?UTF-8?q?fix(render):=20A7=20Fix=20D=20D-2=20?= =?UTF-8?q?=E2=80=94=20EnvCell=20shell=20binds=20its=20own=20per-cell=20li?= =?UTF-8?q?ght=20set=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5 per-instance set, computed per cell by LightManager.SelectForObject over the cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 1 + .../Rendering/Wb/EnvCellRenderer.cs | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3735979e..8fd2afda 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7775,6 +7775,7 @@ public sealed class GameWindow : IDisposable // SceneLighting UBO built below (binding=1) — terrain/sky read those. Lighting.BuildPointLightSnapshot(camPos); _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 2fe1a37a..421890e2 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable private uint _clipSlotBuffer; private uint[] _clipSlotData = Array.Empty(); + // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, + // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last + // left bound. binding=4 = global point-light snapshot (same data/indices as the + // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. + private uint _globalLightsSsbo; // binding=4 + private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; + private uint _instLightSetSsbo; // binding=5 + private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + private System.Collections.Generic.IReadOnlyList? _pointSnapshot; + private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); + // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind // our own one-slot no-clip fallback so the shader never reads an unbound SSBO. @@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); + // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. + _gl.GenBuffers(1, out _globalLightsSsbo); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); + + _gl.GenBuffers(1, out _instLightSetSsbo); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), + null, GLEnum.DynamicDraw); + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } @@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) => _cellIdToSlot = cellIdToSlot; + /// + /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot + /// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside + /// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets + /// reference this snapshot, which is also uploaded to binding=4 here, so the + /// pass is self-contained. Null/empty -> shells receive no point lights. + /// + public void SetPointSnapshot( + System.Collections.Generic.IReadOnlyList? snapshot) + => _pointSnapshot = snapshot; + // --------------------------------------------------------------------------- // GetEnvCellGeomId // Verbatim copy of WB EnvCellRenderManager.cs:94-103. @@ -997,6 +1031,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable } } + // --------------------------------------------------------------------------- + // GetCellLightSet (A7 Fix D D-2 helper) + // Per-cell up-to-8 point lights, cached per frame. Camera-independent, like + // WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds. + // --------------------------------------------------------------------------- + + // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world + // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). + // Cached per frame; unused slots are -1 (shader adds no point light there). + private int[] GetCellLightSet(uint cellId) + { + if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached; + + var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + System.Array.Fill(set, -1); + + var snap = _pointSnapshot; + if (snap is { Count: > 0 } && + _landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) && + lb.EnvCellBounds.TryGetValue(cellId, out var b)) + { + Vector3 center = (b.Min + b.Max) * 0.5f; + float radius = (b.Max - b.Min).Length() * 0.5f; + AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set); + } + _cellLightSetCache[cellId] = set; + return set; + } + // --------------------------------------------------------------------------- // RenderModernMDIInternal // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). @@ -1016,6 +1079,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable int passIdx = (int)renderPass; if (passIdx < 0 || passIdx > 2) return; + // A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in + // GetCellLightSet below). Clear once here so each cell gets a fresh lookup + // using this frame's _pointSnapshot. Called for EVERY pass (opaque AND + // transparent); the cache entries are stable within a frame since PointSnapshot + // doesn't change between Render calls, so clearing once (at the opaque pass) + // and leaving stale entries for the transparent pass would also be correct, but + // clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet. + _cellLightSetCache.Clear(); + // §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads. // Without the global VAO nothing can draw, and returning AFTER the pass state // was established leaked it (same early-out shape as the totalDraws==0 leak — @@ -1213,6 +1285,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable (nuint)(uniqueInstanceCount * sizeof(uint)), ptr); } + // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, + // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). + int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; + if (_lightSetData.Length < uniqueInstanceCount * lightStride) + _lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; + for (int i = 0; i < uniqueInstanceCount; i++) + { + int[] cellSet = GetCellLightSet(allInstances[i].CellId); + System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); + } + + // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). + int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int glUploadCount = lightCount > 0 ? lightCount : 1; + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), + null, GLEnum.DynamicDraw); + fixed (float* gp = _globalLightData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); + + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); + _gl.BufferData(GLEnum.ShaderStorageBuffer, + (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); + fixed (int* lp = _lightSetData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); + // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. // (globalVao validated at the top of the method — a return here would leak the // pass state established above.) @@ -1228,6 +1329,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable // (binding=2, via the GameWindow ClipFrame or our no-clip fallback). _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); BindClipRegionBinding2(); + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2) + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2) _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); @@ -1443,5 +1546,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 + if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2) + if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2) } } From b57a53edc42952df0bec1e27d21299817296adec Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:47:09 +0200 Subject: [PATCH 12/16] =?UTF-8?q?docs(register):=20correct=20AP-35=20(per-?= =?UTF-8?q?vertex+wrap+norm=20ported,=20point=20sum=20clamped)=20=E2=80=94?= =?UTF-8?q?=20A7=20Fix=20D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix A (aa94ced) moved point lighting to per-vertex Gouraud and ported the half-Lambert wrap + norm distance attenuation. Fix D D-1 added the separate point-light accumulator clamped to [0,1] matching retail's SetStaticLightingVertexColors bake clamp. AP-35 previously stated the path was per-pixel (mesh_modern.frag:52) and that wrap + normalization factor were "neither ported" — both wrong. Rewrite to reflect current state: per-vertex in mesh_modern.vert (pointContribution), wrap + norm ported, point sum clamped. Residual is architecture-only (per- frame GPU evaluate vs retail bake-once), not a visual divergence. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8a9ddd3c..ee2463d1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -131,7 +131,7 @@ accepted-divergence entries (#96, #49, #50). | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | -| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | +| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual:** acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE. This is an architecture/performance difference, not a visual one — the per-vertex contribution is numerically equivalent | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. Per-frame evaluate vs. bake-once is the only remaining deviation; the pipeline computes the same vertex colours retail bakes. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | A new frame-time consumer that bypasses `accumulateLights` (e.g. a future prepass) would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than retail's one-shot bake for static geometry | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | --- From 156dc453c90ccf0141ed64d39d613ce5bf4d4f16 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:54:34 +0200 Subject: [PATCH 13/16] =?UTF-8?q?docs(register):=20AP-35=20drop=20false=20?= =?UTF-8?q?equivalence;=20AP-16=20retarget=20to=20per-object/cell=208-ligh?= =?UTF-8?q?t=20cap=20=E2=80=94=20A7=20Fix=20D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AP-35: the "numerically equivalent" claim was false. Residual is now two parts: (a) per-frame GPU evaluate vs retail's bake-once (architecture/perf difference only; formula matches), and (b) SelectForObject 8-cap means a surface reached by >8 point lights is dimmer than retail's uncapped bake. Cross-references AP-16 for the cap ownership. AP-16: the old "global nearest-8 viewer-distance into UBO" description was stale — the UBO point-light path is now vestigial (mesh_modern.vert skips posAndKind.w!=0 entries; point lights come exclusively from the per-object SSBO binding 5). Retargeted to the current SelectForObject per-object/cell 8-cap mechanism with correct file:line (LightManager.cs:234), both call sites (ComputeEntityLightSet + GetCellLightSet), and the retail oracle distinction (hardware cap 0x0054d480 faithful; bake 0x0059cfe0 not). Preserved the UBO-directional-only note inline rather than losing it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ee2463d1..b7b358ff 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -111,7 +111,7 @@ accepted-divergence entries (#96, #49, #50). | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | -| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint | +| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) | | AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E | | AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | @@ -131,7 +131,7 @@ accepted-divergence entries (#96, #49, #50). | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | -| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual:** acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE. This is an architecture/performance difference, not a visual one — the per-vertex contribution is numerically equivalent | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. Per-frame evaluate vs. bake-once is the only remaining deviation; the pipeline computes the same vertex colours retail bakes. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | A new frame-time consumer that bypasses `accumulateLights` (e.g. a future prepass) would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than retail's one-shot bake for static geometry | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | +| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | --- From 0980bea48dfcc8b94b524b11f6a1a898b86f9524 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 21:38:30 +0200 Subject: [PATCH 14/16] =?UTF-8?q?fix(render):=20A7=20Fix=20D=20D-3/D-4=20?= =?UTF-8?q?=E2=80=94=20two-path=20lighting=20(objects=20plain-Lambert+sun,?= =?UTF-8?q?=20EnvCell=20wrap+no-sun)=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh_modern unified all meshes into one calc_point_light path: it applied the bake's half-Lambert wrap to objects (lighting character backs from a torch behind them) and added the sun to EnvCell building shells (warm facade wash). Retail splits these: objects = hardware plain Lambert max(0,N.L) + sun; EnvCell walls = baked wrap, dynamics only, NO sun (minimize_envcell_lighting). Add a per-draw uLightingMode (WbDrawDispatcher=0 object, EnvCellRenderer=1 envcell) selecting the angular term (wrap vs plain Lambert) and gating the sun. Per-light cap + D-1 clamp unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/Shaders/mesh_modern.vert | 37 +++++++++++-------- .../Rendering/Wb/EnvCellRenderer.cs | 1 + .../Rendering/Wb/WbDrawDispatcher.cs | 3 ++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 667db74d..78011f66 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -122,6 +122,7 @@ uniform mat4 uViewProjection; // _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. uniform int uDrawIDOffset; +uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun) // SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO // binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO @@ -157,16 +158,19 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { float d = sqrt(distsq); float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 if (d >= range || range <= 1e-4) return vec3(0.0); - // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). N·D = d·cosθ (D un-normalised); the - // +0.5·d bias lets a face angled AWAY from the torch still catch light (retail's - // soft terminator). wrap≤0 = fully shadowed. TwoLpr=1.5, WrapBias=0.5. - float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); - if (wrap <= 0.0) return vec3(0.0); + // A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the + // half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT + // mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes + // nothing (retail's hardware path). toL is un-normalised (length d). + float angular = (uLightingMode == 1) + ? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake) + : max(0.0, dot(N, toL)); // plain Lambert (object/hardware) + if (angular <= 0.0) return vec3(0.0); // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo; // <1 m → just d (dodge the near singularity). "Punchy near, soft far." float norm = (distsq > 1.0) ? (distsq * d) : d; float intensity = L.colorAndIntensity.w; - float scale = (1.0 - d / range) * intensity * (wrap / norm); + float scale = (1.0 - d / range) * intensity * (angular / norm); if (kind == 2) { // Spotlight: hard-edged cos-cone gate layered on the point ramp. vec3 Ldir = toL / max(d, 1e-4); @@ -183,15 +187,18 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { vec3 lit = uCellAmbient.xyz; - // SUN / directional — material-lit term (added with ambient, NOT into the - // torch sum), unchanged. - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + // SUN / directional — OBJECT path only (mode 0). retail's EnvCell path + // (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so + // EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4). + if (uLightingMode == 0) { + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } } // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 421890e2..bf80e9e0 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -877,6 +877,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB EnvCellRenderManager.cs:406-409: uniform state setup. _shader.SetInt("uRenderPass", (int)renderPass); _shader.SetInt("uFilterByCell", 0); + _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when // moving"): upload uViewProjection HERE rather than inheriting it from diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index fa686b3c..fc131abb 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -893,6 +893,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _indoorProbeFrameCounter++; var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); + // A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set + // explicitly (shared GL uniform; EnvCellRenderer sets it to 1). + _shader.SetInt("uLightingMode", 0); // #128 self-heal: fresh re-request dedup per Draw pass. _missRequested.Clear(); From 1e6fbff9bca91520ef1d53db35a52fb64a6a63f1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 19 Jun 2026 23:22:50 +0200 Subject: [PATCH 15/16] =?UTF-8?q?docs(lighting):=20A7=20Fix=20D=20round-2?= =?UTF-8?q?=20CHECKPOINT=20=E2=80=94=20real=20cause=20is=20object=20torch?= =?UTF-8?q?=20REACH=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-instant cdb proved acdream ambient (0.447) == retail (0.4465) and time/sun match, so the building/character over-brightness is NOT the bake/wrap/EnvCell/clamp (D-1..D-4, all correct but off-target) — those light the wrong surfaces. The Holtburg building exterior is a mode-0 OBJECT (IsBuildingShell, not an EnvCell). Isolation (object point lights gated OFF) made it match retail => cause is the torch REACH being too long (acdream range 7.8 = Falloff 6x1.3 vs retail 5.2 = Falloff 4x1.3), flooding the small facade. OPEN: confirm same-torch Falloff acdream-vs-retail before tightening the reach. Diagnostic shader hack reverted (tree clean); D-1..D-4 kept. Branch not merged. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...g-a7-fixD-round2-torch-reach-CHECKPOINT.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md new file mode 100644 index 00000000..94924439 --- /dev/null +++ b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md @@ -0,0 +1,94 @@ +# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT + +**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate) +**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` +**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed + +correct but **did NOT fix the visible symptom** — they were the wrong subsystem. + +## TL;DR — what the visible bug actually is (and is NOT) + +The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs +lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak. +Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is: + +1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail + ~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool. + **CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match + retail ("looks much better", user 2026-06-19). +2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0 + runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual + is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches). + +## Render-path facts (source-verified, workflow `wf_c4ad8cf8`) + +- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`, + built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher** + which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so + **D-4 (EnvCell walls get no sun) never touched it**. +- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by + WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT + (mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works). +- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells + from the dat EnvCell list — never `info.Buildings`, never characters. +- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare + `WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the + ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one + shared uniform; each renderer re-sets it before its draws. + +## Ground truth (live cdb retail + acdream probe, SAME-INSTANT) + +- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`. + → same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream + purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it). +- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude + **0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky + state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects. +- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm + `(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT + pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs). +- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5). + acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5. + +## The OPEN question to resolve FIRST on resume (don't guess) + +acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at +**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different +torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in +acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch +Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff` +for the same one. Then: +- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion + (the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's. +- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is + the issue; tighten how building shells receive torches (the per-vertex range gate already + localises, so this is unlikely — favour the Falloff hypothesis). + +## Proposed fix (after the falloff is confirmed) + +Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows +a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs` +(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register +row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause: +object point lights OFF == retail-match). + +## State of the committed work (KEEP — all correct, just off-target for the visible bug) + +| Commit | What | Verdict | +|---|---|---| +| `180b4af` | D-1 clamp point sum on its own | faithful; keep | +| `39c70f0` | D-2 prep — LightBake conformance test | keep | +| `cf62793` | D-1 shader clamp | keep | +| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep | +| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep | +| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) | + +`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted** +(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge. + +## DO-NOT-RETRY (cost a lot this session) + +- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the + building is a mode-0 OBJECT, none of that path lights it. +- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446). +- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725). +- The visible building bug is the **torch REACH** (confirmed by isolation); start there. From b7d655bce7672c7bbfa3ac43db637037b2b31dba Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 19 Jun 2026 23:56:49 +0200 Subject: [PATCH 16/16] =?UTF-8?q?fix(lighting):=20A7=20Fix=20D=20round=202?= =?UTF-8?q?=20=E2=80=94=20outdoor=20objects=20get=20NO=20torches=20(retail?= =?UTF-8?q?=20useSunlight=20gate)=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1 checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail Falloff 4). That theory is WRONG, and this commit fixes the real cause. Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere in Holtburg. Both clients read the same dat float, so reach was never inflated. Decomp (read verbatim + corroborated by an independent adversarial workflow): retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`. The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a, before LScape::draw), so the building EXTERIOR shell — drawn via DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit by SUN + ambient ONLY; torches are SKIPPED. The static bake (SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER torch-lights outdoor objects. This exactly explains the isolation test (object point lights OFF → building matches retail). Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell (UpdateSunFromSky). No dungeon regression: EnvCell statics get ParentCellId set (keep torches). Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight; only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED banner correcting the reach theory. Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 1 + ...g-a7-fixD-round2-torch-reach-CHECKPOINT.md | 58 +++++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 37 ++++++ .../Wb/WbDrawDispatcherTorchGateTests.cs | 42 +++++++ .../HoltburgTorchFalloffProbeTests.cs | 116 ++++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs create mode 100644 tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b7b358ff..11d8cabb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -131,6 +131,7 @@ accepted-divergence entries (#96, #49, #50). | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | +| AP-37 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | --- diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md index 94924439..a9baf4ac 100644 --- a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md +++ b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md @@ -5,6 +5,64 @@ **Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed + correct but **did NOT fix the visible symptom** — they were the wrong subsystem. +--- + +## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all + +**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT +"shorten torch reach" — it is "outdoor objects receive NO torches."** + +**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg +neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance +torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads +this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO +Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not +be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT +inflated. So "acdream 6 vs retail 4" was a red herring. + +**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow +`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is +**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`. +The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right +before `LScape::draw`), so when the building EXTERIOR shell is drawn +(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 → +CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the +**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The +static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller +`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the +wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights +OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the +non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30 +uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100` +and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never +enables it outdoors.) + +**The three retail lighting regimes (now all mapped):** +1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun. + → acdream mode 1 (EnvCell). ✓ already correct. +2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**. +3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**. + acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3. + +**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object +torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`) +via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId +null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail. +The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell +(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-37** added (documents the residual: +acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's +per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests: +`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green; +App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.** + +**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach / +change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells. +The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach +to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any +reach is the bug. + +--- + ## TL;DR — what the visible bug actually is (and is NOT) The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index fc131abb..f772dcc5 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2026,6 +2026,26 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// a static building's torches stay constant as the viewer moves. Fills /// ; unused slots are -1. On the no-lights /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + /// + /// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN + + /// ambient ONLY — never the static wall torches. The per-object torch step + /// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage: + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under + /// if (Render::useSunlight == 0), and the outdoor landscape stage runs + /// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right + /// before LScape::draw which draws buildings/scenery). So a building + /// EXTERIOR shell (, + /// = null) and all outdoor scenery / + /// creatures get the sun, not torches. We mirror that: only objects parented to + /// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so + /// the sun path alone lights them. This is what made the Holtburg meeting-hall + /// facade wash out warm — the dat's intensity-100 wall torches (range + /// Falloff×1.3) were flooding the exterior shell that retail never torch-lights. + /// The indoor "no sun" half is already handled by the global sun kill when the + /// player is inside a cell (UpdateSunFromSky). See the divergence register + /// (AP-37) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. + /// /// private void ComputeEntityLightSet(WorldEntity entity) { @@ -2033,12 +2053,29 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var snap = _pointSnapshot; if (snap is null || snap.Count == 0) return; + // Retail useSunlight gate: outdoor objects receive no per-object torches. + if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return; + if (entity.AabbDirty) entity.RefreshAabb(); Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); } + /// + /// Retail's useSunlight gate for per-object torch lighting, as a pure + /// predicate. An object receives the static wall torches (the indoor + /// minimize_object_lighting pass) ONLY when it is parented to an EnvCell + /// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100. + /// Outdoor objects (building shells with null , + /// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures) + /// are sun-lit only and return false. Mirrors + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff + /// Render::useSunlight == 0, which is true only in the indoor draw stage. + /// + internal static bool IndoorObjectReceivesTorches(uint? parentCellId) + => parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u; + /// /// Fix B: append the current entity's 8-slot light set to a group's /// , parallel to its Matrices (one diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs new file mode 100644 index 00000000..cb1ffd7c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs @@ -0,0 +1,42 @@ +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// A7 Fix D round 2 — pins retail's useSunlight gate for per-object torch +/// lighting (WbDrawDispatcher.IndoorObjectReceivesTorches). Retail enables +/// the static wall-torches on an object ONLY in the indoor stage +/// (DrawMeshInternal 0x0059f398: if (useSunlight == 0) minimize_object_lighting()), +/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor +/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only +/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches. +/// +public sealed class WbDrawDispatcherTorchGateTests +{ + [Fact] + public void BuildingShell_NullParent_IsOutdoor_NoTorches() + { + // Building exterior shells are top-level landblock stabs with no + // ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent). + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null)); + } + + [Theory] + [InlineData(0xA9B4_0001u)] // outdoor land sub-cell + [InlineData(0xA9B4_0020u)] // outdoor land sub-cell + [InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40) + public void OutdoorLandCell_NoTorches(uint parentCellId) + { + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } + + [Theory] + [InlineData(0xA9B4_0100u)] // first EnvCell + [InlineData(0xA9B4_0164u)] // interior EnvCell + [InlineData(0x0007_0143u)] // dungeon EnvCell + public void IndoorEnvCell_GetsTorches(uint parentCellId) + { + Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } +} diff --git a/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs new file mode 100644 index 00000000..1d3a7a41 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Lighting; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; +using DatSetup = DatReaderWriter.DBObjs.Setup; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without +/// guessing or a live launch: dump the RAW dat LightInfo.Falloff for every +/// static light in the Holtburg landblocks, via the EXACT production load path +/// (). The dat is the SAME file retail reads, so +/// these falloffs ARE what retail reads (modulo any load-time transform, settled +/// separately in the decomp). Output-only — no assertions; read the log. +/// +public sealed class HoltburgTorchFalloffProbeTests +{ + private readonly ITestOutputHelper _out; + public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Dump_Holtburg_StaticLight_Falloffs() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // The meeting hall sits in the Holtburg town landblocks. Sweep a small + // neighbourhood so we catch every entrance torch the streaming window + // would load around the player at the hall. + uint[] landblocks = + { + 0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u, + }; + + // Tally every distinct raw Falloff seen (the headline number). + var falloffTally = new SortedDictionary(); + int totalLights = 0; + + foreach (uint lb in landblocks) + { + uint infoId = (lb << 16) | 0xFFFEu; + var info = dats.Get(infoId); + if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; } + + int buildings = info.Buildings?.Count ?? 0; + int objects = info.Objects?.Count ?? 0; + _out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ==="); + + // Record building-shell origins so we can rank torches by proximity. + var shells = new List<(uint model, Vector3 pos)>(); + if (info.Buildings is not null) + { + foreach (var b in info.Buildings) + { + var o = b.Frame.Origin; + shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z))); + _out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}"); + } + } + + if (info.Objects is null) continue; + foreach (var stab in info.Objects) + { + // Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary — + // identical gate to GameWindow.cs:6399. + if ((stab.Id & 0xFF000000u) != 0x02000000u) continue; + var setup = dats.Get(stab.Id); + if (setup?.Lights is null || setup.Lights.Count == 0) continue; + + var loaded = LightInfoLoader.Load( + setup, + ownerId: 0, + entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z), + entityRotation: new Quaternion( + stab.Frame.Orientation.X, stab.Frame.Orientation.Y, + stab.Frame.Orientation.Z, stab.Frame.Orientation.W)); + + foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l))) + { + float rawFalloff = kvp.Value.Falloff; + totalLights++; + falloffTally.TryGetValue(rawFalloff, out int c); + falloffTally[rawFalloff] = c + 1; + + // Nearest building shell, for "is this an entrance torch on the hall?". + float nearest = float.MaxValue; + uint nearestModel = 0; + foreach (var (model, spos) in shells) + { + float dd = Vector3.Distance(ls.WorldPosition, spos); + if (dd < nearest) { nearest = dd; nearestModel = model; } + } + + _out.WriteLine( + $" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " + + $"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " + + $"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " + + $"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " + + $"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m"); + } + } + } + + _out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ==="); + foreach (var kv in falloffTally) + _out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}"); + } +}