acdream/docs/research/2026-07-06-176-177-handoff-A7-lighting.md
Erik e1746ca10d docs: #176/#177 handoff — root-caused, folded into A7 dungeon lighting
Session handoff for the next pickup. #176 (purple seam flash) + #177
(stair-room light pop-in) are ONE bug, root-caused to the camera-nearest
MaxGlobalLights=128 snapshot cap evicting in-range lights of visible
cells (Hub = 366 fixtures). Fix deferred to A7 because uncapping exposes
unported per-cell light-reach (through-floor light) + the fixture
falloff-curve misassignment + an unattributed striped-floor artifact.

- New handoff: docs/research/2026-07-06-176-177-handoff-A7-lighting.md
  (root cause, why-deferred, the A7 fix order, tooling inventory).
- Roadmap A7 updated: now owns #176/#177; the 'light visibility culling'
  hypothesis layer is CONFIRMED (no per-cell insert_light registration);
  fixture-curve layer added. Analysis pre-paid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:16:17 +02:00

8.9 KiB
Raw Blame History

Pickup prompt — #176/#177 root-caused, deferred to A7 dungeon lighting (paste into a fresh session)

Read claude-memory/project_render_pipeline_digest.md FIRST (top banner is the #176/#177 outcome + DO-NOT-RETRY), then ISSUES #176 and #177, then this file. Then read roadmap Phase A7 (docs/plans/2026-04-11-roadmap.md §"Phase A7 — Indoor lighting fidelity") — this session effectively pre-paid A7's analysis for the light-cap slice.

Where we are (2026-07-06 end of session)

Currently working toward: M1.5 — Indoor world feels right. Critical path: #137 dungeon collision (DONE, gated), #138 teleport-OUT, A7 dungeon lighting (#79/#93 + now #176/#177).

HEAD = d591e3bb on main AND branch claude/vigorous-joliot-f0c3ad (fast-forwarded, in sync). Working tree clean. All suites green (Core 2591 + 3 skip / App 719 + 2 skip / UI 425 / Net 385).

Three commits this session:

Commit What
b8e9e204 #176/#177 investigation: 12 mechanisms refuted, apparatus shipped, probe protocol staged
4d25e04d fix attempt: MaxGlobalLights 128→1024 (stops the pops) — REVERTED
d591e3bb revert to 128 + full deferral docs (register AP-85 rewritten, ISSUES back to OPEN, digest DO-NOT-RETRY)

A client may still be running (launch-176-revert-check.log) — the user manages lifecycle. It is on the reverted 128 baseline (rooms normal, seam flashes present = the still-open issue).

THE ROOT CAUSE (confirmed, not a hypothesis)

#176 and #177 are ONE bug: per-cell 8-light SET COMPOSITION churning under a camera-nearest snapshot cap. LightManager.BuildPointLightSnapshot keeps only the MaxGlobalLights=128 point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so 238 are evicted per frame by camera distance. SelectForObject (the faithful per-object 8-cap, retail minimize_object_lighting 0x0054d480) can only choose from the surviving 128 — so an in-range torch of a VISIBLE cell that ranks past the cap drops out of that cell's 8-set, and the cell's per-vertex Gouraud lighting flips as the camera moves.

  • #176 — the flipping unit is a CELL → discontinuities at exactly cell-seam granularity; camera-angle dependent (the chase boom swings the camera position, re-ranking the 128); the dominant flipping light is the under-room PORTALS' purple → purple flashes on the floor at seams.
  • #177 — a stair room whose fixtures ALL rank past the cap renders at bare 0.2 ambient (near-black = "not visible from the corridor"); approach re-admits them ("pops into existence"); the eviction boundary sweeping during the descent strips the ramp's lights ("disappears on the last step"). The geometry never vanished — its LIGHTS did.

How it was confirmed (the discriminator): the user reproduced the flash while [light] (ambient branch — stable 0.2 grey) AND [pv-input] (portal flood — zero drops in 54k frames) read provably healthy in the probe log. That eliminated every CPU signal the probes COULD see and left the one they can't: set composition ([light] prints counts, not membership). The log's headline number — registeredLights=366 vs cap 128 — closed it.

WHY THE FIX IS DEFERRED (do not re-raise the cap alone)

Uncapping (128→1024, 4d25e04d) stopped the pops but the full 366-fixture pool exposed three UNPORTED retail lighting semantics that then dominated the Hub — this is why it was reverted (user: "rooms have no textures" → actually a magenta light wash over intact textures; then "purple stripes… something fighting to draw the purple lightning over the floor"):

  1. Light-through-solid-floors. Retail registers lights per-CELL (insert_light 0x0054d1b0); a light belongs to a cell and only lights that cell's geometry. Our snapshot is a flat world-space sphere-overlap with NO reach/occlusion notion → the under-room portals' purple light washed the corridors ABOVE them. This is the big one. The 128 cap accidentally MASKED it by keeping the pool camera-local (far under-room lights fell off the list before they could reach up).
  2. Fixture falloff curve misassignment. Stationary weenie fixtures (ACE serves dungeon lanterns/braziers as CreateObject weenies) register via the isDynamic:true path → D3D 1/d falloff (LightInfoLoader.cs:89, GameWindow weenie-light block ~3688). Retail bakes STATIONARY fixture light with the static 1/d³ curve (calc_point_light 0x0059c8b0, static_light_factor 1.3). 1/d is ~9× stronger at 3 m → every pool over-broad + over-saturated. The isDynamic flag should be reserved for genuinely MOVING lights (portal swirls, projectiles); a stationary fixture — even server-spawned — is static.
  3. Striped floor z-fight-like artifact. User's 2nd screenshot: regular magenta bands across one floor region, "like something is fighting to draw the purple over the floor." NOT attributed. Ruled out: not coincident dat geometry (the CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs sweep found only the legit z=12 under-hall floor quad-fan, nothing near the 6 corridor floor); not a striped texture (all corridor surfaces are plain Base1Image stone 0x08000375/6/7/8). Leading guess: two draws of the same floor with DIFFERENT light sets (the per-cell-vs-per-something set assignment splitting), or an MDI instance-order/light-set-index desync exposed only when the purple light is stably present. Hunt this in A7 with the full pool temporarily on — it's invisible at cap 128.

THE A7 FIX SHAPE (the real fix, in order)

  1. Port per-cell light registration (insert_light 0x0054d1b0 + the per-cell light list retail keeps). A light lights its OWN cell's geometry + cells reachable through portals — NOT arbitrary world-space overlap. This kills #1 (through-floor) and makes the global pool cap irrelevant (per-cell sets are naturally bounded), which is what actually lets #176/#177 close.
  2. Static curve for stationary fixtures. Decide isDynamic by whether the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern is stationary → static 1/d³. (Register AP-67/AP-44 are the weenie-light path; AP-85 is the pool cap; #143 is the curve-by-path decision to revisit.)
  3. Hunt the stripes with the full pool on (see #3 above).
  4. THEN uncap MaxGlobalLights — un-skip LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant (it asserts the retail end-state: an in-range light of a cell is never camera-evicted).

Tooling built this session (reuse, don't rebuild)

  • tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs — dat truth for the Hub: portal-poly draw verdicts, reciprocal coincidence, stair geometry owner (0x8A020182's ramp shell, vertical portals, ZERO statics), CellBSP containment (partitions exactly at portal planes), under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0 transparent texels), and CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs (the stripe-geometry sweep — came back empty for the 6 floor).
  • tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs — production-matched portal-flood replays (approach/descent/gaze-sweep/walk + the ScenarioE incoherent-root sensitivity pin). Flood is HEALTHY — do not re-investigate it for these issues.
  • tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs — resolver cell-flip is plane-exact (membership is NOT the bug).
  • LightManagerTests.PointSnapshot_HubScaleLightCount_* — Skip'd end-state pin (RED@128, GREEN@1024).
  • Ledger: docs/research/2026-07-06-176-177-render-pair-investigation.md (13 refuted mechanisms + the probe-run discriminator + the OUTCOME banner).

Live probe env (all zero-cost off; use for the A7 spike)

ACDREAM_PROBE_LIGHT=1      # [light] insideCell/ambient/sun/registeredLights/activeLights — rate-limited
ACDREAM_PROBE_PVINPUT=1    # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1       # [cell-transit] timeline anchors
ACDREAM_PROBE_TEXFLUSH=1   # [tex-flush] staged-upload drain (proves #105 healthy: after=0)

A7.L1's planned [indoor-light] probe (per-cell active-light dump: position, color, attenuation, direction) is the natural next apparatus — it prints exactly the SET COMPOSITION the current [light] counts can't. Build it FIRST.

Launch protocol (unchanged)

dotnet build green first; PowerShell launch with the CLAUDE.md env block (+ the probes above), background + Tee to launch-*.log. User manages client lifecycle (graceful close → ACE clears in ~5 s; hard kill → ~3 min). Strip \000 before grep (PowerShell Tee = UTF-16): tr -d '\000' < launch.log | grep .... Test char spawns near 0x8A020179 (the ramp corridor); the 015E↔017A corridor loop is the #176 repro; look-into + descend the 0178→0182→0183 stairs for #177.