using System.Numerics; using AcDream.Core.Lighting; using Xunit; namespace AcDream.Core.Tests.Lighting; public sealed class LightManagerTests { private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0) => new LightSource { Kind = LightKind.Point, WorldPosition = pos, Range = range, IsLit = lit, OwnerId = ownerId, CellId = cellId, }; private static LightSource MakeDynamic(Vector3 pos, float range, uint cellId = 0) => new LightSource { Kind = LightKind.Point, WorldPosition = pos, Range = range, IsLit = true, IsDynamic = true, CellId = cellId, }; [Fact] public void Register_Unregister_TracksList() { var mgr = new LightManager(); var a = MakePoint(Vector3.Zero, 5f); var b = MakePoint(new Vector3(10, 0, 0), 5f); mgr.Register(a); mgr.Register(b); Assert.Equal(2, mgr.RegisteredCount); mgr.Unregister(a); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void Register_DuplicateInstance_Idempotent() { var mgr = new LightManager(); var light = MakePoint(Vector3.Zero, 5f); mgr.Register(light); mgr.Register(light); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void Tick_SelectsByDistance_Top8() { var mgr = new LightManager(); // 12 lights at varying distances, all with range 100 so none filter out. for (int i = 0; i < 12; i++) mgr.Register(MakePoint(new Vector3(i, 0, 0), 100f)); mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(8, mgr.ActiveCount); // Top 8 should be the closest (i=0..7). foreach (var l in mgr.Active) { Assert.NotNull(l); Assert.True(l!.WorldPosition.X <= 7f); } } [Fact] public void Tick_SelectsByDistance_RegardlessOfViewerRange() { // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch // lights its OWN surfaces — the shader applies the hard `d < range` cutoff // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing // outside the range of is still selected; it lights the wall it sits on. // Replaces the old viewer-range candidacy filter that suppressed it, which // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); // A nearby point light is selected (distance-only candidacy; the shader // applies the per-fragment range cutoff). mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); } [Fact] public void Tick_SunSlot0_PreservedAcrossTicks() { var mgr = new LightManager(); var sun = new LightSource { Kind = LightKind.Directional, WorldForward = -Vector3.UnitZ }; mgr.Sun = sun; mgr.Register(MakePoint(Vector3.Zero, 100f)); mgr.Tick(Vector3.Zero); Assert.Equal(2, mgr.ActiveCount); Assert.Same(sun, mgr.Active[0]); } [Fact] public void Tick_UnlitLight_Excluded() { var mgr = new LightManager(); var light = MakePoint(Vector3.Zero, 100f, lit: false); mgr.Register(light); mgr.Tick(Vector3.Zero); Assert.Equal(0, mgr.ActiveCount); // Toggle lit: should now appear. light.IsLit = true; mgr.Tick(Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); } [Fact] public void UnregisterByOwner_RemovesAttachedLights() { var mgr = new LightManager(); mgr.Register(MakePoint(Vector3.Zero, 5f, ownerId: 42)); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, ownerId: 42)); mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, ownerId: 99)); mgr.UnregisterByOwner(42); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void DistSq_UpdatedEachTick() { var mgr = new LightManager(); var light = MakePoint(new Vector3(3, 0, 4), 10f); // dist 5 mgr.Register(light); mgr.Tick(Vector3.Zero); Assert.Equal(25f, light.DistSq, 2); mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── [Fact] public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() { var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Single(mgr.PointSnapshot); Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); } [Fact] public void BuildPointLightSnapshot_IndexStable_InBudget() { var mgr = new LightManager(); // Registration order preserved when under MaxGlobalLights (no sort). mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(2, mgr.PointSnapshot.Count); Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); } // ── Resident collection (#176 corrected reading, 2026-07-06) ─────────────── // Retail collects the pool from ALL RESIDENT EnvCells each frame: // CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static // CEnvCell::visible_cell_table — the loaded-cell registry add_visible_cell // (0x0052de40) fills from each activated cell + its dat visible-cell list. It // is NOT the per-frame portal flood; camera gaze cannot change the pool. The // earlier flood-scoped port (c500912b) made the under-room portal purples // enter/leave the pool as the camera turned — the #176 seam-floor blink. [Fact] public void PointSnapshot_ResidentCollection_CellTagDoesNotFilter() { var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // "visible" room mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room mgr.Register(MakePoint(new Vector3(3, 0, 0), 5f, cellId: 0u)); // cell-less (viewer fill) mgr.BuildPointLightSnapshot(Vector3.Zero); // ALL resident lights are candidates. The under-room portal light reaching // the corridor's pool is retail-correct — the live cdb capture // (tools/cdb/issue176-floor-light.cdb) showed retail applying the // intensity-100 purples to EVERY Hub cell; the faceted purple wedge is // faithful, only its gaze-coupled blinking was ours. Assert.Equal(3, mgr.PointSnapshot.Count); } [Fact] public void PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics() { var mgr = new LightManager(); // More statics than the cap, ALL nearer the player than every dynamic. for (int i = 0; i < LightManager.MaxGlobalLights + 20; i++) mgr.Register(MakePoint(new Vector3(i * 0.01f, 0, 0), 5f, ownerId: (uint)(i + 1))); // 7 dynamics farther out (retail's dynamics live in their own 7-slot pool — // Render::add_dynamic_light 0x0054d420 — statics can never crowd them out). var dyns = new LightSource[7]; for (int i = 0; i < dyns.Length; i++) { dyns[i] = MakeDynamic(new Vector3(50f + i, 0, 0), range: 9f); mgr.Register(dyns[i]); } mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); foreach (var d in dyns) Assert.Contains(d, mgr.PointSnapshot); } [Fact] public void PointSnapshot_OverCap_KeepsNearestThePlayer() { var mgr = new LightManager(); // A big cluster far from the player (where a chase camera might sit) and // one torch beside the player. Retail sorts by distance to // Render::player_pos (insert_light 0x0054d1b0) — the near-player torch // must survive the cap no matter how many far lights exist. for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++) mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), 5f, ownerId: (uint)(i + 1))); var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00Du); mgr.Register(torch); mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero); Assert.Contains(torch, mgr.PointSnapshot); } [Fact] public void SelectForObject_EmptySnapshot_ReturnsZero() { Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); Assert.Equal(0, n); } [Fact] public void SelectForObject_InRange_Selected() { var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(1, n); Assert.Equal(0, idx[0]); } [Fact] public void SelectForObject_OutOfRange_Excluded() { // dist 10, range 5, radius 0 → 10 >= 5 → excluded. var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(0, n); } [Fact] public void SelectForObject_ObjectRadiusExtendsReach() { // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere // overlaps (7 < 5+3). The whole object catches the light — retail uses the // object's bounding sphere, not its centre point. var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; Span idx = stackalloc int[8]; Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); } [Fact] public void SelectForObject_MoreThan8_KeepsNearest8() { // 10 candidate lights all in range; expect the 8 nearest the object centre, // ascending by distance, with the two farthest dropped. var snapshot = new LightSource[10]; for (int i = 0; i < 10; i++) snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(8, n); // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest // (indices 8,9 / dist 9,10) are evicted. for (int k = 0; k < 8; k++) Assert.Equal(k, idx[k]); } [Fact] public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() { // Same snapshot, same object centre → identical selection regardless of // where any "camera" is (the method takes no camera). This is the property // that kills the "lights up as I approach" popping. var snapshot = new[] { MakePoint(new Vector3(2, 0, 0), range: 10f), MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 }; Span a = stackalloc int[8]; Span b = stackalloc int[8]; int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); Assert.Equal(1, na); Assert.Equal(na, nb); Assert.Equal(a[0], b[0]); } // ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ── [Fact] public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach() { // Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same // portal lights on every Facility Hub cell) — including ones that don't reach it, // since the shader's range cutoff zeroes those. Static lights still cull by reach. var snapshot = new[] { MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches MakeDynamic(new Vector3(100, 0, 0), range: 5f), // 1: dynamic, FAR (out of reach) MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach) }; Span sel = stackalloc int[LightManager.MaxLightsPerObject]; int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel); bool d1 = false, d2 = false, s0 = false, s3 = false; for (int i = 0; i < n; i++) { if (sel[i] == 1) d1 = true; if (sel[i] == 2) d2 = true; if (sel[i] == 0) s0 = true; if (sel[i] == 3) s3 = true; } Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics"); Assert.True(d2, "the near dynamic light is applied"); Assert.True(s0, "the near static light reaches the cell → selected"); Assert.False(s3, "the far static light doesn't reach → not selected"); } [Fact] public void SelectForCell_SameDynamicSet_ForCellsFarApart_NoFlap() { // The stability retail has and we lacked: two cells far apart get the SAME dynamic // set. A per-cell sphere-overlap cull of dynamics (the old SelectForObject path) let // that set differ/flip as the flood shifted → the floor lighting FLAPPED (#176). var snapshot = new[] { MakeDynamic(new Vector3(0, 0, 0), range: 5f), MakeDynamic(new Vector3(100, 0, 0), range: 5f), }; Span a = stackalloc int[8]; Span b = stackalloc int[8]; int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a); int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b); Assert.Equal(2, na); // both dynamics on the near cell Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap } /// /// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is /// retail's RESIDENT collection anchored at the PLAYER: a light in range of an /// object near the player is selected no matter where a chase camera sits, /// because the camera is not an input to BuildPointLightSnapshot at all /// (the two prior camera-coupled pools — nearest-camera cap, then frame-flood /// scoping c500912b — were each a #176 flicker mechanism). Here the /// player stands by the torch while 400 fixtures cluster 200 m away where a /// camera might look: the torch must always survive the cap and light the /// object. See docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md /// (corrected §1.3) — CEnvCell::visible_cell_table is the resident-cell /// registry, and Render::insert_light (0x0054d1b0) sorts by distance to /// Render::player_pos. /// [Fact] public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant() { var mgr = new LightManager(); // 400 fixtures clustered far away (in the direction a camera might sit), // all in another cell. Under either old camera-coupled pool these could // displace or gate the player-side torch; under the player anchor they are // simply the farthest candidates. const uint farRoom = 0xAAAA0102u; for (int i = 0; i < 400; i++) mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: farRoom)); // The target torch: beside the player, in the player's room. const uint playerRoom = 0xAAAA0101u; var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: playerRoom); mgr.Register(torch); Span sel = stackalloc int[LightManager.MaxLightsPerObject]; // The player (the ONLY positional input) stands at the origin. Rebuild // twice to mirror consecutive frames of a rotating camera — the pool and // the selection must be identical (no camera input exists to vary). mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero); int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel); bool torchSelected1 = SelectedContains(mgr.PointSnapshot, sel, n1, torch); mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero); int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel); bool torchSelected2 = SelectedContains(mgr.PointSnapshot, sel, n2, torch); Assert.True(torchSelected1, "an in-range light beside the player was evicted from the pool — " + "per-cell lighting would pop (the #176/#177 mechanism)"); Assert.True(torchSelected2, "consecutive same-player builds must select identically"); Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster static bool SelectedContains( System.Collections.Generic.IReadOnlyList snapshot, Span indices, int count, LightSource target) { for (int i = 0; i < count; i++) if (ReferenceEquals(snapshot[indices[i]], target)) return true; return false; } } }