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

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