acdream/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs
Erik 9ebb206086 fix(#79/#93): mesh-empty hydration gate was dropping light-only fixtures
Root-caused via dat-truth inspection, not inference: the A7.L1 visible-
cell scoping fix (previous commit) had zero visual effect because the
Town Network fountain room (cell 0x00070144) registers ZERO lights of
its own — confirmed directly against the dat, not assumed. The room's
one dat-authored fixture, Setup 0x02000365 (a ceiling light 5m above the
fountain, warm color, intensity 100), has a single visual part that is a
#136-class runtime-hidden marker. Flattened mesh ref count: 0. GameWindow
's per-stab hydration loop treated meshRefs.Count==0 as "doesn't exist"
and dropped the whole entity before the Setup's Lights were ever read —
so a mesh-less "light attach point" fixture, a normal AC dat authoring
pattern, could never register. Retail's light registration (add_light,
CEnvCell::UnPack) is architecturally independent of a fixture's own mesh
visibility.

Fix: track the stab's Setup.Lights.Count alongside meshRefs during
hydration; keep the entity (with empty MeshRefs — nothing to draw, still
something to light) whenever either is nonzero. Extracted the decision
into EntityHydrationRules.ShouldKeepEntity (pure, unit-tested) since
GameWindow's hydration loop isn't independently testable. Confirmed no
downstream consumer assumes MeshRefs.Count >= 1 (WbDrawDispatcher already
guards on it before any indexing).

Core 2666+2skip / App 741+2skip / UI 425 / Net 385 green. Apparatus:
Issue93TownNetworkFountainRoomLightInspectionTests (dat-truth dump,
reusable for other rooms in this class).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 11:11:16 +02:00

137 lines
6.4 KiB
C#

using System;
using System.IO;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Rendering;
/// <summary>
/// #79/#93/#176/#177 A7.L1 (2026-07-09) — dat-truth check for the Town Network
/// fountain room (0x00070144). Live probe evidence (launch-a7-lightscope.log)
/// showed 0x00070144 NEVER appears in the [indoor-light] byCell histogram across
/// every visit this session, regardless of the visible-cell scoping fix — every
/// pooled light belongs to NEIGHBORING cells (0x132/0x133/0x143/0x145/0x155/0x157),
/// never to 0x144 itself. Both registration sites (GameWindow.cs:3771 live weenie
/// spawns, GameWindow.cs:7919 dat EnvCell statics) already tag CellId correctly, so
/// this is either (a) the room genuinely has no dat-authored Setup.Lights of its
/// own (a registration/spawn-side non-issue — the darkness is caused by something
/// else entirely), or (b) it DOES have fixtures that never made it into
/// EnvCell.StaticObjects / the live weenie spawn set (a real hydration gap).
/// Dumps StaticObjects + each Setup-sourced static's Setup.Lights.Count for the
/// fountain room and its immediate neighbors observed this session, so the answer
/// is read from the dat directly instead of inferred from aggregate counts.
/// </summary>
public class Issue93TownNetworkFountainRoomLightInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue93TownNetworkFountainRoomLightInspectionTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
[Theory]
[InlineData(0x00070144u)] // the fountain room — the user's original repro spot
[InlineData(0x00070156u)] // player's teleport-in cell this session
[InlineData(0x00070164u)]
[InlineData(0x00070132u)]
[InlineData(0x00070133u)]
[InlineData(0x00070143u)]
[InlineData(0x00070145u)]
[InlineData(0x00070146u)]
[InlineData(0x00070155u)]
[InlineData(0x00070157u)]
public void StaticObjects_SetupLightsCount_Dump(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null)
{
_out.WriteLine($"=== 0x{cellId:X8} NOT FOUND in dat ===");
return;
}
_out.WriteLine($"=== 0x{cellId:X8} Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " +
$"pos=({envCell.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " +
$"StaticObjects={envCell.StaticObjects.Count} ===");
int totalLights = 0;
foreach (var so in envCell.StaticObjects)
{
if ((so.Id & 0xFF000000u) == 0x02000000u)
{
var setup = dats.Get<Setup>(so.Id);
int lightCount = setup?.Lights.Count ?? -1;
totalLights += Math.Max(lightCount, 0);
_out.WriteLine($" SETUP id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2}) Lights={lightCount}");
}
else
{
_out.WriteLine($" id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2}) (not a Setup — no Lights dict)");
}
}
_out.WriteLine($" TOTAL dat-authored Lights in cell 0x{cellId:X8} StaticObjects: {totalLights}");
}
/// <summary>
/// Setup 0x02000365 at (69.88,-69.92,5.01) — 5 m above the fountain, the room's
/// ONLY dat-authored light (Lights=1) — never appeared in the live [indoor-light]
/// byCell histogram. GameWindow.cs:7324 drops the ENTIRE hydrated entity (light
/// included) when its flattened <c>meshRefs.Count == 0</c> — mirror that exact
/// gate here (IsRuntimeHiddenMarker skip, then GfxObj-resolves check) to see
/// whether this specific fixture is a mesh-less/marker-only "light attach point"
/// that the mesh-gate silently drops before the lights loop (GameWindow.cs:7892)
/// ever sees it.
/// </summary>
[Fact]
public void CeilingFixtureSetup_MeshFlattenSurvivorCount_Dump()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint setupId = 0x02000365u;
var setup = dats.Get<Setup>(setupId);
Assert.NotNull(setup);
_out.WriteLine($"=== Setup 0x{setupId:X8}: Parts={setup!.Parts.Count} PlacementFrames={setup.PlacementFrames.Count} Lights={setup.Lights.Count} ===");
foreach (var kvp in setup.Lights)
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle}");
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
_out.WriteLine($" SetupMesh.Flatten -> {flat.Count} MeshRefs");
int survivors = 0, markerSkipped = 0, gfxNull = 0;
foreach (var mr in flat)
{
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(dats, mr.GfxObjId))
{
markerSkipped++;
_out.WriteLine($" part gfx=0x{mr.GfxObjId:X8} -> MARKER (skipped)");
continue;
}
var gfx = dats.Get<GfxObj>(mr.GfxObjId);
if (gfx is null)
{
gfxNull++;
_out.WriteLine($" part gfx=0x{mr.GfxObjId:X8} -> GFXOBJ-NULL (dropped)");
continue;
}
survivors++;
_out.WriteLine($" part gfx=0x{mr.GfxObjId:X8} -> survives (Polygons={gfx.Polygons.Count})");
}
_out.WriteLine($" meshRefs survivor count = {survivors} (markerSkipped={markerSkipped} gfxNull={gfxNull}) " +
$"=> GameWindow.cs:7324 would {(survivors == 0 ? "DROP" : "KEEP")} this entity");
}
}