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>
This commit is contained in:
parent
d275ed554e
commit
9ebb206086
4 changed files with 221 additions and 2 deletions
|
|
@ -7263,6 +7263,14 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
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)
|
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||||
{
|
{
|
||||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
||||||
|
|
@ -7285,6 +7293,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (setup is not null)
|
if (setup is not null)
|
||||||
{
|
{
|
||||||
_physicsDataCache.CacheSetup(stab.Id, setup);
|
_physicsDataCache.CacheSetup(stab.Id, setup);
|
||||||
|
stabLightCount = setup.Lights.Count;
|
||||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||||
if (dumpStab)
|
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)
|
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;
|
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
|
// Stabs inside EnvCells are already in landblock-local coordinates
|
||||||
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
||||||
|
|
|
||||||
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal file
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace AcDream.Core.Meshing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Should a hydrated stab/entity survive when its visual mesh flattens to
|
||||||
|
/// zero drawable parts?
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this exists (#79/#93, 2026-07-09).</b> A dat-authored "light attach
|
||||||
|
/// point" is a Setup whose sole purpose is to carry a <c>Setup.Lights</c>
|
||||||
|
/// 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
|
||||||
|
/// (<c>CObjCell::add_light</c>, populated at <c>CEnvCell::UnPack</c>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class EntityHydrationRules
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ShouldKeepEntity(int meshRefCount, int setupLightCount)
|
||||||
|
=> meshRefCount > 0 || setupLightCount > 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Meshing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue