Ports the last remaining half of retail's Skills page: the four-bucket sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained, UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's description + formula completion. - ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/ Description/Formula from the global SkillTable, exposed via a new ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so every pre-existing ChargenOptions call site compiles unchanged). ChargenTableReader.Project populates it from the same SkillTable loop that already builds GlobalSkillCostsBySkillId. - CharacterCreationSkillsPage.RebuildRows now groups every costable skill into SkillBucket, sorts each bucket alphabetically by name (InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and builds one Templates[0] header row per bucket ahead of that bucket's Templates[1] skill rows — DoSkillRecords' own unconditional 4-header-then-populate order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild with the current selection explicitly preserved). - RefreshInfoBox now composes description (word-wrapped via DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped literal — NOT routed through word-wrap, which would have collapsed its authored double-space formatting) + ComposeFormula's "Formula : ..." line (MakeSkillFormula ported with high confidence for the prefix/ per-attribute-term/divisor/bonus-suffix shape; the two-attribute connector text is a disclosed approximation, register AP-231, since the decompiled function's own connector literals could not be recovered byte-exact by this session's static-only tooling). Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed SkillTable's MinLevel distribution matches the investigation's own recorded finding exactly (38 entries, 23 useable-untrained / 15 trained-required). 3 new fixture tests + 1 new live-DAT test; 3 pre-existing integration tests fixed (they captured row widget references before a bucket-changing click, which now rebuilds and discards those references — a real, correct consequence of the new model, not a bug). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
5.5 KiB
C#
127 lines
5.5 KiB
C#
namespace AcDream.Core.CharGen;
|
|
|
|
/// <summary>
|
|
/// Retail's four skill states. Wire values match ACE's
|
|
/// <c>ACE.Entity.Enum.SkillAdvancementClass</c> exactly (0=Inactive,
|
|
/// 1=Untrained, 2=Trained, 3=Specialized) — ACE unpacks the 0xF656
|
|
/// <c>CharacterCreateInfo.SkillAdvancementClasses</c> list with this same
|
|
/// numbering, and retail's <c>CharGenState::UpdateRemainingSkillCredits @
|
|
/// 0x005C37C0</c> only charges credits for Trained (2) and Specialized (3).
|
|
/// </summary>
|
|
public enum ChargenSkillAdvancementClass : uint
|
|
{
|
|
Inactive = 0,
|
|
Untrained = 1,
|
|
Trained = 2,
|
|
Specialized = 3,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One skill's retail training cost for a heritage. Retail schema:
|
|
/// <c>SkillCG</c>, entries of <c>HeritageGroupCG.Skills</c>.
|
|
/// <c>PrimaryCost</c> is the TOTAL cost to reach Specialized (not an
|
|
/// increment on top of <c>NormalCost</c>) — retail's
|
|
/// <c>UpdateRemainingSkillCredits</c> adds exactly one of the two per
|
|
/// skill, never both.
|
|
/// </summary>
|
|
public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
|
|
|
|
/// <summary>
|
|
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c>'s six raw inputs —
|
|
/// retail's <c>SkillFormula</c> struct (<c>acclient.h</c>) verbatim field
|
|
/// order/shape: <c>_w</c>=<see cref="AdditiveBonus"/>,
|
|
/// <c>_x</c>=<see cref="Attribute1Multiplier"/>,
|
|
/// <c>_y</c>=<see cref="Attribute2Multiplier"/>, <c>_z</c>=<see cref="Divisor"/>,
|
|
/// <c>_attr1</c>=<see cref="Attribute1"/>, <c>_attr2</c>=<see cref="Attribute2"/>.
|
|
/// <see cref="Attribute1"/>/<see cref="Attribute2"/> are the raw 1-6 retail
|
|
/// attribute id (matching <c>AcDream.Runtime.Session.ChargenAttributeId</c>'s
|
|
/// own numbering exactly — Strength=1..Self=6) rather than that enum type
|
|
/// itself, since Core does not (and must not) reference Runtime; the App
|
|
/// layer, which already references both, does the enum cast at the one
|
|
/// call site that needs an attribute NAME.
|
|
/// </summary>
|
|
public readonly record struct ChargenSkillFormula(
|
|
int AdditiveBonus,
|
|
int Attribute1Multiplier,
|
|
int Attribute2Multiplier,
|
|
int Divisor,
|
|
uint Attribute1,
|
|
uint Attribute2);
|
|
|
|
/// <summary>
|
|
/// One skill's GLOBAL (heritage-independent) presentation data — retail's
|
|
/// <c>SkillBase._min_level</c>/<c>_description</c>/<c>_formula</c> fields,
|
|
/// sourced ONLY from the portal.dat SkillTable. Distinct from
|
|
/// <see cref="ChargenSkillCost"/> (which exists BOTH per-heritage
|
|
/// (<c>SkillCG</c>) AND globally) because these three fields have NO
|
|
/// per-heritage override in retail at all — <c>SkillCG</c> (the per-
|
|
/// heritage cost record <c>HeritageGroupCG.Skills</c> projects) carries
|
|
/// only <c>Id</c>/<c>NormalCost</c>/<c>PrimaryCost</c>, verified against the
|
|
/// DatReaderWriter binding.
|
|
/// </summary>
|
|
/// <param name="MinLevel">
|
|
/// Retail's <c>_min_level</c> is typed <c>SKILL_ADVANCEMENT_CLASS</c>, not a
|
|
/// character level — <c>gmCGSkillsPage::UpdateSkillEntry @0x00480bf0</c>'s
|
|
/// own bucket test (<c>arg2->iMinlevel <= 1</c>) reads it as "the
|
|
/// lowest <see cref="ChargenSkillAdvancementClass"/> at which this skill is
|
|
/// USEABLE" — <c><= 1</c> (Inactive/Untrained) means useable while
|
|
/// untrained, <c>== 2</c> (Trained) means training is required first.
|
|
/// </param>
|
|
public readonly record struct ChargenSkillDetail(
|
|
uint SkillId,
|
|
uint MinLevel,
|
|
string Description,
|
|
ChargenSkillFormula Formula);
|
|
|
|
/// <summary>
|
|
/// Retail's fixed-size per-character skill-advancement array
|
|
/// (<c>CharGenState.skillLevels</c>). ACE's <c>CharacterCreateInfo.Unpack</c>
|
|
/// terminates the connection if the wire's <c>numSkills</c> count is not
|
|
/// exactly <see cref="SlotCount"/> (55): retail's own loop in
|
|
/// <c>UpdateRemainingSkillCredits</c> walks indices <c>1..totalNumSkills</c>
|
|
/// (skipping reserved slot 0), and Chorizite's <c>SkillId</c> enum runs
|
|
/// 1..54 — 54 real skills plus the reserved slot 0 is exactly 55. This type
|
|
/// makes that shape structural: it always holds exactly 55 slots, so a
|
|
/// caller building the 0xF656 body (CC2) cannot accidentally send a
|
|
/// different count.
|
|
/// </summary>
|
|
public sealed class ChargenSkillAdvancementSet
|
|
{
|
|
/// <summary>Slot 0 is reserved (unused by retail); slots 1..54 map 1:1
|
|
/// to Chorizite's <c>DatReaderWriter.Enums.SkillId</c> values.</summary>
|
|
public const int SlotCount = 55;
|
|
|
|
private readonly ChargenSkillAdvancementClass[] _slots = new ChargenSkillAdvancementClass[SlotCount];
|
|
|
|
/// <summary>Skill state by raw skill id. Ids outside <c>1..54</c> read
|
|
/// as <see cref="ChargenSkillAdvancementClass.Inactive"/> and cannot be
|
|
/// set.</summary>
|
|
public ChargenSkillAdvancementClass this[uint skillId]
|
|
{
|
|
get => skillId >= 1 && skillId < SlotCount
|
|
? _slots[skillId]
|
|
: ChargenSkillAdvancementClass.Inactive;
|
|
set
|
|
{
|
|
if (skillId < 1 || skillId >= SlotCount)
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(skillId),
|
|
skillId,
|
|
$"Skill id must be in 1..{SlotCount - 1}.");
|
|
_slots[skillId] = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materializes the wire body shape: exactly <see cref="SlotCount"/>
|
|
/// entries, slot 0 first, matching ACE's
|
|
/// <c>CharacterCreateInfo.SkillAdvancementClasses</c> read order.
|
|
/// </summary>
|
|
public IReadOnlyList<uint> ToWireClasses()
|
|
{
|
|
var wire = new uint[SlotCount];
|
|
for (int i = 0; i < SlotCount; i++)
|
|
wire[i] = (uint)_slots[i];
|
|
return wire;
|
|
}
|
|
}
|