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>
127 lines
5.5 KiB
C#
127 lines
5.5 KiB
C#
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Content;
|
|
using AcDream.Content.CharGen;
|
|
using AcDream.Content.Vfx;
|
|
using AcDream.Core.CharGen;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Installed-DAT gate for <see cref="ChargenPreviewEntityBuilder"/> —
|
|
/// mirrors <see cref="CornerFloodReplayTests"/>'s env-gated skip pattern
|
|
/// (no unit-testable pure surface exists here the way
|
|
/// <see cref="DollEntityBuilder"/> has one, because THIS builder's whole job
|
|
/// is resolving Setup/GfxObj/Surface/Animation dat data that
|
|
/// <see cref="DollEntityBuilder"/> receives pre-resolved).
|
|
/// </summary>
|
|
public sealed class ChargenPreviewEntityBuilderTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public ChargenPreviewEntityBuilderTests(ITestOutputHelper output) => _out = output;
|
|
|
|
[Fact]
|
|
public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity()
|
|
{
|
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian.
|
|
Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male));
|
|
|
|
var catalog = new ChargenAppearanceCatalog(adapter);
|
|
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
|
|
{
|
|
HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
|
|
SkinShade = 0.5,
|
|
};
|
|
|
|
bool composed = ChargenAppearanceFactory.TryCompose(
|
|
options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance);
|
|
Assert.True(composed);
|
|
Assert.Empty(appearance.MissingPalSetIds);
|
|
Assert.Empty(appearance.MissingClothingTableIds);
|
|
|
|
var animations = new RetailAnimationLoader(adapter);
|
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
|
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object());
|
|
|
|
Assert.NotNull(entity);
|
|
Assert.NotEmpty(entity!.MeshRefs);
|
|
Assert.Equal(appearance.SetupId, entity.SourceGfxObjOrSetupId);
|
|
Assert.Equal(ChargenPreviewEntityBuilder.PreviewServerGuid, entity.ServerGuid);
|
|
Assert.Equal(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id);
|
|
Assert.NotNull(entity.PaletteOverride);
|
|
Assert.Equal(appearance.BasePaletteId, entity.PaletteOverride!.BasePaletteId);
|
|
|
|
_out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}");
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuild_UnknownSetupId_ReturnsNull()
|
|
{
|
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
var animations = new RetailAnimationLoader(adapter);
|
|
|
|
var bogusAppearance = new ChargenAppearanceResult(
|
|
SetupId: 0x0200_FFFFu, // Not a real installed Setup id.
|
|
BasePaletteId: 0u,
|
|
ObjDesc: ChargenObjDesc.Empty,
|
|
MissingPalSetIds: [],
|
|
MissingClothingTableIds: [],
|
|
ClothingTablesMissingBaseEffectForSetup: []);
|
|
|
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
|
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object());
|
|
|
|
Assert.Null(entity);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages()
|
|
{
|
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); // Olthoi.
|
|
Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)
|
|
|| olthoi.GendersByKey.TryGetValue(2, out male));
|
|
Assert.NotNull(male);
|
|
int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key;
|
|
|
|
var catalog = new ChargenAppearanceCatalog(adapter);
|
|
var animations = new RetailAnimationLoader(adapter);
|
|
|
|
bool composed = ChargenAppearanceFactory.TryCompose(
|
|
options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 },
|
|
catalog, catalog, out ChargenAppearanceResult appearance);
|
|
Assert.True(composed);
|
|
|
|
var entity = ChargenPreviewEntityBuilder.TryBuild(
|
|
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object());
|
|
|
|
// Just proves the Olthoi branch doesn't throw / silently fall through to
|
|
// "no mesh" — the exact pose DID differs internally (0x10000011 vs
|
|
// 0x10000005) but both should still resolve a drawable mesh from Olthoi's
|
|
// own Setup.
|
|
Assert.NotNull(entity);
|
|
Assert.NotEmpty(entity!.MeshRefs);
|
|
}
|
|
}
|