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>
267 lines
9.5 KiB
C#
267 lines
9.5 KiB
C#
using AcDream.Core.Meshing;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.Core.Tests.Meshing;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="GfxObjDegradeResolver"/>. The resolver is
|
|
/// the Issue #47 fix: route a base GfxObj id to its retail close-detail
|
|
/// mesh via the DIDDegrade table's slot 0. Tests use the callback
|
|
/// overload so we can stand up tiny in-memory fixtures without dragging
|
|
/// in a real DatCollection.
|
|
/// </summary>
|
|
public class GfxObjDegradeResolverTests
|
|
{
|
|
/// <summary>
|
|
/// When the base GfxObj has no degrade table (HasDIDDegrade flag
|
|
/// clear), the resolver returns the base id unchanged.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NoDegradeTable_ReturnsBaseMesh()
|
|
{
|
|
const uint baseId = 0x01001212u;
|
|
var baseGfx = new GfxObj { Flags = 0, DIDDegrade = 0 };
|
|
var gfxObjs = new Dictionary<uint, GfxObj> { [baseId] = baseGfx };
|
|
|
|
bool ok = GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
|
id => gfxObjs.GetValueOrDefault(id),
|
|
_ => null,
|
|
baseId,
|
|
out uint resolvedId,
|
|
out var resolvedGfx);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(baseId, resolvedId);
|
|
Assert.Same(baseGfx, resolvedGfx);
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the base GfxObj has a populated DIDDegrade table, the
|
|
/// resolver returns Degrades[0].Id and its loaded GfxObj — the
|
|
/// close-detail mesh retail draws for nearby objects.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ValidDegradeTable_ReturnsSlotZero()
|
|
{
|
|
const uint baseId = 0x01000055u; // low-detail Aluvian Male upper arm
|
|
const uint degradeInfoId = 0x110006D0u;
|
|
const uint closeId = 0x01001795u; // retail close-detail variant
|
|
|
|
var baseGfx = new GfxObj
|
|
{
|
|
Flags = GfxObjFlags.HasDIDDegrade,
|
|
DIDDegrade = degradeInfoId,
|
|
};
|
|
var closeGfx = new GfxObj { Flags = 0 };
|
|
var degradeInfo = new GfxObjDegradeInfo
|
|
{
|
|
Degrades = { new GfxObjInfo { Id = closeId } },
|
|
};
|
|
|
|
var gfxObjs = new Dictionary<uint, GfxObj>
|
|
{
|
|
[baseId] = baseGfx,
|
|
[closeId] = closeGfx,
|
|
};
|
|
var degradeInfos = new Dictionary<uint, GfxObjDegradeInfo>
|
|
{
|
|
[degradeInfoId] = degradeInfo,
|
|
};
|
|
|
|
bool ok = GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
|
id => gfxObjs.GetValueOrDefault(id),
|
|
id => degradeInfos.GetValueOrDefault(id),
|
|
baseId,
|
|
out uint resolvedId,
|
|
out var resolvedGfx);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(closeId, resolvedId);
|
|
Assert.Same(closeGfx, resolvedGfx);
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the degrade table references a GfxObj that isn't present in
|
|
/// the dat (corrupt / partial dat), the resolver falls back to the
|
|
/// base mesh rather than returning null. Better to render the
|
|
/// low-detail variant than nothing at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissingSlotZeroMesh_FallsBackToBase()
|
|
{
|
|
const uint baseId = 0x01000055u;
|
|
const uint degradeInfoId = 0x110006D0u;
|
|
const uint missingCloseId = 0xDEADBEEFu;
|
|
|
|
var baseGfx = new GfxObj
|
|
{
|
|
Flags = GfxObjFlags.HasDIDDegrade,
|
|
DIDDegrade = degradeInfoId,
|
|
};
|
|
var degradeInfo = new GfxObjDegradeInfo
|
|
{
|
|
Degrades = { new GfxObjInfo { Id = missingCloseId } },
|
|
};
|
|
var gfxObjs = new Dictionary<uint, GfxObj> { [baseId] = baseGfx };
|
|
var degradeInfos = new Dictionary<uint, GfxObjDegradeInfo>
|
|
{
|
|
[degradeInfoId] = degradeInfo,
|
|
};
|
|
|
|
bool ok = GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
|
id => gfxObjs.GetValueOrDefault(id),
|
|
id => degradeInfos.GetValueOrDefault(id),
|
|
baseId,
|
|
out uint resolvedId,
|
|
out var resolvedGfx);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(baseId, resolvedId);
|
|
Assert.Same(baseGfx, resolvedGfx);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Empty Degrades list (table present but no entries) falls back
|
|
/// to base. Mirrors retail's "no LOD entries → just draw the base"
|
|
/// behavior.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EmptyDegradesList_FallsBackToBase()
|
|
{
|
|
const uint baseId = 0x01000055u;
|
|
const uint degradeInfoId = 0x110006D0u;
|
|
|
|
var baseGfx = new GfxObj
|
|
{
|
|
Flags = GfxObjFlags.HasDIDDegrade,
|
|
DIDDegrade = degradeInfoId,
|
|
};
|
|
var degradeInfo = new GfxObjDegradeInfo(); // empty Degrades
|
|
|
|
var gfxObjs = new Dictionary<uint, GfxObj> { [baseId] = baseGfx };
|
|
var degradeInfos = new Dictionary<uint, GfxObjDegradeInfo>
|
|
{
|
|
[degradeInfoId] = degradeInfo,
|
|
};
|
|
|
|
bool ok = GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
|
id => gfxObjs.GetValueOrDefault(id),
|
|
id => degradeInfos.GetValueOrDefault(id),
|
|
baseId,
|
|
out uint resolvedId,
|
|
out var resolvedGfx);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(baseId, resolvedId);
|
|
Assert.Same(baseGfx, resolvedGfx);
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the base GfxObj itself is missing from the dat, the
|
|
/// resolver returns false so the caller can drop the part rather
|
|
/// than trying to render a null mesh.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissingBaseGfxObj_ReturnsFalse()
|
|
{
|
|
const uint baseId = 0xDEADBEEFu;
|
|
|
|
bool ok = GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
|
_ => null,
|
|
_ => null,
|
|
baseId,
|
|
out uint resolvedId,
|
|
out var resolvedGfx);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(baseId, resolvedId);
|
|
Assert.Null(resolvedGfx);
|
|
}
|
|
|
|
// ── #136: editor-only placement marker detection ──────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<uint, GfxObj> { [markerGfx] = gfx };
|
|
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
|
|
|
|
Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
|
|
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx));
|
|
}
|
|
|
|
/// <summary>A real LOD object — slot 0 visible out to a real distance (MaxDist>0) —
|
|
/// is NOT a marker, even though it degrades further.</summary>
|
|
[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<uint, GfxObj> { [baseId] = gfx };
|
|
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
|
|
|
|
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
|
|
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId));
|
|
}
|
|
|
|
/// <summary>No degrade table at all → not a marker.</summary>
|
|
[Fact]
|
|
public void IsRuntimeHiddenMarker_NoDegradeTable_False()
|
|
{
|
|
const uint baseId = 0x01001212u;
|
|
var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 };
|
|
var gfxObjs = new Dictionary<uint, GfxObj> { [baseId] = gfx };
|
|
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
|
|
id => gfxObjs.GetValueOrDefault(id), _ => null, baseId));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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<uint, GfxObj> { [baseId] = gfx };
|
|
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
|
|
|
|
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
|
|
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId));
|
|
}
|
|
}
|