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>
80 lines
3.2 KiB
C#
80 lines
3.2 KiB
C#
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;
|
|
}
|
|
}
|