feat(content): Campaign CC CC1 — chargen table reader and typed options model

Adds the CC1 data layer for Campaign CC (retail character creation):
a reader for portal.dat's CharGen table (0x0E000002) plus a
presentation-free, Chorizite-free typed options model, and the pure
attribute/skill credit math the later CC3 Runtime owner needs.

Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas +
  HeritageGroups)
- HeritageGroup_CG::Serialize @ 0x005C2100
- Sex_CG::Serialize @ 0x005C1600
- Template_CG::Serialize @ 0x005C0450
- CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider
  setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits
  - (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the
  budget the six RAW attribute values must fit, not points above the floor.
- CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100.
- gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when
  remainingAtrbCredits > 0 (attributes only — skill credits are never
  gated to zero, confirmed by reading the function body).
- CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of
  NormalCost/PrimaryCost is charged per Trained/Specialized skill.
- gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd
  (Olthoi/OlthoiAcid) camera-offset branch CC6 will need.

Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and
ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders
(same field order, different byte format) and ACE.Entity.Enum.HeritageGroup
/ SkillAdvancementClass for the two small stable enums the model exposes.

src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas +
HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc
+ every appearance-option list: hair styles/colors, eye colors, eye/nose/
mouth strips, headgear/shirt/pants/footwear, clothing colors),
ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap
shape, mirrors PaletteOverride's presentation-free pattern), and the pure
math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and
ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus
ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved
slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything
but exactly 55 entries.

src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite
DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite
type crosses into ChargenOptions.

Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the
Content projector (Content.Tests), plus six installed-DAT gate tests
(ContentConformanceDats pattern) against the real portal.dat: 13 heritage
groups (11 standard + 2 Olthoi), the four named heritages with retail
display names incl. "Gharu'ndim", every heritage has a gender with
non-empty appearance option lists, every template's attributes stay in
10..100 and never exceed its heritage's budget (discovered live: NOT every
template fully spends it — each human heritage's "Adventurer" template
sits at the floor as retail's real-DAT-backed "Custom" starting point),
start-area indices resolve into the shared list, and skill costs key to
valid 1..54 wire ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 12:53:50 +02:00
parent c3a8c231b8
commit 0445004164
19 changed files with 1656 additions and 0 deletions

View file

@ -0,0 +1,214 @@
using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests.CharGen;
/// <summary>
/// Installed-DAT gate for <see cref="ChargenTableReader"/>: proves the real
/// CharGen table (portal.dat 0x0E000002) loads through the SAME
/// <see cref="DatCollectionAdapter"/> production uses and lands in a
/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT
/// suite (<see cref="ContentConformanceDats.ResolveDatDir"/>) — skips
/// cleanly (with a console note) when no DAT directory is configured,
/// matching e.g. <c>PakEquivalenceTests</c> / <c>RetailDatLoaderTests</c>.
/// </summary>
public sealed class ChargenTableReaderInstalledDatTests
{
// ACE ACE.Entity.Enum.HeritageGroup — the four heritages CC1's spec
// calls out by name.
private const uint AluvianId = 1u;
private const uint GharundimId = 2u;
private const uint ShoId = 3u;
private const uint ViamontianId = 4u;
private const uint OlthoiId = 12u;
private const uint OlthoiAcidId = 13u;
[Fact]
public void InstalledCharGenTable_LoadsAndHasThirteenHeritageGroups()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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);
// ACE's loader comment: "11 standard player races and 2 Olthoi".
Assert.Equal(13, options.HeritagesById.Count);
Assert.NotEmpty(options.StarterAreas);
}
[Fact]
public void InstalledCharGenTable_HasTheFourNamedHeritagesWithRetailDisplayNames()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
Assert.Equal("Aluvian", aluvian.Name);
Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim));
Assert.Equal("Gharu'ndim", gharundim.Name);
Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho));
Assert.Equal("Sho", sho.Name);
Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian));
Assert.Equal("Viamontian", viamontian.Name);
Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi));
Assert.True(olthoi.IsOlthoi);
Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid));
Assert.True(olthoiAcid.IsOlthoi);
}
[Fact]
public void InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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);
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
Assert.NotEmpty(heritage.GendersByKey);
Assert.Contains(
heritage.GendersByKey.Values,
gender => gender.HasAnyAppearanceOptions);
}
}
/// <summary>
/// Every template's six attributes fall within retail's 10..100 range,
/// and no template's total exceeds its heritage's AttributeCredits
/// budget. NOT every template fully spends the budget — each of the
/// four human heritages (and Olthoi Acid) ships an "Adventurer" template
/// sitting at the floor (60 of a 330 budget; confirmed against the
/// installed DAT), which is retail's "Custom" starting point delivered
/// as a real TemplateCG entry rather than a special-cased UI-only
/// option. Every OTHER named human template (Bow Hunter, Swashbuckler,
/// Life Caster, War Mage, Wayfarer, Soldier) exactly exhausts its
/// heritage's credits, and Olthoi's single "Ripper" template exactly
/// exhausts its (much smaller, non-customizable) 60-credit budget — so
/// SOME template somewhere fully spends its budget, even though no
/// single heritage is guaranteed to (Olthoi Acid's only template is the
/// unspent "Adventurer" one).
/// </summary>
[Fact]
public void InstalledHeritages_EveryTemplateAttributeSpreadFitsTheAttributeBudget()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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);
int templatesChecked = 0;
bool anyFullySpentAnywhere = false;
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach (ChargenTemplate template in heritage.Templates)
{
templatesChecked++;
Assert.True(
ChargenAttributeMath.AreAllWithinRange(template.Attributes),
$"{heritage.Name}/{template.Name}: an attribute fell outside " +
$"{ChargenAttributeMath.AttributeMin}..{ChargenAttributeMath.AttributeMax} " +
$"({template.Attributes}).");
Assert.True(
template.Attributes.Total <= heritage.AttributeCredits,
$"{heritage.Name}/{template.Name}: attribute spread totals " +
$"{template.Attributes.Total}, exceeding the {heritage.AttributeCredits}-credit budget.");
anyFullySpentAnywhere |= ChargenAttributeMath.IsFullySpent(heritage.AttributeCredits, template.Attributes);
}
}
Assert.True(templatesChecked > 0, "Expected at least one profession template across all heritages.");
Assert.True(
anyFullySpentAnywhere,
"Expected at least one named preset template to fully spend its heritage's credit budget.");
}
[Fact]
public void InstalledHeritages_PrimaryAndSecondaryStartAreaIndicesResolveIntoTheSharedList()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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);
int indicesChecked = 0;
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach (int index in heritage.PrimaryStartAreaIndices.Concat(heritage.SecondaryStartAreaIndices))
{
indicesChecked++;
Assert.True(
options.TryGetStarterArea(index, out ChargenStarterArea area),
$"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list.");
Assert.False(string.IsNullOrEmpty(area.Name));
}
}
Assert.True(indicesChecked > 0, "Expected at least one heritage start-area reference.");
}
[Fact]
public void InstalledHeritages_SkillCostsResolveToKnownWireSkillIds()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.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.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
Assert.NotEmpty(aluvian.SkillCostsBySkillId);
foreach (var pair in aluvian.SkillCostsBySkillId)
{
Assert.Equal(pair.Key, pair.Value.SkillId);
Assert.InRange(pair.Key, 1u, 54u);
Assert.True(pair.Value.NormalCost >= 0);
Assert.True(pair.Value.PrimaryCost >= 0);
}
}
}

View file

@ -0,0 +1,403 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using AcDream.Content;
using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using DatCharGen = DatReaderWriter.DBObjs.CharGen;
using DatObjDesc = DatReaderWriter.Types.ObjDesc;
namespace AcDream.Content.Tests.CharGen;
/// <summary>
/// Tests for <see cref="ChargenTableReader"/>'s pure projection
/// (<see cref="ChargenTableReader.Project"/>) against a hand-built
/// <see cref="DatCharGen"/> fixture — proves the field mapping without
/// depending on the installed DAT. The installed-DAT read path itself is
/// covered by <see cref="ChargenTableReaderInstalledDatTests"/>.
/// </summary>
public sealed class ChargenTableReaderTests
{
private static PStringBase<byte> Str(string value)
{
var s = new PStringBase<byte>();
s.Value = value;
return s;
}
private static QualifiedDataId<T> Qdi<T>(uint id) where T : DBObj, new()
{
var qdi = new QualifiedDataId<T>();
qdi.DataId = id;
return qdi;
}
private static DatObjDesc MakeObjDesc(uint paletteId, byte partIndex, uint oldTex, uint newTex)
{
var od = new DatObjDesc();
od.PaletteId = new PackedQualifiedDataId<Palette>();
od.PaletteId.DataId = paletteId;
var sub = new SubPalette();
sub.SubId = new PackedQualifiedDataId<Palette>();
sub.SubId.DataId = 0x04000099u;
sub.Offset = 8;
sub.NumColors = 24;
od.SubPalettes.Add(sub);
var tex = new TextureMapChange();
tex.PartIndex = partIndex;
tex.OldTexture = new PackedQualifiedDataId<SurfaceTexture>();
tex.OldTexture.DataId = oldTex;
tex.NewTexture = new PackedQualifiedDataId<SurfaceTexture>();
tex.NewTexture.DataId = newTex;
od.TextureChanges.Add(tex);
var part = new AnimationPartChange();
part.PartIndex = 2;
part.PartId = new PackedQualifiedDataId<GfxObj>();
part.PartId.DataId = 0x0100ABCDu;
od.AnimPartChanges.Add(part);
return od;
}
private static DatCharGen BuildFixture()
{
var table = new DatCharGen();
var area = new StartingArea();
area.Name = Str("Holtburg");
var position = new Position();
position.CellId = 0xA9B40000u;
position.Frame = new Frame
{
Origin = new Vector3(1f, 2f, 3f),
Orientation = new Quaternion(0f, 0f, 0f, 1f),
};
area.Locations.Add(position);
table.StartingAreas.Add(area);
var heritage = new HeritageGroupCG();
heritage.Name = Str("Aluvian");
heritage.IconId = Qdi<RenderSurface>(0x06000001u);
heritage.SetupId = Qdi<Setup>(0x02000010u);
heritage.EnvironmentSetupId = Qdi<Setup>(0x02000020u);
heritage.AttributeCredits = 180u;
heritage.SkillCredits = 50u;
heritage.PrimaryStartAreas.Add(0);
heritage.SecondaryStartAreas.Add(0);
var skill = new SkillCG();
skill.Id = DatReaderWriter.Enums.SkillId.Axe;
skill.NormalCost = 4;
skill.PrimaryCost = 12;
heritage.Skills.Add(skill);
var template = new TemplateCG();
template.Name = Str("Soldier");
template.IconId = Qdi<RenderSurface>(0x06000002u);
template.Title = 42u;
template.Strength = 40;
template.Endurance = 40;
template.Coordination = 40;
template.Quickness = 20;
template.Focus = 20;
template.Self = 20;
template.NormalSkills.Add(DatReaderWriter.Enums.SkillId.Axe);
template.PrimarySkills.Add(DatReaderWriter.Enums.SkillId.MeleeDefense);
heritage.Templates.Add(template);
var sex = new SexCG();
sex.Name = Str("Male");
sex.Scale = 1000000u;
sex.SetupId = Qdi<Setup>(0x02000030u);
sex.SoundTable = Qdi<SoundTable>(0x22000001u);
sex.IconId = Qdi<RenderSurface>(0x06000003u);
sex.BasePalette = Qdi<Palette>(0x04000001u);
sex.SkinPalSet = Qdi<PalSet>(0x04001001u);
sex.PhysicsTable = Qdi<PhysicsScriptTable>(0x0D000001u);
sex.MotionTable = Qdi<MotionTable>(0x09000001u);
sex.CombatTable = Qdi<CombatTable>(0x0F000001u);
sex.BaseObjDesc = MakeObjDesc(0x04000002u, 0, 0x05000001u, 0x05000002u);
sex.HairColors.Add(0x0Au);
sex.EyeColors.Add(0x0Bu);
sex.ClothingColors.Add(0x0Cu);
var hairStyle = new HairStyleCG();
hairStyle.IconId = Qdi<RenderSurface>(0x06000004u);
hairStyle.Bald = false;
hairStyle.AlternateSetup = 0x02000099u;
hairStyle.ObjDesc = MakeObjDesc(0x04000003u, 1, 0x05000003u, 0x05000004u);
sex.HairStyles.Add(hairStyle);
var eyeStrip = new EyeStripCG();
eyeStrip.IconId = Qdi<RenderSurface>(0x06000005u);
eyeStrip.BaldIconId = 0x06000006u;
eyeStrip.ObjDesc = MakeObjDesc(0x04000004u, 2, 0x05000005u, 0x05000006u);
eyeStrip.BaldObjDesc = MakeObjDesc(0x04000005u, 3, 0x05000007u, 0x05000008u);
sex.EyeStrips.Add(eyeStrip);
var noseStrip = new FaceStripCG();
noseStrip.IconId = Qdi<RenderSurface>(0x06000007u);
noseStrip.ObjDesc = MakeObjDesc(0x04000006u, 4, 0x05000009u, 0x0500000Au);
sex.NoseStrips.Add(noseStrip);
var mouthStrip = new FaceStripCG();
mouthStrip.IconId = Qdi<RenderSurface>(0x06000008u);
mouthStrip.ObjDesc = MakeObjDesc(0x04000007u, 5, 0x0500000Bu, 0x0500000Cu);
sex.MouthStrips.Add(mouthStrip);
var headgear = new GearCG();
headgear.Name = Str("Leather Cap");
headgear.ClothingTable = Qdi<ClothingTable>(0x31000001u);
headgear.WeenieDefault = 300001u;
sex.Headgears.Add(headgear);
var shirt = new GearCG();
shirt.Name = Str("Tunic");
shirt.ClothingTable = Qdi<ClothingTable>(0x31000002u);
shirt.WeenieDefault = 300002u;
sex.Shirts.Add(shirt);
var pants = new GearCG();
pants.Name = Str("Breeches");
pants.ClothingTable = Qdi<ClothingTable>(0x31000003u);
pants.WeenieDefault = 300003u;
sex.Pants.Add(pants);
var footwear = new GearCG();
footwear.Name = Str("Boots");
footwear.ClothingTable = Qdi<ClothingTable>(0x31000004u);
footwear.WeenieDefault = 300004u;
sex.Footwear.Add(footwear);
heritage.Genders.Add(0, sex);
table.HeritageGroups.Add(1u, heritage);
return table;
}
[Fact]
public void Project_MapsStarterAreasWithPositionsAndCellId()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
ChargenStarterArea area = Assert.Single(options.StarterAreas);
Assert.Equal(0, area.Index);
Assert.Equal("Holtburg", area.Name);
ChargenPosition position = Assert.Single(area.Locations);
Assert.Equal(0xA9B40000u, position.CellId);
Assert.Equal(new Vector3(1f, 2f, 3f), position.Origin);
Assert.Equal(new Quaternion(0f, 0f, 0f, 1f), position.Orientation);
}
[Fact]
public void Project_MapsHeritageScalarFieldsAndStartAreaIndices()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage));
Assert.Equal("Aluvian", heritage.Name);
Assert.Equal(0x06000001u, heritage.IconId);
Assert.Equal(0x02000010u, heritage.SetupId);
Assert.Equal(0x02000020u, heritage.EnvironmentSetupId);
Assert.Equal(180u, heritage.AttributeCredits);
Assert.Equal(50u, heritage.SkillCredits);
Assert.Equal([0], heritage.PrimaryStartAreaIndices);
Assert.Equal([0], heritage.SecondaryStartAreaIndices);
Assert.False(heritage.IsOlthoi);
}
[Fact]
public void Project_MapsSkillCostsKeyedByRawSkillId()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe;
Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost));
Assert.Equal(axeId, cost.SkillId);
Assert.Equal(4, cost.NormalCost);
Assert.Equal(12, cost.PrimaryCost);
}
[Fact]
public void Project_MapsTemplateAttributesAndSkillLists()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
ChargenTemplate template = Assert.Single(heritage.Templates);
Assert.Equal("Soldier", template.Name);
Assert.Equal(42u, template.TitleStringId);
Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes);
Assert.Equal(180, template.Attributes.Total);
Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.Axe], template.NormalSkills);
Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.MeleeDefense], template.PrimarySkills);
}
[Fact]
public void Project_MapsGenderScalarsAndOptionLists()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender));
Assert.Equal("Male", gender!.Name);
Assert.Equal(1000000u, gender.Scale);
Assert.Equal(0x02000030u, gender.SetupId);
Assert.Equal(0x04000001u, gender.BasePaletteId);
Assert.Equal(0x04001001u, gender.SkinPalSetId);
Assert.Equal([0x0Au], gender.HairColors);
Assert.Equal([0x0Bu], gender.EyeColors);
Assert.Equal([0x0Cu], gender.ClothingColors);
Assert.True(gender.HasAnyAppearanceOptions);
ChargenHairStyle hair = Assert.Single(gender.HairStyles);
Assert.Equal(0x06000004u, hair.IconId);
Assert.False(hair.Bald);
Assert.Equal(0x02000099u, hair.AlternateSetup);
ChargenEyeStrip eye = Assert.Single(gender.EyeStrips);
Assert.Equal(0x06000005u, eye.IconId);
Assert.Equal(0x06000006u, eye.BaldIconId);
ChargenFaceStrip nose = Assert.Single(gender.NoseStrips);
Assert.Equal(0x06000007u, nose.IconId);
ChargenFaceStrip mouth = Assert.Single(gender.MouthStrips);
Assert.Equal(0x06000008u, mouth.IconId);
ChargenGearOption headgear = Assert.Single(gender.Headgears);
Assert.Equal("Leather Cap", headgear.Name);
Assert.Equal(0x31000001u, headgear.ClothingTableId);
Assert.Equal(300001u, headgear.WeenieDefaultId);
Assert.Single(gender.Shirts);
Assert.Single(gender.Pants);
Assert.Single(gender.Footwear);
}
[Fact]
public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges()
{
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender);
ChargenObjDesc baseDesc = gender!.BaseObjDesc;
Assert.Equal(0x04000002u, baseDesc.PaletteId);
ChargenSubPalette sub = Assert.Single(baseDesc.SubPalettes);
Assert.Equal(0x04000099u, sub.SubPaletteId);
Assert.Equal((byte)8, sub.Offset);
Assert.Equal((byte)24, sub.NumColors);
ChargenTextureChange tex = Assert.Single(baseDesc.TextureChanges);
Assert.Equal((byte)0, tex.PartIndex);
Assert.Equal(0x05000001u, tex.OldTextureId);
Assert.Equal(0x05000002u, tex.NewTextureId);
ChargenAnimPartChange part = Assert.Single(baseDesc.AnimPartChanges);
Assert.Equal((byte)2, part.PartIndex);
Assert.Equal(0x0100ABCDu, part.PartId);
// The eye strip carries a SEPARATE bald ObjDesc from its normal one.
ChargenEyeStrip eye = Assert.Single(gender.EyeStrips);
Assert.Equal(0x04000004u, eye.ObjDesc.PaletteId);
Assert.Equal(0x04000005u, eye.BaldObjDesc.PaletteId);
Assert.NotEqual(eye.ObjDesc.PaletteId, eye.BaldObjDesc.PaletteId);
}
[Fact]
public void Load_ReturnsEmptyOptions_WhenTableIsMissingFromDatSource()
{
var empty = new EmptyDatReaderWriter();
ChargenOptions options = ChargenTableReader.Load(empty);
Assert.Empty(options.StarterAreas);
Assert.Empty(options.HeritagesById);
}
/// <summary>Minimal <see cref="IDatReaderWriter"/> stub whose <c>Get</c>
/// always misses — proves <see cref="ChargenTableReader.Load"/>'s
/// missing-table tolerance without a live DAT. Mirrors the shape of
/// <c>DatResolutionPrecedenceTests.ResolutionSource</c>.</summary>
private sealed class EmptyDatReaderWriter : IDatReaderWriter
{
private readonly StubDatabase _db = new();
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => _db;
public IDatDatabase Cell => _db;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => _db;
public IDatDatabase Language => _db;
public IDatDatabase Local => _db;
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => default;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public void Dispose() { }
private sealed class StubDatabase : IDatDatabase
{
public DatDatabase Db => null!;
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
{
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
{
bytesRead = 0;
return false;
}
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj => false;
public void Dispose() { }
}
}
}