diff --git a/src/AcDream.App/Rendering/DollEntityBuilder.cs b/src/AcDream.App/Rendering/DollEntityBuilder.cs
new file mode 100644
index 00000000..76d3bf2e
--- /dev/null
+++ b/src/AcDream.App/Rendering/DollEntityBuilder.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Builds the dedicated paperdoll WorldEntity — retail's makeObject(player)
+/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
+/// + part overrides), posed at the scene origin facing the viewer.
+///
+///
+/// The palette / part-override mapping mirrors the inline construction in
+/// GameWindow.cs around lines 3390–3431. Extracted here so it is
+/// unit-testable without dats and so the paperdoll renderer owns a clean
+/// seam: it calls with fresh player state each time the
+/// ObjDesc changes, and the renderer only has to swap the entity into its
+/// dedicated mini-scene.
+///
+///
+///
+/// The is a reserved synthetic guid that is:
+/// (a) non-zero (so EntitySpawnAdapter.OnCreate's ServerGuid != 0
+/// guard accepts it), and (b) high enough that it never collides with a real
+/// server-assigned guid.
+///
+///
+public static class DollEntityBuilder
+{
+ ///
+ /// Reserved synthetic guid for the paperdoll clone. High, deliberately
+ /// outside the server's assignable range, and non-zero.
+ ///
+ 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);
+
+ ///
+ /// Builds a synthetic for the paperdoll mini-scene.
+ ///
+ /// The player's Setup dat id (0x02xxxxxx).
+ /// Pre-resolved mesh refs (may be empty; caller fills them in).
+ ///
+ /// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
+ /// also present — mirrors GameWindow which only builds a PaletteOverride
+ /// when SubPalettes.Count > 0.
+ ///
+ ///
+ /// 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 → PaletteOverride on the returned entity is null.
+ ///
+ ///
+ /// AnimPartChange swaps from the server ObjDesc. Each tuple is a
+ /// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
+ ///
+ public static WorldEntity Build(
+ uint setupId,
+ IReadOnlyList 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();
+ }
+ 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
+ };
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
new file mode 100644
index 00000000..192b0d69
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
@@ -0,0 +1,84 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class DollEntityBuilderTests
+{
+ [Fact]
+ public void Builds_doll_entity_with_synthetic_guid_and_player_setup()
+ {
+ // SubPaletteRange uses: SubPaletteId, Offset (byte), Length (byte)
+ var doll = DollEntityBuilder.Build(
+ setupId: 0x0200_0001u,
+ meshRefs: new List(),
+ basePaletteId: 0x04000ABCu,
+ subPalettes: new (uint SubPaletteId, byte Offset, byte Length)[] { (0x0F00_0001u, 0, 8) },
+ partOverrides: new (byte PartIndex, uint GfxObjId)[] { (2, 0x0100_0042u) });
+
+ Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId);
+ Assert.Equal(DollEntityBuilder.DollServerGuid, doll.ServerGuid);
+ Assert.NotEqual(0u, doll.ServerGuid);
+ Assert.Single(doll.PartOverrides);
+ Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex);
+ Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId);
+ Assert.NotNull(doll.PaletteOverride);
+ Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId);
+ Assert.Single(doll.PaletteOverride.SubPalettes);
+ Assert.Equal(0x0F00_0001u, doll.PaletteOverride.SubPalettes[0].SubPaletteId);
+ }
+
+ [Fact]
+ public void Null_overrides_give_empty_collections_not_null()
+ {
+ var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null);
+ // No subpalettes => no PaletteOverride (mirrors GameWindow: only built when Count > 0)
+ Assert.Null(doll.PaletteOverride);
+ Assert.Empty(doll.PartOverrides);
+ }
+
+ [Fact]
+ public void Empty_subpalettes_give_null_palette_override()
+ {
+ // Mirror GameWindow: SubPalettes with Count == 0 => no override
+ var doll = DollEntityBuilder.Build(
+ 0x0200_0001u,
+ new List(),
+ basePaletteId: 0x04000001u,
+ subPalettes: System.Array.Empty<(uint, byte, byte)>(),
+ partOverrides: null);
+ Assert.Null(doll.PaletteOverride);
+ }
+
+ [Fact]
+ public void Heading_is_normalized_quaternion_facing_viewer()
+ {
+ var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null);
+ Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f);
+ }
+
+ [Fact]
+ public void Position_is_world_origin()
+ {
+ var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null);
+ Assert.Equal(Vector3.Zero, doll.Position);
+ }
+
+ [Fact]
+ public void ParentCellId_is_null_for_doll_scene()
+ {
+ var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null);
+ Assert.Null(doll.ParentCellId);
+ }
+
+ [Fact]
+ public void MeshRefs_are_passed_through()
+ {
+ var refs = new List { new MeshRef(0x01000001u, Matrix4x4.Identity) };
+ var doll = DollEntityBuilder.Build(0x0200_0001u, refs, null, null, null);
+ Assert.Same(refs, doll.MeshRefs);
+ }
+}