acdream/docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md
Erik 0bd9b9693b 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>
2026-05-06 16:46:23 +02:00

224 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 1733), 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.