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>
116 lines
5.3 KiB
C#
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}");
|
|
}
|
|
}
|