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>
15 KiB
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 viainsert_light0x0054d1b0" and that "retail'sminimize_object_lightinghas NO global camera-nearest pool cap." Both are imprecise. Reading the source:insert_lightmaintains a GLOBAL player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally analogous to acdream'sBuildPointLightSnapshot. The real per-cell mechanism is the collection phase: retail rebuilds that global pool each frame from only the currently-VISIBLE cells (CEnvCell::add_*_lightswalks the portal-floodvisible_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 ownlight_list(aDArray<LIGHTOBJ const*>),num_lightscounter. Populated at cell load:CEnvCell::UnPack(0x0052d470) unpacksnum_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
attencurve from this BN pseudo-C. Lines 425331–425341 are dense x87 FPU register juggling (distsq/distvs1.5/(distsq·dist)branch ondistsq ≷ 1), exactly the "x87 dropout / misread" class the project has been burned by twice (seefeedback_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_lists (§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:
- Tag each
LightSourcewith its owning cell id (addCellIdtoLightSource; populate at every registration site from the cell/landblock in scope). Retail'sadd_*_light(info, cellId, frame)carries exactly this. - 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
_allset. This is retail'sadd_*_lights over visible_cell_table. The pool is then naturally bounded;MaxGlobalLightsstops 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. - Fix #2 — static curve for stationary fixtures. Decide
isDynamicby whether the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern is stationary → static 1/d³ (range × 1.3), reservingisDynamic(range × 1.5, 1/d) for genuinely moving lights (portal swirls, projectiles). See §1.6 warning. - 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.
- 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:
LightSource.CellId(newuint, 0 = cell-less/global). Retail's per-light cell (insert_light arg6 → RenderLight +0x6c).LightInfoLoader.Load(..., uint cellId = 0)propagates it onto each light.- 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-frameadd_dynamic_light(&viewer_light, objcell_id)is unconditional).
- Site A live weenie fixtures —
LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)— a light joins the pool iffCellId == 0ORvisibleCells == null(outdoor) ORvisibleCells.Contains(CellId). The 128 cap stays as a now-non-biting backstop.- 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, INSIDERetailPViewRenderer.DrawInside. So the scoped rebuild is threaded via a new context callback:RetailPViewDrawContext.RebuildScopedLights, invoked inDrawInsideright afterprepareCells(every cell drawn this frame) is finalized and BEFOREPrepareRenderBatches/ the draws (RetailPViewRenderer.cs:~131). GameWindow wires it tovisible => Lighting.BuildPointLightSnapshot(camPos, visible)(GameWindow.cs:~9371). The renderers hold a reference to the same_pointSnapshotlist (rebuilt in place), andEnvCellRenderer._cellLightSetCacheis.Clear()'d every pass, so no stale indices.SceneLightingUbo.Buildreadslights.Active(Tick), not the snapshot, so it is unaffected by the relocation. The outdoorelsepath (clipRoot == null: pre-login / fly) never invokes the callback and keeps the legacy null-scope full pool. - 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 |