fix(render): hide editor-only placement markers in dungeons — port retail's degrade-to-nothing (#136)

The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat
EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green
MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker:
its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX},
i.e. visible ONLY at distance 0 (the WorldBuilder editor origin) and degraded to
GfxObj id 0 (nothing) at any real distance. retail's distance-based degrade
(CPhysicsPart::UpdateViewerDistance 0x0050E030 -> Draw 0x0050D7A0) therefore never
draws it in the live client.

acdream's render pipeline is extracted from WorldBuilder, which (being an editor)
renders every cell static's base mesh directly and has NO degrade handling at all
(zero DIDDegrade references in references/WorldBuilder) — so acdream inherited the
"show the marker" behavior and drew it forever. It only became visible now because
the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it.

Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern
(HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell
static-object hydration (GameWindow ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via
meshRefs.Count==0). This is the faithful equivalent of retail's runtime degrade for
static geometry (always viewed at distance > 0); real LOD objects (slot0.MaxDist>0)
and degrade-to-real-mesh objects are untouched.

Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via
flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker
surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup +
degrade table confirmed the editor-marker pattern). Verified live via a frame dump:
the red cone + green petals are gone, all real dungeon decorations still render.
4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table /
degrades-to-real-mesh cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 19:03:08 +02:00
parent b4ed8e7908
commit 6f81e2c91d
3 changed files with 151 additions and 0 deletions

View file

@ -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<AcDream.Core.World.MeshRef>();
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<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null)
{

View file

@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver
resolvedGfxObj = closeGfxObj;
return true;
}
/// <summary>
/// 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 (<c>Degrades[0].MaxDist == 0</c>) and the table degrades to GfxObj
/// id 0 (= nothing) at real distance. Retail
/// (<c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030 → <c>Draw</c> 0x0050D7A0 picks
/// <c>gfxobj[deg_level]</c> 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 &gt; 0) skip such GfxObjs.
/// </summary>
public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId)
=> IsRuntimeHiddenMarker(
id => dats.Get<GfxObj>(id),
id => dats.Get<GfxObjDegradeInfo>(id),
gfxObjId);
/// <summary>Loader-callback overload of <see cref="IsRuntimeHiddenMarker(DatCollection, uint)"/>.</summary>
public static bool IsRuntimeHiddenMarker(
Func<uint, GfxObj?> getGfxObj,
Func<uint, GfxObjDegradeInfo?> 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;
}
}