fix(rendering): #47 — walk DIDDegrade for retail close-detail meshes

Humanoid bodies (Setup 0x02000001 + heritage variants) rendered visibly
flat / bulky vs retail because we drew the base GfxObj id from Setup /
AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as the entry point to a DIDDegrade
table; close/player rendering uses Degrades[0].Id, which is the
higher-detail mesh that carries bicep / deltoid / shoulder geometry.

ACViewer also has this bug — it was the key signal it isn't acdream-
specific. Both clients drew the LOD-3 base mesh (e.g. 14 verts / 17
polys for Aluvian Male upper arm 0x01000055), missing the close-
detail variant (0x01001795: 32 verts / 60 polys).

Adds GfxObjDegradeResolver that walks the table with safe fallbacks
at every step. Wired in GameWindow after AnimPartChange application
and before texture-change resolution so texture overrides match the
resolved mesh's surfaces. Gated by ACDREAM_RETAIL_CLOSE_DEGRADES=1
and scoped to humanoid setups (34 parts with >=8 null-sentinel
attachment slots) while the fix bakes — the change is harmless on
non-humanoid setups (resolver falls back to base when no degrade
table) but we hold the broader sweep until LOD distance plumbing
lands.

User confirmed visually 2026-05-06: bicep, deltoid, and back-muscle
definition match retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-06 16:46:23 +02:00
parent 8d7cad5b14
commit 0bd9b9693b
5 changed files with 648 additions and 2 deletions

View file

@ -172,6 +172,38 @@ public sealed class GameWindow : IDisposable
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
private static readonly int s_hidePartIndex =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1;
// Issue #47 — opt in to retail's close-detail GfxObj selection on
// humanoid setups. When enabled, every per-part GfxObj id (after
// server AnimPartChanges are applied) is replaced with Degrades[0]
// from its DIDDegrade table when present. See GfxObjDegradeResolver
// for the full retail-decomp citation. Off by default while the fix
// bakes; flip to default-on once we've confirmed no scenery/setup
// regressions.
private static readonly bool s_retailCloseDegrades =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "1", StringComparison.Ordinal);
/// <summary>
/// Issue #47 humanoid-setup detector. Matches Aluvian Male
/// (<c>0x02000001</c>) and the 34-part heritage sibling setups
/// (Aluvian Female, Sho M/F, Gharu M/F, Viamont/Empyrean, etc.)
/// by structure rather than id list: a humanoid setup has exactly
/// 34 parts, and the trailing attachment slots (parts 1733) are
/// the AC null-part sentinel <c>0x010001EC</c>. Non-humanoid
/// 34-part setups (rare) won't have the sentinel pattern.
/// </summary>
private static bool IsIssue47HumanoidSetup(DatReaderWriter.DBObjs.Setup setup)
{
if (setup.Parts.Count != 34) return false;
const uint NullPartGfx = 0x010001ECu;
int nullSlots = 0;
for (int i = 17; i < setup.Parts.Count; i++)
if ((uint)setup.Parts[i] == NullPartGfx) nullSlots++;
// At least half of slots 1733 wired to the null sentinel — enough
// to distinguish humanoids from any future 34-part creature setup.
return nullSlots >= 8;
}
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
@ -2076,6 +2108,41 @@ public sealed class GameWindow : IDisposable
}
}
// Issue #47 — retail's close/player rendering path resolves each
// part's base GfxObj through its DIDDegrade table to the close-
// detail mesh in slot 0. Without this, humanoid arms/torso draw
// the LOW-detail base GfxObj (e.g. 0x01000055, 14 verts / 17
// polys) instead of the close mesh (0x01001795, 32 verts / 60
// polys), losing all bicep/shoulder/back geometry. See
// <see cref="GfxObjDegradeResolver"/> for the named-retail
// citation (CPhysicsPart::LoadGfxObjArray at 0x0050DCF0,
// ::UpdateViewerDistance at 0x0050E030, ::Draw at 0x0050D7A0).
//
// Order matters: the swap happens AFTER AnimPartChanges have
// installed the server's body/clothing/head ids, BEFORE texture
// changes resolve (which match against the resolved mesh's
// surfaces) and BEFORE the GfxObjMesh.Build / texture upload
// path consumes the part list.
if (s_retailCloseDegrades && IsIssue47HumanoidSetup(setup))
{
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
var part = parts[partIdx];
if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj(
_dats, part.GfxObjId,
out uint resolvedId, out _))
continue;
if (resolvedId == part.GfxObjId)
continue;
parts[partIdx] = new AcDream.Core.World.MeshRef(
resolvedId, part.PartTransform);
if (dumpClothing)
Console.WriteLine($" DEGRADE part={partIdx:D2} gfx=0x{part.GfxObjId:X8} -> close=0x{resolvedId:X8}");
}
}
// Build per-part texture overrides. The server sends TextureChanges as
// (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids
// are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed

View file

@ -0,0 +1,144 @@
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;
}
}