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); + } +}