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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -93,9 +93,13 @@ public sealed class ChargenPreviewCamera : ICamera
|
||||||
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, ctor pseudo-C
|
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, ctor pseudo-C
|
||||||
/// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000,
|
/// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000,
|
||||||
/// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly,
|
/// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly,
|
||||||
/// no reconstruction needed). Consumed by CC6b's rotation controller as
|
/// no reconstruction needed). Retail's own per-tick formula
|
||||||
/// <c>360f / RotationDegreesPerSecond</c> — NOT applied here; see this
|
/// (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>, pseudo-C
|
||||||
/// class's own doc comment on why rotation is not a camera concern.
|
/// ~0x0047CAC7): <c>deltaDegrees = ((now - lastRotateTime) /
|
||||||
|
/// RotationSecondsPerRevolution) * 360</c> — CC6b's rotation controller
|
||||||
|
/// consumes this constant in exactly that shape, not as a
|
||||||
|
/// degrees-per-second rate. NOT applied here; see this class's own doc
|
||||||
|
/// comment on why rotation is not a camera concern.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const float RotationSecondsPerRevolution = 3.0f;
|
public const float RotationSecondsPerRevolution = 3.0f;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,74 +60,85 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
|
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
|
||||||
/// as "drop this spawn").
|
/// as "drop this spawn").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="datLock">
|
||||||
|
/// Shared exclusion object for every dat read this method performs.
|
||||||
|
/// <c>DatCollection</c> is NOT thread-safe (see
|
||||||
|
/// <c>claude-memory/feedback_phase_a1_hotfix_saga.md</c>) — every other
|
||||||
|
/// dat-touching renderer/resolver in this layer
|
||||||
|
/// (<c>RetailPaperdollPoseApplicator</c>, <c>PlayerModeController</c>,
|
||||||
|
/// <c>DatProjectileSetupResolver</c>, <c>EquippedChildRenderController</c>)
|
||||||
|
/// takes the SAME <c>object datLock</c> the composition root threads
|
||||||
|
/// through as <c>RuntimeOptions</c>/<c>d.DatLock</c>; callers MUST pass
|
||||||
|
/// that same shared instance, not a private lock, or this method's reads
|
||||||
|
/// race every other consumer's.
|
||||||
|
/// </param>
|
||||||
public static WorldEntity? TryBuild(
|
public static WorldEntity? TryBuild(
|
||||||
IDatReaderWriter dats,
|
IDatReaderWriter dats,
|
||||||
IAnimationLoader animations,
|
IAnimationLoader animations,
|
||||||
ChargenAppearanceResult appearance,
|
ChargenAppearanceResult appearance,
|
||||||
uint heritageId,
|
uint heritageId,
|
||||||
Quaternion heading)
|
Quaternion heading,
|
||||||
|
object datLock)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dats);
|
ArgumentNullException.ThrowIfNull(dats);
|
||||||
ArgumentNullException.ThrowIfNull(animations);
|
ArgumentNullException.ThrowIfNull(animations);
|
||||||
ArgumentNullException.ThrowIfNull(appearance);
|
ArgumentNullException.ThrowIfNull(appearance);
|
||||||
|
ArgumentNullException.ThrowIfNull(datLock);
|
||||||
|
|
||||||
Setup? setup = dats.Get<Setup>(appearance.SetupId);
|
List<MeshRef> meshRefs;
|
||||||
if (setup is null)
|
uint setupId = appearance.SetupId;
|
||||||
return null;
|
PaletteOverride? paletteOverride;
|
||||||
|
PartOverride[] partOverrides;
|
||||||
|
|
||||||
var flattened = new List<MeshRef>(SetupMesh.Flatten(setup));
|
// Every dat read this method performs — the Setup fetch, the held-
|
||||||
|
// pose animation resolution, the per-part GfxObj drawable checks,
|
||||||
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
|
// and the texture-change surface resolution — happens inside this
|
||||||
|
// one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve
|
||||||
|
// everything under lock, then do pure processing" shape.
|
||||||
|
lock (datLock)
|
||||||
{
|
{
|
||||||
if (change.PartIndex < flattened.Count)
|
Setup? setup = dats.Get<Setup>(setupId);
|
||||||
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
|
if (setup is null)
|
||||||
}
|
return null;
|
||||||
|
|
||||||
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
|
var flattened = new List<MeshRef>(SetupMesh.Flatten(setup));
|
||||||
|
|
||||||
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
|
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
|
||||||
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
|
|
||||||
|
|
||||||
var meshRefs = new List<MeshRef>(flattened.Count);
|
|
||||||
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
|
|
||||||
{
|
|
||||||
MeshRef part = flattened[partIndex];
|
|
||||||
if (dats.Get<GfxObj>(part.GfxObjId) is null)
|
|
||||||
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
|
|
||||||
|
|
||||||
IReadOnlyDictionary<uint, uint>? overrides = null;
|
|
||||||
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
|
|
||||||
overrides = perPart;
|
|
||||||
|
|
||||||
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
|
|
||||||
}
|
|
||||||
if (meshRefs.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
PaletteOverride? paletteOverride = null;
|
|
||||||
if (appearance.ObjDesc.SubPalettes.Count > 0)
|
|
||||||
{
|
|
||||||
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
|
|
||||||
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
|
|
||||||
{
|
{
|
||||||
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
|
if (change.PartIndex < flattened.Count)
|
||||||
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
|
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
|
||||||
}
|
}
|
||||||
paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges);
|
|
||||||
}
|
|
||||||
|
|
||||||
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
|
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
|
||||||
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
|
|
||||||
{
|
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
|
||||||
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
|
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
|
||||||
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
|
|
||||||
|
meshRefs = new List<MeshRef>(flattened.Count);
|
||||||
|
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
|
||||||
|
{
|
||||||
|
MeshRef part = flattened[partIndex];
|
||||||
|
if (dats.Get<GfxObj>(part.GfxObjId) is null)
|
||||||
|
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
|
||||||
|
|
||||||
|
IReadOnlyDictionary<uint, uint>? overrides = null;
|
||||||
|
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
|
||||||
|
overrides = perPart;
|
||||||
|
|
||||||
|
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
|
||||||
|
}
|
||||||
|
if (meshRefs.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
paletteOverride = BuildPaletteOverride(appearance);
|
||||||
|
partOverrides = BuildPartOverrides(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WorldEntity
|
return new WorldEntity
|
||||||
{
|
{
|
||||||
Id = PreviewRenderId,
|
Id = PreviewRenderId,
|
||||||
ServerGuid = PreviewServerGuid,
|
ServerGuid = PreviewServerGuid,
|
||||||
SourceGfxObjOrSetupId = appearance.SetupId,
|
SourceGfxObjOrSetupId = setupId,
|
||||||
Position = Vector3.Zero,
|
Position = Vector3.Zero,
|
||||||
Rotation = heading,
|
Rotation = heading,
|
||||||
MeshRefs = meshRefs,
|
MeshRefs = meshRefs,
|
||||||
|
|
@ -137,6 +148,35 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>No dat access — pure projection of the already-composed
|
||||||
|
/// ObjDesc's subpalettes, safe to call outside <c>datLock</c>.</summary>
|
||||||
|
private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance)
|
||||||
|
{
|
||||||
|
if (appearance.ObjDesc.SubPalettes.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
|
||||||
|
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
|
||||||
|
{
|
||||||
|
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
|
||||||
|
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
|
||||||
|
}
|
||||||
|
return new PaletteOverride(appearance.BasePaletteId, ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No dat access — pure projection, safe to call outside
|
||||||
|
/// <c>datLock</c>.</summary>
|
||||||
|
private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance)
|
||||||
|
{
|
||||||
|
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
|
||||||
|
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
|
||||||
|
{
|
||||||
|
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
|
||||||
|
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
|
||||||
|
}
|
||||||
|
return partOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Overwrites every part's transform from the resolved rest pose's
|
/// Overwrites every part's transform from the resolved rest pose's
|
||||||
/// FINAL frame — same "hold the settled last frame at zero frame rate"
|
/// FINAL frame — same "hold the settled last frame at zero frame rate"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
namespace AcDream.Core.CharGen;
|
namespace AcDream.Core.CharGen;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies
|
||||||
/// to it (<c>CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0</c> is
|
/// to it (<c>CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0</c> is
|
||||||
/// retail's equivalent apply step). The three diagnostic lists let callers
|
/// retail's equivalent apply step). The three diagnostic lists let callers
|
||||||
|
|
@ -11,11 +11,15 @@ namespace AcDream.Core.CharGen;
|
||||||
/// <param name="SetupId">
|
/// <param name="SetupId">
|
||||||
/// The body Setup dat id (0x02......) to build the preview mesh from —
|
/// The body Setup dat id (0x02......) to build the preview mesh from —
|
||||||
/// <c>gender.SetupId</c>, overridden by the selected hair style's
|
/// <c>gender.SetupId</c>, overridden by the selected hair style's
|
||||||
/// <c>AlternateSetup</c> when nonzero (Gear Knight / Undead / Tumerok body
|
/// <c>AlternateSetup</c> when it is neither 0 nor retail's <c>INVALID_DID</c>
|
||||||
/// variants), falling back to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
|
/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back
|
||||||
/// when both are zero (retail: <c>CPhysicsObj::makeObject(setupId)</c>'s own
|
/// to <see cref="ChargenAppearanceFactory.HumanSetupId"/> when the resolved
|
||||||
/// HUMAN_SETUP_ID fallback, <c>gmCG3DView</c> ctor pseudo-C ~0x004EE79D and
|
/// id is 0 OR <c>INVALID_DID</c> (retail: <c>CharGenState::GetSetupID @
|
||||||
/// <c>gmCG3DView::Update</c> ~0x004EEA61).
|
/// 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>
|
||||||
/// <param name="BasePaletteId">
|
/// <param name="BasePaletteId">
|
||||||
/// <c>gender.BasePaletteId</c> (retail <c>Sex_CG.BasePalette</c>) — the
|
/// <c>gender.BasePaletteId</c> (retail <c>Sex_CG.BasePalette</c>) — the
|
||||||
|
|
@ -28,7 +32,7 @@ namespace AcDream.Core.CharGen;
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="ObjDesc">
|
/// <param name="ObjDesc">
|
||||||
/// The composed subpalette/texture/part-swap deltas, in retail's exact
|
/// 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>
|
/// </param>
|
||||||
public sealed record ChargenAppearanceResult(
|
public sealed record ChargenAppearanceResult(
|
||||||
uint SetupId,
|
uint SetupId,
|
||||||
|
|
@ -86,6 +90,18 @@ public static class ChargenAppearanceFactory
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint HumanSetupId = 0x02000001u;
|
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>
|
/// <summary>
|
||||||
/// Skin subpalette overlay range, retail's hard-coded literal at
|
/// Skin subpalette overlay range, retail's hard-coded literal at
|
||||||
/// <c>gmCG3DView::Update</c> ~0x004EF066-0x004EF07E: real byte offset 0,
|
/// <c>gmCG3DView::Update</c> ~0x004EF066-0x004EF07E: real byte offset 0,
|
||||||
|
|
@ -151,10 +167,10 @@ public static class ChargenAppearanceFactory
|
||||||
&& selection.HairStyle < (uint)gender.HairStyles.Count)
|
&& selection.HairStyle < (uint)gender.HairStyles.Count)
|
||||||
{
|
{
|
||||||
hairStyle = gender.HairStyles[(int)selection.HairStyle];
|
hairStyle = gender.HairStyles[(int)selection.HairStyle];
|
||||||
if (hairStyle.AlternateSetup != 0)
|
if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid)
|
||||||
setupId = hairStyle.AlternateSetup;
|
setupId = hairStyle.AlternateSetup;
|
||||||
}
|
}
|
||||||
if (setupId == 0)
|
if (setupId == 0 || setupId == InvalidDid)
|
||||||
setupId = HumanSetupId;
|
setupId = HumanSetupId;
|
||||||
|
|
||||||
// ── 2. ObjDesc accumulation, retail's exact append order ───────
|
// ── 2. ObjDesc accumulation, retail's exact append order ───────
|
||||||
|
|
@ -322,15 +338,22 @@ public static class ChargenAppearanceFactory
|
||||||
|
|
||||||
uint paletteTemplateId = clothingColors[(int)colorIndex];
|
uint paletteTemplateId = clothingColors[(int)colorIndex];
|
||||||
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
|
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)
|
foreach (ChargenClothingSubPaletteChoice choice in template.Choices)
|
||||||
{
|
{
|
||||||
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
|
ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId);
|
||||||
if (palSet is null)
|
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);
|
missingPalSets.Add(choice.PalSetId);
|
||||||
continue;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
|
int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade);
|
||||||
|
|
@ -342,9 +365,55 @@ public static class ChargenAppearanceFactory
|
||||||
{
|
{
|
||||||
subPalettes.Add(new ChargenSubPalette(
|
subPalettes.Add(new ChargenSubPalette(
|
||||||
paletteId,
|
paletteId,
|
||||||
(byte)(range.Offset / 8),
|
PackOffset(range.Offset),
|
||||||
(byte)(range.NumColors / 8)));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ namespace AcDream.Core.CharGen;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The fourteen style/color indices plus the six f64 shades
|
/// The fourteen style/color indices plus the six f64 shades
|
||||||
/// <see cref="ChargenAppearanceFactory.Compose"/> needs to build a preview
|
/// <see cref="ChargenAppearanceFactory.TryCompose"/> needs to build a preview
|
||||||
/// description — field-for-field the same shape as CC3's
|
/// description — field-for-field the same shape as CC3's
|
||||||
/// <c>AcDream.Runtime.Session.RuntimeCharacterCreationAppearance</c> (and,
|
/// <c>AcDream.Runtime.Session.RuntimeCharacterCreationAppearance</c> (and,
|
||||||
/// through it, <c>CharacterCreate.Appearance</c>'s wire fields), kept as a
|
/// through it, <c>CharacterCreate.Appearance</c>'s wire fields), kept as a
|
||||||
|
|
|
||||||
|
|
@ -85,31 +85,35 @@ public sealed record ChargenClothingBaseEffect(
|
||||||
/// Penumbraen, Undead skeleton/zombie, Anakshay) when
|
/// Penumbraen, Undead skeleton/zombie, Anakshay) when
|
||||||
/// <see cref="BaseEffectsBySetupId"/> has no direct entry for the requested
|
/// <see cref="BaseEffectsBySetupId"/> has no direct entry for the requested
|
||||||
/// body Setup. CC6a's composer looks up <see cref="BaseEffectsBySetupId"/>
|
/// body Setup. CC6a's composer looks up <see cref="BaseEffectsBySetupId"/>
|
||||||
/// directly and skips a slot's part/texture contribution on a miss
|
/// directly and skips a slot's part/texture contribution on a miss (this is
|
||||||
/// (matching retail's own "hash miss → BuildObjDesc returns failure, caller
|
/// the OUTER lookup — <c>ClothingTable::_cloBaseHash</c> — whose retail
|
||||||
/// does not check it, ObjDesc keeps whatever it already had" behavior)
|
/// miss behavior is genuinely a no-op the caller never checks; the SEPARATE
|
||||||
/// rather than porting the substitution chain. The installed-DAT catalog
|
/// inner per-choice PalSet lookup inside the same function's subpalette loop
|
||||||
/// test (<c>ChargenAppearanceCatalogInstalledDatTests</c>) MEASURED this
|
/// has its own, stricter, abort-on-miss behavior — see
|
||||||
/// directly across all 26 heritage/gender combinations rather than assuming
|
/// <c>ChargenAppearanceFactory.ComposeClothingSlot</c>'s own doc, ported
|
||||||
/// it: for the 9 standard heritages where retail's own UI actually shows
|
/// faithfully there) rather than porting the Setup-substitution chain. The
|
||||||
/// clothing controls (everything except Gear Knight and the two Olthoi
|
/// installed-DAT catalog test (<c>ChargenAppearanceCatalogInstalledDatTests</c>)
|
||||||
/// variants, which retail hides the clothes button for entirely —
|
/// MEASURED this directly across all 26 heritage/gender combinations rather
|
||||||
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>'s
|
/// 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>m_pClothesButton->SetVisible(0)</c> branches for
|
||||||
/// <c>mHeritageGroup == 6</c> and <c>== 0xc || == 0xd</c>), the default
|
/// <c>mHeritageGroup == 6</c> and <c>== 0xc || == 0xd</c>), the default
|
||||||
/// gear choices resolve against their own body Setup with ZERO missing
|
/// gear choices resolve against their own body Setup with ZERO missing
|
||||||
/// coverage. <b>Undead IS a real gap</b> — retail DOES show clothing
|
/// coverage. <b>Undead IS a real gap</b> — retail DOES show clothing
|
||||||
/// controls for Undead, but its default headgear/trousers/footwear choices
|
/// controls for Undead, and MEASURED coverage is missing for <b>ALL FOUR</b>
|
||||||
/// have no <see cref="BaseEffectsBySetupId"/> entry for either gender's
|
/// clothing slots (headgear, trousers, shirt, AND footwear — not just three
|
||||||
/// live Setup id (measured: 4 of 4 non-shirt slots miss, on both genders),
|
/// of the four), on both genders: neither gender's live body Setup has a
|
||||||
/// because Undead's live body Setup IS one of the skeleton/zombie variants
|
/// <see cref="BaseEffectsBySetupId"/> entry in any of its four default gear
|
||||||
/// the un-ported substitution chain exists to redirect. A live preview for
|
/// choices' clothing tables, because Undead's live body Setup IS one of the
|
||||||
/// Undead will therefore render its default headgear/trousers/footwear
|
/// skeleton/zombie variants the un-ported substitution chain exists to
|
||||||
/// choice with NO part/texture override applied (the underlying body shows
|
/// redirect. A live preview for Undead will therefore render its default
|
||||||
/// through unclothed for those slots) until the substitution chain — or an
|
/// clothing selection with NO part/texture override applied on any of the
|
||||||
/// equivalent per-heritage default-clothing-setup mapping — lands. Filed as
|
/// four slots (the underlying body shows through unclothed) until the
|
||||||
/// a known CC6a limitation for CC6b/a follow-up rather than silently
|
/// substitution chain — or an equivalent per-heritage default-clothing-setup
|
||||||
/// "confirmed unreachable."
|
/// mapping — lands. Filed as a known CC6a limitation for CC6b/a follow-up
|
||||||
|
/// rather than silently "confirmed unreachable."
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ChargenClothingTable(
|
public sealed record ChargenClothingTable(
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,25 @@ namespace AcDream.Core.CharGen;
|
||||||
/// (<c>PalSet::GetPaletteID @ 0x005AC570</c>, invoked from
|
/// (<c>PalSet::GetPaletteID @ 0x005AC570</c>, invoked from
|
||||||
/// <c>gmCG3DView::Update @ 0x004EE9D0</c> for the skin/hair subpalette
|
/// <c>gmCG3DView::Update @ 0x004EE9D0</c> for the skin/hair subpalette
|
||||||
/// build and from <c>ClothingTable::BuildObjDesc @ 0x005A7900</c> for every
|
/// build and from <c>ClothingTable::BuildObjDesc @ 0x005A7900</c> for every
|
||||||
/// clothing-slot dye choice). The decompiled body is FPU-elided (the x87
|
/// clothing-slot dye choice). The decompiled body is genuinely FPU-elided —
|
||||||
/// bounds-compare against 0.0/1.0 and the truncating <c>_ftol2()</c> cast
|
/// the <c>_ftol2()</c> truncating-cast operand is lost to the decompiler,
|
||||||
/// lose their operands to the decompiler), but ACE's
|
/// and can only be read as "some product of <paramref name="count"/>-ish and
|
||||||
/// <c>ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID</c> carries the
|
/// <paramref name="shade"/>-ish operands" from the surrounding x87 stack
|
||||||
/// explicit comment "Taken from acclient.c (PalSet::GetPaletteID)" with the
|
/// traffic — but the decomp's own control-flow SHAPE is still verifiable
|
||||||
/// exact formula below — corroborated by the decomp's own control-flow
|
/// independent of that lost operand: a two-sided FPU compare at
|
||||||
/// shape (a two-sided FPU compare consistent with a <c>[0,1]</c> bounds
|
/// <c>0x005AC5A0</c> gating on <c>>= 0.0</c>, consistent with a
|
||||||
/// check, then one truncating cast) and independently by ACViewer's
|
/// <c>[0,1]</c> shade bounds check before the cast. What resolves the
|
||||||
/// <c>ClothingTableList.xaml.cs:97</c> UI slider, which reimplements the
|
/// elided operand is ACE's <c>ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID</c>,
|
||||||
/// identical <c>(count - 0.000001) * shade</c> expression for its own shade
|
/// which carries the explicit comment "Taken from acclient.c
|
||||||
/// preview. Three independent sources agree.
|
/// (PalSet::GetPaletteID)" against the exact formula below. That is TWO
|
||||||
|
/// sources (decomp control flow + ACE's cited port), not three: the
|
||||||
|
/// <c>PaletteSet.cs</c> file present in the vendored ACViewer checkout is
|
||||||
|
/// ACE's own file, not an independent reimplementation, and ACViewer's
|
||||||
|
/// <c>ClothingTableList.xaml.cs:97</c> UI slider computes a DIFFERENT
|
||||||
|
/// expression for a DIFFERENT problem (mapping a shade back to a slider tick
|
||||||
|
/// position against <c>Shades.Maximum</c>, i.e. <c>count-1</c>, not
|
||||||
|
/// <c>count</c>) — neither corroborates this formula and both are dropped
|
||||||
|
/// from the evidence chain here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChargenPalSetMath
|
public static class ChargenPalSetMath
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
|
|
||||||
var animations = new RetailAnimationLoader(adapter);
|
var animations = new RetailAnimationLoader(adapter);
|
||||||
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
||||||
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity);
|
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object());
|
||||||
|
|
||||||
Assert.NotNull(entity);
|
Assert.NotNull(entity);
|
||||||
Assert.NotEmpty(entity!.MeshRefs);
|
Assert.NotEmpty(entity!.MeshRefs);
|
||||||
|
|
@ -85,7 +85,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
ClothingTablesMissingBaseEffectForSetup: []);
|
ClothingTablesMissingBaseEffectForSetup: []);
|
||||||
|
|
||||||
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
||||||
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity);
|
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object());
|
||||||
|
|
||||||
Assert.Null(entity);
|
Assert.Null(entity);
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
Assert.True(composed);
|
Assert.True(composed);
|
||||||
|
|
||||||
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
||||||
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity);
|
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object());
|
||||||
|
|
||||||
// Just proves the Olthoi branch doesn't throw / silently fall through to
|
// Just proves the Olthoi branch doesn't throw / silently fall through to
|
||||||
// "no mesh" — the exact pose DID differs internally (0x10000011 vs
|
// "no mesh" — the exact pose DID differs internally (0x10000011 vs
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,52 @@ namespace AcDream.Content.Tests.CharGen;
|
||||||
/// everywhere, mid shade" selection and asserts it resolves with no missing
|
/// everywhere, mid shade" selection and asserts it resolves with no missing
|
||||||
/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance
|
/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance
|
||||||
/// bar ("every heritage/gender's default selection resolves to a complete
|
/// bar ("every heritage/gender's default selection resolves to a complete
|
||||||
/// description with no missing dat ids"). Also records (without asserting
|
/// description with no missing dat ids"). ALSO pins the TS-82 measurement
|
||||||
/// zero — see the class doc on <see cref="ChargenClothingTable"/>'s
|
/// with real assertions (not WriteLine-only diagnostics, per the CC6a
|
||||||
/// deliberate scope cut) how many clothing slots have no
|
/// review fix round F7): the nine standard heritages with clothing UI shown
|
||||||
/// <c>ClothingBaseEffects</c> entry for their own gender's body Setup, so a
|
/// resolve zero <c>ClothingBaseEffects</c> gaps, and Undead resolves
|
||||||
/// future session can see at a glance whether CC6a's decision to skip
|
/// EXACTLY the four measured gaps on both genders — see the class doc on
|
||||||
/// retail's Setup-substitution fallback chain ever actually costs
|
/// <see cref="ChargenClothingTable"/>'s deliberate scope cut.
|
||||||
/// coverage on the real dat.
|
///
|
||||||
|
/// <para>Env-gated skip (house pattern, matched from
|
||||||
|
/// <c>ChargenTableReaderInstalledDatTests</c>/<c>ContentConformanceDats</c>):
|
||||||
|
/// returns green with a console SKIP note when no installed dat directory is
|
||||||
|
/// configured, rather than a true xUnit Skipped status — no other Content
|
||||||
|
/// installed-DAT test in this project uses <c>Assert.Skip</c>, so this stays
|
||||||
|
/// consistent with the rest of the suite rather than introducing a new
|
||||||
|
/// convention.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChargenAppearanceCatalogInstalledDatTests
|
public sealed class ChargenAppearanceCatalogInstalledDatTests
|
||||||
{
|
{
|
||||||
private readonly ITestOutputHelper _out;
|
private readonly ITestOutputHelper _out;
|
||||||
public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output;
|
public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
// ACE ACE.Entity.Enum.HeritageGroup ids. Gearknight (6)/Olthoi (12)/
|
||||||
|
// OlthoiAcid (13) are deliberately not named here — see the WriteLine-only
|
||||||
|
// comment in the loop below for why they carry no pinned expectation.
|
||||||
|
private const uint TumerokId = 7u;
|
||||||
|
private const uint UndeadId = 11u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The 9 standard heritages whose UI actually shows clothing controls
|
||||||
|
/// AND whose default gear resolves with zero <c>ClothingBaseEffects</c>
|
||||||
|
/// gaps (measured, not the full "clothing UI shown" set — Undead is
|
||||||
|
/// ALSO clothing-UI-shown but is the one real gap, asserted separately
|
||||||
|
/// below). Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Tumerok/Lugian/
|
||||||
|
/// Empyrean/Penumbraen = every heritage id 1-10 except Gearknight (6).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly uint[] StandardZeroGapHeritageIds = [1u, 2u, 3u, 4u, 5u, TumerokId, 8u, 9u, 10u];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Measured (installed EoR dat, both genders, identical order): Undead's
|
||||||
|
/// default headgear/trousers/shirt/footwear choices' clothing tables, in
|
||||||
|
/// the factory's own Headgear→Trousers→Shirt→Footwear composition order.
|
||||||
|
/// ALL FOUR slots miss — not "headgear/trousers/footwear" (a three-slot
|
||||||
|
/// undercount an earlier draft of this row stated in error).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly uint[] UndeadMeasuredMissingClothingTableIds =
|
||||||
|
[0x10000009u, 0x100000F9u, 0x10000001u, 0x10000007u];
|
||||||
|
|
||||||
private static string? ResolveDatDir()
|
private static string? ResolveDatDir()
|
||||||
{
|
{
|
||||||
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
|
@ -55,8 +88,8 @@ public sealed class ChargenAppearanceCatalogInstalledDatTests
|
||||||
var catalog = new ChargenAppearanceCatalog(adapter);
|
var catalog = new ChargenAppearanceCatalog(adapter);
|
||||||
|
|
||||||
int composed = 0;
|
int composed = 0;
|
||||||
int absentBaseEffectTotal = 0;
|
|
||||||
var missingSummaries = new List<string>();
|
var missingSummaries = new List<string>();
|
||||||
|
var baseEffectGapFailures = new List<string>();
|
||||||
|
|
||||||
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
||||||
{
|
{
|
||||||
|
|
@ -79,23 +112,134 @@ public sealed class ChargenAppearanceCatalogInstalledDatTests
|
||||||
+ $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]");
|
+ $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count;
|
_out.WriteLine(
|
||||||
if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0)
|
$"heritage={heritage.Name} (0x{heritage.HeritageId:X}) gender={genderKey} setup=0x{result.SetupId:X8}: "
|
||||||
|
+ $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no "
|
||||||
|
+ "ClothingBaseEffects entry for this body setup "
|
||||||
|
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
|
||||||
|
|
||||||
|
// TS-82's pinned measurement — real assertions, not WriteLine-only.
|
||||||
|
if (StandardZeroGapHeritageIds.Contains(heritage.HeritageId))
|
||||||
{
|
{
|
||||||
_out.WriteLine(
|
if (result.ClothingTablesMissingBaseEffectForSetup.Count != 0)
|
||||||
$"heritage={heritage.Name} gender={genderKey} setup=0x{result.SetupId:X8}: "
|
{
|
||||||
+ $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no "
|
baseEffectGapFailures.Add(
|
||||||
+ "ClothingBaseEffects entry for this body setup "
|
$"heritage={heritage.Name} gender={genderKey}: expected ZERO ClothingBaseEffects "
|
||||||
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
|
+ $"gaps (a standard heritage with clothing UI shown), measured "
|
||||||
|
+ $"{result.ClothingTablesMissingBaseEffectForSetup.Count}: "
|
||||||
|
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (heritage.HeritageId == UndeadId)
|
||||||
|
{
|
||||||
|
if (!result.ClothingTablesMissingBaseEffectForSetup.SequenceEqual(UndeadMeasuredMissingClothingTableIds))
|
||||||
|
{
|
||||||
|
baseEffectGapFailures.Add(
|
||||||
|
$"heritage=Undead gender={genderKey}: expected EXACTLY "
|
||||||
|
+ $"[{string.Join(",", UndeadMeasuredMissingClothingTableIds.Select(id => $"0x{id:X8}"))}], measured "
|
||||||
|
+ $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gearknight/Olthoi/OlthoiAcid: retail hides the clothing UI
|
||||||
|
// entirely for these three (gmCGAppearancePage::Update
|
||||||
|
// @0x0047E8F0's SetVisible(0) branches), so a real chargen
|
||||||
|
// selection never reaches this composer's clothing slots for
|
||||||
|
// them — no pinned expectation either way, WriteLine above
|
||||||
|
// is diagnostic only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"composed {composed} heritage/gender selections.");
|
||||||
|
Assert.True(
|
||||||
|
missingSummaries.Count == 0,
|
||||||
|
"Missing dat ids found:\n" + string.Join('\n', missingSummaries));
|
||||||
|
Assert.True(
|
||||||
|
baseEffectGapFailures.Count == 0,
|
||||||
|
"TS-82 measurement drifted from its pinned expectation:\n" + string.Join('\n', baseEffectGapFailures));
|
||||||
|
Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC6a review fix round F1: retail's Setup-id "unset" sentinel is
|
||||||
|
/// <c>INVALID_DID</c> (0xFFFFFFFF), not 0
|
||||||
|
/// (<c>CharGenState::GetSetupID @ 0x005C5B22</c>). Sweeps EVERY hair
|
||||||
|
/// style of all 26 heritage/gender combinations and asserts the composed
|
||||||
|
/// SetupId always resolves to a REAL installed Setup dat entry — proving
|
||||||
|
/// neither sentinel value, wherever a hair style's <c>AlternateSetup</c>
|
||||||
|
/// field happens to store one, ever reaches <c>Get<Setup></c> as a
|
||||||
|
/// literal id.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId()
|
||||||
|
{
|
||||||
|
string? datDir = ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
|
||||||
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||||
|
Assert.NotEmpty(options.HeritagesById);
|
||||||
|
var catalog = new ChargenAppearanceCatalog(adapter);
|
||||||
|
|
||||||
|
int sweptHairStyles = 0;
|
||||||
|
var unresolvedSetups = new List<string>();
|
||||||
|
|
||||||
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
||||||
|
{
|
||||||
|
foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey)
|
||||||
|
{
|
||||||
|
for (uint hairStyleIndex = 0; hairStyleIndex < (uint)gender.HairStyles.Count; hairStyleIndex++)
|
||||||
|
{
|
||||||
|
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
|
||||||
|
{
|
||||||
|
HairStyle = hairStyleIndex,
|
||||||
|
SkinShade = 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ok = ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, heritage.HeritageId, genderKey, selection,
|
||||||
|
catalog, catalog, out ChargenAppearanceResult result);
|
||||||
|
Assert.True(ok);
|
||||||
|
sweptHairStyles++;
|
||||||
|
|
||||||
|
if (adapter.Get<DatReaderWriter.DBObjs.Setup>(result.SetupId) is null)
|
||||||
|
{
|
||||||
|
unresolvedSetups.Add(
|
||||||
|
$"heritage={heritage.Name} gender={genderKey} hairStyle={hairStyleIndex}: "
|
||||||
|
+ $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every gender is swept even with zero hair styles (still
|
||||||
|
// exercises the "no hair style selected" default-setup path).
|
||||||
|
if (gender.HairStyles.Count == 0)
|
||||||
|
{
|
||||||
|
bool ok = ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, heritage.HeritageId, genderKey,
|
||||||
|
ChargenAppearanceSelection.Default with { SkinShade = 0.5 },
|
||||||
|
catalog, catalog, out ChargenAppearanceResult result);
|
||||||
|
Assert.True(ok);
|
||||||
|
sweptHairStyles++;
|
||||||
|
if (adapter.Get<DatReaderWriter.DBObjs.Setup>(result.SetupId) is null)
|
||||||
|
{
|
||||||
|
unresolvedSetups.Add(
|
||||||
|
$"heritage={heritage.Name} gender={genderKey} (no hair styles): "
|
||||||
|
+ $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total.");
|
_out.WriteLine($"swept {sweptHairStyles} hair-style/no-hair-style selections across 26 heritage/gender combinations.");
|
||||||
Assert.True(
|
Assert.True(
|
||||||
missingSummaries.Count == 0,
|
unresolvedSetups.Count == 0,
|
||||||
"Missing dat ids found:\n" + string.Join('\n', missingSummaries));
|
"Composed SetupId(s) that don't resolve to a real installed Setup:\n" + string.Join('\n', unresolvedSetups));
|
||||||
Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}.");
|
Assert.True(sweptHairStyles > 26, $"Expected more than 26 swept selections (multiple hair styles per gender), got {sweptHairStyles}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,49 @@ public sealed class ChargenAppearanceFactoryTests
|
||||||
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
|
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC6a review fix round F1: retail's "unset" sentinel for a Setup id is
|
||||||
|
/// <c>INVALID_DID</c> (0xFFFFFFFF — <c>CharGenState::GetSetupID @
|
||||||
|
/// 0x005C5B22</c>), not 0. A hair style whose <c>AlternateSetup</c> field
|
||||||
|
/// stores 0xFFFFFFFF must NOT be adopted as the body Setup id — before
|
||||||
|
/// this fix the factory would hand 0xFFFFFFFF straight to a caller's
|
||||||
|
/// <c>Get<Setup></c>, which nulls, and the whole preview build
|
||||||
|
/// would fail silently.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryCompose_HairStyleAlternateSetupIsInvalidDid_IsTreatedAsUnsetNotAdopted()
|
||||||
|
{
|
||||||
|
ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: 0xFFFFFFFFu));
|
||||||
|
var (pal, clothing) = MakeSources();
|
||||||
|
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
|
||||||
|
|
||||||
|
ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
|
||||||
|
|
||||||
|
Assert.Equal(BodySetupId, result.SetupId); // gender.SetupId, NOT the INVALID_DID sentinel.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Companion to <see cref="TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId"/>:
|
||||||
|
/// the resolved Setup id can ALSO be stuck at INVALID_DID (rather than 0)
|
||||||
|
/// when the gender's own <c>SetupId</c> dat field happens to be
|
||||||
|
/// 0xFFFFFFFF — the fallback to <see cref="ChargenAppearanceFactory.HumanSetupId"/>
|
||||||
|
/// must catch that case too.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryCompose_GenderSetupIdIsInvalidDid_FallsBackToHumanSetupId()
|
||||||
|
{
|
||||||
|
ChargenGenderOptions gender = MakeGender() with { SetupId = 0xFFFFFFFFu };
|
||||||
|
ChargenOptions options = MakeOptions(gender);
|
||||||
|
var (pal, clothing) = MakeSources(bodySetupId: 0xFFFFFFFFu);
|
||||||
|
|
||||||
|
ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default,
|
||||||
|
pal, clothing, out ChargenAppearanceResult result);
|
||||||
|
|
||||||
|
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald()
|
public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald()
|
||||||
{
|
{
|
||||||
|
|
@ -368,6 +411,133 @@ public sealed class ChargenAppearanceFactoryTests
|
||||||
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
|
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC6a review fix round F8: retail's inner subpalette loop
|
||||||
|
/// (<c>ClothingTable::BuildObjDesc</c> ~0x005A7B24-0x005A7BD3) returns 0
|
||||||
|
/// IMMEDIATELY when a PalSet read fails for one choice (~0x005A7B32),
|
||||||
|
/// aborting every REMAINING choice in that garment's palette template —
|
||||||
|
/// not merely skipping the failed one and continuing. A two-choice
|
||||||
|
/// template with the FIRST choice's PalSet missing must therefore emit
|
||||||
|
/// NEITHER choice's subpalette, even though the second choice's own
|
||||||
|
/// PalSet is present and would resolve fine on its own.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryCompose_PalSetMissingMidLoop_AbortsRemainingChoicesInThatGarment()
|
||||||
|
{
|
||||||
|
const uint missingPalSetId = 0x0F00_00AAu;
|
||||||
|
const uint presentPalSetId = 0x0F00_00BBu;
|
||||||
|
|
||||||
|
var firstChoice = new ChargenClothingSubPaletteChoice(
|
||||||
|
missingPalSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]);
|
||||||
|
var secondChoice = new ChargenClothingSubPaletteChoice(
|
||||||
|
presentPalSetId, [new ChargenClothingSubPaletteRange(160u, 8u)]);
|
||||||
|
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
|
||||||
|
{
|
||||||
|
[BodySetupId] = ChargenClothingBaseEffect.Empty,
|
||||||
|
};
|
||||||
|
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
|
||||||
|
{
|
||||||
|
[7u] = new ChargenClothingPaletteTemplate([firstChoice, secondChoice]),
|
||||||
|
};
|
||||||
|
var table = new ChargenClothingTable(baseEffects, templates);
|
||||||
|
|
||||||
|
ChargenOptions options = MakeOptions(MakeGender());
|
||||||
|
var (pal, clothing) = MakeSources();
|
||||||
|
clothing.Add(HeadgearClothingTableId, table); // override the shared fixture's single-choice table.
|
||||||
|
pal.Add(presentPalSetId, 0x0400_0055u); // deliberately NOT adding missingPalSetId.
|
||||||
|
|
||||||
|
var selection = ChargenAppearanceSelection.Default with
|
||||||
|
{
|
||||||
|
HeadgearStyle = 0u,
|
||||||
|
HeadgearColor = 0u,
|
||||||
|
HeadgearShade = 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ok = ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Contains(missingPalSetId, result.MissingPalSetIds);
|
||||||
|
// Real range (160, 8) would pack to (20, 1) if the second choice were
|
||||||
|
// (incorrectly) still applied after the first choice's miss.
|
||||||
|
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 20 && sp.NumColors == 1);
|
||||||
|
// Nothing from EITHER choice's own range landed.
|
||||||
|
Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC6a review fix round F10: a real dat <c>NumColors</c> of exactly
|
||||||
|
/// 2048 (256*8) is retail's own "whole palette" value spelled out in
|
||||||
|
/// real units — it packs to the byte 0 sentinel
|
||||||
|
/// (<see cref="AcDream.Core.World.PaletteOverride"/>'s documented
|
||||||
|
/// "Length=0 means entire palette") EXPLICITLY, not via an unchecked
|
||||||
|
/// narrowing coincidence.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryCompose_ClothingRangeNumColorsIsWholePaletteSentinel_PacksToZeroExplicitly()
|
||||||
|
{
|
||||||
|
var choice = new ChargenClothingSubPaletteChoice(
|
||||||
|
0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2048u)]);
|
||||||
|
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
|
||||||
|
{
|
||||||
|
[BodySetupId] = ChargenClothingBaseEffect.Empty,
|
||||||
|
};
|
||||||
|
var table = new ChargenClothingTable(
|
||||||
|
baseEffects,
|
||||||
|
new Dictionary<uint, ChargenClothingPaletteTemplate> { [7u] = new([choice]) });
|
||||||
|
|
||||||
|
ChargenOptions options = MakeOptions(MakeGender());
|
||||||
|
var (pal, clothing) = MakeSources();
|
||||||
|
clothing.Add(HeadgearClothingTableId, table);
|
||||||
|
|
||||||
|
var selection = ChargenAppearanceSelection.Default with
|
||||||
|
{
|
||||||
|
HeadgearStyle = 0u,
|
||||||
|
HeadgearColor = 0u,
|
||||||
|
HeadgearShade = 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result);
|
||||||
|
|
||||||
|
Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC6a review fix round F10: a shape the packed *8 byte convention
|
||||||
|
/// cannot represent losslessly (not a multiple of 8, and not the 2048
|
||||||
|
/// whole-palette sentinel) must THROW rather than silently truncate via
|
||||||
|
/// an unchecked <c>(byte)</c> cast.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryCompose_ClothingRangeDoesNotFitThePackedByteConvention_Throws()
|
||||||
|
{
|
||||||
|
var choice = new ChargenClothingSubPaletteChoice(
|
||||||
|
0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2041u)]); // not a multiple of 8, not 2048.
|
||||||
|
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>
|
||||||
|
{
|
||||||
|
[BodySetupId] = ChargenClothingBaseEffect.Empty,
|
||||||
|
};
|
||||||
|
var table = new ChargenClothingTable(
|
||||||
|
baseEffects,
|
||||||
|
new Dictionary<uint, ChargenClothingPaletteTemplate> { [7u] = new([choice]) });
|
||||||
|
|
||||||
|
ChargenOptions options = MakeOptions(MakeGender());
|
||||||
|
var (pal, clothing) = MakeSources();
|
||||||
|
clothing.Add(HeadgearClothingTableId, table);
|
||||||
|
|
||||||
|
var selection = ChargenAppearanceSelection.Default with
|
||||||
|
{
|
||||||
|
HeadgearStyle = 0u,
|
||||||
|
HeadgearColor = 0u,
|
||||||
|
HeadgearShade = 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
ChargenAppearanceFactory.TryCompose(
|
||||||
|
options, HeritageId, GenderKey, selection, pal, clothing, out _));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped()
|
public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue