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
123
src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs
Normal file
123
src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's simplest animation-clip playback shape: advance a frame position
|
||||
/// at a fixed framerate and wrap it back into <c>[LowFrame, HighFrame]</c>,
|
||||
/// then linearly interpolate one part's origin/orientation between the two
|
||||
/// bracketing frames. This is the effect of
|
||||
/// <c>CPhysicsObj::set_sequence_animation</c> (<c>0x0050F6F0</c>) when called
|
||||
/// with a constant DID and a nonzero framerate and no further motion-command
|
||||
/// traffic — e.g. <c>gmCG3DView::StartAnimation</c> (<c>0x004EE600</c>),
|
||||
/// which plays the chargen preview's idle DID at a flat 30 fps with no
|
||||
/// transitional blending.
|
||||
///
|
||||
/// <para>
|
||||
/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as
|
||||
/// an inline, App-layer-only implementation for the "legacy" (no
|
||||
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path —
|
||||
/// <c>LiveEntityAnimationPresenter.Present</c>'s non-sequencer branch
|
||||
/// (<c>CurrFrame += legacyAdvanceSeconds * Framerate</c> with the same
|
||||
/// modulo wrap) and its private <c>TryResolvePartFrame</c> helper (the same
|
||||
/// frame-bracket lerp/slerp). That call site has a live entity, a
|
||||
/// <c>LiveEntityRuntime</c> membership, and per-tick elapsed time supplied by
|
||||
/// the render loop; the chargen preview has none of that (there is no live
|
||||
/// entity — character creation hasn't happened yet), so it cannot reuse that
|
||||
/// class directly. Rather than re-typing the same formula a second time,
|
||||
/// this Core, pure, unit-testable class is the shared primitive: the
|
||||
/// chargen preview (<c>AcDream.App.Rendering.ChargenPreviewAnimator</c>)
|
||||
/// consumes it directly, and it is safe for a future pass to redirect
|
||||
/// <c>LiveEntityAnimationPresenter</c>'s inline copy through it as a
|
||||
/// behavior-preserving mechanical follow-up (not done here — that file is
|
||||
/// live, heavily tested production entity-rendering code with zero relation
|
||||
/// to this preview-only feature, so touching it is out of this slice's
|
||||
/// blast radius by design, not oversight).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailAnimationCyclePlayback
|
||||
{
|
||||
/// <summary>
|
||||
/// Advances <paramref name="currFrame"/> by <c>elapsedSeconds * framerate</c>
|
||||
/// and wraps it back into <c>[lowFrame, highFrame]</c> with the SAME modulo
|
||||
/// shape <c>LiveEntityAnimationPresenter.Present</c>'s legacy branch uses
|
||||
/// (<c>over % (span + 1)</c>, not a plain clamp — a frame position that
|
||||
/// overshoots the end by more than one span wraps around more than once
|
||||
/// rather than sticking at the boundary, matching a long stall/resume).
|
||||
/// Returns <paramref name="currFrame"/> unchanged for a degenerate cycle
|
||||
/// (<paramref name="highFrame"/> <= <paramref name="lowFrame"/>), a
|
||||
/// non-positive <paramref name="framerate"/>, or a non-positive
|
||||
/// <paramref name="elapsedSeconds"/>.
|
||||
/// </summary>
|
||||
public static float Advance(
|
||||
float currFrame,
|
||||
int lowFrame,
|
||||
int highFrame,
|
||||
float framerate,
|
||||
float elapsedSeconds)
|
||||
{
|
||||
int span = highFrame - lowFrame;
|
||||
if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f)
|
||||
return currFrame;
|
||||
|
||||
float next = currFrame + elapsedSeconds * framerate;
|
||||
if (next > highFrame)
|
||||
{
|
||||
float over = next - lowFrame;
|
||||
next = lowFrame + (over % (span + 1));
|
||||
}
|
||||
else if (next < lowFrame)
|
||||
{
|
||||
next = lowFrame;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves part <paramref name="partIndex"/>'s origin/orientation at
|
||||
/// <paramref name="currFrame"/> by linearly interpolating (lerp origin,
|
||||
/// slerp orientation) between the frame at <c>floor(currFrame)</c> and
|
||||
/// the next frame in the cycle (wrapping <paramref name="highFrame"/>+1
|
||||
/// back to <paramref name="lowFrame"/>). Returns <c>false</c> — with
|
||||
/// <c>default</c> outputs — when <paramref name="partIndex"/> is outside
|
||||
/// the bracketing frame's part list, matching
|
||||
/// <c>LiveEntityAnimationPresenter.TryResolvePartFrame</c>'s no-
|
||||
/// sequence-frames branch exactly.
|
||||
/// </summary>
|
||||
public static bool TryInterpolatePart(
|
||||
Animation animation,
|
||||
float currFrame,
|
||||
int lowFrame,
|
||||
int highFrame,
|
||||
int partIndex,
|
||||
out Vector3 origin,
|
||||
out Quaternion orientation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(animation);
|
||||
|
||||
int frameIndex = (int)MathF.Floor(currFrame);
|
||||
if (frameIndex < lowFrame || frameIndex > highFrame || frameIndex >= animation.PartFrames.Count)
|
||||
frameIndex = lowFrame;
|
||||
int nextIndex = frameIndex + 1;
|
||||
if (nextIndex > highFrame || nextIndex >= animation.PartFrames.Count)
|
||||
nextIndex = lowFrame;
|
||||
float t = Math.Clamp(currFrame - frameIndex, 0f, 1f);
|
||||
|
||||
var frames = animation.PartFrames[frameIndex].Frames;
|
||||
var nextFrames = animation.PartFrames[nextIndex].Frames;
|
||||
if (partIndex < frames.Count)
|
||||
{
|
||||
var first = frames[partIndex];
|
||||
var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first;
|
||||
origin = Vector3.Lerp(first.Origin, next.Origin, t);
|
||||
orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t);
|
||||
return true;
|
||||
}
|
||||
|
||||
origin = default;
|
||||
orientation = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue