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

9.5 KiB
Raw Permalink Blame History

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

# 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 0x0050DCF0docs/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.