fix #176: light pool tracked the camera via flood scoping - collect residents, anchor at player
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>
This commit is contained in:
parent
8cb3176daa
commit
d8984e877f
12 changed files with 525 additions and 194 deletions
|
|
@ -149,21 +149,37 @@ the connecting opening, and stays rendered through the full descent.
|
||||||
|
|
||||||
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
|
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
|
||||||
|
|
||||||
**⚠️ UPDATE 2026-07-06 (final — cdb-confirmed, RenderDoc handoff) — it's a RUNTIME draw
|
**🟡 FIX SHIPPED 2026-07-06 (pending user visual gate) — the flicker was the LIGHT POOL
|
||||||
z-fight, NOT lighting.** Full elimination table + plan:
|
tracking the CAMERA, not a draw z-fight.** The z-fight framing was refuted by the in-engine
|
||||||
`docs/research/2026-07-06-176-seam-floor-zfight-handoff.md`. The purple WEDGE SHAPE is
|
`[seam-*]` probe (RenderDoc is infeasible on this pipeline — it hides
|
||||||
retail-FAITHFUL — cdb (`tools/cdb/issue176-floor-light.cdb`) proved retail applies the same
|
`GL_ARB_bindless_texture` and our mandatory-modern startup gate throws; AMD GPU rules out
|
||||||
4 intensity-100 magenta portal lights + the viewer fill to EVERY Hub cell via D3D hardware,
|
Nsight): exactly ONE shell instance per seam cell at the lifted z (−5.98), NO
|
||||||
so the faceted per-vertex purple is exactly what retail shows. The only defect is the
|
floor-coincident entity (portal entities sit at z=−12.05, six meters down), ZERO portal
|
||||||
FLICKER, eliminated as: NOT lighting (per-light cap + all-dynamic-per-cell both no-change),
|
depth fans (sealed dungeon). What the probe DID catch: the corridor floor's applied light
|
||||||
NOT membership (render cell `0x8A020164` stable on 100% of 188k frames / 526 angles), NOT dat
|
set flipping wholesale with flood composition. Root cause (named-decomp-verified): the
|
||||||
geometry (coplanar sweep empty at the z=−6 corridor floor, incl. cell `0164`). → a RUNTIME
|
`c500912b` scoping port glossed retail's `CEnvCell::visible_cell_table` as "the portal-flood
|
||||||
double-draw of the seam floor. Suspects: the `+0.02 m` shell lift (AP-32/#130), the #178
|
visible set" — it is the **RESIDENT-cell registry** (`add_visible_cell` 0x0052de40
|
||||||
double-sided cell-shell stopgap, a portal depth-mask. **NEXT: RenderDoc pixel-history** on a
|
dat-loads absent cells; populated from each activated cell + its dat visible-cell list;
|
||||||
flickering floor pixel to name the two fighting draws. Shipped this arc (retail-faithful, NOT
|
gaze can never remove entries), and `Render::insert_light` (0x0054d1b0) caps the pool
|
||||||
the fix): scoping `c500912b` + `SelectForCell` (all-dynamic-lights-per-cell).
|
nearest **`Render::player_pos`**, not the camera. Our flood-scoped pool dropped/admitted
|
||||||
|
the six intensity-100 under-room portal purples as the camera turn changed the flood
|
||||||
|
(probe: flood churned 8→41 cells over a full turn) → the wedge blinked. **Fix:**
|
||||||
|
`BuildPointLightSnapshot(playerWorldPos)` — resident collection (all registered lit
|
||||||
|
lights), dynamics-first player-nearest cap; the `RebuildScopedLights` callback deleted.
|
||||||
|
Verified live: full-circle turn sweep, flood churning 8→41, the floor's set held the SAME
|
||||||
|
8 identities on every post-spawn frame. Pins: `PointSnapshot_HubScale…CameraInvariant` (rewritten),
|
||||||
|
`PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`.
|
||||||
|
Register AP-85 rewritten; correction banner in
|
||||||
|
`docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md`. Residual parity note for the
|
||||||
|
gate: our single 128 pool admits 7 purples + viewer (8 dynamics, all 8 slots) where retail's
|
||||||
|
7-dynamic/40-static dual pool showed 4 purples + viewer + fixture slots — if the wedge reads
|
||||||
|
"too purple," the A7-arc dual-pool cap is the faithful trim. `[seam-*]` probes stay until the
|
||||||
|
gate passes, then strip.
|
||||||
|
|
||||||
**Status:** OPEN
|
**Gate:** stand in the `0x8A020164` corridor, turn back-and-forth across the seam — the
|
||||||
|
purple wedge must hold steady (its faceted SHAPE is retail-correct and stays).
|
||||||
|
|
||||||
|
**Status:** 🟡 FIX SHIPPED — pending user gate
|
||||||
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
|
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
|
||||||
**Filed:** 2026-07-06
|
**Filed:** 2026-07-06
|
||||||
**Component:** render — floor-portal polygons / portal surface state
|
**Component:** render — floor-portal polygons / portal surface state
|
||||||
|
|
|
||||||
|
|
@ -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 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **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-85 | **Point-light pool = single 128-cap player-nearest list vs retail's dual pools (7 dynamic + 40 static, degrade-scaled)** (#176 CORRECTED AGAIN 2026-07-06, second correction same day): the pool is now retail's shape — collected per frame from ALL registered (=resident-cell) lit lights and, over cap, kept dynamics-FIRST then nearest-THE-PLAYER (`Render::insert_light` 0x0054d1b0 sorts by `Render::player_pos`; `CEnvCell::add_dynamic_lights` 0x0052d410 walks the WHOLE static `CEnvCell::visible_cell_table`, which `add_visible_cell` 0x0052de40 proves is the RESIDENT-cell registry — it DBObj-loads absent cells; NOT the portal flood). **Two prior camera-coupled models were each a #176 flicker mechanism and are deleted: (1) nearest-CAMERA-128 (chase-boom churn), (2) frame-FLOOD scoping `c500912b` (the prior AP-85 text called it faithful — WRONG: the doc glossed `visible_cell_table` as "the portal-flood visible set"; the under-room portal purples entered/left the pool as the camera turned = the seam blink, probe-proven `[seam-blk]`: flood churned 8→41 cells over a full turn while the fixed pool held the same 8 identities).** Remaining deviation: ONE pool capped 128 vs retail's separate 7-dynamic/40-static with degrade scaling (`0x0081ec94/98`, `SetDegradeLevelInternal` 0x0054c3c0) — in the Hub this admits 7 purples + viewer (8 dynamics) where retail's 7-dynamic cap + static slots would mix in warm fixture light (cdb saw 4 purples + viewer) | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter` | Single-pool is the minimal faithful-in-effect shape: dynamics-first ordering reproduces the never-evicted property; 128 > 40+7 only ever admits EXTRA far statics (out of selection range anyway) | A room with >7 resident dynamics shows them all (retail trims to 7 player-nearest) — slightly purpler Hub wedge than retail; adopt the dual pools + degrade caps in the A7 arc | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (resident registry); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||||
| 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` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
# Pickup handoff — #176 seam-floor lighting flicker = a RUNTIME draw z-fight (RenderDoc next)
|
# Pickup handoff — #176 seam-floor lighting flicker = a RUNTIME draw z-fight (RenderDoc next)
|
||||||
|
|
||||||
|
> ## ✅ OUTCOME (2026-07-06, the pickup session) — z-fight REFUTED; it was the light POOL tracking the camera; FIX SHIPPED
|
||||||
|
>
|
||||||
|
> RenderDoc was infeasible (it does not support `GL_ARB_bindless_texture` and hides
|
||||||
|
> it → our mandatory-modern startup gate throws `NotSupportedException`; the AMD RX
|
||||||
|
> 9070 XT rules out Nsight). The equivalent evidence came from the in-engine
|
||||||
|
> `ACDREAM_PROBE_SEAMDRAW` probe (`[seam-blk]/[seam-cell]/[seam-snap]/[seam-ent]/
|
||||||
|
> [seam-mask]`): **one** shell instance per seam cell at the lifted z (suspect 1
|
||||||
|
> dead), **no** floor-coincident entity (suspect-class dead), **zero** portal depth
|
||||||
|
> fans (suspect 3 dead) — but the corridor floor's applied LIGHT SET flipped
|
||||||
|
> wholesale with flood composition. Root cause: the `c500912b` visible-cell scoping
|
||||||
|
> was built on a wrong gloss — retail's `CEnvCell::visible_cell_table` is the
|
||||||
|
> **RESIDENT-cell registry** (`add_visible_cell` 0x0052de40 dat-loads absent cells),
|
||||||
|
> not the frame flood, and `insert_light` (0x0054d1b0) anchors the pool at
|
||||||
|
> **`Render::player_pos`**. Fix: `BuildPointLightSnapshot(playerWorldPos)` — resident
|
||||||
|
> collection, dynamics-first player-nearest cap; `RebuildScopedLights` deleted.
|
||||||
|
> Live-verified: flood churned 8→41 cells over a full-circle turn while the floor's
|
||||||
|
> set held the same 8 identities. Details: ISSUES #176, register AP-85, the
|
||||||
|
> correction banner in `2026-07-06-a7-per-cell-lighting-pseudocode.md`. The document
|
||||||
|
> below is kept as the historical elimination record — its DO-NOT-RETRY table
|
||||||
|
> remains valid (all those channels stay dead).
|
||||||
|
|
||||||
**Paste the companion prompt below into a fresh session.** Read this file, then
|
**Paste the companion prompt below into a fresh session.** Read this file, then
|
||||||
`claude-memory/project_render_pipeline_digest.md` (top banner), then **ISSUES #176**.
|
`claude-memory/project_render_pipeline_digest.md` (top banner), then **ISSUES #176**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,40 @@
|
||||||
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
|
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
|
||||||
|
|
||||||
|
> ## ⚠️ CORRECTION #2 (2026-07-06, later the same day — the #176 flicker root cause)
|
||||||
|
>
|
||||||
|
> **§1.3's gloss is WRONG: `CEnvCell::visible_cell_table` is NOT "the portal-flood
|
||||||
|
> visible set".** It is a **static class member** — `HashTable<uint, CEnvCell*>
|
||||||
|
> CEnvCell::visible_cell_table` (defined 0x0081dd28, ctor 0x006fea4a) — the
|
||||||
|
> **RESIDENT-EnvCell registry**. Proof from the named decomp:
|
||||||
|
> - `CEnvCell::add_visible_cell(id)` (0x0052de40) returns the cell if present,
|
||||||
|
> otherwise **loads it from the dat** (`DBObj::Get(QDID(id, 3))`) and inserts it.
|
||||||
|
> Entries leave only via the flush machinery (`cell_flush_table` /
|
||||||
|
> `flush_cells` 0x0052def0) — **camera gaze can never remove a cell**.
|
||||||
|
> - It is populated at cell activation: a cell adds **itself + its entire dat
|
||||||
|
> visible-cell list** (`add_visible_cell(this->m_DID.id)` 0x0052e228 +
|
||||||
|
> `add_visible_cell(this->stab_list[i])` loop 0x0052e24a).
|
||||||
|
> - `CEnvCell::add_dynamic_lights` (0x0052d410) walks the **whole table**
|
||||||
|
> (`begin(&visible_cell_table)`), per frame, from 0x00452d30 (SmartBox).
|
||||||
|
> - `Render::insert_light` (0x0054d1b0) sorts the pool by distance to
|
||||||
|
> **`Render::player_pos`** (0x0054d1dd–0x0054d214; set from
|
||||||
|
> `player->m_position`, SmartBox 0x00453d3a, viewer fallback 0x00455ab6).
|
||||||
|
>
|
||||||
|
> **So retail's pool = all RESIDENT cells' lights, capped nearest-the-PLAYER —
|
||||||
|
> a function of player position only, camera-invariant.** The §3.1 "slice 1"
|
||||||
|
> shipped below (flood-scoped `BuildPointLightSnapshot(camPos, visibleCells)`,
|
||||||
|
> `c500912b`) implemented the wrong gloss and WAS the #176 seam-floor blink: the
|
||||||
|
> under-room portal purples entered/left the pool as the camera turned (probe
|
||||||
|
> `[seam-blk]`: flood churned 8→41 cells across a full turn). Fixed same day —
|
||||||
|
> the scoping + the `RebuildScopedLights` callback are deleted;
|
||||||
|
> `BuildPointLightSnapshot(playerWorldPos)` collects every registered lit light
|
||||||
|
> (registration lifecycle = residency) with dynamics-first player-nearest
|
||||||
|
> capping. §2's "Retail never has 366 candidates" and the through-floor
|
||||||
|
> paragraph are likewise wrong: retail's Hub pool DOES contain the under-room
|
||||||
|
> purples (the cdb capture showed them applied to every Hub cell — the wedge is
|
||||||
|
> faithful). Register row AP-85 rewritten. Lesson (feedback_bn_decomp_field_names
|
||||||
|
> class): a decomp identifier's NAME is not its semantics — verify what
|
||||||
|
> POPULATES a structure before adopting its name as its meaning.
|
||||||
|
|
||||||
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
|
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
|
||||||
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
|
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
|
||||||
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
|
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
|
||||||
|
|
|
||||||
|
|
@ -9103,7 +9103,12 @@ public sealed class GameWindow : IDisposable
|
||||||
// camera UBO set for point/spot lights so a wall's torches stay tied to
|
// camera UBO set for point/spot lights so a wall's torches stay tied to
|
||||||
// the wall as the camera moves. The SUN + ambient still flow through the
|
// the wall as the camera moves. The SUN + ambient still flow through the
|
||||||
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
|
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
|
||||||
Lighting.BuildPointLightSnapshot(camPos);
|
// #176 root cause: the pool is anchored at the PLAYER (retail
|
||||||
|
// Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and
|
||||||
|
// collected from ALL registered (=resident-cell) lights — it is a
|
||||||
|
// function of player position only, so camera rotation cannot change
|
||||||
|
// it. playerViewPos carries retail's no-player fallback (camPos).
|
||||||
|
Lighting.BuildPointLightSnapshot(playerViewPos);
|
||||||
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
|
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
|
||||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||||
|
|
||||||
|
|
@ -9409,11 +9414,10 @@ public sealed class GameWindow : IDisposable
|
||||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||||
Camera = camera,
|
Camera = camera,
|
||||||
CameraWorldPosition = camPos,
|
CameraWorldPosition = camPos,
|
||||||
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
|
// (#176 correction: the former RebuildScopedLights callback —
|
||||||
// rebuild the point-light pool from ONLY those cells' lights (retail's
|
// rebuilding the light pool from the frame's FLOOD — was the
|
||||||
// per-frame add_*_lights over visible_cell_table). The renderers hold a
|
// flicker mechanism and is deleted. The pool is built once per
|
||||||
// reference to the same PointSnapshot list, rebuilt in place here.
|
// frame above, player-anchored, from all resident lights.)
|
||||||
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
|
|
||||||
Frustum = frustum,
|
Frustum = frustum,
|
||||||
PlayerLandblockId = playerLb,
|
PlayerLandblockId = playerLb,
|
||||||
AnimatedEntityIds = animatedIds,
|
AnimatedEntityIds = animatedIds,
|
||||||
|
|
@ -11511,6 +11515,22 @@ public sealed class GameWindow : IDisposable
|
||||||
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
|
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #176 seam-draw probe: a sealed dungeon must emit ZERO depth fans
|
||||||
|
// (only OtherCellId==0xFFFF portals reach here) — any line in a
|
||||||
|
// target cell is a finding (a depth stamp fighting the shell floor).
|
||||||
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
|
||||||
|
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(sliceCtx.CellId))
|
||||||
|
{
|
||||||
|
float seamZMin = float.MaxValue, seamZMax = float.MinValue;
|
||||||
|
for (int v = 0; v < n; v++)
|
||||||
|
{
|
||||||
|
seamZMin = System.Math.Min(seamZMin, world[v].Z);
|
||||||
|
seamZMax = System.Math.Max(seamZMax, world[v].Z);
|
||||||
|
}
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[seam-mask] t={System.Environment.TickCount64} cell=0x{sliceCtx.CellId:X8} portal={i} far={forceFarZ} n={n} z=[{seamZMin:F3},{seamZMax:F3}]"));
|
||||||
|
}
|
||||||
|
|
||||||
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
|
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,11 +146,10 @@ public sealed class RetailPViewRenderer
|
||||||
prepareCells = _lookInPrepareScratch;
|
prepareCells = _lookInPrepareScratch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
|
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
|
||||||
// drawn, NOW that the flood has resolved the visible set (retail collects lights
|
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
|
||||||
// per-frame over visible_cell_table). Must run before the cell/entity draws below
|
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
|
||||||
// that select from LightManager.PointSnapshot.
|
// is built once per frame in GameWindow, player-anchored.)
|
||||||
ctx.RebuildScopedLights?.Invoke(prepareCells);
|
|
||||||
|
|
||||||
_envCells.PrepareRenderBatches(
|
_envCells.PrepareRenderBatches(
|
||||||
ctx.ViewProjection,
|
ctx.ViewProjection,
|
||||||
|
|
@ -1108,16 +1107,6 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||||
public Action? DrawUnattachedSceneParticles { get; init; }
|
public Action? DrawUnattachedSceneParticles { get; init; }
|
||||||
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
||||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||||
|
|
||||||
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
|
|
||||||
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
|
|
||||||
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
|
|
||||||
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
|
|
||||||
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
|
|
||||||
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
|
|
||||||
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
|
|
||||||
/// legacy full-pool snapshot.</summary>
|
|
||||||
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RetailPViewFrameResult
|
public sealed class RetailPViewFrameResult
|
||||||
|
|
|
||||||
|
|
@ -890,7 +890,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
|
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
|
||||||
|
|
||||||
var allInstances = new List<InstanceData>();
|
var allInstances = new List<InstanceData>();
|
||||||
var drawCalls = new List<(ObjectRenderData renderData, int count, int offset)>();
|
var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>();
|
||||||
|
|
||||||
if (filter == null)
|
if (filter == null)
|
||||||
{
|
{
|
||||||
|
|
@ -902,7 +902,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||||||
if (renderData != null && !renderData.IsSetup)
|
if (renderData != null && !renderData.IsSetup)
|
||||||
{
|
{
|
||||||
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
|
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
|
||||||
allInstances.AddRange(transforms);
|
allInstances.AddRange(transforms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -950,12 +950,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||||||
if (renderData != null && !renderData.IsSetup)
|
if (renderData != null && !renderData.IsSetup)
|
||||||
{
|
{
|
||||||
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
|
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
|
||||||
allInstances.AddRange(transforms);
|
allInstances.AddRange(transforms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #176 seam-draw probe: stash this call's filter so the opaque-pass
|
||||||
|
// emitter inside RenderModernMDIInternal can report flood membership
|
||||||
|
// per target cell (null on the unfiltered/outdoor path).
|
||||||
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||||
|
_seamProbeFilter = filter;
|
||||||
|
|
||||||
// WB EnvCellRenderManager.cs:470-483:
|
// WB EnvCellRenderManager.cs:470-483:
|
||||||
if (allInstances.Count > 0)
|
if (allInstances.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1103,7 +1109,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
|
|
||||||
private void RenderModernMDIInternal(
|
private void RenderModernMDIInternal(
|
||||||
AcDream.App.Rendering.Shader shader,
|
AcDream.App.Rendering.Shader shader,
|
||||||
List<(ObjectRenderData renderData, int count, int offset)> drawCalls,
|
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||||||
List<InstanceData> allInstances,
|
List<InstanceData> allInstances,
|
||||||
WbRenderPass renderPass)
|
WbRenderPass renderPass)
|
||||||
{
|
{
|
||||||
|
|
@ -1330,6 +1336,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light
|
||||||
|
// sets read through the just-cleared cache against THIS frame's
|
||||||
|
// _pointSnapshot — the exact data the SSBO upload below carries.
|
||||||
|
if (renderPass == WbRenderPass.Opaque
|
||||||
|
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||||
|
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
|
||||||
|
|
||||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||||
|
|
@ -1419,6 +1432,104 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
||||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus.
|
||||||
|
// The in-engine replacement for the RenderDoc pixel-history the pipeline
|
||||||
|
// can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern
|
||||||
|
// startup gate throws). Per opaque pass: for each target cell — flood
|
||||||
|
// membership, every shell instance (count + translation, F3 z shows the
|
||||||
|
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
|
||||||
|
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
|
||||||
|
// intensity; raw indices shuffle when the pool rebuilds). Plus the
|
||||||
|
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
|
||||||
|
// ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity
|
||||||
|
// flipping with flood membership = the snapshot-scope mechanism; two
|
||||||
|
// coincident instances = the z-fight. See RenderingDiagnostics.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private HashSet<uint>? _seamProbeFilter;
|
||||||
|
private string? _seamSig;
|
||||||
|
private long _seamLastEmitMs;
|
||||||
|
|
||||||
|
private void EmitSeamDrawProbe(
|
||||||
|
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||||||
|
List<InstanceData> allInstances,
|
||||||
|
HashSet<uint>? filter)
|
||||||
|
{
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
var snap = _pointSnapshot;
|
||||||
|
var sb = new System.Text.StringBuilder(640);
|
||||||
|
|
||||||
|
var sorted = new List<uint>(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells);
|
||||||
|
sorted.Sort();
|
||||||
|
foreach (uint cell in sorted)
|
||||||
|
{
|
||||||
|
sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8"));
|
||||||
|
sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N'));
|
||||||
|
|
||||||
|
int totalInst = 0;
|
||||||
|
foreach (var dc in drawCalls)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
InstanceData first = default;
|
||||||
|
for (int i = dc.offset; i < dc.offset + dc.count; i++)
|
||||||
|
{
|
||||||
|
if (allInstances[i].CellId != cell) continue;
|
||||||
|
if (n == 0) first = allInstances[i];
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n == 0) continue;
|
||||||
|
totalInst += n;
|
||||||
|
var t = first.Transform.Translation;
|
||||||
|
sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})",
|
||||||
|
dc.gfxObjId, n, t.X, t.Y, t.Z);
|
||||||
|
}
|
||||||
|
if (totalInst == 0) sb.Append(" inst=0");
|
||||||
|
|
||||||
|
// The 8-light set this cell's instances carry (fresh: the per-pass
|
||||||
|
// cache was cleared at the top of RenderModernMDIInternal).
|
||||||
|
int[] set = GetCellLightSet(cell);
|
||||||
|
sb.Append(" L=[");
|
||||||
|
bool any = false;
|
||||||
|
for (int k = 0; k < set.Length; k++)
|
||||||
|
{
|
||||||
|
int idx = set[k];
|
||||||
|
if (idx < 0) continue;
|
||||||
|
if (any) sb.Append(',');
|
||||||
|
if (snap is not null && idx < snap.Count)
|
||||||
|
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||||
|
else
|
||||||
|
sb.Append('?').Append(idx);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=[");
|
||||||
|
if (snap is not null)
|
||||||
|
{
|
||||||
|
bool anyHot = false;
|
||||||
|
for (int i = 0; i < snap.Count; i++)
|
||||||
|
{
|
||||||
|
var ls = snap[i];
|
||||||
|
if (ls.Intensity < 50f) continue;
|
||||||
|
if (anyHot) sb.Append(',');
|
||||||
|
sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})",
|
||||||
|
ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z);
|
||||||
|
anyHot = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
string sig = sb.ToString();
|
||||||
|
long now = System.Environment.TickCount64;
|
||||||
|
bool changed = sig != _seamSig;
|
||||||
|
if (!changed && (now - _seamLastEmitMs) < 2000) return;
|
||||||
|
_seamSig = sig;
|
||||||
|
_seamLastEmitMs = now;
|
||||||
|
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SetCullMode
|
// SetCullMode
|
||||||
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
|
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
|
||||||
|
|
|
||||||
|
|
@ -1111,6 +1111,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
|
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
|
||||||
// so a routed-out entity still reports its state.
|
// so a routed-out entity still reports its state.
|
||||||
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
|
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
|
||||||
|
|
||||||
|
// #176 seam-draw probe: any entity parented to a target cell reports
|
||||||
|
// its position + light set (a floor-coincident static/plate would be
|
||||||
|
// the z-fight's second draw; the player entity is the positive
|
||||||
|
// control). Before the culled-continue, like the dump above.
|
||||||
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
|
||||||
|
&& entity.ParentCellId is { } seamPc
|
||||||
|
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
|
||||||
|
MaybeEmitSeamEnt(entity);
|
||||||
}
|
}
|
||||||
prevTupleEntityId = entity.Id;
|
prevTupleEntityId = entity.Id;
|
||||||
|
|
||||||
|
|
@ -2075,6 +2084,44 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
|
||||||
|
// [seam-ent] line per target-cell entity, re-emitted on state change: world
|
||||||
|
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
|
||||||
|
// and the SelectForObject light set resolved to identities (owner-cell
|
||||||
|
// low16 + intensity). Sig dict is bounded by the handful of entities that
|
||||||
|
// ever live in the target cells.
|
||||||
|
private readonly Dictionary<ulong, string> _seamEntSigs = new();
|
||||||
|
|
||||||
|
private void MaybeEmitSeamEnt(WorldEntity entity)
|
||||||
|
{
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
var snap = _pointSnapshot;
|
||||||
|
var sb = new System.Text.StringBuilder(200);
|
||||||
|
sb.AppendFormat(ci,
|
||||||
|
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
|
||||||
|
entity.ServerGuid, entity.ParentCellId ?? 0u,
|
||||||
|
entity.Position.X, entity.Position.Y, entity.Position.Z,
|
||||||
|
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
|
||||||
|
bool any = false;
|
||||||
|
for (int k = 0; k < _currentEntityLightSet.Length; k++)
|
||||||
|
{
|
||||||
|
int idx = _currentEntityLightSet[k];
|
||||||
|
if (idx < 0) continue;
|
||||||
|
if (any) sb.Append(',');
|
||||||
|
if (snap is not null && idx < snap.Count)
|
||||||
|
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||||
|
else
|
||||||
|
sb.Append('?').Append(idx);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
string sig = sb.ToString();
|
||||||
|
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
|
||||||
|
_seamEntSigs[entity.Id] = sig;
|
||||||
|
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
|
||||||
|
}
|
||||||
|
|
||||||
private void ComputeEntityLightSet(WorldEntity entity)
|
private void ComputeEntityLightSet(WorldEntity entity)
|
||||||
{
|
{
|
||||||
// #142: set the indoor flag first so it's available even when the early-return
|
// #142: set the indoor flag first so it's available even when the early-return
|
||||||
|
|
|
||||||
|
|
@ -176,27 +176,25 @@ 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. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177,
|
/// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is
|
||||||
/// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures →
|
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
|
||||||
/// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED
|
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
|
||||||
/// mechanism of the #176 purple seam flash + the #177 stair-room light
|
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
|
||||||
/// pop-in — an in-range torch of a visible cell that ranks past the cap
|
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
|
||||||
/// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the
|
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
|
||||||
/// camera moves. Retail's <c>minimize_object_lighting</c> (0x0054d480) has
|
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
|
||||||
/// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was
|
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
|
||||||
/// live-tested 2026-07-06 and REVERTED — with the full pool active, three
|
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
|
||||||
/// unported retail lighting semantics dominate the frame: (a) lights reach
|
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||||||
/// THROUGH solid floors/walls (retail registers lights per-CELL via
|
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||||||
/// <c>insert_light</c> 0x0054d1b0 — a portal's purple light below never
|
/// purples entered/left the pool as the camera turned — the seam-floor
|
||||||
/// touches the corridor above; our flat sphere-overlap selection has no
|
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
|
||||||
/// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC
|
/// (=resident) lit lights, dynamics-first then nearest-player, capped here.
|
||||||
/// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve),
|
/// 128 is wider than retail's 40+7 — a documented backstop that in Hub-scale
|
||||||
/// (c) an unexplained striped z-fight-like artifact on lit floor regions
|
/// rooms only ever evicts far-out-of-range statics; adopting retail's exact
|
||||||
/// (user screenshot, launch-176-texflush session). The proper fix is the
|
/// dual-pool caps + degrade levels is A7-arc work. The 1024 uncap remains
|
||||||
/// A7 dungeon-lighting arc: per-cell light registration + the static curve
|
/// refuted (striped-floor artifact + the unported static 1/d³ fixture curve,
|
||||||
/// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired
|
/// A7 fix #2). Register row AP-85.</summary>
|
||||||
/// end-state pin (currently Skip):
|
|
||||||
/// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary>
|
|
||||||
public const int MaxGlobalLights = 128;
|
public const int MaxGlobalLights = 128;
|
||||||
|
|
||||||
private readonly List<LightSource> _pointSnapshot = new();
|
private readonly List<LightSource> _pointSnapshot = new();
|
||||||
|
|
@ -209,55 +207,76 @@ public sealed class LightManager
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
|
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
|
||||||
|
|
||||||
/// <summary>
|
// Pool-sort state for BuildPointLightSnapshot: the comparison delegate is
|
||||||
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
|
// cached (allocated once) and reads the anchor from a field so the per-frame
|
||||||
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
|
// over-cap sort allocates nothing beyond List.Sort's own wrapper — the same
|
||||||
/// path; unlit torches contribute nothing). When more than
|
// profile as the previous static-lambda sort (MP-Alloc discipline).
|
||||||
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
|
private Vector3 _poolAnchor;
|
||||||
/// most relevant lights survive the cap. Call once per frame before
|
private Comparison<LightSource>? _poolComparison;
|
||||||
/// per-object selection.
|
|
||||||
/// </summary>
|
|
||||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
|
||||||
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
|
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
|
||||||
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
|
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
|
||||||
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
|
/// The sun and unlit lights are excluded (the sun is global ambient-path;
|
||||||
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
|
/// unlit torches contribute nothing).
|
||||||
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
|
/// <para>
|
||||||
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
|
/// Retail anchors (#176 corrected reading, 2026-07-06):
|
||||||
/// global lights) is always included. This is what (a) stops an under-room light
|
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
|
||||||
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
|
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
|
||||||
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
|
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
|
||||||
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
|
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
|
||||||
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
|
/// entries leave only via the flush machinery). It is NOT the per-frame portal
|
||||||
/// is unchanged from the legacy full-pool path.
|
/// flood: camera gaze cannot remove a cell from it. acdream's <c>_all</c>
|
||||||
|
/// (register at hydration, unregister at unload) is that resident set, so the
|
||||||
|
/// collection is simply every registered lit light. The under-room portal
|
||||||
|
/// purples reaching the corridor's pool is retail-correct (cdb: retail applies
|
||||||
|
/// them to every Hub cell) — the faceted purple wedge is faithful.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
|
||||||
|
/// first (retail's dynamic lights live in their own 7-slot pool —
|
||||||
|
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
|
||||||
|
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
|
||||||
|
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
|
||||||
|
/// set from <c>player->m_position</c>, SmartBox 0x00453d3a, with the
|
||||||
|
/// viewer-cell fallback 0x00455ab6). The pool is therefore a function of
|
||||||
|
/// PLAYER position and light registration ONLY — camera rotation/position
|
||||||
|
/// cannot change it. Both prior camera-coupled pools (nearest-camera cap;
|
||||||
|
/// frame-flood scoping <c>c500912b</c>) produced the #176 seam-floor purple
|
||||||
|
/// blink. Call once per frame before per-object selection.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
|
/// <param name="playerWorldPos">The player's world position (render position;
|
||||||
|
/// callers pass the camera position only when no player exists — retail's
|
||||||
|
/// player/viewer branch).</param>
|
||||||
|
public void BuildPointLightSnapshot(Vector3 playerWorldPos)
|
||||||
{
|
{
|
||||||
_pointSnapshot.Clear();
|
_pointSnapshot.Clear();
|
||||||
foreach (var light in _all)
|
foreach (var light in _all)
|
||||||
{
|
{
|
||||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||||
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
|
|
||||||
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
|
|
||||||
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
|
|
||||||
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
|
|
||||||
continue;
|
|
||||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
|
||||||
_pointSnapshot.Add(light);
|
_pointSnapshot.Add(light);
|
||||||
}
|
}
|
||||||
if (_pointSnapshot.Count > MaxGlobalLights)
|
if (_pointSnapshot.Count > MaxGlobalLights)
|
||||||
{
|
{
|
||||||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
_poolAnchor = playerWorldPos;
|
||||||
|
_poolComparison ??= (a, b) =>
|
||||||
|
{
|
||||||
|
// Dynamics-first mirrors retail's separate dynamic pool; ties by
|
||||||
|
// player distance mirror insert_light's player-nearest sort.
|
||||||
|
if (a.IsDynamic != b.IsDynamic) return a.IsDynamic ? -1 : 1;
|
||||||
|
float da = (a.WorldPosition - _poolAnchor).LengthSquared();
|
||||||
|
float db = (b.WorldPosition - _poolAnchor).LengthSquared();
|
||||||
|
return da.CompareTo(db);
|
||||||
|
};
|
||||||
|
_pointSnapshot.Sort(_poolComparison);
|
||||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
|
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
|
||||||
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
|
// the flag check keeps it zero-cost off.
|
||||||
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
|
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@ public sealed class LightSource
|
||||||
public float ConeAngle = 0f; // radians, Spot only
|
public float ConeAngle = 0f; // radians, Spot only
|
||||||
public uint OwnerId; // attached entity id; 0 = world-global
|
public uint OwnerId; // attached entity id; 0 = world-global
|
||||||
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
||||||
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
|
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) for the
|
||||||
// per-frame pool can be built from only the VISIBLE cells' lights
|
// cross-cell block-offset distance math. #176 correction (2026-07-06): it is
|
||||||
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
|
// NOT a pool filter — retail collects from ALL resident cells
|
||||||
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
|
// (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood);
|
||||||
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
|
// acdream keeps the tag for probes ([indoor-light]/[seam-*]) + future parity.
|
||||||
public bool IsLit = true; // SetLightHook latch
|
public bool IsLit = true; // SetLightHook latch
|
||||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||||
// false = static dat-baked bake (1/d³, range×1.3)
|
// false = static dat-baked bake (1/d³, range×1.3)
|
||||||
|
|
|
||||||
|
|
@ -272,32 +272,91 @@ public static class RenderingDiagnostics
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the
|
/// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the
|
||||||
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||||
/// lived in set MEMBERSHIP, not counts). When true, the scoped
|
/// lived in set MEMBERSHIP, not counts). When true,
|
||||||
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||||
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
|
/// <c>[indoor-light]</c> line describing the point-light pool
|
||||||
/// (see <see cref="EmitIndoorLight"/>):
|
/// (see <see cref="EmitIndoorLight"/>):
|
||||||
/// <code>
|
/// <code>
|
||||||
/// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R>
|
/// [indoor-light] pool=<M> cellLess=<K> registered=<R> capped=<R-M>
|
||||||
/// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...]
|
/// byCell=[0x<id>:<count>,...]
|
||||||
/// </code>
|
/// </code>
|
||||||
/// This validates the A7 fix's load-bearing assumption end-to-end:
|
/// #176 correction (2026-07-06, same day): the pool is now retail's
|
||||||
/// <list type="bullet">
|
/// RESIDENT-cell collection capped nearest-the-PLAYER — visible-cell scoping
|
||||||
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
|
/// (the gaze-coupled pool) was the #176 flicker mechanism and is deleted, so
|
||||||
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
|
/// the line no longer carries <c>visibleCells/droppedNonVisible</c>. The
|
||||||
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
|
/// diagnostic value that remains: <c>cellLess==pool</c> in a fixture-rich room
|
||||||
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
|
/// still means cell tagging FAILED (ParentCellId not flowing), and
|
||||||
/// room would go dark.</description></item>
|
/// <c>byCell</c> shows which cells' lights are pooled.
|
||||||
/// <item><description><c>droppedNonVisible>0</c> with <c>byCell</c> tracking the
|
|
||||||
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
|
|
||||||
/// dropped ones).</description></item>
|
|
||||||
/// </list>
|
|
||||||
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ProbeIndoorLightEnabled { get; set; } =
|
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 seam-floor flicker decisive probe (2026-07-06). RenderDoc pixel-history
|
||||||
|
/// is infeasible on this pipeline (RenderDoc does not support
|
||||||
|
/// GL_ARB_bindless_texture and hides it from the app → our mandatory-modern
|
||||||
|
/// startup gate throws), so this is the in-engine equivalent at draw
|
||||||
|
/// granularity: every draw route that can put geometry at the corridor seam
|
||||||
|
/// floor reports itself, plus the per-cell light sets ACTUALLY applied.
|
||||||
|
/// When set, four line families emit (all change-deduped, Console):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>[seam-cell]</c> — <c>EnvCellRenderer.Render</c>
|
||||||
|
/// (opaque pass): per target cell — in-filter flag, per-gfx instance count +
|
||||||
|
/// transform translation (shows the +0.02 shell lift), per-batch
|
||||||
|
/// cull/translucency, and the cell's 8-light set RESOLVED to identities
|
||||||
|
/// (owner cell + intensity — raw snapshot indices shuffle when the pool
|
||||||
|
/// rebuilds, so identities are the stable signature). Two instances of one
|
||||||
|
/// (cell,gfx) = the runtime double-draw; light identities flipping with the
|
||||||
|
/// flood = the snapshot-scope mechanism.</description></item>
|
||||||
|
/// <item><description><c>[seam-snap]</c> — the point-light snapshot's HOT
|
||||||
|
/// subset (intensity ≥ 50: the portal purples; fixtures are ~1–2, the viewer
|
||||||
|
/// fill 2.25) with owner cells, emitted with the block.</description></item>
|
||||||
|
/// <item><description><c>[seam-ent]</c> — <c>WbDrawDispatcher</c>: any entity
|
||||||
|
/// parented to a target cell — position, culled/slot, resolved light set. A
|
||||||
|
/// floor-coincident entity (plate/static) would be the z-fight's second draw;
|
||||||
|
/// the player entity doubles as the probe's positive control.</description></item>
|
||||||
|
/// <item><description><c>[seam-mask]</c> — <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>:
|
||||||
|
/// every portal depth fan drawn in a target cell. A sealed dungeon must show
|
||||||
|
/// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Value <c>1</c> = the default Facility Hub target set (corridor 0x8A020164 +
|
||||||
|
/// seam neighbors 0165/016E/017A + under-hall 011E + portal-light cells
|
||||||
|
/// 0118/0119); a comma-separated hex cell-id list overrides it. Throwaway
|
||||||
|
/// apparatus — strip when #176 closes. Initial state from
|
||||||
|
/// <c>ACDREAM_PROBE_SEAMDRAW</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeSeamDrawEnabled { get; set; } =
|
||||||
|
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||||
|
|
||||||
|
/// <summary>Target cell ids for the #176 seam-draw probe (see
|
||||||
|
/// <see cref="ProbeSeamDrawEnabled"/>). Full 32-bit cell ids.</summary>
|
||||||
|
public static IReadOnlySet<uint> SeamDrawTargetCells { get; } =
|
||||||
|
ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||||
|
|
||||||
|
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
|
||||||
|
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving
|
||||||
|
/// grammar as <see cref="ParseDumpEntityIds"/>). Internal for unit tests.</summary>
|
||||||
|
internal static IReadOnlySet<uint> ParseSeamDrawTargets(string? raw)
|
||||||
|
{
|
||||||
|
// Default: the corridor + the coplanar-sweep seed neighbors + the under-hall
|
||||||
|
// + the two portal-weenie cells whose intensity-100 purple lights are the
|
||||||
|
// wedge's source (handoff 2026-07-06-176-seam-floor-zfight-handoff.md).
|
||||||
|
var defaults = new HashSet<uint>
|
||||||
|
{
|
||||||
|
0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au,
|
||||||
|
0x8A02011Eu, 0x8A020118u, 0x8A020119u,
|
||||||
|
};
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return defaults;
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
if (trimmed == "1" || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return defaults;
|
||||||
|
var parsed = ParseDumpEntityIds(raw);
|
||||||
|
return parsed.Count > 0 ? parsed : defaults;
|
||||||
|
}
|
||||||
|
|
||||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||||
|
|
@ -484,15 +543,15 @@ public static class RenderingDiagnostics
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||||
/// visible-cell-scoped point-light pool: the SET COMPOSITION the <c>[light]</c>
|
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
|
||||||
/// counts can't show. Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is
|
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
|
||||||
/// false; otherwise fires at most once per second. Called from the scoped
|
/// fires at most once per second. Called from
|
||||||
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
|
/// <c>LightManager.BuildPointLightSnapshot</c> (the resident collection —
|
||||||
|
/// #176 correction removed the visible-cell scoping).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
|
|
||||||
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||||
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
|
/// <param name="scopedSnapshot">The point-light pool just built.</param>
|
||||||
public static void EmitIndoorLight(int visibleCellCount,
|
public static void EmitIndoorLight(
|
||||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
||||||
{
|
{
|
||||||
|
|
@ -518,13 +577,12 @@ public static class RenderingDiagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb = new StringBuilder(220);
|
var sb = new StringBuilder(220);
|
||||||
sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount);
|
sb.Append("[indoor-light] pool=").Append(pool);
|
||||||
sb.Append(" pool=").Append(pool);
|
|
||||||
sb.Append(" cellLess=").Append(cellLess);
|
sb.Append(" cellLess=").Append(cellLess);
|
||||||
sb.Append(" registered=").Append(registeredLitPoints);
|
sb.Append(" registered=").Append(registeredLitPoints);
|
||||||
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
|
// Lights dropped by the MaxGlobalLights nearest-player cap (0 in Hub-scale
|
||||||
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
|
// rooms for dynamics — statics beyond the 128th-nearest are out of range).
|
||||||
sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool);
|
sb.Append(" capped=").Append(registeredLitPoints - pool);
|
||||||
sb.Append(" byCell=[");
|
sb.Append(" byCell=[");
|
||||||
const int MaxCells = 12;
|
const int MaxCells = 12;
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
|
|
|
||||||
|
|
@ -188,53 +188,72 @@ public sealed class LightManagerTests
|
||||||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
|
// ── Resident collection (#176 corrected reading, 2026-07-06) ───────────────
|
||||||
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
|
// Retail collects the pool from ALL RESIDENT EnvCells each frame:
|
||||||
// visible cells (plus cell-less globals), not a flat world-space set.
|
// CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static
|
||||||
|
// CEnvCell::visible_cell_table — the loaded-cell registry add_visible_cell
|
||||||
|
// (0x0052de40) fills from each activated cell + its dat visible-cell list. It
|
||||||
|
// is NOT the per-frame portal flood; camera gaze cannot change the pool. The
|
||||||
|
// earlier flood-scoped port (c500912b) made the under-room portal purples
|
||||||
|
// enter/leave the pool as the camera turned — the #176 seam-floor blink.
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
|
public void PointSnapshot_ResidentCollection_CellTagDoesNotFilter()
|
||||||
{
|
{
|
||||||
var mgr = new LightManager();
|
var mgr = new LightManager();
|
||||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // "visible" room
|
||||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room
|
||||||
|
mgr.Register(MakePoint(new Vector3(3, 0, 0), 5f, cellId: 0u)); // cell-less (viewer fill)
|
||||||
|
|
||||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
|
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
|
||||||
|
|
||||||
// Only the visible cell's light survives — the under-room light can't wash
|
// ALL resident lights are candidates. The under-room portal light reaching
|
||||||
// through the floor (retail: its cell isn't in visible_cell_table).
|
// the corridor's pool is retail-correct — the live cdb capture
|
||||||
Assert.Single(mgr.PointSnapshot);
|
// (tools/cdb/issue176-floor-light.cdb) showed retail applying the
|
||||||
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
|
// intensity-100 purples to EVERY Hub cell; the faceted purple wedge is
|
||||||
|
// faithful, only its gaze-coupled blinking was ours.
|
||||||
|
Assert.Equal(3, mgr.PointSnapshot.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
|
public void PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics()
|
||||||
{
|
{
|
||||||
var mgr = new LightManager();
|
var mgr = new LightManager();
|
||||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
|
// More statics than the cap, ALL nearer the player than every dynamic.
|
||||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
|
for (int i = 0; i < LightManager.MaxGlobalLights + 20; i++)
|
||||||
|
mgr.Register(MakePoint(new Vector3(i * 0.01f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
||||||
|
// 7 dynamics farther out (retail's dynamics live in their own 7-slot pool —
|
||||||
|
// Render::add_dynamic_light 0x0054d420 — statics can never crowd them out).
|
||||||
|
var dyns = new LightSource[7];
|
||||||
|
for (int i = 0; i < dyns.Length; i++)
|
||||||
|
{
|
||||||
|
dyns[i] = MakeDynamic(new Vector3(50f + i, 0, 0), range: 9f);
|
||||||
|
mgr.Register(dyns[i]);
|
||||||
|
}
|
||||||
|
|
||||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
|
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
|
||||||
|
|
||||||
// The cell-less light (viewer fill) is always a candidate; the non-visible
|
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count);
|
||||||
// cell's light is dropped.
|
foreach (var d in dyns)
|
||||||
Assert.Single(mgr.PointSnapshot);
|
Assert.Contains(d, mgr.PointSnapshot);
|
||||||
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
|
public void PointSnapshot_OverCap_KeepsNearestThePlayer()
|
||||||
{
|
{
|
||||||
var mgr = new LightManager();
|
var mgr = new LightManager();
|
||||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
|
// A big cluster far from the player (where a chase camera might sit) and
|
||||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
|
// one torch beside the player. Retail sorts by distance to
|
||||||
|
// Render::player_pos (insert_light 0x0054d1b0) — the near-player torch
|
||||||
|
// must survive the cap no matter how many far lights exist.
|
||||||
|
for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++)
|
||||||
|
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
||||||
|
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00Du);
|
||||||
|
mgr.Register(torch);
|
||||||
|
|
||||||
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
|
|
||||||
|
|
||||||
Assert.Equal(2, mgr.PointSnapshot.Count);
|
Assert.Contains(torch, mgr.PointSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -371,59 +390,55 @@ public sealed class LightManagerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
|
/// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is
|
||||||
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
|
/// retail's RESIDENT collection anchored at the PLAYER: a light in range of an
|
||||||
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
|
/// object near the player is selected no matter where a chase camera sits,
|
||||||
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
|
/// because the camera is not an input to <c>BuildPointLightSnapshot</c> at all
|
||||||
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
|
/// (the two prior camera-coupled pools — nearest-camera cap, then frame-flood
|
||||||
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
|
/// scoping <c>c500912b</c> — were each a #176 flicker mechanism). Here the
|
||||||
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
|
/// player stands by the torch while 400 fixtures cluster 200 m away where a
|
||||||
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
|
/// camera might look: the torch must always survive the cap and light the
|
||||||
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
|
/// object. See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>
|
||||||
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
|
/// (corrected §1.3) — <c>CEnvCell::visible_cell_table</c> is the resident-cell
|
||||||
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
|
/// registry, and <c>Render::insert_light</c> (0x0054d1b0) sorts by distance to
|
||||||
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
|
/// <c>Render::player_pos</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
||||||
{
|
{
|
||||||
var mgr = new LightManager();
|
var mgr = new LightManager();
|
||||||
|
|
||||||
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
|
// 400 fixtures clustered far away (in the direction a camera might sit),
|
||||||
// visible from the target room). These would have filled every low
|
// all in another cell. Under either old camera-coupled pool these could
|
||||||
// camera-distance rank under the old camera-nearest cap.
|
// displace or gate the player-side torch; under the player anchor they are
|
||||||
const uint underRoom = 0xAAAA0102u;
|
// simply the farthest candidates.
|
||||||
|
const uint farRoom = 0xAAAA0102u;
|
||||||
for (int i = 0; i < 400; i++)
|
for (int i = 0; i < 400; i++)
|
||||||
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
|
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: farRoom));
|
||||||
|
|
||||||
// The target torch: far from the origin-side camera, in the VISIBLE room
|
// The target torch: beside the player, in the player's room.
|
||||||
// cell, squarely in range of the target object around (200, 0, 0).
|
const uint playerRoom = 0xAAAA0101u;
|
||||||
const uint targetRoom = 0xAAAA0101u;
|
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: playerRoom);
|
||||||
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
|
|
||||||
mgr.Register(torch);
|
mgr.Register(torch);
|
||||||
|
|
||||||
// The portal flood says only the target room is visible.
|
|
||||||
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
|
|
||||||
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
||||||
|
|
||||||
// Camera parked at the origin end — the torch must still light the visible cell.
|
// The player (the ONLY positional input) stands at the origin. Rebuild
|
||||||
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
|
// twice to mirror consecutive frames of a rotating camera — the pool and
|
||||||
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
// the selection must be identical (no camera input exists to vary).
|
||||||
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||||
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
|
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
||||||
int underRoomInPool = 0;
|
bool torchSelected1 = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||||
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
|
|
||||||
|
|
||||||
// Camera next to the cell — the reference behaviour.
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||||
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
|
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
||||||
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
bool torchSelected2 = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
||||||
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(torchSelected1,
|
||||||
Assert.True(torchSelectedFar,
|
"an in-range light beside the player was evicted from the pool — " +
|
||||||
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
|
"per-cell lighting would pop (the #176/#177 mechanism)");
|
||||||
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
Assert.True(torchSelected2, "consecutive same-player builds must select identically");
|
||||||
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
|
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster
|
||||||
|
|
||||||
static bool SelectedContains(
|
static bool SelectedContains(
|
||||||
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue