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:
parent
c3a8c231b8
commit
0445004164
19 changed files with 1656 additions and 0 deletions
215
src/AcDream.Content/CharGen/ChargenTableReader.cs
Normal file
215
src/AcDream.Content/CharGen/ChargenTableReader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs
Normal file
41
src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs
Normal 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);
|
||||
56
src/AcDream.Core/CharGen/ChargenAttributeMath.cs
Normal file
56
src/AcDream.Core/CharGen/ChargenAttributeMath.cs
Normal 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 && eax->remainingAtrbCredits > 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->atrbMin = 0xa</c> — every attribute's floor.</summary>
|
||||
public const int AttributeMin = 10;
|
||||
|
||||
/// <summary>Retail's <c>CharGenState::Reset</c>
|
||||
/// <c>this->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);
|
||||
}
|
||||
22
src/AcDream.Core/CharGen/ChargenAttributeValues.cs
Normal file
22
src/AcDream.Core/CharGen/ChargenAttributeValues.cs
Normal 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;
|
||||
}
|
||||
54
src/AcDream.Core/CharGen/ChargenGenderOptions.cs
Normal file
54
src/AcDream.Core/CharGen/ChargenGenderOptions.cs
Normal 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;
|
||||
}
|
||||
31
src/AcDream.Core/CharGen/ChargenHeritageGroup.cs
Normal file
31
src/AcDream.Core/CharGen/ChargenHeritageGroup.cs
Normal 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,
|
||||
}
|
||||
32
src/AcDream.Core/CharGen/ChargenHeritageOptions.cs
Normal file
32
src/AcDream.Core/CharGen/ChargenHeritageOptions.cs
Normal 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;
|
||||
}
|
||||
41
src/AcDream.Core/CharGen/ChargenObjDesc.cs
Normal file
41
src/AcDream.Core/CharGen/ChargenObjDesc.cs
Normal 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>());
|
||||
}
|
||||
34
src/AcDream.Core/CharGen/ChargenOptions.cs
Normal file
34
src/AcDream.Core/CharGen/ChargenOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
Normal file
80
src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs
Normal file
62
src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs
Normal 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 > 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);
|
||||
}
|
||||
23
src/AcDream.Core/CharGen/ChargenStarterArea.cs
Normal file
23
src/AcDream.Core/CharGen/ChargenStarterArea.cs
Normal 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);
|
||||
19
src/AcDream.Core/CharGen/ChargenTemplate.cs
Normal file
19
src/AcDream.Core/CharGen/ChargenTemplate.cs
Normal 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);
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.CharGen;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.Content.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Installed-DAT gate for <see cref="ChargenTableReader"/>: proves the real
|
||||
/// CharGen table (portal.dat 0x0E000002) loads through the SAME
|
||||
/// <see cref="DatCollectionAdapter"/> production uses and lands in a
|
||||
/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT
|
||||
/// suite (<see cref="ContentConformanceDats.ResolveDatDir"/>) — skips
|
||||
/// cleanly (with a console note) when no DAT directory is configured,
|
||||
/// matching e.g. <c>PakEquivalenceTests</c> / <c>RetailDatLoaderTests</c>.
|
||||
/// </summary>
|
||||
public sealed class ChargenTableReaderInstalledDatTests
|
||||
{
|
||||
// ACE ACE.Entity.Enum.HeritageGroup — the four heritages CC1's spec
|
||||
// calls out by name.
|
||||
private const uint AluvianId = 1u;
|
||||
private const uint GharundimId = 2u;
|
||||
private const uint ShoId = 3u;
|
||||
private const uint ViamontianId = 4u;
|
||||
private const uint OlthoiId = 12u;
|
||||
private const uint OlthoiAcidId = 13u;
|
||||
|
||||
[Fact]
|
||||
public void InstalledCharGenTable_LoadsAndHasThirteenHeritageGroups()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
// ACE's loader comment: "11 standard player races and 2 Olthoi".
|
||||
Assert.Equal(13, options.HeritagesById.Count);
|
||||
Assert.NotEmpty(options.StarterAreas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledCharGenTable_HasTheFourNamedHeritagesWithRetailDisplayNames()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
|
||||
Assert.Equal("Aluvian", aluvian.Name);
|
||||
|
||||
Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim));
|
||||
Assert.Equal("Gharu'ndim", gharundim.Name);
|
||||
|
||||
Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho));
|
||||
Assert.Equal("Sho", sho.Name);
|
||||
|
||||
Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian));
|
||||
Assert.Equal("Viamontian", viamontian.Name);
|
||||
|
||||
Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi));
|
||||
Assert.True(olthoi.IsOlthoi);
|
||||
Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid));
|
||||
Assert.True(olthoiAcid.IsOlthoi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
Assert.NotEmpty(options.HeritagesById);
|
||||
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
||||
{
|
||||
Assert.NotEmpty(heritage.GendersByKey);
|
||||
Assert.Contains(
|
||||
heritage.GendersByKey.Values,
|
||||
gender => gender.HasAnyAppearanceOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every template's six attributes fall within retail's 10..100 range,
|
||||
/// and no template's total exceeds its heritage's AttributeCredits
|
||||
/// budget. NOT every template fully spends the budget — each of the
|
||||
/// four human heritages (and Olthoi Acid) ships an "Adventurer" template
|
||||
/// sitting at the floor (60 of a 330 budget; confirmed against the
|
||||
/// installed DAT), which is retail's "Custom" starting point delivered
|
||||
/// as a real TemplateCG entry rather than a special-cased UI-only
|
||||
/// option. Every OTHER named human template (Bow Hunter, Swashbuckler,
|
||||
/// Life Caster, War Mage, Wayfarer, Soldier) exactly exhausts its
|
||||
/// heritage's credits, and Olthoi's single "Ripper" template exactly
|
||||
/// exhausts its (much smaller, non-customizable) 60-credit budget — so
|
||||
/// SOME template somewhere fully spends its budget, even though no
|
||||
/// single heritage is guaranteed to (Olthoi Acid's only template is the
|
||||
/// unspent "Adventurer" one).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstalledHeritages_EveryTemplateAttributeSpreadFitsTheAttributeBudget()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
int templatesChecked = 0;
|
||||
bool anyFullySpentAnywhere = false;
|
||||
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
||||
{
|
||||
foreach (ChargenTemplate template in heritage.Templates)
|
||||
{
|
||||
templatesChecked++;
|
||||
Assert.True(
|
||||
ChargenAttributeMath.AreAllWithinRange(template.Attributes),
|
||||
$"{heritage.Name}/{template.Name}: an attribute fell outside " +
|
||||
$"{ChargenAttributeMath.AttributeMin}..{ChargenAttributeMath.AttributeMax} " +
|
||||
$"({template.Attributes}).");
|
||||
Assert.True(
|
||||
template.Attributes.Total <= heritage.AttributeCredits,
|
||||
$"{heritage.Name}/{template.Name}: attribute spread totals " +
|
||||
$"{template.Attributes.Total}, exceeding the {heritage.AttributeCredits}-credit budget.");
|
||||
anyFullySpentAnywhere |= ChargenAttributeMath.IsFullySpent(heritage.AttributeCredits, template.Attributes);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(templatesChecked > 0, "Expected at least one profession template across all heritages.");
|
||||
Assert.True(
|
||||
anyFullySpentAnywhere,
|
||||
"Expected at least one named preset template to fully spend its heritage's credit budget.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledHeritages_PrimaryAndSecondaryStartAreaIndicesResolveIntoTheSharedList()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
int indicesChecked = 0;
|
||||
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
||||
{
|
||||
foreach (int index in heritage.PrimaryStartAreaIndices.Concat(heritage.SecondaryStartAreaIndices))
|
||||
{
|
||||
indicesChecked++;
|
||||
Assert.True(
|
||||
options.TryGetStarterArea(index, out ChargenStarterArea area),
|
||||
$"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list.");
|
||||
Assert.False(string.IsNullOrEmpty(area.Name));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(indicesChecked > 0, "Expected at least one heritage start-area reference.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledHeritages_SkillCostsResolveToKnownWireSkillIds()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
|
||||
Assert.NotEmpty(aluvian.SkillCostsBySkillId);
|
||||
foreach (var pair in aluvian.SkillCostsBySkillId)
|
||||
{
|
||||
Assert.Equal(pair.Key, pair.Value.SkillId);
|
||||
Assert.InRange(pair.Key, 1u, 54u);
|
||||
Assert.True(pair.Value.NormalCost >= 0);
|
||||
Assert.True(pair.Value.PrimaryCost >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
403
tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs
Normal file
403
tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.CharGen;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using DatReaderWriter.Types;
|
||||
using DatCharGen = DatReaderWriter.DBObjs.CharGen;
|
||||
using DatObjDesc = DatReaderWriter.Types.ObjDesc;
|
||||
|
||||
namespace AcDream.Content.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChargenTableReader"/>'s pure projection
|
||||
/// (<see cref="ChargenTableReader.Project"/>) against a hand-built
|
||||
/// <see cref="DatCharGen"/> fixture — proves the field mapping without
|
||||
/// depending on the installed DAT. The installed-DAT read path itself is
|
||||
/// covered by <see cref="ChargenTableReaderInstalledDatTests"/>.
|
||||
/// </summary>
|
||||
public sealed class ChargenTableReaderTests
|
||||
{
|
||||
private static PStringBase<byte> Str(string value)
|
||||
{
|
||||
var s = new PStringBase<byte>();
|
||||
s.Value = value;
|
||||
return s;
|
||||
}
|
||||
|
||||
private static QualifiedDataId<T> Qdi<T>(uint id) where T : DBObj, new()
|
||||
{
|
||||
var qdi = new QualifiedDataId<T>();
|
||||
qdi.DataId = id;
|
||||
return qdi;
|
||||
}
|
||||
|
||||
private static DatObjDesc MakeObjDesc(uint paletteId, byte partIndex, uint oldTex, uint newTex)
|
||||
{
|
||||
var od = new DatObjDesc();
|
||||
od.PaletteId = new PackedQualifiedDataId<Palette>();
|
||||
od.PaletteId.DataId = paletteId;
|
||||
|
||||
var sub = new SubPalette();
|
||||
sub.SubId = new PackedQualifiedDataId<Palette>();
|
||||
sub.SubId.DataId = 0x04000099u;
|
||||
sub.Offset = 8;
|
||||
sub.NumColors = 24;
|
||||
od.SubPalettes.Add(sub);
|
||||
|
||||
var tex = new TextureMapChange();
|
||||
tex.PartIndex = partIndex;
|
||||
tex.OldTexture = new PackedQualifiedDataId<SurfaceTexture>();
|
||||
tex.OldTexture.DataId = oldTex;
|
||||
tex.NewTexture = new PackedQualifiedDataId<SurfaceTexture>();
|
||||
tex.NewTexture.DataId = newTex;
|
||||
od.TextureChanges.Add(tex);
|
||||
|
||||
var part = new AnimationPartChange();
|
||||
part.PartIndex = 2;
|
||||
part.PartId = new PackedQualifiedDataId<GfxObj>();
|
||||
part.PartId.DataId = 0x0100ABCDu;
|
||||
od.AnimPartChanges.Add(part);
|
||||
|
||||
return od;
|
||||
}
|
||||
|
||||
private static DatCharGen BuildFixture()
|
||||
{
|
||||
var table = new DatCharGen();
|
||||
|
||||
var area = new StartingArea();
|
||||
area.Name = Str("Holtburg");
|
||||
var position = new Position();
|
||||
position.CellId = 0xA9B40000u;
|
||||
position.Frame = new Frame
|
||||
{
|
||||
Origin = new Vector3(1f, 2f, 3f),
|
||||
Orientation = new Quaternion(0f, 0f, 0f, 1f),
|
||||
};
|
||||
area.Locations.Add(position);
|
||||
table.StartingAreas.Add(area);
|
||||
|
||||
var heritage = new HeritageGroupCG();
|
||||
heritage.Name = Str("Aluvian");
|
||||
heritage.IconId = Qdi<RenderSurface>(0x06000001u);
|
||||
heritage.SetupId = Qdi<Setup>(0x02000010u);
|
||||
heritage.EnvironmentSetupId = Qdi<Setup>(0x02000020u);
|
||||
heritage.AttributeCredits = 180u;
|
||||
heritage.SkillCredits = 50u;
|
||||
heritage.PrimaryStartAreas.Add(0);
|
||||
heritage.SecondaryStartAreas.Add(0);
|
||||
|
||||
var skill = new SkillCG();
|
||||
skill.Id = DatReaderWriter.Enums.SkillId.Axe;
|
||||
skill.NormalCost = 4;
|
||||
skill.PrimaryCost = 12;
|
||||
heritage.Skills.Add(skill);
|
||||
|
||||
var template = new TemplateCG();
|
||||
template.Name = Str("Soldier");
|
||||
template.IconId = Qdi<RenderSurface>(0x06000002u);
|
||||
template.Title = 42u;
|
||||
template.Strength = 40;
|
||||
template.Endurance = 40;
|
||||
template.Coordination = 40;
|
||||
template.Quickness = 20;
|
||||
template.Focus = 20;
|
||||
template.Self = 20;
|
||||
template.NormalSkills.Add(DatReaderWriter.Enums.SkillId.Axe);
|
||||
template.PrimarySkills.Add(DatReaderWriter.Enums.SkillId.MeleeDefense);
|
||||
heritage.Templates.Add(template);
|
||||
|
||||
var sex = new SexCG();
|
||||
sex.Name = Str("Male");
|
||||
sex.Scale = 1000000u;
|
||||
sex.SetupId = Qdi<Setup>(0x02000030u);
|
||||
sex.SoundTable = Qdi<SoundTable>(0x22000001u);
|
||||
sex.IconId = Qdi<RenderSurface>(0x06000003u);
|
||||
sex.BasePalette = Qdi<Palette>(0x04000001u);
|
||||
sex.SkinPalSet = Qdi<PalSet>(0x04001001u);
|
||||
sex.PhysicsTable = Qdi<PhysicsScriptTable>(0x0D000001u);
|
||||
sex.MotionTable = Qdi<MotionTable>(0x09000001u);
|
||||
sex.CombatTable = Qdi<CombatTable>(0x0F000001u);
|
||||
sex.BaseObjDesc = MakeObjDesc(0x04000002u, 0, 0x05000001u, 0x05000002u);
|
||||
sex.HairColors.Add(0x0Au);
|
||||
sex.EyeColors.Add(0x0Bu);
|
||||
sex.ClothingColors.Add(0x0Cu);
|
||||
|
||||
var hairStyle = new HairStyleCG();
|
||||
hairStyle.IconId = Qdi<RenderSurface>(0x06000004u);
|
||||
hairStyle.Bald = false;
|
||||
hairStyle.AlternateSetup = 0x02000099u;
|
||||
hairStyle.ObjDesc = MakeObjDesc(0x04000003u, 1, 0x05000003u, 0x05000004u);
|
||||
sex.HairStyles.Add(hairStyle);
|
||||
|
||||
var eyeStrip = new EyeStripCG();
|
||||
eyeStrip.IconId = Qdi<RenderSurface>(0x06000005u);
|
||||
eyeStrip.BaldIconId = 0x06000006u;
|
||||
eyeStrip.ObjDesc = MakeObjDesc(0x04000004u, 2, 0x05000005u, 0x05000006u);
|
||||
eyeStrip.BaldObjDesc = MakeObjDesc(0x04000005u, 3, 0x05000007u, 0x05000008u);
|
||||
sex.EyeStrips.Add(eyeStrip);
|
||||
|
||||
var noseStrip = new FaceStripCG();
|
||||
noseStrip.IconId = Qdi<RenderSurface>(0x06000007u);
|
||||
noseStrip.ObjDesc = MakeObjDesc(0x04000006u, 4, 0x05000009u, 0x0500000Au);
|
||||
sex.NoseStrips.Add(noseStrip);
|
||||
|
||||
var mouthStrip = new FaceStripCG();
|
||||
mouthStrip.IconId = Qdi<RenderSurface>(0x06000008u);
|
||||
mouthStrip.ObjDesc = MakeObjDesc(0x04000007u, 5, 0x0500000Bu, 0x0500000Cu);
|
||||
sex.MouthStrips.Add(mouthStrip);
|
||||
|
||||
var headgear = new GearCG();
|
||||
headgear.Name = Str("Leather Cap");
|
||||
headgear.ClothingTable = Qdi<ClothingTable>(0x31000001u);
|
||||
headgear.WeenieDefault = 300001u;
|
||||
sex.Headgears.Add(headgear);
|
||||
|
||||
var shirt = new GearCG();
|
||||
shirt.Name = Str("Tunic");
|
||||
shirt.ClothingTable = Qdi<ClothingTable>(0x31000002u);
|
||||
shirt.WeenieDefault = 300002u;
|
||||
sex.Shirts.Add(shirt);
|
||||
|
||||
var pants = new GearCG();
|
||||
pants.Name = Str("Breeches");
|
||||
pants.ClothingTable = Qdi<ClothingTable>(0x31000003u);
|
||||
pants.WeenieDefault = 300003u;
|
||||
sex.Pants.Add(pants);
|
||||
|
||||
var footwear = new GearCG();
|
||||
footwear.Name = Str("Boots");
|
||||
footwear.ClothingTable = Qdi<ClothingTable>(0x31000004u);
|
||||
footwear.WeenieDefault = 300004u;
|
||||
sex.Footwear.Add(footwear);
|
||||
|
||||
heritage.Genders.Add(0, sex);
|
||||
|
||||
table.HeritageGroups.Add(1u, heritage);
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsStarterAreasWithPositionsAndCellId()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
|
||||
ChargenStarterArea area = Assert.Single(options.StarterAreas);
|
||||
Assert.Equal(0, area.Index);
|
||||
Assert.Equal("Holtburg", area.Name);
|
||||
ChargenPosition position = Assert.Single(area.Locations);
|
||||
Assert.Equal(0xA9B40000u, position.CellId);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), position.Origin);
|
||||
Assert.Equal(new Quaternion(0f, 0f, 0f, 1f), position.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsHeritageScalarFieldsAndStartAreaIndices()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage));
|
||||
Assert.Equal("Aluvian", heritage.Name);
|
||||
Assert.Equal(0x06000001u, heritage.IconId);
|
||||
Assert.Equal(0x02000010u, heritage.SetupId);
|
||||
Assert.Equal(0x02000020u, heritage.EnvironmentSetupId);
|
||||
Assert.Equal(180u, heritage.AttributeCredits);
|
||||
Assert.Equal(50u, heritage.SkillCredits);
|
||||
Assert.Equal([0], heritage.PrimaryStartAreaIndices);
|
||||
Assert.Equal([0], heritage.SecondaryStartAreaIndices);
|
||||
Assert.False(heritage.IsOlthoi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsSkillCostsKeyedByRawSkillId()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
|
||||
uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe;
|
||||
Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost));
|
||||
Assert.Equal(axeId, cost.SkillId);
|
||||
Assert.Equal(4, cost.NormalCost);
|
||||
Assert.Equal(12, cost.PrimaryCost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsTemplateAttributesAndSkillLists()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
|
||||
ChargenTemplate template = Assert.Single(heritage.Templates);
|
||||
Assert.Equal("Soldier", template.Name);
|
||||
Assert.Equal(42u, template.TitleStringId);
|
||||
Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes);
|
||||
Assert.Equal(180, template.Attributes.Total);
|
||||
Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.Axe], template.NormalSkills);
|
||||
Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.MeleeDefense], template.PrimarySkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsGenderScalarsAndOptionLists()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
|
||||
Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender));
|
||||
Assert.Equal("Male", gender!.Name);
|
||||
Assert.Equal(1000000u, gender.Scale);
|
||||
Assert.Equal(0x02000030u, gender.SetupId);
|
||||
Assert.Equal(0x04000001u, gender.BasePaletteId);
|
||||
Assert.Equal(0x04001001u, gender.SkinPalSetId);
|
||||
Assert.Equal([0x0Au], gender.HairColors);
|
||||
Assert.Equal([0x0Bu], gender.EyeColors);
|
||||
Assert.Equal([0x0Cu], gender.ClothingColors);
|
||||
Assert.True(gender.HasAnyAppearanceOptions);
|
||||
|
||||
ChargenHairStyle hair = Assert.Single(gender.HairStyles);
|
||||
Assert.Equal(0x06000004u, hair.IconId);
|
||||
Assert.False(hair.Bald);
|
||||
Assert.Equal(0x02000099u, hair.AlternateSetup);
|
||||
|
||||
ChargenEyeStrip eye = Assert.Single(gender.EyeStrips);
|
||||
Assert.Equal(0x06000005u, eye.IconId);
|
||||
Assert.Equal(0x06000006u, eye.BaldIconId);
|
||||
|
||||
ChargenFaceStrip nose = Assert.Single(gender.NoseStrips);
|
||||
Assert.Equal(0x06000007u, nose.IconId);
|
||||
ChargenFaceStrip mouth = Assert.Single(gender.MouthStrips);
|
||||
Assert.Equal(0x06000008u, mouth.IconId);
|
||||
|
||||
ChargenGearOption headgear = Assert.Single(gender.Headgears);
|
||||
Assert.Equal("Leather Cap", headgear.Name);
|
||||
Assert.Equal(0x31000001u, headgear.ClothingTableId);
|
||||
Assert.Equal(300001u, headgear.WeenieDefaultId);
|
||||
|
||||
Assert.Single(gender.Shirts);
|
||||
Assert.Single(gender.Pants);
|
||||
Assert.Single(gender.Footwear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender);
|
||||
|
||||
ChargenObjDesc baseDesc = gender!.BaseObjDesc;
|
||||
Assert.Equal(0x04000002u, baseDesc.PaletteId);
|
||||
ChargenSubPalette sub = Assert.Single(baseDesc.SubPalettes);
|
||||
Assert.Equal(0x04000099u, sub.SubPaletteId);
|
||||
Assert.Equal((byte)8, sub.Offset);
|
||||
Assert.Equal((byte)24, sub.NumColors);
|
||||
|
||||
ChargenTextureChange tex = Assert.Single(baseDesc.TextureChanges);
|
||||
Assert.Equal((byte)0, tex.PartIndex);
|
||||
Assert.Equal(0x05000001u, tex.OldTextureId);
|
||||
Assert.Equal(0x05000002u, tex.NewTextureId);
|
||||
|
||||
ChargenAnimPartChange part = Assert.Single(baseDesc.AnimPartChanges);
|
||||
Assert.Equal((byte)2, part.PartIndex);
|
||||
Assert.Equal(0x0100ABCDu, part.PartId);
|
||||
|
||||
// The eye strip carries a SEPARATE bald ObjDesc from its normal one.
|
||||
ChargenEyeStrip eye = Assert.Single(gender.EyeStrips);
|
||||
Assert.Equal(0x04000004u, eye.ObjDesc.PaletteId);
|
||||
Assert.Equal(0x04000005u, eye.BaldObjDesc.PaletteId);
|
||||
Assert.NotEqual(eye.ObjDesc.PaletteId, eye.BaldObjDesc.PaletteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReturnsEmptyOptions_WhenTableIsMissingFromDatSource()
|
||||
{
|
||||
var empty = new EmptyDatReaderWriter();
|
||||
|
||||
ChargenOptions options = ChargenTableReader.Load(empty);
|
||||
|
||||
Assert.Empty(options.StarterAreas);
|
||||
Assert.Empty(options.HeritagesById);
|
||||
}
|
||||
|
||||
/// <summary>Minimal <see cref="IDatReaderWriter"/> stub whose <c>Get</c>
|
||||
/// always misses — proves <see cref="ChargenTableReader.Load"/>'s
|
||||
/// missing-table tolerance without a live DAT. Mirrors the shape of
|
||||
/// <c>DatResolutionPrecedenceTests.ResolutionSource</c>.</summary>
|
||||
private sealed class EmptyDatReaderWriter : IDatReaderWriter
|
||||
{
|
||||
private readonly StubDatabase _db = new();
|
||||
|
||||
public string SourceDirectory => string.Empty;
|
||||
public IDatDatabase Portal => _db;
|
||||
public IDatDatabase Cell => _db;
|
||||
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
||||
new(new Dictionary<uint, IDatDatabase>());
|
||||
public IDatDatabase HighRes => _db;
|
||||
public IDatDatabase Language => _db;
|
||||
public IDatDatabase Local => _db;
|
||||
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||
new(new Dictionary<uint, uint>());
|
||||
public int PortalIteration => 0;
|
||||
public int CellIteration => 0;
|
||||
public int HighResIteration => 0;
|
||||
public int LanguageIteration => 0;
|
||||
|
||||
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
bytesRead = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
|
||||
|
||||
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
[return: MaybeNull]
|
||||
public T Get<T>(uint fileId) where T : IDBObj => default;
|
||||
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class StubDatabase : IDatDatabase
|
||||
{
|
||||
public DatDatabase Db => null!;
|
||||
public int Iteration => 0;
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
bytesRead = 0;
|
||||
return false;
|
||||
}
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj => false;
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChargenAttributeMath"/> — retail's attribute-credit
|
||||
/// budget port (<c>CharGenState::SetHeritageGroup @ 0x005C67A0</c>,
|
||||
/// <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>). Uses synthetic budgets so
|
||||
/// these don't depend on real DAT values.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
77
tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
Normal file
77
tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="ChargenOptions"/> / <see cref="ChargenHeritageOptions"/>
|
||||
/// lookup helpers, built from hand-crafted synthetic records (no DAT
|
||||
/// dependency — the installed-DAT read path is covered separately by
|
||||
/// AcDream.Content.Tests.CharGen).
|
||||
/// </summary>
|
||||
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<uint, ChargenSkillCost>(),
|
||||
Templates: [],
|
||||
GendersByKey: new Dictionary<int, ChargenGenderOptions>());
|
||||
|
||||
[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<uint, ChargenHeritageOptions> { [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<uint, ChargenHeritageOptions>());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChargenSkillAdvancementSet"/> — the structural
|
||||
/// 55-slot shape ACE's <c>CharacterCreateInfo.Unpack</c> requires on the
|
||||
/// 0xF656 wire (numSkills must be exactly 55: reserved slot 0 plus
|
||||
/// Chorizite's 54 named <c>SkillId</c> values, 1..54).
|
||||
/// </summary>
|
||||
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<uint> 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<uint> 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<ArgumentOutOfRangeException>(
|
||||
() => set[0u] = ChargenSkillAdvancementClass.Trained);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(55u)]
|
||||
[InlineData(1000u)]
|
||||
public void Indexer_SetOutOfRange_Throws(uint skillId)
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChargenSkillCreditMath"/> — retail's skill-credit
|
||||
/// spend port (<c>CharGenState::UpdateRemainingSkillCredits @
|
||||
/// 0x005C37C0</c>).
|
||||
/// </summary>
|
||||
public sealed class ChargenSkillCreditMathTests
|
||||
{
|
||||
private static readonly Dictionary<uint, ChargenSkillCost> 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue