acdream/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md
Erik ad53180190 docs(plan): A7 Fix D implementation plan — 5 tasks (#140)
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) <noreply@anthropic.com>
2026-06-18 17:15:36 +02:00

28 KiB
Raw Permalink Blame History

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

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:

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:

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:

    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
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:

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
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)):

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 pointContributionLightBake.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

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:

    // 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:

    /// <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:

        // 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:

    // 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:

        _cellLightSetCache.Clear();

(b) Where _clipSlotData is filled per instance (1195-1206), add a parallel fill of _lightSetData right after it:

        // 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:

        // 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:

        _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:

            _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:

        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
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
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.