fix(chargen): CC1 review fix round — Custom is template 0, SkillTable cost fallback, frozen model

Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):

- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
  ChargenTemplate entry and cited two nonexistent addresses. Verified
  against the named retail decomp: gmCGProfessionPage::UpdateProfession
  @ 0x004821b0 resolves BOTH the highlighted button and the description
  string from CharGenState.template_ 0..6, and case 0 is button
  0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
  "Adventurer" row CC1 already found sitting at the attribute floor).
  CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
  Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
  so selecting Custom resets the sliders/skills to that row rather than
  leaving them untouched.

- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
  (ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
  0x005C26D0/0x005C27D0 fall through to the global SkillTable,
  portal.dat 0x0E000004, on a heritage-list miss — confirmed against
  ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
  also projects the global SkillTable into
  ChargenOptions.GlobalSkillCostsBySkillId, and
  ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
  heritage list first and the global list on a miss. Added an
  installed-DAT completeness assertion recording reality: the global
  table prices 38/54 advancement skill ids, every one of the 13
  installed heritages ships exactly one heritage-specific override
  (always also priced globally), and 16 ids are genuinely uncostable in
  both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
  override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
  — a retail-legal build may be rejected by local ACE at the CC7
  connected gate; that is an ACE bug, not an acdream defect.

- F3 (MEDIUM): every collection ChargenTableReader hands into the
  record model is now frozen at projection (ToFrozenDictionary/ToArray,
  matching MagicCatalog's house pattern), including both
  ChargenOptions.Empty dictionaries.

- F4 (LOW): added a reflection guard test
  (ChargenNoChoriziteLeakTests) that walks every public
  AcDream.Core.CharGen member (property/indexer/constructor/method
  types, recursively through generic arguments) and fails if any
  resolves to the DatReaderWriter or a Chorizite* assembly.

- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
  states precisely what the installed-DAT gate proves (an OR across
  eight lists, for at least one gender per heritage) rather than the
  stronger claim it previously made, and explicitly calls out the three
  omitted color lists. Added a second installed-DAT gate that records
  per-list reality across every gender of every heritage — found
  complete, no empty lists anywhere in the installed DAT today.

- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
  [MaybeNullWhen(false)] instead of null! suppression, matching the
  house pattern already used elsewhere in the test suite. Fixed every
  call site this surfaced (more than the five originally estimated,
  since Content.Tests has TreatWarningsAsErrors).

Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 13:32:40 +02:00
parent 70d52b0da2
commit cb4703e8d5
11 changed files with 637 additions and 139 deletions

View file

@ -187,6 +187,34 @@ worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7.
arbiter. arbiter.
7. `references/*` absent in worktrees (except WorldBuilder, uninitialized 7. `references/*` absent in worktrees (except WorldBuilder, uninitialized
submodule) — agents read ACE/holtburger from the MAIN checkout path. submodule) — agents read ACE/holtburger from the MAIN checkout path.
8. **CC7 landmine (found in the CC1 review fix round, 2026-08-15):** ACE's
`PlayerFactory.CreatePlayer` heritage-override branch
(references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211)
over-deducts skill credits when specializing a skill the active
heritage's own list prices. For a skill priced ONLY by the global
SkillTable, ACE correctly computes the incremental specialize cost via
`SkillBase.UpgradeCostFromTrainedToSpecialized` (= `SpecializedCost -
TrainedCost`) and charges `TrainSkill(trainedCost) +
SpecializeSkill(incrementalCost)` = the field's TOTAL, matching retail.
But when the heritage's own list has an entry, ACE sets
`specializedCost = skillGroup.PrimaryCost` directly — `PrimaryCost` is
already the TOTAL cost to reach Specialized (acdream's own
`ChargenSkillCost.PrimaryCost` convention, confirmed against retail) —
and then still charges `TrainSkill(NormalCost) +
SpecializeSkill(PrimaryCost)`, over-deducting by an extra `NormalCost`
credits versus what retail's client computed and what the player agreed
to spend. Practical impact for CC7's connected gate: a retail-legal
character build that specializes a skill the ACTIVE HERITAGE prices
(every one of the 13 installed heritages has exactly one such skill —
see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`)
may be REJECTED by local ACE with `FailedToSpecializeSkill` even though
acdream sent the byte-correct 0xF656 body. If CC7's gate hits this,
it is an ACE-side bug reproduced from its own source, NOT an acdream
wire or math defect — do not "fix" acdream's cost math to match ACE's
over-deduction. Register: file an AD row if CC7 needs a documented
workaround (e.g. picking a Specialized skill combination that avoids
the heritage-priced skill for the connected gate) rather than silently
adjusting acdream's send.
## Review protocol ## Review protocol
@ -200,7 +228,7 @@ the user gate.
| Slice | Status | Commits | Review | Notes | | Slice | Status | Commits | Review | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| CC1 | implemented; review in flight | `04450041` | dispatched 2026-08-15 | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case | | CC1 | implemented; review fix round complete | `04450041`, `459a87f2` | dispatched 2026-08-15 | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`459a87f2`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. |
| CC2 | implemented (campaign-cc2); review in flight | `5eaad2c8` | dispatched 2026-08-15 | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC2 | implemented (campaign-cc2); review in flight | `5eaad2c8` | dispatched 2026-08-15 | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL |
| CC3 | — | | | | | CC3 | — | | | |
| CC4 | — | | | | | CC4 | — | | | |

View file

@ -1,22 +1,30 @@
using System.Collections.Frozen;
using AcDream.Core.CharGen; using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc; using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc;
using DatCharGen = DatReaderWriter.DBObjs.CharGen; using DatCharGen = DatReaderWriter.DBObjs.CharGen;
using DatObjDesc = DatReaderWriter.Types.ObjDesc; using DatObjDesc = DatReaderWriter.Types.ObjDesc;
using DatSkillTable = DatReaderWriter.DBObjs.SkillTable;
namespace AcDream.Content.CharGen; namespace AcDream.Content.CharGen;
/// <summary> /// <summary>
/// Projects portal.dat's CharGen table (id <see cref="ChargenTableDid"/>, /// Projects portal.dat's CharGen table (id <see cref="ChargenTableDid"/>,
/// retail <c>ACCharGenData::Serialize @ 0x005C36D0</c>) into acdream's /// retail <c>ACCharGenData::Serialize @ 0x005C36D0</c>) PLUS the global
/// SkillTable (id <see cref="SkillTableDid"/>) into acdream's
/// presentation-free <see cref="AcDream.Core.CharGen.ChargenOptions"/> tree. /// presentation-free <see cref="AcDream.Core.CharGen.ChargenOptions"/> tree.
/// Mirrors <c>MagicCatalog.Load</c>'s shape: one static entry point over /// Mirrors <c>MagicCatalog.Load</c>'s shape: one static entry point over
/// <see cref="IDatReaderWriter"/>, no Chorizite types cross into the /// <see cref="IDatReaderWriter"/>, every returned collection is frozen at
/// projection (<c>ToFrozenDictionary</c> / <c>ToArray</c>, matching
/// <c>MagicCatalog</c>'s pattern), and no Chorizite types cross into the
/// returned model. Cross-checked against ACE's /// returned model. Cross-checked against ACE's
/// <c>ACE.DatLoader.FileTypes.CharGen</c> + /// <c>ACE.DatLoader.FileTypes.CharGen</c> +
/// <c>ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG</c> loaders, /// <c>ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG</c> loaders,
/// which unpack the identical field order from the same DAT bytes. /// which unpack the identical field order from the same DAT bytes, and
/// against <c>ACE.DatLoader.FileTypes.SkillTable</c> for the global
/// skill-cost fallback (see
/// <see cref="AcDream.Core.CharGen.ChargenOptions.GlobalSkillCostsBySkillId"/>).
/// </summary> /// </summary>
public static class ChargenTableReader public static class ChargenTableReader
{ {
@ -24,48 +32,83 @@ public static class ChargenTableReader
/// <c>ACE.DatLoader.FileTypes.CharGen.FILE_ID</c>).</summary> /// <c>ACE.DatLoader.FileTypes.CharGen.FILE_ID</c>).</summary>
public const uint ChargenTableDid = 0x0E000002u; public const uint ChargenTableDid = 0x0E000002u;
/// <summary>Retail's global SkillTable DAT file id (ACE:
/// <c>ACE.DatLoader.FileTypes.SkillTable.FILE_ID</c>) — the fallback
/// retail's <c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
/// <c>GetSkillSpecializedCost @ 0x005C27D0</c> read via
/// <c>DBCache::GetFromEnumStatic(4, 2, 0x10000004)</c> when a heritage's
/// own skill-cost list has no entry for a skill id.</summary>
public const uint SkillTableDid = 0x0E000004u;
/// <summary> /// <summary>
/// Loads and projects the installed CharGen table. Returns /// Loads and projects the installed CharGen table plus the global
/// <see cref="ChargenOptions.Empty"/> if the table is missing from the /// SkillTable fallback. Returns <see cref="ChargenOptions.Empty"/> if
/// supplied dat source (mirrors <c>MagicCatalog</c>'s tolerance for a /// the CharGen table is missing from the supplied dat source (mirrors
/// missing optional table — callers that require the table present /// <c>MagicCatalog</c>'s tolerance for a missing optional table —
/// should check <c>HeritagesById.Count</c> themselves). /// callers that require the table present should check
/// <c>HeritagesById.Count</c> themselves). A missing global SkillTable
/// degrades to an empty fallback dictionary rather than failing the
/// whole load — the per-heritage costs (the common case) still work.
/// </summary> /// </summary>
public static ChargenOptions Load(IDatReaderWriter dats) public static ChargenOptions Load(IDatReaderWriter dats)
{ {
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
DatCharGen? table = dats.Get<DatCharGen>(ChargenTableDid); DatCharGen? table = dats.Get<DatCharGen>(ChargenTableDid);
return table is null ? ChargenOptions.Empty : Project(table); if (table is null)
return ChargenOptions.Empty;
DatSkillTable? skillTable = dats.Get<DatSkillTable>(SkillTableDid);
return Project(table, skillTable);
} }
/// <summary>Pure projection from an already-loaded DAT record — split out /// <summary>Pure projection from an already-loaded DAT record — split out
/// from <see cref="Load"/> so tests can exercise it against /// from <see cref="Load"/> so tests can exercise it against
/// hand-built <see cref="DatCharGen"/> fixtures without a live DAT.</summary> /// hand-built <see cref="DatCharGen"/> fixtures without a live DAT.
public static ChargenOptions Project(DatCharGen table) /// <paramref name="skillTable"/> is optional (mirrors <see cref="Load"/>'s
/// missing-table tolerance) and projects into
/// <see cref="AcDream.Core.CharGen.ChargenOptions.GlobalSkillCostsBySkillId"/>.</summary>
public static ChargenOptions Project(DatCharGen table, DatSkillTable? skillTable = null)
{ {
ArgumentNullException.ThrowIfNull(table); ArgumentNullException.ThrowIfNull(table);
var starterAreas = new List<ChargenStarterArea>(table.StartingAreas.Count); var starterAreas = new ChargenStarterArea[table.StartingAreas.Count];
for (int i = 0; i < table.StartingAreas.Count; i++) for (int i = 0; i < table.StartingAreas.Count; i++)
starterAreas.Add(ProjectStarterArea(i, table.StartingAreas[i])); starterAreas[i] = ProjectStarterArea(i, table.StartingAreas[i]);
var heritagesById = new Dictionary<uint, ChargenHeritageOptions>(table.HeritageGroups.Count); var heritagesById = new Dictionary<uint, ChargenHeritageOptions>(table.HeritageGroups.Count);
foreach (KeyValuePair<uint, HeritageGroupCG> pair in table.HeritageGroups) foreach (KeyValuePair<uint, HeritageGroupCG> pair in table.HeritageGroups)
heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value);
return new ChargenOptions(starterAreas, heritagesById); var globalSkillCosts = new Dictionary<uint, ChargenSkillCost>(skillTable?.Skills.Count ?? 0);
if (skillTable is not null)
{
foreach (KeyValuePair<DatReaderWriter.Enums.SkillId, SkillBase> pair in skillTable.Skills)
{
uint skillId = (uint)pair.Key;
globalSkillCosts[skillId] = new ChargenSkillCost(
skillId,
pair.Value.TrainedCost,
pair.Value.SpecializedCost);
}
}
return new ChargenOptions(
starterAreas,
heritagesById.ToFrozenDictionary(),
globalSkillCosts.ToFrozenDictionary());
} }
private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area)
{ {
var locations = new List<ChargenPosition>(area.Locations.Count); var locations = new ChargenPosition[area.Locations.Count];
foreach (Position position in area.Locations) for (int i = 0; i < area.Locations.Count; i++)
{ {
locations.Add(new ChargenPosition( Position position = area.Locations[i];
locations[i] = new ChargenPosition(
position.CellId, position.CellId,
position.Frame.Origin, position.Frame.Origin,
position.Frame.Orientation)); position.Frame.Orientation);
} }
return new ChargenStarterArea(index, area.Name.Value, locations); return new ChargenStarterArea(index, area.Name.Value, locations);
} }
@ -79,9 +122,9 @@ public static class ChargenTableReader
skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost); skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost);
} }
var templates = new List<ChargenTemplate>(cg.Templates.Count); var templates = new ChargenTemplate[cg.Templates.Count];
foreach (TemplateCG template in cg.Templates) for (int i = 0; i < cg.Templates.Count; i++)
templates.Add(ProjectTemplate(template)); templates[i] = ProjectTemplate(cg.Templates[i]);
var gendersByKey = new Dictionary<int, ChargenGenderOptions>(cg.Genders.Count); var gendersByKey = new Dictionary<int, ChargenGenderOptions>(cg.Genders.Count);
foreach (KeyValuePair<int, SexCG> pair in cg.Genders) foreach (KeyValuePair<int, SexCG> pair in cg.Genders)
@ -95,22 +138,22 @@ public static class ChargenTableReader
cg.EnvironmentSetupId.DataId, cg.EnvironmentSetupId.DataId,
cg.AttributeCredits, cg.AttributeCredits,
cg.SkillCredits, cg.SkillCredits,
new List<int>(cg.PrimaryStartAreas), cg.PrimaryStartAreas.ToArray(),
new List<int>(cg.SecondaryStartAreas), cg.SecondaryStartAreas.ToArray(),
skillCosts, skillCosts.ToFrozenDictionary(),
templates, templates,
gendersByKey); gendersByKey.ToFrozenDictionary());
} }
private static ChargenTemplate ProjectTemplate(TemplateCG template) private static ChargenTemplate ProjectTemplate(TemplateCG template)
{ {
var normalSkills = new List<uint>(template.NormalSkills.Count); var normalSkills = new uint[template.NormalSkills.Count];
foreach (var skillId in template.NormalSkills) for (int i = 0; i < template.NormalSkills.Count; i++)
normalSkills.Add((uint)skillId); normalSkills[i] = (uint)template.NormalSkills[i];
var primarySkills = new List<uint>(template.PrimarySkills.Count); var primarySkills = new uint[template.PrimarySkills.Count];
foreach (var skillId in template.PrimarySkills) for (int i = 0; i < template.PrimarySkills.Count; i++)
primarySkills.Add((uint)skillId); primarySkills[i] = (uint)template.PrimarySkills[i];
return new ChargenTemplate( return new ChargenTemplate(
template.Name.Value, template.Name.Value,
@ -129,33 +172,41 @@ public static class ChargenTableReader
private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex) private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex)
{ {
var hairStyles = new List<ChargenHairStyle>(sex.HairStyles.Count); var hairStyles = new ChargenHairStyle[sex.HairStyles.Count];
foreach (HairStyleCG hair in sex.HairStyles) for (int i = 0; i < sex.HairStyles.Count; i++)
{ {
hairStyles.Add(new ChargenHairStyle( HairStyleCG hair = sex.HairStyles[i];
hairStyles[i] = new ChargenHairStyle(
hair.IconId.DataId, hair.IconId.DataId,
hair.Bald, hair.Bald,
hair.AlternateSetup, hair.AlternateSetup,
ProjectObjDesc(hair.ObjDesc))); ProjectObjDesc(hair.ObjDesc));
} }
var eyeStrips = new List<ChargenEyeStrip>(sex.EyeStrips.Count); var eyeStrips = new ChargenEyeStrip[sex.EyeStrips.Count];
foreach (EyeStripCG eye in sex.EyeStrips) for (int i = 0; i < sex.EyeStrips.Count; i++)
{ {
eyeStrips.Add(new ChargenEyeStrip( EyeStripCG eye = sex.EyeStrips[i];
eyeStrips[i] = new ChargenEyeStrip(
eye.IconId.DataId, eye.IconId.DataId,
eye.BaldIconId, eye.BaldIconId,
ProjectObjDesc(eye.ObjDesc), ProjectObjDesc(eye.ObjDesc),
ProjectObjDesc(eye.BaldObjDesc))); ProjectObjDesc(eye.BaldObjDesc));
} }
var noseStrips = new List<ChargenFaceStrip>(sex.NoseStrips.Count); var noseStrips = new ChargenFaceStrip[sex.NoseStrips.Count];
foreach (FaceStripCG strip in sex.NoseStrips) for (int i = 0; i < sex.NoseStrips.Count; i++)
noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); {
FaceStripCG strip = sex.NoseStrips[i];
noseStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc));
}
var mouthStrips = new List<ChargenFaceStrip>(sex.MouthStrips.Count); var mouthStrips = new ChargenFaceStrip[sex.MouthStrips.Count];
foreach (FaceStripCG strip in sex.MouthStrips) for (int i = 0; i < sex.MouthStrips.Count; i++)
mouthStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); {
FaceStripCG strip = sex.MouthStrips[i];
mouthStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc));
}
return new ChargenGenderOptions( return new ChargenGenderOptions(
genderKey, genderKey,
@ -170,9 +221,9 @@ public static class ChargenTableReader
sex.MotionTable.DataId, sex.MotionTable.DataId,
sex.CombatTable.DataId, sex.CombatTable.DataId,
ProjectObjDesc(sex.BaseObjDesc), ProjectObjDesc(sex.BaseObjDesc),
new List<uint>(sex.HairColors), sex.HairColors.ToArray(),
hairStyles, hairStyles,
new List<uint>(sex.EyeColors), sex.EyeColors.ToArray(),
eyeStrips, eyeStrips,
noseStrips, noseStrips,
mouthStrips, mouthStrips,
@ -180,35 +231,45 @@ public static class ChargenTableReader
ProjectGearList(sex.Shirts), ProjectGearList(sex.Shirts),
ProjectGearList(sex.Pants), ProjectGearList(sex.Pants),
ProjectGearList(sex.Footwear), ProjectGearList(sex.Footwear),
new List<uint>(sex.ClothingColors)); sex.ClothingColors.ToArray());
} }
private static List<ChargenGearOption> ProjectGearList(List<GearCG> gearList) private static ChargenGearOption[] ProjectGearList(List<GearCG> gearList)
{ {
var result = new List<ChargenGearOption>(gearList.Count); var result = new ChargenGearOption[gearList.Count];
foreach (GearCG gear in gearList) for (int i = 0; i < gearList.Count; i++)
result.Add(new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault)); {
GearCG gear = gearList[i];
result[i] = new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault);
}
return result; return result;
} }
private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc) private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc)
{ {
var subPalettes = new List<ChargenSubPalette>(objDesc.SubPalettes.Count); var subPalettes = new ChargenSubPalette[objDesc.SubPalettes.Count];
foreach (SubPalette sub in objDesc.SubPalettes) for (int i = 0; i < objDesc.SubPalettes.Count; i++)
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( SubPalette sub = objDesc.SubPalettes[i];
change.PartIndex, subPalettes[i] = new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors);
change.OldTexture.DataId,
change.NewTexture.DataId));
} }
var animPartChanges = new List<ChargenAnimPartChange>(objDesc.AnimPartChanges.Count); var textureChanges = new ChargenTextureChange[objDesc.TextureChanges.Count];
foreach (AnimationPartChange change in objDesc.AnimPartChanges) for (int i = 0; i < objDesc.TextureChanges.Count; i++)
animPartChanges.Add(new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId)); {
TextureMapChange change = objDesc.TextureChanges[i];
textureChanges[i] = new ChargenTextureChange(
change.PartIndex,
change.OldTexture.DataId,
change.NewTexture.DataId);
}
var animPartChanges = new ChargenAnimPartChange[objDesc.AnimPartChanges.Count];
for (int i = 0; i < objDesc.AnimPartChanges.Count; i++)
{
AnimationPartChange change = objDesc.AnimPartChanges[i];
animPartChanges[i] = new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId);
}
return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges); return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges);
} }

View file

@ -36,11 +36,22 @@ public sealed record ChargenGenderOptions(
IReadOnlyList<uint> ClothingColors) IReadOnlyList<uint> ClothingColors)
{ {
/// <summary> /// <summary>
/// Every appearance option list is non-empty for a playable gender — /// True when AT LEAST ONE of the eight lists below is non-empty (an OR
/// CC1's installed-DAT gate asserts this holds for at least one gender /// across all eight, not a per-list guarantee). Deliberately omits
/// per heritage. A gender missing an option list can still be a valid /// <see cref="HairColors"/>, <see cref="EyeColors"/>, and
/// data shape (e.g. a bald-only heritage's hair styles), so callers /// <see cref="ClothingColors"/> — CC6's color-wheel controls need those
/// building UI should still defend against empty lists individually. /// three independently of this property and must check them
/// separately. CC1's installed-DAT gate
/// (<c>ChargenTableReaderInstalledDatTests.InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions</c>)
/// only proves "at least one gender per heritage has at least one
/// non-empty list among these eight" — it does NOT prove every list is
/// non-empty for every gender of every heritage, and it does not cover
/// the three color lists at all; see
/// <c>ChargenTableReaderInstalledDatTests.InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness</c>
/// for the per-list installed-DAT reality. A gender missing an
/// individual option list can still be a valid data shape (e.g. a
/// bald-only heritage's hair styles), so callers building UI must still
/// defend against empty lists individually.
/// </summary> /// </summary>
public bool HasAnyAppearanceOptions => public bool HasAnyAppearanceOptions =>
HairStyles.Count > 0 HairStyles.Count > 0

View file

@ -1,34 +1,52 @@
using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
namespace AcDream.Core.CharGen; namespace AcDream.Core.CharGen;
/// <summary> /// <summary>
/// Top-level, presentation-free, immutable projection of retail's CharGen /// Top-level, presentation-free, immutable projection of retail's CharGen
/// DAT table (portal.dat 0x0E000002, <c>ACCharGenData::Serialize @ /// DAT table (portal.dat 0x0E000002, <c>ACCharGenData::Serialize @
/// 0x005C36D0</c>). Production builds create this from the installed DAT /// 0x005C36D0</c>) PLUS the global SkillTable (portal.dat 0x0E000004) that
/// through Content's <c>AcDream.Content.CharGen.ChargenTableReader.Load</c>; /// retail falls back to when a heritage's own skill-cost list has no entry
/// this type itself has no DAT/Chorizite dependency so it is safe to hand /// for a given skill id. Retail's <c>ACCharGenData::GetSkillTrainedCost @
/// to plugin-facing or test code. Everything a "typed chargen options /// 0x005C26D0</c> / <c>GetSkillSpecializedCost @ 0x005C27D0</c> both scan
/// model" needs — starter areas, heritages, templates, per-gender /// the heritage's own list first and, on a miss (or an empty list), fall
/// appearance option lists, skill costs — hangs off this one root. /// through to <c>DBCache::GetFromEnumStatic(4, 2, 0x10000004)</c> — the
/// SAME global SkillTable every other skill-cost lookup in the client
/// reads — rather than treating the skill as free or invalid. See
/// <see cref="GlobalSkillCostsBySkillId"/>. 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. Every collection is frozen/immutable at construction (a
/// caller cannot downcast an <see cref="IReadOnlyDictionary{TKey,TValue}"/>
/// back to a mutable <see cref="Dictionary{TKey,TValue}"/> and mutate this
/// process-shared model out from under other readers). Everything a "typed
/// chargen options model" needs — starter areas, heritages, templates,
/// per-gender appearance option lists, skill costs — hangs off this one
/// root.
/// </summary> /// </summary>
public sealed record ChargenOptions( public sealed record ChargenOptions(
IReadOnlyList<ChargenStarterArea> StarterAreas, IReadOnlyList<ChargenStarterArea> StarterAreas,
IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById) IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById,
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId)
{ {
public static ChargenOptions Empty { get; } = new( public static ChargenOptions Empty { get; } = new(
Array.Empty<ChargenStarterArea>(), Array.Empty<ChargenStarterArea>(),
new Dictionary<uint, ChargenHeritageOptions>()); FrozenDictionary<uint, ChargenHeritageOptions>.Empty,
FrozenDictionary<uint, ChargenSkillCost>.Empty);
public bool TryGetHeritage(uint heritageId, out ChargenHeritageOptions heritage) => public bool TryGetHeritage(uint heritageId, [MaybeNullWhen(false)] out ChargenHeritageOptions heritage) =>
HeritagesById.TryGetValue(heritageId, out heritage!); HeritagesById.TryGetValue(heritageId, out heritage);
public bool TryGetStarterArea(int index, out ChargenStarterArea area) public bool TryGetStarterArea(int index, [MaybeNullWhen(false)] out ChargenStarterArea area)
{ {
if (index >= 0 && index < StarterAreas.Count) if (index >= 0 && index < StarterAreas.Count)
{ {
area = StarterAreas[index]; area = StarterAreas[index];
return true; return true;
} }
area = null!; area = default;
return false; return false;
} }
} }

View file

@ -7,23 +7,35 @@ namespace AcDream.Core.CharGen;
/// for Specialized (never both), and subtract the total from the heritage's /// for Specialized (never both), and subtract the total from the heritage's
/// <c>SkillCredits</c> budget. No DAT/DatReaderWriter dependency — callers /// <c>SkillCredits</c> budget. No DAT/DatReaderWriter dependency — callers
/// (CC3's Runtime owner) pass in the heritage's already-projected /// (CC3's Runtime owner) pass in the heritage's already-projected
/// <see cref="ChargenSkillCost"/> lookup. /// <see cref="ChargenSkillCost"/> lookup plus the global SkillTable
/// fallback lookup (<see cref="ChargenOptions.GlobalSkillCostsBySkillId"/>).
/// </summary> /// </summary>
public static class ChargenSkillCreditMath public static class ChargenSkillCreditMath
{ {
/// <summary> /// <summary>
/// Total credits spent across every Trained/Specialized skill in /// Total credits spent across every Trained/Specialized skill in
/// <paramref name="advancement"/>. A skill with no cost entry for the /// <paramref name="advancement"/>. Retail's cost lookup
/// active heritage (i.e. the heritage doesn't offer it) is skipped — /// (<c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
/// retail's own UI can never reach that state, so this is defensive /// <c>GetSkillSpecializedCost @ 0x005C27D0</c>) is TWO-TIERED: it scans
/// rather than a documented retail behavior. /// the active heritage's own <paramref name="costsBySkillId"/> list
/// first, and only on a miss falls through to the global SkillTable
/// (<paramref name="globalCostsBySkillId"/>, portal.dat 0x0E000004 via
/// <c>DBCache::GetFromEnumStatic(4, 2, 0x10000004)</c>). A skill id
/// missing from BOTH tiers is retail's -1/"no cost" case; this port
/// treats that as uncostable and skips it (mirrors ACE's identical
/// precedence in
/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-196,
/// which seeds from <c>SkillTable.SkillBaseHash[i]</c> and then applies
/// a heritage override).
/// </summary> /// </summary>
public static int ComputeSpent( public static int ComputeSpent(
ChargenSkillAdvancementSet advancement, ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId) IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId,
IReadOnlyDictionary<uint, ChargenSkillCost> globalCostsBySkillId)
{ {
ArgumentNullException.ThrowIfNull(advancement); ArgumentNullException.ThrowIfNull(advancement);
ArgumentNullException.ThrowIfNull(costsBySkillId); ArgumentNullException.ThrowIfNull(costsBySkillId);
ArgumentNullException.ThrowIfNull(globalCostsBySkillId);
int spent = 0; int spent = 0;
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
@ -35,8 +47,11 @@ public static class ChargenSkillCreditMath
continue; continue;
} }
if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)) if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)
&& !globalCostsBySkillId.TryGetValue(skillId, out cost))
{
continue; continue;
}
spent += cls == ChargenSkillAdvancementClass.Specialized spent += cls == ChargenSkillAdvancementClass.Specialized
? cost.PrimaryCost ? cost.PrimaryCost
@ -57,6 +72,7 @@ public static class ChargenSkillCreditMath
public static int RemainingCredits( public static int RemainingCredits(
uint totalSkillCredits, uint totalSkillCredits,
ChargenSkillAdvancementSet advancement, ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId) => IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId,
checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId); IReadOnlyDictionary<uint, ChargenSkillCost> globalCostsBySkillId) =>
checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId, globalCostsBySkillId);
} }

View file

@ -1,14 +1,36 @@
namespace AcDream.Core.CharGen; namespace AcDream.Core.CharGen;
/// <summary> /// <summary>
/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage, /// One profession preset offered on the Profession page. Retail's
/// Wayfarer, Soldier, ...) offered on the Profession page /// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c> switches on
/// (<c>UpdateProfession @ 0x00478d1c/0x0047a4a4</c>-adjacent per the /// <c>CharGenState.template_</c> and resolves BOTH the button to highlight
/// campaign plan's recon; template buttons 0x100003da..df). "Custom" is NOT /// AND the description string from the SAME 0..6 index: 0 → button
/// one of these — it is retail's own free-attribute-assignment mode /// 0x100003d9 / <c>ID_CharGen_CustomText</c> ("Custom"), 1 → 0x100003da
/// selected by button 0x100003d9 and has no <see cref="ChargenTemplate"/> /// (Bow Hunter), 2 → 0x100003df (Swashbuckler), 3 → 0x100003db (Life
/// entry. Retail schema: <c>Template_CG::Serialize @ 0x005C0450</c> /// Caster), 4 → 0x100003dc (War Caster), 5 → 0x100003dd (Wayfarer), 6 →
/// (ACE's <c>TemplateCG.Unpack</c> mirrors the same field order). /// 0x100003de (Soldier). "Custom" is therefore template index 0, NOT a
/// special UI-only mode with no data — it is a real <see cref="ChargenTemplate"/>
/// entry (confirmed against the installed DAT: each human heritage ships
/// this row as "Adventurer", sitting at the attribute floor rather than
/// spending the full credit budget). The seven profession buttons all wire
/// to <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> (N =
/// 0..6, the second arg a "commit" flag); <c>SetTemplate</c> writes
/// <c>template_ = N</c> and, because <c>N != 0xffffffff</c> (retail's
/// no-template sentinel — never sent by any button), immediately calls
/// <c>CharGenState::ApplyTemplate @ 0x005C5080</c>, which re-reads that
/// template row's six attributes and skill list and re-applies them.
/// Selecting "Custom" therefore RESETS the attribute sliders and skill
/// picks to the Adventurer row's floor spread rather than leaving the
/// current values untouched — retail's own Custom-button handler
/// (<c>gmCGProfessionPage::ListenToElementMessage</c> case 0xed) calls
/// <c>SetTemplate(state, 0, 1)</c> then <c>gmCGProfessionPage::UpdateToDefaultAttributes
/// @ 0x00482860</c> to refresh the slider UI to match. ACE's
/// <c>PlayerFactory.CreatePlayer</c> confirms the same indexing server-side:
/// it indexes <c>heritageGroup.Templates[characterCreateInfo.TemplateOption]</c>
/// with no special-cased "no template" branch
/// (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138).
/// Retail schema: <c>Template_CG::Serialize @ 0x005C0450</c> (ACE's
/// <c>TemplateCG.Unpack</c> mirrors the same field order).
/// </summary> /// </summary>
public sealed record ChargenTemplate( public sealed record ChargenTemplate(
string Name, string Name,

View file

@ -59,22 +59,22 @@ public sealed class ChargenTableReaderInstalledDatTests
using var adapter = new DatCollectionAdapter(dats); using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter); ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian));
Assert.Equal("Aluvian", aluvian.Name); Assert.Equal("Aluvian", aluvian!.Name);
Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim)); Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions? gharundim));
Assert.Equal("Gharu'ndim", gharundim.Name); Assert.Equal("Gharu'ndim", gharundim!.Name);
Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho)); Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions? sho));
Assert.Equal("Sho", sho.Name); Assert.Equal("Sho", sho!.Name);
Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian)); Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions? viamontian));
Assert.Equal("Viamontian", viamontian.Name); Assert.Equal("Viamontian", viamontian!.Name);
Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi)); Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions? olthoi));
Assert.True(olthoi.IsOlthoi); Assert.True(olthoi!.IsOlthoi);
Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid)); Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions? olthoiAcid));
Assert.True(olthoiAcid.IsOlthoi); Assert.True(olthoiAcid!.IsOlthoi);
} }
[Fact] [Fact]
@ -178,9 +178,9 @@ public sealed class ChargenTableReaderInstalledDatTests
{ {
indicesChecked++; indicesChecked++;
Assert.True( Assert.True(
options.TryGetStarterArea(index, out ChargenStarterArea area), options.TryGetStarterArea(index, out ChargenStarterArea? area),
$"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list."); $"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list.");
Assert.False(string.IsNullOrEmpty(area.Name)); Assert.False(string.IsNullOrEmpty(area!.Name));
} }
} }
@ -201,8 +201,8 @@ public sealed class ChargenTableReaderInstalledDatTests
using var adapter = new DatCollectionAdapter(dats); using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter); ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian));
Assert.NotEmpty(aluvian.SkillCostsBySkillId); Assert.NotEmpty(aluvian!.SkillCostsBySkillId);
foreach (var pair in aluvian.SkillCostsBySkillId) foreach (var pair in aluvian.SkillCostsBySkillId)
{ {
Assert.Equal(pair.Key, pair.Value.SkillId); Assert.Equal(pair.Key, pair.Value.SkillId);
@ -211,4 +211,134 @@ public sealed class ChargenTableReaderInstalledDatTests
Assert.True(pair.Value.PrimaryCost >= 0); Assert.True(pair.Value.PrimaryCost >= 0);
} }
} }
/// <summary>
/// F2's completeness assertion: records EXACTLY what the installed DAT's
/// heritage-vs-global skill-cost coverage looks like (found
/// 2026-08-15), so <see cref="ChargenSkillCreditMath"/>'s two-tier
/// fallback (see <see cref="AcDream.Core.CharGen.ChargenOptions.GlobalSkillCostsBySkillId"/>)
/// has a real regression gate instead of only synthetic unit fixtures.
/// Findings: the global SkillTable (portal.dat 0x0E000004) prices 38 of
/// the 54 advancement skill ids; EVERY one of the 13 installed
/// heritages ships EXACTLY one heritage-specific skill-cost override
/// (never zero, never more), and that one override always ALSO has a
/// global entry — no heritage in this DAT relies on a heritage-only
/// price the global table doesn't know about. The remaining 16 skill
/// ids are absent from BOTH tiers in every heritage — retail's genuine
/// -1/"no cost" case (non-advancement or deprecated skill slots such as
/// the pre-Skill-DID-remap gaps). If a future DAT drop changes any of
/// this shape, this test should fail and get updated with the new
/// reality, not be loosened silently.
/// </summary>
[Fact]
public void InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet()
{
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.Equal(38, options.GlobalSkillCostsBySkillId.Count);
// Retail's -1/"no cost" case: absent from BOTH the global SkillTable
// AND every heritage's own list in the installed DAT.
uint[] expectedUncostableSkillIds =
[1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 17, 25, 26, 42, 53];
Assert.NotEmpty(options.HeritagesById);
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
Assert.Single(heritage.SkillCostsBySkillId);
var uncostable = new List<uint>();
foreach (KeyValuePair<uint, ChargenSkillCost> pair in heritage.SkillCostsBySkillId)
{
Assert.True(
options.GlobalSkillCostsBySkillId.ContainsKey(pair.Key),
$"{heritage.Name}: skill {pair.Key} is heritage-only with no global fallback entry — " +
"new ground truth found; update this test's recorded reality.");
}
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
bool inHeritage = heritage.SkillCostsBySkillId.ContainsKey(skillId);
bool inGlobal = options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
if (!inHeritage && !inGlobal)
uncostable.Add(skillId);
}
Assert.Equal(expectedUncostableSkillIds, uncostable.OrderBy(id => id));
}
}
/// <summary>
/// F5's strengthened installed-DAT gate. <see cref="ChargenGenderOptions.HasAnyAppearanceOptions"/>
/// only proves an OR across eight lists for at least one gender per
/// heritage (see <see cref="InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions"/>
/// above). This test records the STRONGER fact actually found in the
/// installed DAT (2026-08-15): every one of the eight
/// <c>HasAnyAppearanceOptions</c> lists, PLUS the three color lists it
/// deliberately excludes (<see cref="ChargenGenderOptions.HairColors"/>,
/// <see cref="ChargenGenderOptions.EyeColors"/>,
/// <see cref="ChargenGenderOptions.ClothingColors"/>), is non-empty for
/// EVERY gender of EVERY one of the 13 heritages — even the sparse ones
/// (Gear Knight and both Olthoi variants ship as few as 1-2 entries per
/// list, but never 0). This is a fact about today's data, not a
/// structural guarantee the DAT format enforces — the type's own doc
/// comment still warns callers to defend against an empty list
/// individually, and a future DAT could reintroduce a gap (e.g. a
/// bald-only heritage's hair styles). If this test starts failing on a
/// new DAT drop, that is this gate doing its job, not a reader bug.
/// </summary>
[Fact]
public void InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness()
{
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);
var emptyLists = new List<string>();
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach (KeyValuePair<int, ChargenGenderOptions> genderPair in heritage.GendersByKey)
{
ChargenGenderOptions gender = genderPair.Value;
string label = $"{heritage.Name}/{gender.Name} (heritage={heritage.HeritageId}, gender={genderPair.Key})";
void RecordIfEmpty(string listName, int count)
{
if (count == 0)
emptyLists.Add($"{label}: {listName}");
}
RecordIfEmpty(nameof(gender.HairStyles), gender.HairStyles.Count);
RecordIfEmpty(nameof(gender.EyeStrips), gender.EyeStrips.Count);
RecordIfEmpty(nameof(gender.NoseStrips), gender.NoseStrips.Count);
RecordIfEmpty(nameof(gender.MouthStrips), gender.MouthStrips.Count);
RecordIfEmpty(nameof(gender.Headgears), gender.Headgears.Count);
RecordIfEmpty(nameof(gender.Shirts), gender.Shirts.Count);
RecordIfEmpty(nameof(gender.Pants), gender.Pants.Count);
RecordIfEmpty(nameof(gender.Footwear), gender.Footwear.Count);
RecordIfEmpty(nameof(gender.HairColors), gender.HairColors.Count);
RecordIfEmpty(nameof(gender.EyeColors), gender.EyeColors.Count);
RecordIfEmpty(nameof(gender.ClothingColors), gender.ClothingColors.Count);
}
}
Assert.Empty(emptyLists);
}
} }

View file

@ -203,8 +203,8 @@ public sealed class ChargenTableReaderTests
{ {
ChargenOptions options = ChargenTableReader.Project(BuildFixture()); ChargenOptions options = ChargenTableReader.Project(BuildFixture());
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage)); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
Assert.Equal("Aluvian", heritage.Name); Assert.Equal("Aluvian", heritage!.Name);
Assert.Equal(0x06000001u, heritage.IconId); Assert.Equal(0x06000001u, heritage.IconId);
Assert.Equal(0x02000010u, heritage.SetupId); Assert.Equal(0x02000010u, heritage.SetupId);
Assert.Equal(0x02000020u, heritage.EnvironmentSetupId); Assert.Equal(0x02000020u, heritage.EnvironmentSetupId);
@ -219,10 +219,10 @@ public sealed class ChargenTableReaderTests
public void Project_MapsSkillCostsKeyedByRawSkillId() public void Project_MapsSkillCostsKeyedByRawSkillId()
{ {
ChargenOptions options = ChargenTableReader.Project(BuildFixture()); ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe; uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe;
Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost)); Assert.True(heritage!.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost));
Assert.Equal(axeId, cost.SkillId); Assert.Equal(axeId, cost.SkillId);
Assert.Equal(4, cost.NormalCost); Assert.Equal(4, cost.NormalCost);
Assert.Equal(12, cost.PrimaryCost); Assert.Equal(12, cost.PrimaryCost);
@ -232,9 +232,9 @@ public sealed class ChargenTableReaderTests
public void Project_MapsTemplateAttributesAndSkillLists() public void Project_MapsTemplateAttributesAndSkillLists()
{ {
ChargenOptions options = ChargenTableReader.Project(BuildFixture()); ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
ChargenTemplate template = Assert.Single(heritage.Templates); ChargenTemplate template = Assert.Single(heritage!.Templates);
Assert.Equal("Soldier", template.Name); Assert.Equal("Soldier", template.Name);
Assert.Equal(42u, template.TitleStringId); Assert.Equal(42u, template.TitleStringId);
Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes); Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes);
@ -247,9 +247,9 @@ public sealed class ChargenTableReaderTests
public void Project_MapsGenderScalarsAndOptionLists() public void Project_MapsGenderScalarsAndOptionLists()
{ {
ChargenOptions options = ChargenTableReader.Project(BuildFixture()); ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender)); Assert.True(heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender));
Assert.Equal("Male", gender!.Name); Assert.Equal("Male", gender!.Name);
Assert.Equal(1000000u, gender.Scale); Assert.Equal(1000000u, gender.Scale);
Assert.Equal(0x02000030u, gender.SetupId); Assert.Equal(0x02000030u, gender.SetupId);
@ -288,8 +288,8 @@ public sealed class ChargenTableReaderTests
public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges() public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges()
{ {
ChargenOptions options = ChargenTableReader.Project(BuildFixture()); ChargenOptions options = ChargenTableReader.Project(BuildFixture());
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender); heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender);
ChargenObjDesc baseDesc = gender!.BaseObjDesc; ChargenObjDesc baseDesc = gender!.BaseObjDesc;
Assert.Equal(0x04000002u, baseDesc.PaletteId); Assert.Equal(0x04000002u, baseDesc.PaletteId);

View file

@ -0,0 +1,150 @@
using System.Reflection;
using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Pins the CC1 no-leak contract with a real assertion rather than a doc
/// comment. AcDream.Core references Chorizite.DatReaderWriter (for
/// <c>TextureHelpers</c>), so "no Chorizite type on any public CharGen
/// member" was convention only until this guard exists — nothing stopped a
/// future edit from putting e.g. a <c>DatReaderWriter.Enums.SkillId</c>
/// directly on a public property. This test walks every public type in the
/// <c>AcDream.Core.CharGen</c> namespace and asserts that no public
/// property, indexer, constructor parameter, or method return/parameter
/// type — nor any of their generic type arguments, recursively — comes
/// from the <c>DatReaderWriter</c> assembly or any assembly whose name
/// starts with <c>Chorizite</c>.
/// </summary>
public sealed class ChargenNoChoriziteLeakTests
{
[Fact]
public void PublicCharGenSurface_NeverExposesChoriziteOrDatReaderWriterTypes()
{
Assembly coreAssembly = typeof(ChargenOptions).Assembly;
Type[] publicCharGenTypes = coreAssembly.GetTypes()
.Where(t => t.IsPublic && t.Namespace == "AcDream.Core.CharGen")
.ToArray();
// Guards the guard: if the namespace ever ends up empty (e.g. a
// rename), this test must fail loudly rather than vacuously pass.
Assert.True(
publicCharGenTypes.Length > 5,
$"Expected multiple public types in AcDream.Core.CharGen, found {publicCharGenTypes.Length}. " +
"Did the namespace get renamed or moved?");
var offenders = new List<string>();
foreach (Type type in publicCharGenTypes)
{
foreach (PropertyInfo property in type.GetProperties(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
{
CheckSite(property.PropertyType, $"{type.FullName}.{property.Name} (property)", offenders);
foreach (ParameterInfo indexParam in property.GetIndexParameters())
{
CheckSite(
indexParam.ParameterType,
$"{type.FullName}.{property.Name}[{indexParam.Name}] (indexer parameter)",
offenders);
}
}
foreach (ConstructorInfo ctor in type.GetConstructors(
BindingFlags.Public | BindingFlags.Instance))
{
foreach (ParameterInfo param in ctor.GetParameters())
{
CheckSite(
param.ParameterType,
$"{type.FullName}..ctor({param.Name}) (constructor parameter)",
offenders);
}
}
foreach (MethodInfo method in type.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
{
// Property accessors, operators, and other compiler-emitted
// members are IsSpecialName; the property/indexer loop above
// already covers accessor types directly.
if (method.IsSpecialName)
continue;
CheckSite(method.ReturnType, $"{type.FullName}.{method.Name} (return type)", offenders);
foreach (ParameterInfo param in method.GetParameters())
{
CheckSite(
param.ParameterType,
$"{type.FullName}.{method.Name}({param.Name}) (method parameter)",
offenders);
}
}
}
Assert.True(
offenders.Count == 0,
"A Chorizite/DatReaderWriter type leaked onto a public AcDream.Core.CharGen member:\n"
+ string.Join('\n', offenders));
}
private static void CheckSite(Type type, string site, List<string> offenders)
{
foreach (Type candidate in FlattenTypeArguments(type))
{
string? assemblyName = candidate.Assembly.GetName().Name;
if (assemblyName is null)
continue;
bool isForbidden =
assemblyName.Equals("DatReaderWriter", StringComparison.OrdinalIgnoreCase)
|| assemblyName.StartsWith("Chorizite", StringComparison.OrdinalIgnoreCase);
if (isForbidden)
offenders.Add($"{site}: {candidate.FullName} (assembly '{assemblyName}')");
}
}
/// <summary>Yields <paramref name="type"/> itself plus every generic
/// type argument, array element type, and by-ref (out/ref parameter)
/// element type, recursively — so e.g. <c>out IReadOnlyDictionary
/// &lt;uint, ChargenSkillCost&gt;</c> is checked against
/// <c>ChargenSkillCost</c>, not just the outer dictionary type.</summary>
private static IEnumerable<Type> FlattenTypeArguments(Type type)
{
yield return type;
if (type.IsByRef || type.IsPointer)
{
Type? element = type.GetElementType();
if (element is not null)
{
foreach (Type inner in FlattenTypeArguments(element))
yield return inner;
}
yield break;
}
if (type.IsArray)
{
Type? element = type.GetElementType();
if (element is not null)
{
foreach (Type inner in FlattenTypeArguments(element))
yield return inner;
}
yield break;
}
if (type.IsGenericType)
{
foreach (Type argument in type.GetGenericArguments())
{
foreach (Type inner in FlattenTypeArguments(argument))
yield return inner;
}
}
}
}

View file

@ -37,9 +37,10 @@ public sealed class ChargenOptionsTests
var aluvian = MakeHeritage(1u, "Aluvian"); var aluvian = MakeHeritage(1u, "Aluvian");
var options = new ChargenOptions( var options = new ChargenOptions(
[], [],
new Dictionary<uint, ChargenHeritageOptions> { [1u] = aluvian }); new Dictionary<uint, ChargenHeritageOptions> { [1u] = aluvian },
new Dictionary<uint, ChargenSkillCost>());
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found)); Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? found));
Assert.Same(aluvian, found); Assert.Same(aluvian, found);
} }
@ -55,9 +56,12 @@ public sealed class ChargenOptionsTests
public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange() public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange()
{ {
var area = new ChargenStarterArea(0, "Holtburg", []); var area = new ChargenStarterArea(0, "Holtburg", []);
var options = new ChargenOptions([area], new Dictionary<uint, ChargenHeritageOptions>()); var options = new ChargenOptions(
[area],
new Dictionary<uint, ChargenHeritageOptions>(),
new Dictionary<uint, ChargenSkillCost>());
Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found)); Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea? found));
Assert.Same(area, found); Assert.Same(area, found);
Assert.False(options.TryGetStarterArea(1, out _)); Assert.False(options.TryGetStarterArea(1, out _));
Assert.False(options.TryGetStarterArea(-1, out _)); Assert.False(options.TryGetStarterArea(-1, out _));

View file

@ -5,7 +5,9 @@ namespace AcDream.Core.Tests.CharGen;
/// <summary> /// <summary>
/// Tests for <see cref="ChargenSkillCreditMath"/> — retail's skill-credit /// Tests for <see cref="ChargenSkillCreditMath"/> — retail's skill-credit
/// spend port (<c>CharGenState::UpdateRemainingSkillCredits @ /// spend port (<c>CharGenState::UpdateRemainingSkillCredits @
/// 0x005C37C0</c>). /// 0x005C37C0</c>), including the two-tier heritage/global cost lookup
/// (<c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
/// <c>GetSkillSpecializedCost @ 0x005C27D0</c>).
/// </summary> /// </summary>
public sealed class ChargenSkillCreditMathTests public sealed class ChargenSkillCreditMathTests
{ {
@ -17,6 +19,10 @@ public sealed class ChargenSkillCreditMathTests
// Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it. // Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it.
}; };
/// <summary>Empty global fallback — tests that only exercise the
/// heritage tier pass this so a miss is a genuine both-tiers miss.</summary>
private static readonly Dictionary<uint, ChargenSkillCost> NoGlobalCosts = new();
[Fact] [Fact]
public void ComputeSpent_IgnoresInactiveAndUntrainedSkills() public void ComputeSpent_IgnoresInactiveAndUntrainedSkills()
{ {
@ -26,7 +32,7 @@ public sealed class ChargenSkillCreditMathTests
[11u] = ChargenSkillAdvancementClass.Untrained, [11u] = ChargenSkillAdvancementClass.Untrained,
}; };
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
} }
[Fact] [Fact]
@ -34,7 +40,7 @@ public sealed class ChargenSkillCreditMathTests
{ {
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
} }
[Fact] [Fact]
@ -44,7 +50,7 @@ public sealed class ChargenSkillCreditMathTests
// both — PrimaryCost is the TOTAL cost to reach Specialized. // both — PrimaryCost is the TOTAL cost to reach Specialized.
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized }; var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized };
Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
} }
[Fact] [Fact]
@ -57,15 +63,67 @@ public sealed class ChargenSkillCreditMathTests
[24u] = ChargenSkillAdvancementClass.Trained, // 1 [24u] = ChargenSkillAdvancementClass.Trained, // 1
}; };
Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
} }
[Fact] [Fact]
public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively() public void ComputeSpent_SkillWithNoCostEntryInEitherTierIsSkipped()
{ {
// Retail's -1/"no cost" case: absent from BOTH the heritage list AND
// the global SkillTable (ACCharGenData::GetSkillTrainedCost @
// 0x005C26D0 returns 0xffffffff when even the global lookup misses).
var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained }; var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained };
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
}
[Fact]
public void ComputeSpent_HeritageCostWinsOverGlobalCostWhenBothPresent()
{
// Skill id 1 (Axe) is priced differently by the heritage list and the
// global SkillTable — retail's lookup checks the heritage's own list
// FIRST and never consults the global table when the heritage
// provides its own entry.
var globalCosts = new Dictionary<uint, ChargenSkillCost>
{
[1u] = new ChargenSkillCost(1u, NormalCost: 999, PrimaryCost: 999),
};
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
}
[Fact]
public void ComputeSpent_FallsBackToGlobalCostWhenHeritageListHasNoEntry()
{
// Skill id 2 (Bow) is absent from the heritage's own list but present
// in the global SkillTable fallback — retail charges the global cost
// rather than treating the skill as free.
var globalCosts = new Dictionary<uint, ChargenSkillCost>
{
[2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18),
};
var advancement = new ChargenSkillAdvancementSet
{
[2u] = ChargenSkillAdvancementClass.Trained,
};
Assert.Equal(6, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
}
[Fact]
public void ComputeSpent_FallsBackToGlobalCostForSpecializedSkillsToo()
{
var globalCosts = new Dictionary<uint, ChargenSkillCost>
{
[2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18),
};
var advancement = new ChargenSkillAdvancementSet
{
[2u] = ChargenSkillAdvancementClass.Specialized,
};
Assert.Equal(18, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
} }
[Fact] [Fact]
@ -77,10 +135,10 @@ public sealed class ChargenSkillCreditMathTests
[11u] = ChargenSkillAdvancementClass.Specialized, [11u] = ChargenSkillAdvancementClass.Specialized,
}; };
Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs)); Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs, NoGlobalCosts));
// Retail's Finish gate never checks remainingSkillCredits, so // Retail's Finish gate never checks remainingSkillCredits, so
// overspending relative to the (small, synthetic) budget below is a // overspending relative to the (small, synthetic) budget below is a
// representable state, not a thrown exception. // representable state, not a thrown exception.
Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs)); Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs, NoGlobalCosts));
} }
} }