diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs
new file mode 100644
index 0000000..a9ba8df
--- /dev/null
+++ b/src/AcDream.Core/Lighting/LightManager.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Lighting;
+
+///
+/// Manages the registered dynamic lights in the world and picks the 8
+/// most relevant ones each frame for the shader to consume. Matches
+/// retail's fixed-function-era "8 hardware lights" constraint (r13
+/// §12.2).
+///
+///
+/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
+///
+/// -
+/// Recompute DistSq from viewer to every registered
+/// point/spot light.
+///
+/// -
+/// Drop lights outside Range² * 1.1 (10% slack prevents
+/// pop as we walk across the boundary).
+///
+/// -
+/// Rank remaining lights by DistSq ascending. Pick top 7.
+///
+/// -
+/// Reserve slot 0 for the sun (directional, infinite range).
+///
+///
+///
+///
+///
+/// Not thread-safe — the render thread owns the light list.
+///
+///
+public sealed class LightManager
+{
+ public const int MaxActiveLights = 8; // D3D parity
+ private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff
+
+ private readonly List _all = new();
+ private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
+ private int _activeCount;
+
+ /// Current cell ambient state applied to everything.
+ public CellAmbientState CurrentAmbient { get; set; }
+
+ ///
+ /// The sun (or "global directional") — always slot 0 of the active
+ /// list. Set this from the
+ /// each frame.
+ ///
+ public LightSource? Sun { get; set; }
+
+ /// Snapshot of the currently-active lights (up to 8).
+ public ReadOnlySpan Active => _active.AsSpan(0, _activeCount);
+
+ public int ActiveCount => _activeCount;
+ public int RegisteredCount => _all.Count;
+
+ /// Add a light. Idempotent — adding the same instance twice is a no-op.
+ public void Register(LightSource light)
+ {
+ ArgumentNullException.ThrowIfNull(light);
+ foreach (var existing in _all)
+ if (ReferenceEquals(existing, light)) return;
+ _all.Add(light);
+ }
+
+ /// Remove by reference.
+ public void Unregister(LightSource light)
+ {
+ _all.Remove(light);
+ }
+
+ /// Remove every light attached to a specific entity.
+ public void UnregisterByOwner(uint ownerId)
+ {
+ _all.RemoveAll(l => l.OwnerId == ownerId);
+ }
+
+ public void Clear()
+ {
+ _all.Clear();
+ Array.Clear(_active);
+ _activeCount = 0;
+ }
+
+ ///
+ /// Refresh the active-light list for the current viewer position.
+ /// Called once per render frame from the render thread; the shader
+ /// reads and uploads to the light UBO.
+ ///
+ public void Tick(Vector3 viewerWorldPos)
+ {
+ // Pass 1: compute DistSq + filter out lights outside the slack radius.
+ var candidates = new List(_all.Count);
+ foreach (var light in _all)
+ {
+ if (!light.IsLit) continue;
+ if (light.Kind == LightKind.Directional)
+ {
+ // Directional lights don't participate in this ranking —
+ // the sun is always slot 0.
+ continue;
+ }
+
+ Vector3 delta = light.WorldPosition - viewerWorldPos;
+ light.DistSq = delta.LengthSquared();
+
+ float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack;
+ if (light.DistSq > rangeSq) continue;
+ candidates.Add(light);
+ }
+
+ // Pass 2: sort by DistSq ascending, take up to 7.
+ candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq));
+
+ Array.Clear(_active);
+ _activeCount = 0;
+
+ // Slot 0 = sun when present.
+ if (Sun is not null)
+ {
+ _active[0] = Sun;
+ _activeCount = 1;
+ }
+
+ int maxPoint = MaxActiveLights - _activeCount;
+ int pointCount = Math.Min(maxPoint, candidates.Count);
+ for (int i = 0; i < pointCount; i++)
+ {
+ _active[_activeCount + i] = candidates[i];
+ }
+ _activeCount += pointCount;
+ }
+}
diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs
new file mode 100644
index 0000000..7bfb7d4
--- /dev/null
+++ b/src/AcDream.Core/Lighting/LightSource.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Lighting;
+
+///
+/// Retail light-source kinds (r13 §12.1).
+///
+public enum LightKind
+{
+ Directional = 0, // sun, moon — no position, infinite range
+ Point = 1, // torch, fireplace, spell aura
+ Spot = 2, // cone-shaped (rare in AC, used for a few specific lamps)
+}
+
+///
+/// Per-frame light record. Used by and fed to
+/// the shader UBO on every draw call.
+///
+///
+/// Retail semantics (r13 §10.2):
+///
+/// -
+/// Hard cutoff at — no smoothstep, no distance
+/// attenuation inside the range. "Crisp bubble of illumination."
+///
+/// -
+/// Max 8 active lights (),
+/// ranked by distance-to-viewer. Slot 0 is reserved for the sun.
+///
+/// -
+/// latches the SetLightHook value so
+/// animations can toggle a light on/off (torch being lit, light
+/// crystal activating).
+///
+///
+///
+///
+public sealed class LightSource
+{
+ public LightKind Kind;
+ public Vector3 WorldPosition;
+ public Vector3 WorldForward; // for Spot/Directional
+ public Vector3 ColorLinear = Vector3.One; // R,G,B in [0,1], pre-brightness
+ public float Intensity = 1f;
+ public float Range = 10f; // metres, hard cutoff
+ public float ConeAngle = 0f; // radians, Spot only
+ public uint OwnerId; // attached entity id; 0 = world-global
+ public bool IsLit = true; // SetLightHook latch
+
+ // Cached each frame by LightManager.
+ public float DistSq;
+}
+
+///
+/// Per-cell ambient + sun state. For outdoor cells this comes from the
+/// R12 sky state; for indoor cells the EnvCell dat carries a per-cell
+/// ambient override (r13 §3).
+///
+public readonly record struct CellAmbientState(
+ Vector3 AmbientColor,
+ Vector3 SunColor,
+ Vector3 SunDirection);
diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
new file mode 100644
index 0000000..9df68a2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
@@ -0,0 +1,139 @@
+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)
+ => new LightSource
+ {
+ Kind = LightKind.Point,
+ WorldPosition = pos,
+ Range = range,
+ IsLit = lit,
+ OwnerId = ownerId,
+ };
+
+ [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_DropsLightsOutsideRangeWithSlack()
+ {
+ var mgr = new LightManager();
+ mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range
+
+ mgr.Tick(viewerWorldPos: Vector3.Zero);
+
+ Assert.Equal(0, mgr.ActiveCount);
+ }
+
+ [Fact]
+ public void Tick_IncludesLightsNearRangeEdge_WithSlack()
+ {
+ var mgr = new LightManager();
+ // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included.
+ 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);
+ }
+}