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
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue