From ad53180190ad239c22cd2f3ed10fbd64798af3cb Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:15:36 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20A7=20Fix=20D=20implementation=20p?= =?UTF-8?q?lan=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.