diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 98e4425..3b790ae 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md b/docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md new file mode 100644 index 0000000..2c22a4e --- /dev/null +++ b/docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md @@ -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`) +- 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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 726f829..21e72ea 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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); + + /// + /// Issue #47 humanoid-setup detector. Matches Aluvian Male + /// (0x02000001) 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 0x010001EC. Non-humanoid + /// 34-part setups (rare) won't have the sentinel pattern. + /// + 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 _activeSkyPes = new(); private readonly HashSet _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 + // 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 diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs new file mode 100644 index 0000000..c8d38bf --- /dev/null +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -0,0 +1,144 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Meshing; + +/// +/// Resolve a base GfxObj id to its retail "close-detail" mesh by walking +/// the DIDDegrade table to Degrades[0]. +/// +/// +/// Why this exists (Issue #47). 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 +/// HasDIDDegrade flag is set, DIDDegrade points at a +/// , and at +/// Degrades[0] 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. +/// +/// +/// +/// Concrete example. The Aluvian Male upper-arm GfxObj +/// 0x01000055 is a 14-vertex / 17-poly low-detail stub. Its +/// degrade table 0x110006D0 points at 0x01001795, 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 0x01000056 → 0x0100178F and matching +/// heritage variants (0x010004BF → 0x010017A8, +/// 0x010004BD → 0x010017A7, 0x010004B7 → 0x0100179A). +/// +/// +/// +/// Retail flow (named-retail decomp). +/// +/// +/// acclient!CPhysicsPart::LoadGfxObjArray at 0x0050DCF0 +/// loads the base GfxObj solely to discover DIDDegrade; if +/// a exists, retail loads each entry +/// in Degrades into the part's render array. +/// +/// +/// acclient!CPhysicsPart::UpdateViewerDistance at +/// 0x0050E030 picks deg_level per part by distance. +/// For close / player rendering deg_level == 0. +/// +/// +/// acclient!CPhysicsPart::Draw at 0x0050D7A0 +/// draws gfxobj[deg_level]. +/// +/// +/// +/// +/// +/// 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. +/// +/// +public static class GfxObjDegradeResolver +{ + /// + /// DatCollection-backed convenience overload. Production callers use + /// this; tests use the callback overload below for easy fakes. + /// + public static bool TryResolveCloseGfxObj( + DatCollection dats, + uint gfxObjId, + out uint resolvedId, + out GfxObj? resolvedGfxObj) + => TryResolveCloseGfxObj( + id => dats.Get(id), + id => dats.Get(id), + gfxObjId, + out resolvedId, + out resolvedGfxObj); + + /// + /// 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. + /// + /// + /// Lookup for a GfxObj by id. May return null when not found. + /// + /// + /// Lookup for a GfxObjDegradeInfo by id. May return null. + /// + /// Base GfxObj id (post-AnimPartChange). + /// + /// The id to actually render. Same as + /// when no degrade table exists; Degrades[0].Id when it does. + /// + /// + /// The loaded GfxObj for , cached so + /// callers don't have to re-read. + /// + /// + /// true if a usable GfxObj was resolved (either base or + /// degrade slot 0 loaded). false only when the base GfxObj + /// itself was missing — caller should drop this part. + /// + public static bool TryResolveCloseGfxObj( + Func getGfxObj, + Func 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; + } +} diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs new file mode 100644 index 0000000..54dc9c2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -0,0 +1,182 @@ +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +/// +/// Unit tests for . 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. +/// +public class GfxObjDegradeResolverTests +{ + /// + /// When the base GfxObj has no degrade table (HasDIDDegrade flag + /// clear), the resolver returns the base id unchanged. + /// + [Fact] + public void NoDegradeTable_ReturnsBaseMesh() + { + const uint baseId = 0x01001212u; + var baseGfx = new GfxObj { Flags = 0, DIDDegrade = 0 }; + var gfxObjs = new Dictionary { [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); + } + + /// + /// 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. + /// + [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 + { + [baseId] = baseGfx, + [closeId] = closeGfx, + }; + var degradeInfos = new Dictionary + { + [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); + } + + /// + /// 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. + /// + [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 { [baseId] = baseGfx }; + var degradeInfos = new Dictionary + { + [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); + } + + /// + /// Empty Degrades list (table present but no entries) falls back + /// to base. Mirrors retail's "no LOD entries → just draw the base" + /// behavior. + /// + [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 { [baseId] = baseGfx }; + var degradeInfos = new Dictionary + { + [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); + } + + /// + /// 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. + /// + [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); + } +}