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:
parent
55bfd9ca82
commit
1774d8b298
11 changed files with 561 additions and 120 deletions
|
|
@ -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<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,
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue