fix(chargen): Campaign CC CC6a review fix round — F1-F12

Addresses the CC6a dual-lens review (architectural PASS with reservations,
retail fidelity PASS with reservations, merge after F1/F2/F3).

F1 (BLOCKING) - AlternateSetup/setupId tested the wrong sentinel (0)
instead of retail's INVALID_DID (0xFFFFFFFF, CharGenState::GetSetupID
@0x005C5B22). A hair style storing that value would have been adopted as
a literal Setup id, nulling Get<Setup> and killing the whole preview.
Fixed both sites with a new InvalidDid constant; added two hand-built
tests plus an installed-DAT sweep of every hair style across all 26
heritage/gender combinations (869 selections, zero unresolved Setup ids).

F2 (BLOCKING) - TS-82's register row, ChargenClothingTable.cs's doc, and
the plan's ledger row all understated Undead's measured clothing-coverage
gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting
"4 of 4 non-shirt slots" aside. Corrected everywhere to the true measured
ALL FOUR slots (headgear, trousers, shirt, footwear).

F3 (BLOCKING) - the palette-math "three independent sources" claim
overcounted: ACViewer's ClothingTableList.xaml.cs:97 computes a different
expression for a different problem, and its vendored PaletteSet.cs is
ACE's own file, not an independent implementation. Rewrote the evidence
paragraph in ChargenPalSetMath.cs to the two sources that actually hold
(decomp control flow + ACE's "Taken from acclient.c" port).

F4 (MEDIUM) - ChargenPreviewEntityBuilder.TryBuild did unlocked dat reads;
DatCollection is not thread-safe and every sibling dat-touching resolver
in this layer takes a shared datLock. Added a required datLock parameter;
every dat read now happens inside one lock, mirroring
RetailPaperdollPoseApplicator.Apply's shape.

F5 (LOW) - noted the pre-existing Streaming.LandblockBuildFactoryTests
timing flake in the ledger so a future session doesn't chase it.

F6 (LOW) - fixed ChargenPreviewCamera.cs's rotation doc, which cited a
nonexistent identifier in a dimensionally-wrong expression; corrected to
retail's actual DoRotation @0x0047CAC7 per-tick formula.

F7 (LOW-MEDIUM) - the TS-82 measurement was WriteLine-only; pinned with
real assertions (zero gaps for the 9 standard heritages, exactly the 4
measured Undead table ids on both genders). Kept the existing env-gated
skip pattern (confirmed house convention).

F8 (LOW) - the inner PalSet-miss loop recorded-and-continued past a miss;
retail's own loop returns immediately on a miss (~0x005A7B32), aborting
every remaining choice in that garment. Changed continue to break; added
a test proving a subsequent present PalSet is correctly not applied.

F9 (LOW) - fixed three dangling <see cref="...Compose"/> doc references
(the method is TryCompose).

F10 (LOW) - the packed (byte)(range/8) narrowing was unchecked; a real
NumColors of 2048 happened to wrap to the correct "whole palette" 0
sentinel by unchecked-cast accident. Replaced with explicit PackOffset/
PackNumColors helpers that document the 2048->0 equivalence deliberately
and throw on any other unrepresentable shape.

F11/F12 (LOW, CC6b scope) - noted in the plan's CC6b row: the second
m_alternateSetupID override source is unmodelled, and a shared
RetailHeldPose helper is worth extracting before a fourth consumer.

Test counts: Core.Tests 4772/1 skip (+5), Content.Tests 147/0 (+1),
App.Tests 5121/6 skips (unchanged; F5's named flake did not reproduce) -
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:51:14 +02:00
parent 55bfd9ca82
commit 1774d8b298
11 changed files with 561 additions and 120 deletions

View file

@ -1,7 +1,7 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The resolved render description <see cref="ChargenAppearanceFactory.Compose"/>
/// 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
@ -11,11 +11,15 @@ namespace AcDream.Core.CharGen;
/// <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).
/// <c>AlternateSetup</c> when it is neither 0 nor retail's <c>INVALID_DID</c>
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back
/// to <see cref="ChargenAppearanceFactory.HumanSetupId"/> when the resolved
/// id is 0 OR <c>INVALID_DID</c> (retail: <c>CharGenState::GetSetupID @
/// 0x005C5B22</c> and <c>gmCG3DView::Update</c>'s own check at
/// ~0x004EEA51/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
@ -28,7 +32,7 @@ namespace AcDream.Core.CharGen;
/// </param>
/// <param name="ObjDesc">
/// The composed subpalette/texture/part-swap deltas, in retail's exact
/// application order (see <see cref="ChargenAppearanceFactory.Compose"/>).
/// application order (see <see cref="ChargenAppearanceFactory.TryCompose"/>).
/// </param>
public sealed record ChargenAppearanceResult(
uint SetupId,
@ -86,6 +90,18 @@ public static class ChargenAppearanceFactory
/// </summary>
public const uint HumanSetupId = 0x02000001u;
/// <summary>
/// Retail's <c>IDClass</c> "unset" sentinel (<c>INVALID_DID</c>,
/// 0xFFFFFFFF — <c>acclient.h:39909</c>). <c>CharGenState::GetSetupID @
/// 0x005C5B22</c> and <c>gmCG3DView::Update</c>'s own checks
/// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not
/// zero — a hair style whose <c>AlternateSetup</c> field happens to
/// store this sentinel must be treated as "no override," exactly like
/// zero, or the factory would hand a bogus Setup id to
/// <c>Get&lt;Setup&gt;</c> and produce no preview at all.
/// </summary>
private const uint InvalidDid = 0xFFFFFFFFu;
/// <summary>
/// Skin subpalette overlay range, retail's hard-coded literal at
/// <c>gmCG3DView::Update</c> ~0x004EF066-0x004EF07E: real byte offset 0,
@ -151,10 +167,10 @@ public static class ChargenAppearanceFactory
&& selection.HairStyle < (uint)gender.HairStyles.Count)
{
hairStyle = gender.HairStyles[(int)selection.HairStyle];
if (hairStyle.AlternateSetup != 0)
if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid)
setupId = hairStyle.AlternateSetup;
}
if (setupId == 0)
if (setupId == 0 || setupId == InvalidDid)
setupId = HumanSetupId;
// ── 2. ObjDesc accumulation, retail's exact append order ───────
@ -322,15 +338,22 @@ public static class ChargenAppearanceFactory
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.
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);
continue;
break;
}
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
@ -342,9 +365,55 @@ public static class ChargenAppearanceFactory
{
subPalettes.Add(new ChargenSubPalette(
paletteId,
(byte)(range.Offset / 8),
(byte)(range.NumColors / 8)));
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);
}
}