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.
This commit is contained in:
Erik 2026-09-05 14:57:52 +02:00
parent e8efe1131f
commit a5debaca2b
30 changed files with 928 additions and 399 deletions

View file

@ -12,6 +12,7 @@ public sealed class LightManagerTests
{
Kind = LightKind.Point,
WorldPosition = pos,
RankingOrigin = pos,
Range = range,
IsLit = lit,
OwnerId = ownerId,
@ -23,6 +24,7 @@ public sealed class LightManagerTests
{
Kind = LightKind.Point,
WorldPosition = pos,
RankingOrigin = pos,
Range = range,
IsLit = true,
IsDynamic = true,
@ -53,6 +55,21 @@ public sealed class LightManagerTests
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()
{
@ -175,18 +192,148 @@ public sealed class LightManagerTests
}
[Fact]
public void BuildPointLightSnapshot_IndexStable_InBudget()
public void BuildPointLightSnapshot_UnderCap_SortsByRootDistance()
{
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);
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) ───────────────
@ -280,8 +427,6 @@ public sealed class LightManagerTests
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedTieFallback);
Assert.False(manager.LastPointSnapshotUsedBoundedSelection);
manager.BuildPointLightSnapshot(Vector3.Zero);
long before = GC.GetAllocatedBytesForCurrentThread();
@ -369,8 +514,6 @@ public sealed class LightManagerTests
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
}
[Fact]
@ -391,8 +534,6 @@ public sealed class LightManagerTests
}
manager.BuildPointLightSnapshot(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 100; iteration++)
@ -498,14 +639,14 @@ public sealed class LightManagerTests
Assert.Equal(a[0], b[0]);
}
// ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ──
// ── SelectForCell — complete retained 7-dynamic + 40-static products ──
[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.
// 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
@ -513,8 +654,8 @@ public sealed class LightManagerTests
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.MaxLightsPerObject];
int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel);
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++)
@ -527,7 +668,7 @@ public sealed class LightManagerTests
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");
Assert.True(s3, "the complete retained static product is supplied; the shader applies range");
}
[Fact]
@ -541,15 +682,35 @@ public sealed class LightManagerTests
MakeDynamic(new Vector3(0, 0, 0), range: 5f),
MakeDynamic(new Vector3(100, 0, 0), range: 5f),
};
Span<int> a = stackalloc int[8];
Span<int> 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);
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
@ -599,7 +760,7 @@ public sealed class LightManagerTests
"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
Assert.Equal(LightManager.MaxStaticPointLights, mgr.PointSnapshot.Count);
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
@ -623,22 +784,34 @@ public sealed class LightManagerTests
continue;
ranked.Add(new OracleRank(
light,
Vector3.DistanceSquared(light.WorldPosition, player)));
light.Kind == LightKind.Point
? Vector3.DistanceSquared(light.RankingOrigin, player)
: 0f));
}
if (ranked.Count <= LightManager.MaxGlobalLights)
return ranked.Select(static item => item.Light).ToArray();
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();
ranked.Sort(static (left, right) =>
static void StableRetailInsertion(List<OracleRank> values, int cap)
{
if (left.Light.IsDynamic != right.Light.IsDynamic)
return left.Light.IsDynamic ? -1 : 1;
return left.DistanceSq.CompareTo(right.DistanceSq);
});
return ranked
.Take(LightManager.MaxGlobalLights)
.Select(static item => item.Light)
.ToArray();
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(

View file

@ -14,9 +14,9 @@ public sealed class LightingHookSinkTests
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true };
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(light1);
sink.RegisterOwnedLight(light2);
sink.RegisterOwnedLight(other);
@ -35,8 +35,8 @@ public sealed class LightingHookSinkTests
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
Assert.Equal(2, mgr.RegisteredCount);
sink.UnregisterOwner(7);
@ -48,7 +48,7 @@ public sealed class LightingHookSinkTests
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light = new LightSource { OwnerId = 1, IsLit = true };
var light = new LightSource { OwnerId = 1, IsLit = true, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(light);
// Should not crash or change state for non-SetLight hooks.
@ -71,6 +71,7 @@ public sealed class LightingHookSinkTests
var light = new LightSource
{
OwnerId = 42u,
RankingOrigin = Vector3.Zero,
LocalPose = Matrix4x4.CreateTranslation(1, 0, 2),
TracksOwnerPose = true,
};
@ -81,6 +82,7 @@ public sealed class LightingHookSinkTests
Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f);
Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f);
Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f);
Assert.Equal(new Vector3(10f, 20f, 30f), light.RankingOrigin);
Assert.Equal(0x01010002u, light.CellId);
}
@ -95,6 +97,7 @@ public sealed class LightingHookSinkTests
{
OwnerId = 42u,
WorldPosition = new Vector3(7, 8, 9),
RankingOrigin = new Vector3(7, 8, 9),
LocalPose = Matrix4x4.CreateTranslation(1, 0, 0),
TracksOwnerPose = false,
};
@ -114,7 +117,7 @@ public sealed class LightingHookSinkTests
sink.InitializeOwnerLighting(7u, enabled: true);
sink.SetOwnerLighting(7u, enabled: false);
sink.UnregisterOwner(7u, forgetState: false);
var replacement = new LightSource { OwnerId = 7u };
var replacement = new LightSource { OwnerId = 7u, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(replacement);
@ -160,6 +163,7 @@ public sealed class LightInfoLoaderTests
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.Equal(new Vector3(100, 200, 300), light.RankingOrigin);
Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
}

View file

@ -30,6 +30,7 @@ public sealed class SceneLightingUboTests
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1, 2, 3),
RankingOrigin = new Vector3(1, 2, 3),
ColorLinear = new Vector3(1f, 0.5f, 0.25f),
Intensity = 0.8f,
Range = 6f,
@ -84,6 +85,7 @@ public sealed class SceneLightingUboTests
{
Kind = LightKind.Point,
WorldPosition = new Vector3(i, 0, 0),
RankingOrigin = new Vector3(i, 0, 0),
Range = 200f, // all in range
});
}

View file

@ -1,6 +1,9 @@
using System;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Lighting;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -111,7 +114,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
_out.WriteLine($"=== Setup 0x{setupId:X8}: Parts={setup!.Parts.Count} PlacementFrames={setup.PlacementFrames.Count} Lights={setup.Lights.Count} ===");
foreach (var kvp in setup.Lights)
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle}");
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle} LocalOrigin=({kvp.Value.ViewSpaceLocation?.Origin.X:R},{kvp.Value.ViewSpaceLocation?.Origin.Y:R},{kvp.Value.ViewSpaceLocation?.Origin.Z:R})");
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
_out.WriteLine($" SetupMesh.Flatten -> {flat.Count} MeshRefs");
@ -139,6 +142,68 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
$"=> GameWindow.cs:7324 would {(survivors == 0 ? "DROP" : "KEEP")} this entity");
}
[Fact]
public void TownCeilingFixture_AuthoredTypeKeyAndRootRankOrigin_ArePinnedFromInstalledDat()
{
var datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint cellId = 0x00070144u;
const uint setupId = 0x02000365u;
EnvCell cell = Assert.IsType<EnvCell>(dats.Get<EnvCell>(cellId));
var fixture = Assert.Single(cell.StaticObjects, entry => entry.Id == setupId);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(setupId));
var authored = Assert.Single(setup.Lights);
// The dictionary key is native LIGHTINFO.type. The current loader's
// cone-derived Point/Spot projection deliberately remains unchanged.
Assert.Equal(0, authored.Key);
Assert.Equal(0xCDCDCDCDu, BitConverter.SingleToUInt32Bits(authored.Value.ConeAngle));
Assert.Equal(0.000759337f, authored.Value.ViewSpaceLocation.Origin.X);
Assert.Equal(0.00675148f, authored.Value.ViewSpaceLocation.Origin.Y);
Assert.Equal(0.0277f, authored.Value.ViewSpaceLocation.Origin.Z);
Vector3 root = new(
fixture.Frame.Origin.X,
fixture.Frame.Origin.Y,
fixture.Frame.Origin.Z);
Quaternion rotation = new(
fixture.Frame.Orientation.X,
fixture.Frame.Orientation.Y,
fixture.Frame.Orientation.Z,
fixture.Frame.Orientation.W);
Assert.Equal(new Vector3(69.875f, -69.916f, 5.005f), root);
Assert.Equal(new Quaternion(0f, 0f, -0.94372f, 0.330745f), rotation);
LightSource light = Assert.Single(LightInfoLoader.Load(
setup,
ownerId: 0x4000712Fu,
entityPosition: root,
entityRotation: rotation,
isDynamic: false,
cellId: cellId));
Assert.True(light.HasRankingOrigin);
Assert.Equal(root, light.RankingOrigin);
Assert.Equal(LightKind.Point, light.Kind);
Assert.NotEqual(root, light.WorldPosition);
var localFrame = authored.Value.ViewSpaceLocation;
var localOffset = new Vector3(
localFrame.Origin.X,
localFrame.Origin.Y,
localFrame.Origin.Z);
var localRotation = new Quaternion(
localFrame.Orientation.X,
localFrame.Orientation.Y,
localFrame.Orientation.Z,
localFrame.Orientation.W);
Matrix4x4 expectedWorld = (Matrix4x4.CreateFromQuaternion(localRotation)
* Matrix4x4.CreateTranslation(localOffset))
* (Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(root));
Assert.Equal(expectedWorld.Translation, light.WorldPosition);
}
/// <summary>
/// Follow-up (same session, 2026-07-09): user confirmed lighting improved but
/// reported missing candle flames + fountain water particles. Hypothesis tested: