revert #176/#177 cap raise: the uncapped light pool exposes unported per-cell reach semantics — defer to A7

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>
This commit is contained in:
Erik 2026-07-05 23:11:18 +02:00
parent 4d25e04d83
commit d591e3bbe5
5 changed files with 169 additions and 48 deletions

View file

@ -106,23 +106,34 @@ 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.
**Status update:** 🟡 FIX SHIPPED 2026-07-06 — pending user gate. **Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7
**Root cause (found via the probe launch):** the geometry never vanished — dungeon-lighting arc (the cap-raise fix was live-tested and REVERTED,
its LIGHTS did. `BuildPointLightSnapshot` kept only the `MaxGlobalLights=128` see below).
point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so **Root cause (confirmed via the probe launches):** the geometry never
238 were evicted per frame by camera distance. A room whose torches all vanishes — its LIGHTS do. `BuildPointLightSnapshot` keeps only the
ranked past the cap rendered at bare 0.2 ambient (near-black in a dungeon = `MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub
"not visible"); approaching re-admitted them ("pops into existence"); the registers 366 fixtures, so 238 are evicted per frame by camera distance.
eviction boundary sweeping with the camera dropped the ramp's lights A room whose torches all rank past the cap renders at bare 0.2 ambient
mid-descent ("disappears on the last step"). Retail's (near-black in a dungeon = "not visible"); approaching re-admits them
`minimize_object_lighting` (0x0054d480) has no global camera-nearest cap. ("pops into existence"); the eviction boundary sweeping with the camera
**FIX:** cap raised to a non-biting 1024 safety valve (register row AP-85); drops the ramp's lights mid-descent ("disappears on the last step").
pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` Retail's `minimize_object_lighting` (0x0054d480) has no global
(RED at 128, GREEN at 1024). Investigation ledger (12 refuted mechanisms + camera-nearest cap.
the probe run that discriminated): **Why the fix is deferred:** raising the cap to 1024 (commit `4d25e04d`)
made the pops stop but exposed three unported retail lighting semantics
that DOMINATE the frame with the full pool active: (a) lights reach
through solid floors/walls — retail registers lights per-CELL
(`insert_light` 0x0054d1b0) so the under-room portal light never touches
the corridor above; our flat sphere-overlap has no reach notion; (b)
stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9× retail's
static 1/d³ at 3 m — the #143 isDynamic misassignment for ACE-served
fixtures); (c) an unexplained striped z-fight-like artifact on lit floor
regions (user screenshot). Reverted to 128 (`AP-85` documents the
stopgap; the desired-end-state pin is Skip'd in LightManagerTests).
**A7 fix shape:** per-cell light registration (insert_light port) +
static curve for stationary fixtures + the stripe hunt, THEN uncap.
Full investigation ledger:
`docs/research/2026-07-06-176-177-render-pair-investigation.md`. `docs/research/2026-07-06-176-177-render-pair-investigation.md`.
**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 **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.
@ -143,25 +154,28 @@ 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.
**Status update:** 🟡 FIX SHIPPED 2026-07-06 — pending user gate. **Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7
**Root cause (found via the probe launch):** per-cell LIGHTING pops, not a dungeon-lighting arc (see #177 for the revert story — same mechanism,
draw failure. The probe run reproduced the flash while the ambient branch same deferral).
([light] insideCell/0.2 grey — stable) and the portal flood ([pv-input] — **Root cause (confirmed via the probe launches):** per-cell LIGHTING pops,
zero drops in 54k frames) were provably healthy — which eliminated the not a draw failure. The probe run reproduced the flash while the ambient
last CPU-side theories and exposed the one channel the probes could not branch ([light] — stable 0.2 grey) and the portal flood ([pv-input] —
see: per-cell 8-light SET COMPOSITION. `BuildPointLightSnapshot` kept the zero drops in 54k frames) were provably healthy, which eliminated the
128 lights nearest THE CAMERA of the Hub's 366 registered fixtures; an last CPU-side theories and left the one channel the probes cannot see:
in-range torch of a visible cell that ranked past the cap was evicted, so per-cell 8-light SET COMPOSITION. The camera-capped snapshot (128 of the
that CELL's Gouraud lighting flipped as the camera moved — discontinuity Hub's 366 fixtures, nearest-to-camera) evicts in-range lights of visible
lines at exactly cell-seam granularity, camera-angle dependent (the chase cells; the flipping unit is a CELL, so the discontinuities sit at exactly
boom swings the camera position, re-ranking the 128), and a torch-losing cell-seam granularity, swing with the camera position (the chase boom),
floor drops to dim blue-grey stone (the perceived purple flash). Twelve and the dominant flipping light is the under-room PORTALS' purple —
other mechanisms refuted first — ledger in hence purple flashes on the floor. Twelve other mechanisms were 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`.
**FIX:** `MaxGlobalLights` 128 → 1024 non-biting safety valve (AP-85); **Deferral:** the uncapped pool (live-tested `4d25e04d`, reverted)
pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`. stabilizes the pops but floods rooms with through-floor portal light
**Gate:** no purple/lighting flashes on floors at corridor seams from any (no per-cell reach semantics), over-strong dynamic-curve fixture light,
camera angle while running the 015E↔017A loop. and a striped floor artifact — the A7 arc owns the real fix (per-cell
`insert_light` registration + static fixture curve + stripe hunt, then
uncap). Register row AP-85.
**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,7 +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-85 | **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
| 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

@ -176,18 +176,28 @@ 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. #176/#177 (2026-07-06): the old value of 128 BIT in the Facility /// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177,
/// Hub — 366 registered fixtures, so 238 were evicted per frame by camera /// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures →
/// distance. An in-range torch of a VISIBLE cell that ranked past the cap /// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED
/// dropped out of the cell's 8-light set, so per-cell Gouraud lighting popped /// mechanism of the #176 purple seam flash + the #177 stair-room light
/// as the camera moved (the purple seam flash / the stair-room pop-in). /// pop-in — an in-range torch of a visible cell that ranks past the cap
/// Retail's <c>minimize_object_lighting</c> (0x0054d480) has NO global /// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the
/// camera-nearest cap — every registered light reaching an object is a /// camera moves. Retail's <c>minimize_object_lighting</c> (0x0054d480) has
/// candidate. 1024 is a pure safety valve (the whole Facility Hub registers /// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was
/// 366; GlobalLightPacker grows to fit, 64 B/light on the GPU) and must never /// live-tested 2026-07-06 and REVERTED — with the full pool active, three
/// bite at real content scale — if it ever does, raise it, don't tune it. /// unported retail lighting semantics dominate the frame: (a) lights reach
/// Pin: LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary> /// THROUGH solid floors/walls (retail registers lights per-CELL via
public const int MaxGlobalLights = 1024; /// <c>insert_light</c> 0x0054d1b0 — a portal's purple light below never
/// touches the corridor above; our flat sphere-overlap selection has no
/// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC
/// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve),
/// (c) an unexplained striped z-fight-like artifact on lit floor regions
/// (user screenshot, launch-176-texflush session). The proper fix is the
/// A7 dungeon-lighting arc: per-cell light registration + the static curve
/// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired
/// end-state pin (currently Skip):
/// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new(); private readonly List<LightSource> _pointSnapshot = new();

View file

@ -271,7 +271,13 @@ public sealed class LightManagerTests
/// end-to-end property: a light in range of an object stays selected no /// 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. /// matter where the camera is, at Facility-Hub-scale light counts.
/// </summary> /// </summary>
[Fact] [Fact(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " +
"(minimize_object_lighting has no global cap), but uncapping was live-tested " +
"2026-07-06 and reverted — it exposes unported per-cell light-reach semantics " +
"(through-floor light), the dynamic-vs-static falloff misassignment for weenie " +
"fixtures, and an unexplained striped floor artifact. Un-skip when the A7 " +
"dungeon-lighting arc lands per-cell registration (insert_light 0x0054d1b0) " +
"and raises MaxGlobalLights. See ISSUES #176/#177 + register row AP-85.")]
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant() public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
{ {
var mgr = new LightManager(); var mgr = new LightManager();

View file

@ -233,6 +233,94 @@ public class Issue176177DungeonSeamInspectionTests
} }
} }
/// <summary>
/// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region
/// z-fights in regular bands between a purple-lit copy and an unlit copy —
/// two COINCIDENT DRAWN surfaces with different per-cell light sets. This
/// sweep hunts the pair in the dat: every pair of DRAWN polys across the
/// corridor neighborhood that is coplanar AND overlapping in area. Before
/// the light-cap fix both copies were usually equally unlit (the purple
/// portal light was cap-evicted) so the fight was invisible; the stable
/// light exposed it.
/// </summary>
[Fact]
public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Seed cells around the screenshot location (the 016E/017A seam) +
// one portal ring.
var cellIds = new HashSet<uint> { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
foreach (var seed in new List<uint>(cellIds))
{
var seedCell = dats.Get<EnvCell>(seed);
if (seedCell is null) continue;
foreach (var p in seedCell.CellPortals)
cellIds.Add(0x8A020000u | p.OtherCellId);
}
// Collect all DRAWN polys world-space per cell.
var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D,
Vector3 Min, Vector3 Max, uint SurfaceId)>();
foreach (var cellId in cellIds)
{
var loaded = LoadCell(dats, cellId);
if (loaded is null) continue;
var (cell, cs) = loaded.Value;
var world = WorldTransform(cell);
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
var w = WorldVerts(cs, poly, world);
if (w.Count < 3) continue;
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
float d = Vector3.Dot(n, w[0]);
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
drawn.Add((cellId, id, n, d, min, max, surfaceId));
}
}
_out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}");
int pairs = 0;
for (int i = 0; i < drawn.Count; i++)
{
for (int j = i + 1; j < drawn.Count; j++)
{
var a = drawn[i]; var b = drawn[j];
if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue;
float align = Vector3.Dot(a.N, b.N);
if (MathF.Abs(align) < 0.999f) continue;
float dB = align > 0 ? b.D : -b.D;
if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm
// Overlap in world AABB, with meaningful area in the plane.
float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X);
float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y);
float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z);
if (ox < 0.05f || oy < 0.05f) continue;
// For horizontal planes require XY overlap area; for walls allow thin Z.
bool horizontal = MathF.Abs(a.N.Z) > 0.85f;
if (horizontal && ox * oy < 0.05f) continue;
if (!horizontal && oz < 0.05f) continue;
pairs++;
_out.WriteLine(
$">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " +
$"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " +
$"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " +
$"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " +
$"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]");
}
}
_out.WriteLine($"coplanar overlapping drawn pairs: {pairs}");
}
/// <summary> /// <summary>
/// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE /// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE
/// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps /// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps
@ -379,6 +467,9 @@ public class Issue176177DungeonSeamInspectionTests
[InlineData(0x8A02011Du)] [InlineData(0x8A02011Du)]
[InlineData(0x8A020122u)] [InlineData(0x8A020122u)]
[InlineData(0x8A02011Fu)] [InlineData(0x8A02011Fu)]
[InlineData(0x8A02016Eu)] // corridor cells — the striped-floor screenshot area
[InlineData(0x8A02017Au)]
[InlineData(0x8A020165u)]
public void UnderHall_DrawnPolys_SurfaceColors(uint cellId) public void UnderHall_DrawnPolys_SurfaceColors(uint cellId)
{ {
var datDir = ResolveDatDir(); var datDir = ResolveDatDir();