# 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.