acdream/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs
Erik b7d655bce7 fix(lighting): A7 Fix D round 2 — outdoor objects get NO torches (retail useSunlight gate) (#140)
The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1
checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail
Falloff 4). That theory is WRONG, and this commit fixes the real cause.

Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production
LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat
Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere
in Holtburg. Both clients read the same dat float, so reach was never inflated.

Decomp (read verbatim + corroborated by an independent adversarial workflow):
retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated
in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`.
The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a,
before LScape::draw), so the building EXTERIOR shell — drawn via
DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit
by SUN + ambient ONLY; torches are SKIPPED. The static bake
(SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER
torch-lights outdoor objects. This exactly explains the isolation test (object
point lights OFF → building matches retail).

Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on
the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the
pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with
null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light
set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by
the global sun-kill when the player is inside a cell (UpdateSunFromSky). No
dungeon regression: EnvCell statics get ParentCellId set (keep torches).

Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own
cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight;
only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED
banner correcting the reach theory.

Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat
dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:56:49 +02:00

116 lines
5.3 KiB
C#

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}");
}
}