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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,135 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback,
/// mirroring <c>gmCG3DView::StartAnimation</c>/<c>StopAnimation</c>'s swap
/// (<c>0x004EE600</c>/<c>0x004EE640</c>) and
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s immediate call into it
/// (<c>0x0047D024</c>/<c>0x0047D160</c> — the swap happens the instant the
/// button is pressed, NOT once the camera's own 0.6s tween finishes).
///
/// <para>
/// <b>Retail default is idle-PLAYING, not frozen</b> — see
/// <see cref="ChargenPreviewEntityBuilder"/>'s class doc for the decomp
/// citations. This class's own default (<see cref="IsZoomedIn"/> starts
/// <c>false</c>) reproduces that: its constructor immediately plays the
/// idle animation's frame 0 when one resolved, matching
/// <c>gmCGAppearancePage::Update</c>'s own trailing
/// <c>if (m_bZoomedIn == 0) StartAnimation()</c> gate
/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/
/// appearance change too — <see cref="SetZoomedIn"/> restarts the idle loop
/// at frame 0 on every transition INTO the playing state for the same
/// reason: <c>set_sequence_animation</c>'s <c>arg3=1</c> clears the sequence
/// before appending, so every <c>StartAnimation</c> call restarts the clip.
/// </para>
///
/// <para>
/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out
/// buttons to <see cref="SetZoomedIn"/> and the render loop to
/// <see cref="Tick"/>; nothing in this repository calls either yet.
/// </para>
/// </summary>
internal sealed class ChargenPreviewAnimator
{
/// <summary>
/// <c>gmCG3DView::StartAnimation</c>'s literal framerate argument
/// (<c>set_sequence_animation(this->m_pPlayerObject,
/// this->m_didAnimation.id, 1, 0, 30f)</c>, pseudo-C ~0x004ee61b).
/// </summary>
public const float IdleFramerate = 30f;
private readonly ChargenPreviewAnimatedBuild _build;
private float _currFrame;
private bool _zoomedIn;
public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build)
{
_build = build ?? throw new ArgumentNullException(nameof(build));
_currFrame = build.IdleLowFrame;
if (build.IdleAnimation is not null)
ApplyIdleFrame(); // retail's true default: idle playing, frame 0.
// Else: Entity.MeshRefs already holds RestMeshRefs (set by
// TryBuildAnimated) as the best available fallback.
}
/// <summary>The live preview entity — mutated in place by <see cref="Tick"/>
/// and <see cref="SetZoomedIn"/>; the renderer never needs to re-call
/// <c>SetPreview</c> after the first assignment (<c>WorldEntity.MeshRefs</c>
/// is read fresh every draw — see its own doc comment).</summary>
public WorldEntity Entity => _build.Entity;
public bool IsZoomedIn => _zoomedIn;
/// <summary>
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s
/// <c>StopAnimation</c>/<c>StartAnimation</c> call, applied immediately
/// (retail does not wait for the camera tween to finish before swapping
/// animation state — see this class's own doc comment). No-op if
/// already in the requested state, matching retail's own early-return
/// guards (<c>ZoomIn</c>'s <c>if (m_bZoomedIn != 0) return</c>,
/// <c>ZoomOut</c>'s mirror).
/// </summary>
public void SetZoomedIn(bool zoomedIn)
{
if (_zoomedIn == zoomedIn)
return;
_zoomedIn = zoomedIn;
if (zoomedIn)
{
_build.Entity.MeshRefs = _build.RestMeshRefs;
}
else
{
_currFrame = _build.IdleLowFrame;
if (_build.IdleAnimation is not null)
ApplyIdleFrame();
}
}
/// <summary>
/// Advances the idle loop by <paramref name="elapsedSeconds"/>. No-op
/// while zoomed in (the rest pose is frozen — retail's framerate-0
/// <c>set_sequence_animation</c> call never advances) or when no idle
/// Animation resolved (heritage/DID gap; the entity keeps whatever pose
/// the constructor seeded).
/// </summary>
public void Tick(float elapsedSeconds)
{
if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f)
return;
_currFrame = RetailAnimationCyclePlayback.Advance(
_currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds);
ApplyIdleFrame();
}
private void ApplyIdleFrame()
{
DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!;
IReadOnlyList<ChargenPreviewDrawablePart> parts = _build.DrawableParts;
var meshRefs = new List<MeshRef>(parts.Count);
foreach (ChargenPreviewDrawablePart part in parts)
{
bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart(
animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame,
part.SetupPartIndex, out Vector3 origin, out Quaternion orientation);
// Same defensive default as ApplyHeldPoseTransforms: a part
// index the bracketing frame doesn't cover (a Setup/Animation
// part-count mismatch, never expected in practice) keeps
// identity rather than a degenerate zero quaternion.
if (!resolved)
{
origin = Vector3.Zero;
orientation = Quaternion.Identity;
}
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation);
meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides });
}
_build.Entity.MeshRefs = meshRefs;
}
}

View file

@ -25,10 +25,15 @@ namespace AcDream.App.Rendering;
/// button (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>) advances a
/// HEADING applied to the preview CHARACTER (<c>CPhysicsObj::set_heading</c>
/// inside <c>gmCG3DView::Update</c>, pseudo-C ~242088) — the camera's own
/// position/direction never change during a rotation. CC6b's heading
/// parameter therefore belongs on the entity builder
/// (<see cref="ChargenPreviewEntityBuilder"/>), not here; this class stays a
/// fixed-per-heritage eye, exactly like retail's own camera.
/// position/direction never change during a rotation. The heading itself
/// lives on <see cref="ChargenPreviewRotationController"/> (CC6b: the
/// <c>DoRotation</c>/<c>Rotate</c> port) and is applied to the entity via
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>/<c>TryBuildAnimated</c>'s
/// <c>heading</c> parameter, not here; this class stays a fixed-per-heritage
/// eye, exactly like retail's own camera. <see cref="ChargenPreviewZoomController"/>
/// (CC6b: the <c>ZoomIn</c>/<c>ZoomOut</c>/<c>DoZoomAnimation</c> port) DOES
/// mutate this class's <see cref="Eye"/> — zoom is a camera concern, unlike
/// rotation.
/// </para>
/// </summary>
public sealed class ChargenPreviewCamera : ICamera

View file

@ -10,7 +10,50 @@ using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the static-pose chargen preview <see cref="WorldEntity"/> from a
/// One resolved drawable part of the chargen preview body — a Setup part
/// index (needed to sample <c>Animation.PartFrames[frame].Frames[index]</c>
/// and <c>Setup.DefaultScale[index]</c>) paired with its resolved GfxObj id,
/// default scale (captured once at build time — scale never changes across
/// an idle cycle), and surface overrides. <see cref="ChargenPreviewAnimator"/>
/// walks this list every tick without touching the dat source again.
/// </summary>
internal readonly record struct ChargenPreviewDrawablePart(
int SetupPartIndex,
uint GfxObjId,
Vector3 DefaultScale,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides);
/// <summary>
/// The richer sibling of <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
/// result: the built <see cref="WorldEntity"/> (seeded with retail's true
/// default pose — see <see cref="ChargenPreviewAnimator"/>) plus everything
/// needed to drive it frame-by-frame without re-touching the dat source —
/// the resolved drawable parts, the precomputed frozen rest pose, and the
/// resolved idle Animation + its frame range.
/// </summary>
internal sealed class ChargenPreviewAnimatedBuild
{
public required WorldEntity Entity { get; init; }
public required IReadOnlyList<ChargenPreviewDrawablePart> DrawableParts { get; init; }
/// <summary>
/// The held final-frame rest pose, precomputed once (retail:
/// <c>gmCG3DView::StopAnimation</c>'s framerate-0
/// <c>set_sequence_animation</c> call never advances, so there is
/// nothing to recompute per tick while zoomed in). Falls back to each
/// part's raw Setup-default transform (no-op) when the rest DID doesn't
/// resolve, matching the pre-CC6b <c>ApplyHeldPose</c> no-op behavior.
/// </summary>
public required IReadOnlyList<MeshRef> RestMeshRefs { get; init; }
/// <summary>Retail's live idle DID (<c>m_didAnimation</c>), or null if unresolved.</summary>
public Animation? IdleAnimation { get; init; }
public int IdleLowFrame { get; init; }
public int IdleHighFrame { get; init; }
}
/// <summary>
/// Builds the chargen preview <see cref="WorldEntity"/> from a
/// <see cref="ChargenAppearanceResult"/> — the App-layer counterpart to
/// <see cref="DollEntityBuilder"/>, except this one resolves its OWN
/// MeshRefs from a Setup + the composed ObjDesc rather than receiving
@ -20,7 +63,30 @@ namespace AcDream.App.Rendering;
/// closest existing precedent for the actual mesh-flatten/apply-changes/
/// resolve-surface-overrides steps is
/// <c>DatLiveEntityProjectionMaterializer.TryMaterialize</c>, trimmed to
/// what a private, non-animated, non-collision preview scene needs.
/// what a private, non-collision preview scene needs.
///
/// <para>
/// <b>CC6b:</b> retail's chargen preview does NOT default to a frozen pose —
/// <c>gmCGAppearancePage::Update</c>'s own trailing gate
/// (~0x0047EF01-0x0047EF12) calls <c>gmCG3DView::StartAnimation</c> (idle
/// loop playing) whenever <c>m_bZoomedIn == 0</c>, and that field is never
/// explicitly initialized away from its zero-initialized default in the
/// ctor (<c>gmCGAppearancePage::gmCGAppearancePage</c>, pseudo-C
/// ~0x0047CD58-0x0047CD64 — <c>m_bShouldZoomAnimate</c>/<c>m_bRotating</c>/
/// <c>m_bZoomedIn</c> are three consecutive bool bytes the decompiler shows
/// only the first two of, a known decompiler-elision class per
/// <c>claude-memory/feedback_bn_decomp_field_names.md</c>). So retail's
/// chargen preview plays its idle loop (<c>m_didAnimation</c>, 30fps) from
/// the very first frame; the REST pose (<c>m_didAnimationRest</c>, held
/// final frame, this class's pre-CC6b-only behavior) only appears once the
/// user presses Zoom In (<c>gmCGAppearancePage::ZoomIn</c> calls
/// <c>gmCG3DView::StopAnimation</c> immediately, before its camera tween
/// even starts). <see cref="TryBuild"/> keeps its ORIGINAL (rest-only)
/// behavior unchanged for its existing callers; <see cref="TryBuildAnimated"/>
/// plus <see cref="ChargenPreviewAnimator"/> are the new, retail-accurate
/// entry point a live preview (idle-playing by default, freezing on zoom-in)
/// should use.
/// </para>
/// </summary>
internal static class ChargenPreviewEntityBuilder
{
@ -37,8 +103,8 @@ internal static class ChargenPreviewEntityBuilder
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>
/// Retail's held-pose animation DID enum key, resolved through master
/// map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
/// — 0x10000005 for every standard heritage (the SAME enum id the
/// paperdoll's own held pose reads), matching
/// <c>gmCG3DView</c>'s ctor / <c>::Update</c> per-heritage
@ -55,10 +121,35 @@ internal static class ChargenPreviewEntityBuilder
};
/// <summary>
/// Builds the preview entity, or null when the resolved body Setup
/// isn't in the dat source (a corrupted/incomplete install — the same
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
/// as "drop this spawn").
/// Retail's LIVE idle-loop animation DID enum key (<c>m_didAnimation</c>,
/// the one <c>gmCG3DView::StartAnimation</c> plays at 30fps) — 0x10000006
/// for every standard heritage, matching <c>gmCG3DView</c>'s ctor /
/// <c>::Update</c> per-heritage assignment (pseudo-C ~0x004ee6cc,
/// ~0x004eec2d). <b>Olthoi and OlthoiAcid use the SAME did for BOTH idle
/// and rest</b> (0x10000011 / 0x10000013 respectively, pseudo-C
/// ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8) — a genuine retail
/// quirk, not a porting shortcut: those two heritages show no visible
/// difference between "idle playing" and "zoomed in and frozen" in the
/// chargen preview.
/// </summary>
private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000006u,
};
/// <summary>
/// Builds the STATIC (held rest-pose) preview entity, or null when the
/// resolved body Setup isn't in the dat source (a corrupted/incomplete
/// install — the same failure shape
/// <see cref="DatLiveEntityProjectionMaterializer"/> treats as "drop this
/// spawn"). Unchanged since CC6a — a thin wrapper over
/// <see cref="TryBuildAnimated"/> that keeps this method's existing
/// callers' behavior byte-identical. New code that wants retail's true
/// default (idle loop playing) should call
/// <see cref="TryBuildAnimated"/> and wrap the result in a
/// <see cref="ChargenPreviewAnimator"/> instead.
/// </summary>
/// <param name="datLock">
/// Shared exclusion object for every dat read this method performs.
@ -79,21 +170,48 @@ internal static class ChargenPreviewEntityBuilder
uint heritageId,
Quaternion heading,
object datLock)
{
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
dats, animations, appearance, heritageId, heading, datLock);
if (build is null)
return null;
build.Entity.MeshRefs = build.RestMeshRefs;
return build.Entity;
}
/// <summary>
/// Builds the preview entity PLUS everything a <see cref="ChargenPreviewAnimator"/>
/// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching
/// the dat source. The returned <see cref="ChargenPreviewAnimatedBuild.Entity"/>
/// is initially posed with <see cref="ChargenPreviewAnimatedBuild.RestMeshRefs"/>
/// (cheap, always available) — <see cref="ChargenPreviewAnimator"/>'s
/// constructor immediately reposes it to the true retail default (idle
/// frame 0) when an idle Animation resolved.
/// </summary>
public static ChargenPreviewAnimatedBuild? TryBuildAnimated(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading,
object datLock)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animations);
ArgumentNullException.ThrowIfNull(appearance);
ArgumentNullException.ThrowIfNull(datLock);
List<MeshRef> meshRefs;
uint setupId = appearance.SetupId;
PaletteOverride? paletteOverride;
PartOverride[] partOverrides;
List<ChargenPreviewDrawablePart> drawableParts;
List<MeshRef> restMeshRefs;
Animation? idleAnimation;
int idleLowFrame = 0, idleHighFrame = -1;
// Every dat read this method performs — the Setup fetch, the held-
// pose animation resolution, the per-part GfxObj drawable checks,
// and the texture-change surface resolution — happens inside this
// one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve
// Every dat read this method performs — the Setup fetch, both pose
// DID resolutions, the per-part GfxObj drawable checks, and the
// texture-change surface resolution — happens inside this one lock,
// mirroring RetailPaperdollPoseApplicator.Apply's "resolve
// everything under lock, then do pure processing" shape.
lock (datLock)
{
@ -109,12 +227,16 @@ internal static class ChargenPreviewEntityBuilder
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
}
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
// Rest pose: overwrite flattened's transforms with the held
// final frame (no-op — keeps Setup-default transforms — if the
// rest DID or its Animation don't resolve).
ApplyHeldPoseTransforms(dats, animations, setup, ResolveRestPoseEnum(heritageId), flattened);
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
meshRefs = new List<MeshRef>(flattened.Count);
drawableParts = new List<ChargenPreviewDrawablePart>(flattened.Count);
restMeshRefs = new List<MeshRef>(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
@ -125,27 +247,52 @@ internal static class ChargenPreviewEntityBuilder
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
overrides = perPart;
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
restMeshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
Vector3 defaultScale = partIndex < setup.DefaultScale.Count
? setup.DefaultScale[partIndex]
: Vector3.One;
drawableParts.Add(new ChargenPreviewDrawablePart(partIndex, part.GfxObjId, defaultScale, overrides));
}
if (meshRefs.Count == 0)
if (drawableParts.Count == 0)
return null;
paletteOverride = BuildPaletteOverride(appearance);
partOverrides = BuildPartOverrides(appearance);
// Idle DID: independent lookup, no mutation of flattened.
uint idleDid = RetailHeldPose.ResolvePoseDid(dats, ResolveIdleAnimEnum(heritageId));
idleAnimation = (idleDid >> 24) == 0x03u ? animations.LoadAnimation(idleDid) : null;
if (idleAnimation is not null && idleAnimation.PartFrames.Count > 0)
{
idleLowFrame = 0;
idleHighFrame = idleAnimation.PartFrames.Count - 1;
}
else
{
idleAnimation = null;
}
}
return new WorldEntity
var entity = new WorldEntity
{
Id = PreviewRenderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = heading,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = partOverrides,
MeshRefs = restMeshRefs,
PaletteOverride = BuildPaletteOverride(appearance),
PartOverrides = BuildPartOverrides(appearance),
ParentCellId = null,
};
return new ChargenPreviewAnimatedBuild
{
Entity = entity,
DrawableParts = drawableParts,
RestMeshRefs = restMeshRefs,
IdleAnimation = idleAnimation,
IdleLowFrame = idleLowFrame,
IdleHighFrame = idleHighFrame,
};
}
/// <summary>No dat access — pure projection of the already-composed
@ -178,8 +325,8 @@ internal static class ChargenPreviewEntityBuilder
}
/// <summary>
/// Overwrites every part's transform from the resolved rest pose's
/// FINAL frame — same "hold the settled last frame at zero frame rate"
/// Overwrites every part's transform from the resolved pose DID's FINAL
/// frame — same "hold the settled last frame at zero frame rate"
/// approach as <c>RetailPaperdollPoseApplicator.Apply</c>
/// (<c>RedressCreature @ 0x004A3C22</c>), applied to the FULL
/// setup-part-indexed array (before drawable filtering) so the index
@ -187,14 +334,14 @@ internal static class ChargenPreviewEntityBuilder
/// GfxObj. No-ops (keeps the default placement frame) when the pose
/// DID or its animation can't be resolved.
/// </summary>
private static void ApplyHeldPose(
private static void ApplyHeldPoseTransforms(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint heritageId,
uint poseEnum,
List<MeshRef> flattened)
{
uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId));
uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum);
if ((poseDid >> 24) != 0x03u)
return;
@ -214,32 +361,12 @@ internal static class ChargenPreviewEntityBuilder
orientation = frame.Frames[index].Orientation;
}
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
flattened[index] = new MeshRef(flattened[index].GfxObjId, transform);
flattened[index] = new MeshRef(
flattened[index].GfxObjId,
RetailHeldPose.ComposePartTransform(scale, origin, orientation));
}
}
/// <summary>
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent — verbatim
/// port of <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>,
/// parameterized by the target enum key.
/// </summary>
private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
{
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s

View file

@ -14,22 +14,32 @@ namespace AcDream.App.Rendering;
/// paperdoll's fixed one.
///
/// <para>
/// <b>NOT wired here (CC6b, after CC4 merges per the campaign's parallelism
/// contract):</b> mounting into the authored Appearance/Summary viewport ids
/// (<c>0x100003bb</c> / <c>0x10000406</c>), spin/color-wheel controls, and
/// the rotate/zoom buttons. This class is a standalone, composition-root-
/// agnostic renderer — nothing in <c>AcDream.App/UI/Layout/</c> or
/// <c>RetailUiRuntime.cs</c> references it yet.
/// <b>NOT wired here (CC6b page-mount half, after CC4 merges per the
/// campaign's parallelism contract):</b> mounting into the authored
/// Appearance/Summary viewport ids (<c>0x100003bb</c> / <c>0x10000406</c>)
/// and binding the spin/color-wheel/rotate/zoom widgets to
/// <see cref="ChargenPreviewAnimator"/>/<see cref="ChargenPreviewRotationController"/>/
/// <see cref="ChargenPreviewZoomController"/>. This class is a standalone,
/// composition-root-agnostic renderer — nothing in
/// <c>AcDream.App/UI/Layout/</c> or <c>RetailUiRuntime.cs</c> references it
/// yet.
/// </para>
///
/// <para>
/// <b>Register row (staged deviation, retired by CC6b):</b> retail plays a
/// live 30fps idle loop in the preview
/// (<c>gmCG3DView</c>'s <c>m_didAnimation</c>/<c>m_didAnimArray</c>,
/// <c>set_sequence_animation</c>, distinct from the STATIC
/// <c>m_didAnimationRest</c> this class's entity builder uses). CC6a holds
/// the static rest-pose final frame only — see
/// <c>docs/architecture/retail-divergence-register.md</c>.
/// <b>CC6b (pre-mount half):</b> the preview now HAS a real live idle loop
/// (<see cref="ChargenPreviewAnimator"/>, retail's <c>m_didAnimation</c> DID
/// at 30fps via <c>set_sequence_animation</c>) instead of the CC6a-only held
/// rest pose — TS-83 is retired. <see cref="SetPreview"/> still accepts a
/// static <c>WorldEntity</c> for callers that only want
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>'s unchanged rest-pose
/// snapshot; a caller that wants the animated preview constructs a
/// <see cref="ChargenPreviewAnimator"/> from
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> and passes its
/// <c>Entity</c> here once — the animator mutates that SAME entity's
/// <c>MeshRefs</c> in place every <c>Tick</c>, and <c>Render</c> reads it
/// fresh (no re-<c>SetPreview</c> needed per frame; see
/// <c>WorldEntity.MeshRefs</c>'s own "mutable so the animation tick can
/// replace it each frame" doc comment).
/// </para>
/// </summary>
internal sealed class ChargenPreviewRenderer :

View 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);
}

View file

@ -0,0 +1,135 @@
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Presentation-free port of <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>
/// (<c>0x0047CF00</c>/<c>0x0047D050</c>) and <c>DoZoomAnimation</c>
/// (<c>0x0047C960</c>): a linear 0.6s tween of the preview camera's eye
/// between <see cref="ChargenPreviewCamera.ResolveDefaultEye"/> (zoomed IN)
/// and <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> (zoomed OUT),
/// driving the SAME <see cref="ChargenPreviewAnimator"/> zoom-state swap the
/// button presses trigger in retail — immediately, not once the tween
/// finishes (see <see cref="ChargenPreviewAnimator"/>'s own doc comment).
///
/// <para>
/// Retail drives <see cref="Tick"/> once per frame from a global-message-3
/// tick while <c>m_bShouldZoomAnimate</c> is set
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
/// CC6b page-mount half will bind the Zoom In/Out buttons to
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/> and the render loop to
/// <see cref="Tick"/>. Direction is always <c>(0,0,0)</c> for this camera
/// (see <see cref="ChargenPreviewCamera"/>'s own remarks), so only the eye
/// position tweens — retail's own <c>m_vectCurDirection</c> lerp is a no-op
/// here and is not reproduced.
/// </para>
/// </summary>
internal sealed class ChargenPreviewZoomController
{
/// <summary>
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s explicit invalidation write
/// (<c>this->m_dAnimDuration = -0.1</c>, pseudo-C ~0x0047cff1/0x0047cffb
/// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for
/// <c>-0.1</c>) so the very next <see cref="Tick"/> resets the duration
/// to <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/> and the
/// start time to "now", matching <c>DoZoomAnimation</c>'s own
/// reset-if-invalid guard exactly.
/// </summary>
private const double InvalidDurationSentinel = -0.1;
private readonly uint _heritageId;
private Vector3 _startEye;
private Vector3 _targetEye;
private double _animStartTime;
private double _animDuration;
private bool _shouldAnimate;
private bool _zoomedIn;
public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera)
{
ArgumentNullException.ThrowIfNull(camera);
_heritageId = heritageId;
Camera = camera;
}
public ChargenPreviewCamera Camera { get; }
/// <summary>Mirrors retail's <c>m_bZoomedIn</c> — false (not zoomed in)
/// is the ctor-implicit default, matching <see cref="ChargenPreviewAnimator"/>'s
/// own default (see that class's doc comment for the shared citation).</summary>
public bool IsZoomedIn => _zoomedIn;
/// <summary>
/// <c>gmCGAppearancePage::ZoomIn @ 0x0047CF00</c>: no-op if already
/// zoomed in (retail's own early-return guard). Otherwise starts a tween
/// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage
/// profile and swaps <paramref name="animator"/> to the frozen rest
/// pose IMMEDIATELY (<c>gmCG3DView::StopAnimation</c>'s call site,
/// pseudo-C ~0x0047d024, precedes the tween's own completion by
/// definition — it runs once, synchronously, inside <c>ZoomIn</c>
/// itself).
/// </summary>
public void ZoomIn(ChargenPreviewAnimator? animator)
{
if (_zoomedIn)
return;
StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId));
_zoomedIn = true;
animator?.SetZoomedIn(true);
}
/// <summary>
/// <c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>: no-op if not
/// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT
/// per-heritage profile and swaps <paramref name="animator"/> back to
/// the playing idle loop immediately, mirroring <see cref="ZoomIn"/>.
/// </summary>
public void ZoomOut(ChargenPreviewAnimator? animator)
{
if (!_zoomedIn)
return;
StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId));
_zoomedIn = false;
animator?.SetZoomedIn(false);
}
private void StartTween(Vector3 targetEye)
{
_startEye = Camera.Eye;
_targetEye = targetEye;
_shouldAnimate = true;
_animDuration = InvalidDurationSentinel;
}
/// <summary>
/// <c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>: a LINEAR
/// (not eased) lerp of the eye position from <c>m_vectStartPosition</c>
/// to <c>m_vectTargPosition</c> over
/// <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/>, clamping
/// <c>t</c> to exactly 1.0 (and clearing <c>m_bShouldZoomAnimate</c>) the
/// tick that reaches or passes the duration — the decomp shows a
/// straight <c>(targ - start) * t + start</c> per axis with no easing
/// curve applied anywhere in this function.
/// </summary>
public void Tick(double now)
{
if (!_shouldAnimate)
return;
if (_animDuration <= 0d)
{
_animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds;
_animStartTime = now;
}
double elapsed = now - _animStartTime;
if (elapsed >= _animDuration)
{
_shouldAnimate = false;
elapsed = _animDuration;
}
float t = (float)(elapsed / _animDuration);
Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t);
}
}

View file

@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
/// <summary>
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
/// Animation DID.
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
/// paperdoll's own fixed enum key.
/// </summary>
private uint ResolvePoseDid()
{
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
masterDid,
out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
subDid,
out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did)
? did
: 0u;
}
private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u);
public void Apply(WorldEntity doll, uint setupId)
{
@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
orientation = frame.Frames[index].Orientation;
}
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation);
MeshRef source = doll.MeshRefs[index];
reposed.Add(new MeshRef(source.GfxObjId, transform)
{

View file

@ -0,0 +1,61 @@
using System.Numerics;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// Shared primitives behind retail's "resolve a rest-pose DID via master-map
/// slot 7, load its Animation, hold the final frame" algorithm — the
/// mechanism <see cref="RetailPaperdollPoseApplicator"/> (paperdoll,
/// <c>gmPaperDollUI::RedressCreature @ 0x004A3C22</c>) and
/// <see cref="ChargenPreviewEntityBuilder"/> (chargen preview,
/// <c>gmCG3DView::StopAnimation @ 0x004EE640</c>) both implement. Extracted
/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a
/// shared <c>RetailHeldPose</c> helper is worth extracting before a fourth
/// held-pose consumer exists") — CC6b's own idle-loop work makes chargen's
/// implementation grow enough that mechanically sharing the two primitives
/// BOTH sites already had byte-identical (DID resolution, final-frame
/// transform composition) is a clean win without forcing the two sites'
/// slightly different per-index LOOP shapes (paperdoll walks an
/// already-built, already-filtered <c>WorldEntity.MeshRefs</c>; chargen
/// walks the pre-filter, Setup-part-indexed scratch list) into one method
/// they don't actually share.
/// </summary>
internal static class RetailHeldPose
{
/// <summary>
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent: master
/// map → slot 7's sub-map → <paramref name="poseEnum"/>'s Animation DID.
/// Returns 0 if any link in the chain is missing. MUST be called under
/// the caller's dat lock (see <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
/// <c>datLock</c> doc — <c>DatCollection</c> is not thread-safe).
/// </summary>
public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
{
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !dats.Portal.TryGet<EnumIDMap>(subDid, out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
}
/// <summary>
/// Retail's per-part pose transform: <c>Scale(defaultScale) *
/// Rotate(orientation) * Translate(origin)</c> — the SAME composition
/// both <c>RetailPaperdollPoseApplicator.Apply</c> and
/// <see cref="ChargenPreviewEntityBuilder"/>'s pose steps use, whether
/// the (origin, orientation) pair comes from a held final frame or an
/// interpolated idle-cycle frame.
/// </summary>
public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) =>
Matrix4x4.CreateScale(defaultScale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
}

View file

@ -12,14 +12,20 @@ namespace AcDream.Core.CharGen;
/// The body Setup dat id (0x02......) to build the preview mesh from —
/// <c>gender.SetupId</c>, overridden by the selected hair style's
/// <c>AlternateSetup</c> when it is neither 0 nor retail's <c>INVALID_DID</c>
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back
/// to <see cref="ChargenAppearanceFactory.HumanSetupId"/> when the resolved
/// id is 0 OR <c>INVALID_DID</c> (retail: <c>CharGenState::GetSetupID @
/// 0x005C5B22</c> and <c>gmCG3DView::Update</c>'s own check at
/// ~0x004EEA51/0x004EEA5F both test against <c>INVALID_DID</c>, not zero —
/// <c>acclient.h:39909</c> types the field as <c>IDClass</c>, whose "unset"
/// value is 0xFFFFFFFF; <c>CPhysicsObj::makeObject(setupId)</c>'s own
/// HUMAN_SETUP_ID fallback, <c>gmCG3DView</c> ctor pseudo-C ~0x004EE79D).
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn
/// overridden outright by <see cref="ChargenAppearanceFactory.TryCompose"/>'s
/// own <c>alternateSetupIdOverride</c> parameter when THAT is not
/// <c>INVALID_DID</c> (<c>gmCG3DView::Update</c>'s own
/// <c>m_alternateSetupID</c> resolution, ~0x004EEA46-0x004EEA53 — see that
/// parameter's doc for why chargen's own Appearance page never actually sets
/// it), falling back to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
/// when the resolved id is STILL 0 OR <c>INVALID_DID</c> after all three
/// tiers (retail: <c>CharGenState::GetSetupID @ 0x005C5B22</c> and
/// <c>gmCG3DView::Update</c>'s own check at ~0x004EEA5F both test against
/// <c>INVALID_DID</c>, not zero — <c>acclient.h:39909</c> types the field as
/// <c>IDClass</c>, whose "unset" value is 0xFFFFFFFF;
/// <c>CPhysicsObj::makeObject(setupId)</c>'s own HUMAN_SETUP_ID fallback,
/// <c>gmCG3DView</c> ctor pseudo-C ~0x004EE79D).
/// </param>
/// <param name="BasePaletteId">
/// <c>gender.BasePaletteId</c> (retail <c>Sex_CG.BasePalette</c>) — the
@ -136,6 +142,30 @@ public static class ChargenAppearanceFactory
/// contribution is skipped, matching retail's own "hash miss → no-op,
/// caller never checks BuildObjDesc's return value" behavior.
/// </summary>
/// <param name="alternateSetupIdOverride">
/// Retail's SECOND body-Setup-override source — <c>gmCG3DView</c>'s
/// <c>m_alternateSetupID</c> field (default <c>INVALID_DID</c>, read at
/// <c>gmCG3DView::Update @ ~0x004EEA46-0x004EEA53</c>) — which, when set
/// to anything other than <c>INVALID_DID</c>, REPLACES the hairstyle/
/// gender-resolved Setup id outright rather than combining with it.
/// <b>Decomp-verified NOT to be a character-creation-time mechanism:</b>
/// every write site for <c>m_alternateSetupID</c> (the Penumbraen-crown
/// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/
/// 0x004E0DB1) lives on <c>gmBarberUI</c> — the POST-CREATION barber-
/// shop appearance-editing screen, a wholly separate UI class from
/// character creation's <c>gmCGAppearancePage</c>, which has no
/// <c>m_pOption1Checkbox</c>-equivalent field and never writes
/// <c>m_alternateSetupID</c> anywhere in its own methods (confirmed
/// against every field on <c>gmCGAppearancePage</c>,
/// <c>acclient.h:56373-56428</c>). For chargen's own preview,
/// <c>m_alternateSetupID</c> is therefore ALWAYS <c>INVALID_DID</c> in
/// retail, and this parameter's default (<see cref="InvalidDid"/>)
/// reproduces that exactly — a real, decomp-verified precedence tier is
/// threaded through so a future non-chargen consumer of this same
/// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can
/// supply one, without inventing a UI source chargen's own Appearance
/// page doesn't have.
/// </param>
public static bool TryCompose(
ChargenOptions options,
uint heritageId,
@ -143,7 +173,8 @@ public static class ChargenAppearanceFactory
ChargenAppearanceSelection selection,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
out ChargenAppearanceResult result)
out ChargenAppearanceResult result,
uint alternateSetupIdOverride = InvalidDid)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(palSets);
@ -170,6 +201,14 @@ public static class ChargenAppearanceFactory
if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid)
setupId = hairStyle.AlternateSetup;
}
// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID,
// when set, REPLACES the hairstyle/gender-resolved id outright — it
// does not combine with it. See alternateSetupIdOverride's own doc
// for why chargen's own Appearance page never actually supplies one.
if (alternateSetupIdOverride != InvalidDid)
setupId = alternateSetupIdOverride;
if (setupId == 0 || setupId == InvalidDid)
setupId = HumanSetupId;

View 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"/> &lt;= <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;
}
}

View file

@ -0,0 +1,154 @@
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);
}
}

View file

@ -124,4 +124,155 @@ public sealed class ChargenPreviewEntityBuilderTests
Assert.NotNull(entity);
Assert.NotEmpty(entity!.MeshRefs);
}
/// <summary>
/// CC6b: <c>TryBuildAnimated</c> resolves a real idle Animation (retail's
/// <c>m_didAnimation</c>) against the installed EoR dat, with a usable
/// frame range and a non-empty drawable-part list a
/// <c>ChargenPreviewAnimator</c> can drive.
/// </summary>
[Fact]
public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian));
Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male));
var catalog = new ChargenAppearanceCatalog(adapter);
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
{
HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
SkinShade = 0.5,
};
bool composed = ChargenAppearanceFactory.TryCompose(
options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance);
Assert.True(composed);
var animations = new RetailAnimationLoader(adapter);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object());
Assert.NotNull(build);
Assert.NotEmpty(build!.DrawableParts);
Assert.NotEmpty(build.RestMeshRefs);
Assert.NotNull(build.IdleAnimation);
Assert.True(build.IdleHighFrame >= build.IdleLowFrame);
Assert.True(build.IdleAnimation!.PartFrames.Count > build.IdleHighFrame);
// Live end-to-end: an Animator built from this resolves a non-empty,
// playable preview — retail's true default (idle playing), not the
// frozen rest pose TryBuild alone still returns.
var animator = new ChargenPreviewAnimator(build);
Assert.False(animator.IsZoomedIn);
Assert.NotEmpty(animator.Entity.MeshRefs);
animator.Tick(1f / 30f); // one frame's worth — must not throw or empty the mesh.
Assert.NotEmpty(animator.Entity.MeshRefs);
}
[Fact]
public void TryBuildAnimated_UnknownSetupId_ReturnsNull()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var animations = new RetailAnimationLoader(adapter);
var bogusAppearance = new ChargenAppearanceResult(
SetupId: 0x0200_FFFFu,
BasePaletteId: 0u,
ObjDesc: ChargenObjDesc.Empty,
MissingPalSetIds: [],
MissingClothingTableIds: [],
ClothingTablesMissingBaseEffectForSetup: []);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object());
Assert.Null(build);
}
/// <summary>
/// Decomp-verified quirk (<c>gmCG3DView</c>'s ctor / <c>::Update</c>,
/// pseudo-C ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8): Olthoi
/// and OlthoiAcid use the SAME enum key (0x10000011 / 0x10000013) for
/// BOTH the live idle DID (<c>m_didAnimation</c>) and the rest DID
/// (<c>m_didAnimationRest</c>) — every standard heritage uses two
/// DIFFERENT keys (0x10000006 idle vs 0x10000005 rest). This proves the
/// SHARED enum key resolves to a real installed Animation DID (the same
/// <c>RetailHeldPose.ResolvePoseDid</c> call
/// <c>ChargenPreviewEntityBuilder</c>'s <c>ResolveIdleAnimEnum</c> AND
/// <c>ResolveRestPoseEnum</c> both return for these two heritages) — the
/// enum-key identity itself is source-verified (both private methods
/// literally return the SAME numeric constant for Olthoi/OlthoiAcid, see
/// their own doc comments), so a single resolution here is enough to
/// confirm the shared key is not a dead/unresolvable id.
/// </summary>
[Theory]
[InlineData(0x10000011u)] // Olthoi's shared idle/rest enum key.
[InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key.
public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey)
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
uint did = RetailHeldPose.ResolvePoseDid(adapter, sharedEnumKey);
Assert.NotEqual(0u, did);
Assert.Equal(0x03u, did >> 24); // resolves to a real Animation DID.
}
/// <summary>
/// Extends <see cref="TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages"/>
/// to the idle side: <c>TryBuildAnimated</c> resolves a real idle
/// Animation for Olthoi too (not just the rest pose the older
/// <c>TryBuild</c>-only test covers), so an Olthoi
/// <c>ChargenPreviewAnimator</c> actually plays instead of silently
/// falling back to the rest-only pose.
/// </summary>
[Fact]
public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi));
Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)
|| olthoi.GendersByKey.TryGetValue(2, out male));
Assert.NotNull(male);
int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key;
var catalog = new ChargenAppearanceCatalog(adapter);
var animations = new RetailAnimationLoader(adapter);
bool composed = ChargenAppearanceFactory.TryCompose(
options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 },
catalog, catalog, out ChargenAppearanceResult appearance);
Assert.True(composed);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object());
Assert.NotNull(build);
Assert.NotNull(build!.IdleAnimation);
var animator = new ChargenPreviewAnimator(build);
Assert.False(animator.IsZoomedIn);
Assert.NotEmpty(animator.Entity.MeshRefs);
}
}

View file

@ -0,0 +1,117 @@
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Pure (no dat access) tests for <see cref="ChargenPreviewRotationController"/>
/// — the port of <c>gmCGAppearancePage::Rotate</c>/<c>DoRotation</c>
/// (<c>0x0047CB50</c>/<c>0x0047CA80</c>).
/// </summary>
public sealed class ChargenPreviewRotationControllerTests
{
[Fact]
public void Toggle_StartsRotatingInTheGivenDirection()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
Assert.True(controller.IsRotating);
Assert.Equal(ChargenRotateDirection.Clockwise, controller.Direction);
}
[Fact]
public void Toggle_SameDirectionWhileRotating_Stops()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Toggle(ChargenRotateDirection.Clockwise);
Assert.False(controller.IsRotating);
}
[Fact]
public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Toggle(ChargenRotateDirection.CounterClockwise);
Assert.True(controller.IsRotating);
Assert.Equal(ChargenRotateDirection.CounterClockwise, controller.Direction);
}
[Fact]
public void Tick_WhileNotRotating_IsANoOp()
{
var controller = new ChargenPreviewRotationController();
controller.Tick(100.0);
Assert.Equal(0f, controller.HeadingDegrees);
}
[Fact]
public void Tick_FirstCallAfterToggle_ContributesZeroDelta()
{
// Rotate() invalidates m_dLastRotateTime so the very first DoRotation
// tick resets it to "now" rather than computing a huge jump from a
// stale/never-set timestamp.
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Tick(1000.0);
Assert.Equal(0f, controller.HeadingDegrees);
}
[Fact]
public void Tick_ClockwiseAdvance_AddsTheExactPerTickFormula()
{
// deltaDegrees = ((now - last) / RotationSecondsPerRevolution) * 360.
// Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid
// guard, same as retail's own sentinel check would if Timer::cur_time
// could ever read exactly zero — never in practice, so tests avoid
// it too).
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
controller.Tick(11.5); // half a revolution at 3 s/rev.
Assert.Equal(180f, controller.HeadingDegrees, 3);
}
[Fact]
public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.CounterClockwise);
controller.Tick(10.0);
controller.Tick(11.5); // would go to -180, wraps to +180.
Assert.Equal(180f, controller.HeadingDegrees, 3);
}
[Fact]
public void Tick_AccumulatesAcrossMultipleTicks()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Tick(10.0);
controller.Tick(10.5); // +60 deg.
controller.Tick(11.0); // +60 deg more.
Assert.Equal(120f, controller.HeadingDegrees, 3);
}
[Fact]
public void ToOrientation_AtZeroHeading_IsIdentity()
{
var controller = new ChargenPreviewRotationController();
Quaternion orientation = controller.ToOrientation();
Assert.Equal(Quaternion.Identity.X, orientation.X, 4);
Assert.Equal(Quaternion.Identity.Y, orientation.Y, 4);
Assert.Equal(Quaternion.Identity.Z, orientation.Z, 4);
Assert.Equal(Quaternion.Identity.W, orientation.W, 4);
}
}

View file

@ -0,0 +1,166 @@
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);
}
}

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);
}
}