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>
193 lines
7.7 KiB
C#
193 lines
7.7 KiB
C#
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.Core.Meshing;
|
|
|
|
/// <summary>
|
|
/// Resolve a base GfxObj id to its retail "close-detail" mesh by walking
|
|
/// the <c>DIDDegrade</c> table to <c>Degrades[0]</c>.
|
|
///
|
|
/// <para>
|
|
/// <b>Why this exists (Issue #47).</b> Many AC GfxObjs — most notably
|
|
/// humanoid body parts — store the LOW-detail mesh as the GfxObj that
|
|
/// the Setup or AnimPartChange references. The high-detail mesh used
|
|
/// for close/player rendering is reached indirectly: the base GfxObj's
|
|
/// <c>HasDIDDegrade</c> flag is set, <c>DIDDegrade</c> points at a
|
|
/// <see cref="GfxObjDegradeInfo"/>, and <see cref="GfxObjInfo.Id"/> at
|
|
/// <c>Degrades[0]</c> is the close-detail variant. Drawing the base
|
|
/// GfxObj id directly produces the LOD-3 mesh — visibly bulky and
|
|
/// detail-less — which is exactly what acdream and ACViewer were both
|
|
/// rendering for humanoid body parts before this fix.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Concrete example.</b> The Aluvian Male upper-arm GfxObj
|
|
/// <c>0x01000055</c> is a 14-vertex / 17-poly low-detail stub. Its
|
|
/// degrade table <c>0x110006D0</c> points at <c>0x01001795</c>, the
|
|
/// 32-vertex / 60-poly close-detail mesh that carries the bicep /
|
|
/// deltoid / shoulder geometry retail draws on the player. Same story
|
|
/// for the lower arm <c>0x01000056 → 0x0100178F</c> and matching
|
|
/// heritage variants (<c>0x010004BF → 0x010017A8</c>,
|
|
/// <c>0x010004BD → 0x010017A7</c>, <c>0x010004B7 → 0x0100179A</c>).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Retail flow (named-retail decomp).</b>
|
|
/// <list type="bullet">
|
|
/// <item>
|
|
/// <c>acclient!CPhysicsPart::LoadGfxObjArray</c> at <c>0x0050DCF0</c>
|
|
/// loads the base GfxObj solely to discover <c>DIDDegrade</c>; if
|
|
/// a <see cref="GfxObjDegradeInfo"/> exists, retail loads each entry
|
|
/// in <c>Degrades</c> into the part's render array.
|
|
/// </item>
|
|
/// <item>
|
|
/// <c>acclient!CPhysicsPart::UpdateViewerDistance</c> at
|
|
/// <c>0x0050E030</c> picks <c>deg_level</c> per part by distance.
|
|
/// For close / player rendering <c>deg_level == 0</c>.
|
|
/// </item>
|
|
/// <item>
|
|
/// <c>acclient!CPhysicsPart::Draw</c> at <c>0x0050D7A0</c>
|
|
/// draws <c>gfxobj[deg_level]</c>.
|
|
/// </item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// We don't yet have distance-based LOD plumbing, so this resolver
|
|
/// always returns slot 0 (the close-detail mesh). That's correct for
|
|
/// player + nearby NPC rendering; far-distance LOD is a future concern.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class GfxObjDegradeResolver
|
|
{
|
|
/// <summary>
|
|
/// DatCollection-backed convenience overload. Production callers use
|
|
/// this; tests use the callback overload below for easy fakes.
|
|
/// </summary>
|
|
public static bool TryResolveCloseGfxObj(
|
|
DatCollection dats,
|
|
uint gfxObjId,
|
|
out uint resolvedId,
|
|
out GfxObj? resolvedGfxObj)
|
|
=> TryResolveCloseGfxObj(
|
|
id => dats.Get<GfxObj>(id),
|
|
id => dats.Get<GfxObjDegradeInfo>(id),
|
|
gfxObjId,
|
|
out resolvedId,
|
|
out resolvedGfxObj);
|
|
|
|
/// <summary>
|
|
/// Loader-callback overload. Returns the close-detail GfxObj id and
|
|
/// loaded object when a degrade table is present, otherwise the
|
|
/// base id and base GfxObj.
|
|
/// </summary>
|
|
/// <param name="getGfxObj">
|
|
/// Lookup for a GfxObj by id. May return null when not found.
|
|
/// </param>
|
|
/// <param name="getDegradeInfo">
|
|
/// Lookup for a GfxObjDegradeInfo by id. May return null.
|
|
/// </param>
|
|
/// <param name="gfxObjId">Base GfxObj id (post-AnimPartChange).</param>
|
|
/// <param name="resolvedId">
|
|
/// The id to actually render. Same as <paramref name="gfxObjId"/>
|
|
/// when no degrade table exists; <c>Degrades[0].Id</c> when it does.
|
|
/// </param>
|
|
/// <param name="resolvedGfxObj">
|
|
/// The loaded GfxObj for <paramref name="resolvedId"/>, cached so
|
|
/// callers don't have to re-read.
|
|
/// </param>
|
|
/// <returns>
|
|
/// <c>true</c> if a usable GfxObj was resolved (either base or
|
|
/// degrade slot 0 loaded). <c>false</c> only when the base GfxObj
|
|
/// itself was missing — caller should drop this part.
|
|
/// </returns>
|
|
public static bool TryResolveCloseGfxObj(
|
|
Func<uint, GfxObj?> getGfxObj,
|
|
Func<uint, GfxObjDegradeInfo?> getDegradeInfo,
|
|
uint gfxObjId,
|
|
out uint resolvedId,
|
|
out GfxObj? resolvedGfxObj)
|
|
{
|
|
var gfxObj = getGfxObj(gfxObjId);
|
|
if (gfxObj is null)
|
|
{
|
|
resolvedId = gfxObjId;
|
|
resolvedGfxObj = null;
|
|
return false;
|
|
}
|
|
|
|
// Default: base mesh stays selected unless the degrade table
|
|
// resolves cleanly. Every fallback below leaves these set.
|
|
resolvedId = gfxObjId;
|
|
resolvedGfxObj = gfxObj;
|
|
|
|
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfxObj.DIDDegrade == 0)
|
|
return true;
|
|
|
|
var degradeInfo = getDegradeInfo(gfxObj.DIDDegrade);
|
|
if (degradeInfo is null || degradeInfo.Degrades.Count == 0)
|
|
return true;
|
|
|
|
uint closeId = (uint)degradeInfo.Degrades[0].Id;
|
|
if (closeId == 0)
|
|
return true;
|
|
|
|
var closeGfxObj = getGfxObj(closeId);
|
|
if (closeGfxObj is null)
|
|
return true;
|
|
|
|
resolvedId = closeId;
|
|
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 > 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;
|
|
}
|
|
}
|