feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)
Port retail's per-frame light collection: the point-light pool is built from ONLY the currently-visible cells' lights, matching CObjCell::add_*_to_global_lights (0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a flat world-space set capped at 128-nearest-camera. - LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics). - LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8). - Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the flood resolves prepareCells and before the draws (renderers select from the same in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass). - [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION. Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame, CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool composition is NOT the cause. #176 real cause = an over-bright purple point light (intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss (stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected; ISSUES #176/#177 re-diagnosed; render digest banner updated. Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light 0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. Suites green: Core 2595 + 2 skip, App 719 + 2 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e1746ca10d
commit
c500912bf8
10 changed files with 516 additions and 35 deletions
|
|
@ -94,6 +94,13 @@ investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
|
|||
|
||||
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
|
||||
|
||||
**⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting.** The A7 visible-cell
|
||||
light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs
|
||||
STILL not visible looking back from the corridor (zoom-out changes the last-step case).
|
||||
Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells
|
||||
(0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose
|
||||
as visibility. See the render digest banner.
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon)
|
||||
**Filed:** 2026-07-06
|
||||
|
|
@ -142,6 +149,14 @@ the connecting opening, and stays rendered through the full descent.
|
|||
|
||||
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
|
||||
|
||||
**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The
|
||||
A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame),
|
||||
yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single
|
||||
over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)`
|
||||
(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide
|
||||
whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not
|
||||
set-composition, not through-floor. See the render digest banner.
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
|
||||
**Filed:** 2026-07-06
|
||||
|
|
|
|||
|
|
@ -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-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||
| AP-85 | **Per-frame flat point-light snapshot 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 | **⚠️ 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-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` |
|
||||
|
||||
|
|
|
|||
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
|
||||
|
||||
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
|
||||
**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
|
||||
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
|
||||
match it line-for-line.
|
||||
|
||||
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
|
||||
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
|
||||
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
|
||||
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
|
||||
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
|
||||
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
|
||||
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
|
||||
> the *collection phase*: retail rebuilds that global pool **each frame from only
|
||||
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
|
||||
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
|
||||
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
|
||||
> This is a *better* fit for acdream than the handoff's framing, because acdream
|
||||
> already computes the visible-cell set every frame (the portal flood).
|
||||
|
||||
---
|
||||
|
||||
## 1. The retail model, as source-confirmed
|
||||
|
||||
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
|
||||
|
||||
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
|
||||
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
|
||||
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
|
||||
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
|
||||
path feeds it from the landblock's static object lights (caller at line ~285976,
|
||||
`CObjCell::add_light(cell, lights->lightobj + i)`).
|
||||
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
|
||||
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
|
||||
|
||||
### 1.2 A cell pushes its own lights to the global pool
|
||||
|
||||
```
|
||||
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
|
||||
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
|
||||
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||
|
||||
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
|
||||
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
|
||||
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||
```
|
||||
|
||||
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
|
||||
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
|
||||
block-offset distance math).
|
||||
|
||||
### 1.3 Per frame, ONLY visible cells contribute (the crux)
|
||||
|
||||
```
|
||||
CEnvCell::add_dynamic_lights(): # 0x0052d410
|
||||
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
|
||||
CObjCell::add_dynamic_to_global_lights(cell)
|
||||
|
||||
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
|
||||
for cell in CEnvCell::visible_cell_table: # SAME visible set
|
||||
cell.init_static_objects()
|
||||
CObjCell::init_objects(cell)
|
||||
CObjCell::add_static_to_global_lights(cell)
|
||||
```
|
||||
|
||||
`visible_cell_table` is the set of cells reached by the portal flood from the
|
||||
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
|
||||
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
|
||||
lights to the global pool.** This is the entire reason retail doesn't churn.
|
||||
|
||||
### 1.4 The global pool is small and player-sorted (`insert_light`)
|
||||
|
||||
```
|
||||
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
|
||||
distsq = 0
|
||||
if info.type == 0: # point light
|
||||
# squared distance from THIS light to the PLAYER, across the cell block offset
|
||||
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
|
||||
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
|
||||
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
|
||||
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
|
||||
# capped at maxCount; when full, evict the farthest-from-player.
|
||||
|
||||
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
|
||||
insert_light(max_static_lights, &world_lights.num_static_lights,
|
||||
world_lights.static_lights, world_lights.sorted_static_lights,
|
||||
info, cellId, frame, max_dynamic_lights + 1)
|
||||
|
||||
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
|
||||
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
|
||||
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
|
||||
info, cellId, frame, 1)
|
||||
```
|
||||
|
||||
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
|
||||
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
|
||||
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
|
||||
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
|
||||
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
|
||||
|
||||
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
|
||||
|
||||
```
|
||||
Render::minimize_object_lighting(): # 0x0054d480
|
||||
reset_active_lights_state()
|
||||
used = 0
|
||||
# DYNAMIC lights first (priority), pre-sorted nearest-player:
|
||||
for i in 0 .. num_dynamic_lights:
|
||||
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
|
||||
add_active_light(i, 2); used += 1
|
||||
else: dynamic_light_used[i] = 0
|
||||
# STATIC lights fill remaining slots:
|
||||
for i in 0 .. num_static_lights:
|
||||
if used >= 8: static_light_used[i] = 0; continue
|
||||
L = sorted_static_lights[i]
|
||||
if L.info.type != 0: # non-point (directional): always use
|
||||
add_active_light(i, 1); used += 1
|
||||
else: # point: sphere-overlap test
|
||||
reach = L.range + local_object_radius
|
||||
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
|
||||
add_active_light(i, 1); used += 1
|
||||
else: static_light_used[i] = 0
|
||||
enable_active_lights()
|
||||
```
|
||||
|
||||
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
|
||||
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
|
||||
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
|
||||
Minor — parity item, not the #176/#177 cause.
|
||||
|
||||
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
|
||||
|
||||
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
|
||||
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
|
||||
|
||||
```
|
||||
calc_point_light(vertex, &r, &g, &b, info):
|
||||
d = |info.offset.origin - vertex.pos|
|
||||
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
|
||||
if d < range:
|
||||
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
|
||||
if faces_light:
|
||||
atten = <1/d-ish curve, x87 — SEE WARNING>
|
||||
f = atten * (1 - d/range) * info.intensity
|
||||
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
|
||||
g += clamp(f * info.color.g, ..)
|
||||
b += clamp(f * info.color.b, ..)
|
||||
```
|
||||
|
||||
> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines
|
||||
> 425331–425341 are dense x87 FPU register juggling (`distsq/dist` vs
|
||||
> `1.5/(distsq·dist)` branch on `distsq ≷ 1`), exactly the "x87 dropout / misread"
|
||||
> class the project has been burned by twice (see `feedback_bn_decomp_field_names`,
|
||||
> `feedback_retail_binary_dispatch`). When implementing fix #2, cross-reference a
|
||||
> SECOND source (ACE / ACViewer static-light port, or the Ghidra decomp) and pin
|
||||
> the curve with a conformance test before trusting it. The STRUCTURE above
|
||||
> (range = falloff × static_light_factor, per-vertex N·L, intensity scale, colour
|
||||
> clamp) is solid; the attenuation exponent is the part to verify.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why #176/#177 happen in acdream (refined root cause)
|
||||
|
||||
acdream `LightManager` registers **every** fixture permanently into `_all` (server
|
||||
weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at
|
||||
`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the
|
||||
Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject`
|
||||
can only choose from the surviving 128, so an in-range torch of a *visible* cell that
|
||||
ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops
|
||||
as the camera moves (#176 seam flash / #177 stair-room pop-in).
|
||||
|
||||
**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY
|
||||
the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells —
|
||||
under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop
|
||||
that essentially never fires because the input is already visibility-scoped.
|
||||
|
||||
This also explains the **through-floor purple wash** the cap-raise exposed: acdream's
|
||||
flat world-space sphere-overlap of all 366 lights let an under-room portal light reach
|
||||
up through a solid floor. Retail's under-room cell isn't in the corridor's
|
||||
`visible_cell_table` (the flood doesn't pass through the solid floor), so its light
|
||||
never enters the pool. Per-cell reach = *the light is only a candidate when its cell
|
||||
is visibly flooded.*
|
||||
|
||||
---
|
||||
|
||||
## 3. The fix (materially different from "just uncap MaxGlobalLights")
|
||||
|
||||
**Port the visibility-scoped per-frame collection**, not a bigger cap:
|
||||
|
||||
1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`;
|
||||
populate at every registration site from the cell/landblock in scope). Retail's
|
||||
`add_*_light(info, cellId, frame)` carries exactly this.
|
||||
2. **Build the per-frame point-light pool from ONLY the currently-visible cells** —
|
||||
the portal-flood set the renderer already computes — instead of the whole `_all`
|
||||
set. This is retail's `add_*_lights over visible_cell_table`. The pool is then
|
||||
naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's
|
||||
40+7 as a documented backstop). The Skip'd end-state pin
|
||||
(`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`)
|
||||
asserts exactly this: an in-range light of a visible cell is never camera-evicted.
|
||||
3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether
|
||||
the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
|
||||
is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d)
|
||||
for genuinely moving lights (portal swirls, projectiles). See §1.6 warning.
|
||||
4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped)
|
||||
pool on. Invisible at cap 128; see the handoff for the two leading guesses.
|
||||
5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin.
|
||||
|
||||
### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping)
|
||||
|
||||
The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object
|
||||
(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was
|
||||
that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA.
|
||||
The fix scopes that pool to visible cells. Concretely:
|
||||
|
||||
1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell
|
||||
(insert_light arg6 → RenderLight +0x6c).
|
||||
2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light.
|
||||
3. **Both registration sites tag the owning cell** from `entity.ParentCellId`:
|
||||
- Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`).
|
||||
- Site B dat EnvCell statics — `GameWindow.cs:~7696` (same).
|
||||
- Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame
|
||||
`add_dynamic_light(&viewer_light, objcell_id)` is unconditional).
|
||||
4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)`** —
|
||||
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
|
||||
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
|
||||
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
|
||||
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
|
||||
portal flood + all cell/entity draws happen LATER, INSIDE
|
||||
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
|
||||
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
|
||||
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
|
||||
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
|
||||
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
|
||||
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
|
||||
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
|
||||
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
|
||||
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
|
||||
fly) never invokes the callback and keeps the legacy null-scope full pool.
|
||||
6. **Validation apparatus** — `ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
|
||||
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
|
||||
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
|
||||
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
|
||||
give (#176/#177 lived in set membership).
|
||||
|
||||
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
|
||||
|
||||
---
|
||||
|
||||
## 4. Source anchors (for the register + future sessions)
|
||||
|
||||
| Retail fn | Addr | Role |
|
||||
|---|---|---|
|
||||
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
|
||||
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
|
||||
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
|
||||
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
|
||||
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
|
||||
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
|
||||
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
|
||||
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
|
||||
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
|
||||
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
|
||||
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
|
||||
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue