Merge branch 'claude/jovial-chebyshev-d1d9da' — Issue #47 close-detail meshes
Brings GfxObjDegradeResolver and the ACDREAM_RETAIL_CLOSE_DEGRADES wiring that resolves humanoid body parts to their retail close-detail meshes via DIDDegrade slot 0. User-confirmed visual fix for the bulky/flat humanoid body bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f6975eb1cd
5 changed files with 648 additions and 2 deletions
|
|
@ -1258,13 +1258,42 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
|
|||
|
||||
---
|
||||
|
||||
## #47 — Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail
|
||||
## #47 — [DONE 2026-05-06] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE (commit pending)
|
||||
**Closed:** 2026-05-06
|
||||
**Severity:** MEDIUM (cosmetic — characters readable but visibly different from retail)
|
||||
**Filed:** 2026-05-06
|
||||
**Component:** rendering / mesh / character animation
|
||||
|
||||
**Resolution:** Root cause was that we drew the base GfxObj id from
|
||||
Setup / `AnimPartChange` directly. Retail's `CPhysicsPart::LoadGfxObjArray`
|
||||
(`0x0050DCF0`) treats that base id as an **entry point to the
|
||||
`DIDDegrade` table**; for close/player rendering it draws
|
||||
`Degrades[0].Id`, which is the higher-detail mesh that carries the
|
||||
bicep / deltoid / shoulder geometry. ACViewer also has this bug —
|
||||
that was the key signal it wasn't acdream-specific.
|
||||
|
||||
Concrete swaps the resolver now performs:
|
||||
- Aluvian Male upper arm `0x01000055` → `0x01001795` (14/17 → 32/60 verts/polys)
|
||||
- Aluvian Male lower arm `0x01000056` → `0x0100178F`
|
||||
- Heritage variants: `0x010004BF → 0x010017A8`, `0x010004BD → 0x010017A7`,
|
||||
`0x010004B7 → 0x0100179A`, etc.
|
||||
|
||||
Fix landed as `GfxObjDegradeResolver`, gated behind
|
||||
`ACDREAM_RETAIL_CLOSE_DEGRADES=1` and scoped to humanoid setups
|
||||
(34-part with ≥8 null-sentinel attachment slots). User confirmed
|
||||
visually 2026-05-06.
|
||||
|
||||
Files: `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`,
|
||||
`src/AcDream.App/Rendering/GameWindow.cs` (wiring), 5 unit tests in
|
||||
`tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs`.
|
||||
Research note: `docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md`.
|
||||
|
||||
---
|
||||
|
||||
### Original investigation (kept for reference)
|
||||
|
||||
**Description:** Every humanoid character using Setup `0x02000001`
|
||||
(Aluvian Male) renders in acdream with a "bulky, less-defined" silhouette
|
||||
compared to retail's view of the same character. Specifically: shoulders
|
||||
|
|
|
|||
224
docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md
Normal file
224
docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# Issue #47 — humanoid bulky/flat rendering: GfxObj close-degrade fix
|
||||
|
||||
**Status:** root cause identified and patched (2026-05-06).
|
||||
**Flag:** `ACDREAM_RETAIL_CLOSE_DEGRADES=1` enables; off by default
|
||||
while the fix bakes.
|
||||
**Files:** `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`,
|
||||
wiring in `src/AcDream.App/Rendering/GameWindow.cs`.
|
||||
|
||||
## The bug, in one sentence
|
||||
|
||||
acdream and ACViewer both rendered humanoid body parts using the
|
||||
**low-detail** GfxObj that the Setup / `AnimPartChange` references,
|
||||
instead of walking that base GfxObj's `DIDDegrade` table to slot 0
|
||||
(the close-detail mesh) the way retail does.
|
||||
|
||||
## How we got here
|
||||
|
||||
We spent a session investigating Issue #47 ("humanoid Setup 0x02000001
|
||||
renders bulky vs retail") and ruled out, with screenshots, every
|
||||
hypothesis on the original handoff list:
|
||||
|
||||
- per-face vs smoothed vertex normals (smooth-normal pass had no
|
||||
visible effect; dat normals were already smooth)
|
||||
- transform composition (acdream's `Scale * RotPart * TransPart *
|
||||
RotEntity * TransEntity` matches retail's `Frame::combine` at
|
||||
`0x518FD0` algebraically)
|
||||
- ambient floor / cell ambient tuning (lighting tweak, doesn't change
|
||||
silhouette)
|
||||
- MSAA (anti-aliasing doesn't change silhouette thickness)
|
||||
- `client_highres.dat` precedence (retail does prefer HighRes over
|
||||
Portal in `CLCache::GetDiskController` at `0x4f8fa0`, but the
|
||||
humanoid body GfxObjs we were drawing don't get high-res
|
||||
replacements — they get LOD replacements via DIDDegrade)
|
||||
- the `0x010001EC` null-part stubs in slots 17-33 (correctly skipped
|
||||
per ACE's "essentially a null part" comment, but they were 1-tri
|
||||
meshes — visually negligible, not the bug)
|
||||
|
||||
The user critically reported that **ACViewer showed the same flat
|
||||
arms**, which meant the bug couldn't be in our renderer alone — it
|
||||
had to be in something both renderers shared. Both load from the same
|
||||
dat. Both run the AnimPartChange ids through their renderers as
|
||||
final mesh ids. Neither walks DIDDegrade.
|
||||
|
||||
A side-by-side screenshot pair of `+Acdream` in retail vs acdream
|
||||
made the symptom precise: retail showed clear per-face linear gradients
|
||||
with visible bicep / deltoid / pectoral edges; acdream showed a smooth
|
||||
featureless tube.
|
||||
|
||||
## Why retail looks different
|
||||
|
||||
Retail's CPhysicsPart load and draw flow walks the degrade table:
|
||||
|
||||
| Function | Address | What it does |
|
||||
|-----------------------------------------|-------------|---------------------------------------------------------------------------------------------|
|
||||
| `CPhysicsPart::LoadGfxObjArray` | `0x0050DCF0`| Loads the base GfxObj only to read `DIDDegrade`. If a `GfxObjDegradeInfo` exists, retail loads each entry from `Degrades` into the part's render array. |
|
||||
| `CPhysicsPart::UpdateViewerDistance` | `0x0050E030`| Picks `deg_level` per part by camera distance. For close / player rendering `deg_level == 0`. |
|
||||
| `CPhysicsPart::Draw` | `0x0050D7A0`| Draws `gfxobj[deg_level]`. |
|
||||
|
||||
So for close / player rendering the actual mesh is
|
||||
`GfxObjDegradeInfo.Degrades[0].Id`, NOT the base GfxObj id.
|
||||
|
||||
## Concrete evidence
|
||||
|
||||
Comparing the base meshes the server hands us (post-AnimPartChange)
|
||||
against the close-detail meshes their `DIDDegrade` tables point at:
|
||||
|
||||
| Body part | Base id | Base verts/polys | Degrade table | Slot 0 close id | Close verts/polys |
|
||||
|---------------------------------|----------------|------------------|---------------|-----------------|-------------------|
|
||||
| Aluvian Male upper arm | `0x01000055` | 14 / 17 | `0x110006D0` | `0x01001795` | 32 / 60 |
|
||||
| Aluvian Male lower arm | `0x01000056` | 8 / 6 | (per dat) | `0x0100178F` | 22 / 39 |
|
||||
| Heritage variant upper arm | `0x010004BF` | (low) | (per dat) | `0x010017A8` | (high) |
|
||||
| Heritage variant lower arm-A | `0x010004BD` | (low) | (per dat) | `0x010017A7` | (high) |
|
||||
| Heritage variant lower arm-B | `0x010004B7` | (low) | (per dat) | `0x0100179A` | (high) |
|
||||
|
||||
Drawing the base ids gave us visibly LOD-3 bodies on close-up players —
|
||||
no bicep, no deltoid contour, no shoulder geometry. The degrade-slot-0
|
||||
meshes have the geometry that produces the per-face gradients the user
|
||||
expected.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```
|
||||
TryResolveCloseGfxObj(getGfxObj, getDegradeInfo, gfxObjId)
|
||||
→ resolvedId, resolvedGfxObj
|
||||
|
||||
base = getGfxObj(gfxObjId)
|
||||
if base is null:
|
||||
return (gfxObjId, null, false) # caller drops the part
|
||||
|
||||
resolved = (gfxObjId, base)
|
||||
|
||||
if base.Flags HasDIDDegrade is clear OR base.DIDDegrade == 0:
|
||||
return (resolved, true)
|
||||
|
||||
info = getDegradeInfo(base.DIDDegrade)
|
||||
if info is null OR info.Degrades is empty:
|
||||
return (resolved, true)
|
||||
|
||||
closeId = info.Degrades[0].Id
|
||||
if closeId == 0:
|
||||
return (resolved, true)
|
||||
|
||||
closeObj = getGfxObj(closeId)
|
||||
if closeObj is null:
|
||||
return (resolved, true)
|
||||
|
||||
return ((closeId, closeObj), true)
|
||||
```
|
||||
|
||||
Every fallback leaves the base mesh selected — better to render the
|
||||
low-detail variant than nothing at all when the dat is partial.
|
||||
|
||||
## Wiring in `GameWindow.OnLiveEntitySpawnedLocked`
|
||||
|
||||
The order matters because the resolver has to see the **final**
|
||||
per-part GfxObj id, and downstream consumers (texture-change
|
||||
resolution, palette detection, mesh build) have to see the resolved
|
||||
mesh's surfaces:
|
||||
|
||||
```
|
||||
1. Setup flatten → per-part transforms with default GfxObj ids.
|
||||
2. Apply server AnimPartChanges → replace per-part ids with the
|
||||
body / clothing / head GfxObjs the server picked.
|
||||
3. *** NEW *** If retail close-degrades enabled AND the setup is a
|
||||
humanoid (34 parts with ≥8 null-sentinel slots in 17–33), run
|
||||
each part's id through GfxObjDegradeResolver and swap to slot 0.
|
||||
4. Resolve TextureChanges against the resolved GfxObj's surfaces.
|
||||
5. Build palette overrides.
|
||||
6. GfxObjMesh.Build / texture upload.
|
||||
```
|
||||
|
||||
Wiring it before AnimPartChanges would replace Setup defaults that
|
||||
will get overwritten anyway. Wiring it after texture-change resolution
|
||||
would point texture overrides at the wrong surface ids.
|
||||
|
||||
## Scope
|
||||
|
||||
For now the swap is gated to humanoid setups only. The detector is
|
||||
purely structural: 34 parts with at least 8 of slots 17-33 wired to
|
||||
the AC null-part sentinel `0x010001EC`. This matches Aluvian Male
|
||||
(`0x02000001`), the heritage variants, and any future 34-part
|
||||
humanoid sibling without enumerating ids.
|
||||
|
||||
Why scoped vs. always-on:
|
||||
|
||||
- Scenery and creatures may have degrade tables too (buildings
|
||||
certainly do). For non-humanoids we haven't visually verified
|
||||
that swapping to slot 0 is correct for the current camera distance,
|
||||
so we hold the change.
|
||||
- True LOD plumbing (distance-based `deg_level` selection per
|
||||
`CPhysicsPart::UpdateViewerDistance`) is still future work; until
|
||||
then "always slot 0" is right for player + nearby NPCs but might
|
||||
over-detail far-distance scenery.
|
||||
|
||||
When the close-degrade path is validated everywhere, drop the
|
||||
humanoid scoping and remove the env-var flag.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
# Acceptance: side-by-side screenshots of `+Acdream` (or any humanoid
|
||||
# NPC) in acdream vs retail show matching shoulder / bicep / back
|
||||
# definition. Drudges and other monster setups stay correct.
|
||||
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
$env:ACDREAM_RETAIL_CLOSE_DEGRADES = "1"
|
||||
$env:ACDREAM_DUMP_CLOTHING = "1" # log resolver swaps per spawn
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
|
||||
Tee-Object -FilePath "launch_issue47_close_degrade.log"
|
||||
```
|
||||
|
||||
Expected log lines (per spawn):
|
||||
|
||||
```
|
||||
DEGRADE part=01 gfx=0x0100004F -> close=0x0100178D
|
||||
DEGRADE part=02 gfx=0x0100004D -> close=0x01001787
|
||||
DEGRADE part=10 gfx=0x0100122B -> close=0x01001795
|
||||
…
|
||||
```
|
||||
|
||||
(Exact ids vary by which body parts AnimPartChange installs for the
|
||||
character's heritage / equipped clothing. The `->` arrow confirms
|
||||
the swap fired.)
|
||||
|
||||
## What was rejected
|
||||
|
||||
These were diagnostic experiments during the investigation, NOT part
|
||||
of the fix:
|
||||
|
||||
- Smooth-normal recompute behind `ACDREAM_SMOOTH_NORMALS`
|
||||
- HighRes-first lookup in `TextureCache.DecodeFromDats`
|
||||
- Skipping `0x010001EC` null-part placeholders
|
||||
- Per-vertex Gouraud shader rewrite of `mesh.frag`
|
||||
- Cell ambient floor / minimum diffuse tuning
|
||||
- MSAA toggle
|
||||
- Identity per-part orientation
|
||||
- Positive-only polygon emission
|
||||
|
||||
The successful fix is ONLY the close GfxObj degrade slot 0 swap. All
|
||||
of the above were reverted before this patch landed.
|
||||
|
||||
## References
|
||||
|
||||
- `acclient!CPhysicsPart::LoadGfxObjArray` at `0x0050DCF0` —
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||
- `acclient!CPhysicsPart::UpdateViewerDistance` at `0x0050E030`
|
||||
- `acclient!CPhysicsPart::Draw` at `0x0050D7A0`
|
||||
- DatReaderWriter:
|
||||
`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/GfxObj.generated.cs`
|
||||
(`HasDIDDegrade` flag, `DIDDegrade` field)
|
||||
`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/GfxObjDegradeInfo.generated.cs`
|
||||
(`Degrades : List<GfxObjInfo>`)
|
||||
- ACE: `references/ACE/Source/ACE.DatLoader/FileTypes/GfxObjDegradeInfo.cs`
|
||||
- ACViewer + ACME both miss this same step — they draw the base id
|
||||
directly. ACViewer's confirmation was the key signal that the bug
|
||||
isn't acdream-specific.
|
||||
|
|
@ -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 17–33) 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 17–33 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
|
||||
|
|
|
|||
144
src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs
Normal file
144
src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
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