feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation

Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:21:34 +02:00
parent 3a6b7e3115
commit 55bfd9ca82
16 changed files with 2131 additions and 2 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,176 @@
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. CC6b's heading
/// parameter therefore belongs on the entity builder
/// (<see cref="ChargenPreviewEntityBuilder"/>), not here; this class stays a
/// fixed-per-heritage eye, exactly like retail's own camera.
/// </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). Consumed by CC6b's rotation controller as
/// <c>360f / RotationDegreesPerSecond</c> — 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,258 @@
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>
/// Builds the static-pose 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-animated, non-collision preview scene needs.
/// </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 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>
/// Builds the preview entity, or null when the resolved body Setup
/// isn't in the dat source (a corrupted/incomplete install — the same
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
/// as "drop this spawn").
/// </summary>
public static WorldEntity? TryBuild(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animations);
ArgumentNullException.ThrowIfNull(appearance);
Setup? setup = dats.Get<Setup>(appearance.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);
}
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
var meshRefs = 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;
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
}
if (meshRefs.Count == 0)
return null;
PaletteOverride? paletteOverride = null;
if (appearance.ObjDesc.SubPalettes.Count > 0)
{
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);
}
paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges);
}
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 new WorldEntity
{
Id = PreviewRenderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = appearance.SetupId,
Position = Vector3.Zero,
Rotation = heading,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = partOverrides,
ParentCellId = null,
};
}
/// <summary>
/// Overwrites every part's transform from the resolved rest pose'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 ApplyHeldPose(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint heritageId,
List<MeshRef> flattened)
{
uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId));
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;
}
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
flattened[index] = new MeshRef(flattened[index].GfxObjId, transform);
}
}
/// <summary>
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent — verbatim
/// port of <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>,
/// parameterized by the target enum key.
/// </summary>
private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
{
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
/// 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,80 @@
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, after CC4 merges per the campaign's parallelism
/// contract):</b> mounting into the authored Appearance/Summary viewport ids
/// (<c>0x100003bb</c> / <c>0x10000406</c>), spin/color-wheel controls, and
/// the rotate/zoom buttons. This class is a standalone, composition-root-
/// agnostic renderer — nothing in <c>AcDream.App/UI/Layout/</c> or
/// <c>RetailUiRuntime.cs</c> references it yet.
/// </para>
///
/// <para>
/// <b>Register row (staged deviation, retired by CC6b):</b> retail plays a
/// live 30fps idle loop in the preview
/// (<c>gmCG3DView</c>'s <c>m_didAnimation</c>/<c>m_didAnimArray</c>,
/// <c>set_sequence_animation</c>, distinct from the STATIC
/// <c>m_didAnimationRest</c> this class's entity builder uses). CC6a holds
/// the static rest-pose final frame only — see
/// <c>docs/architecture/retail-divergence-register.md</c>.
/// </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,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,350 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The resolved render description <see cref="ChargenAppearanceFactory.Compose"/>
/// 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 nonzero (Gear Knight / Undead / Tumerok body
/// variants), falling back to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
/// when both are zero (retail: <c>CPhysicsObj::makeObject(setupId)</c>'s own
/// HUMAN_SETUP_ID fallback, <c>gmCG3DView</c> ctor pseudo-C ~0x004EE79D and
/// <c>gmCG3DView::Update</c> ~0x004EEA61).
/// </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.Compose"/>).
/// </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>
/// 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>
public static bool TryCompose(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
out ChargenAppearanceResult result)
{
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)
setupId = hairStyle.AlternateSetup;
}
if (setupId == 0)
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 palette-template lookup is a silent no-op.
foreach (ChargenClothingSubPaletteChoice choice in template.Choices)
{
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
if (palSet is null)
{
missingPalSets.Add(choice.PalSetId);
continue;
}
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,
(byte)(range.Offset / 8),
(byte)(range.NumColors / 8)));
}
}
}
}

View file

@ -0,0 +1,52 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The fourteen style/color indices plus the six f64 shades
/// <see cref="ChargenAppearanceFactory.Compose"/> 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,143 @@
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
/// (matching retail's own "hash miss → BuildObjDesc returns failure, caller
/// does not check it, ObjDesc keeps whatever it already had" behavior)
/// rather than porting the 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, but its default headgear/trousers/footwear choices
/// have no <see cref="BaseEffectsBySetupId"/> entry for either gender's
/// live Setup id (measured: 4 of 4 non-shirt slots miss, on both genders),
/// 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 headgear/trousers/footwear
/// choice with NO part/texture override applied (the underlying body shows
/// through unclothed for those slots) 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,49 @@
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 FPU-elided (the x87
/// bounds-compare against 0.0/1.0 and the truncating <c>_ftol2()</c> cast
/// lose their operands to the decompiler), but ACE's
/// <c>ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID</c> carries the
/// explicit comment "Taken from acclient.c (PalSet::GetPaletteID)" with the
/// exact formula below — corroborated by the decomp's own control-flow
/// shape (a two-sided FPU compare consistent with a <c>[0,1]</c> bounds
/// check, then one truncating cast) and independently by ACViewer's
/// <c>ClothingTableList.xaml.cs:97</c> UI slider, which reimplements the
/// identical <c>(count - 0.000001) * shade</c> expression for its own shade
/// preview. Three independent sources agree.
/// </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,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,127 @@
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);
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);
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);
// 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);
}
}

View file

@ -0,0 +1,155 @@
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 records (without asserting
/// zero — see the class doc on <see cref="ChargenClothingTable"/>'s
/// deliberate scope cut) how many clothing slots have no
/// <c>ClothingBaseEffects</c> entry for their own gender's body Setup, so a
/// future session can see at a glance whether CC6a's decision to skip
/// retail's Setup-substitution fallback chain ever actually costs
/// coverage on the real dat.
/// </summary>
public sealed class ChargenAppearanceCatalogInstalledDatTests
{
private readonly ITestOutputHelper _out;
public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output;
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;
int absentBaseEffectTotal = 0;
var missingSummaries = 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}"))}]");
}
absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count;
if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0)
{
_out.WriteLine(
$"heritage={heritage.Name} 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}"))}]");
}
}
}
_out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total.");
Assert.True(
missingSummaries.Count == 0,
"Missing dat ids found:\n" + string.Join('\n', missingSummaries));
Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}.");
}
/// <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,436 @@
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);
}
[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);
}
[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;
}
}
}