diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4db481e0..d7a2228a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7263,6 +7263,14 @@ public sealed class GameWindow : IDisposable var meshRefs = new List(); var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + // #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a + // runtime-hidden marker (#136) flattens to zero mesh refs even though its + // Setup carries real Lights — a "light attach point" fixture (e.g. the Town + // Network fountain room's ceiling light, Setup 0x02000365). Track the dat + // Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop + // the entity that would otherwise carry those lights to the registration + // pass (GameWindow.cs ~7900). + int stabLightCount = 0; if ((stab.Id & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(stab.Id); @@ -7285,6 +7293,7 @@ public sealed class GameWindow : IDisposable if (setup is not null) { _physicsDataCache.CacheSetup(stab.Id, setup); + stabLightCount = setup.Lights.Count; var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); if (dumpStab) { @@ -7321,12 +7330,14 @@ public sealed class GameWindow : IDisposable } } - if (meshRefs.Count == 0) + if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount)) { if (dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 -> entity dropped"); + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped"); continue; } + if (meshRefs.Count == 0 && dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier"); // Stabs inside EnvCells are already in landblock-local coordinates // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would diff --git a/src/AcDream.Core/Meshing/EntityHydrationRules.cs b/src/AcDream.Core/Meshing/EntityHydrationRules.cs new file mode 100644 index 00000000..d61035b9 --- /dev/null +++ b/src/AcDream.Core/Meshing/EntityHydrationRules.cs @@ -0,0 +1,33 @@ +namespace AcDream.Core.Meshing; + +/// +/// Should a hydrated stab/entity survive when its visual mesh flattens to +/// zero drawable parts? +/// +/// +/// Why this exists (#79/#93, 2026-07-09). A dat-authored "light attach +/// point" is a Setup whose sole purpose is to carry a Setup.Lights +/// entry — its own visual part is commonly a #136-class runtime-hidden +/// marker (degrades to nothing at any real distance, matching retail's +/// editor-only placement markers). Retail's light registration +/// (CObjCell::add_light, populated at CEnvCell::UnPack) is +/// entirely independent of a fixture's own mesh visibility — a mesh-less +/// carrier still lights the room. The Town Network fountain room's only +/// light (Setup 0x02000365, a ceiling fixture 5 m above the fountain) never +/// registered because the mesh-empty gate treated "nothing to draw" as +/// "nothing exists," dropping the entity — and its Lights — before the +/// light-registration pass ever saw it. +/// +/// +public static class EntityHydrationRules +{ + /// + /// True when the entity should still be added to the landblock's entity + /// set even with zero mesh refs, because it has dat-authored lights to + /// register. An entity with any mesh is always kept (unchanged from the + /// pre-existing gate); the entity is dropped only when it has neither + /// geometry to draw nor lights to register. + /// + public static bool ShouldKeepEntity(int meshRefCount, int setupLightCount) + => meshRefCount > 0 || setupLightCount > 0; +} diff --git a/tests/AcDream.Core.Tests/Meshing/EntityHydrationRulesTests.cs b/tests/AcDream.Core.Tests/Meshing/EntityHydrationRulesTests.cs new file mode 100644 index 00000000..ab2c5660 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/EntityHydrationRulesTests.cs @@ -0,0 +1,38 @@ +using AcDream.Core.Meshing; +using Xunit; + +namespace AcDream.Core.Tests.Meshing; + +/// +/// #79/#93 (2026-07-09) — the Town Network fountain room's only dat-authored +/// light (Setup 0x02000365, a ceiling fixture) never registered: its sole +/// visual part is a #136-class runtime-hidden marker, so it flattens to zero +/// mesh refs, and GameWindow.cs's hydration loop dropped the ENTIRE entity — +/// light included — whenever meshRefs was empty. Retail's light registration +/// (CObjCell::add_light, CEnvCell::UnPack) is entirely independent of a +/// fixture's own mesh visibility; the mesh-empty gate must not also swallow +/// a Setup's Lights. +/// +public class EntityHydrationRulesTests +{ + [Fact] + public void ShouldKeepEntity_NoMeshNoLights_False() + { + Assert.False(EntityHydrationRules.ShouldKeepEntity(meshRefCount: 0, setupLightCount: 0)); + } + + [Fact] + public void ShouldKeepEntity_NoMeshWithLights_True() + { + // The exact fountain-room case: a mesh-less "light attach point" must + // still survive hydration so its Lights can be registered. + Assert.True(EntityHydrationRules.ShouldKeepEntity(meshRefCount: 0, setupLightCount: 1)); + } + + [Fact] + public void ShouldKeepEntity_HasMesh_TrueRegardlessOfLights() + { + Assert.True(EntityHydrationRules.ShouldKeepEntity(meshRefCount: 1, setupLightCount: 0)); + Assert.True(EntityHydrationRules.ShouldKeepEntity(meshRefCount: 3, setupLightCount: 2)); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs new file mode 100644 index 00000000..4dc7f889 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs @@ -0,0 +1,137 @@ +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; + +/// +/// #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. +/// +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(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(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}"); + } + + /// + /// 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 meshRefs.Count == 0 — 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. + /// + [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(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(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"); + } +}