diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs
new file mode 100644
index 00000000..4b30a776
--- /dev/null
+++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs
@@ -0,0 +1,215 @@
+using AcDream.Core.CharGen;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc;
+using DatCharGen = DatReaderWriter.DBObjs.CharGen;
+using DatObjDesc = DatReaderWriter.Types.ObjDesc;
+
+namespace AcDream.Content.CharGen;
+
+///
+/// Projects portal.dat's CharGen table (id ,
+/// retail ACCharGenData::Serialize @ 0x005C36D0) into acdream's
+/// presentation-free tree.
+/// Mirrors MagicCatalog.Load's shape: one static entry point over
+/// , no Chorizite types cross into the
+/// returned model. Cross-checked against ACE's
+/// ACE.DatLoader.FileTypes.CharGen +
+/// ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG loaders,
+/// which unpack the identical field order from the same DAT bytes.
+///
+public static class ChargenTableReader
+{
+ /// Retail's CharGen DAT file id (ACE:
+ /// ACE.DatLoader.FileTypes.CharGen.FILE_ID).
+ public const uint ChargenTableDid = 0x0E000002u;
+
+ ///
+ /// Loads and projects the installed CharGen table. Returns
+ /// if the table is missing from the
+ /// supplied dat source (mirrors MagicCatalog's tolerance for a
+ /// missing optional table — callers that require the table present
+ /// should check HeritagesById.Count themselves).
+ ///
+ public static ChargenOptions Load(IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+
+ DatCharGen? table = dats.Get(ChargenTableDid);
+ return table is null ? ChargenOptions.Empty : Project(table);
+ }
+
+ /// Pure projection from an already-loaded DAT record — split out
+ /// from so tests can exercise it against
+ /// hand-built fixtures without a live DAT.
+ public static ChargenOptions Project(DatCharGen table)
+ {
+ ArgumentNullException.ThrowIfNull(table);
+
+ var starterAreas = new List(table.StartingAreas.Count);
+ for (int i = 0; i < table.StartingAreas.Count; i++)
+ starterAreas.Add(ProjectStarterArea(i, table.StartingAreas[i]));
+
+ var heritagesById = new Dictionary(table.HeritageGroups.Count);
+ foreach (KeyValuePair pair in table.HeritageGroups)
+ heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value);
+
+ return new ChargenOptions(starterAreas, heritagesById);
+ }
+
+ private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area)
+ {
+ var locations = new List(area.Locations.Count);
+ foreach (Position position in area.Locations)
+ {
+ locations.Add(new ChargenPosition(
+ position.CellId,
+ position.Frame.Origin,
+ position.Frame.Orientation));
+ }
+ return new ChargenStarterArea(index, area.Name.Value, locations);
+ }
+
+ private static ChargenHeritageOptions ProjectHeritage(uint heritageId, HeritageGroupCG cg)
+ {
+ var skillCosts = new Dictionary(cg.Skills.Count);
+ foreach (SkillCG skill in cg.Skills)
+ {
+ uint skillId = (uint)skill.Id;
+ skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost);
+ }
+
+ var templates = new List(cg.Templates.Count);
+ foreach (TemplateCG template in cg.Templates)
+ templates.Add(ProjectTemplate(template));
+
+ var gendersByKey = new Dictionary(cg.Genders.Count);
+ foreach (KeyValuePair pair in cg.Genders)
+ gendersByKey[pair.Key] = ProjectGender(pair.Key, pair.Value);
+
+ return new ChargenHeritageOptions(
+ heritageId,
+ cg.Name.Value,
+ cg.IconId.DataId,
+ cg.SetupId.DataId,
+ cg.EnvironmentSetupId.DataId,
+ cg.AttributeCredits,
+ cg.SkillCredits,
+ new List(cg.PrimaryStartAreas),
+ new List(cg.SecondaryStartAreas),
+ skillCosts,
+ templates,
+ gendersByKey);
+ }
+
+ private static ChargenTemplate ProjectTemplate(TemplateCG template)
+ {
+ var normalSkills = new List(template.NormalSkills.Count);
+ foreach (var skillId in template.NormalSkills)
+ normalSkills.Add((uint)skillId);
+
+ var primarySkills = new List(template.PrimarySkills.Count);
+ foreach (var skillId in template.PrimarySkills)
+ primarySkills.Add((uint)skillId);
+
+ return new ChargenTemplate(
+ template.Name.Value,
+ template.IconId.DataId,
+ template.Title,
+ new ChargenAttributeValues(
+ template.Strength,
+ template.Endurance,
+ template.Coordination,
+ template.Quickness,
+ template.Focus,
+ template.Self),
+ normalSkills,
+ primarySkills);
+ }
+
+ private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex)
+ {
+ var hairStyles = new List(sex.HairStyles.Count);
+ foreach (HairStyleCG hair in sex.HairStyles)
+ {
+ hairStyles.Add(new ChargenHairStyle(
+ hair.IconId.DataId,
+ hair.Bald,
+ hair.AlternateSetup,
+ ProjectObjDesc(hair.ObjDesc)));
+ }
+
+ var eyeStrips = new List(sex.EyeStrips.Count);
+ foreach (EyeStripCG eye in sex.EyeStrips)
+ {
+ eyeStrips.Add(new ChargenEyeStrip(
+ eye.IconId.DataId,
+ eye.BaldIconId,
+ ProjectObjDesc(eye.ObjDesc),
+ ProjectObjDesc(eye.BaldObjDesc)));
+ }
+
+ var noseStrips = new List(sex.NoseStrips.Count);
+ foreach (FaceStripCG strip in sex.NoseStrips)
+ noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)));
+
+ var mouthStrips = new List(sex.MouthStrips.Count);
+ foreach (FaceStripCG strip in sex.MouthStrips)
+ mouthStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)));
+
+ return new ChargenGenderOptions(
+ genderKey,
+ sex.Name.Value,
+ sex.Scale,
+ sex.SetupId.DataId,
+ sex.SoundTable.DataId,
+ sex.IconId.DataId,
+ sex.BasePalette.DataId,
+ sex.SkinPalSet.DataId,
+ sex.PhysicsTable.DataId,
+ sex.MotionTable.DataId,
+ sex.CombatTable.DataId,
+ ProjectObjDesc(sex.BaseObjDesc),
+ new List(sex.HairColors),
+ hairStyles,
+ new List(sex.EyeColors),
+ eyeStrips,
+ noseStrips,
+ mouthStrips,
+ ProjectGearList(sex.Headgears),
+ ProjectGearList(sex.Shirts),
+ ProjectGearList(sex.Pants),
+ ProjectGearList(sex.Footwear),
+ new List(sex.ClothingColors));
+ }
+
+ private static List ProjectGearList(List gearList)
+ {
+ var result = new List(gearList.Count);
+ foreach (GearCG gear in gearList)
+ result.Add(new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault));
+ return result;
+ }
+
+ private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc)
+ {
+ var subPalettes = new List(objDesc.SubPalettes.Count);
+ foreach (SubPalette sub in objDesc.SubPalettes)
+ subPalettes.Add(new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors));
+
+ var textureChanges = new List(objDesc.TextureChanges.Count);
+ foreach (TextureMapChange change in objDesc.TextureChanges)
+ {
+ textureChanges.Add(new ChargenTextureChange(
+ change.PartIndex,
+ change.OldTexture.DataId,
+ change.NewTexture.DataId));
+ }
+
+ var animPartChanges = new List(objDesc.AnimPartChanges.Count);
+ foreach (AnimationPartChange change in objDesc.AnimPartChanges)
+ animPartChanges.Add(new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId));
+
+ return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges);
+ }
+}
diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs
new file mode 100644
index 00000000..7894374e
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs
@@ -0,0 +1,41 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// One hair-style option in a
+/// list. Retail schema: HairStyle_CG (nested inside
+/// Sex_CG::Serialize @ 0x005C1600). Bald and
+/// AlternateSetup handle the Gear Knight / Olthoi bald-head special
+/// case ACE's SexCG.GetHeadObject comment documents.
+///
+public sealed record ChargenHairStyle(
+ uint IconId,
+ bool Bald,
+ uint AlternateSetup,
+ ChargenObjDesc ObjDesc);
+
+///
+/// One eye-strip option. Retail carries a SEPARATE bald variant
+/// (BaldIconId / BaldObjDesc) because a bald hairstyle
+/// selection changes which eye texture applies — see ACE's
+/// SexCG.GetEyeTexture(strip, isBald).
+///
+public sealed record ChargenEyeStrip(
+ uint IconId,
+ uint BaldIconId,
+ ChargenObjDesc ObjDesc,
+ ChargenObjDesc BaldObjDesc);
+
+/// One nose- or mouth-strip option (retail FaceStrip_CG).
+public sealed record ChargenFaceStrip(uint IconId, ChargenObjDesc ObjDesc);
+
+///
+/// One clothing-slot option (headgear/shirt/pants/footwear). Retail schema:
+/// Gear_CG. ClothingTableId resolves through
+/// ClothingTable::BuildObjDesc; WeenieDefaultId is the weenie
+/// class the character actually receives in inventory on creation (ACE's
+/// SexCG.GetHeadgearWeenie family).
+///
+public sealed record ChargenGearOption(
+ string Name,
+ uint ClothingTableId,
+ uint WeenieDefaultId);
diff --git a/src/AcDream.Core/CharGen/ChargenAttributeMath.cs b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs
new file mode 100644
index 00000000..2b7b6807
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs
@@ -0,0 +1,56 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Pure port of retail's attribute-credit budget math
+/// (CharGenState::SetHeritageGroup @ 0x005C67A0 and the six
+/// attribute-slider setters around 0x005C46CE..0x005C494E, all of
+/// the shape remainingAtrbCredits = totalAtrbCredits - (str + end +
+/// coord + quick + focus + self)): a heritage's AttributeCredits
+/// is the total budget the SIX RAW attribute values (each already including
+/// its 10-point floor) must sum to exactly — not a budget of points spent
+/// above the floor. Retail's Finish gate
+/// (gmCharGenMainUI::DoFinish @ 0x004E9170, line
+/// if (arg2 != 0 && eax->remainingAtrbCredits > 0)) aborts
+/// creation with a warning dialog whenever credits remain unspent — retail
+/// forces a full spend. ACE's server does not re-validate this; acdream
+/// ports the CLIENT gate (see the campaign plan's Finish section).
+///
+public static class ChargenAttributeMath
+{
+ /// Retail's CharGenState::Reset @ 0x005C68A0
+ /// this->atrbMin = 0xa — every attribute's floor.
+ public const int AttributeMin = 10;
+
+ /// Retail's CharGenState::Reset
+ /// this->atrbMax = 0x64 — every attribute's ceiling.
+ public const int AttributeMax = 100;
+
+ /// attributeCreditBudget - values.Total. Zero means the
+ /// budget is exactly spent; positive means credits remain (Finish must
+ /// refuse); this port never expects negative (retail's own slider
+ /// clamping through ConstrainAllByHeritage prevents overspend,
+ /// but callers building a candidate outside that UI path should treat a
+ /// negative result as an invalid state, not silently accept it).
+ public static int RemainingCredits(uint attributeCreditBudget, ChargenAttributeValues values) =>
+ checked((int)attributeCreditBudget) - values.Total;
+
+ /// Retail's Finish gate: creation may proceed only when this is
+ /// true.
+ public static bool IsFullySpent(uint attributeCreditBudget, ChargenAttributeValues values) =>
+ RemainingCredits(attributeCreditBudget, values) == 0;
+
+ /// True when a single attribute value falls within
+ /// .. inclusive.
+ public static bool IsWithinRange(int value) => value >= AttributeMin && value <= AttributeMax;
+
+ /// True when every one of the six attributes falls within
+ /// range individually (does not check the credit total — see
+ /// for that).
+ public static bool AreAllWithinRange(ChargenAttributeValues values) =>
+ IsWithinRange(values.Strength)
+ && IsWithinRange(values.Endurance)
+ && IsWithinRange(values.Coordination)
+ && IsWithinRange(values.Quickness)
+ && IsWithinRange(values.Focus)
+ && IsWithinRange(values.Self);
+}
diff --git a/src/AcDream.Core/CharGen/ChargenAttributeValues.cs b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs
new file mode 100644
index 00000000..21a35a47
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs
@@ -0,0 +1,22 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// The six primary attributes in retail's chargen wire/serialization order
+/// (Strength, Endurance, Coordination, Quickness, Focus, Self) — matches
+/// both Template_CG::Serialize @ 0x005C0450 and the 0xF656
+/// ACCharGenData::CG_Pack @ 0x005C7200 attribute block the plan
+/// documents. Used both for a preset 's fixed
+/// spread and, by CC3's Runtime owner, as the candidate values a "Custom"
+/// profession is actively assigning via the six attribute sliders
+/// (0x100003e6..eb).
+///
+public readonly record struct ChargenAttributeValues(
+ int Strength,
+ int Endurance,
+ int Coordination,
+ int Quickness,
+ int Focus,
+ int Self)
+{
+ public int Total => Strength + Endurance + Coordination + Quickness + Focus + Self;
+}
diff --git a/src/AcDream.Core/CharGen/ChargenGenderOptions.cs b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs
new file mode 100644
index 00000000..0ee4b2e0
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs
@@ -0,0 +1,54 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Per-gender chargen options for one heritage: base body model plus every
+/// appearance option list the Appearance page's spin/color controls
+/// (0x100003af..b8, 0x1000030e..0x10000321) enumerate. Retail schema:
+/// Sex_CG::Serialize @ 0x005C1600 (ACE's SexCG.Unpack mirrors
+/// the same field order). GenderKey is the raw key from
+/// HeritageGroupCG.Genders (retail/ACE both key this as a small int —
+/// carried through unmapped rather than assumed 0=male/1=female, confirmed
+/// live by CC1's installed-DAT tests).
+///
+public sealed record ChargenGenderOptions(
+ int GenderKey,
+ string Name,
+ uint Scale,
+ uint SetupId,
+ uint SoundTableId,
+ uint IconId,
+ uint BasePaletteId,
+ uint SkinPalSetId,
+ uint PhysicsTableId,
+ uint MotionTableId,
+ uint CombatTableId,
+ ChargenObjDesc BaseObjDesc,
+ IReadOnlyList HairColors,
+ IReadOnlyList HairStyles,
+ IReadOnlyList EyeColors,
+ IReadOnlyList EyeStrips,
+ IReadOnlyList NoseStrips,
+ IReadOnlyList MouthStrips,
+ IReadOnlyList Headgears,
+ IReadOnlyList Shirts,
+ IReadOnlyList Pants,
+ IReadOnlyList Footwear,
+ IReadOnlyList ClothingColors)
+{
+ ///
+ /// Every appearance option list is non-empty for a playable gender —
+ /// CC1's installed-DAT gate asserts this holds for at least one gender
+ /// per heritage. A gender missing an option list can still be a valid
+ /// data shape (e.g. a bald-only heritage's hair styles), so callers
+ /// building UI should still defend against empty lists individually.
+ ///
+ public bool HasAnyAppearanceOptions =>
+ HairStyles.Count > 0
+ || EyeStrips.Count > 0
+ || NoseStrips.Count > 0
+ || MouthStrips.Count > 0
+ || Headgears.Count > 0
+ || Shirts.Count > 0
+ || Pants.Count > 0
+ || Footwear.Count > 0;
+}
diff --git a/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs
new file mode 100644
index 00000000..b69cf525
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs
@@ -0,0 +1,31 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Retail's 11 standard player heritages plus the two Olthoi player-race
+/// variants (ACE's loader comment on CharGen.Unpack: "HERITAGE
+/// GROUPS -- 11 standard player races and 2 Olthoi"). Numeric values match
+/// ACE's ACE.Entity.Enum.HeritageGroup exactly — the same ids the
+/// wire uses and the same ids that key
+/// . Display names come from the
+/// DAT's own HeritageGroupCG.Name string, not this enum — this enum
+/// exists only for callers that need to branch on a KNOWN heritage
+/// identity (e.g. CC6's Olthoi-vs-human camera offsets, per the campaign
+/// plan's 3D-preview recon).
+///
+public enum ChargenHeritageGroup : uint
+{
+ Invalid = 0,
+ Aluvian = 1,
+ Gharundim = 2,
+ Sho = 3,
+ Viamontian = 4,
+ Shadowbound = 5,
+ Gearknight = 6,
+ Tumerok = 7,
+ Lugian = 8,
+ Empyrean = 9,
+ Penumbraen = 10,
+ Undead = 11,
+ Olthoi = 12,
+ OlthoiAcid = 13,
+}
diff --git a/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs
new file mode 100644
index 00000000..a97f3d03
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs
@@ -0,0 +1,32 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Everything the Heritage/Profession/Skills/Appearance/Town pages need for
+/// one heritage. Retail schema: HeritageGroup_CG::Serialize @
+/// 0x005C2100 (ACE's HeritageGroupCG.Unpack mirrors the same
+/// field order). PrimaryStartAreaIndices / SecondaryStartAreaIndices
+/// index into the CharGen table's SHARED ChargenOptions.StarterAreas
+/// list, not a per-heritage list of their own.
+///
+public sealed record ChargenHeritageOptions(
+ uint HeritageId,
+ string Name,
+ uint IconId,
+ uint SetupId,
+ uint EnvironmentSetupId,
+ uint AttributeCredits,
+ uint SkillCredits,
+ IReadOnlyList PrimaryStartAreaIndices,
+ IReadOnlyList SecondaryStartAreaIndices,
+ IReadOnlyDictionary SkillCostsBySkillId,
+ IReadOnlyList Templates,
+ IReadOnlyDictionary GendersByKey)
+{
+ /// True for the two Olthoi player-race variants (ids 12/13) —
+ /// CC6's 3D preview hard-codes a different camera target position for
+ /// these (gmCGAppearancePage::Update @ 0x0047E8F0, the
+ /// mHeritageGroup == 0xc || mHeritageGroup == 0xd branch).
+ public bool IsOlthoi =>
+ HeritageId == (uint)ChargenHeritageGroup.Olthoi
+ || HeritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
+}
diff --git a/src/AcDream.Core/CharGen/ChargenObjDesc.cs b/src/AcDream.Core/CharGen/ChargenObjDesc.cs
new file mode 100644
index 00000000..11af5931
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenObjDesc.cs
@@ -0,0 +1,41 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// One palette overlay range inside a . Retail
+/// applies NumColors * 8 colors from SubPaletteId starting at
+/// Offset * 8 in the base palette (Chorizite.ACProtocol.Types.Subpalette
+/// docs; the live-session equivalent is
+/// ).
+///
+public readonly record struct ChargenSubPalette(uint SubPaletteId, byte Offset, byte NumColors);
+
+/// One texture-map override inside a .
+/// PartIndex identifies which GfxObj part's surface list the swap
+/// applies to.
+public readonly record struct ChargenTextureChange(byte PartIndex, uint OldTextureId, uint NewTextureId);
+
+/// One animated-part swap override inside a .
+public readonly record struct ChargenAnimPartChange(byte PartIndex, uint PartId);
+
+///
+/// Presentation-free projection of Chorizite's ObjDesc shape (retail's
+/// CObjDesc): the palette id plus the overlay/texture/part-swap deltas
+/// a live appearance is built from. Used both for a gender's base body
+/// () and for every appearance
+/// option's own overlay (hair styles, eye/nose/mouth strips) — CC6's
+/// index→ObjDesc appearance factory composes these the same way retail's
+/// ClothingTable::BuildObjDesc / DoObjDescChangesFromDefault
+/// pipeline does.
+///
+public sealed record ChargenObjDesc(
+ uint PaletteId,
+ IReadOnlyList SubPalettes,
+ IReadOnlyList TextureChanges,
+ IReadOnlyList AnimPartChanges)
+{
+ public static ChargenObjDesc Empty { get; } = new(
+ 0u,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty());
+}
diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs
new file mode 100644
index 00000000..e68555a7
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenOptions.cs
@@ -0,0 +1,34 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Top-level, presentation-free, immutable projection of retail's CharGen
+/// DAT table (portal.dat 0x0E000002, ACCharGenData::Serialize @
+/// 0x005C36D0). Production builds create this from the installed DAT
+/// through Content's AcDream.Content.CharGen.ChargenTableReader.Load;
+/// this type itself has no DAT/Chorizite dependency so it is safe to hand
+/// to plugin-facing or test code. Everything a "typed chargen options
+/// model" needs — starter areas, heritages, templates, per-gender
+/// appearance option lists, skill costs — hangs off this one root.
+///
+public sealed record ChargenOptions(
+ IReadOnlyList StarterAreas,
+ IReadOnlyDictionary HeritagesById)
+{
+ public static ChargenOptions Empty { get; } = new(
+ Array.Empty(),
+ new Dictionary());
+
+ public bool TryGetHeritage(uint heritageId, out ChargenHeritageOptions heritage) =>
+ HeritagesById.TryGetValue(heritageId, out heritage!);
+
+ public bool TryGetStarterArea(int index, out ChargenStarterArea area)
+ {
+ if (index >= 0 && index < StarterAreas.Count)
+ {
+ area = StarterAreas[index];
+ return true;
+ }
+ area = null!;
+ return false;
+ }
+}
diff --git a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
new file mode 100644
index 00000000..cba65741
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
@@ -0,0 +1,80 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Retail's four skill states. Wire values match ACE's
+/// ACE.Entity.Enum.SkillAdvancementClass exactly (0=Inactive,
+/// 1=Untrained, 2=Trained, 3=Specialized) — ACE unpacks the 0xF656
+/// CharacterCreateInfo.SkillAdvancementClasses list with this same
+/// numbering, and retail's CharGenState::UpdateRemainingSkillCredits @
+/// 0x005C37C0 only charges credits for Trained (2) and Specialized (3).
+///
+public enum ChargenSkillAdvancementClass : uint
+{
+ Inactive = 0,
+ Untrained = 1,
+ Trained = 2,
+ Specialized = 3,
+}
+
+///
+/// One skill's retail training cost for a heritage. Retail schema:
+/// SkillCG, entries of HeritageGroupCG.Skills.
+/// PrimaryCost is the TOTAL cost to reach Specialized (not an
+/// increment on top of NormalCost) — retail's
+/// UpdateRemainingSkillCredits adds exactly one of the two per
+/// skill, never both.
+///
+public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
+
+///
+/// Retail's fixed-size per-character skill-advancement array
+/// (CharGenState.skillLevels). ACE's CharacterCreateInfo.Unpack
+/// terminates the connection if the wire's numSkills count is not
+/// exactly (55): retail's own loop in
+/// UpdateRemainingSkillCredits walks indices 1..totalNumSkills
+/// (skipping reserved slot 0), and Chorizite's SkillId enum runs
+/// 1..54 — 54 real skills plus the reserved slot 0 is exactly 55. This type
+/// makes that shape structural: it always holds exactly 55 slots, so a
+/// caller building the 0xF656 body (CC2) cannot accidentally send a
+/// different count.
+///
+public sealed class ChargenSkillAdvancementSet
+{
+ /// Slot 0 is reserved (unused by retail); slots 1..54 map 1:1
+ /// to Chorizite's DatReaderWriter.Enums.SkillId values.
+ public const int SlotCount = 55;
+
+ private readonly ChargenSkillAdvancementClass[] _slots = new ChargenSkillAdvancementClass[SlotCount];
+
+ /// Skill state by raw skill id. Ids outside 1..54 read
+ /// as and cannot be
+ /// set.
+ public ChargenSkillAdvancementClass this[uint skillId]
+ {
+ get => skillId >= 1 && skillId < SlotCount
+ ? _slots[skillId]
+ : ChargenSkillAdvancementClass.Inactive;
+ set
+ {
+ if (skillId < 1 || skillId >= SlotCount)
+ throw new ArgumentOutOfRangeException(
+ nameof(skillId),
+ skillId,
+ $"Skill id must be in 1..{SlotCount - 1}.");
+ _slots[skillId] = value;
+ }
+ }
+
+ ///
+ /// Materializes the wire body shape: exactly
+ /// entries, slot 0 first, matching ACE's
+ /// CharacterCreateInfo.SkillAdvancementClasses read order.
+ ///
+ public IReadOnlyList ToWireClasses()
+ {
+ var wire = new uint[SlotCount];
+ for (int i = 0; i < SlotCount; i++)
+ wire[i] = (uint)_slots[i];
+ return wire;
+ }
+}
diff --git a/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs
new file mode 100644
index 00000000..2a8ab4fa
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs
@@ -0,0 +1,62 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// Pure port of retail's skill-credit spend calculation
+/// (CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0): walk
+/// every skill slot, add NormalCost for Trained or PrimaryCost
+/// for Specialized (never both), and subtract the total from the heritage's
+/// SkillCredits budget. No DAT/DatReaderWriter dependency — callers
+/// (CC3's Runtime owner) pass in the heritage's already-projected
+/// lookup.
+///
+public static class ChargenSkillCreditMath
+{
+ ///
+ /// Total credits spent across every Trained/Specialized skill in
+ /// . A skill with no cost entry for the
+ /// active heritage (i.e. the heritage doesn't offer it) is skipped —
+ /// retail's own UI can never reach that state, so this is defensive
+ /// rather than a documented retail behavior.
+ ///
+ public static int ComputeSpent(
+ ChargenSkillAdvancementSet advancement,
+ IReadOnlyDictionary costsBySkillId)
+ {
+ ArgumentNullException.ThrowIfNull(advancement);
+ ArgumentNullException.ThrowIfNull(costsBySkillId);
+
+ int spent = 0;
+ for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
+ {
+ ChargenSkillAdvancementClass cls = advancement[skillId];
+ if (cls != ChargenSkillAdvancementClass.Trained
+ && cls != ChargenSkillAdvancementClass.Specialized)
+ {
+ continue;
+ }
+
+ if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
+ continue;
+
+ spent += cls == ChargenSkillAdvancementClass.Specialized
+ ? cost.PrimaryCost
+ : cost.NormalCost;
+ }
+ return spent;
+ }
+
+ ///
+ /// totalSkillCredits - ComputeSpent(...) — retail's
+ /// remainingSkillCredits. Retail's Finish gate (DoFinish @
+ /// 0x004E91F2-adjacent) only checks remainingAtrbCredits > 0
+ /// for attributes; skill credits are NOT required to hit exactly zero
+ /// (unspent skill credits are simply lost on creation) — callers should
+ /// not port an "exact spend" gate for skills the way
+ /// does for attributes.
+ ///
+ public static int RemainingCredits(
+ uint totalSkillCredits,
+ ChargenSkillAdvancementSet advancement,
+ IReadOnlyDictionary costsBySkillId) =>
+ checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId);
+}
diff --git a/src/AcDream.Core/CharGen/ChargenStarterArea.cs b/src/AcDream.Core/CharGen/ChargenStarterArea.cs
new file mode 100644
index 00000000..e4161e73
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenStarterArea.cs
@@ -0,0 +1,23 @@
+using System.Numerics;
+
+namespace AcDream.Core.CharGen;
+
+/// One spawn point inside a . Retail
+/// schema: Position nested inside StartingArea
+/// (ACCharGenData::Serialize @ 0x005C36D0).
+public readonly record struct ChargenPosition(uint CellId, Vector3 Origin, Quaternion Orientation);
+
+///
+/// One named starting area (a town/region a heritage may spawn a new
+/// character in) with its candidate spawn points. The CharGen table holds
+/// ONE shared list of these —
+/// and SecondaryStartAreaIndices reference this list by index, they
+/// do not carry their own copies. Retail schema:
+/// ACCharGenData::Serialize @ 0x005C36D0 (ACE's loader comment names
+/// this StarterArea; Chorizite names the DAT type StartingArea
+/// — same shape).
+///
+public sealed record ChargenStarterArea(
+ int Index,
+ string Name,
+ IReadOnlyList Locations);
diff --git a/src/AcDream.Core/CharGen/ChargenTemplate.cs b/src/AcDream.Core/CharGen/ChargenTemplate.cs
new file mode 100644
index 00000000..0bdc5ceb
--- /dev/null
+++ b/src/AcDream.Core/CharGen/ChargenTemplate.cs
@@ -0,0 +1,19 @@
+namespace AcDream.Core.CharGen;
+
+///
+/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage,
+/// Wayfarer, Soldier, ...) offered on the Profession page
+/// (UpdateProfession @ 0x00478d1c/0x0047a4a4-adjacent per the
+/// campaign plan's recon; template buttons 0x100003da..df). "Custom" is NOT
+/// one of these — it is retail's own free-attribute-assignment mode
+/// selected by button 0x100003d9 and has no
+/// entry. Retail schema: Template_CG::Serialize @ 0x005C0450
+/// (ACE's TemplateCG.Unpack mirrors the same field order).
+///
+public sealed record ChargenTemplate(
+ string Name,
+ uint IconId,
+ uint TitleStringId,
+ ChargenAttributeValues Attributes,
+ IReadOnlyList NormalSkills,
+ IReadOnlyList PrimarySkills);
diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
new file mode 100644
index 00000000..9c8f4f08
--- /dev/null
+++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
@@ -0,0 +1,214 @@
+using AcDream.Content.CharGen;
+using AcDream.Core.CharGen;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+
+namespace AcDream.Content.Tests.CharGen;
+
+///
+/// Installed-DAT gate for : proves the real
+/// CharGen table (portal.dat 0x0E000002) loads through the SAME
+/// production uses and lands in a
+/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT
+/// suite () — skips
+/// cleanly (with a console note) when no DAT directory is configured,
+/// matching e.g. PakEquivalenceTests / RetailDatLoaderTests.
+///
+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);
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ [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);
+ }
+ }
+}
diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs
new file mode 100644
index 00000000..e519653a
--- /dev/null
+++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs
@@ -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;
+
+///
+/// Tests for 's pure projection
+/// () against a hand-built
+/// fixture — proves the field mapping without
+/// depending on the installed DAT. The installed-DAT read path itself is
+/// covered by .
+///
+public sealed class ChargenTableReaderTests
+{
+ private static PStringBase Str(string value)
+ {
+ var s = new PStringBase();
+ s.Value = value;
+ return s;
+ }
+
+ private static QualifiedDataId Qdi(uint id) where T : DBObj, new()
+ {
+ var qdi = new QualifiedDataId();
+ 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();
+ od.PaletteId.DataId = paletteId;
+
+ var sub = new SubPalette();
+ sub.SubId = new PackedQualifiedDataId();
+ 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();
+ tex.OldTexture.DataId = oldTex;
+ tex.NewTexture = new PackedQualifiedDataId();
+ tex.NewTexture.DataId = newTex;
+ od.TextureChanges.Add(tex);
+
+ var part = new AnimationPartChange();
+ part.PartIndex = 2;
+ part.PartId = new PackedQualifiedDataId();
+ 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(0x06000001u);
+ heritage.SetupId = Qdi(0x02000010u);
+ heritage.EnvironmentSetupId = Qdi(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(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(0x02000030u);
+ sex.SoundTable = Qdi(0x22000001u);
+ sex.IconId = Qdi(0x06000003u);
+ sex.BasePalette = Qdi(0x04000001u);
+ sex.SkinPalSet = Qdi(0x04001001u);
+ sex.PhysicsTable = Qdi(0x0D000001u);
+ sex.MotionTable = Qdi(0x09000001u);
+ sex.CombatTable = Qdi(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(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(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(0x06000007u);
+ noseStrip.ObjDesc = MakeObjDesc(0x04000006u, 4, 0x05000009u, 0x0500000Au);
+ sex.NoseStrips.Add(noseStrip);
+
+ var mouthStrip = new FaceStripCG();
+ mouthStrip.IconId = Qdi(0x06000008u);
+ mouthStrip.ObjDesc = MakeObjDesc(0x04000007u, 5, 0x0500000Bu, 0x0500000Cu);
+ sex.MouthStrips.Add(mouthStrip);
+
+ var headgear = new GearCG();
+ headgear.Name = Str("Leather Cap");
+ headgear.ClothingTable = Qdi(0x31000001u);
+ headgear.WeenieDefault = 300001u;
+ sex.Headgears.Add(headgear);
+
+ var shirt = new GearCG();
+ shirt.Name = Str("Tunic");
+ shirt.ClothingTable = Qdi(0x31000002u);
+ shirt.WeenieDefault = 300002u;
+ sex.Shirts.Add(shirt);
+
+ var pants = new GearCG();
+ pants.Name = Str("Breeches");
+ pants.ClothingTable = Qdi(0x31000003u);
+ pants.WeenieDefault = 300003u;
+ sex.Pants.Add(pants);
+
+ var footwear = new GearCG();
+ footwear.Name = Str("Boots");
+ footwear.ClothingTable = Qdi(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);
+ }
+
+ /// Minimal stub whose Get
+ /// always misses — proves 's
+ /// missing-table tolerance without a live DAT. Mirrors the shape of
+ /// DatResolutionPrecedenceTests.ResolutionSource.
+ 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 CellRegions { get; } =
+ new(new Dictionary());
+ public IDatDatabase HighRes => _db;
+ public IDatDatabase Language => _db;
+ public IDatDatabase Local => _db;
+ public ReadOnlyDictionary RegionFileMap { get; } =
+ new(new Dictionary());
+ 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 GetAllIdsOfType() where T : IDBObj => Array.Empty();
+
+ public IEnumerable ResolveId(uint id) =>
+ Array.Empty();
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ [return: MaybeNull]
+ public T Get(uint fileId) where T : IDBObj => default;
+
+ public bool TryGet(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 GetAllIdsOfType() where T : IDBObj => Array.Empty();
+ public bool TryGet(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 obj, int iteration = 0) where T : IDBObj => false;
+ public void Dispose() { }
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs
new file mode 100644
index 00000000..22875865
--- /dev/null
+++ b/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs
@@ -0,0 +1,86 @@
+using AcDream.Core.CharGen;
+
+namespace AcDream.Core.Tests.CharGen;
+
+///
+/// Tests for — retail's attribute-credit
+/// budget port (CharGenState::SetHeritageGroup @ 0x005C67A0,
+/// gmCharGenMainUI::DoFinish @ 0x004E9170). Uses synthetic budgets so
+/// these don't depend on real DAT values.
+///
+public sealed class ChargenAttributeMathTests
+{
+ [Fact]
+ public void RemainingCredits_IsBudgetMinusSumOfRawAttributeValues()
+ {
+ // Retail's floor is 10 per attribute; six attributes at the floor
+ // already "spend" 60 of the budget even before any points are added.
+ var values = new ChargenAttributeValues(10, 10, 10, 10, 10, 10);
+
+ int remaining = ChargenAttributeMath.RemainingCredits(180u, values);
+
+ Assert.Equal(120, remaining);
+ }
+
+ [Fact]
+ public void RemainingCredits_ZeroWhenValuesExactlyConsumeBudget()
+ {
+ var values = new ChargenAttributeValues(40, 40, 40, 20, 20, 20);
+ Assert.Equal(180, values.Total);
+
+ Assert.Equal(0, ChargenAttributeMath.RemainingCredits(180u, values));
+ }
+
+ [Fact]
+ public void IsFullySpent_TrueOnlyWhenRemainingIsExactlyZero()
+ {
+ var underspent = new ChargenAttributeValues(10, 10, 10, 10, 10, 10);
+ var exact = new ChargenAttributeValues(40, 40, 40, 20, 20, 20);
+
+ Assert.False(ChargenAttributeMath.IsFullySpent(180u, underspent));
+ Assert.True(ChargenAttributeMath.IsFullySpent(180u, exact));
+ }
+
+ [Fact]
+ public void IsFullySpent_FalseWhenCreditsRemain_MatchesRetailFinishGate()
+ {
+ // gmCharGenMainUI::DoFinish only refuses when remainingAtrbCredits
+ // > 0 (unspent credits) — it never special-cases "spent too much"
+ // because retail's own slider clamping never allows it.
+ var oneUnderBudget = new ChargenAttributeValues(40, 40, 40, 20, 20, 19);
+
+ Assert.False(ChargenAttributeMath.IsFullySpent(180u, oneUnderBudget));
+ Assert.Equal(1, ChargenAttributeMath.RemainingCredits(180u, oneUnderBudget));
+ }
+
+ [Theory]
+ [InlineData(9, false)]
+ [InlineData(10, true)]
+ [InlineData(100, true)]
+ [InlineData(101, false)]
+ public void IsWithinRange_EnforcesRetailFloorAndCeiling(int value, bool expected)
+ {
+ Assert.Equal(expected, ChargenAttributeMath.IsWithinRange(value));
+ }
+
+ [Fact]
+ public void AreAllWithinRange_FalseWhenAnySingleAttributeIsOutOfRange()
+ {
+ var withinRange = new ChargenAttributeValues(10, 100, 50, 50, 50, 50);
+ var oneTooLow = withinRange with { Focus = 9 };
+ var oneTooHigh = withinRange with { Self = 101 };
+
+ Assert.True(ChargenAttributeMath.AreAllWithinRange(withinRange));
+ Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooLow));
+ Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooHigh));
+ }
+
+ [Fact]
+ public void AttributeValues_TotalSumsAllSixInWireOrder()
+ {
+ var values = new ChargenAttributeValues(
+ Strength: 1, Endurance: 2, Coordination: 3, Quickness: 4, Focus: 5, Self: 6);
+
+ Assert.Equal(21, values.Total);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
new file mode 100644
index 00000000..154ea031
--- /dev/null
+++ b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
@@ -0,0 +1,77 @@
+using AcDream.Core.CharGen;
+
+namespace AcDream.Core.Tests.CharGen;
+
+///
+/// Tests for the /
+/// lookup helpers, built from hand-crafted synthetic records (no DAT
+/// dependency — the installed-DAT read path is covered separately by
+/// AcDream.Content.Tests.CharGen).
+///
+public sealed class ChargenOptionsTests
+{
+ private static ChargenHeritageOptions MakeHeritage(uint id, string name) => new(
+ HeritageId: id,
+ Name: name,
+ IconId: 0x06000001u,
+ SetupId: 0x02000001u,
+ EnvironmentSetupId: 0x02000002u,
+ AttributeCredits: 180u,
+ SkillCredits: 100u,
+ PrimaryStartAreaIndices: [0],
+ SecondaryStartAreaIndices: [],
+ SkillCostsBySkillId: new Dictionary(),
+ Templates: [],
+ GendersByKey: new Dictionary());
+
+ [Fact]
+ public void Empty_HasNoStarterAreasOrHeritages()
+ {
+ Assert.Empty(ChargenOptions.Empty.StarterAreas);
+ Assert.Empty(ChargenOptions.Empty.HeritagesById);
+ }
+
+ [Fact]
+ public void TryGetHeritage_FindsRegisteredHeritageById()
+ {
+ var aluvian = MakeHeritage(1u, "Aluvian");
+ var options = new ChargenOptions(
+ [],
+ new Dictionary { [1u] = aluvian });
+
+ Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found));
+ Assert.Same(aluvian, found);
+ }
+
+ [Fact]
+ public void TryGetHeritage_MissingIdReturnsFalse()
+ {
+ var options = ChargenOptions.Empty;
+
+ Assert.False(options.TryGetHeritage(1u, out _));
+ }
+
+ [Fact]
+ public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange()
+ {
+ var area = new ChargenStarterArea(0, "Holtburg", []);
+ var options = new ChargenOptions([area], new Dictionary());
+
+ Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found));
+ Assert.Same(area, found);
+ Assert.False(options.TryGetStarterArea(1, out _));
+ Assert.False(options.TryGetStarterArea(-1, out _));
+ }
+
+ [Theory]
+ [InlineData(1u, false)] // Aluvian
+ [InlineData(11u, false)] // Undead
+ [InlineData(12u, true)] // Olthoi
+ [InlineData(13u, true)] // OlthoiAcid
+ public void IsOlthoi_TrueOnlyForTheTwoOlthoiVariants(uint heritageId, bool expected)
+ {
+ ChargenHeritageOptions heritage = MakeHeritage(heritageId, "Test");
+
+ Assert.Equal(expected, heritage.IsOlthoi);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs
new file mode 100644
index 00000000..cee2f806
--- /dev/null
+++ b/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs
@@ -0,0 +1,80 @@
+using AcDream.Core.CharGen;
+
+namespace AcDream.Core.Tests.CharGen;
+
+///
+/// Tests for — the structural
+/// 55-slot shape ACE's CharacterCreateInfo.Unpack requires on the
+/// 0xF656 wire (numSkills must be exactly 55: reserved slot 0 plus
+/// Chorizite's 54 named SkillId values, 1..54).
+///
+public sealed class ChargenSkillAdvancementSetTests
+{
+ [Fact]
+ public void ToWireClasses_AlwaysProducesExactlyFiftyFiveEntries()
+ {
+ var set = new ChargenSkillAdvancementSet();
+
+ Assert.Equal(55, set.ToWireClasses().Count);
+ Assert.Equal(55, ChargenSkillAdvancementSet.SlotCount);
+ }
+
+ [Fact]
+ public void DefaultState_EverySlotIsInactive()
+ {
+ var set = new ChargenSkillAdvancementSet();
+
+ IReadOnlyList wire = set.ToWireClasses();
+ Assert.All(wire, value => Assert.Equal(0u, value));
+ Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[1u]);
+ Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[54u]);
+ }
+
+ [Fact]
+ public void Indexer_RoundTripsAssignedSkillState()
+ {
+ var set = new ChargenSkillAdvancementSet
+ {
+ [1u] = ChargenSkillAdvancementClass.Trained,
+ [54u] = ChargenSkillAdvancementClass.Specialized,
+ };
+
+ Assert.Equal(ChargenSkillAdvancementClass.Trained, set[1u]);
+ Assert.Equal(ChargenSkillAdvancementClass.Specialized, set[54u]);
+
+ IReadOnlyList wire = set.ToWireClasses();
+ Assert.Equal(0u, wire[0]); // reserved slot never assignable
+ Assert.Equal((uint)ChargenSkillAdvancementClass.Trained, wire[1]);
+ Assert.Equal((uint)ChargenSkillAdvancementClass.Specialized, wire[54]);
+ }
+
+ [Fact]
+ public void Indexer_ReservedSlotZero_ReadsInactiveAndCannotBeSet()
+ {
+ var set = new ChargenSkillAdvancementSet();
+
+ Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[0u]);
+ Assert.Throws(
+ () => set[0u] = ChargenSkillAdvancementClass.Trained);
+ }
+
+ [Theory]
+ [InlineData(55u)]
+ [InlineData(1000u)]
+ public void Indexer_SetOutOfRange_Throws(uint skillId)
+ {
+ var set = new ChargenSkillAdvancementSet();
+
+ Assert.Throws(
+ () => set[skillId] = ChargenSkillAdvancementClass.Trained);
+ }
+
+ [Fact]
+ public void Indexer_GetOutOfRange_ReadsInactiveWithoutThrowing()
+ {
+ var set = new ChargenSkillAdvancementSet();
+
+ Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[55u]);
+ Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[uint.MaxValue]);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs
new file mode 100644
index 00000000..1ae40564
--- /dev/null
+++ b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs
@@ -0,0 +1,86 @@
+using AcDream.Core.CharGen;
+
+namespace AcDream.Core.Tests.CharGen;
+
+///
+/// Tests for — retail's skill-credit
+/// spend port (CharGenState::UpdateRemainingSkillCredits @
+/// 0x005C37C0).
+///
+public sealed class ChargenSkillCreditMathTests
+{
+ private static readonly Dictionary Costs = new()
+ {
+ [1u] = new ChargenSkillCost(1u, NormalCost: 4, PrimaryCost: 12), // Axe
+ [11u] = new ChargenSkillCost(11u, NormalCost: 4, PrimaryCost: 12), // Sword
+ [24u] = new ChargenSkillCost(24u, NormalCost: 1, PrimaryCost: 3), // Run
+ // Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it.
+ };
+
+ [Fact]
+ public void ComputeSpent_IgnoresInactiveAndUntrainedSkills()
+ {
+ var advancement = new ChargenSkillAdvancementSet
+ {
+ [1u] = ChargenSkillAdvancementClass.Inactive,
+ [11u] = ChargenSkillAdvancementClass.Untrained,
+ };
+
+ Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
+ }
+
+ [Fact]
+ public void ComputeSpent_ChargesNormalCostForTrainedSkills()
+ {
+ var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
+
+ Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
+ }
+
+ [Fact]
+ public void ComputeSpent_ChargesPrimaryCostInsteadOfNormalCostForSpecializedSkills()
+ {
+ // Retail adds exactly one of NormalCost/PrimaryCost per skill, never
+ // both — PrimaryCost is the TOTAL cost to reach Specialized.
+ var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized };
+
+ Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
+ }
+
+ [Fact]
+ public void ComputeSpent_SumsAcrossMultipleTrainedAndSpecializedSkills()
+ {
+ var advancement = new ChargenSkillAdvancementSet
+ {
+ [1u] = ChargenSkillAdvancementClass.Trained, // 4
+ [11u] = ChargenSkillAdvancementClass.Specialized, // 12
+ [24u] = ChargenSkillAdvancementClass.Trained, // 1
+ };
+
+ Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
+ }
+
+ [Fact]
+ public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively()
+ {
+ var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained };
+
+ Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
+ }
+
+ [Fact]
+ public void RemainingCredits_IsTotalMinusSpent_AndMayGoNegativeUnlikeAttributes()
+ {
+ var advancement = new ChargenSkillAdvancementSet
+ {
+ [1u] = ChargenSkillAdvancementClass.Trained,
+ [11u] = ChargenSkillAdvancementClass.Specialized,
+ };
+
+ Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs));
+ // Retail's Finish gate never checks remainingSkillCredits, so
+ // overspending relative to the (small, synthetic) budget below is a
+ // representable state, not a thrown exception.
+ Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs));
+ }
+}