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

@ -1,22 +1,30 @@
using System.Collections.Frozen;
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;
using DatSkillTable = DatReaderWriter.DBObjs.SkillTable;
namespace AcDream.Content.CharGen;
/// <summary>
/// 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.
/// 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
/// <c>ACE.DatLoader.FileTypes.CharGen</c> +
/// <c>ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG</c> loaders,
/// which unpack the identical field order from the same DAT bytes.
/// 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>
public static class ChargenTableReader
{
@ -24,48 +32,83 @@ public static class ChargenTableReader
/// <c>ACE.DatLoader.FileTypes.CharGen.FILE_ID</c>).</summary>
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>
/// Loads and projects the installed CharGen table. Returns
/// <see cref="ChargenOptions.Empty"/> if the table is missing from the
/// supplied dat source (mirrors <c>MagicCatalog</c>'s tolerance for a
/// missing optional table — callers that require the table present
/// should check <c>HeritagesById.Count</c> themselves).
/// Loads and projects the installed CharGen table plus the global
/// SkillTable fallback. Returns <see cref="ChargenOptions.Empty"/> if
/// the CharGen table is missing from the supplied dat source (mirrors
/// <c>MagicCatalog</c>'s tolerance for a missing optional table —
/// callers that require the table present should check
/// <c>HeritagesById.Count</c> themselves). 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>
public static ChargenOptions Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
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
/// from <see cref="Load"/> so tests can exercise it against
/// hand-built <see cref="DatCharGen"/> fixtures without a live DAT.</summary>
public static ChargenOptions Project(DatCharGen table)
/// hand-built <see cref="DatCharGen"/> fixtures without a live DAT.
/// <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);
var starterAreas = new List<ChargenStarterArea>(table.StartingAreas.Count);
var starterAreas = new ChargenStarterArea[table.StartingAreas.Count];
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);
foreach (KeyValuePair<uint, HeritageGroupCG> pair in table.HeritageGroups)
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)
{
var locations = new List<ChargenPosition>(area.Locations.Count);
foreach (Position position in area.Locations)
var locations = new ChargenPosition[area.Locations.Count];
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.Frame.Origin,
position.Frame.Orientation));
position.Frame.Orientation);
}
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);
}
var templates = new List<ChargenTemplate>(cg.Templates.Count);
foreach (TemplateCG template in cg.Templates)
templates.Add(ProjectTemplate(template));
var templates = new ChargenTemplate[cg.Templates.Count];
for (int i = 0; i < cg.Templates.Count; i++)
templates[i] = ProjectTemplate(cg.Templates[i]);
var gendersByKey = new Dictionary<int, ChargenGenderOptions>(cg.Genders.Count);
foreach (KeyValuePair<int, SexCG> pair in cg.Genders)
@ -95,22 +138,22 @@ public static class ChargenTableReader
cg.EnvironmentSetupId.DataId,
cg.AttributeCredits,
cg.SkillCredits,
new List<int>(cg.PrimaryStartAreas),
new List<int>(cg.SecondaryStartAreas),
skillCosts,
cg.PrimaryStartAreas.ToArray(),
cg.SecondaryStartAreas.ToArray(),
skillCosts.ToFrozenDictionary(),
templates,
gendersByKey);
gendersByKey.ToFrozenDictionary());
}
private static ChargenTemplate ProjectTemplate(TemplateCG template)
{
var normalSkills = new List<uint>(template.NormalSkills.Count);
foreach (var skillId in template.NormalSkills)
normalSkills.Add((uint)skillId);
var normalSkills = new uint[template.NormalSkills.Count];
for (int i = 0; i < template.NormalSkills.Count; i++)
normalSkills[i] = (uint)template.NormalSkills[i];
var primarySkills = new List<uint>(template.PrimarySkills.Count);
foreach (var skillId in template.PrimarySkills)
primarySkills.Add((uint)skillId);
var primarySkills = new uint[template.PrimarySkills.Count];
for (int i = 0; i < template.PrimarySkills.Count; i++)
primarySkills[i] = (uint)template.PrimarySkills[i];
return new ChargenTemplate(
template.Name.Value,
@ -129,33 +172,41 @@ public static class ChargenTableReader
private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex)
{
var hairStyles = new List<ChargenHairStyle>(sex.HairStyles.Count);
foreach (HairStyleCG hair in sex.HairStyles)
var hairStyles = new ChargenHairStyle[sex.HairStyles.Count];
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.Bald,
hair.AlternateSetup,
ProjectObjDesc(hair.ObjDesc)));
ProjectObjDesc(hair.ObjDesc));
}
var eyeStrips = new List<ChargenEyeStrip>(sex.EyeStrips.Count);
foreach (EyeStripCG eye in sex.EyeStrips)
var eyeStrips = new ChargenEyeStrip[sex.EyeStrips.Count];
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.BaldIconId,
ProjectObjDesc(eye.ObjDesc),
ProjectObjDesc(eye.BaldObjDesc)));
ProjectObjDesc(eye.BaldObjDesc));
}
var noseStrips = new List<ChargenFaceStrip>(sex.NoseStrips.Count);
foreach (FaceStripCG strip in sex.NoseStrips)
noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)));
var noseStrips = new ChargenFaceStrip[sex.NoseStrips.Count];
for (int i = 0; i < sex.NoseStrips.Count; i++)
{
FaceStripCG strip = sex.NoseStrips[i];
noseStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc));
}
var mouthStrips = new List<ChargenFaceStrip>(sex.MouthStrips.Count);
foreach (FaceStripCG strip in sex.MouthStrips)
mouthStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)));
var mouthStrips = new ChargenFaceStrip[sex.MouthStrips.Count];
for (int i = 0; i < sex.MouthStrips.Count; i++)
{
FaceStripCG strip = sex.MouthStrips[i];
mouthStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc));
}
return new ChargenGenderOptions(
genderKey,
@ -170,9 +221,9 @@ public static class ChargenTableReader
sex.MotionTable.DataId,
sex.CombatTable.DataId,
ProjectObjDesc(sex.BaseObjDesc),
new List<uint>(sex.HairColors),
sex.HairColors.ToArray(),
hairStyles,
new List<uint>(sex.EyeColors),
sex.EyeColors.ToArray(),
eyeStrips,
noseStrips,
mouthStrips,
@ -180,35 +231,45 @@ public static class ChargenTableReader
ProjectGearList(sex.Shirts),
ProjectGearList(sex.Pants),
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);
foreach (GearCG gear in gearList)
result.Add(new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault));
var result = new ChargenGearOption[gearList.Count];
for (int i = 0; i < gearList.Count; i++)
{
GearCG gear = gearList[i];
result[i] = new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault);
}
return result;
}
private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc)
{
var subPalettes = new List<ChargenSubPalette>(objDesc.SubPalettes.Count);
foreach (SubPalette sub in objDesc.SubPalettes)
subPalettes.Add(new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors));
var textureChanges = new List<ChargenTextureChange>(objDesc.TextureChanges.Count);
foreach (TextureMapChange change in objDesc.TextureChanges)
var subPalettes = new ChargenSubPalette[objDesc.SubPalettes.Count];
for (int i = 0; i < objDesc.SubPalettes.Count; i++)
{
textureChanges.Add(new ChargenTextureChange(
change.PartIndex,
change.OldTexture.DataId,
change.NewTexture.DataId));
SubPalette sub = objDesc.SubPalettes[i];
subPalettes[i] = new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors);
}
var animPartChanges = new List<ChargenAnimPartChange>(objDesc.AnimPartChanges.Count);
foreach (AnimationPartChange change in objDesc.AnimPartChanges)
animPartChanges.Add(new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId));
var textureChanges = new ChargenTextureChange[objDesc.TextureChanges.Count];
for (int i = 0; i < objDesc.TextureChanges.Count; i++)
{
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);
}

View file

@ -36,11 +36,22 @@ public sealed record ChargenGenderOptions(
IReadOnlyList<uint> ClothingColors)
{
/// <summary>
/// Every appearance option list is non-empty for a playable gender —
/// CC1's installed-DAT gate asserts this holds for at least one gender
/// per heritage. A gender missing an option list can still be a valid
/// data shape (e.g. a bald-only heritage's hair styles), so callers
/// building UI should still defend against empty lists individually.
/// True when AT LEAST ONE of the eight lists below is non-empty (an OR
/// across all eight, not a per-list guarantee). Deliberately omits
/// <see cref="HairColors"/>, <see cref="EyeColors"/>, and
/// <see cref="ClothingColors"/> — CC6's color-wheel controls need those
/// 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>
public bool HasAnyAppearanceOptions =>
HairStyles.Count > 0

View file

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

View file

@ -7,23 +7,35 @@ namespace AcDream.Core.CharGen;
/// for Specialized (never both), and subtract the total from the heritage's
/// <c>SkillCredits</c> budget. No DAT/DatReaderWriter dependency — callers
/// (CC3's Runtime owner) pass in the heritage's already-projected
/// <see cref="ChargenSkillCost"/> lookup.
/// <see cref="ChargenSkillCost"/> lookup plus the global SkillTable
/// fallback lookup (<see cref="ChargenOptions.GlobalSkillCostsBySkillId"/>).
/// </summary>
public static class ChargenSkillCreditMath
{
/// <summary>
/// Total credits spent across every Trained/Specialized skill in
/// <paramref name="advancement"/>. A skill with no cost entry for the
/// active heritage (i.e. the heritage doesn't offer it) is skipped —
/// retail's own UI can never reach that state, so this is defensive
/// rather than a documented retail behavior.
/// <paramref name="advancement"/>. Retail's cost lookup
/// (<c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
/// <c>GetSkillSpecializedCost @ 0x005C27D0</c>) is TWO-TIERED: it scans
/// 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>
public static int ComputeSpent(
ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId)
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId,
IReadOnlyDictionary<uint, ChargenSkillCost> globalCostsBySkillId)
{
ArgumentNullException.ThrowIfNull(advancement);
ArgumentNullException.ThrowIfNull(costsBySkillId);
ArgumentNullException.ThrowIfNull(globalCostsBySkillId);
int spent = 0;
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
@ -35,8 +47,11 @@ public static class ChargenSkillCreditMath
continue;
}
if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)
&& !globalCostsBySkillId.TryGetValue(skillId, out cost))
{
continue;
}
spent += cls == ChargenSkillAdvancementClass.Specialized
? cost.PrimaryCost
@ -57,6 +72,7 @@ public static class ChargenSkillCreditMath
public static int RemainingCredits(
uint totalSkillCredits,
ChargenSkillAdvancementSet advancement,
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId) =>
checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId);
IReadOnlyDictionary<uint, ChargenSkillCost> costsBySkillId,
IReadOnlyDictionary<uint, ChargenSkillCost> globalCostsBySkillId) =>
checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId, globalCostsBySkillId);
}

View file

@ -1,14 +1,36 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage,
/// Wayfarer, Soldier, ...) offered on the Profession page
/// (<c>UpdateProfession @ 0x00478d1c/0x0047a4a4</c>-adjacent per the
/// campaign plan's recon; template buttons 0x100003da..df). "Custom" is NOT
/// one of these — it is retail's own free-attribute-assignment mode
/// selected by button 0x100003d9 and has no <see cref="ChargenTemplate"/>
/// entry. Retail schema: <c>Template_CG::Serialize @ 0x005C0450</c>
/// (ACE's <c>TemplateCG.Unpack</c> mirrors the same field order).
/// One profession preset offered on the Profession page. Retail's
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c> switches on
/// <c>CharGenState.template_</c> and resolves BOTH the button to highlight
/// AND the description string from the SAME 0..6 index: 0 → button
/// 0x100003d9 / <c>ID_CharGen_CustomText</c> ("Custom"), 1 → 0x100003da
/// (Bow Hunter), 2 → 0x100003df (Swashbuckler), 3 → 0x100003db (Life
/// Caster), 4 → 0x100003dc (War Caster), 5 → 0x100003dd (Wayfarer), 6 →
/// 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>
public sealed record ChargenTemplate(
string Name,