diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f9baef23..8f27733a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5800,6 +5800,17 @@ public sealed class GameWindow : IDisposable .DumpEntitySourceIds.Contains(stab.Id); int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to + // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based + // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the + // WorldBuilder editor shows it at the origin. acdream's render path came from + // WB (no distance LOD), so without this skip it draws the marker forever (the + // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs + // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). + if ((stab.Id & 0xFF000000u) == 0x01000000u + && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) + continue; + var meshRefs = new List(); var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((stab.Id & 0xFF000000u) == 0x01000000u) @@ -5833,6 +5844,12 @@ public sealed class GameWindow : IDisposable } foreach (var mr in flat) { + // #136: skip an editor-only marker PART (retail hides it at runtime + // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole + // part GfxObj 0x010028CA is such a marker — skipping it empties + // meshRefs and the whole stab drops below. + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) + continue; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c8d38bf7..c9c2bedd 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver resolvedGfxObj = closeGfxObj; return true; } + + /// + /// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based + /// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible + /// ONLY at distance 0 (Degrades[0].MaxDist == 0) and the table degrades to GfxObj + /// id 0 (= nothing) at real distance. Retail + /// (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw 0x0050D7A0 picks + /// gfxobj[deg_level] by viewer distance) therefore never draws it in the live + /// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame + /// distance-LOD (the resolver above always returns slot 0), so without this check it + /// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39 + /// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, + /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at + /// distance > 0) skip such GfxObjs. + /// + public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) + => IsRuntimeHiddenMarker( + id => dats.Get(id), + id => dats.Get(id), + gfxObjId); + + /// Loader-callback overload of . + public static bool IsRuntimeHiddenMarker( + Func getGfxObj, + Func getDegradeInfo, + uint gfxObjId) + { + var gfxObj = getGfxObj(gfxObjId); + if (gfxObj is null + || !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) + || gfxObj.DIDDegrade == 0) + return false; + + var info = getDegradeInfo(gfxObj.DIDDegrade); + if (info is null || info.Degrades.Count == 0) + return false; + + // Closest slot visible only at distance exactly 0 = editor-only placement marker. + bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f; + if (!firstSlotEditorOnly) + return false; + + // ...and the table degrades to NOTHING (id 0) at real distance — confirms it + // becomes invisible at runtime rather than LOD-swapping to a real mesh. + foreach (var d in info.Degrades) + if ((uint)d.Id == 0u) + return true; + return false; + } } diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs index 54dc9c28..887bd340 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -179,4 +179,89 @@ public class GfxObjDegradeResolverTests Assert.Equal(baseId, resolvedId); Assert.Null(resolvedGfx); } + + // ── #136: editor-only placement marker detection ────────────────────────── + + /// + /// The #136 dungeon "cone": its degrade table's slot 0 is visible ONLY at distance 0 + /// (MaxDist=0) and the table degrades to GfxObj id 0 (= nothing) at real distance. + /// Retail's distance degrade never draws it in the live client; we must skip it. + /// + [Fact] + public void IsRuntimeHiddenMarker_EditorMarkerDegradingToNothing_True() + { + const uint markerGfx = 0x010028CAu; + const uint degradeId = 0x11000118u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = markerGfx, MaxDist = 0f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [markerGfx] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx)); + } + + /// A real LOD object — slot 0 visible out to a real distance (MaxDist>0) — + /// is NOT a marker, even though it degrades further. + [Fact] + public void IsRuntimeHiddenMarker_NormalLodObject_False() + { + const uint baseId = 0x01000055u; + const uint degradeId = 0x110006D0u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = 0x01001795u, MaxDist = 25f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } + + /// No degrade table at all → not a marker. + [Fact] + public void IsRuntimeHiddenMarker_NoDegradeTable_False() + { + const uint baseId = 0x01001212u; + var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), _ => null, baseId)); + } + + /// slot 0 is editor-only (MaxDist=0) but degrades to a REAL mesh (no id-0 + /// entry) — a genuine close-only LOD, not an invisible marker. Do NOT skip. + [Fact] + public void IsRuntimeHiddenMarker_EditorSlotButDegradesToRealMesh_False() + { + const uint baseId = 0x01002000u; + const uint degradeId = 0x11002000u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = baseId, MaxDist = 0f }, + new GfxObjInfo { Id = 0x01002001u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } }