namespace AcDream.Core.CharGen;
///
/// The resolved render description
/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies
/// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 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.
///
///
/// The body Setup dat id (0x02......) to build the preview mesh from —
/// gender.SetupId, overridden by the selected hair style's
/// AlternateSetup when it is neither 0 nor retail's INVALID_DID
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn
/// overridden outright by 's
/// own alternateSetupIdOverride parameter when THAT is not
/// INVALID_DID (gmCG3DView::Update's own
/// m_alternateSetupID resolution, ~0x004EEA46-0x004EEA53 — see that
/// parameter's doc for why chargen's own Appearance page never actually sets
/// it), falling back to
/// when the resolved id is STILL 0 OR INVALID_DID after all three
/// tiers (retail: CharGenState::GetSetupID @ 0x005C5B22 and
/// gmCG3DView::Update's own check at ~0x004EEA5F both test against
/// INVALID_DID, not zero — acclient.h:39909 types the field as
/// IDClass, whose "unset" value is 0xFFFFFFFF;
/// CPhysicsObj::makeObject(setupId)'s own HUMAN_SETUP_ID fallback,
/// gmCG3DView ctor pseudo-C ~0x004EE79D).
///
///
/// gender.BasePaletteId (retail Sex_CG.BasePalette) — the
/// palette a mesh builder should pass as the entity's base, NOT
/// ObjDesc.PaletteId (retail's own on-disk BaseObjDesc.PaletteId
/// field is unused for this purpose; cross-checked against
/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58,
/// which sets PropertyDataId.PaletteBase from sex.BasePalette
/// directly).
///
///
/// The composed subpalette/texture/part-swap deltas, in retail's exact
/// application order (see ).
///
public sealed record ChargenAppearanceResult(
uint SetupId,
uint BasePaletteId,
ChargenObjDesc ObjDesc,
IReadOnlyList MissingPalSetIds,
IReadOnlyList MissingClothingTableIds,
IReadOnlyList ClothingTablesMissingBaseEffectForSetup);
///
/// Index→ObjDesc appearance factory: the missing piece the campaign plan's
/// "acdream seams" section names (Appearance building: DollEntityBuilder.Build
/// 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 ChargenOptions family; PalSet/ClothingTable
/// dat reads are pushed behind /
/// , whose production implementation
/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) does the actual
/// dat work.
///
///
/// Ports gmCG3DView::Update @ 0x004EE9D0'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):
///
///
/// - Base body (Sex_CG.BaseObjDesc).
/// - Hair style overlay (HairStyle_CG.ObjDesc), if selected.
/// - Clothing, in retail's own order — Headgear, Trousers, Shirt,
/// Footwear (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 ClothingBase part/texture overrides unconditionally, then
/// — only when a color is also selected — its dye subpalette via
/// ClothingTable::BuildObjDesc @ 0x005A7900.
/// - Eyes strip overlay (bald variant when the selected hair style's
/// Bald flag is set), if selected.
/// - Nose strip overlay, if selected.
/// - Mouth strip overlay, if selected.
/// - 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
/// 0xFFFFFFFF sentinel).
/// - Hair color subpalette, if selected.
/// - Eye color subpalette, if selected.
///
///
public static class ChargenAppearanceFactory
{
///
/// Retail's HUMAN_SETUP_ID fallback (ACViewer.Entity.Enum.SetupConst.HumanMale
/// = 0x02000001; the same constant gmCG3DView's ctor and
/// ::Update fall back to when no valid body Setup is resolvable).
///
public const uint HumanSetupId = 0x02000001u;
///
/// Retail's IDClass "unset" sentinel (INVALID_DID,
/// 0xFFFFFFFF — acclient.h:39909). CharGenState::GetSetupID @
/// 0x005C5B22 and gmCG3DView::Update's own checks
/// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not
/// zero — a hair style whose AlternateSetup field happens to
/// store this sentinel must be treated as "no override," exactly like
/// zero, or the factory would hand a bogus Setup id to
/// Get<Setup> and produce no preview at all.
///
private const uint InvalidDid = 0xFFFFFFFFu;
///
/// Skin subpalette overlay range, retail's hard-coded literal at
/// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0,
/// real color count 192 (0xC0), packed to 's
/// *8 on-disk units as (0, 24).
///
private const byte SkinRangeOffset = 0;
private const byte SkinRangeNumColors = 24; // 192 / 8
///
/// 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).
///
private const byte HairRangeOffset = 24; // 192 / 8
private const byte HairRangeNumColors = 8; // 64 / 8
///
/// 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).
///
private const byte EyeRangeOffset = 32; // 256 / 8
private const byte EyeRangeNumColors = 8; // 64 / 8
///
/// Composes a preview appearance description for one heritage/gender +
/// selection, or returns false when the heritage/gender itself doesn't
/// resolve (mirrors the Try* convention
/// 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.
///
///
/// Retail's SECOND body-Setup-override source — gmCG3DView's
/// m_alternateSetupID field (default INVALID_DID, read at
/// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53) — which, when set
/// to anything other than INVALID_DID, REPLACES the hairstyle/
/// gender-resolved Setup id outright rather than combining with it.
/// Decomp-verified NOT to be a character-creation-time mechanism:
/// every write site for m_alternateSetupID (the Penumbraen-crown
/// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/
/// 0x004E0DB1) lives on gmBarberUI — the POST-CREATION barber-
/// shop appearance-editing screen, a wholly separate UI class from
/// character creation's gmCGAppearancePage, which has no
/// m_pOption1Checkbox-equivalent field and never writes
/// m_alternateSetupID anywhere in its own methods (confirmed
/// against every field on gmCGAppearancePage,
/// acclient.h:56373-56428). For chargen's own preview,
/// m_alternateSetupID is therefore ALWAYS INVALID_DID in
/// retail, and this parameter's default ()
/// reproduces that exactly — a real, decomp-verified precedence tier is
/// threaded through so a future non-chargen consumer of this same
/// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can
/// supply one, without inventing a UI source chargen's own Appearance
/// page doesn't have.
///
public static bool TryCompose(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
out ChargenAppearanceResult result,
uint alternateSetupIdOverride = InvalidDid)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(palSets);
ArgumentNullException.ThrowIfNull(clothingTables);
result = default!;
if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|| !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender))
{
return false;
}
var missingPalSets = new List();
var missingClothingTables = new List();
var absentBaseEffects = new List();
// ── 1. body Setup id ────────────────────────────────────────────
uint setupId = gender.SetupId;
ChargenHairStyle? hairStyle = null;
if (selection.HairStyle != ChargenAppearanceSelection.Unset
&& selection.HairStyle < (uint)gender.HairStyles.Count)
{
hairStyle = gender.HairStyles[(int)selection.HairStyle];
if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid)
setupId = hairStyle.AlternateSetup;
}
// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID,
// when set, REPLACES the hairstyle/gender-resolved id outright — it
// does not combine with it. See alternateSetupIdOverride's own doc
// for why chargen's own Appearance page never actually supplies one.
if (alternateSetupIdOverride != InvalidDid)
setupId = alternateSetupIdOverride;
if (setupId == 0 || setupId == InvalidDid)
setupId = HumanSetupId;
// ── 2. ObjDesc accumulation, retail's exact append order ───────
var subPalettes = new List();
var textureChanges = new List();
var animPartChanges = new List();
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 subPalettes,
List textureChanges,
List animPartChanges)
{
subPalettes.AddRange(source.SubPalettes);
textureChanges.AddRange(source.TextureChanges);
animPartChanges.AddRange(source.AnimPartChanges);
}
private static void ComposeClothingSlot(
IReadOnlyList gearOptions,
uint styleIndex,
IReadOnlyList clothingColors,
uint colorIndex,
double shade,
uint bodySetupId,
IChargenClothingTableSource clothingTables,
IChargenPalSetSource palSets,
List subPalettes,
List textureChanges,
List animPartChanges,
List missingClothingTables,
List missingPalSets,
List absentBaseEffects)
{
if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count)
return;
ChargenGearOption gear = gearOptions[(int)styleIndex];
ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId);
if (table is null)
{
missingClothingTables.Add(gear.ClothingTableId);
return;
}
if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect))
{
animPartChanges.AddRange(baseEffect.PartChanges);
textureChanges.AddRange(baseEffect.TextureChanges);
}
else
{
absentBaseEffects.Add(gear.ClothingTableId);
}
if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count)
return;
uint paletteTemplateId = clothingColors[(int)colorIndex];
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op.
foreach (ChargenClothingSubPaletteChoice choice in template.Choices)
{
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
if (palSet is null)
{
// Retail's own inner loop (ClothingTable::BuildObjDesc
// ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when
// DBObj::Get fails for one subpalEffect entry's PalSet
// (~0x005A7B32) — aborting every REMAINING choice in this
// same garment's palette template, not merely skipping the
// failed one. `break`, not `continue`, matches that; the
// miss is still recorded so callers can see it happened.
missingPalSets.Add(choice.PalSetId);
break;
}
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
if (index < 0)
continue;
uint paletteId = palSet.PaletteIds[index];
foreach (ChargenClothingSubPaletteRange range in choice.Ranges)
{
subPalettes.Add(new ChargenSubPalette(
paletteId,
PackOffset(range.Offset),
PackNumColors(range.NumColors)));
}
}
}
///
/// Converts a real (unpacked) clothing subpalette offset into
/// 's packed *8 on-disk unit. Throws
/// rather than silently truncating on a shape we've never seen and
/// don't know how to represent losslessly (guards against the
/// unchecked-narrowing footgun a plain (byte)(value / 8) cast
/// would otherwise hide).
///
private static byte PackOffset(uint realOffset)
{
if (realOffset % 8u != 0 || realOffset > 2040u)
{
throw new ArgumentOutOfRangeException(
nameof(realOffset),
realOffset,
"Clothing subpalette range offset does not fit the packed *8 byte "
+ "convention (expected a multiple of 8 in [0, 2040]).");
}
return (byte)(realOffset / 8u);
}
///
/// Same packing as , plus retail's own explicit
/// "whole palette" sentinel: a packed NumColors of 0 means "the
/// entire palette" ('s
/// doc: "Length=0 is a sentinel meaning entire palette... defaulting to
/// 256*8"). A real count of exactly 2048 (256*8) IS that same value
/// spelled out in real units, so it packs to 0 BY DESIGN — not because
/// an unchecked (byte) cast happens to wrap 256 back to 0.
///
private static byte PackNumColors(uint realNumColors)
{
if (realNumColors == 2048u)
return 0;
if (realNumColors % 8u != 0 || realNumColors > 2040u)
{
throw new ArgumentOutOfRangeException(
nameof(realNumColors),
realNumColors,
"Clothing subpalette range color count does not fit the packed *8 byte "
+ "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 "
+ "for the whole-palette sentinel).");
}
return (byte)(realNumColors / 8u);
}
}