fix #176 #177: the camera-capped light snapshot evicted visible cells' torches — per-cell lighting popped at seams

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>
This commit is contained in:
Erik 2026-07-05 21:55:43 +02:00
parent b8e9e204ad
commit 4d25e04d83
5 changed files with 151 additions and 28 deletions

View file

@ -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-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. portal flood from the viewer's cell until the viewer crosses into it.
**Root cause / status:** INVESTIGATED 2026-07-06 (session 2) — the #119 **Status update:** 🟡 FIX SHIPPED 2026-07-06 — pending user gate.
class is REFUTED for this issue: the "stairs" are a RAMP owned by shell **Root cause (found via the probe launch):** the geometry never vanished —
cell 0x8A020182 (no statics anywhere near), connected by VERTICAL portals its LIGHTS did. `BuildPointLightSnapshot` kept only the `MaxGlobalLights=128`
(not floor-portals), and the portal flood admits 0x0182/0x0183 correctly point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so
from every tested corridor eye/pitch (headless replays 238 were evicted per frame by camera distance. A room whose torches all
`Issue176177FacilityHubFloodReplayTests`). Membership transits are ranked past the cap rendered at bare 0.2 ambient (near-black in a dungeon =
plane-exact; mesh/frustum/texture layers verified healthy. Twelve "not visible"); approaching re-admitted them ("pops into existence"); the
candidate mechanisms refuted — full ledger + the surviving theories + eviction boundary sweeping with the camera dropped the ramp's lights
the ONE-launch probe protocol in 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`. `docs/research/2026-07-06-176-177-render-pair-investigation.md`.
NEXT: run the probe protocol (ACDREAM_PROBE_LIGHT + ACDREAM_PROBE_PVINPUT **Gate:** stairs/stair room stay visible (lit) from the corridor, through
+ ACDREAM_PROBE_CELL) during the pending #175 door-gate launch. the descent, at all approach angles.
**Acceptance:** the staircase renders whenever its room is visible through **Acceptance:** the staircase renders whenever its room is visible through
the connecting opening, and stays rendered through the full descent. 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 REMAINS — so it is a render-side issue in its own right, correlated with
camera angle. camera angle.
**Root cause / status:** INVESTIGATED 2026-07-06 (session 2) — the **Status update:** 🟡 FIX SHIPPED 2026-07-06 — pending user gate.
placeholder-texture theory is REFUTED (all surfaces resolve; the floor **Root cause (found via the probe launch):** per-cell LIGHTING pops, not a
IS drawn: the textured PortalSide floor-portal strips are included by draw failure. The probe run reproduced the flash while the ambient branch
CellMesh.Build; the under-hall reciprocal is NoPos). So are eleven other ([light] insideCell/0.2 grey — stable) and the portal flood ([pv-input] —
mechanisms (seal z-fight, root/eye incoherence, flood bistability, DXT zero drops in 54k frames) were provably healthy — which eliminated the
alpha/undefined mips, fog mix, lightning leak, viewer-light pops…) — last CPU-side theories and exposed the one channel the probes could not
full refutation ledger, the two surviving theories (T-A outdoor-ambient see: per-cell 8-light SET COMPOSITION. `BuildPointLightSnapshot` kept the
flip on playerRoot gaps: sun-on-floors explains the FLOOR selectivity; 128 lights nearest THE CAMERA of the Hub's 366 registered fixtures; an
T-B flood misbehavior under real production inputs), and the ONE-launch in-range torch of a visible cell that ranked past the cap was evicted, so
probe protocol in 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`. `docs/research/2026-07-06-176-177-render-pair-investigation.md`.
The purple itself can only be the fog-tinted clear color (undrawn **FIX:** `MaxGlobalLights` 128 → 1024 non-biting safety valve (AP-85);
pixels) or the outdoor ambient+sun tint — every other purple source in pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`.
the pipeline is now excluded. NEXT: the probe launch (see #177). **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 **Acceptance:** no purple/placeholder flashes on dungeon floors from any
camera angle at the corridor seams. camera angle at the corridor seams.

View file

@ -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-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-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-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-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` | | 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` |

View file

@ -1,6 +1,53 @@
# #176/#177 render pair — investigation ledger (2026-07-06, session 2) # #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 Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
replays, production-log analysis, code reads). Every layer that can be checked replays, production-log analysis, code reads). Every layer that can be checked
offline is verified HEALTHY at the anchor cells. The surviving discriminator offline is verified HEALTHY at the anchor cells. The surviving discriminator

View file

@ -176,9 +176,18 @@ public sealed class LightManager
public const int MaxLightsPerObject = 8; public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader /// <summary>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 /// indexes. #176/#177 (2026-07-06): the old value of 128 BIT in the Facility
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary> /// Hub — 366 registered fixtures, so 238 were evicted per frame by camera
public const int MaxGlobalLights = 128; /// 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 <c>minimize_object_lighting</c> (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.</summary>
public const int MaxGlobalLights = 1024;
private readonly List<LightSource> _pointSnapshot = new(); private readonly List<LightSource> _pointSnapshot = new();

View file

@ -256,4 +256,60 @@ public sealed class LightManagerTests
Assert.Equal(na, nb); Assert.Equal(na, nb);
Assert.Equal(a[0], b[0]); Assert.Equal(a[0], b[0]);
} }
/// <summary>
/// #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.
/// </summary>
[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<int> 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<LightSource> snapshot,
Span<int> indices, int count, LightSource target)
{
for (int i = 0; i < count; i++)
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
return false;
}
}
} }