fix(D.2b): Slice 2 — retail-exact doll pose, camera + heading (visual gate)

The paperdoll doll now matches retail: correct held pose, framing, and
facing. Three decomp-sourced fixes closed the visual gate.

Pose: cdb-confirmed m_didAnimation = 0x030003C0 (gmPaperDollUI), played
once + HELD (set_sequence_animation framerate=0, RedressCreature
0x004a3c22). Dumping the dat showed the 29-frame anim has only two
distinct keyframes — frame 0 (transitional, bent arm) and frames 1..28
(byte-identical: the settled stance, arms down + leg back) — so
ApplyPaperdollPose applies the LAST frame statically (no looping).

Camera: ported verbatim from UIElement_Viewport::SetCamera (decomp
0x004a5a39). position (0.12,-2.4,0.88); direction (0,0,0) => IDENTITY
view frame => look straight down +Y, ZERO yaw; FOV pi/4 (CreatureMode
ctor default 0x004543cf); ambient 0.3. The prior hand-tune aimed the
camera at mid-body, adding a ~2deg yaw that turned the doll's face away
— full-body framing comes from eye-height + FOV, not aiming.

Heading: retail Frame::set_heading(h) (0x00535e40) builds facing
(sin h, cos h); System.Numerics CreateFromAxisAngle(+Z, +h) rotates the
body's default +Y forward to (-sin h, cos h) — the X-lean was MIRRORED
(~22deg), the real cause of the turned-away face. Negate the angle to
land on retail's facing.

Wrap-up: stripped the temporary O/P pose-frame stepper + Slice2
diagnostics; divergence register AP-66 reworded, AP-67 (RTT doll render
vs in-cell CreatureMode::Render) + AP-68 (per-race UpdateForRace
unimpl) added; DollCameraTests pinned to the retail values + a zero-yaw
guard. tools/cdb/paperdoll-pose.cdb = the pose-DID capture script.
Build + full suite green (Core 1579 / Core.Net 343 / App 597 / UI 425).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 13:16:27 +02:00
parent 594942f127
commit 8fa66c23d5
8 changed files with 136 additions and 37 deletions

View file

@ -3816,13 +3816,82 @@ public sealed class GameWindow : IDisposable
foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
}
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // frozen-pose snapshot
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // dressed parts (player's live frame)
var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
_paperdollViewportRenderer.SetDoll(doll);
return true;
}
/// <summary>
/// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
/// <c>gmPaperDollUI</c> ctor (decomp 174243) sets <c>m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7)</c>,
/// which is <c>DBCache::GetDIDFromEnumStatic</c> (decomp 20380) = <c>master[MasterMapId][0x10000005]</c>
/// → submap <c>0x25000009</c> → key <c>7</c>. (Icon effects use keys 1-6 of the same submap; key 7 is the
/// paperdoll pose.) Per-race <c>UpdateForRace</c> override deferred — the ctor default applies to all
/// body-types for now. Returns 0 if the chain can't resolve.
/// </summary>
private uint ResolvePaperdollPoseDid()
{
var dats = _dats;
if (dats is null) return 0u;
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (masterDid == 0) return 0u;
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)) return 0u;
// DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
// GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)) return 0u;
return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
}
/// <summary>
/// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
/// retail <c>set_sequence_animation(doll, m_didAnimation, …)</c>. Keeps each cloned part's GfxObjId +
/// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
/// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
/// animation tick (<see cref="TickAnimations"/>, GameWindow.cs ~9999). Static (the pose is fixed). If the
/// DID does not resolve to an <c>Animation</c>, the doll keeps its cloned pose (no regression) — the log
/// line surfaces what resolved so the pose is verified, not guessed.
/// </summary>
private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
{
var dats = _dats;
if (dats is null) return;
// Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
_animatedEntities.Remove(doll.Id);
// poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
// did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
// garbage frame count → OOM).
uint poseDid = ResolvePaperdollPoseDid();
if ((poseDid >> 24) != 0x03u) return;
var anim = dats.Get<DatReaderWriter.DBObjs.Animation>(poseDid);
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
// Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
// RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
// frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
// arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
int frameIdx = anim.PartFrames.Count - 1;
var frame = anim.PartFrames[frameIdx];
var src = doll.MeshRefs;
var reposed = new List<AcDream.Core.World.MeshRef>(src.Count);
for (int i = 0; i < src.Count; i++)
{
var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
var transform = System.Numerics.Matrix4x4.CreateScale(scale)
* System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
* System.Numerics.Matrix4x4.CreateTranslation(origin);
reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
}
doll.MeshRefs = reposed;
}
/// <summary>
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.