fix(A7): port retail calc_point_light (1-dist/falloff) ramp — kill the "spotlight" hard edge (#133)

The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.

Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.

Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
  Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
  false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
  allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
  (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.

Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-13 21:48:46 +02:00
parent 5872bcf075
commit 007e287309
6 changed files with 44 additions and 26 deletions

View file

@ -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 |
---

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting;
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Recompute <c>DistSq</c> from viewer to every registered
/// point/spot light.
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </description></item>
/// <item><description>
/// Drop lights outside <c>Range² * 1.1</c> (10% slack prevents
/// pop as we walk across the boundary).
/// </description></item>
/// <item><description>
/// Rank remaining lights by <c>DistSq</c> ascending. Pick top 7.
/// </description></item>
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range).
/// For every registered lit point/spot light, recompute <c>DistSq</c>
/// from the viewer and keep the nearest <c>(MaxActiveLights sunSlot)</c>
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
/// </description></item>
/// </list>
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (<c>mesh_modern.frag</c>: <c>d &lt; range</c>), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
/// </para>
///
/// <para>

View file

@ -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);