diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b7a710c0..90c82257 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 34 rows
+## 3. Documented approximation (AP) — 35 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -111,7 +111,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
-| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
+| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
@@ -130,6 +130,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
+| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
---
diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag
index 7765a46a..45fe4e7f 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh.frag
@@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
-// Retail hard-cutoff lighting equation (r13 §10.2). No distance
-// attenuation inside Range; hard edge at Range; spotlights use a
-// binary cos-cone test. This is deliberate — the retail "bubble of
-// light" look relies on crisp boundaries.
+// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
+// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
+// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
+// Range / crisp boundaries" note was a misread; it is the literal cause of
+// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
+// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
@@ -73,7 +75,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
- float atten = 1.0; // retail: no attenuation inside Range
+ // calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
+ // carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
+ float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
// Spotlight: hard-edged cos-cone test.
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
index bbcc9584..040e15b2 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
@@ -49,7 +49,15 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
- float atten = 1.0;
+ // Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
+ // line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
+ // LINEAR fade to exactly 0 at the edge. That is what makes a torch a
+ // smooth glow that blends into the ambient instead of a flat disc with
+ // a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
+ // falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
+ // into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
+ // denominator is just Range and fades to 0 exactly at the cutoff.
+ float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs
index db9bf9bc..671da599 100644
--- a/src/AcDream.Core/Lighting/LightInfoLoader.cs
+++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs
@@ -79,12 +79,15 @@ public static class LightInfoLoader
(info.Color?.Green ?? 255) / 255f,
(info.Color?.Blue ?? 255) / 255f),
Intensity = info.Intensity,
- // Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the
- // hardware light Range = Falloff * rangeAdjust, where rangeAdjust is
- // the fixed global 1.5 (0x00820cc4). Our prior Range = Falloff reached
- // only 2/3 of retail's distance → tight torch bubbles (the dungeon
- // "candles/spotlights" report, #133 A7). Match retail's reach.
- Range = info.Falloff * 1.5f,
+ // falloff_eff for the per-vertex point-light burn-in (calc_point_light
+ // 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor
+ // is the fixed global 1.3 (0x00820e24). That is the path that lights
+ // STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is
+ // about — so we match it, not the D3D-dynamic config_hardware_light
+ // rangeAdjust (1.5, a different path for moving objects). The shader ramp
+ // (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard
+ // disc edge that read as a spotlight.
+ Range = info.Falloff * 1.3f,
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
IsLit = true,
diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs
index 0f4a73c9..24769c6e 100644
--- a/src/AcDream.Core/Lighting/LightManager.cs
+++ b/src/AcDream.Core/Lighting/LightManager.cs
@@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting;
/// §12.2).
///
///
-/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
+/// Active-light selection algorithm (r13 §12.2), as implemented by
+/// :
///
/// -
-/// Recompute DistSq from viewer to every registered
-/// point/spot light.
+/// Reserve slot 0 for the sun (directional, infinite range) when present.
///
/// -
-/// Drop lights outside Range² * 1.1 (10% slack prevents
-/// pop as we walk across the boundary).
-///
-/// -
-/// Rank remaining lights by DistSq ascending. Pick top 7.
-///
-/// -
-/// Reserve slot 0 for the sun (directional, infinite range).
+/// For every registered lit point/spot light, recompute DistSq
+/// from the viewer and keep the nearest (MaxActiveLights − sunSlot)
+/// directly in the active window via an allocation-free insertion
+/// partial-select (no per-frame list/sort).
///
///
+/// There is deliberately NO viewer-range candidacy filter: each light's
+/// own range cutoff is applied PER SURFACE in the shader
+/// (mesh_modern.frag: d < range), so a torch the viewer
+/// stands outside the range of must still light the wall it sits on. The
+/// earlier Range² × 1.1 slack filter wrongly dropped exactly those
+/// lights (the #133 "lighting off" report).
///
///
///
diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
index 0651b274..676155bf 100644
--- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
+++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
@@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests
var light = result[0];
Assert.Equal(LightKind.Point, light.Kind);
Assert.Equal(77u, light.OwnerId);
- Assert.Equal(12f, light.Range); // Falloff 8 × retail rangeAdjust 1.5 (config_hardware_light)
+ Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);