feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation

Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:21:34 +02:00
parent 3a6b7e3115
commit 55bfd9ca82
16 changed files with 2131 additions and 2 deletions

View file

@ -0,0 +1,176 @@
using System;
using System.Numerics;
using AcDream.Core.CharGen;
namespace AcDream.App.Rendering;
/// <summary>
/// Heritage-parameterized camera for the chargen 3D preview
/// (<c>gmCG3DView</c>, Appearance page viewport <c>0x100003bb</c> / Summary
/// <c>0x10000406</c>). Retail-exact eye positions, ported from
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c> (pseudo-C ~139037-139114,
/// which sets <c>m_vectTargPosition</c>/<c>m_vectCurPosition</c> per
/// heritage and snaps them together with no tween — CC6a's static preview
/// renders that snapped default, the "zoomed-in" framing) and cross-checked
/// against the IDENTICAL literals in <c>gmCGAppearancePage::ZoomIn @
/// 0x0047CF00</c> (pseudo-C ~137618-137638). Direction is always
/// <c>(0,0,0)</c> ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view
/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention
/// <see cref="DollCamera"/> already established for the paperdoll (look
/// straight down +Y, +Z up); every camera position below is used AS the
/// world-space eye directly, matching that camera's approach.
///
/// <para>
/// <b>Rotation is NOT a camera property.</b> Retail's continuous-rotation
/// button (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>) advances a
/// HEADING applied to the preview CHARACTER (<c>CPhysicsObj::set_heading</c>
/// inside <c>gmCG3DView::Update</c>, pseudo-C ~242088) — the camera's own
/// position/direction never change during a rotation. CC6b's heading
/// parameter therefore belongs on the entity builder
/// (<see cref="ChargenPreviewEntityBuilder"/>), not here; this class stays a
/// fixed-per-heritage eye, exactly like retail's own camera.
/// </para>
/// </summary>
public sealed class ChargenPreviewCamera : ICamera
{
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera.
private Vector3 _eye;
public ChargenPreviewCamera(uint heritageId = 0u)
{
_eye = ResolveDefaultEye(heritageId);
}
/// <summary>
/// The camera's current world-space eye. Settable so CC6b can react to a
/// heritage change without reconstructing the camera.
/// </summary>
public Vector3 Eye
{
get => _eye;
set => _eye = value;
}
/// <summary>Re-derives <see cref="Eye"/> for the given heritage id (retail's <c>mHeritageGroup</c>).</summary>
public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId);
/// <summary>
/// Retail default (zoomed-in) camera eye per heritage. All four profiles
/// share <c>X=0</c>; only <c>(Y, Z)</c> — the AC world-space forward
/// offset and height — vary. FOUR distinct profiles across the 13
/// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho,
/// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen,
/// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME
/// numeric offset as Gearknight's own dedicated branch in the decomp.
/// </summary>
public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f),
(uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f),
_ => new Vector3(0f, -0.550000012f, 1.64999998f),
};
/// <summary>
/// Retail zoomed-OUT camera eye per heritage
/// (<c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>, pseudo-C
/// ~137671-137687). CC6a does not implement the zoom button (CC6b) —
/// recorded here as the verified target CC6b's tween will animate
/// toward. Olthoi/OlthoiAcid each keep their own dedicated profile;
/// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is
/// special-cased but whose zoomed-OUT is not — shares one value.
/// </summary>
public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f),
_ => new Vector3(0f, -2.5f, 0.95f),
};
/// <summary>
/// Seconds per 360° revolution for the continuous-rotation button
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, ctor pseudo-C
/// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000,
/// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly,
/// no reconstruction needed). Consumed by CC6b's rotation controller as
/// <c>360f / RotationDegreesPerSecond</c> — NOT applied here; see this
/// class's own doc comment on why rotation is not a camera concern.
/// </summary>
public const float RotationSecondsPerRevolution = 3.0f;
/// <summary>
/// Zoom tween duration in seconds
/// (<c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>'s
/// reset-if-invalid default, cross-confirmed by <c>ZoomIn</c>/<c>ZoomOut</c>'s
/// own <c>-0.1</c> sentinel write, which deliberately invalidates
/// <c>m_dAnimDuration</c> so the very next <c>DoZoomAnimation</c> tick
/// resets it to this same value). The campaign plan flagged this
/// constant as decompiler-garbled (both sites split the raw double
/// across two 32-bit stores, and the decompiler mis-renders the LOW
/// dword's store as a bogus float literal instead of raw bits) — it is
/// NOT unrecoverable: reinterpreting each garbled float literal as its
/// own raw 32-bit pattern and pairing it with the store's (clean) high
/// dword reconstructs an exact IEEE-754 double both times.
/// <c>DoZoomAnimation</c>'s own reset path: low32 from
/// <c>4.17232506e-08f</c> reinterpreted = <c>0x33333333</c>, high32 =
/// <c>0x3fe33333</c> (clean) → exactly <b>0.6</b>. Cross-check via
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s sentinel: low32 from
/// <c>-1.58818684e-23f</c> reinterpreted = <c>0x9999999A</c>, high32 =
/// <c>0xbfb99999</c> (clean) → exactly <b>-0.1</b>, the well-known
/// IEEE-754 bit pattern for -0.1 (<c>0xBFB999999999999A</c>) — confirming
/// the reconstruction technique itself, not just this one value.
/// </summary>
public const float ZoomTweenDurationSeconds = 0.6f;
public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera.
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 50f;
public float Aspect { get; set; } = 1f;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
}
/// <summary>
/// Internal private-viewport adapter, mirroring <c>DollViewportCamera</c>'s
/// role for <see cref="ChargenPreviewCamera"/>.
/// </summary>
internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera
{
private readonly ChargenPreviewCamera _camera;
public ChargenPreviewViewportCamera(uint heritageId = 0u)
{
_camera = new ChargenPreviewCamera(heritageId);
}
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public Vector3 Eye => _camera.Eye;
public float FovRadians
{
get => _camera.FovRadians;
set => _camera.FovRadians = value;
}
public float Near
{
get => _camera.Near;
set => _camera.Near = value;
}
public float Far
{
get => _camera.Far;
set => _camera.Far = value;
}
public float Aspect
{
get => _camera.Aspect;
set => _camera.Aspect = value;
}
public Matrix4x4 View => _camera.View;
public Matrix4x4 Projection => _camera.Projection;
}