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