diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 9e4c9384..231bef55 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -106,18 +106,23 @@ corridor, and (c) pops into existence on entering the room. Classic
portal-visibility miss: the stair geometry's cell is not reached by the
portal flood from the viewer's cell until the viewer crosses into it.
-**Root cause / status:** INVESTIGATED 2026-07-06 (session 2) β the #119
-class is REFUTED for this issue: the "stairs" are a RAMP owned by shell
-cell 0x8A020182 (no statics anywhere near), connected by VERTICAL portals
-(not floor-portals), and the portal flood admits 0x0182/0x0183 correctly
-from every tested corridor eye/pitch (headless replays
-`Issue176177FacilityHubFloodReplayTests`). Membership transits are
-plane-exact; mesh/frustum/texture layers verified healthy. Twelve
-candidate mechanisms refuted β full ledger + the surviving theories +
-the ONE-launch probe protocol in
+**Status update:** π‘ FIX SHIPPED 2026-07-06 β pending user gate.
+**Root cause (found via the probe launch):** the geometry never vanished β
+its LIGHTS did. `BuildPointLightSnapshot` kept only the `MaxGlobalLights=128`
+point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so
+238 were evicted per frame by camera distance. A room whose torches all
+ranked past the cap rendered at bare 0.2 ambient (near-black in a dungeon =
+"not visible"); approaching re-admitted them ("pops into existence"); the
+eviction boundary sweeping with the camera dropped the ramp's lights
+mid-descent ("disappears on the last step"). Retail's
+`minimize_object_lighting` (0x0054d480) has no global camera-nearest cap.
+**FIX:** cap raised to a non-biting 1024 safety valve (register row AP-85);
+pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
+(RED at 128, GREEN at 1024). Investigation ledger (12 refuted mechanisms +
+the probe run that discriminated):
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
-NEXT: run the probe protocol (ACDREAM_PROBE_LIGHT + ACDREAM_PROBE_PVINPUT
-+ ACDREAM_PROBE_CELL) during the pending #175 door-gate launch.
+**Gate:** stairs/stair room stay visible (lit) from the corridor, through
+the descent, at all approach angles.
**Acceptance:** the staircase renders whenever its room is visible through
the connecting opening, and stays rendered through the full descent.
@@ -138,20 +143,25 @@ render; the physics fix landed (seam shake gone, user-gated) and the flash
REMAINS β so it is a render-side issue in its own right, correlated with
camera angle.
-**Root cause / status:** INVESTIGATED 2026-07-06 (session 2) β the
-placeholder-texture theory is REFUTED (all surfaces resolve; the floor
-IS drawn: the textured PortalSide floor-portal strips are included by
-CellMesh.Build; the under-hall reciprocal is NoPos). So are eleven other
-mechanisms (seal z-fight, root/eye incoherence, flood bistability, DXT
-alpha/undefined mips, fog mix, lightning leak, viewer-light popsβ¦) β
-full refutation ledger, the two surviving theories (T-A outdoor-ambient
-flip on playerRoot gaps: sun-on-floors explains the FLOOR selectivity;
-T-B flood misbehavior under real production inputs), and the ONE-launch
-probe protocol in
+**Status update:** π‘ FIX SHIPPED 2026-07-06 β pending user gate.
+**Root cause (found via the probe launch):** per-cell LIGHTING pops, not a
+draw failure. The probe run reproduced the flash while the ambient branch
+([light] insideCell/0.2 grey β stable) and the portal flood ([pv-input] β
+zero drops in 54k frames) were provably healthy β which eliminated the
+last CPU-side theories and exposed the one channel the probes could not
+see: per-cell 8-light SET COMPOSITION. `BuildPointLightSnapshot` kept the
+128 lights nearest THE CAMERA of the Hub's 366 registered fixtures; an
+in-range torch of a visible cell that ranked past the cap was evicted, so
+that CELL's Gouraud lighting flipped as the camera moved β discontinuity
+lines at exactly cell-seam granularity, camera-angle dependent (the chase
+boom swings the camera position, re-ranking the 128), and a torch-losing
+floor drops to dim blue-grey stone (the perceived purple flash). Twelve
+other mechanisms refuted first β ledger in
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
-The purple itself can only be the fog-tinted clear color (undrawn
-pixels) or the outdoor ambient+sun tint β every other purple source in
-the pipeline is now excluded. NEXT: the probe launch (see #177).
+**FIX:** `MaxGlobalLights` 128 β 1024 non-biting safety valve (AP-85);
+pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`.
+**Gate:** no purple/lighting flashes on floors at corridor seams from any
+camera angle while running the 015Eβ017A loop.
**Acceptance:** no purple/placeholder flashes on dungeon floors from any
camera angle at the corridor seams.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 5f91120d..4766abe6 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -179,6 +179,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone β without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions β a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 β which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speedΓquantum < \|dist\|`, `dist < 0`), acdream applies `delta = β(speedΓquantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there β a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDistβ0 β "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum β₯1/30 β threshold ~1 m; render-rate quanta β ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
+| AP-85 | **Per-frame flat point-light snapshot with a 1024 nearest-to-camera safety cap** (#176/#177 fix, 2026-07-06): acdream collects ALL lit point/spot lights into one flat per-frame snapshot (`BuildPointLightSnapshot`) that per-cell/per-object selection (`SelectForObject`, the faithful AP-16 8-cap) draws from; retail registers lights per-CELL (`insert_light` 0x0054d1b0) and consults the reaching set with NO global pool cap. The previous cap of 128 BIT in the Facility Hub (366 registered fixtures β 238 camera-distance evictions/frame β in-range torches of VISIBLE cells dropped from their 8-sets β per-cell Gouraud lighting popped with camera movement = the #176 purple seam flash + the #177 stair-room light pop-in) | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights`, `BuildPointLightSnapshot`); pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The flat scan is a perf-shape adaptation (feel-identical while the cap never bites); per-object selection stays retail's `minimize_object_lighting` 8-nearest | If content ever registers >1024 lit lights in relevance range, per-cell sets silently become camera-dependent again β the #176/#177 pop class returns. Raise the cap, don't tune it | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors β `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case β doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits β the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path β SetCollisionNormal + Collided β is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp β retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` Β§7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail β clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
diff --git a/docs/research/2026-07-06-176-177-render-pair-investigation.md b/docs/research/2026-07-06-176-177-render-pair-investigation.md
index 238da142..238cb533 100644
--- a/docs/research/2026-07-06-176-177-render-pair-investigation.md
+++ b/docs/research/2026-07-06-176-177-render-pair-investigation.md
@@ -1,6 +1,53 @@
# #176/#177 render pair β investigation ledger (2026-07-06, session 2)
-**Status: mechanism NOT yet pinned β but the hypothesis space is now razor-thin.**
+## β
OUTCOME (same day, after the probe launch): ROOT CAUSE FOUND + FIX SHIPPED
+
+**The probe run discriminated it.** The user reproduced the purple floor
+flash while BOTH surviving CPU theories read provably healthy in the log β
+`[light]` insideCell/ambient rock-stable (one pre-spawn outdoor line, then
+flat 0.2 grey through 36 transits), `[pv-input]` flood stable (54k frames,
+zero collapses). That eliminated T-A and T-B and exposed the one channel
+the probes were structurally blind to: **per-cell 8-light SET COMPOSITION.**
+
+The log's own headline number told the story: `registeredLights=366` β
+against `MaxGlobalLights = 128`. `BuildPointLightSnapshot` kept the 128
+lights nearest THE CAMERA and evicted 238 every frame; `SelectForObject`
+(camera-independent, faithfully retail β 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 β the cell's
+per-vertex Gouraud lighting flipped as the camera moved (the chase boom
+swings the camera position by meters, re-ranking the 128):
+
+- **#176:** the flipping unit is A CELL β discontinuity lines at exactly
+ cell-seam granularity; a torch-losing floor drops to dim blue-grey
+ stone (0.2 ambient Γ stone = the perceived purple); camera-angle
+ dependent by construction.
+- **#177:** a stair room whose torches ALL ranked past the cap rendered
+ at bare 0.2 ambient β near-black in a dungeon = "not visible from the
+ corridor"; approaching re-admitted them = "pops into existence"; the
+ boundary sweeping during the descent dropped the ramp's lights =
+ "disappears on the last step". The geometry never vanished β its
+ LIGHTS did.
+
+Retail anchor: `minimize_object_lighting` (0x0054d480) selects from the
+cell-registered reaching set (`insert_light` 0x0054d1b0) β **no global
+camera-nearest pool cap exists in retail.**
+
+**Fix:** `MaxGlobalLights` 128 β 1024 (a non-biting safety valve; the
+GPU packer grows to fit β 64 B/light). Register row **AP-85**. TDD pin:
+`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
+(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). All four
+suites green. **Pending the user visual gate.**
+
+Process note: the pre-existing test
+`SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre` was written
+to pin "the property that kills the lights-up-as-I-approach popping" β it
+proved the SELECTOR camera-independent while the SNAPSHOT it selects from
+was camera-capped. The pop re-entered one stage upstream of the pin.
+
+---
+
+**Pre-launch status below (kept as the audit trail): mechanism NOT yet pinned β but the hypothesis space is now razor-thin.**
Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
replays, production-log analysis, code reads). Every layer that can be checked
offline is verified HEALTHY at the anchor cells. The surviving discriminator
diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs
index e3f7605a..570669de 100644
--- a/src/AcDream.Core/Lighting/LightManager.cs
+++ b/src/AcDream.Core/Lighting/LightManager.cs
@@ -176,9 +176,18 @@ public sealed class LightManager
public const int MaxLightsPerObject = 8;
/// Hard cap on the per-frame global point-light snapshot the shader
- /// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
- /// is generous. If exceeded, the nearest-to-camera are kept (cold path).
- public const int MaxGlobalLights = 128;
+ /// indexes. #176/#177 (2026-07-06): the old value of 128 BIT in the Facility
+ /// Hub β 366 registered fixtures, so 238 were evicted per frame by camera
+ /// distance. An in-range torch of a VISIBLE cell that ranked past the cap
+ /// dropped out of the cell's 8-light set, so per-cell Gouraud lighting popped
+ /// as the camera moved (the purple seam flash / the stair-room pop-in).
+ /// Retail's minimize_object_lighting (0x0054d480) has NO global
+ /// camera-nearest cap β every registered light reaching an object is a
+ /// candidate. 1024 is a pure safety valve (the whole Facility Hub registers
+ /// 366; GlobalLightPacker grows to fit, 64 B/light on the GPU) and must never
+ /// bite at real content scale β if it ever does, raise it, don't tune it.
+ /// Pin: LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.
+ public const int MaxGlobalLights = 1024;
private readonly List _pointSnapshot = new();
diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
index 264c498c..0bf02967 100644
--- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
@@ -256,4 +256,60 @@ public sealed class LightManagerTests
Assert.Equal(na, nb);
Assert.Equal(a[0], b[0]);
}
+
+ ///
+ /// #176/#177 (2026-07-06): SelectForObject is camera-independent (the test
+ /// above), but the SNAPSHOT it selects from was not β BuildPointLightSnapshot
+ /// kept only the MaxGlobalLights nearest THE CAMERA. In the Facility Hub
+ /// (366 registered fixtures vs the old cap of 128), an in-range torch of a
+ /// VISIBLE cell could rank past the cap and be evicted, so the cell's 8-set
+ /// (and its Gouraud vertex lighting) flipped as the camera moved β per-cell
+ /// lighting pops at seam granularity (#176's flash), and a whole room's
+ /// torches vanishing until approach (#177's pop-in). Retail's
+ /// minimize_object_lighting (0x0054d480) has NO global camera-nearest cap β
+ /// every registered light reaching the object is a candidate. This pins the
+ /// end-to-end property: a light in range of an object stays selected no
+ /// matter where the camera is, at Facility-Hub-scale light counts.
+ ///
+ [Fact]
+ public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
+ {
+ var mgr = new LightManager();
+
+ // 400 fixtures clustered near the origin (the "camera side" of the
+ // dungeon) β these fill every low camera-distance rank.
+ for (int i = 0; i < 400; i++)
+ mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1)));
+
+ // The target torch: far from the origin-side camera (rank ~401), but
+ // squarely in range of the target cell around (200, 0, 0).
+ var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du);
+ mgr.Register(torch);
+
+ Span sel = stackalloc int[LightManager.MaxLightsPerObject];
+
+ // Camera parked at the origin end β the torch must still light the cell.
+ mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero);
+ int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
+ bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
+
+ // Camera next to the cell β the reference behavior.
+ mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0));
+ int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
+ bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
+
+ Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
+ Assert.True(torchSelectedFar,
+ "an in-range light of a visible cell was evicted by the camera-nearest snapshot cap β " +
+ "per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
+
+ static bool SelectedContains(
+ System.Collections.Generic.IReadOnlyList snapshot,
+ Span indices, int count, LightSource target)
+ {
+ for (int i = 0; i < count; i++)
+ if (ReferenceEquals(snapshot[indices[i]], target)) return true;
+ return false;
+ }
+ }
}