Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
458 lines
22 KiB
C#
458 lines
22 KiB
C#
namespace AcDream.Core.CharGen;
|
|
|
|
/// <summary>
|
|
/// The resolved render description <see cref="ChargenAppearanceFactory.TryCompose"/>
|
|
/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies
|
|
/// to it (<c>CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0</c> is
|
|
/// retail's equivalent apply step). The three diagnostic lists let callers
|
|
/// (and CC6a's installed-DAT test) verify a selection resolved with no
|
|
/// missing dat data without needing to re-walk the composition themselves.
|
|
/// </summary>
|
|
/// <param name="SetupId">
|
|
/// The body Setup dat id (0x02......) to build the preview mesh from —
|
|
/// <c>gender.SetupId</c>, overridden by the selected hair style's
|
|
/// <c>AlternateSetup</c> when it is neither 0 nor retail's <c>INVALID_DID</c>
|
|
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn
|
|
/// overridden outright by <see cref="ChargenAppearanceFactory.TryCompose"/>'s
|
|
/// own <c>alternateSetupIdOverride</c> parameter when THAT is not
|
|
/// <c>INVALID_DID</c> (<c>gmCG3DView::Update</c>'s own
|
|
/// <c>m_alternateSetupID</c> resolution, ~0x004EEA46-0x004EEA53 — see that
|
|
/// parameter's doc for why chargen's own Appearance page never actually sets
|
|
/// it), falling back to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
|
|
/// when the resolved id is STILL 0 OR <c>INVALID_DID</c> after all three
|
|
/// tiers (retail: <c>CharGenState::GetSetupID @ 0x005C5B22</c> and
|
|
/// <c>gmCG3DView::Update</c>'s own check at ~0x004EEA5F both test against
|
|
/// <c>INVALID_DID</c>, not zero — <c>acclient.h:39909</c> types the field as
|
|
/// <c>IDClass</c>, whose "unset" value is 0xFFFFFFFF;
|
|
/// <c>CPhysicsObj::makeObject(setupId)</c>'s own HUMAN_SETUP_ID fallback,
|
|
/// <c>gmCG3DView</c> ctor pseudo-C ~0x004EE79D).
|
|
/// </param>
|
|
/// <param name="BasePaletteId">
|
|
/// <c>gender.BasePaletteId</c> (retail <c>Sex_CG.BasePalette</c>) — the
|
|
/// palette a mesh builder should pass as the entity's base, NOT
|
|
/// <c>ObjDesc.PaletteId</c> (retail's own on-disk <c>BaseObjDesc.PaletteId</c>
|
|
/// field is unused for this purpose; cross-checked against
|
|
/// <c>references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58</c>,
|
|
/// which sets <c>PropertyDataId.PaletteBase</c> from <c>sex.BasePalette</c>
|
|
/// directly).
|
|
/// </param>
|
|
/// <param name="ObjDesc">
|
|
/// The composed subpalette/texture/part-swap deltas, in retail's exact
|
|
/// application order (see <see cref="ChargenAppearanceFactory.TryCompose"/>).
|
|
/// </param>
|
|
public sealed record ChargenAppearanceResult(
|
|
uint SetupId,
|
|
uint BasePaletteId,
|
|
ChargenObjDesc ObjDesc,
|
|
IReadOnlyList<uint> MissingPalSetIds,
|
|
IReadOnlyList<uint> MissingClothingTableIds,
|
|
IReadOnlyList<uint> ClothingTablesMissingBaseEffectForSetup);
|
|
|
|
/// <summary>
|
|
/// Index→ObjDesc appearance factory: the missing piece the campaign plan's
|
|
/// "acdream seams" section names (Appearance building: <c>DollEntityBuilder.Build</c>
|
|
/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat
|
|
/// →ObjDesc factory). Pure — no Chorizite types on this type's public
|
|
/// surface, matching CC1's <c>ChargenOptions</c> family; PalSet/ClothingTable
|
|
/// dat reads are pushed behind <see cref="IChargenPalSetSource"/>/
|
|
/// <see cref="IChargenClothingTableSource"/>, whose production implementation
|
|
/// (<c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>) does the actual
|
|
/// dat work.
|
|
///
|
|
/// <para>
|
|
/// Ports <c>gmCG3DView::Update @ 0x004EE9D0</c>'s ObjDesc rebuild verbatim,
|
|
/// in its EXACT append order (verified against the decompiled control flow,
|
|
/// not inferred from the UI's tab order or the wire's field order, both of
|
|
/// which differ — see the per-slot XML doc below):
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>Base body (<c>Sex_CG.BaseObjDesc</c>).</item>
|
|
/// <item>Hair style overlay (<c>HairStyle_CG.ObjDesc</c>), if selected.</item>
|
|
/// <item>Clothing, in retail's own order — <b>Headgear, Trousers, Shirt,
|
|
/// Footwear</b> (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/
|
|
/// footwear, and NOT the wire field order from CC2's 0xF656 builder,
|
|
/// which is also headgear/shirt/trousers/footwear). Each slot applies
|
|
/// its <c>ClothingBase</c> part/texture overrides unconditionally, then
|
|
/// — only when a color is also selected — its dye subpalette via
|
|
/// <c>ClothingTable::BuildObjDesc @ 0x005A7900</c>.</item>
|
|
/// <item>Eyes strip overlay (bald variant when the selected hair style's
|
|
/// <c>Bald</c> flag is set), if selected.</item>
|
|
/// <item>Nose strip overlay, if selected.</item>
|
|
/// <item>Mouth strip overlay, if selected.</item>
|
|
/// <item>Skin subpalette — UNCONDITIONAL, no "if selected" guard in
|
|
/// retail (the decompiled block runs every time, unlike every style/
|
|
/// color slot above and below it, which all gate on retail's
|
|
/// <c>0xFFFFFFFF</c> sentinel).</item>
|
|
/// <item>Hair color subpalette, if selected.</item>
|
|
/// <item>Eye color subpalette, if selected.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public static class ChargenAppearanceFactory
|
|
{
|
|
/// <summary>
|
|
/// Retail's HUMAN_SETUP_ID fallback (<c>ACViewer.Entity.Enum.SetupConst.HumanMale</c>
|
|
/// = 0x02000001; the same constant <c>gmCG3DView</c>'s ctor and
|
|
/// <c>::Update</c> fall back to when no valid body Setup is resolvable).
|
|
/// </summary>
|
|
public const uint HumanSetupId = 0x02000001u;
|
|
|
|
/// <summary>
|
|
/// Retail's <c>IDClass</c> "unset" sentinel (<c>INVALID_DID</c>,
|
|
/// 0xFFFFFFFF — <c>acclient.h:39909</c>). <c>CharGenState::GetSetupID @
|
|
/// 0x005C5B22</c> and <c>gmCG3DView::Update</c>'s own checks
|
|
/// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not
|
|
/// zero — a hair style whose <c>AlternateSetup</c> field happens to
|
|
/// store this sentinel must be treated as "no override," exactly like
|
|
/// zero, or the factory would hand a bogus Setup id to
|
|
/// <c>Get<Setup></c> and produce no preview at all.
|
|
/// </summary>
|
|
private const uint InvalidDid = 0xFFFFFFFFu;
|
|
|
|
/// <summary>
|
|
/// Skin subpalette overlay range, retail's hard-coded literal at
|
|
/// <c>gmCG3DView::Update</c> ~0x004EF066-0x004EF07E: real byte offset 0,
|
|
/// real color count 192 (0xC0), packed to <see cref="ChargenSubPalette"/>'s
|
|
/// *8 on-disk units as (0, 24).
|
|
/// </summary>
|
|
private const byte SkinRangeOffset = 0;
|
|
private const byte SkinRangeNumColors = 24; // 192 / 8
|
|
|
|
/// <summary>
|
|
/// Hair color subpalette overlay range, retail's hard-coded literal at
|
|
/// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40),
|
|
/// packed to (24, 8).
|
|
/// </summary>
|
|
private const byte HairRangeOffset = 24; // 192 / 8
|
|
private const byte HairRangeNumColors = 8; // 64 / 8
|
|
|
|
/// <summary>
|
|
/// Eye color subpalette overlay range, retail's hard-coded literal at
|
|
/// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64
|
|
/// (0x40), packed to (32, 8).
|
|
/// </summary>
|
|
private const byte EyeRangeOffset = 32; // 256 / 8
|
|
private const byte EyeRangeNumColors = 8; // 64 / 8
|
|
|
|
/// <summary>
|
|
/// Composes a preview appearance description for one heritage/gender +
|
|
/// selection, or returns false when the heritage/gender itself doesn't
|
|
/// resolve (mirrors the <c>Try*</c> convention <see cref="ChargenOptions"/>
|
|
/// already uses). Never throws on missing PalSet/ClothingTable data —
|
|
/// a miss is recorded in the result's diagnostic lists and that single
|
|
/// contribution is skipped, matching retail's own "hash miss → no-op,
|
|
/// caller never checks BuildObjDesc's return value" behavior.
|
|
/// </summary>
|
|
/// <param name="alternateSetupIdOverride">
|
|
/// Retail's SECOND body-Setup-override source — <c>gmCG3DView</c>'s
|
|
/// <c>m_alternateSetupID</c> field (default <c>INVALID_DID</c>, read at
|
|
/// <c>gmCG3DView::Update @ ~0x004EEA46-0x004EEA53</c>) — which, when set
|
|
/// to anything other than <c>INVALID_DID</c>, REPLACES the hairstyle/
|
|
/// gender-resolved Setup id outright rather than combining with it.
|
|
/// <b>Decomp-verified NOT to be a character-creation-time mechanism:</b>
|
|
/// every write site for <c>m_alternateSetupID</c> (the Penumbraen-crown
|
|
/// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/
|
|
/// 0x004E0DB1) lives on <c>gmBarberUI</c> — the POST-CREATION barber-
|
|
/// shop appearance-editing screen, a wholly separate UI class from
|
|
/// character creation's <c>gmCGAppearancePage</c>, which has no
|
|
/// <c>m_pOption1Checkbox</c>-equivalent field and never writes
|
|
/// <c>m_alternateSetupID</c> anywhere in its own methods (confirmed
|
|
/// against every field on <c>gmCGAppearancePage</c>,
|
|
/// <c>acclient.h:56373-56428</c>). For chargen's own preview,
|
|
/// <c>m_alternateSetupID</c> is therefore ALWAYS <c>INVALID_DID</c> in
|
|
/// retail, and this parameter's default (<see cref="InvalidDid"/>)
|
|
/// reproduces that exactly — a real, decomp-verified precedence tier is
|
|
/// threaded through so a future non-chargen consumer of this same
|
|
/// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can
|
|
/// supply one, without inventing a UI source chargen's own Appearance
|
|
/// page doesn't have.
|
|
/// </param>
|
|
public static bool TryCompose(
|
|
ChargenOptions options,
|
|
uint heritageId,
|
|
int genderKey,
|
|
ChargenAppearanceSelection selection,
|
|
IChargenPalSetSource palSets,
|
|
IChargenClothingTableSource clothingTables,
|
|
out ChargenAppearanceResult result,
|
|
uint alternateSetupIdOverride = InvalidDid)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(palSets);
|
|
ArgumentNullException.ThrowIfNull(clothingTables);
|
|
|
|
result = default!;
|
|
if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|
|
|| !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var missingPalSets = new List<uint>();
|
|
var missingClothingTables = new List<uint>();
|
|
var absentBaseEffects = new List<uint>();
|
|
|
|
// ── 1. body Setup id ────────────────────────────────────────────
|
|
uint setupId = gender.SetupId;
|
|
ChargenHairStyle? hairStyle = null;
|
|
if (selection.HairStyle != ChargenAppearanceSelection.Unset
|
|
&& selection.HairStyle < (uint)gender.HairStyles.Count)
|
|
{
|
|
hairStyle = gender.HairStyles[(int)selection.HairStyle];
|
|
if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid)
|
|
setupId = hairStyle.AlternateSetup;
|
|
}
|
|
|
|
// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID,
|
|
// when set, REPLACES the hairstyle/gender-resolved id outright — it
|
|
// does not combine with it. See alternateSetupIdOverride's own doc
|
|
// for why chargen's own Appearance page never actually supplies one.
|
|
if (alternateSetupIdOverride != InvalidDid)
|
|
setupId = alternateSetupIdOverride;
|
|
|
|
if (setupId == 0 || setupId == InvalidDid)
|
|
setupId = HumanSetupId;
|
|
|
|
// ── 2. ObjDesc accumulation, retail's exact append order ───────
|
|
var subPalettes = new List<ChargenSubPalette>();
|
|
var textureChanges = new List<ChargenTextureChange>();
|
|
var animPartChanges = new List<ChargenAnimPartChange>();
|
|
|
|
Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges);
|
|
if (hairStyle is not null)
|
|
Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges);
|
|
|
|
ComposeClothingSlot(
|
|
gender.Headgears, selection.HeadgearStyle,
|
|
gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade,
|
|
setupId, clothingTables, palSets,
|
|
subPalettes, textureChanges, animPartChanges,
|
|
missingClothingTables, missingPalSets, absentBaseEffects);
|
|
ComposeClothingSlot(
|
|
gender.Pants, selection.TrousersStyle,
|
|
gender.ClothingColors, selection.TrousersColor, selection.TrousersShade,
|
|
setupId, clothingTables, palSets,
|
|
subPalettes, textureChanges, animPartChanges,
|
|
missingClothingTables, missingPalSets, absentBaseEffects);
|
|
ComposeClothingSlot(
|
|
gender.Shirts, selection.ShirtStyle,
|
|
gender.ClothingColors, selection.ShirtColor, selection.ShirtShade,
|
|
setupId, clothingTables, palSets,
|
|
subPalettes, textureChanges, animPartChanges,
|
|
missingClothingTables, missingPalSets, absentBaseEffects);
|
|
ComposeClothingSlot(
|
|
gender.Footwear, selection.FootwearStyle,
|
|
gender.ClothingColors, selection.FootwearColor, selection.FootwearShade,
|
|
setupId, clothingTables, palSets,
|
|
subPalettes, textureChanges, animPartChanges,
|
|
missingClothingTables, missingPalSets, absentBaseEffects);
|
|
|
|
if (selection.EyesStrip != ChargenAppearanceSelection.Unset
|
|
&& selection.EyesStrip < (uint)gender.EyeStrips.Count)
|
|
{
|
|
ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip];
|
|
bool bald = hairStyle?.Bald == true;
|
|
Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges);
|
|
}
|
|
if (selection.NoseStrip != ChargenAppearanceSelection.Unset
|
|
&& selection.NoseStrip < (uint)gender.NoseStrips.Count)
|
|
{
|
|
Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges);
|
|
}
|
|
if (selection.MouthStrip != ChargenAppearanceSelection.Unset
|
|
&& selection.MouthStrip < (uint)gender.MouthStrips.Count)
|
|
{
|
|
Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges);
|
|
}
|
|
|
|
// ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─
|
|
ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId);
|
|
if (skinPalSet is null)
|
|
{
|
|
missingPalSets.Add(gender.SkinPalSetId);
|
|
}
|
|
else
|
|
{
|
|
int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade);
|
|
if (skinIndex >= 0)
|
|
{
|
|
subPalettes.Add(new ChargenSubPalette(
|
|
skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors));
|
|
}
|
|
}
|
|
|
|
if (selection.HairColor != ChargenAppearanceSelection.Unset
|
|
&& selection.HairColor < (uint)gender.HairColors.Count)
|
|
{
|
|
uint hairPalSetId = gender.HairColors[(int)selection.HairColor];
|
|
ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId);
|
|
if (hairPalSet is null)
|
|
{
|
|
missingPalSets.Add(hairPalSetId);
|
|
}
|
|
else
|
|
{
|
|
int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade);
|
|
if (hairIndex >= 0)
|
|
{
|
|
subPalettes.Add(new ChargenSubPalette(
|
|
hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selection.EyeColor != ChargenAppearanceSelection.Unset
|
|
&& selection.EyeColor < (uint)gender.EyeColors.Count)
|
|
{
|
|
// Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc).
|
|
uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor];
|
|
subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors));
|
|
}
|
|
|
|
var objDesc = new ChargenObjDesc(
|
|
gender.BasePaletteId,
|
|
subPalettes.AsReadOnly(),
|
|
textureChanges.AsReadOnly(),
|
|
animPartChanges.AsReadOnly());
|
|
|
|
result = new ChargenAppearanceResult(
|
|
setupId,
|
|
gender.BasePaletteId,
|
|
objDesc,
|
|
missingPalSets.AsReadOnly(),
|
|
missingClothingTables.AsReadOnly(),
|
|
absentBaseEffects.AsReadOnly());
|
|
return true;
|
|
}
|
|
|
|
private static void Append(
|
|
ChargenObjDesc source,
|
|
List<ChargenSubPalette> subPalettes,
|
|
List<ChargenTextureChange> textureChanges,
|
|
List<ChargenAnimPartChange> animPartChanges)
|
|
{
|
|
subPalettes.AddRange(source.SubPalettes);
|
|
textureChanges.AddRange(source.TextureChanges);
|
|
animPartChanges.AddRange(source.AnimPartChanges);
|
|
}
|
|
|
|
private static void ComposeClothingSlot(
|
|
IReadOnlyList<ChargenGearOption> gearOptions,
|
|
uint styleIndex,
|
|
IReadOnlyList<uint> clothingColors,
|
|
uint colorIndex,
|
|
double shade,
|
|
uint bodySetupId,
|
|
IChargenClothingTableSource clothingTables,
|
|
IChargenPalSetSource palSets,
|
|
List<ChargenSubPalette> subPalettes,
|
|
List<ChargenTextureChange> textureChanges,
|
|
List<ChargenAnimPartChange> animPartChanges,
|
|
List<uint> missingClothingTables,
|
|
List<uint> missingPalSets,
|
|
List<uint> absentBaseEffects)
|
|
{
|
|
if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count)
|
|
return;
|
|
|
|
ChargenGearOption gear = gearOptions[(int)styleIndex];
|
|
ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId);
|
|
if (table is null)
|
|
{
|
|
missingClothingTables.Add(gear.ClothingTableId);
|
|
return;
|
|
}
|
|
|
|
if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect))
|
|
{
|
|
animPartChanges.AddRange(baseEffect.PartChanges);
|
|
textureChanges.AddRange(baseEffect.TextureChanges);
|
|
}
|
|
else
|
|
{
|
|
absentBaseEffects.Add(gear.ClothingTableId);
|
|
}
|
|
|
|
if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count)
|
|
return;
|
|
|
|
uint paletteTemplateId = clothingColors[(int)colorIndex];
|
|
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
|
|
return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op.
|
|
|
|
foreach (ChargenClothingSubPaletteChoice choice in template.Choices)
|
|
{
|
|
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
|
|
if (palSet is null)
|
|
{
|
|
// Retail's own inner loop (ClothingTable::BuildObjDesc
|
|
// ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when
|
|
// DBObj::Get fails for one subpalEffect entry's PalSet
|
|
// (~0x005A7B32) — aborting every REMAINING choice in this
|
|
// same garment's palette template, not merely skipping the
|
|
// failed one. `break`, not `continue`, matches that; the
|
|
// miss is still recorded so callers can see it happened.
|
|
missingPalSets.Add(choice.PalSetId);
|
|
break;
|
|
}
|
|
|
|
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
|
|
if (index < 0)
|
|
continue;
|
|
|
|
uint paletteId = palSet.PaletteIds[index];
|
|
foreach (ChargenClothingSubPaletteRange range in choice.Ranges)
|
|
{
|
|
subPalettes.Add(new ChargenSubPalette(
|
|
paletteId,
|
|
PackOffset(range.Offset),
|
|
PackNumColors(range.NumColors)));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a real (unpacked) clothing subpalette offset into
|
|
/// <see cref="ChargenSubPalette"/>'s packed *8 on-disk unit. Throws
|
|
/// rather than silently truncating on a shape we've never seen and
|
|
/// don't know how to represent losslessly (guards against the
|
|
/// unchecked-narrowing footgun a plain <c>(byte)(value / 8)</c> cast
|
|
/// would otherwise hide).
|
|
/// </summary>
|
|
private static byte PackOffset(uint realOffset)
|
|
{
|
|
if (realOffset % 8u != 0 || realOffset > 2040u)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(realOffset),
|
|
realOffset,
|
|
"Clothing subpalette range offset does not fit the packed *8 byte "
|
|
+ "convention (expected a multiple of 8 in [0, 2040]).");
|
|
}
|
|
return (byte)(realOffset / 8u);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Same packing as <see cref="PackOffset"/>, plus retail's own explicit
|
|
/// "whole palette" sentinel: a packed <c>NumColors</c> of 0 means "the
|
|
/// entire palette" (<see cref="AcDream.Core.World.PaletteOverride"/>'s
|
|
/// doc: "Length=0 is a sentinel meaning entire palette... defaulting to
|
|
/// 256*8"). A real count of exactly 2048 (256*8) IS that same value
|
|
/// spelled out in real units, so it packs to 0 BY DESIGN — not because
|
|
/// an unchecked <c>(byte)</c> cast happens to wrap 256 back to 0.
|
|
/// </summary>
|
|
private static byte PackNumColors(uint realNumColors)
|
|
{
|
|
if (realNumColors == 2048u)
|
|
return 0;
|
|
if (realNumColors % 8u != 0 || realNumColors > 2040u)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(realNumColors),
|
|
realNumColors,
|
|
"Clothing subpalette range color count does not fit the packed *8 byte "
|
|
+ "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 "
|
|
+ "for the whole-palette sentinel).");
|
|
}
|
|
return (byte)(realNumColors / 8u);
|
|
}
|
|
}
|