feat(chargen): Campaign CC slice CC6b-PRE — idle loop, rotation, zoom (mount-independent half)
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>
This commit is contained in:
parent
1774d8b298
commit
8dfee1118f
18 changed files with 1657 additions and 108 deletions
114
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal file
114
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's toggle direction enum
|
||||
/// (<c>gmBarberUI::ERotateDirection</c>/<c>gmCGAppearancePage::ERotateDirection</c>
|
||||
/// typedef alias, <c>acclient.h:6848-6852,6960</c>): <c>Invalid=0</c>,
|
||||
/// <c>Clockwise=1</c>, <c>CounterClockwise=2</c>.
|
||||
/// </summary>
|
||||
internal enum ChargenRotateDirection
|
||||
{
|
||||
Invalid = 0,
|
||||
Clockwise = 1,
|
||||
CounterClockwise = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free port of <c>gmCGAppearancePage::Rotate</c>
|
||||
/// (<c>0x0047CB50</c>) + <c>DoRotation</c> (<c>0x0047CA80</c>) — the
|
||||
/// button-toggled continuous rotation retail applies to the preview
|
||||
/// CHARACTER's heading (<c>CPhysicsObj::set_heading</c> inside
|
||||
/// <c>gmCG3DView::Update</c>, pseudo-C ~0x0047eecf1), not the camera (see
|
||||
/// <see cref="ChargenPreviewCamera"/>'s own doc comment on why rotation
|
||||
/// lives here instead). Retail drives <see cref="Tick"/> once per frame from
|
||||
/// a global-message-3 tick while <see cref="IsRotating"/> is set
|
||||
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
|
||||
/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise
|
||||
/// buttons to <see cref="Toggle"/> and the render loop to <see cref="Tick"/>.
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewRotationController
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>Rotate</c>'s explicit sentinel write
|
||||
/// (<c>this->m_dLastRotateTime = -1.0</c>, pseudo-C ~0x0047cba7/0x0047cbb1
|
||||
/// — the high dword <c>0xbff00000</c> paired with a zero low dword is the
|
||||
/// exact IEEE-754 bit pattern for <c>-1.0</c>) — invalidates the
|
||||
/// timestamp so the very next <see cref="Tick"/> resets it to "now"
|
||||
/// (a zero-length first delta) instead of computing a huge jump from a
|
||||
/// stale or never-set value.
|
||||
/// </summary>
|
||||
private const double InvalidTimeSentinel = -1.0;
|
||||
|
||||
private double _lastRotateTime = InvalidTimeSentinel;
|
||||
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
|
||||
private bool _rotating;
|
||||
|
||||
public bool IsRotating => _rotating;
|
||||
public ChargenRotateDirection Direction => _direction;
|
||||
|
||||
/// <summary>Retail's <c>m_fCurHeading</c>, degrees, ctor default 0 —
|
||||
/// applied to the preview entity via <c>MoveToMath.SetHeading</c>
|
||||
/// (<c>CPhysicsObj::set_heading</c>'s exact port).</summary>
|
||||
public float HeadingDegrees { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::Rotate @ 0x0047CB50</c>: pressing the SAME
|
||||
/// direction a second time while already rotating STOPS rotation
|
||||
/// (retail's button-toggle UX); any other press (opposite direction, or
|
||||
/// starting from stopped) sets that direction and (re)starts,
|
||||
/// invalidating <c>m_dLastRotateTime</c> per this class's own sentinel
|
||||
/// doc.
|
||||
/// </summary>
|
||||
public void Toggle(ChargenRotateDirection direction)
|
||||
{
|
||||
if (_rotating && direction == _direction)
|
||||
{
|
||||
_rotating = false;
|
||||
return;
|
||||
}
|
||||
_direction = direction;
|
||||
_lastRotateTime = InvalidTimeSentinel;
|
||||
_rotating = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>: per-tick
|
||||
/// <c>deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution)
|
||||
/// * 360</c>, added for <see cref="ChargenRotateDirection.Clockwise"/>
|
||||
/// and subtracted for every other direction (pseudo-C ~0x0047cacd:
|
||||
/// <c>if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else
|
||||
/// heading += delta;</c>), then a SINGLE-PASS clamp back into
|
||||
/// <c>[0, 360)</c> — not a full modulo loop; retail's own tail only
|
||||
/// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is
|
||||
/// exactly enough for any realistic per-frame delta and is reproduced
|
||||
/// here verbatim rather than "improved" into a `%=`.
|
||||
/// </summary>
|
||||
public void Tick(double now)
|
||||
{
|
||||
if (!_rotating)
|
||||
return;
|
||||
if (_lastRotateTime <= 0d)
|
||||
_lastRotateTime = now;
|
||||
|
||||
double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0;
|
||||
HeadingDegrees = _direction == ChargenRotateDirection.Clockwise
|
||||
? HeadingDegrees + (float)deltaDegrees
|
||||
: HeadingDegrees - (float)deltaDegrees;
|
||||
|
||||
if (HeadingDegrees < 0f)
|
||||
HeadingDegrees += 360f;
|
||||
if (HeadingDegrees > 360f)
|
||||
HeadingDegrees -= 360f;
|
||||
|
||||
_lastRotateTime = now;
|
||||
}
|
||||
|
||||
/// <summary><c>CPhysicsObj::set_heading</c>'s exact quaternion
|
||||
/// construction — the SAME shared Core primitive retail movement already
|
||||
/// ports (<see cref="MoveToMath.SetHeading"/>).</summary>
|
||||
public Quaternion ToOrientation() =>
|
||||
MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue