feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)

Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431 in a
testable static helper. Build() accepts plain (SubPaletteId, Offset, Length)
and (PartIndex, GfxObjId) tuples, builds PaletteOverride only when
subPalettes.Count > 0 (same gate as GameWindow), and poses the entity at
origin facing the viewer (191.367905° / +Z). Reserved synthetic guid
0xDA11D011 keeps the doll distinct from the live player and satisfies
EntitySpawnAdapter's ServerGuid != 0 guard. 7 new tests green; suite 594/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 09:07:02 +02:00
parent ebcdf44c0c
commit 362d41aacf
2 changed files with 202 additions and 0 deletions

View file

@ -0,0 +1,118 @@
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 33903431. 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;
// retail set_heading: 191.367905° about +Z, expressed as a Quaternion
// (axis-angle, axis = (0,0,1), θ = 191.367905° in radians).
private static readonly float _headingRad = 191.367905f * (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 &gt; 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 = 0u,
ServerGuid = DollServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = _dollRotation,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
ParentCellId = null, // paperdoll mini-scene has no parent cell
};
}
}