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}");
+ }
+}