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:
parent
8d7cad5b14
commit
0bd9b9693b
5 changed files with 648 additions and 2 deletions
182
tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs
Normal file
182
tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue