The page-mount half CC6b-PRE deferred: CharacterCreationAppearancePage (gender buttons, Face/Clothes sub-tabs, nine spin controls with retail's decrement/increment/select-as-current-part OnClickAt zones, nine color swatches, shade scrollbar, zoom/rotate wiring) plus ChargenPreviewController, which bridges the ChargenPreviewRenderer/ChargenPreviewZoomController camera-injection gap CC6a/CC6b-PRE left open and mounts as the third private creature viewport beside paperdoll/creature-appraisal. Color-wheel scouting (campaign risk item 4): live-DAT probe found every color-wheel-family id resolves through existing DatWidgetFactory mappings (Button/Scrollbar/generic fallback) — no new widget type needed. The @140355 gender-flip-on-init oddity (risk item 5): resolved via decomp alone — gmCharGenMainUI's own ctor calls CharGenState::RandomizeCharacter before any page constructs, so retail's chargen screen is never actually blank on open; the Appearance page's gender-flip code always fires against a real, randomly-rolled gender. Filed AP-214 (acdream doesn't port RandomizeCharacter this round, so it opens honestly blank instead) and AP-215 (two narrow visual substitutions: swatch .Selected highlight vs retail's separate overlay, ordinal labels vs retail's icon-only spins). AD-101 retired: the Heritage page's auto-gender-select interim default is deleted now that the Appearance page's real gender buttons exist. TS-82 narrowed to Summary-only. Scope addendum: ChargenPreviewRotationController's parameterless-constructor default changes from 0f to a new RetailDefaultHeadingDegrees=180f constant (retail's InitializePage override, not the ctor's raw 0) — every real gmCG3DView owner converges on 180 before its first frame, so a controller defaulting to 0 was a trap for future consumers. Runtime 1713/0, Core 4786/1 skip, Content 147/0, App 5220/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1) — zero failures across two clean full-solution runs; the one Core.Net.Tests NakEmissionTests flake observed on a third run is the same pre-existing, previously-documented timing flake (zero files under src/AcDream.Core.Net/ touched, passes 100% in isolation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
9.1 KiB
C#
199 lines
9.1 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CC6b-MOUNT seam: wraps an EXTERNALLY-owned <see cref="ChargenPreviewCamera"/>
|
|
/// instead of constructing a private one. <see cref="ChargenPreviewZoomController"/>
|
|
/// needs a settable <see cref="ChargenPreviewCamera.Eye"/> to tween — the
|
|
/// other constructor's private <c>_camera</c> field is unreachable from
|
|
/// outside this class, so the page-mount composition (which owns the
|
|
/// zoom controller) must supply the SAME camera instance both this
|
|
/// adapter and the zoom controller mutate/read.
|
|
/// </summary>
|
|
public ChargenPreviewViewportCamera(ChargenPreviewCamera camera)
|
|
{
|
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
|
}
|
|
|
|
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;
|
|
}
|