Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
8.4 KiB
C#
185 lines
8.4 KiB
C#
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. The heading itself
|
|
/// lives on <see cref="ChargenPreviewRotationController"/> (CC6b: the
|
|
/// <c>DoRotation</c>/<c>Rotate</c> port) and is applied to the entity via
|
|
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>/<c>TryBuildAnimated</c>'s
|
|
/// <c>heading</c> parameter, not here; this class stays a fixed-per-heritage
|
|
/// eye, exactly like retail's own camera. <see cref="ChargenPreviewZoomController"/>
|
|
/// (CC6b: the <c>ZoomIn</c>/<c>ZoomOut</c>/<c>DoZoomAnimation</c> port) DOES
|
|
/// mutate this class's <see cref="Eye"/> — zoom is a camera concern, unlike
|
|
/// rotation.
|
|
/// </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). Retail's own per-tick formula
|
|
/// (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>, pseudo-C
|
|
/// ~0x0047CAC7): <c>deltaDegrees = ((now - lastRotateTime) /
|
|
/// RotationSecondsPerRevolution) * 360</c> — CC6b's rotation controller
|
|
/// consumes this constant in exactly that shape, not as a
|
|
/// degrees-per-second rate. 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;
|
|
}
|