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>
166 lines
7.1 KiB
C#
166 lines
7.1 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.CharGen;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Pure (no dat access) tests for <see cref="ChargenPreviewZoomController"/>
|
|
/// — the port of <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>/
|
|
/// <c>DoZoomAnimation</c> (<c>0x0047CF00</c>/<c>0x0047D050</c>/<c>0x0047C960</c>)
|
|
/// including its immediate wiring into <see cref="ChargenPreviewAnimator"/>'s
|
|
/// idle-loop ↔ rest-pose swap.
|
|
/// </summary>
|
|
public sealed class ChargenPreviewZoomControllerTests
|
|
{
|
|
private static ChargenPreviewAnimator MakeAnimator()
|
|
{
|
|
const uint gfxObjId = 0x0100_0001u;
|
|
var restMeshRefs = new System.Collections.Generic.List<MeshRef>
|
|
{
|
|
new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))),
|
|
};
|
|
var drawableParts = new System.Collections.Generic.List<ChargenPreviewDrawablePart>
|
|
{
|
|
new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null),
|
|
};
|
|
var anim = new Animation();
|
|
var pf0 = new AnimationFrame(1);
|
|
pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity });
|
|
anim.PartFrames.Add(pf0);
|
|
var entity = new WorldEntity
|
|
{
|
|
Id = ChargenPreviewEntityBuilder.PreviewRenderId,
|
|
ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid,
|
|
SourceGfxObjOrSetupId = 0x0200_0001u,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = restMeshRefs,
|
|
};
|
|
var build = new ChargenPreviewAnimatedBuild
|
|
{
|
|
Entity = entity,
|
|
DrawableParts = drawableParts,
|
|
RestMeshRefs = restMeshRefs,
|
|
IdleAnimation = anim,
|
|
IdleLowFrame = 0,
|
|
IdleHighFrame = 0,
|
|
};
|
|
return new ChargenPreviewAnimator(build);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
|
|
controller.ZoomIn(animator: null);
|
|
|
|
Assert.True(controller.IsZoomedIn);
|
|
// Tween in progress — eye hasn't jumped yet (Tick hasn't run).
|
|
Assert.Equal(ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian), camera.Eye);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZoomIn_WhileAlreadyZoomedIn_IsANoOp()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
controller.ZoomIn(animator: null); // real tween: zoomed-out eye -> default eye.
|
|
controller.Tick(10.0);
|
|
controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it.
|
|
Vector3 eyeAfterCompletion = camera.Eye;
|
|
|
|
controller.ZoomIn(animator: null); // second call — retail's own early-return guard.
|
|
controller.Tick(9999.0); // if ZoomIn wrongly armed a tween, this would move the eye.
|
|
|
|
Assert.Equal(eyeAfterCompletion, camera.Eye);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZoomOut_WhileNotZoomedIn_IsANoOp()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
Vector3 startEye = camera.Eye;
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
|
|
controller.ZoomOut(animator: null);
|
|
|
|
Assert.False(controller.IsZoomedIn);
|
|
Assert.Equal(startEye, camera.Eye);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_LinearlyInterpolatesTheEye_HalfwayAtHalfTheDuration()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
Vector3 startEye = camera.Eye; // ctor default == the zoomed-IN eye.
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
controller.ZoomIn(animator: null); // reach the "zoomed in" state (zero-distance tween — Eye already there).
|
|
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
|
controller.ZoomOut(animator: null); // NOW arms a real tween: default eye -> zoomed-out eye.
|
|
|
|
controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel).
|
|
controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0);
|
|
|
|
Vector3 expectedHalfway = Vector3.Lerp(startEye, targetEye, 0.5f);
|
|
Assert.Equal(expectedHalfway.X, camera.Eye.X, 3);
|
|
Assert.Equal(expectedHalfway.Y, camera.Eye.Y, 3);
|
|
Assert.Equal(expectedHalfway.Z, camera.Eye.Z, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_PastTheFullDuration_ClampsExactlyToTheTargetEye_AndStopsAnimating()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
controller.ZoomIn(animator: null); // reach "zoomed in" (zero-distance).
|
|
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
|
controller.ZoomOut(animator: null); // arms the real tween toward targetEye.
|
|
|
|
controller.Tick(0.0);
|
|
controller.Tick(100.0); // way past the 0.6s duration.
|
|
|
|
Assert.Equal(targetEye, camera.Eye);
|
|
|
|
Vector3 eyeAfterCompletion = camera.Eye;
|
|
controller.Tick(200.0); // tween finished — further ticks must not move the eye.
|
|
Assert.Equal(eyeAfterCompletion, camera.Eye);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZoomIn_ImmediatelyFreezesTheAnimatorToTheRestPose_BeforeTheTweenCompletes()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
var animator = MakeAnimator();
|
|
|
|
controller.ZoomIn(animator);
|
|
|
|
// No Tick() call at all — retail's ZoomIn calls StopAnimation
|
|
// synchronously, before the camera tween has advanced a single frame.
|
|
Assert.True(animator.IsZoomedIn);
|
|
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZoomOut_ImmediatelyResumesTheAnimatorsIdleLoop()
|
|
{
|
|
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
|
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
|
var animator = MakeAnimator();
|
|
controller.ZoomIn(animator);
|
|
|
|
controller.ZoomOut(animator);
|
|
|
|
Assert.False(animator.IsZoomedIn);
|
|
Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
|
}
|
|
}
|