diff --git a/src/AcDream.Core/Lighting/LightBake.cs b/src/AcDream.Core/Lighting/LightBake.cs
new file mode 100644
index 00000000..1ab52714
--- /dev/null
+++ b/src/AcDream.Core/Lighting/LightBake.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Lighting;
+
+///
+/// Retail per-vertex static-light burn-in. Ported verbatim from
+/// calc_point_light (acclient 0x0059c8b0), the function retail's
+/// D3DPolyRender::SetStaticLightingVertexColors (0x0059cfe0) runs over
+/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the
+/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud-
+/// interpolates it across each triangle and the texture stage modulates it).
+///
+///
+/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our
+/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and
+/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL
+/// reaching lights into the vertex once, keyed on light position not camera —
+/// uniform, stable, and never blown out (each light is clamped to its own
+/// colour, then the vertex sum is clamped to [0,1]).
+///
+///
+/// Constants (decomp-cited, not guessed):
+///
+/// - static_light_factor = 1.3 (0x00820e24) — folded into
+/// by LightInfoLoader, so
+/// falloff_eff == light.Range here.
+/// - LIGHT_POINT_RANGE = 0.75 (0x007e5430) — the half-Lambert wrap
+/// uses 2·LPR = 1.5 as the divisor and (2·LPR − 1) = 0.5 as the
+/// distance bias, so even surfaces angled away from a torch receive some light.
+///
+///
+public static class LightBake
+{
+ // calc_point_light literals.
+ private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE
+ private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) − 1.0
+
+ ///
+ /// Accumulate one static light's contribution into a per-vertex RGB sum,
+ /// exactly as calc_point_light does. Returns the contribution to ADD
+ /// (already per-channel clamped to the light's own colour); the caller sums
+ /// over all reaching lights and clamps the total to [0,1].
+ ///
+ public static Vector3 PointContribution(
+ Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light)
+ {
+ // D = light − vertex (FROM vertex TO light), used un-normalised.
+ float dx = light.WorldPosition.X - vtxWorldPos.X;
+ float dy = light.WorldPosition.Y - vtxWorldPos.Y;
+ float dz = light.WorldPosition.Z - vtxWorldPos.Z;
+
+ float distsq = dx * dx + dy * dy + dz * dz;
+ float dist = MathF.Sqrt(distsq);
+ float falloffEff = light.Range; // = Falloff × static_light_factor(1.3)
+ if (dist >= falloffEff || falloffEff <= 1e-4f)
+ return Vector3.Zero;
+
+ // Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal.
+ float wrap = (1f / TwoLpr) *
+ (vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz
+ + WrapBias * dist);
+ if (wrap <= 0f)
+ return Vector3.Zero;
+
+ // norm branch — ported EXACTLY (changes the near-vs-far falloff shape).
+ float norm = distsq > 1f ? distsq * dist : dist;
+ float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm);
+
+ // Per channel: contribution clamped to the light's own colour (a single
+ // light can never push a channel past its colour — the no-blowout ceiling).
+ return new Vector3(
+ MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X),
+ MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y),
+ MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z));
+ }
+
+ ///
+ /// Bake the full per-vertex colour by summing every reaching lit point/spot
+ /// light, then clamping to [0,1] (the SetStaticLightingVertexColors
+ /// final clamp). Directional lights are skipped — they are handled by the
+ /// sun path, not the static burn-in.
+ ///
+ public static Vector3 ComputeVertexColor(
+ Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList reaching)
+ {
+ float r = 0f, g = 0f, b = 0f;
+ for (int i = 0; i < reaching.Count; i++)
+ {
+ var light = reaching[i];
+ if (!light.IsLit || light.Kind == LightKind.Directional) continue;
+ var c = PointContribution(vtxWorldPos, vtxWorldNormal, light);
+ r += c.X; g += c.Y; b += c.Z;
+ }
+ return new Vector3(
+ Math.Clamp(r, 0f, 1f),
+ Math.Clamp(g, 0f, 1f),
+ Math.Clamp(b, 0f, 1f));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
new file mode 100644
index 00000000..be082b37
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Lighting;
+using Xunit;
+
+namespace AcDream.Core.Tests.Lighting;
+
+///
+/// Conformance tests for the per-vertex static-light burn-in
+/// (), ported from retail calc_point_light
+/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation:
+/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq>1 ? distsq·dist : dist;
+/// scale = (1 − dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color).
+///
+public sealed class LightBakeTests
+{
+ private static LightSource Torch(Vector3 pos, float intensity = 100f, float range = 10f)
+ => new LightSource
+ {
+ Kind = LightKind.Point,
+ WorldPosition = pos,
+ ColorLinear = Vector3.One,
+ Intensity = intensity,
+ Range = range,
+ IsLit = true,
+ };
+
+ [Fact]
+ public void NearTorch_FacingIt_SaturatesToColor()
+ {
+ // Vertex at origin facing up (+Z); torch 2 m above.
+ // dist=2, distsq=4, wrap=(1/1.5)(2+1)=2, norm=4·2=8,
+ // scale=(1-0.2)·100·(2/8)=20 → min(20·1,1)=1 per channel.
+ var c = LightBake.PointContribution(
+ Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 2)));
+ Assert.Equal(1f, c.X, 4);
+ Assert.Equal(1f, c.Y, 4);
+ Assert.Equal(1f, c.Z, 4);
+ }
+
+ [Fact]
+ public void FarTorch_FallsOffSmoothly()
+ {
+ // Torch 8 m above (still within Range 10). scale=(1-0.8)·100·(8/512)=0.3125.
+ var c = LightBake.PointContribution(
+ Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 8)));
+ Assert.Equal(0.3125f, c.X, 4);
+ Assert.Equal(0.3125f, c.Y, 4);
+ Assert.Equal(0.3125f, c.Z, 4);
+ }
+
+ [Fact]
+ public void OutOfRange_ContributesNothing()
+ {
+ // Torch 11 m above, Range 10 → dist >= falloff_eff, skipped.
+ var c = LightBake.PointContribution(
+ Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 11)));
+ Assert.Equal(Vector3.Zero, c);
+ }
+
+ [Fact]
+ public void FacingAway_BeyondWrap_ContributesNothing()
+ {
+ // Normal points away (−Z) from a torch above: N·D=−2, wrap=(1/1.5)(−2+1)<0.
+ var c = LightBake.PointContribution(
+ Vector3.Zero, new Vector3(0, 0, -1), Torch(new Vector3(0, 0, 2)));
+ Assert.Equal(Vector3.Zero, c);
+ }
+
+ [Fact]
+ public void HalfLambertWrap_LightsSurfaceAngledPast90Degrees()
+ {
+ // Normal at ~100° from the light direction still gets light (Lambert would not).
+ // Light straight above (+Z 2 m); normal tilted to (sin100°, 0, cos100°).
+ double t = 100.0 * Math.PI / 180.0;
+ var n = new Vector3((float)Math.Sin(t), 0, (float)Math.Cos(t)); // cos100° < 0
+ var c = LightBake.PointContribution(Vector3.Zero, n, Torch(new Vector3(0, 0, 2)));
+ Assert.True(c.X > 0f, "half-Lambert wrap should light a surface angled past 90°");
+ }
+
+ [Fact]
+ public void ComputeVertexColor_SumsLightsAndClampsToOne()
+ {
+ // Two saturating torches → sum clamps to 1, never overflows.
+ var lights = new[]
+ {
+ Torch(new Vector3(0, 0, 2)),
+ Torch(new Vector3(0, 0, 2)),
+ };
+ var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights);
+ Assert.Equal(1f, c.X, 4);
+ Assert.Equal(1f, c.Y, 4);
+ Assert.Equal(1f, c.Z, 4);
+ }
+
+ [Fact]
+ public void ComputeVertexColor_SkipsDirectionalAndUnlit()
+ {
+ var lights = new[]
+ {
+ new LightSource { Kind = LightKind.Directional, WorldPosition = new Vector3(0,0,2),
+ ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = true },
+ new LightSource { Kind = LightKind.Point, WorldPosition = new Vector3(0,0,2),
+ ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = false },
+ };
+ var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights);
+ Assert.Equal(Vector3.Zero, c);
+ }
+}