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,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;
/// <summary>
/// Projects portal.dat's CharGen table (id <see cref="ChargenTableDid"/>,
/// retail <c>ACCharGenData::Serialize @ 0x005C36D0</c>) into acdream's
/// presentation-free <see cref="AcDream.Core.CharGen.ChargenOptions"/> tree.
/// Mirrors <c>MagicCatalog.Load</c>'s shape: one static entry point over
/// <see cref="IDatReaderWriter"/>, no Chorizite types cross into the
/// returned model. Cross-checked against ACE's
/// <c>ACE.DatLoader.FileTypes.CharGen</c> +
/// <c>ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG</c> loaders,
/// which unpack the identical field order from the same DAT bytes.
/// </summary>
public static class ChargenTableReader
{
/// <summary>Retail's CharGen DAT file id (ACE:
/// <c>ACE.DatLoader.FileTypes.CharGen.FILE_ID</c>).</summary>
public const uint ChargenTableDid = 0x0E000002u;
/// <summary>
/// Loads and projects the installed CharGen table. Returns
/// <see cref="ChargenOptions.Empty"/> if the table is missing from the
/// supplied dat source (mirrors <c>MagicCatalog</c>'s tolerance for a
/// missing optional table — callers that require the table present
/// should check <c>HeritagesById.Count</c> themselves).
/// </summary>
public static ChargenOptions Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
DatCharGen? table = dats.Get<DatCharGen>(ChargenTableDid);
return table is null ? ChargenOptions.Empty : Project(table);
}
/// <summary>Pure projection from an already-loaded DAT record — split out
/// from <see cref="Load"/> so tests can exercise it against
/// hand-built <see cref="DatCharGen"/> fixtures without a live DAT.</summary>
public static ChargenOptions Project(DatCharGen table)
{
ArgumentNullException.ThrowIfNull(table);
var starterAreas = new List<ChargenStarterArea>(table.StartingAreas.Count);
for (int i = 0; i < table.StartingAreas.Count; i++)
starterAreas.Add(ProjectStarterArea(i, table.StartingAreas[i]));
var heritagesById = new Dictionary<uint, ChargenHeritageOptions>(table.HeritageGroups.Count);
foreach (KeyValuePair<uint, HeritageGroupCG> 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<ChargenPosition>(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<uint, ChargenSkillCost>(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<ChargenTemplate>(cg.Templates.Count);
foreach (TemplateCG template in cg.Templates)
templates.Add(ProjectTemplate(template));
var gendersByKey = new Dictionary<int, ChargenGenderOptions>(cg.Genders.Count);
foreach (KeyValuePair<int, SexCG> 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<int>(cg.PrimaryStartAreas),
new List<int>(cg.SecondaryStartAreas),
skillCosts,
templates,
gendersByKey);
}
private static ChargenTemplate ProjectTemplate(TemplateCG template)
{
var normalSkills = new List<uint>(template.NormalSkills.Count);
foreach (var skillId in template.NormalSkills)
normalSkills.Add((uint)skillId);
var primarySkills = new List<uint>(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<ChargenHairStyle>(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<ChargenEyeStrip>(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<ChargenFaceStrip>(sex.NoseStrips.Count);
foreach (FaceStripCG strip in sex.NoseStrips)
noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)));
var mouthStrips = new List<ChargenFaceStrip>(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<uint>(sex.HairColors),
hairStyles,
new List<uint>(sex.EyeColors),
eyeStrips,
noseStrips,
mouthStrips,
ProjectGearList(sex.Headgears),
ProjectGearList(sex.Shirts),
ProjectGearList(sex.Pants),
ProjectGearList(sex.Footwear),
new List<uint>(sex.ClothingColors));
}
private static List<ChargenGearOption> ProjectGearList(List<GearCG> gearList)
{
var result = new List<ChargenGearOption>(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<ChargenSubPalette>(objDesc.SubPalettes.Count);
foreach (SubPalette sub in objDesc.SubPalettes)
subPalettes.Add(new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors));
var textureChanges = new List<ChargenTextureChange>(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<ChargenAnimPartChange>(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);
}
}

View file

@ -0,0 +1,41 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// One hair-style option in a <see cref="ChargenGenderOptions.HairStyles"/>
/// list. Retail schema: <c>HairStyle_CG</c> (nested inside
/// <c>Sex_CG::Serialize @ 0x005C1600</c>). <c>Bald</c> and
/// <c>AlternateSetup</c> handle the Gear Knight / Olthoi bald-head special
/// case ACE's <c>SexCG.GetHeadObject</c> comment documents.
/// </summary>
public sealed record ChargenHairStyle(
uint IconId,
bool Bald,
uint AlternateSetup,
ChargenObjDesc ObjDesc);
/// <summary>
/// One eye-strip option. Retail carries a SEPARATE bald variant
/// (<c>BaldIconId</c> / <c>BaldObjDesc</c>) because a bald hairstyle
/// selection changes which eye texture applies — see ACE's
/// <c>SexCG.GetEyeTexture(strip, isBald)</c>.
/// </summary>
public sealed record ChargenEyeStrip(
uint IconId,
uint BaldIconId,
ChargenObjDesc ObjDesc,
ChargenObjDesc BaldObjDesc);
/// <summary>One nose- or mouth-strip option (retail <c>FaceStrip_CG</c>).</summary>
public sealed record ChargenFaceStrip(uint IconId, ChargenObjDesc ObjDesc);
/// <summary>
/// One clothing-slot option (headgear/shirt/pants/footwear). Retail schema:
/// <c>Gear_CG</c>. <c>ClothingTableId</c> resolves through
/// <c>ClothingTable::BuildObjDesc</c>; <c>WeenieDefaultId</c> is the weenie
/// class the character actually receives in inventory on creation (ACE's
/// <c>SexCG.GetHeadgearWeenie</c> family).
/// </summary>
public sealed record ChargenGearOption(
string Name,
uint ClothingTableId,
uint WeenieDefaultId);

View file

@ -0,0 +1,56 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Pure port of retail's attribute-credit budget math
/// (<c>CharGenState::SetHeritageGroup @ 0x005C67A0</c> and the six
/// attribute-slider setters around <c>0x005C46CE..0x005C494E</c>, all of
/// the shape <c>remainingAtrbCredits = totalAtrbCredits - (str + end +
/// coord + quick + focus + self)</c>): a heritage's <c>AttributeCredits</c>
/// 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
/// (<c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>, line
/// <c>if (arg2 != 0 &amp;&amp; eax-&gt;remainingAtrbCredits &gt; 0)</c>) 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).
/// </summary>
public static class ChargenAttributeMath
{
/// <summary>Retail's <c>CharGenState::Reset @ 0x005C68A0</c>
/// <c>this-&gt;atrbMin = 0xa</c> — every attribute's floor.</summary>
public const int AttributeMin = 10;
/// <summary>Retail's <c>CharGenState::Reset</c>
/// <c>this-&gt;atrbMax = 0x64</c> — every attribute's ceiling.</summary>
public const int AttributeMax = 100;
/// <summary><c>attributeCreditBudget - values.Total</c>. 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 <c>ConstrainAllByHeritage</c> prevents overspend,
/// but callers building a candidate outside that UI path should treat a
/// negative result as an invalid state, not silently accept it).</summary>
public static int RemainingCredits(uint attributeCreditBudget, ChargenAttributeValues values) =>
checked((int)attributeCreditBudget) - values.Total;
/// <summary>Retail's Finish gate: creation may proceed only when this is
/// true.</summary>
public static bool IsFullySpent(uint attributeCreditBudget, ChargenAttributeValues values) =>
RemainingCredits(attributeCreditBudget, values) == 0;
/// <summary>True when a single attribute value falls within
/// <see cref="AttributeMin"/>..<see cref="AttributeMax"/> inclusive.</summary>
public static bool IsWithinRange(int value) => value >= AttributeMin && value <= AttributeMax;
/// <summary>True when every one of the six attributes falls within
/// range individually (does not check the credit total — see
/// <see cref="IsFullySpent"/> for that).</summary>
public static bool AreAllWithinRange(ChargenAttributeValues values) =>
IsWithinRange(values.Strength)
&& IsWithinRange(values.Endurance)
&& IsWithinRange(values.Coordination)
&& IsWithinRange(values.Quickness)
&& IsWithinRange(values.Focus)
&& IsWithinRange(values.Self);
}

View file

@ -0,0 +1,22 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// The six primary attributes in retail's chargen wire/serialization order
/// (Strength, Endurance, Coordination, Quickness, Focus, Self) — matches
/// both <c>Template_CG::Serialize @ 0x005C0450</c> and the 0xF656
/// <c>ACCharGenData::CG_Pack @ 0x005C7200</c> attribute block the plan
/// documents. Used both for a preset <see cref="ChargenTemplate"/>'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).
/// </summary>
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;
}

View file

@ -0,0 +1,54 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// 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:
/// <c>Sex_CG::Serialize @ 0x005C1600</c> (ACE's <c>SexCG.Unpack</c> mirrors
/// the same field order). <c>GenderKey</c> is the raw key from
/// <c>HeritageGroupCG.Genders</c> (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).
/// </summary>
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<uint> HairColors,
IReadOnlyList<ChargenHairStyle> HairStyles,
IReadOnlyList<uint> EyeColors,
IReadOnlyList<ChargenEyeStrip> EyeStrips,
IReadOnlyList<ChargenFaceStrip> NoseStrips,
IReadOnlyList<ChargenFaceStrip> MouthStrips,
IReadOnlyList<ChargenGearOption> Headgears,
IReadOnlyList<ChargenGearOption> Shirts,
IReadOnlyList<ChargenGearOption> Pants,
IReadOnlyList<ChargenGearOption> Footwear,
IReadOnlyList<uint> ClothingColors)
{
/// <summary>
/// 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.
/// </summary>
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;
}

View file

@ -0,0 +1,31 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Retail's 11 standard player heritages plus the two Olthoi player-race
/// variants (ACE's loader comment on <c>CharGen.Unpack</c>: "HERITAGE
/// GROUPS -- 11 standard player races and 2 Olthoi"). Numeric values match
/// ACE's <c>ACE.Entity.Enum.HeritageGroup</c> exactly — the same ids the
/// wire uses and the same ids that key
/// <see cref="ChargenOptions.HeritagesById"/>. Display names come from the
/// DAT's own <c>HeritageGroupCG.Name</c> 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).
/// </summary>
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,
}

View file

@ -0,0 +1,32 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Everything the Heritage/Profession/Skills/Appearance/Town pages need for
/// one heritage. Retail schema: <c>HeritageGroup_CG::Serialize @
/// 0x005C2100</c> (ACE's <c>HeritageGroupCG.Unpack</c> mirrors the same
/// field order). <c>PrimaryStartAreaIndices</c> / <c>SecondaryStartAreaIndices</c>
/// index into the CharGen table's SHARED <c>ChargenOptions.StarterAreas</c>
/// list, not a per-heritage list of their own.
/// </summary>
public sealed record ChargenHeritageOptions(
uint HeritageId,
string Name,
uint IconId,
uint SetupId,
uint EnvironmentSetupId,
uint AttributeCredits,
uint SkillCredits,
IReadOnlyList<int> PrimaryStartAreaIndices,
IReadOnlyList<int> SecondaryStartAreaIndices,
IReadOnlyDictionary<uint, ChargenSkillCost> SkillCostsBySkillId,
IReadOnlyList<ChargenTemplate> Templates,
IReadOnlyDictionary<int, ChargenGenderOptions> GendersByKey)
{
/// <summary>True for the two Olthoi player-race variants (ids 12/13) —
/// CC6's 3D preview hard-codes a different camera target position for
/// these (<c>gmCGAppearancePage::Update @ 0x0047E8F0</c>, the
/// <c>mHeritageGroup == 0xc || mHeritageGroup == 0xd</c> branch).</summary>
public bool IsOlthoi =>
HeritageId == (uint)ChargenHeritageGroup.Olthoi
|| HeritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
}

View file

@ -0,0 +1,41 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// One palette overlay range inside a <see cref="ChargenObjDesc"/>. Retail
/// applies <c>NumColors * 8</c> colors from <c>SubPaletteId</c> starting at
/// <c>Offset * 8</c> in the base palette (Chorizite.ACProtocol.Types.Subpalette
/// docs; the live-session equivalent is
/// <see cref="AcDream.Core.World.PaletteOverride.SubPaletteRange"/>).
/// </summary>
public readonly record struct ChargenSubPalette(uint SubPaletteId, byte Offset, byte NumColors);
/// <summary>One texture-map override inside a <see cref="ChargenObjDesc"/>.
/// <c>PartIndex</c> identifies which GfxObj part's surface list the swap
/// applies to.</summary>
public readonly record struct ChargenTextureChange(byte PartIndex, uint OldTextureId, uint NewTextureId);
/// <summary>One animated-part swap override inside a <see cref="ChargenObjDesc"/>.</summary>
public readonly record struct ChargenAnimPartChange(byte PartIndex, uint PartId);
/// <summary>
/// Presentation-free projection of Chorizite's <c>ObjDesc</c> shape (retail's
/// <c>CObjDesc</c>): the palette id plus the overlay/texture/part-swap deltas
/// a live appearance is built from. Used both for a gender's base body
/// (<see cref="ChargenGenderOptions.BaseObjDesc"/>) 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
/// <c>ClothingTable::BuildObjDesc</c> / <c>DoObjDescChangesFromDefault</c>
/// pipeline does.
/// </summary>
public sealed record ChargenObjDesc(
uint PaletteId,
IReadOnlyList<ChargenSubPalette> SubPalettes,
IReadOnlyList<ChargenTextureChange> TextureChanges,
IReadOnlyList<ChargenAnimPartChange> AnimPartChanges)
{
public static ChargenObjDesc Empty { get; } = new(
0u,
Array.Empty<ChargenSubPalette>(),
Array.Empty<ChargenTextureChange>(),
Array.Empty<ChargenAnimPartChange>());
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Top-level, presentation-free, immutable projection of retail's CharGen
/// DAT table (portal.dat 0x0E000002, <c>ACCharGenData::Serialize @
/// 0x005C36D0</c>). Production builds create this from the installed DAT
/// through Content's <c>AcDream.Content.CharGen.ChargenTableReader.Load</c>;
/// 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.
/// </summary>
public sealed record ChargenOptions(
IReadOnlyList<ChargenStarterArea> StarterAreas,
IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById)
{
public static ChargenOptions Empty { get; } = new(
Array.Empty<ChargenStarterArea>(),
new Dictionary<uint, ChargenHeritageOptions>());
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;
}
}

View file

@ -0,0 +1,80 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Retail's four skill states. Wire values match ACE's
/// <c>ACE.Entity.Enum.SkillAdvancementClass</c> exactly (0=Inactive,
/// 1=Untrained, 2=Trained, 3=Specialized) — ACE unpacks the 0xF656
/// <c>CharacterCreateInfo.SkillAdvancementClasses</c> list with this same
/// numbering, and retail's <c>CharGenState::UpdateRemainingSkillCredits @
/// 0x005C37C0</c> only charges credits for Trained (2) and Specialized (3).
/// </summary>
public enum ChargenSkillAdvancementClass : uint
{
Inactive = 0,
Untrained = 1,
Trained = 2,
Specialized = 3,
}
/// <summary>
/// One skill's retail training cost for a heritage. Retail schema:
/// <c>SkillCG</c>, entries of <c>HeritageGroupCG.Skills</c>.
/// <c>PrimaryCost</c> is the TOTAL cost to reach Specialized (not an
/// increment on top of <c>NormalCost</c>) — retail's
/// <c>UpdateRemainingSkillCredits</c> adds exactly one of the two per
/// skill, never both.
/// </summary>
public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
/// <summary>
/// Retail's fixed-size per-character skill-advancement array
/// (<c>CharGenState.skillLevels</c>). ACE's <c>CharacterCreateInfo.Unpack</c>
/// terminates the connection if the wire's <c>numSkills</c> count is not
/// exactly <see cref="SlotCount"/> (55): retail's own loop in
/// <c>UpdateRemainingSkillCredits</c> walks indices <c>1..totalNumSkills</c>
/// (skipping reserved slot 0), and Chorizite's <c>SkillId</c> 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.
/// </summary>
public sealed class ChargenSkillAdvancementSet
{
/// <summary>Slot 0 is reserved (unused by retail); slots 1..54 map 1:1
/// to Chorizite's <c>DatReaderWriter.Enums.SkillId</c> values.</summary>
public const int SlotCount = 55;
private readonly ChargenSkillAdvancementClass[] _slots = new ChargenSkillAdvancementClass[SlotCount];
/// <summary>Skill state by raw skill id. Ids outside <c>1..54</c> read
/// as <see cref="ChargenSkillAdvancementClass.Inactive"/> and cannot be
/// set.</summary>
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;
}
}
/// <summary>
/// Materializes the wire body shape: exactly <see cref="SlotCount"/>
/// entries, slot 0 first, matching ACE's
/// <c>CharacterCreateInfo.SkillAdvancementClasses</c> read order.
/// </summary>
public IReadOnlyList<uint> ToWireClasses()
{
var wire = new uint[SlotCount];
for (int i = 0; i < SlotCount; i++)
wire[i] = (uint)_slots[i];
return wire;
}
}

View file

@ -0,0 +1,62 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Pure port of retail's skill-credit spend calculation
/// (<c>CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0</c>): walk
/// every skill slot, add <c>NormalCost</c> for Trained or <c>PrimaryCost</c>
/// for Specialized (never both), and subtract the total from the heritage's
/// <c>SkillCredits</c> budget. No DAT/DatReaderWriter dependency — callers
/// (CC3's Runtime owner) pass in the heritage's already-projected
/// <see cref="ChargenSkillCost"/> lookup.
/// </summary>
public static class ChargenSkillCreditMath
{
/// <summary>
/// Total credits spent across every Trained/Specialized skill in
/// <paramref name="advancement"/>. 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.
/// </summary>
public static int ComputeSpent(
ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> 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;
}
/// <summary>
/// <c>totalSkillCredits - ComputeSpent(...)</c> — retail's
/// <c>remainingSkillCredits</c>. Retail's Finish gate (<c>DoFinish @
/// 0x004E91F2</c>-adjacent) only checks <c>remainingAtrbCredits &gt; 0</c>
/// 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
/// <see cref="ChargenAttributeMath.IsFullySpent"/> does for attributes.
/// </summary>
public static int RemainingCredits(
uint totalSkillCredits,
ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId) =>
checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId);
}

View file

@ -0,0 +1,23 @@
using System.Numerics;
namespace AcDream.Core.CharGen;
/// <summary>One spawn point inside a <see cref="ChargenStarterArea"/>. Retail
/// schema: <c>Position</c> nested inside <c>StartingArea</c>
/// (<c>ACCharGenData::Serialize @ 0x005C36D0</c>).</summary>
public readonly record struct ChargenPosition(uint CellId, Vector3 Origin, Quaternion Orientation);
/// <summary>
/// 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 — <see cref="ChargenHeritageOptions.PrimaryStartAreaIndices"/>
/// and <c>SecondaryStartAreaIndices</c> reference this list by index, they
/// do not carry their own copies. Retail schema:
/// <c>ACCharGenData::Serialize @ 0x005C36D0</c> (ACE's loader comment names
/// this <c>StarterArea</c>; Chorizite names the DAT type <c>StartingArea</c>
/// — same shape).
/// </summary>
public sealed record ChargenStarterArea(
int Index,
string Name,
IReadOnlyList<ChargenPosition> Locations);

View file

@ -0,0 +1,19 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage,
/// Wayfarer, Soldier, ...) offered on the Profession page
/// (<c>UpdateProfession @ 0x00478d1c/0x0047a4a4</c>-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 <see cref="ChargenTemplate"/>
/// entry. Retail schema: <c>Template_CG::Serialize @ 0x005C0450</c>
/// (ACE's <c>TemplateCG.Unpack</c> mirrors the same field order).
/// </summary>
public sealed record ChargenTemplate(
string Name,
uint IconId,
uint TitleStringId,
ChargenAttributeValues Attributes,
IReadOnlyList<uint> NormalSkills,
IReadOnlyList<uint> PrimarySkills);