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