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>
133 lines
6.2 KiB
C#
133 lines
6.2 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// Builds the dedicated paperdoll WorldEntity — retail's <c>makeObject(player)</c>
|
||
/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
|
||
/// + part overrides), posed at the scene origin facing the viewer.
|
||
///
|
||
/// <para>
|
||
/// The palette / part-override mapping mirrors the inline construction in
|
||
/// <c>GameWindow.cs</c> around lines 3390–3431. Extracted here so it is
|
||
/// unit-testable without dats and so the paperdoll renderer owns a clean
|
||
/// seam: it calls <see cref="Build"/> with fresh player state each time the
|
||
/// ObjDesc changes, and the renderer only has to swap the entity into its
|
||
/// dedicated mini-scene.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The <see cref="DollServerGuid"/> is a reserved synthetic guid that is:
|
||
/// (a) non-zero (so <c>EntitySpawnAdapter.OnCreate</c>'s <c>ServerGuid != 0</c>
|
||
/// guard accepts it), and (b) high enough that it never collides with a real
|
||
/// server-assigned guid.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class DollEntityBuilder
|
||
{
|
||
/// <summary>
|
||
/// Reserved synthetic guid for the paperdoll clone. High, deliberately
|
||
/// outside the server's assignable range, and non-zero.
|
||
/// </summary>
|
||
public const uint DollServerGuid = 0xDA11_D011u;
|
||
|
||
/// <summary>
|
||
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
|
||
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The
|
||
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
|
||
/// doll as animated — which BYPASSES the Tier-1 classification cache
|
||
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW
|
||
/// WorldEntity with this SAME id; without the cache bypass the dispatcher would
|
||
/// serve the previous doll's cached batches and the new gear wouldn't appear.
|
||
/// </summary>
|
||
public const uint DollRenderId = 0xDA11_D012u;
|
||
|
||
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
|
||
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
|
||
// North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about
|
||
// +Z; System.Numerics rotates +Y → (−sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
|
||
// NEGATED (θ = −h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
|
||
private const float _headingDegrees = 191.367905f;
|
||
private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f);
|
||
private static readonly Quaternion _dollRotation =
|
||
Quaternion.CreateFromAxisAngle(new Vector3(0f, 0f, 1f), _headingRad);
|
||
|
||
/// <summary>
|
||
/// Builds a synthetic <see cref="WorldEntity"/> for the paperdoll mini-scene.
|
||
/// </summary>
|
||
/// <param name="setupId">The player's Setup dat id (0x02xxxxxx).</param>
|
||
/// <param name="meshRefs">Pre-resolved mesh refs (may be empty; caller fills them in).</param>
|
||
/// <param name="basePaletteId">
|
||
/// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
|
||
/// also present — mirrors GameWindow which only builds a PaletteOverride
|
||
/// when <c>SubPalettes.Count > 0</c>.
|
||
/// </param>
|
||
/// <param name="subPalettes">
|
||
/// Subpalette overlays from the server ObjDesc. Each tuple carries the
|
||
/// subpalette dat id, byte offset into the base palette, and byte length.
|
||
/// Null or empty → <c>PaletteOverride</c> on the returned entity is null.
|
||
/// </param>
|
||
/// <param name="partOverrides">
|
||
/// AnimPartChange swaps from the server ObjDesc. Each tuple is a
|
||
/// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
|
||
/// </param>
|
||
public static WorldEntity Build(
|
||
uint setupId,
|
||
IReadOnlyList<MeshRef> meshRefs,
|
||
uint? basePaletteId = null,
|
||
IReadOnlyList<(uint SubPaletteId, byte Offset, byte Length)>? subPalettes = null,
|
||
IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null)
|
||
{
|
||
// --- palette override (mirrors GameWindow:3395-3405) ---
|
||
// Only build when there are sub-palette overlays — same gate as GameWindow.
|
||
PaletteOverride? paletteOverride = null;
|
||
if (subPalettes is { Count: > 0 } spList)
|
||
{
|
||
var ranges = new PaletteOverride.SubPaletteRange[spList.Count];
|
||
for (int i = 0; i < spList.Count; i++)
|
||
ranges[i] = new PaletteOverride.SubPaletteRange(
|
||
spList[i].SubPaletteId,
|
||
spList[i].Offset,
|
||
spList[i].Length);
|
||
paletteOverride = new PaletteOverride(
|
||
BasePaletteId: basePaletteId ?? 0u,
|
||
SubPalettes: ranges);
|
||
}
|
||
|
||
// --- part overrides (mirrors GameWindow:3407-3418) ---
|
||
PartOverride[] entityPartOverrides;
|
||
if (partOverrides is null or { Count: 0 })
|
||
{
|
||
entityPartOverrides = Array.Empty<PartOverride>();
|
||
}
|
||
else
|
||
{
|
||
entityPartOverrides = new PartOverride[partOverrides.Count];
|
||
for (int i = 0; i < partOverrides.Count; i++)
|
||
entityPartOverrides[i] = new PartOverride(
|
||
partOverrides[i].PartIndex,
|
||
partOverrides[i].GfxObjId);
|
||
}
|
||
|
||
// --- assemble entity (mirrors GameWindow:3420-3431) ---
|
||
// Id=0: the paperdoll renderer assigns its own render-local id when it
|
||
// registers this entity with the mini-scene GpuWorldState. We set zero
|
||
// here so the builder stays pure (no static counter). The caller may
|
||
// replace it before registration.
|
||
return new WorldEntity
|
||
{
|
||
Id = DollRenderId,
|
||
ServerGuid = DollServerGuid,
|
||
SourceGfxObjOrSetupId = setupId,
|
||
Position = Vector3.Zero,
|
||
Rotation = _dollRotation,
|
||
MeshRefs = meshRefs,
|
||
PaletteOverride = paletteOverride,
|
||
PartOverrides = entityPartOverrides,
|
||
ParentCellId = null, // paperdoll mini-scene has no parent cell
|
||
};
|
||
}
|
||
}
|