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>
This commit is contained in:
parent
8d7cad5b14
commit
0bd9b9693b
5 changed files with 648 additions and 2 deletions
224
docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md
Normal file
224
docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md
Normal file
|
|
@ -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<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue