diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b7b358ff..11d8cabb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -131,6 +131,7 @@ accepted-divergence entries (#96, #49, #50). | 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-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | +| AP-37 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | --- diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md index 94924439..a9baf4ac 100644 --- a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md +++ b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md @@ -5,6 +5,64 @@ **Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed + correct but **did NOT fix the visible symptom** — they were the wrong subsystem. +--- + +## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all + +**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT +"shorten torch reach" — it is "outdoor objects receive NO torches."** + +**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg +neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance +torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads +this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO +Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not +be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT +inflated. So "acdream 6 vs retail 4" was a red herring. + +**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow +`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is +**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`. +The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right +before `LScape::draw`), so when the building EXTERIOR shell is drawn +(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 → +CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the +**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The +static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller +`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the +wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights +OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the +non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30 +uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100` +and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never +enables it outdoors.) + +**The three retail lighting regimes (now all mapped):** +1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun. + → acdream mode 1 (EnvCell). ✓ already correct. +2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**. +3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**. + acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3. + +**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object +torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`) +via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId +null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail. +The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell +(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-37** added (documents the residual: +acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's +per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests: +`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green; +App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.** + +**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach / +change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells. +The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach +to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any +reach is the bug. + +--- + ## TL;DR — what the visible bug actually is (and is NOT) The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index fc131abb..f772dcc5 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2026,6 +2026,26 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// a static building's torches stay constant as the viewer moves. Fills /// ; unused slots are -1. On the no-lights /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + /// + /// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN + + /// ambient ONLY — never the static wall torches. The per-object torch step + /// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage: + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under + /// if (Render::useSunlight == 0), and the outdoor landscape stage runs + /// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right + /// before LScape::draw which draws buildings/scenery). So a building + /// EXTERIOR shell (, + /// = null) and all outdoor scenery / + /// creatures get the sun, not torches. We mirror that: only objects parented to + /// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so + /// the sun path alone lights them. This is what made the Holtburg meeting-hall + /// facade wash out warm — the dat's intensity-100 wall torches (range + /// Falloff×1.3) were flooding the exterior shell that retail never torch-lights. + /// The indoor "no sun" half is already handled by the global sun kill when the + /// player is inside a cell (UpdateSunFromSky). See the divergence register + /// (AP-37) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. + /// /// private void ComputeEntityLightSet(WorldEntity entity) { @@ -2033,12 +2053,29 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var snap = _pointSnapshot; if (snap is null || snap.Count == 0) return; + // Retail useSunlight gate: outdoor objects receive no per-object torches. + if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return; + if (entity.AabbDirty) entity.RefreshAabb(); Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); } + /// + /// Retail's useSunlight gate for per-object torch lighting, as a pure + /// predicate. An object receives the static wall torches (the indoor + /// minimize_object_lighting pass) ONLY when it is parented to an EnvCell + /// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100. + /// Outdoor objects (building shells with null , + /// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures) + /// are sun-lit only and return false. Mirrors + /// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff + /// Render::useSunlight == 0, which is true only in the indoor draw stage. + /// + internal static bool IndoorObjectReceivesTorches(uint? parentCellId) + => parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u; + /// /// Fix B: append the current entity's 8-slot light set to a group's /// , parallel to its Matrices (one diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs new file mode 100644 index 00000000..cb1ffd7c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs @@ -0,0 +1,42 @@ +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// A7 Fix D round 2 — pins retail's useSunlight gate for per-object torch +/// lighting (WbDrawDispatcher.IndoorObjectReceivesTorches). Retail enables +/// the static wall-torches on an object ONLY in the indoor stage +/// (DrawMeshInternal 0x0059f398: if (useSunlight == 0) minimize_object_lighting()), +/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor +/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only +/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches. +/// +public sealed class WbDrawDispatcherTorchGateTests +{ + [Fact] + public void BuildingShell_NullParent_IsOutdoor_NoTorches() + { + // Building exterior shells are top-level landblock stabs with no + // ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent). + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null)); + } + + [Theory] + [InlineData(0xA9B4_0001u)] // outdoor land sub-cell + [InlineData(0xA9B4_0020u)] // outdoor land sub-cell + [InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40) + public void OutdoorLandCell_NoTorches(uint parentCellId) + { + Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } + + [Theory] + [InlineData(0xA9B4_0100u)] // first EnvCell + [InlineData(0xA9B4_0164u)] // interior EnvCell + [InlineData(0x0007_0143u)] // dungeon EnvCell + public void IndoorEnvCell_GetsTorches(uint parentCellId) + { + Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); + } +} diff --git a/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs new file mode 100644 index 00000000..1d3a7a41 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Lighting; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; +using DatSetup = DatReaderWriter.DBObjs.Setup; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without +/// guessing or a live launch: dump the RAW dat LightInfo.Falloff for every +/// static light in the Holtburg landblocks, via the EXACT production load path +/// (). The dat is the SAME file retail reads, so +/// these falloffs ARE what retail reads (modulo any load-time transform, settled +/// separately in the decomp). Output-only — no assertions; read the log. +/// +public sealed class HoltburgTorchFalloffProbeTests +{ + private readonly ITestOutputHelper _out; + public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Dump_Holtburg_StaticLight_Falloffs() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // The meeting hall sits in the Holtburg town landblocks. Sweep a small + // neighbourhood so we catch every entrance torch the streaming window + // would load around the player at the hall. + uint[] landblocks = + { + 0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u, + }; + + // Tally every distinct raw Falloff seen (the headline number). + var falloffTally = new SortedDictionary(); + int totalLights = 0; + + foreach (uint lb in landblocks) + { + uint infoId = (lb << 16) | 0xFFFEu; + var info = dats.Get(infoId); + if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; } + + int buildings = info.Buildings?.Count ?? 0; + int objects = info.Objects?.Count ?? 0; + _out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ==="); + + // Record building-shell origins so we can rank torches by proximity. + var shells = new List<(uint model, Vector3 pos)>(); + if (info.Buildings is not null) + { + foreach (var b in info.Buildings) + { + var o = b.Frame.Origin; + shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z))); + _out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}"); + } + } + + if (info.Objects is null) continue; + foreach (var stab in info.Objects) + { + // Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary — + // identical gate to GameWindow.cs:6399. + if ((stab.Id & 0xFF000000u) != 0x02000000u) continue; + var setup = dats.Get(stab.Id); + if (setup?.Lights is null || setup.Lights.Count == 0) continue; + + var loaded = LightInfoLoader.Load( + setup, + ownerId: 0, + entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z), + entityRotation: new Quaternion( + stab.Frame.Orientation.X, stab.Frame.Orientation.Y, + stab.Frame.Orientation.Z, stab.Frame.Orientation.W)); + + foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l))) + { + float rawFalloff = kvp.Value.Falloff; + totalLights++; + falloffTally.TryGetValue(rawFalloff, out int c); + falloffTally[rawFalloff] = c + 1; + + // Nearest building shell, for "is this an entrance torch on the hall?". + float nearest = float.MaxValue; + uint nearestModel = 0; + foreach (var (model, spos) in shells) + { + float dd = Vector3.Distance(ls.WorldPosition, spos); + if (dd < nearest) { nearest = dd; nearestModel = model; } + } + + _out.WriteLine( + $" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " + + $"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " + + $"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " + + $"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " + + $"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m"); + } + } + } + + _out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ==="); + foreach (var kv in falloffTally) + _out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}"); + } +}