merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch

Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:01:20 +02:00
commit 31d7ffd253
27 changed files with 2327 additions and 103 deletions

View file

@ -0,0 +1,42 @@
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// A7 Fix D round 2 — pins retail's <c>useSunlight</c> gate for per-object torch
/// lighting (<c>WbDrawDispatcher.IndoorObjectReceivesTorches</c>). Retail enables
/// the static wall-torches on an object ONLY in the indoor stage
/// (<c>DrawMeshInternal</c> 0x0059f398: <c>if (useSunlight == 0) minimize_object_lighting()</c>),
/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor
/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only
/// EnvCell-parented (indoor, low word &gt;= 0x0100) objects receive torches.
/// </summary>
public sealed class WbDrawDispatcherTorchGateTests
{
[Fact]
public void BuildingShell_NullParent_IsOutdoor_NoTorches()
{
// Building exterior shells are top-level landblock stabs with no
// ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent).
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null));
}
[Theory]
[InlineData(0xA9B4_0001u)] // outdoor land sub-cell
[InlineData(0xA9B4_0020u)] // outdoor land sub-cell
[InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40)
public void OutdoorLandCell_NoTorches(uint parentCellId)
{
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
}
[Theory]
[InlineData(0xA9B4_0100u)] // first EnvCell
[InlineData(0xA9B4_0164u)] // interior EnvCell
[InlineData(0x0007_0143u)] // dungeon EnvCell
public void IndoorEnvCell_GetsTorches(uint parentCellId)
{
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
}
}

View file

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Lighting;
using DatReaderWriter;
using DatReaderWriter.Options;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
using DatSetup = DatReaderWriter.DBObjs.Setup;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without
/// guessing or a live launch: dump the RAW dat <c>LightInfo.Falloff</c> for every
/// static light in the Holtburg landblocks, via the EXACT production load path
/// (<see cref="LightInfoLoader.Load"/>). The dat is the SAME file retail reads, so
/// these falloffs ARE what retail reads (modulo any load-time transform, settled
/// separately in the decomp). Output-only — no assertions; read the log.
/// </summary>
public sealed class HoltburgTorchFalloffProbeTests
{
private readonly ITestOutputHelper _out;
public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output;
[Fact]
public void Dump_Holtburg_StaticLight_Falloffs()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
// The meeting hall sits in the Holtburg town landblocks. Sweep a small
// neighbourhood so we catch every entrance torch the streaming window
// would load around the player at the hall.
uint[] landblocks =
{
0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u,
};
// Tally every distinct raw Falloff seen (the headline number).
var falloffTally = new SortedDictionary<float, int>();
int totalLights = 0;
foreach (uint lb in landblocks)
{
uint infoId = (lb << 16) | 0xFFFEu;
var info = dats.Get<DatLandBlockInfo>(infoId);
if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; }
int buildings = info.Buildings?.Count ?? 0;
int objects = info.Objects?.Count ?? 0;
_out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ===");
// Record building-shell origins so we can rank torches by proximity.
var shells = new List<(uint model, Vector3 pos)>();
if (info.Buildings is not null)
{
foreach (var b in info.Buildings)
{
var o = b.Frame.Origin;
shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z)));
_out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}");
}
}
if (info.Objects is null) continue;
foreach (var stab in info.Objects)
{
// Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary —
// identical gate to GameWindow.cs:6399.
if ((stab.Id & 0xFF000000u) != 0x02000000u) continue;
var setup = dats.Get<DatSetup>(stab.Id);
if (setup?.Lights is null || setup.Lights.Count == 0) continue;
var loaded = LightInfoLoader.Load(
setup,
ownerId: 0,
entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z),
entityRotation: new Quaternion(
stab.Frame.Orientation.X, stab.Frame.Orientation.Y,
stab.Frame.Orientation.Z, stab.Frame.Orientation.W));
foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l)))
{
float rawFalloff = kvp.Value.Falloff;
totalLights++;
falloffTally.TryGetValue(rawFalloff, out int c);
falloffTally[rawFalloff] = c + 1;
// Nearest building shell, for "is this an entrance torch on the hall?".
float nearest = float.MaxValue;
uint nearestModel = 0;
foreach (var (model, spos) in shells)
{
float dd = Vector3.Distance(ls.WorldPosition, spos);
if (dd < nearest) { nearest = dd; nearestModel = model; }
}
_out.WriteLine(
$" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " +
$"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " +
$"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " +
$"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " +
$"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m");
}
}
}
_out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ===");
foreach (var kv in falloffTally)
_out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}");
}
}

View file

@ -0,0 +1,45 @@
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public class GlobalLightPackerTests
{
[Fact]
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
{
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(10f, 20f, 30f),
WorldForward = new Vector3(0f, 0f, 1f),
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
Intensity = 100f,
Range = 5.2f,
ConeAngle = 0f,
};
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
Assert.Equal(1, count);
Assert.True(buffer.Length >= 16);
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
Assert.Equal(5.2f, buffer[7]);
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
Assert.Equal(100f, buffer[11]);
Assert.Equal(0f, buffer[12]);
}
[Fact]
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
{
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(null, ref buffer);
Assert.Equal(0, count);
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
}
}

View file

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
/// <summary>
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
/// </summary>
public class LightBakeConformanceTests
{
private static LightSource OrangeTorch(Vector3 pos) => new()
{
Kind = LightKind.Point,
WorldPosition = pos,
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
Intensity = 100f,
Range = 4f * 1.3f, // falloff 4 × static_light_factor
IsLit = true,
};
[Theory]
[InlineData(1f)]
[InlineData(2f)]
[InlineData(3f)]
[InlineData(4f)]
[InlineData(5f)]
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
{
var vtx = Vector3.Zero;
var normal = Vector3.UnitX;
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
if (c.X > 0f)
{
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
}
}
[Fact]
public void BeyondRange_ContributesNothing()
{
var torch = OrangeTorch(new Vector3(100f, 0f, 0f));
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
Assert.Equal(Vector3.Zero, c);
}
[Fact]
public void ManyOverlappingIntenseTorches_StillClampToOne()
{
var lights = new List<LightSource>();
for (int i = 0; i < 8; i++)
lights.Add(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
Intensity = 100f,
Range = 5.2f,
IsLit = true,
});
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
}
}

View file

@ -144,4 +144,116 @@ public sealed class LightManagerTests
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);
}
[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]);
}
}

View file

@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests
{
// The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
// where the sun vector is built from heading/pitch/brightness with
// Y unscaled by brightness (decomp 261352).
// 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|.
// cdb-verified (reference-retail-ambient-values): |LScape::sunlight| ==
// DirBright for every keyframe (world-space spherical vector, magnitude
// DirBright·sqrt(cos²P+sin²P) = DirBright).
//
// For this region: H=180°, P=70°, B=1.5
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
// = (0, 0.342, 1.410)
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
// sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70))
// = (0, -0.513, 1.410)
// |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright)
// DirColor.X = 200/255 = 0.7843
// SunColor.X = 0.7843 × 1.4509 = 1.138
// SunColor.X = 0.7843 × 1.500 = 1.1765
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
Assert.InRange(kf.SunColor.X, 1.17f, 1.18f);
}
[Fact]

View file

@ -66,24 +66,33 @@ public sealed class SkyStateTests
}
[Fact]
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
public void RetailSunVector_MagnitudeAlwaysEqualsDirBright()
{
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
// |sunVec| = 1 regardless of B (because Y is unscaled by B)
var kf = new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 0f,
DirColor: Vector3.One,
DirBright: 2.0f, // anything
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0f);
// cdb-verified (2026-06-18, reference-retail-ambient-values): retail's
// world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP),
// whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright
// for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the
// horizon — that was the ~30% over-bright bug.)
// Horizon north (H=0°, P=0°): (0, B, 0), |.| = B.
var horizon = new SkyKeyframe(
Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f,
DirColor: Vector3.One, DirBright: 2.0f,
AmbColor: Vector3.One, AmbBright: 1f,
FogColor: Vector3.One, FogDensity: 0f);
Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 0.99f, 1.01f);
// Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224
// → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright.
var dawn = new SkyKeyframe(
Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f,
DirColor: Vector3.One, DirBright: 0.224f,
AmbColor: Vector3.One, AmbBright: 0.40f,
FogColor: Vector3.One, FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(dawn);
Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224
Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1)
Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035
Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright
}
[Fact]