Merge campaign-cc6a: CC6a preview foundation + CC6b-PRE animation half, review-closed

CC6a (index->ObjDesc factory + static-pose offscreen renderer) and
CC6b-PRE (idle loop, rotation, zoom, alternate-setup plumbing) both
closed through dual-lens review -> fix round -> narrow re-review. The
branch carries its own cross-branch renumbering (TS-84, ISSUES #403) so
this merge is number-clean against the CC4 rows.

Notable review outcomes carried in: the barber refutation (chargen has
NO alternate-setup checkbox — all five write sites are gmBarberUI), the
idle-by-default finding with its corrected InitializePage evidence, and
the 180-degree initial heading owed to the mount half.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-08-15 19:40:15 +02:00
commit 11374484dc
27 changed files with 4294 additions and 28 deletions

View file

@ -24,6 +24,39 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch
**Status:** OPEN (post-CC consolidation follow-up)
**Severity:** LOW
**Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5)
**Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`,
`src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs`
`RetailAnimationCyclePlayback` (advance-with-wrap + lerp/slerp) is a Core,
pure, unit-tested extraction of the SAME algorithm
`LiveEntityAnimationPresenter.Present`'s legacy (no-`AnimationSequencer`)
branch already carries inline for NPC idle cycles
(`CurrFrame += legacyAdvanceSeconds * Framerate` with the same modulo wrap,
plus its own private `TryResolvePartFrame` doing the same frame-bracket
lerp/slerp). The chargen preview (`ChargenPreviewAnimator`) consumes the
new shared type; the two implementations were deliberately left
un-consolidated at CC6b-PRE — `LiveEntityAnimationPresenter` is live,
heavily-tested, in-flight production entity-rendering code with zero
relation to the preview-only feature that motivated the extraction, so
touching it was judged out of that slice's blast radius.
That decision has no tracked owner. Someone should, in a dedicated pass
after Campaign CC closes: redirect `LiveEntityAnimationPresenter`'s inline
copy through `RetailAnimationCyclePlayback` (a behavior-preserving
mechanical swap — same formulas, same order of operations) and delete the
duplicate. Verify byte-identical output first (a differential test against
the pre-change behavior over a representative NPC idle set) before landing.
**Acceptance:** one call site for the advance-with-wrap + lerp/slerp
algorithm; `LiveEntityAnimationPresenter`'s legacy branch calls
`RetailAnimationCyclePlayback` instead of reimplementing it; no behavior
change to any currently-animated NPC.
## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate
**Status:** OPEN (flake, not a regression)

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,157 @@
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.
/// The DEFAULT-false claim itself rests on <c>gmCGAppearancePage::InitializePage
/// @ 0x0047FDD0</c>'s explicit <c>this-&gt;m_bZoomedIn = 0;</c> at
/// <c>0x004802C3</c> — written immediately after that same function sets the
/// camera to the zoomed-IN per-heritage eye (<c>0x00480286-0x0048029E</c>),
/// not from the ctor simply never touching the field (heap <c>operator new</c>
/// memory is indeterminate, not zero — that argument doesn't hold on its
/// own). One retail quirk this implies: the character starts framed close-up
/// AND not-zoomed-in at the same time, so the FIRST Zoom In click (once
/// mounted) tweens close-eye→close-eye — visually null — while still
/// freezing the animation; the port reproduces this faithfully rather than
/// treating it as a bug.
/// </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;
// Double-buffered so a 30fps Tick doesn't allocate a fresh List<MeshRef>
// every frame: one buffer is whatever Entity.MeshRefs currently points
// at (potentially still being read by the renderer's own Render() call
// for this frame), the other is safe to Clear()+refill for the NEXT
// tick and only gets published once fully populated.
private readonly List<MeshRef> _meshRefsBufferA = [];
private readonly List<MeshRef> _meshRefsBufferB = [];
private bool _nextBufferIsA = true;
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;
List<MeshRef> meshRefs = _nextBufferIsA ? _meshRefsBufferA : _meshRefsBufferB;
_nextBufferIsA = !_nextBufferIsA;
meshRefs.Clear();
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

@ -0,0 +1,185 @@
using System;
using System.Numerics;
using AcDream.Core.CharGen;
namespace AcDream.App.Rendering;
/// <summary>
/// Heritage-parameterized camera for the chargen 3D preview
/// (<c>gmCG3DView</c>, Appearance page viewport <c>0x100003bb</c> / Summary
/// <c>0x10000406</c>). Retail-exact eye positions, ported from
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c> (pseudo-C ~139037-139114,
/// which sets <c>m_vectTargPosition</c>/<c>m_vectCurPosition</c> per
/// heritage and snaps them together with no tween — CC6a's static preview
/// renders that snapped default, the "zoomed-in" framing) and cross-checked
/// against the IDENTICAL literals in <c>gmCGAppearancePage::ZoomIn @
/// 0x0047CF00</c> (pseudo-C ~137618-137638). Direction is always
/// <c>(0,0,0)</c> ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view
/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention
/// <see cref="DollCamera"/> already established for the paperdoll (look
/// straight down +Y, +Z up); every camera position below is used AS the
/// world-space eye directly, matching that camera's approach.
///
/// <para>
/// <b>Rotation is NOT a camera property.</b> Retail's continuous-rotation
/// 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. 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
{
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera.
private Vector3 _eye;
public ChargenPreviewCamera(uint heritageId = 0u)
{
_eye = ResolveDefaultEye(heritageId);
}
/// <summary>
/// The camera's current world-space eye. Settable so CC6b can react to a
/// heritage change without reconstructing the camera.
/// </summary>
public Vector3 Eye
{
get => _eye;
set => _eye = value;
}
/// <summary>Re-derives <see cref="Eye"/> for the given heritage id (retail's <c>mHeritageGroup</c>).</summary>
public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId);
/// <summary>
/// Retail default (zoomed-in) camera eye per heritage. All four profiles
/// share <c>X=0</c>; only <c>(Y, Z)</c> — the AC world-space forward
/// offset and height — vary. FOUR distinct profiles across the 13
/// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho,
/// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen,
/// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME
/// numeric offset as Gearknight's own dedicated branch in the decomp.
/// </summary>
public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f),
(uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f),
_ => new Vector3(0f, -0.550000012f, 1.64999998f),
};
/// <summary>
/// Retail zoomed-OUT camera eye per heritage
/// (<c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>, pseudo-C
/// ~137671-137687). CC6a does not implement the zoom button (CC6b) —
/// recorded here as the verified target CC6b's tween will animate
/// toward. Olthoi/OlthoiAcid each keep their own dedicated profile;
/// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is
/// special-cased but whose zoomed-OUT is not — shares one value.
/// </summary>
public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f),
(uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f),
_ => new Vector3(0f, -2.5f, 0.95f),
};
/// <summary>
/// Seconds per 360° revolution for the continuous-rotation button
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, ctor pseudo-C
/// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000,
/// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly,
/// no reconstruction needed). Retail's own per-tick formula
/// (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>, pseudo-C
/// ~0x0047CAC7): <c>deltaDegrees = ((now - lastRotateTime) /
/// RotationSecondsPerRevolution) * 360</c> — CC6b's rotation controller
/// consumes this constant in exactly that shape, not as a
/// degrees-per-second rate. NOT applied here; see this class's own doc
/// comment on why rotation is not a camera concern.
/// </summary>
public const float RotationSecondsPerRevolution = 3.0f;
/// <summary>
/// Zoom tween duration in seconds
/// (<c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>'s
/// reset-if-invalid default, cross-confirmed by <c>ZoomIn</c>/<c>ZoomOut</c>'s
/// own <c>-0.1</c> sentinel write, which deliberately invalidates
/// <c>m_dAnimDuration</c> so the very next <c>DoZoomAnimation</c> tick
/// resets it to this same value). The campaign plan flagged this
/// constant as decompiler-garbled (both sites split the raw double
/// across two 32-bit stores, and the decompiler mis-renders the LOW
/// dword's store as a bogus float literal instead of raw bits) — it is
/// NOT unrecoverable: reinterpreting each garbled float literal as its
/// own raw 32-bit pattern and pairing it with the store's (clean) high
/// dword reconstructs an exact IEEE-754 double both times.
/// <c>DoZoomAnimation</c>'s own reset path: low32 from
/// <c>4.17232506e-08f</c> reinterpreted = <c>0x33333333</c>, high32 =
/// <c>0x3fe33333</c> (clean) → exactly <b>0.6</b>. Cross-check via
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s sentinel: low32 from
/// <c>-1.58818684e-23f</c> reinterpreted = <c>0x9999999A</c>, high32 =
/// <c>0xbfb99999</c> (clean) → exactly <b>-0.1</b>, the well-known
/// IEEE-754 bit pattern for -0.1 (<c>0xBFB999999999999A</c>) — confirming
/// the reconstruction technique itself, not just this one value.
/// </summary>
public const float ZoomTweenDurationSeconds = 0.6f;
public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera.
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 50f;
public float Aspect { get; set; } = 1f;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
}
/// <summary>
/// Internal private-viewport adapter, mirroring <c>DollViewportCamera</c>'s
/// role for <see cref="ChargenPreviewCamera"/>.
/// </summary>
internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera
{
private readonly ChargenPreviewCamera _camera;
public ChargenPreviewViewportCamera(uint heritageId = 0u)
{
_camera = new ChargenPreviewCamera(heritageId);
}
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public Vector3 Eye => _camera.Eye;
public float FovRadians
{
get => _camera.FovRadians;
set => _camera.FovRadians = value;
}
public float Near
{
get => _camera.Near;
set => _camera.Near = value;
}
public float Far
{
get => _camera.Far;
set => _camera.Far = value;
}
public float Aspect
{
get => _camera.Aspect;
set => _camera.Aspect = value;
}
public Matrix4x4 View => _camera.View;
public Matrix4x4 Projection => _camera.Projection;
}

View file

@ -0,0 +1,431 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// 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
/// already-resolved refs from a live entity (there is no live entity yet;
/// character creation hasn't happened). DAT-touching, unlike
/// <see cref="DollEntityBuilder"/>'s pure index-agnostic builder — the
/// 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-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 default is
/// DIRECTLY ASSIGNED, not inherited:
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> writes an
/// explicit <c>m_bZoomedIn = 0</c> at <c>0x004802C3</c> (right after
/// setting the camera to the zoomed-IN per-heritage eye at
/// <c>0x00480286-0x0048029E</c> — the null-tween quirk the zoom
/// controller's doc records). The earlier elided-ctor-byte argument was
/// unsound (heap-new members are indeterminate, not zero) and was
/// replaced by this citation at the CC6b-PRE re-review. 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
{
/// <summary>Reserved synthetic guid for the chargen preview clone —
/// same reserved family as <see cref="DollEntityBuilder.DollServerGuid"/>
/// (0xDA11D0xx) and <c>CreatureAppraisalEntityBuilder</c> (0xDA11D02x).</summary>
public const uint PreviewServerGuid = 0xDA11_D031u;
/// <summary>Reserved render-local entity id — passed in
/// <c>animatedEntityIds</c> by the renderer so a re-dress (a new
/// selection) bypasses <c>WbDrawDispatcher</c>'s Tier-1 classification
/// cache, mirroring <see cref="DollEntityBuilder.DollRenderId"/>'s own
/// doc comment.</summary>
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>
/// 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
/// <c>m_didAnimationRest</c> assignment (pseudo-C ~0x004EE948,
/// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest
/// DID — the one divergence from the paperdoll, which never needs an
/// Olthoi branch because a live player can't be one.
/// </summary>
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000005u,
};
/// <summary>
/// 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 for its RESULT — a thin wrapper over
/// <see cref="TryBuildAnimated"/> that returns exactly the same
/// <c>WorldEntity</c> (rest-posed) this method's existing callers already
/// expect; ALL 3 of those callers' tests still pass unmodified. Not
/// byte-identical internally any more — <see cref="TryBuildAnimated"/>
/// also resolves the idle DID and loads the idle Animation before this
/// wrapper discards them, extra dat work the pre-CC6b method never did.
/// 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.
/// <c>DatCollection</c> is NOT thread-safe (see
/// <c>claude-memory/feedback_phase_a1_hotfix_saga.md</c>) — every other
/// dat-touching renderer/resolver in this layer
/// (<c>RetailPaperdollPoseApplicator</c>, <c>PlayerModeController</c>,
/// <c>DatProjectileSetupResolver</c>, <c>EquippedChildRenderController</c>)
/// takes the SAME <c>object datLock</c> the composition root threads
/// through as <c>RuntimeOptions</c>/<c>d.DatLock</c>; callers MUST pass
/// that same shared instance, not a private lock, or this method's reads
/// race every other consumer's.
/// </param>
public static WorldEntity? TryBuild(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
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);
uint setupId = appearance.SetupId;
List<ChargenPreviewDrawablePart> drawableParts;
List<MeshRef> restMeshRefs;
Animation? idleAnimation;
int idleLowFrame = 0, idleHighFrame = -1;
// 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)
{
Setup? setup = dats.Get<Setup>(setupId);
if (setup is null)
return null;
var flattened = new List<MeshRef>(SetupMesh.Flatten(setup));
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
{
if (change.PartIndex < flattened.Count)
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
}
// 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);
drawableParts = new List<ChargenPreviewDrawablePart>(flattened.Count);
restMeshRefs = new List<MeshRef>(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
if (dats.Get<GfxObj>(part.GfxObjId) is null)
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
IReadOnlyDictionary<uint, uint>? overrides = null;
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
overrides = perPart;
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 (drawableParts.Count == 0)
return null;
// 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;
}
}
var entity = new WorldEntity
{
Id = PreviewRenderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = heading,
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
/// ObjDesc's subpalettes, safe to call outside <c>datLock</c>.</summary>
private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance)
{
if (appearance.ObjDesc.SubPalettes.Count == 0)
return null;
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
{
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
}
return new PaletteOverride(appearance.BasePaletteId, ranges);
}
/// <summary>No dat access — pure projection, safe to call outside
/// <c>datLock</c>.</summary>
private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance)
{
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
{
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
}
return partOverrides;
}
/// <summary>
/// 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
/// alignment holds even if a later part turns out to have a missing
/// GfxObj. No-ops (keeps the default placement frame) when the pose
/// DID or its animation can't be resolved.
/// </summary>
private static void ApplyHeldPoseTransforms(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint poseEnum,
List<MeshRef> flattened)
{
uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum);
if ((poseDid >> 24) != 0x03u)
return;
Animation? animation = animations.LoadAnimation(poseDid);
if (animation is null || animation.PartFrames.Count == 0)
return;
var frame = animation.PartFrames[^1];
for (int index = 0; index < flattened.Count; index++)
{
Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One;
Vector3 origin = Vector3.Zero;
Quaternion orientation = Quaternion.Identity;
if (index < frame.Frames.Count)
{
origin = frame.Frames[index].Origin;
orientation = frame.Frames[index].Orientation;
}
flattened[index] = new MeshRef(
flattened[index].GfxObjId,
RetailHeldPose.ComposePartTransform(scale, origin, orientation));
}
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
/// algorithm against <see cref="ChargenTextureChange"/> instead of the
/// wire's <c>CreateObject.TextureChange</c>.
/// </summary>
private static Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
IDatReaderWriter dats,
IReadOnlyList<MeshRef> parts,
IReadOnlyList<ChargenTextureChange> textureChanges)
{
if (textureChanges.Count == 0)
return null;
var oldToNewByPart = new Dictionary<int, Dictionary<uint, uint>>();
foreach (ChargenTextureChange change in textureChanges)
{
if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew))
{
oldToNew = [];
oldToNewByPart.Add(change.PartIndex, oldToNew);
}
oldToNew[change.OldTextureId] = change.NewTextureId;
}
var result = new Dictionary<int, Dictionary<uint, uint>>();
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
{
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
continue;
GfxObj? gfx = dats.Get<GfxObj>(parts[partIndex].GfxObjId);
if (gfx is null)
continue;
Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = dats.Get<Surface>(surfaceId);
if (surface is null)
continue;
uint originalTexture = (uint)surface.OrigTextureId;
if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture))
continue;
(resolved ??= [])[surfaceId] = newTexture;
}
if (resolved is not null)
result[partIndex] = resolved;
}
return result.Count == 0 ? null : result;
}
}

View file

@ -0,0 +1,90 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Chargen-specific facade over the shared private creature viewport
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
/// the campaign plan's "chargen preview renderer" deliverable. Mirrors
/// <see cref="PaperdollViewportRenderer"/>'s shape exactly, with a
/// heading-capable <see cref="ChargenPreviewViewportCamera"/> in place of the
/// paperdoll's fixed one.
///
/// <para>
/// <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>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 :
IUiViewportRenderer,
IDisposable
{
private readonly PrivateEntityViewportRenderer _renderer;
private readonly ChargenPreviewViewportCamera _camera;
internal ChargenPreviewRenderer(
IWorldPassScope scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint heritageId = 0u)
{
_camera = new ChargenPreviewViewportCamera(heritageId);
_renderer = new PrivateEntityViewportRenderer(
scope,
device,
frames,
dispatcher,
lightUbo,
textureLifetime,
meshAdapter,
ChargenPreviewEntityBuilder.PreviewRenderId,
_camera,
"chargen preview");
}
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
/// <summary>
/// Re-derives the fixed per-heritage camera eye
/// (<see cref="ChargenPreviewCamera.ResolveDefaultEye"/>) — call whenever
/// the selected heritage changes, BEFORE the next <see cref="Render"/>.
/// </summary>
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity);
public uint Render(int width, int height) => _renderer.Render(width, height);
public void Dispose() => _renderer.Dispose();
}

View file

@ -0,0 +1,126 @@
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 `%=`. Fix round F3: Binary
/// Ninja literally renders <c>x87_r7_1 = x87_r6_3</c> at <c>0x0047CAEB</c>
/// inside the counter-clockwise branch — reassigning the local that held
/// the "now" timestamp to the just-computed delta-degrees value — which
/// would make the <c>0x0047CB3D</c> store into <c>m_dLastRotateTime</c>
/// write delta-degrees instead of the timestamp for CCW only; that is an
/// x87-FPU-stack modeling artifact of the decompiler, not real retail
/// behavior (a shipped feature where every counter-clockwise rotation
/// visibly diverges from clockwise is implausible, and
/// <c>claude-memory/feedback_bn_decomp_field_names.md</c> names exactly
/// this x87-stack-register mislabeling as a known decompiler artifact
/// class), so this port stores <c>now</c> into <c>_lastRotateTime</c>
/// unconditionally in BOTH directions.
/// </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,162 @@
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>
/// <b>One owner of the zoom state (fix round F2):</b> retail's
/// <c>m_bZoomedIn</c> is a SINGLE field on <c>gmCGAppearancePage</c> that
/// gates both the camera target AND the animation swap — there is no way
/// for retail's own camera and animation to disagree about which zoom state
/// they're in. The first cut of this port kept two independent bools (one
/// here, one on <see cref="ChargenPreviewAnimator"/>) synced only by
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/> calling a NULLABLE animator
/// parameter — a null pass, or any direct
/// <see cref="ChargenPreviewAnimator.SetZoomedIn"/> call bypassing this
/// controller, would desync the camera's target from the animation's pose.
/// This class now takes its <see cref="ChargenPreviewAnimator"/> as a
/// REQUIRED constructor dependency and <see cref="IsZoomedIn"/> reads
/// straight through to <see cref="ChargenPreviewAnimator.IsZoomedIn"/> — the
/// animator is the sole state owner, matching retail's own single-field
/// design, and there is no longer a second bool that could disagree with it.
/// </para>
///
/// <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 readonly ChargenPreviewAnimator _animator;
private Vector3 _startEye;
private Vector3 _targetEye;
private double _animStartTime;
private double _animDuration;
private bool _shouldAnimate;
public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera, ChargenPreviewAnimator animator)
{
ArgumentNullException.ThrowIfNull(camera);
ArgumentNullException.ThrowIfNull(animator);
_heritageId = heritageId;
Camera = camera;
_animator = animator;
}
public ChargenPreviewCamera Camera { get; }
/// <summary>
/// Mirrors retail's <c>m_bZoomedIn</c> — a straight read-through to
/// <see cref="ChargenPreviewAnimator.IsZoomedIn"/> (see this class's own
/// "one owner" doc above), which itself defaults false per
/// <c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>'s explicit
/// <c>this-&gt;m_bZoomedIn = 0;</c> at <c>0x004802C3</c> — written right
/// after that same function points the camera at the zoomed-IN
/// per-heritage eye (<c>0x00480286-0x0048029E</c>). One retail quirk
/// this produces: the character starts framed close-up while
/// NOT-zoomed-in, so the first Zoom In click (once mounted) tweens
/// close-eye→close-eye — visually null — while still freezing the
/// animation; this port reproduces it faithfully.
/// </summary>
public bool IsZoomedIn => _animator.IsZoomedIn;
/// <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 the 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()
{
if (IsZoomedIn)
return;
StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId));
_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 the animator back to the playing idle
/// loop immediately, mirroring <see cref="ZoomIn"/>.
/// </summary>
public void ZoomOut()
{
if (!IsZoomedIn)
return;
StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId));
_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

@ -0,0 +1,105 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using AcDream.Core.CharGen;
using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable;
using DatPalSet = DatReaderWriter.DBObjs.PalSet;
using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect;
using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette;
namespace AcDream.Content.CharGen;
/// <summary>
/// DAT-backed <see cref="IChargenPalSetSource"/> / <see cref="IChargenClothingTableSource"/>
/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......)
/// dat objects on demand and projects them into <see cref="ChargenAppearanceFactory"/>'s
/// pure Core types, matching <c>ChargenTableReader</c>'s "no Chorizite leak"
/// discipline for everything it returns. Both lookups cache by dat id — a
/// live preview re-composes on every appearance change, and the same
/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and
/// re-selections within one session.
/// </summary>
public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource
{
private readonly IDatReaderWriter _dats;
private readonly ConcurrentDictionary<uint, ChargenPalSet?> _palSets = new();
private readonly ConcurrentDictionary<uint, ChargenClothingTable?> _clothingTables = new();
public ChargenAppearanceCatalog(IDatReaderWriter dats)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
}
public ChargenPalSet? TryGetPalSet(uint palSetId) =>
_palSets.GetOrAdd(palSetId, LoadPalSet);
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_clothingTables.GetOrAdd(clothingTableId, LoadClothingTable);
private ChargenPalSet? LoadPalSet(uint id)
{
DatPalSet? palSet = _dats.Get<DatPalSet>(id);
if (palSet is null)
return null;
var ids = new uint[palSet.Palettes.Count];
for (int i = 0; i < palSet.Palettes.Count; i++)
ids[i] = palSet.Palettes[i].DataId;
return new ChargenPalSet(Array.AsReadOnly(ids));
}
private ChargenClothingTable? LoadClothingTable(uint id)
{
DatClothingTable? table = _dats.Get<DatClothingTable>(id);
if (table is null)
return null;
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>(
table.ClothingBaseEffects.Count);
foreach (var pair in table.ClothingBaseEffects)
baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>(
table.ClothingSubPalEffects.Count);
foreach (var pair in table.ClothingSubPalEffects)
templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes);
return new ChargenClothingTable(
baseEffects.ToFrozenDictionary(),
templates.ToFrozenDictionary());
}
private static ChargenClothingBaseEffect ProjectBaseEffect(
IReadOnlyList<DatCloObjectEffect> objectEffects)
{
var partChanges = new List<ChargenAnimPartChange>(objectEffects.Count);
var textureChanges = new List<ChargenTextureChange>();
foreach (DatCloObjectEffect effect in objectEffects)
{
var partIndex = (byte)effect.Index;
partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId));
foreach (var tex in effect.CloTextureEffects)
{
textureChanges.Add(new ChargenTextureChange(
partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId));
}
}
return new ChargenClothingBaseEffect(
Array.AsReadOnly(partChanges.ToArray()),
Array.AsReadOnly(textureChanges.ToArray()));
}
private static ChargenClothingPaletteTemplate ProjectPaletteTemplate(
IReadOnlyList<DatCloSubPalette> subPalettes)
{
var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count];
for (int i = 0; i < subPalettes.Count; i++)
{
DatCloSubPalette sub = subPalettes[i];
var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count];
for (int j = 0; j < sub.Ranges.Count; j++)
ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors);
choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges));
}
return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices));
}
}

View file

@ -0,0 +1,458 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The resolved render description <see cref="ChargenAppearanceFactory.TryCompose"/>
/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies
/// to it (<c>CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0</c> is
/// retail's equivalent apply step). The three diagnostic lists let callers
/// (and CC6a's installed-DAT test) verify a selection resolved with no
/// missing dat data without needing to re-walk the composition themselves.
/// </summary>
/// <param name="SetupId">
/// 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), 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
/// palette a mesh builder should pass as the entity's base, NOT
/// <c>ObjDesc.PaletteId</c> (retail's own on-disk <c>BaseObjDesc.PaletteId</c>
/// field is unused for this purpose; cross-checked against
/// <c>references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58</c>,
/// which sets <c>PropertyDataId.PaletteBase</c> from <c>sex.BasePalette</c>
/// directly).
/// </param>
/// <param name="ObjDesc">
/// The composed subpalette/texture/part-swap deltas, in retail's exact
/// application order (see <see cref="ChargenAppearanceFactory.TryCompose"/>).
/// </param>
public sealed record ChargenAppearanceResult(
uint SetupId,
uint BasePaletteId,
ChargenObjDesc ObjDesc,
IReadOnlyList<uint> MissingPalSetIds,
IReadOnlyList<uint> MissingClothingTableIds,
IReadOnlyList<uint> ClothingTablesMissingBaseEffectForSetup);
/// <summary>
/// Index→ObjDesc appearance factory: the missing piece the campaign plan's
/// "acdream seams" section names (Appearance building: <c>DollEntityBuilder.Build</c>
/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat
/// →ObjDesc factory). Pure — no Chorizite types on this type's public
/// surface, matching CC1's <c>ChargenOptions</c> family; PalSet/ClothingTable
/// dat reads are pushed behind <see cref="IChargenPalSetSource"/>/
/// <see cref="IChargenClothingTableSource"/>, whose production implementation
/// (<c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>) does the actual
/// dat work.
///
/// <para>
/// Ports <c>gmCG3DView::Update @ 0x004EE9D0</c>'s ObjDesc rebuild verbatim,
/// in its EXACT append order (verified against the decompiled control flow,
/// not inferred from the UI's tab order or the wire's field order, both of
/// which differ — see the per-slot XML doc below):
/// </para>
/// <list type="number">
/// <item>Base body (<c>Sex_CG.BaseObjDesc</c>).</item>
/// <item>Hair style overlay (<c>HairStyle_CG.ObjDesc</c>), if selected.</item>
/// <item>Clothing, in retail's own order — <b>Headgear, Trousers, Shirt,
/// Footwear</b> (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/
/// footwear, and NOT the wire field order from CC2's 0xF656 builder,
/// which is also headgear/shirt/trousers/footwear). Each slot applies
/// its <c>ClothingBase</c> part/texture overrides unconditionally, then
/// — only when a color is also selected — its dye subpalette via
/// <c>ClothingTable::BuildObjDesc @ 0x005A7900</c>.</item>
/// <item>Eyes strip overlay (bald variant when the selected hair style's
/// <c>Bald</c> flag is set), if selected.</item>
/// <item>Nose strip overlay, if selected.</item>
/// <item>Mouth strip overlay, if selected.</item>
/// <item>Skin subpalette — UNCONDITIONAL, no "if selected" guard in
/// retail (the decompiled block runs every time, unlike every style/
/// color slot above and below it, which all gate on retail's
/// <c>0xFFFFFFFF</c> sentinel).</item>
/// <item>Hair color subpalette, if selected.</item>
/// <item>Eye color subpalette, if selected.</item>
/// </list>
/// </summary>
public static class ChargenAppearanceFactory
{
/// <summary>
/// Retail's HUMAN_SETUP_ID fallback (<c>ACViewer.Entity.Enum.SetupConst.HumanMale</c>
/// = 0x02000001; the same constant <c>gmCG3DView</c>'s ctor and
/// <c>::Update</c> fall back to when no valid body Setup is resolvable).
/// </summary>
public const uint HumanSetupId = 0x02000001u;
/// <summary>
/// Retail's <c>IDClass</c> "unset" sentinel (<c>INVALID_DID</c>,
/// 0xFFFFFFFF — <c>acclient.h:39909</c>). <c>CharGenState::GetSetupID @
/// 0x005C5B22</c> and <c>gmCG3DView::Update</c>'s own checks
/// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not
/// zero — a hair style whose <c>AlternateSetup</c> field happens to
/// store this sentinel must be treated as "no override," exactly like
/// zero, or the factory would hand a bogus Setup id to
/// <c>Get&lt;Setup&gt;</c> and produce no preview at all.
/// </summary>
private const uint InvalidDid = 0xFFFFFFFFu;
/// <summary>
/// Skin subpalette overlay range, retail's hard-coded literal at
/// <c>gmCG3DView::Update</c> ~0x004EF066-0x004EF07E: real byte offset 0,
/// real color count 192 (0xC0), packed to <see cref="ChargenSubPalette"/>'s
/// *8 on-disk units as (0, 24).
/// </summary>
private const byte SkinRangeOffset = 0;
private const byte SkinRangeNumColors = 24; // 192 / 8
/// <summary>
/// Hair color subpalette overlay range, retail's hard-coded literal at
/// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40),
/// packed to (24, 8).
/// </summary>
private const byte HairRangeOffset = 24; // 192 / 8
private const byte HairRangeNumColors = 8; // 64 / 8
/// <summary>
/// Eye color subpalette overlay range, retail's hard-coded literal at
/// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64
/// (0x40), packed to (32, 8).
/// </summary>
private const byte EyeRangeOffset = 32; // 256 / 8
private const byte EyeRangeNumColors = 8; // 64 / 8
/// <summary>
/// Composes a preview appearance description for one heritage/gender +
/// selection, or returns false when the heritage/gender itself doesn't
/// resolve (mirrors the <c>Try*</c> convention <see cref="ChargenOptions"/>
/// already uses). Never throws on missing PalSet/ClothingTable data —
/// a miss is recorded in the result's diagnostic lists and that single
/// 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,
int genderKey,
ChargenAppearanceSelection selection,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
out ChargenAppearanceResult result,
uint alternateSetupIdOverride = InvalidDid)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(palSets);
ArgumentNullException.ThrowIfNull(clothingTables);
result = default!;
if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|| !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender))
{
return false;
}
var missingPalSets = new List<uint>();
var missingClothingTables = new List<uint>();
var absentBaseEffects = new List<uint>();
// ── 1. body Setup id ────────────────────────────────────────────
uint setupId = gender.SetupId;
ChargenHairStyle? hairStyle = null;
if (selection.HairStyle != ChargenAppearanceSelection.Unset
&& selection.HairStyle < (uint)gender.HairStyles.Count)
{
hairStyle = gender.HairStyles[(int)selection.HairStyle];
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;
// ── 2. ObjDesc accumulation, retail's exact append order ───────
var subPalettes = new List<ChargenSubPalette>();
var textureChanges = new List<ChargenTextureChange>();
var animPartChanges = new List<ChargenAnimPartChange>();
Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges);
if (hairStyle is not null)
Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges);
ComposeClothingSlot(
gender.Headgears, selection.HeadgearStyle,
gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade,
setupId, clothingTables, palSets,
subPalettes, textureChanges, animPartChanges,
missingClothingTables, missingPalSets, absentBaseEffects);
ComposeClothingSlot(
gender.Pants, selection.TrousersStyle,
gender.ClothingColors, selection.TrousersColor, selection.TrousersShade,
setupId, clothingTables, palSets,
subPalettes, textureChanges, animPartChanges,
missingClothingTables, missingPalSets, absentBaseEffects);
ComposeClothingSlot(
gender.Shirts, selection.ShirtStyle,
gender.ClothingColors, selection.ShirtColor, selection.ShirtShade,
setupId, clothingTables, palSets,
subPalettes, textureChanges, animPartChanges,
missingClothingTables, missingPalSets, absentBaseEffects);
ComposeClothingSlot(
gender.Footwear, selection.FootwearStyle,
gender.ClothingColors, selection.FootwearColor, selection.FootwearShade,
setupId, clothingTables, palSets,
subPalettes, textureChanges, animPartChanges,
missingClothingTables, missingPalSets, absentBaseEffects);
if (selection.EyesStrip != ChargenAppearanceSelection.Unset
&& selection.EyesStrip < (uint)gender.EyeStrips.Count)
{
ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip];
bool bald = hairStyle?.Bald == true;
Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges);
}
if (selection.NoseStrip != ChargenAppearanceSelection.Unset
&& selection.NoseStrip < (uint)gender.NoseStrips.Count)
{
Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges);
}
if (selection.MouthStrip != ChargenAppearanceSelection.Unset
&& selection.MouthStrip < (uint)gender.MouthStrips.Count)
{
Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges);
}
// ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─
ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId);
if (skinPalSet is null)
{
missingPalSets.Add(gender.SkinPalSetId);
}
else
{
int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade);
if (skinIndex >= 0)
{
subPalettes.Add(new ChargenSubPalette(
skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors));
}
}
if (selection.HairColor != ChargenAppearanceSelection.Unset
&& selection.HairColor < (uint)gender.HairColors.Count)
{
uint hairPalSetId = gender.HairColors[(int)selection.HairColor];
ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId);
if (hairPalSet is null)
{
missingPalSets.Add(hairPalSetId);
}
else
{
int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade);
if (hairIndex >= 0)
{
subPalettes.Add(new ChargenSubPalette(
hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors));
}
}
}
if (selection.EyeColor != ChargenAppearanceSelection.Unset
&& selection.EyeColor < (uint)gender.EyeColors.Count)
{
// Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc).
uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor];
subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors));
}
var objDesc = new ChargenObjDesc(
gender.BasePaletteId,
subPalettes.AsReadOnly(),
textureChanges.AsReadOnly(),
animPartChanges.AsReadOnly());
result = new ChargenAppearanceResult(
setupId,
gender.BasePaletteId,
objDesc,
missingPalSets.AsReadOnly(),
missingClothingTables.AsReadOnly(),
absentBaseEffects.AsReadOnly());
return true;
}
private static void Append(
ChargenObjDesc source,
List<ChargenSubPalette> subPalettes,
List<ChargenTextureChange> textureChanges,
List<ChargenAnimPartChange> animPartChanges)
{
subPalettes.AddRange(source.SubPalettes);
textureChanges.AddRange(source.TextureChanges);
animPartChanges.AddRange(source.AnimPartChanges);
}
private static void ComposeClothingSlot(
IReadOnlyList<ChargenGearOption> gearOptions,
uint styleIndex,
IReadOnlyList<uint> clothingColors,
uint colorIndex,
double shade,
uint bodySetupId,
IChargenClothingTableSource clothingTables,
IChargenPalSetSource palSets,
List<ChargenSubPalette> subPalettes,
List<ChargenTextureChange> textureChanges,
List<ChargenAnimPartChange> animPartChanges,
List<uint> missingClothingTables,
List<uint> missingPalSets,
List<uint> absentBaseEffects)
{
if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count)
return;
ChargenGearOption gear = gearOptions[(int)styleIndex];
ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId);
if (table is null)
{
missingClothingTables.Add(gear.ClothingTableId);
return;
}
if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect))
{
animPartChanges.AddRange(baseEffect.PartChanges);
textureChanges.AddRange(baseEffect.TextureChanges);
}
else
{
absentBaseEffects.Add(gear.ClothingTableId);
}
if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count)
return;
uint paletteTemplateId = clothingColors[(int)colorIndex];
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op.
foreach (ChargenClothingSubPaletteChoice choice in template.Choices)
{
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
if (palSet is null)
{
// Retail's own inner loop (ClothingTable::BuildObjDesc
// ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when
// DBObj::Get fails for one subpalEffect entry's PalSet
// (~0x005A7B32) — aborting every REMAINING choice in this
// same garment's palette template, not merely skipping the
// failed one. `break`, not `continue`, matches that; the
// miss is still recorded so callers can see it happened.
missingPalSets.Add(choice.PalSetId);
break;
}
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
if (index < 0)
continue;
uint paletteId = palSet.PaletteIds[index];
foreach (ChargenClothingSubPaletteRange range in choice.Ranges)
{
subPalettes.Add(new ChargenSubPalette(
paletteId,
PackOffset(range.Offset),
PackNumColors(range.NumColors)));
}
}
}
/// <summary>
/// Converts a real (unpacked) clothing subpalette offset into
/// <see cref="ChargenSubPalette"/>'s packed *8 on-disk unit. Throws
/// rather than silently truncating on a shape we've never seen and
/// don't know how to represent losslessly (guards against the
/// unchecked-narrowing footgun a plain <c>(byte)(value / 8)</c> cast
/// would otherwise hide).
/// </summary>
private static byte PackOffset(uint realOffset)
{
if (realOffset % 8u != 0 || realOffset > 2040u)
{
throw new ArgumentOutOfRangeException(
nameof(realOffset),
realOffset,
"Clothing subpalette range offset does not fit the packed *8 byte "
+ "convention (expected a multiple of 8 in [0, 2040]).");
}
return (byte)(realOffset / 8u);
}
/// <summary>
/// Same packing as <see cref="PackOffset"/>, plus retail's own explicit
/// "whole palette" sentinel: a packed <c>NumColors</c> of 0 means "the
/// entire palette" (<see cref="AcDream.Core.World.PaletteOverride"/>'s
/// doc: "Length=0 is a sentinel meaning entire palette... defaulting to
/// 256*8"). A real count of exactly 2048 (256*8) IS that same value
/// spelled out in real units, so it packs to 0 BY DESIGN — not because
/// an unchecked <c>(byte)</c> cast happens to wrap 256 back to 0.
/// </summary>
private static byte PackNumColors(uint realNumColors)
{
if (realNumColors == 2048u)
return 0;
if (realNumColors % 8u != 0 || realNumColors > 2040u)
{
throw new ArgumentOutOfRangeException(
nameof(realNumColors),
realNumColors,
"Clothing subpalette range color count does not fit the packed *8 byte "
+ "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 "
+ "for the whole-palette sentinel).");
}
return (byte)(realNumColors / 8u);
}
}

View file

@ -0,0 +1,52 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The fourteen style/color indices plus the six f64 shades
/// <see cref="ChargenAppearanceFactory.TryCompose"/> needs to build a preview
/// description — field-for-field the same shape as CC3's
/// <c>AcDream.Runtime.Session.RuntimeCharacterCreationAppearance</c> (and,
/// through it, <c>CharacterCreate.Appearance</c>'s wire fields), kept as a
/// SEPARATE type here rather than referenced directly because
/// <c>AcDream.Runtime</c> depends on <c>AcDream.Core</c> and not the other
/// way around. CC6b's job is the trivial field-by-field copy from the
/// Runtime owner's snapshot into this type. <see cref="Unset"/>/
/// <see cref="UnsetShade"/> mirror retail's own sentinels exactly (same
/// citations CC3 already recorded): <c>0xFFFFFFFF</c> for "nothing selected"
/// and the IEEE-754 <c>-1.0</c> construction-time shade default
/// (<c>CharGenState::Reset @ 0x005C68A0</c>).
/// </summary>
public readonly record struct ChargenAppearanceSelection(
uint EyesStrip,
uint NoseStrip,
uint MouthStrip,
uint HairStyle,
uint HairColor,
uint EyeColor,
uint HeadgearStyle,
uint HeadgearColor,
uint ShirtStyle,
uint ShirtColor,
uint TrousersStyle,
uint TrousersColor,
uint FootwearStyle,
uint FootwearColor,
double SkinShade,
double HairShade,
double HeadgearShade,
double ShirtShade,
double TrousersShade,
double FootwearShade)
{
public const uint Unset = 0xFFFFFFFFu;
public const double UnsetShade = -1.0;
public static ChargenAppearanceSelection Default { get; } = new(
Unset, Unset, Unset,
Unset, Unset, Unset,
Unset, Unset,
Unset, Unset,
Unset, Unset,
Unset, Unset,
UnsetShade, UnsetShade, UnsetShade,
UnsetShade, UnsetShade, UnsetShade);
}

View file

@ -0,0 +1,147 @@
using System.Collections.Frozen;
namespace AcDream.Core.CharGen;
/// <summary>
/// One un-resolved dye-shade choice inside a clothing "palette template"
/// (retail's inner <c>CloSubpalEffect</c> array entry, one per
/// <c>ClothingTable::BuildObjDesc @ 0x005A7900</c> loop iteration; Chorizite
/// projects the identical shape as <c>DatReaderWriter.Types.CloSubPalette</c>
/// — a <c>PaletteSet</c> id plus a list of overlay ranges). Offsets/counts
/// here are the REAL (unpacked) color units read straight off the dat
/// (installed-DAT probe: Aluvian male "Cloth Cap" headgear reads
/// off=2000,n=48 for every one of its 28 palette-template entries) — the
/// *8-packed byte convention only applies to the OUTPUT
/// <see cref="ChargenSubPalette"/>, converted once at composition time
/// (<see cref="ChargenAppearanceFactory"/>).
/// </summary>
public readonly record struct ChargenClothingSubPaletteRange(uint Offset, uint NumColors);
/// <summary>
/// One resolvable-by-shade colour choice for a clothing palette template:
/// the PalSet id (0x0F......) to resolve via
/// <see cref="ChargenPalSetMath.GetPaletteIndex"/>, plus every overlay range
/// to apply once resolved.
/// </summary>
public readonly record struct ChargenClothingSubPaletteChoice(
uint PalSetId,
IReadOnlyList<ChargenClothingSubPaletteRange> Ranges);
/// <summary>
/// One clothing-table "palette template" (retail's <c>CloPaletteTemplate</c>,
/// looked up in <c>ClothingTable::_paletteTemplatesHash</c> by the id
/// <c>CharGenState::GetHeadgearPaletteTemplateID</c> (and its Shirt/Trousers/
/// Footwear siblings, all at 0x005C38F0-0x005C3980) return — which is itself
/// just a bounds-checked passthrough of <c>Sex_CG.ClothingColors[index]</c>:
/// every one of the four per-slot template-id arrays
/// (<c>headgearPaletteTemplateIDs</c>/<c>shirtPaletteTemplateIDs</c>/
/// <c>trousersPaletteTemplateIDs</c>/<c>footwearPaletteTemplateIDs</c>) is
/// populated from the SAME single <c>Sex_CG::ClothingColors</c> dat field —
/// there is no per-clothing-slot color list in the dat schema at all. This
/// CONFIRMS (does not merely approximate) register row AP-208's shared-list
/// design in <c>RuntimeCharacterCreationAppearance</c>/
/// <c>ChargenAppearanceSlot</c> — installed-DAT probe: Aluvian male's
/// <c>ClothingColors</c> = {9,6,4,8,7,5,2,3,13}, and the "Cloth Cap"
/// headgear's <c>ClothingSubPalEffects</c> keys include 2,3,4,5,6,7,8,9,13 —
/// the shared list's raw values ARE the template-id keys, verified live.
/// </summary>
public sealed record ChargenClothingPaletteTemplate(
IReadOnlyList<ChargenClothingSubPaletteChoice> Choices)
{
public static ChargenClothingPaletteTemplate Empty { get; } =
new(Array.Empty<ChargenClothingSubPaletteChoice>());
}
/// <summary>
/// One body-Setup-specific part/texture override set (retail's
/// <c>ClothingBaseEffect</c>, applied by
/// <c>ClothingBase::ApplyPartAndTextureChanges @ 0x005A8EB0</c>): for each
/// <c>CloObjectEffect</c>, an unconditional <see cref="ChargenAnimPartChange"/>
/// (part index → replacement GfxObj) plus every
/// <see cref="ChargenTextureChange"/> the SAME object effect carries for
/// that part.
/// </summary>
public sealed record ChargenClothingBaseEffect(
IReadOnlyList<ChargenAnimPartChange> PartChanges,
IReadOnlyList<ChargenTextureChange> TextureChanges)
{
public static ChargenClothingBaseEffect Empty { get; } = new(
Array.Empty<ChargenAnimPartChange>(),
Array.Empty<ChargenTextureChange>());
}
/// <summary>
/// Pure projection of one ClothingTable dat object (0x19......, retail
/// <c>ClothingTable::Unpack</c> / Chorizite
/// <c>DatReaderWriter.DBObjs.ClothingTable</c>). One instance is referenced
/// per <see cref="ChargenGearOption.ClothingTableId"/> — a single garment
/// CHOICE (e.g. "Cloth Cowl") carries its own table covering every body
/// Setup it can be worn on plus every dye choice offered for it.
///
/// <para>
/// <b>Deliberate scope cut (CC6a) — MEASURED, not just asserted:</b> retail's
/// <c>ClothingTable::BuildObjDesc</c> falls back through a chain of ~8
/// hard-coded Setup-id substitutions (Umbraen crown/no-crown/void,
/// Penumbraen, Undead skeleton/zombie, Anakshay) when
/// <see cref="BaseEffectsBySetupId"/> has no direct entry for the requested
/// body Setup. CC6a's composer looks up <see cref="BaseEffectsBySetupId"/>
/// directly and skips a slot's part/texture contribution on a miss (this is
/// the OUTER lookup — <c>ClothingTable::_cloBaseHash</c> — whose retail
/// miss behavior is genuinely a no-op the caller never checks; the SEPARATE
/// inner per-choice PalSet lookup inside the same function's subpalette loop
/// has its own, stricter, abort-on-miss behavior — see
/// <c>ChargenAppearanceFactory.ComposeClothingSlot</c>'s own doc, ported
/// faithfully there) rather than porting the Setup-substitution chain. The
/// installed-DAT catalog test (<c>ChargenAppearanceCatalogInstalledDatTests</c>)
/// MEASURED this directly across all 26 heritage/gender combinations rather
/// than assuming it: for the 9 standard heritages where retail's own UI
/// actually shows clothing controls (everything except Gear Knight and the
/// two Olthoi variants, which retail hides the clothes button for entirely
/// — <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>'s
/// <c>m_pClothesButton->SetVisible(0)</c> branches for
/// <c>mHeritageGroup == 6</c> and <c>== 0xc || == 0xd</c>), the default
/// gear choices resolve against their own body Setup with ZERO missing
/// coverage. <b>Undead IS a real gap</b> — retail DOES show clothing
/// controls for Undead, and MEASURED coverage is missing for <b>ALL FOUR</b>
/// clothing slots (headgear, trousers, shirt, AND footwear — not just three
/// of the four), on both genders: neither gender's live body Setup has a
/// <see cref="BaseEffectsBySetupId"/> entry in any of its four default gear
/// choices' clothing tables, because Undead's live body Setup IS one of the
/// skeleton/zombie variants the un-ported substitution chain exists to
/// redirect. A live preview for Undead will therefore render its default
/// clothing selection with NO part/texture override applied on any of the
/// four slots (the underlying body shows through unclothed) until the
/// substitution chain — or an equivalent per-heritage default-clothing-setup
/// mapping — lands. Filed as a known CC6a limitation for CC6b/a follow-up
/// rather than silently "confirmed unreachable."
/// </para>
/// </summary>
public sealed record ChargenClothingTable(
IReadOnlyDictionary<uint, ChargenClothingBaseEffect> BaseEffectsBySetupId,
IReadOnlyDictionary<uint, ChargenClothingPaletteTemplate> PaletteTemplatesById)
{
public static ChargenClothingTable Empty { get; } = new(
FrozenDictionary<uint, ChargenClothingBaseEffect>.Empty,
FrozenDictionary<uint, ChargenClothingPaletteTemplate>.Empty);
}
/// <summary>
/// Resolves a PalSet dat id (0x0F......) to its pure projection. The
/// production implementation (<c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>)
/// reads and caches the real dat object; this interface keeps
/// <see cref="ChargenAppearanceFactory"/> free of any Chorizite dependency
/// (unit tests supply a hand-built fake).
/// </summary>
public interface IChargenPalSetSource
{
ChargenPalSet? TryGetPalSet(uint palSetId);
}
/// <summary>
/// Resolves a ClothingTable dat id (0x19......) to its pure projection.
/// Same production/test split as <see cref="IChargenPalSetSource"/>.
/// </summary>
public interface IChargenClothingTableSource
{
ChargenClothingTable? TryGetClothingTable(uint clothingTableId);
}

View file

@ -0,0 +1,23 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Pure projection of a PalSet dat object (0x0F......, retail
/// <c>PalSet::Unpack</c> / Chorizite <c>DatReaderWriter.DBObjs.PalSet</c>):
/// the ordered list of Palette dat ids (0x04......) a shade fraction picks
/// from via <see cref="ChargenPalSetMath.GetPaletteIndex"/>. Every appearance
/// color slot that resolves "by shade" — skin (<c>ChargenGenderOptions.SkinPalSetId</c>),
/// hair (<c>ChargenGenderOptions.HairColors[i]</c>), and every clothing
/// dye choice (<c>ChargenClothingSubPaletteChoice.PalSetId</c>) — reads one
/// of these. Eye color is the one exception: retail uses the raw entry
/// from <c>ChargenGenderOptions.EyeColors</c> directly as a Palette id, no
/// PalSet/shade indirection (<c>gmCG3DView::Update</c> pseudo-C ~0x004EF12F;
/// cross-checked against
/// <c>references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:100</c>,
/// which sets <c>EyesPalette</c> straight from <c>sex.EyeColorList[eyeColor]</c>
/// with no <c>GetPaletteID</c> call, unlike the Skin/Hair lines immediately
/// above it).
/// </summary>
public sealed record ChargenPalSet(IReadOnlyList<uint> PaletteIds)
{
public static ChargenPalSet Empty { get; } = new(Array.Empty<uint>());
}

View file

@ -0,0 +1,57 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Pure port of retail's shade→palette-index resolution
/// (<c>PalSet::GetPaletteID @ 0x005AC570</c>, invoked from
/// <c>gmCG3DView::Update @ 0x004EE9D0</c> for the skin/hair subpalette
/// build and from <c>ClothingTable::BuildObjDesc @ 0x005A7900</c> for every
/// clothing-slot dye choice). The decompiled body is genuinely FPU-elided —
/// the <c>_ftol2()</c> truncating-cast operand is lost to the decompiler,
/// and can only be read as "some product of <paramref name="count"/>-ish and
/// <paramref name="shade"/>-ish operands" from the surrounding x87 stack
/// traffic — but the decomp's own control-flow SHAPE is still verifiable
/// independent of that lost operand: a two-sided FPU compare at
/// <c>0x005AC5A0</c> gating on <c>&gt;= 0.0</c>, consistent with a
/// <c>[0,1]</c> shade bounds check before the cast. What resolves the
/// elided operand is ACE's <c>ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID</c>,
/// which carries the explicit comment "Taken from acclient.c
/// (PalSet::GetPaletteID)" against the exact formula below. That is TWO
/// sources (decomp control flow + ACE's cited port), not three: the
/// <c>PaletteSet.cs</c> file present in the vendored ACViewer checkout is
/// ACE's own file, not an independent reimplementation, and ACViewer's
/// <c>ClothingTableList.xaml.cs:97</c> UI slider computes a DIFFERENT
/// expression for a DIFFERENT problem (mapping a shade back to a slider tick
/// position against <c>Shades.Maximum</c>, i.e. <c>count-1</c>, not
/// <c>count</c>) — neither corroborates this formula and both are dropped
/// from the evidence chain here.
/// </summary>
public static class ChargenPalSetMath
{
/// <summary>
/// Resolves a shade fraction to an index into a palette-id list of the
/// given <paramref name="count"/>. Returns -1 (retail's
/// <c>INVALID_DID</c> outcome) when <paramref name="count"/> is
/// non-positive or <paramref name="shade"/> falls outside
/// <c>[0.0, 1.0]</c> — including retail's own <c>-1.0</c> "unset"
/// sentinel (<c>CharGenState::Reset @ 0x005C68A0</c>), which is
/// deliberately out of range so an untouched shade resolves to
/// "nothing," matching retail. Callers should treat -1 as "skip this
/// subpalette contribution" rather than emit a placeholder id.
/// </summary>
public static int GetPaletteIndex(int count, double shade)
{
if (count <= 0 || shade < 0.0 || shade > 1.0)
return -1;
// Truncating cast, exactly as ACE's cited port and the decomp's
// _ftol2() (which truncates toward zero on x86, matching a plain
// C-style (int) cast here since count > 0 and 0 <= shade <= 1 keep
// the product non-negative).
int index = (int)((count - 0.000001) * shade);
if (index < 0)
index = 0;
if (index > count - 1)
index = count - 1;
return index;
}
}

View file

@ -0,0 +1,124 @@
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). Tracked as
/// <c>docs/ISSUES.md</c> #403 so the follow-up has an owner.
/// </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

@ -0,0 +1,110 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.CharGen;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Pins <see cref="ChargenPreviewCamera"/>'s retail-verbatim per-heritage
/// eye positions (<c>gmCGAppearancePage::Update @ 0x0047E8F0</c>,
/// cross-checked against the identical literals in <c>ZoomIn</c>/<c>ZoomOut
/// @ 0x0047CF00</c>/<c>0x0047D050</c>) and the zero-yaw/zero-pitch look
/// convention DollCameraTests already established for the shared private
/// viewport.
/// </summary>
public class ChargenPreviewCameraTests
{
[Theory]
[InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -0.550000012f, 1.64999998f)]
[InlineData((uint)ChargenHeritageGroup.Gharundim, 0f, -0.550000012f, 1.64999998f)]
[InlineData((uint)ChargenHeritageGroup.Gearknight, 0f, -0.550000012f, 1.64999998f)]
[InlineData((uint)ChargenHeritageGroup.Undead, 0f, -0.550000012f, 1.64999998f)]
[InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -0.850000024f, 1.64999998f)]
[InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -1.85000002f, 1.85000002f)]
[InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -3.04999995f, 2.75f)]
public void ResolveDefaultEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z)
{
Vector3 eye = ChargenPreviewCamera.ResolveDefaultEye(heritageId);
Assert.Equal(x, eye.X, 4);
Assert.Equal(y, eye.Y, 4);
Assert.Equal(z, eye.Z, 4);
}
[Theory]
[InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -2.5f, 0.95f)]
[InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -2.5f, 0.95f)] // ZoomOut has NO Tumerok special case, unlike the zoomed-in default.
[InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -3.79999995f, 1.14999998f)]
[InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -5.69999981f, 1.64999998f)]
public void ResolveZoomedOutEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z)
{
Vector3 eye = ChargenPreviewCamera.ResolveZoomedOutEye(heritageId);
Assert.Equal(x, eye.X, 4);
Assert.Equal(y, eye.Y, 4);
Assert.Equal(z, eye.Z, 4);
}
[Fact]
public void Constructor_DefaultsToStandardHeritageEye_ForUnknownHeritageId()
{
var cam = new ChargenPreviewCamera(heritageId: 0u);
Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(0u), cam.Eye);
}
[Fact]
public void SetHeritage_UpdatesEyeToTheNewHeritagesProfile()
{
var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
cam.SetHeritage((uint)ChargenHeritageGroup.Olthoi);
Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye((uint)ChargenHeritageGroup.Olthoi), cam.Eye);
}
[Fact]
public void View_LooksStraightDownPlusY_ZeroYawZeroPitch()
{
// Same identity-direction convention DollCameraTests pins for the paperdoll:
// retail SetCameraDirection(0,0,0) resets the view frame to IDENTITY.
var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian) { Aspect = 1f };
var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33);
Assert.Equal(0f, forward.X, 4);
Assert.Equal(1f, forward.Y, 4);
Assert.Equal(0f, forward.Z, 4);
}
[Fact]
public void Eye_RoundTripsThroughViewMatrixInversion()
{
var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Olthoi) { Aspect = 1f };
Assert.True(Matrix4x4.Invert(cam.View, out var inv));
Vector3 eye = inv.Translation;
Assert.Equal(cam.Eye.X, eye.X, 3);
Assert.Equal(cam.Eye.Y, eye.Y, 3);
Assert.Equal(cam.Eye.Z, eye.Z, 3);
}
[Fact]
public void Projection_IsFiniteAndUsesAspect()
{
var cam = new ChargenPreviewCamera { Aspect = 1.5f };
Assert.True(float.IsFinite(cam.Projection.M11));
Assert.NotEqual(0f, cam.Projection.M34);
}
[Fact]
public void RotationSecondsPerRevolution_IsExactlyThreeSeconds()
{
// Raw double bits low32=0x00000000, high32=0x40080000 — no
// reconstruction needed, the decompiler shows this one cleanly.
Assert.Equal(3.0f, ChargenPreviewCamera.RotationSecondsPerRevolution);
}
[Fact]
public void ZoomTweenDurationSeconds_IsExactlyZeroPointSix()
{
// Recovered by reinterpreting the decompiler's garbled float literal
// as the raw low-32-bit store and pairing it with the (clean) high
// dword; cross-confirmed via the -0.1 sentinel in ZoomIn/ZoomOut
// reconstructing to the well-known IEEE-754 bit pattern for -0.1.
Assert.Equal(0.6f, ChargenPreviewCamera.ZoomTweenDurationSeconds);
}
}

View file

@ -0,0 +1,278 @@
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Content;
using AcDream.Content.CharGen;
using AcDream.Content.Vfx;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Installed-DAT gate for <see cref="ChargenPreviewEntityBuilder"/> —
/// mirrors <see cref="CornerFloodReplayTests"/>'s env-gated skip pattern
/// (no unit-testable pure surface exists here the way
/// <see cref="DollEntityBuilder"/> has one, because THIS builder's whole job
/// is resolving Setup/GfxObj/Surface/Animation dat data that
/// <see cref="DollEntityBuilder"/> receives pre-resolved).
/// </summary>
public sealed class ChargenPreviewEntityBuilderTests
{
private readonly ITestOutputHelper _out;
public ChargenPreviewEntityBuilderTests(ITestOutputHelper output) => _out = output;
[Fact]
public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity()
{
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)); // 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);
Assert.Empty(appearance.MissingPalSetIds);
Assert.Empty(appearance.MissingClothingTableIds);
var animations = new RetailAnimationLoader(adapter);
var entity = ChargenPreviewEntityBuilder.TryBuild(
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object());
Assert.NotNull(entity);
Assert.NotEmpty(entity!.MeshRefs);
Assert.Equal(appearance.SetupId, entity.SourceGfxObjOrSetupId);
Assert.Equal(ChargenPreviewEntityBuilder.PreviewServerGuid, entity.ServerGuid);
Assert.Equal(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id);
Assert.NotNull(entity.PaletteOverride);
Assert.Equal(appearance.BasePaletteId, entity.PaletteOverride!.BasePaletteId);
_out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}");
}
[Fact]
public void TryBuild_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, // Not a real installed Setup id.
BasePaletteId: 0u,
ObjDesc: ChargenObjDesc.Empty,
MissingPalSetIds: [],
MissingClothingTableIds: [],
ClothingTablesMissingBaseEffectForSetup: []);
var entity = ChargenPreviewEntityBuilder.TryBuild(
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object());
Assert.Null(entity);
}
[Fact]
public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages()
{
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)); // 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);
var entity = ChargenPreviewEntityBuilder.TryBuild(
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object());
// Just proves the Olthoi branch doesn't throw / silently fall through to
// "no mesh" — the exact pose DID differs internally (0x10000011 vs
// 0x10000005) but both should still resolve a drawable mesh from Olthoi's
// own Setup.
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,135 @@
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);
}
/// <summary>
/// F7: exercises the <c>&gt;360 -&gt; -360</c> clamp arm (pseudo-C
/// ~0x0047cb1e-0x0047cb31), the one with readable decomp polarity —
/// unlike the CCW-branch FPU-stack artifact F3 documents, this branch's
/// test/subtract shape is unambiguous. One large clockwise tick pushes
/// heading past 360 in a single call.
/// </summary>
[Fact]
public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360()
{
var controller = new ChargenPreviewRotationController();
controller.Toggle(ChargenRotateDirection.Clockwise);
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60.
Assert.Equal(60f, 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,194 @@
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. Fix round F2: the controller now takes its
/// <see cref="ChargenPreviewAnimator"/> as a required constructor dependency
/// and owns no independent zoom-state bool of its own — every test here
/// builds a real (hand-fixture) animator rather than exercising a
/// camera-only path that no longer exists.
/// </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 Constructor_NullAnimator_Throws()
{
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
Assert.Throws<ArgumentNullException>(
() => new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator: null!));
}
[Fact]
public void IsZoomedIn_ReadsThroughToTheAnimator_NoIndependentState()
{
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
var animator = MakeAnimator();
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator);
Assert.False(controller.IsZoomedIn);
// Flip the animator's OWN state directly (bypassing the controller
// entirely) — since the controller now reads straight through, there
// is nothing to desync.
animator.SetZoomedIn(true);
Assert.True(controller.IsZoomedIn);
}
[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, MakeAnimator());
controller.ZoomIn();
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, MakeAnimator());
controller.ZoomIn(); // 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(); // 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, MakeAnimator());
controller.ZoomOut();
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, MakeAnimator());
controller.ZoomIn(); // reach the "zoomed in" state (zero-distance tween — Eye already there).
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
controller.ZoomOut(); // 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, MakeAnimator());
controller.ZoomIn(); // reach "zoomed in" (zero-distance).
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
controller.ZoomOut(); // 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 animator = MakeAnimator();
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator);
controller.ZoomIn();
// 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 animator = MakeAnimator();
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator);
controller.ZoomIn();
controller.ZoomOut();
Assert.False(animator.IsZoomedIn);
Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation);
}
}

View file

@ -0,0 +1,299 @@
using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit.Abstractions;
namespace AcDream.Content.Tests.CharGen;
/// <summary>
/// Installed-DAT gate for <see cref="ChargenAppearanceCatalog"/> +
/// <see cref="ChargenAppearanceFactory"/> together: for every one of the 13
/// installed heritages' genders, composes a "pick the first offered option
/// everywhere, mid shade" selection and asserts it resolves with no missing
/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance
/// bar ("every heritage/gender's default selection resolves to a complete
/// description with no missing dat ids"). ALSO pins the TS-82 measurement
/// with real assertions (not WriteLine-only diagnostics, per the CC6a
/// review fix round F7): the nine standard heritages with clothing UI shown
/// resolve zero <c>ClothingBaseEffects</c> gaps, and Undead resolves
/// EXACTLY the four measured gaps on both genders — see the class doc on
/// <see cref="ChargenClothingTable"/>'s deliberate scope cut.
///
/// <para>Env-gated skip (house pattern, matched from
/// <c>ChargenTableReaderInstalledDatTests</c>/<c>ContentConformanceDats</c>):
/// returns green with a console SKIP note when no installed dat directory is
/// configured, rather than a true xUnit Skipped status — no other Content
/// installed-DAT test in this project uses <c>Assert.Skip</c>, so this stays
/// consistent with the rest of the suite rather than introducing a new
/// convention.</para>
/// </summary>
public sealed class ChargenAppearanceCatalogInstalledDatTests
{
private readonly ITestOutputHelper _out;
public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output;
// ACE ACE.Entity.Enum.HeritageGroup ids. Gearknight (6)/Olthoi (12)/
// OlthoiAcid (13) are deliberately not named here — see the WriteLine-only
// comment in the loop below for why they carry no pinned expectation.
private const uint TumerokId = 7u;
private const uint UndeadId = 11u;
/// <summary>
/// The 9 standard heritages whose UI actually shows clothing controls
/// AND whose default gear resolves with zero <c>ClothingBaseEffects</c>
/// gaps (measured, not the full "clothing UI shown" set — Undead is
/// ALSO clothing-UI-shown but is the one real gap, asserted separately
/// below). Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Tumerok/Lugian/
/// Empyrean/Penumbraen = every heritage id 1-10 except Gearknight (6).
/// </summary>
private static readonly uint[] StandardZeroGapHeritageIds = [1u, 2u, 3u, 4u, 5u, TumerokId, 8u, 9u, 10u];
/// <summary>
/// Measured (installed EoR dat, both genders, identical order): Undead's
/// default headgear/trousers/shirt/footwear choices' clothing tables, in
/// the factory's own Headgear→Trousers→Shirt→Footwear composition order.
/// ALL FOUR slots miss — not "headgear/trousers/footwear" (a three-slot
/// undercount an earlier draft of this row stated in error).
/// </summary>
private static readonly uint[] UndeadMeasuredMissingClothingTableIds =
[0x10000009u, 0x100000F9u, 0x10000001u, 0x10000007u];
private static string? ResolveDatDir()
{
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
[Fact]
public void EveryHeritageGendersDefaultSelection_ResolvesWithNoMissingDatIds()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.NotEmpty(options.HeritagesById);
var catalog = new ChargenAppearanceCatalog(adapter);
int composed = 0;
var missingSummaries = new List<string>();
var baseEffectGapFailures = new List<string>();
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey)
{
ChargenAppearanceSelection selection = MakeDefaultSelection(gender);
bool ok = ChargenAppearanceFactory.TryCompose(
options, heritage.HeritageId, genderKey, selection,
catalog, catalog, out ChargenAppearanceResult result);
Assert.True(ok, $"heritage=0x{heritage.HeritageId:X} gender={genderKey} failed to resolve heritage/gender");
composed++;
if (result.MissingPalSetIds.Count > 0 || result.MissingClothingTableIds.Count > 0)
{
missingSummaries.Add(
$"heritage={heritage.Name} gender={genderKey}: "
+ $"missingPalSets=[{string.Join(",", result.MissingPalSetIds.Select(id => $"0x{id:X8}"))}] "
+ $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]");
}
_out.WriteLine(
$"heritage={heritage.Name} (0x{heritage.HeritageId:X}) gender={genderKey} setup=0x{result.SetupId:X8}: "
+ $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no "
+ "ClothingBaseEffects entry for this body setup "
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
// TS-82's pinned measurement — real assertions, not WriteLine-only.
if (StandardZeroGapHeritageIds.Contains(heritage.HeritageId))
{
if (result.ClothingTablesMissingBaseEffectForSetup.Count != 0)
{
baseEffectGapFailures.Add(
$"heritage={heritage.Name} gender={genderKey}: expected ZERO ClothingBaseEffects "
+ $"gaps (a standard heritage with clothing UI shown), measured "
+ $"{result.ClothingTablesMissingBaseEffectForSetup.Count}: "
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
}
}
else if (heritage.HeritageId == UndeadId)
{
if (!result.ClothingTablesMissingBaseEffectForSetup.SequenceEqual(UndeadMeasuredMissingClothingTableIds))
{
baseEffectGapFailures.Add(
$"heritage=Undead gender={genderKey}: expected EXACTLY "
+ $"[{string.Join(",", UndeadMeasuredMissingClothingTableIds.Select(id => $"0x{id:X8}"))}], measured "
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
}
}
// Gearknight/Olthoi/OlthoiAcid: retail hides the clothing UI
// entirely for these three (gmCGAppearancePage::Update
// @0x0047E8F0's SetVisible(0) branches), so a real chargen
// selection never reaches this composer's clothing slots for
// them — no pinned expectation either way, WriteLine above
// is diagnostic only.
}
}
_out.WriteLine($"composed {composed} heritage/gender selections.");
Assert.True(
missingSummaries.Count == 0,
"Missing dat ids found:\n" + string.Join('\n', missingSummaries));
Assert.True(
baseEffectGapFailures.Count == 0,
"TS-82 measurement drifted from its pinned expectation:\n" + string.Join('\n', baseEffectGapFailures));
Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}.");
}
/// <summary>
/// CC6a review fix round F1: retail's Setup-id "unset" sentinel is
/// <c>INVALID_DID</c> (0xFFFFFFFF), not 0
/// (<c>CharGenState::GetSetupID @ 0x005C5B22</c>). Sweeps EVERY hair
/// style of all 26 heritage/gender combinations and asserts the composed
/// SetupId always resolves to a REAL installed Setup dat entry — proving
/// neither sentinel value, wherever a hair style's <c>AlternateSetup</c>
/// field happens to store one, ever reaches <c>Get&lt;Setup&gt;</c> as a
/// literal id.
/// </summary>
[Fact]
public void EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.NotEmpty(options.HeritagesById);
var catalog = new ChargenAppearanceCatalog(adapter);
int sweptHairStyles = 0;
var unresolvedSetups = new List<string>();
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey)
{
for (uint hairStyleIndex = 0; hairStyleIndex < (uint)gender.HairStyles.Count; hairStyleIndex++)
{
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
{
HairStyle = hairStyleIndex,
SkinShade = 0.5,
};
bool ok = ChargenAppearanceFactory.TryCompose(
options, heritage.HeritageId, genderKey, selection,
catalog, catalog, out ChargenAppearanceResult result);
Assert.True(ok);
sweptHairStyles++;
if (adapter.Get<DatReaderWriter.DBObjs.Setup>(result.SetupId) is null)
{
unresolvedSetups.Add(
$"heritage={heritage.Name} gender={genderKey} hairStyle={hairStyleIndex}: "
+ $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup");
}
}
// Every gender is swept even with zero hair styles (still
// exercises the "no hair style selected" default-setup path).
if (gender.HairStyles.Count == 0)
{
bool ok = ChargenAppearanceFactory.TryCompose(
options, heritage.HeritageId, genderKey,
ChargenAppearanceSelection.Default with { SkinShade = 0.5 },
catalog, catalog, out ChargenAppearanceResult result);
Assert.True(ok);
sweptHairStyles++;
if (adapter.Get<DatReaderWriter.DBObjs.Setup>(result.SetupId) is null)
{
unresolvedSetups.Add(
$"heritage={heritage.Name} gender={genderKey} (no hair styles): "
+ $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup");
}
}
}
}
_out.WriteLine($"swept {sweptHairStyles} hair-style/no-hair-style selections across 26 heritage/gender combinations.");
Assert.True(
unresolvedSetups.Count == 0,
"Composed SetupId(s) that don't resolve to a real installed Setup:\n" + string.Join('\n', unresolvedSetups));
Assert.True(sweptHairStyles > 26, $"Expected more than 26 swept selections (multiple hair styles per gender), got {sweptHairStyles}.");
}
/// <summary>
/// "Pick the first offered option everywhere, mid shade" — CC6a's own
/// default policy for exercising the factory end-to-end, NOT a claim
/// about retail's own CharGenState default selection (that policy is
/// CC3/CC6b's concern). Every index/shade starts at
/// <see cref="ChargenAppearanceSelection.Unset"/>/<see cref="ChargenAppearanceSelection.UnsetShade"/>
/// and is only set when the gender's own list actually offers an
/// option, so a heritage with e.g. no headgear choices exercises the
/// factory's "slot not selected" path rather than an out-of-range index.
/// </summary>
private static ChargenAppearanceSelection MakeDefaultSelection(ChargenGenderOptions gender)
{
const double midShade = 0.5;
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default;
if (gender.HairStyles.Count > 0)
selection = selection with { HairStyle = 0u };
if (gender.EyeStrips.Count > 0)
selection = selection with { EyesStrip = 0u };
if (gender.NoseStrips.Count > 0)
selection = selection with { NoseStrip = 0u };
if (gender.MouthStrips.Count > 0)
selection = selection with { MouthStrip = 0u };
if (gender.HairColors.Count > 0)
selection = selection with { HairColor = 0u, HairShade = midShade };
if (gender.EyeColors.Count > 0)
selection = selection with { EyeColor = 0u };
if (gender.Headgears.Count > 0)
selection = selection with { HeadgearStyle = 0u };
if (gender.Shirts.Count > 0)
selection = selection with { ShirtStyle = 0u };
if (gender.Pants.Count > 0)
selection = selection with { TrousersStyle = 0u };
if (gender.Footwear.Count > 0)
selection = selection with { FootwearStyle = 0u };
if (gender.ClothingColors.Count > 0)
{
selection = selection with
{
HeadgearColor = 0u,
HeadgearShade = midShade,
ShirtColor = 0u,
ShirtShade = midShade,
TrousersColor = 0u,
TrousersShade = midShade,
FootwearColor = 0u,
FootwearShade = midShade,
};
}
return selection with { SkinShade = midShade };
}
}

View file

@ -0,0 +1,686 @@
using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Hand-built-fixture tests for <see cref="ChargenAppearanceFactory.TryCompose"/>.
/// Real installed-DAT coverage (every heritage/gender's default selection,
/// verifying no missing PalSet/ClothingTable ids) lives in
/// AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogInstalledDatTests.
/// </summary>
public sealed class ChargenAppearanceFactoryTests
{
private const uint HeritageId = 1u;
private const int GenderKey = 1;
private const uint BodySetupId = 0x0200_0001u;
private const uint AlternateBodySetupId = 0x0200_00FFu;
private const uint BasePaletteId = 0x0400_0001u;
private const uint SkinPalSetId = 0x0F00_0001u;
private const uint HairColorPalSetId = 0x0F00_0002u;
private const uint EyeColorPaletteId = 0x0400_0099u; // direct palette id, no PalSet indirection.
private const uint HeadgearClothingTableId = 0x1900_0001u;
private const uint TrousersClothingTableId = 0x1900_0002u;
private const uint ShirtClothingTableId = 0x1900_0003u;
private const uint FootwearClothingTableId = 0x1900_0004u;
private static ChargenObjDesc MakeObjDesc(uint tag) => new(
0u,
[],
[new ChargenTextureChange((byte)tag, 0x0500_0000u + tag, 0x0500_1000u + tag)],
[new ChargenAnimPartChange((byte)tag, 0x0100_0000u + tag)]);
private static ChargenGenderOptions MakeGender(uint alternateHairSetup = 0u, bool baldHairStyle = false) => new(
GenderKey: GenderKey,
Name: "Male",
Scale: 100u,
SetupId: BodySetupId,
SoundTableId: 0x0900_0001u,
IconId: 0x0600_0001u,
BasePaletteId: BasePaletteId,
SkinPalSetId: SkinPalSetId,
PhysicsTableId: 0x0D00_0001u,
MotionTableId: 0x0900_0002u,
CombatTableId: 0x0000_0001u,
BaseObjDesc: MakeObjDesc(0),
HairColors: [HairColorPalSetId],
HairStyles:
[
new ChargenHairStyle(0x0600_0002u, baldHairStyle, alternateHairSetup, MakeObjDesc(1)),
],
EyeColors: [EyeColorPaletteId],
EyeStrips:
[
new ChargenEyeStrip(0x0600_0003u, 0x0600_0004u, MakeObjDesc(2), MakeObjDesc(20)),
],
NoseStrips: [new ChargenFaceStrip(0x0600_0005u, MakeObjDesc(3))],
MouthStrips: [new ChargenFaceStrip(0x0600_0006u, MakeObjDesc(4))],
Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0x3000_0001u)],
Shirts: [new ChargenGearOption("Shirt", ShirtClothingTableId, 0x3000_0002u)],
Pants: [new ChargenGearOption("Pants", TrousersClothingTableId, 0x3000_0003u)],
Footwear: [new ChargenGearOption("Boots", FootwearClothingTableId, 0x3000_0004u)],
ClothingColors: [7u]);
private static ChargenOptions MakeOptions(ChargenGenderOptions gender)
{
var heritage = new ChargenHeritageOptions(
HeritageId, "Test", 0x0600_0001u, BodySetupId, BodySetupId,
180u, 100u, [0], [],
new Dictionary<uint, ChargenSkillCost>(), [],
new Dictionary<int, ChargenGenderOptions> { [GenderKey] = gender });
return new ChargenOptions(
[],
new Dictionary<uint, ChargenHeritageOptions> { [HeritageId] = heritage },
new Dictionary<uint, ChargenSkillCost>());
}
/// <summary>One dye choice per clothing table: palette-template id 7,
/// one PalSet, one range (real units 80/16 → packed (10,2)).</summary>
private static ChargenClothingTable MakeClothingTable(uint clothingTableId, uint palSetId, uint bodySetupId)
{
var partChanges = new[] { new ChargenAnimPartChange(5, 0x0100_5000u + clothingTableId) };
var textureChanges = new[] { new ChargenTextureChange(5, 0x0500_5000u, 0x0500_6000u) };
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
{
[bodySetupId] = new ChargenClothingBaseEffect(partChanges, textureChanges),
};
var choice = new ChargenClothingSubPaletteChoice(
palSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
{
[7u] = new ChargenClothingPaletteTemplate([choice]),
};
return new ChargenClothingTable(baseEffects, templates);
}
private sealed class FakePalSetSource : IChargenPalSetSource
{
private readonly Dictionary<uint, ChargenPalSet> _sets = new();
public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds);
public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null;
}
private sealed class FakeClothingTableSource : IChargenClothingTableSource
{
private readonly Dictionary<uint, ChargenClothingTable> _tables = new();
public void Add(uint id, ChargenClothingTable table) => _tables[id] = table;
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_tables.TryGetValue(clothingTableId, out var t) ? t : null;
}
private static (FakePalSetSource pal, FakeClothingTableSource clothing) MakeSources(uint bodySetupId = BodySetupId)
{
var pal = new FakePalSetSource();
pal.Add(SkinPalSetId, 0x0400_0010u, 0x0400_0011u, 0x0400_0012u);
pal.Add(HairColorPalSetId, 0x0400_0020u, 0x0400_0021u);
var clothingDyePalSetId = 0x0F00_0003u;
pal.Add(clothingDyePalSetId, 0x0400_0030u, 0x0400_0031u);
var clothing = new FakeClothingTableSource();
clothing.Add(HeadgearClothingTableId, MakeClothingTable(HeadgearClothingTableId, clothingDyePalSetId, bodySetupId));
clothing.Add(TrousersClothingTableId, MakeClothingTable(TrousersClothingTableId, clothingDyePalSetId, bodySetupId));
clothing.Add(ShirtClothingTableId, MakeClothingTable(ShirtClothingTableId, clothingDyePalSetId, bodySetupId));
clothing.Add(FootwearClothingTableId, MakeClothingTable(FootwearClothingTableId, clothingDyePalSetId, bodySetupId));
return (pal, clothing);
}
[Fact]
public void TryCompose_ReturnsFalse_WhenHeritageIsUnknown()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
bool ok = ChargenAppearanceFactory.TryCompose(
options, heritageId: 999u, GenderKey, ChargenAppearanceSelection.Default,
pal, clothing, out _);
Assert.False(ok);
}
[Fact]
public void TryCompose_ReturnsFalse_WhenGenderIsUnknown()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, genderKey: 999, ChargenAppearanceSelection.Default,
pal, clothing, out _);
Assert.False(ok);
}
[Fact]
public void TryCompose_DefaultSelection_ResolvesBodySetupAndUnconditionalSkinSubpalette()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default,
pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.Equal(BodySetupId, result.SetupId);
Assert.Equal(BasePaletteId, result.BasePaletteId);
Assert.Empty(result.MissingPalSetIds);
Assert.Empty(result.MissingClothingTableIds);
// UnsetShade (-1.0) is out of [0,1], so GetPaletteIndex returns -1 and
// the skin block is skipped for THIS test's default selection — the
// "unconditional" behavior is that the block always RUNS (always
// attempts the PalSet lookup), not that it always emits an entry.
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 0);
// Base body's own ObjDesc still lands (tag 0's texture/anim change).
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 0);
}
[Fact]
public void TryCompose_SkinShadeSelected_EmitsSkinSubpaletteAtPackedOffsetZeroCountTwentyFour()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { SkinShade = 0.5 };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
ChargenSubPalette skin = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 24);
Assert.Equal(0x0400_0011u, skin.SubPaletteId); // index 1 of 3 at shade 0.5.
}
[Fact]
public void TryCompose_HairColorSelected_EmitsHairSubpaletteAtPackedOffsetTwentyFourCountEight()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 1.0 };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
ChargenSubPalette hair = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 24 && sp.NumColors == 8);
Assert.Equal(0x0400_0021u, hair.SubPaletteId); // last of the two at shade 1.0.
}
[Fact]
public void TryCompose_EyeColorSelected_UsesRawPaletteIdDirectlyNoShadeIndirection()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { EyeColor = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
ChargenSubPalette eye = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 32 && sp.NumColors == 8);
Assert.Equal(EyeColorPaletteId, eye.SubPaletteId);
}
[Fact]
public void TryCompose_HairStyleSelected_AppendsHairObjDescAfterBase()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Equal(0u, (uint)result.ObjDesc.AnimPartChanges[0].PartIndex); // base first.
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); // hair style second.
}
[Fact]
public void TryCompose_HairStyleWithAlternateSetup_OverridesBodySetupId()
{
ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: AlternateBodySetupId));
var (pal, clothing) = MakeSources(bodySetupId: AlternateBodySetupId);
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Equal(AlternateBodySetupId, result.SetupId);
}
[Fact]
public void TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId()
{
ChargenGenderOptions gender = MakeGender() with { SetupId = 0u };
ChargenOptions options = MakeOptions(gender);
var (pal, clothing) = MakeSources(bodySetupId: 0u);
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default,
pal, clothing, out ChargenAppearanceResult result);
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
}
/// <summary>
/// CC6a review fix round F1: retail's "unset" sentinel for a Setup id is
/// <c>INVALID_DID</c> (0xFFFFFFFF — <c>CharGenState::GetSetupID @
/// 0x005C5B22</c>), not 0. A hair style whose <c>AlternateSetup</c> field
/// stores 0xFFFFFFFF must NOT be adopted as the body Setup id — before
/// this fix the factory would hand 0xFFFFFFFF straight to a caller's
/// <c>Get&lt;Setup&gt;</c>, which nulls, and the whole preview build
/// would fail silently.
/// </summary>
[Fact]
public void TryCompose_HairStyleAlternateSetupIsInvalidDid_IsTreatedAsUnsetNotAdopted()
{
ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: 0xFFFFFFFFu));
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Equal(BodySetupId, result.SetupId); // gender.SetupId, NOT the INVALID_DID sentinel.
}
/// <summary>
/// Companion to <see cref="TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId"/>:
/// the resolved Setup id can ALSO be stuck at INVALID_DID (rather than 0)
/// when the gender's own <c>SetupId</c> dat field happens to be
/// 0xFFFFFFFF — the fallback to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
/// must catch that case too.
/// </summary>
[Fact]
public void TryCompose_GenderSetupIdIsInvalidDid_FallsBackToHumanSetupId()
{
ChargenGenderOptions gender = MakeGender() with { SetupId = 0xFFFFFFFFu };
ChargenOptions options = MakeOptions(gender);
var (pal, clothing) = MakeSources(bodySetupId: 0xFFFFFFFFu);
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default,
pal, clothing, out ChargenAppearanceResult result);
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()
{
ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: false));
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
// tag 2 = non-bald eye ObjDesc, tag 20 = bald eye ObjDesc.
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u);
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u);
}
[Fact]
public void TryCompose_EyeStripSelected_UsesBaldObjDesc_WhenHairStyleIsBald()
{
ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: true));
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); // tag 20, bald.
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); // tag 2, non-bald.
}
[Fact]
public void TryCompose_NoseAndMouthStripsSelected_AppendBothObjDescs()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { NoseStrip = 0u, MouthStrip = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 3); // nose tag.
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 4); // mouth tag.
}
[Fact]
public void TryCompose_AllFourClothingSlotsSelected_AppearInRetailOrderHeadgearTrousersShirtFootwear()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with
{
HeadgearStyle = 0u,
TrousersStyle = 0u,
ShirtStyle = 0u,
FootwearStyle = 0u,
};
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
// Only the base body's own tag (PartIndex 0) and the four clothing
// slots' PartIndex-5 overrides are present (no hair style/strips
// selected) — asserting the full ordered sequence pins retail's
// Headgear → Trousers → Shirt → Footwear append order directly.
uint[] expectedPartIds =
[
0x0100_0000u, // base body tag.
0x0100_5000u + HeadgearClothingTableId,
0x0100_5000u + TrousersClothingTableId,
0x0100_5000u + ShirtClothingTableId,
0x0100_5000u + FootwearClothingTableId,
];
Assert.Equal(expectedPartIds, result.ObjDesc.AnimPartChanges.Select(c => c.PartId).ToArray());
}
[Fact]
public void TryCompose_ClothingSlotWithColor_EmitsPartTextureAndDyeSubpalette()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with
{
HeadgearStyle = 0u,
HeadgearColor = 0u, // gender.ClothingColors[0] = 7u == the fixture's palette-template key.
HeadgearShade = 0.0,
};
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId);
Assert.Contains(result.ObjDesc.TextureChanges, c => c.PartIndex == 5 && c.NewTextureId == 0x0500_6000u);
// Real range (80, 16) packed by /8 => (10, 2).
Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
}
[Fact]
public void TryCompose_ClothingSlotWithoutColor_SkipsDyeSubpaletteButKeepsPartTextureChanges()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u };
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId);
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
}
/// <summary>
/// CC6a review fix round F8: retail's inner subpalette loop
/// (<c>ClothingTable::BuildObjDesc</c> ~0x005A7B24-0x005A7BD3) returns 0
/// IMMEDIATELY when a PalSet read fails for one choice (~0x005A7B32),
/// aborting every REMAINING choice in that garment's palette template —
/// not merely skipping the failed one and continuing. A two-choice
/// template with the FIRST choice's PalSet missing must therefore emit
/// NEITHER choice's subpalette, even though the second choice's own
/// PalSet is present and would resolve fine on its own.
/// </summary>
[Fact]
public void TryCompose_PalSetMissingMidLoop_AbortsRemainingChoicesInThatGarment()
{
const uint missingPalSetId = 0x0F00_00AAu;
const uint presentPalSetId = 0x0F00_00BBu;
var firstChoice = new ChargenClothingSubPaletteChoice(
missingPalSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]);
var secondChoice = new ChargenClothingSubPaletteChoice(
presentPalSetId, [new ChargenClothingSubPaletteRange(160u, 8u)]);
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
{
[BodySetupId] = ChargenClothingBaseEffect.Empty,
};
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
{
[7u] = new ChargenClothingPaletteTemplate([firstChoice, secondChoice]),
};
var table = new ChargenClothingTable(baseEffects, templates);
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
clothing.Add(HeadgearClothingTableId, table); // override the shared fixture's single-choice table.
pal.Add(presentPalSetId, 0x0400_0055u); // deliberately NOT adding missingPalSetId.
var selection = ChargenAppearanceSelection.Default with
{
HeadgearStyle = 0u,
HeadgearColor = 0u,
HeadgearShade = 0.0,
};
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.Contains(missingPalSetId, result.MissingPalSetIds);
// Real range (160, 8) would pack to (20, 1) if the second choice were
// (incorrectly) still applied after the first choice's miss.
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 20 && sp.NumColors == 1);
// Nothing from EITHER choice's own range landed.
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
}
/// <summary>
/// CC6a review fix round F10: a real dat <c>NumColors</c> of exactly
/// 2048 (256*8) is retail's own "whole palette" value spelled out in
/// real units — it packs to the byte 0 sentinel
/// (<see cref="AcDream.Core.World.PaletteOverride"/>'s documented
/// "Length=0 means entire palette") EXPLICITLY, not via an unchecked
/// narrowing coincidence.
/// </summary>
[Fact]
public void TryCompose_ClothingRangeNumColorsIsWholePaletteSentinel_PacksToZeroExplicitly()
{
var choice = new ChargenClothingSubPaletteChoice(
0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2048u)]);
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
{
[BodySetupId] = ChargenClothingBaseEffect.Empty,
};
var table = new ChargenClothingTable(
baseEffects,
new Dictionary<uint, ChargenClothingPaletteTemplate> { [7u] = new([choice]) });
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
clothing.Add(HeadgearClothingTableId, table);
var selection = ChargenAppearanceSelection.Default with
{
HeadgearStyle = 0u,
HeadgearColor = 0u,
HeadgearShade = 0.0,
};
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 0);
}
/// <summary>
/// CC6a review fix round F10: a shape the packed *8 byte convention
/// cannot represent losslessly (not a multiple of 8, and not the 2048
/// whole-palette sentinel) must THROW rather than silently truncate via
/// an unchecked <c>(byte)</c> cast.
/// </summary>
[Fact]
public void TryCompose_ClothingRangeDoesNotFitThePackedByteConvention_Throws()
{
var choice = new ChargenClothingSubPaletteChoice(
0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2041u)]); // not a multiple of 8, not 2048.
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
{
[BodySetupId] = ChargenClothingBaseEffect.Empty,
};
var table = new ChargenClothingTable(
baseEffects,
new Dictionary<uint, ChargenClothingPaletteTemplate> { [7u] = new([choice]) });
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
clothing.Add(HeadgearClothingTableId, table);
var selection = ChargenAppearanceSelection.Default with
{
HeadgearStyle = 0u,
HeadgearColor = 0u,
HeadgearShade = 0.0,
};
Assert.Throws<ArgumentOutOfRangeException>(() =>
ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out _));
}
[Fact]
public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped()
{
ChargenGenderOptions gender = MakeGender();
gender = gender with
{
Headgears = [new ChargenGearOption("Missing", 0x1900_00FFu, 0x3000_0099u)],
};
ChargenOptions options = MakeOptions(gender);
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u };
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.Contains(0x1900_00FFu, result.MissingClothingTableIds);
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5);
}
[Fact]
public void TryCompose_UnknownHairColorPalSetId_IsRecordedAsMissingAndSkipped()
{
ChargenGenderOptions gender = MakeGender() with { HairColors = [0x0F00_00FFu] };
ChargenOptions options = MakeOptions(gender);
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 0.5 };
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.Contains(0x0F00_00FFu, result.MissingPalSetIds);
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 24);
}
[Fact]
public void TryCompose_BodySetupAbsentFromClothingBaseEffects_IsRecordedButDoesNotThrow()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources(bodySetupId: 0x0200_DEADu); // different from the resolved body setup.
var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u };
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.Contains(HeadgearClothingTableId, result.ClothingTablesMissingBaseEffectForSetup);
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5);
}
[Fact]
public void TryCompose_OutOfRangeStyleIndex_IsTreatedAsUnselected()
{
ChargenOptions options = MakeOptions(MakeGender());
var (pal, clothing) = MakeSources();
var selection = ChargenAppearanceSelection.Default with { HairStyle = 999u, EyesStrip = 999u };
bool ok = ChargenAppearanceFactory.TryCompose(
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
Assert.True(ok);
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1);
Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 2);
}
}

View file

@ -0,0 +1,63 @@
using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Pins <see cref="ChargenPalSetMath.GetPaletteIndex"/> against the exact
/// formula ACE's <c>PaletteSet.GetPaletteID</c> cites as "Taken from
/// acclient.c (PalSet::GetPaletteID)": <c>(int)((count - 0.000001) * shade)</c>,
/// clamped to <c>[0, count-1]</c>, with an out-of-<c>[0,1]</c> shade (or a
/// non-positive count) returning -1.
/// </summary>
public class ChargenPalSetMathTests
{
[Theory]
[InlineData(5, 0.0, 0)]
[InlineData(5, 1.0, 4)]
[InlineData(5, 0.5, 2)]
[InlineData(1, 0.0, 0)]
[InlineData(1, 1.0, 0)]
public void GetPaletteIndex_matches_the_cited_acclient_formula(int count, double shade, int expected)
{
Assert.Equal(expected, ChargenPalSetMath.GetPaletteIndex(count, shade));
}
[Theory]
[InlineData(0, 0.5)]
[InlineData(-1, 0.5)]
public void GetPaletteIndex_returns_negative_one_for_non_positive_count(int count, double shade)
{
Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade));
}
[Theory]
[InlineData(5, -0.0001)]
[InlineData(5, 1.0001)]
[InlineData(5, ChargenAppearanceSelection.UnsetShade)] // retail's own "unset" sentinel is out of [0,1].
public void GetPaletteIndex_returns_negative_one_for_out_of_range_shade(int count, double shade)
{
Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade));
}
[Fact]
public void GetPaletteIndex_never_exceeds_count_minus_one_near_the_upper_bound()
{
// shade == 1.0 exactly must land on the LAST index, not overflow past it —
// the (count - 0.000001) fudge factor exists precisely to guarantee this.
for (int count = 1; count <= 64; count++)
Assert.Equal(count - 1, ChargenPalSetMath.GetPaletteIndex(count, 1.0));
}
[Fact]
public void GetPaletteIndex_is_monotonic_non_decreasing_in_shade()
{
const int count = 13;
int previous = -1;
for (double shade = 0.0; shade <= 1.0; shade += 0.01)
{
int index = ChargenPalSetMath.GetPaletteIndex(count, shade);
Assert.True(index >= previous, $"index regressed at shade={shade}");
previous = index;
}
}
}

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