merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch

Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:01:20 +02:00
commit 31d7ffd253
27 changed files with 2327 additions and 103 deletions

View file

@ -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 35 → Range 3.96.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<float>();
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<float>();
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;
/// <summary>
/// Packs a point-light snapshot into the flat float layout the bindless mesh
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
/// </summary>
public static class GlobalLightPacker
{
public const int FloatsPerLight = 16;
/// <summary>
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
/// </summary>
public static int Pack(IReadOnlyList<LightSource>? 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) <noreply@anthropic.com>"
```
---
## 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;
/// <summary>
/// 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.
/// </summary>
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<LightSource>();
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
```
Near `SetClipRouting` (~262) add the per-frame setter:
```csharp
/// <summary>
/// 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.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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.

View file

@ -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 | 35 | **(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.