feat(lighting): SelectForCell (all dynamic lights per EnvCell) + #176 handoff — residual is a runtime z-fight

cdb trace of LIVE retail (tools/cdb/issue176-floor-light.cdb, binary<->PDB MATCH) PROVED retail
applies ALL its dynamic lights — 4 intensity-100 magenta portal lights (d3dIdx 3-6, falloff 6) +
the viewer fill (d3dIdx 1, 2.25) — as D3D hardware lights to EVERY Facility Hub cell, every frame,
stable. So the faceted purple wedges on the floor are retail-FAITHFUL. acdream did a per-cell
SelectForObject sphere-overlap 8-cap for cells, so the portal set could differ/flip per cell.

- LightManager.SelectForCell (retail minimize_envcell_lighting 0x0054c170): ALL dynamic lights
  applied unconditionally (shader range cutoff zeroes non-reaching = D3D hardware range), then
  nearest static torches fill remaining slots. Wired into EnvCellRenderer.GetCellLightSet.
  Objects keep SelectForObject (minimize_object_lighting). Pins:
  SelectForCell_AppliesAllDynamicLights_EvenOutOfReach + _SameDynamicSet_ForCellsFarApart_NoFlap.
- Apparatus: [light-detail] gains owner/cell/dyn (pinned the culprit = 2 portal weenies
  0x000F4247/48 in 0x8A020118/19, intensity=100 magenta); CellVertexNormals_SmoothOrFaceted_Dump
  (corridor floor uses SMOOTH per-vertex dat normals, not flat); tools/cdb/issue176-floor-light.cdb.

#176 RESIDUAL is NOT this fix. It's a RUNTIME draw z-fight in the seam floor. Eliminated (evidence):
NOT lighting (per-light cap + this both no-change), NOT membership (render cell 0x8A020164 stable
100% of 188k frames / 526 angles, res=None), NOT dat geometry (coplanar sweep empty at z=-6 floor
incl. cell 0164). NEXT = RenderDoc pixel-history. Full handoff + DO-NOT-RETRY:
docs/research/2026-07-06-176-seam-floor-zfight-handoff.md. Suites green: Core 2599 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-06 13:55:16 +02:00
parent 92bb27c03b
commit 8cb3176daa
8 changed files with 366 additions and 11 deletions

View file

@ -17,6 +17,17 @@ public sealed class LightManagerTests
CellId = cellId,
};
private static LightSource MakeDynamic(Vector3 pos, float range, uint cellId = 0)
=> new LightSource
{
Kind = LightKind.Point,
WorldPosition = pos,
Range = range,
IsLit = true,
IsDynamic = true,
CellId = cellId,
};
[Fact]
public void Register_Unregister_TracksList()
{
@ -307,6 +318,58 @@ public sealed class LightManagerTests
Assert.Equal(a[0], b[0]);
}
// ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ──
[Fact]
public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach()
{
// Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same
// portal lights on every Facility Hub cell) — including ones that don't reach it,
// since the shader's range cutoff zeroes those. Static lights still cull by reach.
var snapshot = new[]
{
MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches
MakeDynamic(new Vector3(100, 0, 0), range: 5f), // 1: dynamic, FAR (out of reach)
MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near
MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach)
};
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel);
bool d1 = false, d2 = false, s0 = false, s3 = false;
for (int i = 0; i < n; i++)
{
if (sel[i] == 1) d1 = true;
if (sel[i] == 2) d2 = true;
if (sel[i] == 0) s0 = true;
if (sel[i] == 3) s3 = true;
}
Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics");
Assert.True(d2, "the near dynamic light is applied");
Assert.True(s0, "the near static light reaches the cell → selected");
Assert.False(s3, "the far static light doesn't reach → not selected");
}
[Fact]
public void SelectForCell_SameDynamicSet_ForCellsFarApart_NoFlap()
{
// The stability retail has and we lacked: two cells far apart get the SAME dynamic
// set. A per-cell sphere-overlap cull of dynamics (the old SelectForObject path) let
// that set differ/flip as the flood shifted → the floor lighting FLAPPED (#176).
var snapshot = new[]
{
MakeDynamic(new Vector3(0, 0, 0), range: 5f),
MakeDynamic(new Vector3(100, 0, 0), range: 5f),
};
Span<int> a = stackalloc int[8];
Span<int> b = stackalloc int[8];
int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a);
int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b);
Assert.Equal(2, na); // both dynamics on the near cell
Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap
}
/// <summary>
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the

View file

@ -252,7 +252,7 @@ public class Issue176177DungeonSeamInspectionTests
// Seed cells around the screenshot location (the 016E/017A seam) +
// one portal ring.
var cellIds = new HashSet<uint> { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
var cellIds = new HashSet<uint> { 0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
foreach (var seed in new List<uint>(cellIds))
{
var seedCell = dats.Get<EnvCell>(seed);
@ -621,4 +621,56 @@ public class Issue176177DungeonSeamInspectionTests
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]");
}
}
/// <summary>
/// #176 THE TRIANGLES (user screenshot, 2026-07-06): floor tiles show hard
/// per-triangle purple facets — smooth Gouraud can't produce a hard diagonal
/// across a flat tile, so the CELL VERTEX NORMALS are the suspect. CellMesh.cs:80
/// uses sw.Normal (dat vertex normal), falling back to UnitZ when zero. Dump the
/// per-vertex dat normals for every DRAWN poly: are they smooth (vary per vertex),
/// per-FACE (constant per poly = flat shading = facets), or ZERO (fallback → all
/// up → floor unlit)? Compares the dat vertex normal to the poly's geometric
/// face normal to flag flat-shaded polys.
/// </summary>
[Theory]
[InlineData(0x8A02015Eu)] // #176 repro corridor
[InlineData(0x8A02016Eu)] // corridor with floor-portals
public void CellVertexNormals_SmoothOrFaceted_Dump(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; }
var (cell, cs) = loaded.Value;
int zero = 0, faceMatch = 0, smooth = 0, total = 0;
_out.WriteLine($"=== 0x{cellId:X8} per-vertex dat normals (local space), DRAWN polys ===");
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
// Geometric face normal from the first 3 LOCAL verts.
var lv = new List<Vector3>();
var vn = new List<Vector3>();
foreach (var vid in poly.VertexIds)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) { lv.Add(v.Origin); vn.Add(v.Normal); }
if (lv.Count < 3) continue;
total++;
var face = Vector3.Normalize(Vector3.Cross(lv[1] - lv[0], lv[2] - lv[0]));
// Classify: all-zero (fallback), all-equal-to-face (flat), or varying (smooth).
bool allZero = vn.TrueForAll(x => x == Vector3.Zero);
bool allEqualFace = vn.TrueForAll(x => x != Vector3.Zero && Vector3.Dot(Vector3.Normalize(x), face) > 0.999f);
bool varying = false;
for (int i = 1; i < vn.Count; i++)
if (vn[i] != Vector3.Zero && Vector3.Dot(Vector3.Normalize(vn[i]), Vector3.Normalize(vn[0])) < 0.999f) varying = true;
if (allZero) zero++; else if (allEqualFace) faceMatch++; else if (varying) smooth++;
string tag = allZero ? "ZERO(→UnitZ)" : allEqualFace ? "FLAT(=face)" : varying ? "SMOOTH(varies)" : "uniform(≠face)";
_out.WriteLine($" poly {id} face=({face.X:F2},{face.Y:F2},{face.Z:F2}) {tag} datN=[" +
string.Join(" ", vn.ConvertAll(x => $"({x.X:F2},{x.Y:F2},{x.Z:F2})")) + "]");
}
_out.WriteLine($"SUMMARY 0x{cellId:X8}: drawnPolys={total} zero={zero} flatFace={faceMatch} smooth={smooth}");
}
}