From 82aa2ba1d96a4c20f79ee096fe880352743361c9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 18:21:13 +0200 Subject: [PATCH] fix(core): SetupMesh prefers Placement.Resting over Default (Phase 5d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nullified Statue of a Drudge renders correctly in scale + color + texture after Phases 5a/b/c, but the user reported the figure on top looks like the wrong drudge model — specifically the pose is wrong. acdream shows a hunched aggressive crouch with arms forward, retail shows an upright statue stance with arms at sides. Same drudge mesh, different pose. Diagnosis from a targeted statue dump: [STATUE] objScale=3.500 [STATUE] base Setup 0x020007DD has 17 parts (full drudge body rig) [STATUE] animPart index=1 newModel=0x01001B91 ← NO-OP, same as default [STATUE] placementFrames count=1 The animPart change is a no-op (replaces part 1 with the id it already has). The Setup is the standard drudge body. So the difference HAS to come from the per-part placement frame. With only 1 placement frame, there's exactly one pose to use — and our SetupMesh.Flatten only checks Placement.Default. Fix found by reading ACViewer's Physics/PartArray.cs::CreateMesh: public static PartArray CreateMesh(PhysicsObj owner, uint setupDID) { var mesh = new PartArray(); ... if (!mesh.SetMeshID(setupDID)) return null; mesh.SetPlacementFrame(0x65); // ← always Resting after create return mesh; } 0x65 = 101 = Placement.Resting. ACViewer puts EVERY mesh into the Resting pose immediately after creation, regardless of object type. For drudges/characters/creatures: - Default = aggressive battle crouch (what we render) - Resting = upright idle pose (what retail's statue actually shows) The statue's single placement frame is keyed by Resting, so our "only check Default" code returned no frame and the parts ended up at Setup-root with identity orientation — which happened to look like a clawing-forward pose because each part's local mesh starts in roughly that shape. Fix: SetupMesh.Flatten now tries Resting first and falls back to Default. Static scenery setups (which only define Default) are unaffected; creatures and characters now render in their proper idle pose. One-line conceptual change with effects on every multi-part live entity in the world. The reference-priority rule in CLAUDE.md saved me here: I'd already chased this through ACE.Server and DatReaderWriter looking for a parent- hierarchy walking algorithm before checking ACViewer's Physics/PartArray. The pose fix lives in ACViewer's renderer, exactly where I'd expect "the canonical client-side visual pipeline" per the rule's wording. Tests: 77 core + 83 net = 160, all green. Existing scenery SetupMesh tests still pass because their setups only define Default. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 26 ++++++++++++++++++++++++- src/AcDream.Core/Meshing/SetupMesh.cs | 16 ++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a49f78d..09f7652 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -539,8 +539,9 @@ public sealed class GameWindow : IDisposable // is cheap and gives us exactly one entity's worth of log regardless // of arrival order. bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); - if (isStatue && (texChangeCount > 0 || subPalCount > 0 || animPartCount > 0)) + if (isStatue) { + Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}"); if (spawn.TextureChanges is { } tcs) { foreach (var tc in tcs) @@ -557,6 +558,29 @@ public sealed class GameWindow : IDisposable foreach (var apc in apcs) Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}"); } + + // Dump the BASE setup's part list before AnimPartChanges, so we can + // see how many parts the statue's Setup actually has + what their + // default GfxObjs are. The retail statue may have additional parts + // (e.g. a pedestal sub-mesh) that our setup loader is dropping or + // we're rendering with wrong default GfxObjs. + if (spawn.SetupTableId is { } sid && _dats is not null) + { + var baseSetup = _dats.Get(sid); + if (baseSetup is not null) + { + Console.WriteLine($"live: [STATUE] base Setup 0x{sid:X8} has {baseSetup.Parts.Count} parts:"); + for (int pi = 0; pi < baseSetup.Parts.Count; pi++) + { + uint partGfxId = (uint)baseSetup.Parts[pi]; + var pgfx = _dats.Get(partGfxId); + int subCount = pgfx?.Surfaces.Count ?? -1; + Console.WriteLine($"live: [STATUE] part[{pi}] gfxObj=0x{partGfxId:X8} surfaces={subCount}"); + } + // The placement frame the existing flatten logic uses. + Console.WriteLine($"live: [STATUE] placementFrames count={baseSetup.PlacementFrames.Count}"); + } + } } if (_dats is null || _staticMesh is null) return; diff --git a/src/AcDream.Core/Meshing/SetupMesh.cs b/src/AcDream.Core/Meshing/SetupMesh.cs index ac7a8f3..1be9baa 100644 --- a/src/AcDream.Core/Meshing/SetupMesh.cs +++ b/src/AcDream.Core/Meshing/SetupMesh.cs @@ -16,8 +16,22 @@ public static class SetupMesh /// public static IReadOnlyList Flatten(Setup setup) { + // ACViewer's CreateMesh always calls SetPlacementFrame(0x65) = + // Placement.Resting after creating any mesh, regardless of object + // type. For creatures and characters this matters a lot — Default + // is the aggressive battle crouch pose with arms extended forward, + // and Resting is the upright idle pose. Without this preference, + // every drudge/skeleton/character renders mid-attack, including + // the Nullified Statue of a Drudge whose only placement frame is + // keyed by Resting. + // + // Fall back to Default if Resting isn't present (most scenery + // setups only define Default). The user-visible difference is + // most dramatic for creatures; static dat scenery is unaffected. AnimationFrame? defaultAnim = null; - if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) + defaultAnim = resting; + else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) defaultAnim = af; var result = new List(setup.Parts.Count);