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:
Erik 2026-08-15 19:05:56 +02:00
parent 1774d8b298
commit 8dfee1118f
18 changed files with 1657 additions and 108 deletions

View file

@ -302,6 +302,86 @@ public sealed class ChargenAppearanceFactoryTests
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
}
/// <summary>
/// CC6b MUST-COVER item 4 — <c>alternateSetupIdOverride</c> (retail's
/// <c>m_alternateSetupID</c>) must WIN outright over the hairstyle's own
/// <c>AlternateSetup</c> when both are supplied, matching
/// <c>gmCG3DView::Update</c>'s replace-not-combine precedence
/// (~0x004EEA46-0x004EEA53).
/// </summary>
[Fact]
public void TryCompose_AlternateSetupIdOverride_WinsOverHairStyleAlternateSetup()
{
const uint hairStyleSetup = 0x0200_00AAu;
const uint pageLevelOverride = 0x0200_00BBu;
ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: hairStyleSetup));
var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride);
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result,
alternateSetupIdOverride: pageLevelOverride);
Assert.Equal(pageLevelOverride, result.SetupId);
}
/// <summary>
/// Companion: with NO hair style selected at all (so there is nothing for
/// the page-level override to out-rank), the override still replaces the
/// plain gender.SetupId.
/// </summary>
[Fact]
public void TryCompose_AlternateSetupIdOverride_WinsOverPlainGenderSetupId()
{
const uint pageLevelOverride = 0x0200_00CCu;
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride);
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
out ChargenAppearanceResult result,
alternateSetupIdOverride: pageLevelOverride);
Assert.Equal(pageLevelOverride, result.SetupId);
}
/// <summary>
/// The default (no override supplied) call shape is unaffected — proves
/// the new trailing parameter is additive, not a behavior change for
/// every existing caller.
/// </summary>
[Fact]
public void TryCompose_NoAlternateSetupIdOverrideSupplied_ResolvesAsBefore()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
out ChargenAppearanceResult result);
Assert.Equal(BodySetupId, result.SetupId);
}
/// <summary>
/// An override equal to retail's <c>INVALID_DID</c> sentinel means "no
/// override" (the field's own default), not "adopt 0xFFFFFFFF as the
/// Setup id" — same sentinel discipline as the hairstyle source (F1).
/// </summary>
[Fact]
public void TryCompose_AlternateSetupIdOverrideIsInvalidDid_IsTreatedAsNoOverride()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
out ChargenAppearanceResult result,
alternateSetupIdOverride: 0xFFFFFFFFu);
Assert.Equal(BodySetupId, result.SetupId);
}
[Fact]
public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald()
{

View file

@ -0,0 +1,153 @@
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// <see cref="RetailAnimationCyclePlayback"/> is the shared advance-with-wrap
/// + lerp/slerp primitive behind the chargen preview's idle loop
/// (<c>ChargenPreviewAnimator</c>) — the SAME arithmetic
/// <c>LiveEntityAnimationPresenter.Present</c>'s legacy (no-
/// <see cref="AnimationSequencer"/>) branch already carries for NPC idle
/// cycles, extracted here so a second, live-entity-free consumer (the
/// chargen preview, which has no <c>LiveEntityRuntime</c> membership to hang
/// a sequencer off of) doesn't retype the formula.
/// </summary>
public sealed class RetailAnimationCyclePlaybackTests
{
private static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation)
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame((uint)numParts);
for (int p = 0; p < numParts; p++)
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
anim.PartFrames.Add(pf);
}
return anim;
}
[Fact]
public void Advance_WithinSpan_AddsElapsedTimesFramerate()
{
float result = RetailAnimationCyclePlayback.Advance(
currFrame: 5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.1f);
Assert.Equal(8f, result, precision: 4); // 5 + 0.1*30 = 8.
}
[Fact]
public void Advance_PastHighFrame_WrapsBackToLowFrame()
{
// 29-frame span (0..29 inclusive = 30 frames), advancing from frame
// 28 by one second at 30fps overshoots by (28+30)-29 = 29, wrapping
// to lowFrame + (29 % 30) = 29... use a case with a clean wrap.
float result = RetailAnimationCyclePlayback.Advance(
currFrame: 25f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.2f);
// 25 + 6 = 31, over highFrame(29) by span+1=30: over = 31-0 = 31,
// wrapped = 0 + (31 % 30) = 1.
Assert.Equal(1f, result, precision: 4);
}
[Fact]
public void Advance_BelowLowFrame_ClampsToLowFrame()
{
float result = RetailAnimationCyclePlayback.Advance(
currFrame: -5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.05f);
// -5 + 1.5 = -3.5, still below lowFrame(0) -> clamp.
Assert.Equal(0f, result);
}
[Theory]
[InlineData(0, 0)] // degenerate span (highFrame == lowFrame).
[InlineData(0, -1)] // inverted span.
public void Advance_DegenerateSpan_ReturnsCurrFrameUnchanged(int lowFrame, int highFrame)
{
float result = RetailAnimationCyclePlayback.Advance(
currFrame: 3f, lowFrame, highFrame, framerate: 30f, elapsedSeconds: 1f);
Assert.Equal(3f, result);
}
[Fact]
public void Advance_NonPositiveFramerateOrElapsed_ReturnsCurrFrameUnchanged()
{
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 0f, elapsedSeconds: 1f));
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: 0f));
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: -1f));
}
[Fact]
public void TryInterpolatePart_ExactFrame_ReturnsThatFramesPose()
{
Animation anim = MakeAnim(3, 2, new Vector3(1f, 2f, 3f), Quaternion.Identity);
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
anim, currFrame: 1f, lowFrame: 0, highFrame: 2, partIndex: 0,
out Vector3 origin, out Quaternion orientation);
Assert.True(ok);
Assert.Equal(new Vector3(1f, 2f, 3f), origin);
Assert.Equal(Quaternion.Identity, orientation);
}
[Fact]
public void TryInterpolatePart_BetweenFrames_LerpsOriginHalfway()
{
var anim = new Animation();
var pf0 = new AnimationFrame(1);
pf0.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
var pf1 = new AnimationFrame(1);
pf1.Frames.Add(new Frame { Origin = new Vector3(10f, 0f, 0f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf0);
anim.PartFrames.Add(pf1);
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
anim, currFrame: 0.5f, lowFrame: 0, highFrame: 1, partIndex: 0,
out Vector3 origin, out _);
Assert.True(ok);
Assert.Equal(new Vector3(5f, 0f, 0f), origin);
}
[Fact]
public void TryInterpolatePart_AtHighFrame_WrapsNextFrameToLowFrame()
{
var anim = new Animation();
var pf0 = new AnimationFrame(1);
pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity });
var pf1 = new AnimationFrame(1);
pf1.Frames.Add(new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf0);
anim.PartFrames.Add(pf1);
// currFrame exactly at highFrame(1): frameIndex=1, nextIndex would be
// 2 which is > highFrame -> wraps to lowFrame(0). t=0 so origin==frame[1].
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
anim, currFrame: 1f, lowFrame: 0, highFrame: 1, partIndex: 0,
out Vector3 origin, out _);
Assert.True(ok);
Assert.Equal(new Vector3(2f, 0f, 0f), origin);
}
[Fact]
public void TryInterpolatePart_PartIndexOutOfRange_ReturnsFalse()
{
Animation anim = MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
anim, currFrame: 0f, lowFrame: 0, highFrame: 1, partIndex: 5,
out Vector3 origin, out Quaternion orientation);
Assert.False(ok);
Assert.Equal(default(Vector3), origin);
Assert.Equal(default(Quaternion), orientation);
}
}