acdream/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
Erik a5debaca2b fix(overhaul): integrate reviewed room-light selection repair
Exact26 code/test/architecture/register blobs from621b41fa3; campaign ledger and lead verification included. Independent retail and production/lifetime/ABI reviews PASS. Lead69Core/176App/2actualshader pixels, viewer/clear/NaN negative controls fail as intended, exact restoration69PASS. AP68retired; AP16/35/85 residuals honest. Fresh campaign Release and graphical lighting proof still owed; temporary observer cleanup contract conditional. FPS deferred; no G4 or main merge.
2026-09-05 14:57:52 +02:00

820 lines
32 KiB
C#

using System.Collections.Generic;
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,
RankingOrigin = 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,
RankingOrigin = 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 Register_NondirectionalWithoutExplicitRankingOrigin_Throws()
{
var mgr = new LightManager();
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(3f, 4f, 5f),
};
ArgumentException error = Assert.Throws<ArgumentException>(() => mgr.Register(light));
Assert.Contains("ranking origin", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, 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_UnderCap_SortsByRootDistance()
{
var mgr = new LightManager();
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(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
Assert.Equal(100f, mgr.PointSnapshot[1].WorldPosition.X, 3);
}
[Fact]
public void BuildPointLightSnapshot_RanksRootBeforeAuthoredOffsetFinalPosition()
{
var manager = new LightManager();
LightSource nearRootFarFinal = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 1);
nearRootFarFinal.RankingOrigin = new Vector3(1f, 0f, 0f);
LightSource farRootNearFinal = MakePoint(new Vector3(2f, 0f, 0f), 20f, ownerId: 2);
farRootNearFinal.RankingOrigin = new Vector3(50f, 0f, 0f);
manager.Register(farRootNearFinal);
manager.Register(nearRootFarFinal);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { nearRootFarFinal, farRootNearFinal }, manager.PointSnapshot);
Assert.Equal(1f, manager.PointSnapshot[0].DistSq);
}
[Fact]
public void BuildPointLightSnapshot_StrictForwardInsertion_PreservesEqualAndNaNOrder()
{
var manager = new LightManager();
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 1);
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
LightSource equalA = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 2);
equalA.RankingOrigin = new Vector3(2f, 0f, 0f);
LightSource equalB = MakePoint(new Vector3(20f, 0f, 0f), 20f, ownerId: 3);
equalB.RankingOrigin = new Vector3(-2f, 0f, 0f);
manager.Register(nan);
manager.Register(equalA);
manager.Register(equalB);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { nan, equalA, equalB }, manager.PointSnapshot);
}
[Fact]
public void BuildPointLightSnapshot_StrictForwardInsertion_AdvancesPastNaN()
{
var manager = new LightManager();
LightSource far = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 1);
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 2);
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
LightSource near = MakePoint(Vector3.One, 20f, ownerId: 3);
manager.Register(far);
manager.Register(nan);
manager.Register(near);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { near, far, nan }, manager.PointSnapshot);
Assert.Equal(new[] { 3f, 100f }, manager.PointSnapshot.Take(2).Select(light => light.DistSq));
Assert.True(float.IsNaN(manager.PointSnapshot[2].DistSq));
}
[Fact]
public void BuildPointLightSnapshot_SpotUsesZeroRank_PointUsesRootDistance()
{
var manager = new LightManager();
LightSource point = MakePoint(new Vector3(1f, 0f, 0f), 20f, ownerId: 1);
LightSource spot = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 2);
spot.Kind = LightKind.Spot;
manager.Register(point);
manager.Register(spot);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { spot, point }, manager.PointSnapshot);
Assert.Equal(0f, spot.DistSq);
}
[Fact]
public void BuildPointLightSnapshot_IndependentSevenAndFortyProducts_DoNotCrossEvict()
{
var manager = new LightManager();
var dynamics = new List<LightSource>();
var statics = new List<LightSource>();
for (int i = 0; i < 10; i++)
{
LightSource light = MakeDynamic(new Vector3(100f + i, 0f, 0f), 10f);
light.OwnerId = checked((uint)(100 + i));
dynamics.Add(light);
manager.Register(light);
}
for (int i = 0; i < 50; i++)
{
LightSource light = MakePoint(new Vector3(i, 0f, 0f), 10f, checked((uint)(200 + i)));
statics.Add(light);
manager.Register(light);
}
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(dynamics.Take(7), manager.PointSnapshot.Take(7));
Assert.Equal(statics.Take(40), manager.PointSnapshot.Skip(7));
}
[Fact]
public void BuildPointLightSnapshot_ViewerWinsEqualRootTieThoughRegisteredLast()
{
var manager = new LightManager();
LightSource ordinary = MakeDynamic(new Vector3(0f, 0f, 2f), 15f);
ordinary.RankingOrigin = Vector3.Zero;
manager.Register(ordinary);
manager.UpdateViewerLight(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(2, manager.PointSnapshot.Count);
Assert.NotSame(ordinary, manager.PointSnapshot[0]);
Assert.Equal(new Vector3(0f, 0f, 2f), manager.PointSnapshot[0].WorldPosition);
Assert.Equal(Vector3.Zero, manager.PointSnapshot[0].RankingOrigin);
Assert.Same(ordinary, manager.PointSnapshot[1]);
}
[Fact]
public void BuildPointLightSnapshot_ClearRemovesRetainedProducts()
{
var manager = new LightManager();
manager.Register(MakePoint(Vector3.One, 5f));
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.NotEmpty(manager.PointSnapshot);
manager.Clear();
Assert.Empty(manager.PointSnapshot);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Empty(manager.PointSnapshot);
}
// ── 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 PointSnapshot_OverCap_EqualDistancesPreserveLegacyOrderExactly()
{
var manager = new LightManager();
var registered = new List<LightSource>();
for (int index = 0;
index < LightManager.MaxGlobalLights + 9;
index++)
{
LightSource light = MakePoint(
new Vector3(3f, 4f, 0f),
range: 10f,
ownerId: checked((uint)index + 1));
registered.Add(light);
manager.Register(light);
}
LightSource[] expected = FullSortOracle(
registered,
Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(expected, manager.PointSnapshot);
manager.BuildPointLightSnapshot(Vector3.Zero);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 25; iteration++)
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(
0,
GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void PointSnapshot_BoundedSelectorMatchesCompleteSortRandomized()
{
var random = new Random(0x54D1B0);
for (int scenario = 0; scenario < 64; scenario++)
{
var manager = new LightManager();
var registered = new List<LightSource>();
int count = 180 + random.Next(420);
for (int index = 0; index < count; index++)
{
// Discrete coordinates deliberately create many distance ties.
var position = new Vector3(
random.Next(-12, 13),
random.Next(-12, 13),
random.Next(-3, 4));
LightSource light = MakePoint(
position,
range: 20f,
ownerId: checked((uint)index + 1),
lit: random.Next(13) != 0,
cellId: random.Next(5) == 0
? 0u
: checked((uint)(0xAAAA0100 + random.Next(1, 4))));
light.IsDynamic = random.Next(7) == 0;
if (random.Next(17) == 0)
light.Kind = LightKind.Directional;
registered.Add(light);
manager.Register(light);
}
Vector3 player = new(
random.Next(-4, 5),
random.Next(-4, 5),
random.Next(-2, 3));
LightSource[] expected = FullSortOracle(
registered,
player);
manager.BuildPointLightSnapshot(player);
Assert.Equal(expected, manager.PointSnapshot);
}
}
[Fact]
public void PointSnapshot_TownNetworkScale463_MatchesCompleteSort()
{
var manager = new LightManager();
var registered = new List<LightSource>(463);
const uint fountainRoom = 0x00070144u;
const uint corridor = 0x00070145u;
for (int index = 0; index < 463; index++)
{
LightSource light = MakePoint(
new Vector3(
index + 0.125f,
index * 0.001f,
index * 0.0001f),
range: 15f,
ownerId: checked((uint)index + 1),
cellId: index % 3 == 0
? fountainRoom
: corridor);
light.IsDynamic = index % 61 == 0;
registered.Add(light);
manager.Register(light);
}
Vector3 player = new(4.25f, -1.5f, 0.7f);
LightSource[] expected = FullSortOracle(
registered,
player);
manager.BuildPointLightSnapshot(player);
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(expected, manager.PointSnapshot);
}
[Fact]
public void PointSnapshot_WarmedOverflowPathAllocatesZero()
{
var manager = new LightManager();
for (int index = 0; index < 463; index++)
{
LightSource light = MakePoint(
new Vector3(
index + 0.125f,
index * 0.001f,
index * 0.0001f),
range: 15f,
ownerId: checked((uint)index + 1));
light.IsDynamic = index % 53 == 0;
manager.Register(light);
}
manager.BuildPointLightSnapshot(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 100; iteration++)
manager.BuildPointLightSnapshot(Vector3.Zero);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
[Fact]
public void BuildPointLightSnapshot_UsesAllResidentLights()
{
// Retail walks the resident EnvCell registry. A camera-root transition
// cannot make either resident cell stop contributing candidates.
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAAu));
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xBBBBu));
mgr.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(2, mgr.PointSnapshot.Count);
}
[Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero()
{
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), 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<int> 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<int> 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<int> 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<int> 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<int> a = stackalloc int[8];
Span<int> 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 — complete retained 7-dynamic + 40-static products ──
[Fact]
public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach()
{
// Retail enables the whole dynamic subset and bakes the whole retained static
// product. The GPU path supplies both unchanged; per-vertex range cutoff decides
// which entries contribute.
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<int> sel = stackalloc int[LightManager.MaxLightsPerEnvCell];
int n = LightManager.SelectForCell(snapshot, 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.True(s3, "the complete retained static product is supplied; the shader applies range");
}
[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<int> a = stackalloc int[LightManager.MaxLightsPerEnvCell];
Span<int> b = stackalloc int[LightManager.MaxLightsPerEnvCell];
int na = LightManager.SelectForCell(snapshot, a);
int nb = LightManager.SelectForCell(snapshot, 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
}
[Fact]
public void SelectForCell_CarriesAllSevenDynamicsAndFortyStatics_WhileObjectStaysEight()
{
var snapshot = new List<LightSource>();
for (int i = 0; i < LightManager.MaxDynamicPointLights; i++)
snapshot.Add(MakeDynamic(new Vector3(i, 0f, 0f), 100f));
for (int i = 0; i < LightManager.MaxStaticPointLights; i++)
snapshot.Add(MakePoint(new Vector3(i + 10f, 0f, 0f), 100f));
Span<int> cell = stackalloc int[LightManager.MaxLightsPerEnvCell];
int cellCount = LightManager.SelectForCell(snapshot, cell);
Span<int> obj = stackalloc int[LightManager.MaxLightsPerEnvCell];
int objectCount = LightManager.SelectForObject(snapshot, Vector3.Zero, 100f, obj);
Assert.Equal(47, cellCount);
for (int index = 0; index < cellCount; index++)
Assert.Equal(index, cell[index]);
Assert.Equal(8, objectCount);
}
/// <summary>
/// #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 <c>BuildPointLightSnapshot</c> at all
/// (the two prior camera-coupled pools — nearest-camera cap, then frame-flood
/// scoping <c>c500912b</c> — 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 <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>
/// (corrected §1.3) — <c>CEnvCell::visible_cell_table</c> is the resident-cell
/// registry, and <c>Render::insert_light</c> (0x0054d1b0) sorts by distance to
/// <c>Render::player_pos</c>.
/// </summary>
[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<int> 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.MaxStaticPointLights, mgr.PointSnapshot.Count);
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
Span<int> indices, int count, LightSource target)
{
for (int i = 0; i < count; i++)
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
return false;
}
}
private static LightSource[] FullSortOracle(
IReadOnlyList<LightSource> registered,
Vector3 player)
{
var ranked = new List<OracleRank>();
for (int index = 0; index < registered.Count; index++)
{
LightSource light = registered[index];
if (!light.IsLit || light.Kind == LightKind.Directional)
continue;
ranked.Add(new OracleRank(
light,
light.Kind == LightKind.Point
? Vector3.DistanceSquared(light.RankingOrigin, player)
: 0f));
}
var dynamics = ranked.Where(static item => item.Light.IsDynamic).ToList();
var statics = ranked.Where(static item => !item.Light.IsDynamic).ToList();
StableRetailInsertion(dynamics, LightManager.MaxDynamicPointLights);
StableRetailInsertion(statics, LightManager.MaxStaticPointLights);
return dynamics.Concat(statics).Select(static item => item.Light).ToArray();
static void StableRetailInsertion(List<OracleRank> values, int cap)
{
var selected = new List<OracleRank>(cap);
foreach (OracleRank value in values)
{
int index = 0;
while (index < selected.Count && !(value.DistanceSq < selected[index].DistanceSq))
index++;
if (index >= cap)
continue;
selected.Insert(index, value);
if (selected.Count > cap)
selected.RemoveAt(cap);
}
values.Clear();
values.AddRange(selected);
}
}
private readonly record struct OracleRank(
LightSource Light,
float DistanceSq);
}