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