Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex <noreply@openai.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 LiveEntityRuntime's local 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
|
||
};
|
||
}
|
||
}
|