acdream/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs
Erik 8dfee1118f 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>
2026-08-15 19:05:56 +02:00

154 lines
5.8 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Hand-built-fixture tests for <see cref="ChargenPreviewAnimator"/> — no dat
/// access needed, since a <see cref="ChargenPreviewAnimatedBuild"/> can be
/// constructed entirely in memory. Installed-DAT coverage for the RESOLUTION
/// half (<c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually finding
/// the idle DID against real dat data) lives in
/// <c>ChargenPreviewEntityBuilderTests</c>.
/// </summary>
public sealed class ChargenPreviewAnimatorTests
{
private static Animation MakeTwoFrameAnim(Vector3 frame0Origin, Vector3 frame1Origin)
{
var anim = new Animation();
var pf0 = new AnimationFrame(1);
pf0.Frames.Add(new Frame { Origin = frame0Origin, Orientation = Quaternion.Identity });
var pf1 = new AnimationFrame(1);
pf1.Frames.Add(new Frame { Origin = frame1Origin, Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf0);
anim.PartFrames.Add(pf1);
return anim;
}
private static ChargenPreviewAnimatedBuild MakeBuild(Animation? idleAnimation, int idleLow = 0, int idleHigh = 1)
{
const uint gfxObjId = 0x0100_0001u;
var restMeshRefs = new List<MeshRef>
{
new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), // distinct from any idle frame, so tests can tell them apart.
};
var drawableParts = new List<ChargenPreviewDrawablePart>
{
new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null),
};
var entity = new WorldEntity
{
Id = ChargenPreviewEntityBuilder.PreviewRenderId,
ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = restMeshRefs,
};
return new ChargenPreviewAnimatedBuild
{
Entity = entity,
DrawableParts = drawableParts,
RestMeshRefs = restMeshRefs,
IdleAnimation = idleAnimation,
IdleLowFrame = idleLow,
IdleHighFrame = idleHigh,
};
}
[Fact]
public void Constructor_WithIdleAnimation_SeedsFrameZeroPose_NotTheRestPose()
{
// Retail's true default is the idle loop PLAYING, not the rest pose
// — see ChargenPreviewEntityBuilder's class doc.
var origin0 = new Vector3(1f, 0f, 0f);
var origin1 = new Vector3(5f, 0f, 0f);
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
var animator = new ChargenPreviewAnimator(build);
Assert.False(animator.IsZoomedIn);
Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation);
}
[Fact]
public void Constructor_WithNoIdleAnimation_KeepsTheRestPoseFallback()
{
var build = MakeBuild(idleAnimation: null);
var animator = new ChargenPreviewAnimator(build);
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
}
[Fact]
public void Tick_AdvancesTheIdleFrame_InterpolatingBetweenFrames()
{
var origin0 = new Vector3(0f, 0f, 0f);
var origin1 = new Vector3(10f, 0f, 0f);
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
var animator = new ChargenPreviewAnimator(build);
// 30fps, half a frame's worth of elapsed time -> currFrame 0.5, lerp halfway.
animator.Tick(1f / 60f);
Assert.Equal(5f, animator.Entity.MeshRefs[0].PartTransform.Translation.X, 3);
}
[Fact]
public void SetZoomedIn_True_SwapsToTheFrozenRestPoseImmediately()
{
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
var animator = new ChargenPreviewAnimator(build);
animator.SetZoomedIn(true);
Assert.True(animator.IsZoomedIn);
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
}
[Fact]
public void Tick_WhileZoomedIn_DoesNotAdvanceTheFrozenPose()
{
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
var animator = new ChargenPreviewAnimator(build);
animator.SetZoomedIn(true);
animator.Tick(10f); // large elapsed time — must still be a no-op while zoomed in.
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
}
[Fact]
public void SetZoomedIn_False_RestartsTheIdleLoopAtFrameZero()
{
var origin0 = new Vector3(1f, 0f, 0f);
var origin1 = new Vector3(5f, 0f, 0f);
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
var animator = new ChargenPreviewAnimator(build);
animator.Tick(1f / 30f); // advance to frame 1.
animator.SetZoomedIn(true);
animator.SetZoomedIn(false); // gmCG3DView::StartAnimation restarts the clip (clear-then-append).
Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation);
}
[Fact]
public void SetZoomedIn_SameStateTwice_IsANoOp()
{
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
var animator = new ChargenPreviewAnimator(build);
animator.Tick(1f / 60f); // partway through frame 0->1.
Vector3 beforeX = animator.Entity.MeshRefs[0].PartTransform.Translation;
animator.SetZoomedIn(false); // already not zoomed in — must not restart the loop.
Assert.Equal(beforeX, animator.Entity.MeshRefs[0].PartTransform.Translation);
}
}