Delivers the CC6a foundation half of the chargen 3D preview: the missing index->ObjDesc appearance factory the campaign plan's acdream-seams section named, plus a static-pose offscreen renderer following PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture. Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges). Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces): ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's ObjDesc rebuild in its exact decompiled order - base body, hair style, clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the UI tab order or the wire's field order, both of which differ), eyes (bald-aware), nose, mouth, then the unconditional skin subpalette, hair color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's shade-to-index formula, cross-checked three ways (decomp control flow, ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's identical slider math). ChargenPalSet/ChargenClothingTable are pure projections behind IChargenPalSetSource/IChargenClothingTableSource so the factory itself never touches a dat. Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the cached dat-backed implementation of those two source interfaces, mirroring ChargenTableReader's no-leak discipline. App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and CreatureAppraisalViewportRenderer - no existing rendering file touched. ChargenPreviewCamera carries the four retail-verbatim per-heritage eye profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked against ZoomIn/ZoomOut's identical literals) plus the recovered rotation (3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the decompiler's garbled float literals - the plan's own "measure if it matters" note is resolved, not garbled beyond recovery). Rotation applies to the character model, not the camera, per gmCGAppearancePage::DoRotation. ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself (there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's surface-override algorithm and RetailPaperdollPoseApplicator's held-pose technique, generalized to chargen's per-heritage rest-pose DID. Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail- idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the un-ported clothing Setup-substitution fallback chain costs nothing for the 9 standard heritages with clothing UI, but Undead's default gear choices genuinely lack ClothingBaseEffects coverage for Undead's own body Setup). Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests (installed-DAT-gated, proves a real 34-part Aluvian mesh resolves). Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all pre-existing skips, zero failures, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
155 lines
6.9 KiB
C#
155 lines
6.9 KiB
C#
using AcDream.Content.CharGen;
|
|
using AcDream.Core.CharGen;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.Content.Tests.CharGen;
|
|
|
|
/// <summary>
|
|
/// Installed-DAT gate for <see cref="ChargenAppearanceCatalog"/> +
|
|
/// <see cref="ChargenAppearanceFactory"/> together: for every one of the 13
|
|
/// installed heritages' genders, composes a "pick the first offered option
|
|
/// everywhere, mid shade" selection and asserts it resolves with no missing
|
|
/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance
|
|
/// bar ("every heritage/gender's default selection resolves to a complete
|
|
/// description with no missing dat ids"). Also records (without asserting
|
|
/// zero — see the class doc on <see cref="ChargenClothingTable"/>'s
|
|
/// deliberate scope cut) how many clothing slots have no
|
|
/// <c>ClothingBaseEffects</c> entry for their own gender's body Setup, so a
|
|
/// future session can see at a glance whether CC6a's decision to skip
|
|
/// retail's Setup-substitution fallback chain ever actually costs
|
|
/// coverage on the real dat.
|
|
/// </summary>
|
|
public sealed class ChargenAppearanceCatalogInstalledDatTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private static string? ResolveDatDir()
|
|
{
|
|
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
|
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
|
return fromEnv;
|
|
string def = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
"Documents", "Asheron's Call");
|
|
return Directory.Exists(def) ? def : null;
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryHeritageGendersDefaultSelection_ResolvesWithNoMissingDatIds()
|
|
{
|
|
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 composed = 0;
|
|
int absentBaseEffectTotal = 0;
|
|
var missingSummaries = new List<string>();
|
|
|
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
|
{
|
|
foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey)
|
|
{
|
|
ChargenAppearanceSelection selection = MakeDefaultSelection(gender);
|
|
|
|
bool ok = ChargenAppearanceFactory.TryCompose(
|
|
options, heritage.HeritageId, genderKey, selection,
|
|
catalog, catalog, out ChargenAppearanceResult result);
|
|
|
|
Assert.True(ok, $"heritage=0x{heritage.HeritageId:X} gender={genderKey} failed to resolve heritage/gender");
|
|
composed++;
|
|
|
|
if (result.MissingPalSetIds.Count > 0 || result.MissingClothingTableIds.Count > 0)
|
|
{
|
|
missingSummaries.Add(
|
|
$"heritage={heritage.Name} gender={genderKey}: "
|
|
+ $"missingPalSets=[{string.Join(",", result.MissingPalSetIds.Select(id => $"0x{id:X8}"))}] "
|
|
+ $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]");
|
|
}
|
|
|
|
absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count;
|
|
if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0)
|
|
{
|
|
_out.WriteLine(
|
|
$"heritage={heritage.Name} 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}"))}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
_out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total.");
|
|
Assert.True(
|
|
missingSummaries.Count == 0,
|
|
"Missing dat ids found:\n" + string.Join('\n', missingSummaries));
|
|
Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// "Pick the first offered option everywhere, mid shade" — CC6a's own
|
|
/// default policy for exercising the factory end-to-end, NOT a claim
|
|
/// about retail's own CharGenState default selection (that policy is
|
|
/// CC3/CC6b's concern). Every index/shade starts at
|
|
/// <see cref="ChargenAppearanceSelection.Unset"/>/<see cref="ChargenAppearanceSelection.UnsetShade"/>
|
|
/// and is only set when the gender's own list actually offers an
|
|
/// option, so a heritage with e.g. no headgear choices exercises the
|
|
/// factory's "slot not selected" path rather than an out-of-range index.
|
|
/// </summary>
|
|
private static ChargenAppearanceSelection MakeDefaultSelection(ChargenGenderOptions gender)
|
|
{
|
|
const double midShade = 0.5;
|
|
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default;
|
|
|
|
if (gender.HairStyles.Count > 0)
|
|
selection = selection with { HairStyle = 0u };
|
|
if (gender.EyeStrips.Count > 0)
|
|
selection = selection with { EyesStrip = 0u };
|
|
if (gender.NoseStrips.Count > 0)
|
|
selection = selection with { NoseStrip = 0u };
|
|
if (gender.MouthStrips.Count > 0)
|
|
selection = selection with { MouthStrip = 0u };
|
|
if (gender.HairColors.Count > 0)
|
|
selection = selection with { HairColor = 0u, HairShade = midShade };
|
|
if (gender.EyeColors.Count > 0)
|
|
selection = selection with { EyeColor = 0u };
|
|
|
|
if (gender.Headgears.Count > 0)
|
|
selection = selection with { HeadgearStyle = 0u };
|
|
if (gender.Shirts.Count > 0)
|
|
selection = selection with { ShirtStyle = 0u };
|
|
if (gender.Pants.Count > 0)
|
|
selection = selection with { TrousersStyle = 0u };
|
|
if (gender.Footwear.Count > 0)
|
|
selection = selection with { FootwearStyle = 0u };
|
|
|
|
if (gender.ClothingColors.Count > 0)
|
|
{
|
|
selection = selection with
|
|
{
|
|
HeadgearColor = 0u,
|
|
HeadgearShade = midShade,
|
|
ShirtColor = 0u,
|
|
ShirtShade = midShade,
|
|
TrousersColor = 0u,
|
|
TrousersShade = midShade,
|
|
FootwearColor = 0u,
|
|
FootwearShade = midShade,
|
|
};
|
|
}
|
|
|
|
return selection with { SkinShade = midShade };
|
|
}
|
|
}
|