The seam-floor purple flicker was NOT a draw z-fight. The in-engine
[seam-*] probe (ACDREAM_PROBE_SEAMDRAW - built because RenderDoc cannot
capture this pipeline: it hides GL_ARB_bindless_texture and the
mandatory-modern startup gate throws; AMD GPU rules out Nsight) killed
every double-draw suspect: ONE shell instance per seam cell at the
lifted z, no floor-coincident entity (portal entities sit at z=-12.05),
zero portal depth fans in the sealed Hub. What it caught instead: the
corridor floor's applied light set flipping wholesale with the flood.
Root cause: c500912b scoped BuildPointLightSnapshot by the per-frame
portal flood, on the research doc's gloss of CEnvCell::visible_cell_table
as "the portal-flood visible set". The named decomp refutes the gloss:
add_visible_cell (0x0052de40) DBObj-LOADS absent cells and inserts them;
a cell activation adds itself + its whole dat visible-cell list
(0x0052e228/0x0052e24a); entries leave only via the flush machinery.
It is the RESIDENT-cell registry - gaze can never remove a cell.
add_dynamic_lights (0x0052d410) walks the WHOLE table per frame
(caller 0x00452d30), and insert_light (0x0054d1b0) caps the pool by
distance to Render::player_pos (0x0054d1dd). Retail's pool is a function
of player position only. Ours followed the camera: turning changed the
flood (probe: 8..41 cells across one turn), the six intensity-100
under-room portal purples entered/left the pool, and the wedge blinked.
Fix: BuildPointLightSnapshot(playerWorldPos) collects ALL registered
(=resident) lit lights; over cap keeps dynamics FIRST (retail's separate
7-slot dynamic pool never competes with statics) then nearest-the-player;
the RebuildScopedLights callback is deleted. Live-verified with the probe:
full-circle turn, flood churning 8..41, the floor set held the same 8
identities on every post-spawn frame. The purple wedge SHAPE stays - it
is cdb-proven retail-faithful.
Residual deviation (AP-85 rewritten): single 128 pool vs retail's
7-dynamic/40-static degrade-scaled dual pools - the Hub now shows
7 purples + viewer where retail's cdb showed 4 + viewer + fixture slots;
if the gate reads the wedge as too purple, the A7 dual-pool cap is the
faithful trim.
Pins: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(rewritten to the corrected model),
PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics,
PointSnapshot_OverCap_KeepsNearestThePlayer,
PointSnapshot_ResidentCollection_CellTagDoesNotFilter.
Suites: Core 2599+2skip / App 726+2skip / UI 425 / Net 385.
The [seam-*] probes stay until the visual gate passes, then strip.
Correction banner added to 2026-07-06-a7-per-cell-lighting-pseudocode.md;
outcome banner on the z-fight handoff; ISSUES #176 updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Port retail's per-frame light collection: the point-light pool is built from ONLY the
currently-visible cells' lights, matching CObjCell::add_*_to_global_lights
(0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a
flat world-space set capped at 128-nearest-camera.
- LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both
registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics).
- LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff
CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting
backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8).
- Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the
flood resolves prepareCells and before the draws (renderers select from the same
in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass).
- [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION.
Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.
CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe
PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame,
CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool
composition is NOT the cause. #176 real cause = an over-bright purple point light
(intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss
(stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the
camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected;
ISSUES #176/#177 re-diagnosed; render digest banner updated.
Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light
0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md.
Suites green: Core 2595 + 2 skip, App 719 + 2 skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MaxGlobalLights 128->1024 fix (4d25e04d) was live-tested and made
the eviction pops stop — but with the full 366-fixture pool active,
three unported retail lighting semantics dominate the Facility Hub:
(a) lights reach THROUGH solid floors/walls: retail registers lights
per-CELL (insert_light 0x0054d1b0) so the under-room portals'
purple light never touches the corridor above; our flat
sphere-overlap selection has no reach/occlusion notion — rooms
washed magenta (user screenshot).
(b) stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9x
retail's static 1/d3 bake curve at 3m) — the #143 isDynamic
assignment is wrong for ACE-served world fixtures.
(c) an unexplained striped z-fight-like artifact on lit floor regions
(user screenshot; no coincident dat geometry — the coplanar-pair
sweep came back empty; not a striped texture — all corridor
surfaces are plain Base1Image stone).
Reverted to 128. The cap is now documented as a LOAD-BEARING STOPGAP:
it accidentally approximates per-cell reach by keeping the pool local
to the camera. The #176/#177 root cause (cap eviction popping per-cell
light sets) stays CONFIRMED and fully documented; the real fix is the
A7 dungeon-lighting arc: per-cell light registration + the static
fixture curve + the stripe hunt, THEN uncap. The desired-end-state pin
is kept as Skip with the full pointer. Register row AP-85 rewritten to
match reality; ISSUES #176/#177 back to OPEN with the complete
mechanism story.
Suites: Core 2591+3skip / App 719 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The probe launch discriminated it: the user reproduced the purple floor
flash while [light] (ambient branch) and [pv-input] (portal flood) read
provably healthy — eliminating the last CPU-side theories and exposing
the one channel the probes could not see: per-cell 8-light set
composition.
BuildPointLightSnapshot kept the MaxGlobalLights=128 point lights
nearest THE CAMERA; the Facility Hub registers 366 fixtures, so 238
were evicted per frame by camera distance. SelectForObject (faithfully
camera-independent, and unit-pinned as such) could only choose from the
surviving 128 — an in-range torch of a visible cell that ranked past
the cap dropped out of that cell's 8-set, so per-cell Gouraud lighting
flipped as the chase boom swung the camera:
- #176: the flipping unit is a CELL -> discontinuity lines at exactly
cell-seam granularity; a torch-losing floor drops to dim blue-grey
stone at 0.2 ambient (the perceived purple), camera-angle dependent.
- #177: a stair room whose torches all ranked past the cap rendered at
bare 0.2 ambient (near-black = 'not visible'); approach re-admitted
them ('pops into existence'); the sweeping boundary dropped the
ramp's lights mid-descent ('disappears on the last step'). The
geometry never vanished - its lights did.
Retail's minimize_object_lighting (0x0054d480) has NO global
camera-nearest pool cap (lights register per cell, insert_light
0x0054d1b0). Fix: MaxGlobalLights 128 -> 1024, a non-biting safety
valve (GlobalLightPacker grows to fit; 64 B/light). Register row AP-85.
TDD pin: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). The
pre-existing camera-independence pin covered the SELECTOR but not the
SNAPSHOT it selects from - the pop re-entered one stage upstream.
Suites: Core 2588 / App 719 / UI 425 / Net 385 green. Pending user gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Outdoor objects brightened as the camera approached: lighting selected the
nearest 8 lights to the VIEWER and fed that one global set to everything
(LightManager.Tick), so a building's wall torches only lit it once the camera
got close enough for them to win the global top-8. Probe confirmed the scale of
the problem: a single Holtburg view registers 129 point lights — the global cap
of 8 was hopeless.
Retail selects up to 8 lights PER OBJECT by the object's own position
(minimize_object_lighting 0x0054d480), so a torch always lights the wall it
sits on, camera-independent. Ported faithfully:
- LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy
(light.pos − center)² < (Range + radius)², nearest-8 among those. Plus
BuildPointLightSnapshot for the per-frame stable-indexed light list.
- mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the
snapshot), binding=5 per-instance light SET (8 int indices into it, -1 =
unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot
mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO
(cleared as faithful by the lighting audit) and loops THIS instance's point
lights. pointContribution factored out (same calc_point_light wrap+norm shape).
- WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site
(constant across the entity's parts), by the entity's AABB sphere; threaded
into grp.LightSets parallel to grp.Matrices; global + per-instance buffers
uploaded in Phase 5. Camera-independent ⇒ stable for static buildings.
- GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame.
Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green
(parallel-array lockstep preserved). Visually gated: the meeting hall now holds
steady as the camera approaches (was the popping symptom).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The active-light selection dropped any point light whose range didn't reach the
VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks
the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader
(mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed
a torch whenever the player stood outside its range, so a dungeon room with 2227
registered torches lit only the ~1 the player was standing in (activeLights ~= 1,
rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter;
take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack
const; updated the two tests that codified the old filter. Core lighting suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail-faithful 8-light cap selection (r13 §12) — the fixed-function
D3D pipeline's "hardware lights" constraint carried over to modern GL
via UBO-per-draw.
Core layer (AcDream.Core/Lighting):
- LightSource: Kind (Directional/Point/Spot), WorldPosition,
WorldForward, ColorLinear, Intensity, Range (hard cutoff),
ConeAngle (spot), OwnerId (entity attachment), IsLit latch.
- CellAmbientState: (AmbientColor, SunColor, SunDirection) sourced from
R12 sky state for outdoor cells or EnvCell dat for indoor cells.
- LightManager: Register/Unregister/UnregisterByOwner/Clear + Tick
per frame. Selection matches r13 §12.2 exactly:
1) Skip unlit + directional.
2) Compute DistSq for every registered point/spot.
3) Drop lights outside Range² * 1.1 (10% slack prevents pop).
4) Sort by DistSq ascending; take up to 7 (slot 0 reserved for Sun).
5) Slot 0 = Sun (Directional); slots 1..7 = nearest in-range.
Tests (9 new):
- Register/Unregister/Idempotent register.
- Tick picks top 8 by distance when 12 registered.
- Range filter drops far lights (5.0 range, 20m away).
- Range slack includes lights at exactly the boundary.
- Sun reserved at slot 0 across ticks.
- Unlit lights excluded; toggling IsLit brings them back.
- UnregisterByOwner removes all owner's lights.
- DistSq updated each tick for viewer movement.
Build green, 596 tests pass (up from 587).
Next: wire LightManager into the shader UBO pass (G.2 second commit)
and feed Sun from WorldTimeService.CurrentSunDirection per frame.
Ref: r13 §10.2 (D3D attenuation = none inside Range + hard cutoff),
§12 (full port plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>