diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 9f19098e..b17514ef 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -149,13 +149,19 @@ the connecting opening, and stays rendered through the full descent.
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
-**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The
-A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame),
-yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single
-over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)`
-(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide
-whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not
-set-composition, not through-floor. See the render digest banner.
+**⚠️ UPDATE 2026-07-06 (final — cdb-confirmed, RenderDoc handoff) — it's a RUNTIME draw
+z-fight, NOT lighting.** Full elimination table + plan:
+`docs/research/2026-07-06-176-seam-floor-zfight-handoff.md`. The purple WEDGE SHAPE is
+retail-FAITHFUL — cdb (`tools/cdb/issue176-floor-light.cdb`) proved retail applies the same
+4 intensity-100 magenta portal lights + the viewer fill to EVERY Hub cell via D3D hardware,
+so the faceted per-vertex purple is exactly what retail shows. The only defect is the
+FLICKER, eliminated as: NOT lighting (per-light cap + all-dynamic-per-cell both no-change),
+NOT membership (render cell `0x8A020164` stable on 100% of 188k frames / 526 angles), NOT dat
+geometry (coplanar sweep empty at the z=−6 corridor floor, incl. cell `0164`). → a RUNTIME
+double-draw of the seam floor. Suspects: the `+0.02 m` shell lift (AP-32/#130), the #178
+double-sided cell-shell stopgap, a portal depth-mask. **NEXT: RenderDoc pixel-history** on a
+flickering floor pixel to name the two fighting draws. Shipped this arc (retail-faithful, NOT
+the fix): scoping `c500912b` + `SelectForCell` (all-dynamic-lights-per-cell).
**Status:** OPEN
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
diff --git a/docs/research/2026-07-06-176-seam-floor-zfight-handoff.md b/docs/research/2026-07-06-176-seam-floor-zfight-handoff.md
new file mode 100644
index 00000000..bf932f25
--- /dev/null
+++ b/docs/research/2026-07-06-176-seam-floor-zfight-handoff.md
@@ -0,0 +1,133 @@
+# Pickup handoff — #176 seam-floor lighting flicker = a RUNTIME draw z-fight (RenderDoc next)
+
+**Paste the companion prompt below into a fresh session.** Read this file, then
+`claude-memory/project_render_pipeline_digest.md` (top banner), then **ISSUES #176**.
+
+## TL;DR — what #176 actually is now
+
+The Facility Hub corridor floor shows **hard-edged purple triangular facets that
+flicker as the camera turns**, at cell seams. After a long investigation (11
+hypotheses), the domain is **pinned by evidence**:
+
+> **#176's residual is a RUNTIME draw-level depth-fight (z-fight) in the seam
+> FLOOR geometry — two draws the renderer emits at ~the same depth, one carrying
+> the purple portal light and one not, whose winner flips as the camera moves.
+> It is NOT lighting, NOT membership, NOT dat geometry.**
+
+The **wedge SHAPE itself is retail-faithful** (retail shows the same faceted
+purple — cdb-proven, below). The only defect is the **flicker**, and it is a
+render/draw problem. **The next step is a RenderDoc pixel-history capture** — do
+NOT throw more lighting fixes at it (two already did nothing).
+
+## The elimination table (evidence — this is the DO-NOT-RETRY list)
+
+| Ruled OUT | How (hard evidence) |
+|---|---|
+| Lighting **brightness** | per-light cap on the dynamic branch (`mesh_modern.vert`) → **no visual change**, reverted |
+| Lighting **selection** | `SelectForCell` (all dynamic lights per cell, retail-exact) → **no visual change** |
+| Light-set **camera-cap churn** (the OLD "confirmed" #176 theory) | visible-cell scoping shipped (`c500912b`); probe-proven ~285 through-floor lights dropped/frame — symptom unchanged. **REFUTED.** |
+| **Membership / the "flap"** | `ACDREAM_PROBE_FLAP`: render cell = `0x8A020164` on **100%** of 188,732 frames across **526 distinct camera angles**, `res=None` always; `ACDREAM_PROBE_PVINPUT` flood is stable-per-angle (17/10/8…), never oscillates at a fixed view; 1 `[cell-transit]` (the spawn teleport) total |
+| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=−6 corridor floor (only the benign same-cell z=−12 under-hall floor tiling in `0x011E`) |
+| A2C / alpha-hole see-through | corridor floor surface `0x08000377` is **fully opaque** (`alpha0Texels=0`, `transl=0.00`) |
+| Translucent under-surface blend | the one translucent colored surface `0x08000034` is `NoPos` (not drawn) |
+| Flat (per-face) normals | corridor floor uses **smooth per-vertex dat normals** (center `(0,0,1)`, corners tilted ~27° — retail-style edge smoothing), NOT flat (`CellVertexNormals_SmoothOrFaceted_Dump`) |
+
+## What retail actually does (cdb-proven — the wedge is faithful)
+
+`tools/cdb/issue176-floor-light.cdb` traced live retail's `config_hardware_light`
+standing in the Hub (binary↔PDB MATCH, GUID `9e847e2f…`):
+
+- Retail applies **4 intensity-100 magenta portal lights** (`d3dIdx 3–6`,
+ `color=(0.784,0,0.784)`, `falloff=6`) **+ the viewer fill** (`d3dIdx 1`,
+ `intensity=2.25 falloff=10`) as **D3D hardware lights to EVERY Hub cell**
+ (`0102,0103,0109,0111,0112,0115,0118,0119,011a,011e,011f,0121,0122,0123…`),
+ every frame, stable.
+- So retail's cell floors ARE lit by the portals via per-vertex hardware T&L over
+ the same smoothed fan-corner normals → **the same faceted purple wedges**.
+ → the wedge SHAPE is retail-faithful; only the flicker is ours.
+
+The intensity-100 magenta light is the **portal** (2 portal weenies
+`0x000F4247`/`0x000F4248` in cells `0x8A020118`/`0x8A020119`, `dyn=1`). Confirmed
+via the `[light-detail] owner=/cell=/dyn=` probe fields.
+
+## The residual bug — a runtime double-draw of the seam floor
+
+Since it's not dat geometry, the renderer is emitting the corridor floor (or a
+coincident surface) **twice at ~the same depth**. Prime suspects to check in
+RenderDoc (do NOT assume — capture and read):
+
+1. **The `+0.02 m` shell render lift** (`PortalVisibilityBuilder.ShellDrawLiftZ`,
+ register **AP-32**, the #130 saga). If the shell floor draws lifted while some
+ other pass draws the same floor unlifted, 0.02 m z-fights at grazing angles.
+2. **The A8 double-sided cell-shell stopgap** (**#178**, LIVE):
+ `EnvCellRenderer.RenderModernMDIInternal` `if (cullMode == CullMode.Landblock)
+ cullMode = CullMode.None;` — every cell shell draws two-sided. Retiring this is
+ already an open issue; test whether it's implicated.
+3. **Portal depth-writes** — `RetailPViewRenderer.DrawExitPortalMasks` /
+ the interior-root depth-clear+seal path. Seals are supposed to fire only for
+ `OtherCellId==0xFFFF` (a sealed dungeon draws zero), but VERIFY in the capture
+ that no depth-mask lands on the floor-portal plane (the corridor floor IS a
+ floor-portal poly to the under-room, surf `0x08000377`, z=−6).
+
+## RenderDoc plan (the definitive next step)
+
+1. Launch acdream under RenderDoc (`renderdoc` → capture `AcDream.App`, or inject).
+ Standard `ACDREAM_LIVE` env from CLAUDE.md.
+2. Stand in cell `0x8A020164` corridor, at an angle where the floor flickers.
+ Capture a frame (F12).
+3. In RenderDoc: **right-click a flickering floor pixel → "History"** (pixel
+ history). It lists every draw that touched that pixel + its depth + pass/fail.
+4. **Two draws at ~equal depth writing that pixel = the z-fight.** Identify which
+ two passes they are (shell vs shell-lift vs portal-mask). Report the draw
+ names + depths back — that names the fix.
+5. Fix at the source (make the two draws not coincide: drop the redundant draw,
+ or reconcile the lift), then re-gate. No band-aid depth-bias (that's a
+ workaround — see CLAUDE.md).
+
+## What's committed / the state
+
+- **`c500912b`** (in `main`): visible-cell light **scoping** — `LightSource.CellId`
+ + `BuildPointLightSnapshot(camPos, visibleCells)` (retail `add_*_lights over
+ visible_cell_table`) + the `[indoor-light]` probe. Retail-faithful; fixed the
+ camera-eviction latent bug. Register **AP-85** corrected. Corrected the old
+ handoff's imprecise "insert_light = per-cell" framing — the real model is
+ visible-cell-scoped collection into a small player-nearest pool; see
+ `docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md`.
+- **This commit**: `LightManager.SelectForCell` (all dynamic lights per EnvCell,
+ retail `minimize_envcell_lighting`) wired into `EnvCellRenderer.GetCellLightSet`;
+ the `[light-detail]` owner/cell/dyn probe; `CellVertexNormals_*` dump;
+ `tools/cdb/issue176-floor-light.cdb`. **Retail-faithful, NOT the flicker fix.**
+- Suites green: Core 2599 + 2 skip. All on `main`.
+
+## Apparatus (reuse, don't rebuild)
+
+- `ACDREAM_PROBE_FLAP=1` — per-frame `[flap-cam]` render cell + `[flap]` portal
+ decisions (proved membership stable).
+- `ACDREAM_PROBE_PVINPUT=1` — per-frame `[pv-input] flood=N eye= yaw=`.
+- `ACDREAM_PROBE_LIGHT=1` — `[light]` + `[light-detail]` (now with owner/cell/dyn).
+- `ACDREAM_PROBE_INDOOR_LIGHT=1` — `[indoor-light]` scoped-pool SET composition.
+- `tools/cdb/issue176-floor-light.cdb` — retail light-setup trace.
+- `Issue176177DungeonSeamInspectionTests` — dat truth (coplanar sweep, floor
+ surfaces, vertex normals); `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
+ seed = `{0164,0165,016E,017A}`+neighbors.
+
+## Repro + launch protocol
+
+`dotnet build` green FIRST (close any running client — a client may still be up;
+the user manages lifecycle, do NOT kill it, ASK). Standard `ACDREAM_LIVE`
+PowerShell launch. Test char spawns in the Facility Hub; the flickering corridor
+is cell **`0x8A020164`** (player z≈−5.9). Turn back-and-forth across a seam to
+reproduce. Strip `\000` before grep (PowerShell Tee = UTF-16).
+
+## Process lesson (paid for in full this session)
+
+**Get a screenshot AND a retail cdb trace EARLY for a render bug — before
+theorizing.** This session chased 11 inference-based hypotheses over many
+launches; the screenshot (faceted triangles) + the cdb trace (retail applies the
+same lights → faithful shape) would each have redirected the whole thing in one
+step. The project's own rules already say this; follow them.
+
+## Also open
+
+- **#177** — dungeon stairs pop in/out (`0178/0182/0183`) — a SEPARATE
+ portal-visibility miss, not lighting. Untouched this arc.
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index 0a65655b..fa5c5641 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -1085,7 +1085,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
{
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);
+ // #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
+ // dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
+ // let the portal set flip as the flood shifted → floor-lighting flap.
+ AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs
index c4e45451..1d10d5a8 100644
--- a/src/AcDream.Core/Lighting/LightManager.cs
+++ b/src/AcDream.Core/Lighting/LightManager.cs
@@ -379,4 +379,75 @@ public sealed class LightManager
}
return count;
}
+
+ ///
+ /// Per-CELL light selection — retail minimize_envcell_lighting (0x0054c170).
+ /// Unlike (per-object sphere-overlap cull), retail enables
+ /// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
+ /// config_hardware_light: the same 4 intensity-100 portal lights are applied to
+ /// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
+ /// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
+ /// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
+ /// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
+ /// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
+ /// floor lighting FLAP (#176). Objects keep
+ /// (retail minimize_object_lighting).
+ ///
+ public static int SelectForCell(
+ IReadOnlyList snapshot,
+ Vector3 center,
+ float radius,
+ Span outIndices)
+ {
+ int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
+ if (cap <= 0) return 0;
+
+ int count = 0;
+
+ // 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
+ // every cell — stable regardless of the cell's relation to each light).
+ for (int li = 0; li < snapshot.Count && count < cap; li++)
+ if (snapshot[li].IsDynamic)
+ outIndices[count++] = li;
+
+ // 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
+ // insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
+ int staticStart = count;
+ Span keptDistSq = stackalloc float[MaxLightsPerObject];
+ for (int li = 0; li < snapshot.Count; li++)
+ {
+ var light = snapshot[li];
+ if (light.IsDynamic) continue; // dynamics already added
+ float reach = light.Range + radius;
+ float dsq = (light.WorldPosition - center).LengthSquared();
+ if (dsq >= reach * reach) continue;
+
+ if (count < cap)
+ {
+ int j = count;
+ while (j > staticStart && keptDistSq[j - 1] > dsq)
+ {
+ keptDistSq[j] = keptDistSq[j - 1];
+ outIndices[j] = outIndices[j - 1];
+ j--;
+ }
+ keptDistSq[j] = dsq;
+ outIndices[j] = li;
+ count++;
+ }
+ else if (staticStart < cap && dsq < keptDistSq[cap - 1])
+ {
+ int j = cap - 1;
+ while (j > staticStart && keptDistSq[j - 1] > dsq)
+ {
+ keptDistSq[j] = keptDistSq[j - 1];
+ outIndices[j] = outIndices[j - 1];
+ j--;
+ }
+ keptDistSq[j] = dsq;
+ outIndices[j] = li;
+ }
+ }
+ return count;
+ }
}
diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index 7142e61e..ce8d3e82 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -471,9 +471,10 @@ public static class RenderingDiagnostics
float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f;
Console.WriteLine(string.Format(ci,
- "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}",
+ "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}",
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
- ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist));
+ ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist,
+ ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0));
shown++;
}
}
diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
index 139e0d7a..53e026a6 100644
--- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
@@ -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 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 a = stackalloc int[8];
+ Span 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
+ }
+
///
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// scoping, not "uncap"). Before: BuildPointLightSnapshot kept only the
diff --git a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs
index e861f5fc..5013fb1d 100644
--- a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs
@@ -252,7 +252,7 @@ public class Issue176177DungeonSeamInspectionTests
// Seed cells around the screenshot location (the 016E/017A seam) +
// one portal ring.
- var cellIds = new HashSet { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
+ var cellIds = new HashSet { 0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
foreach (var seed in new List(cellIds))
{
var seedCell = dats.Get(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}]");
}
}
+
+ ///
+ /// #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.
+ ///
+ [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();
+ var vn = new List();
+ 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}");
+ }
}
diff --git a/tools/cdb/issue176-floor-light.cdb b/tools/cdb/issue176-floor-light.cdb
new file mode 100644
index 00000000..d07b9d1d
--- /dev/null
+++ b/tools/cdb/issue176-floor-light.cdb
@@ -0,0 +1,26 @@
+$$
+$$ #176 floor triangles - retail light-setup trace (2026-07-06)
+$$ Q: does retail set up the DYNAMIC magenta portal light (intensity=100,
+$$ color 0.784,0,0.784) as a HARDWARE light applied to the corridor CELL?
+$$ If yes -> retail's cell floor IS lit by the portal via D3D hardware T&L,
+$$ so the per-vertex wedge pattern is retail-FAITHFUL, not an acdream bug.
+$$
+$$ config_hardware_light(d3dIndex@[esp+4], _D3DLIGHT9* out@[esp+8],
+$$ ulong cellID@[esp+0xc], LIGHTINFO* info@[esp+0x10])
+$$ d3dIdx 1-10 = DYNAMIC (viewer / teleport PORTAL); 11+ = STATIC (wall torch, baked)
+$$ Dumping the cellID lets us see WHICH cell the magenta portal is configured for.
+$$
+
+.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\retail-floor-light.log
+.sympath C:\Users\erikn\source\repos\acdream\refs
+.symopt+ 0x40
+.reload /f acclient.exe
+
+r $t0 = 0
+r $t1 = 0
+
+$$ config_hardware_light - every hardware light setup, with its cell id + LIGHTINFO.
+bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d cell=%08x\\n\", @$t1, dwo(@esp+4), dwo(@esp+0xc); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 500) { qd } .else { gc }"
+
+.printf \"issue176-floor-light armed: config_hardware_light with cell id + LIGHTINFO. Stand in the corridor looking at the purple floor+walls. qd (auto-detach) after 500 hits.\\n\"
+g