fix(chargen): Campaign CC gate round 1 closeout — Group 2: Skills page four-bucket model
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>
This commit is contained in:
parent
e1d7d09599
commit
0fed5fdd91
6 changed files with 712 additions and 121 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
|
@ -7,11 +8,12 @@ using AcDream.Runtime.Session;
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) —
|
||||
/// simplified to one flat listbox rather than retail's four-bucket sorted
|
||||
/// insertion model (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>,
|
||||
/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register
|
||||
/// AP-213). Decomp
|
||||
/// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) — now
|
||||
/// ported to retail's four-bucket sorted insertion model
|
||||
/// (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>, Specialized/Trained/
|
||||
/// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the
|
||||
/// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph
|
||||
/// below). Decomp
|
||||
/// anchors: <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c> (listbox
|
||||
/// <c>0x100003f7</c>, credits meter <c>0x100002f3</c> — imports as button
|
||||
/// <c>0x100003f9</c>'s own consumed Label, see the ctor comment — info
|
||||
|
|
@ -51,19 +53,14 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>DecreaseSkillLevel</c>, same dispatcher's case <c>0x10000305</c>).
|
||||
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one
|
||||
/// shared row — the row now wires exactly that, retiring AP-213's own
|
||||
/// click-to-advance/double-click-retreat single-button substitution (the
|
||||
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
|
||||
/// stays registered).
|
||||
/// click-to-advance/double-click-retreat single-button substitution.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
|
||||
/// F1/F2):</b> four of R2-4's five sub-items are fixed here; the
|
||||
/// four-bucket sorted model (R2-4b) is NOT — see the batch report and the
|
||||
/// AP-213 row for the exact missing data channel (retail's Useable-vs-
|
||||
/// Unuseable-Untrained split reads <c>SkillBase.MinLevel</c>, which
|
||||
/// <see cref="AcDream.Core.CharGen.ChargenOptions"/>/
|
||||
/// <see cref="CharacterCreationRuntimeBindings"/> do not carry today).
|
||||
/// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph
|
||||
/// below for where it lands.
|
||||
/// <list type="bullet">
|
||||
/// <item>R2-4a (row selection): a row click (or an arrow click, matching
|
||||
/// retail's own post-Increase/DecreaseSkillLevel <c>SetSelectedItem(...,
|
||||
|
|
@ -72,11 +69,8 @@ namespace AcDream.App.UI.Layout;
|
|||
/// user's own report + the GF-11b precedent) and the info panes
|
||||
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) get <c>ShowSkillsText
|
||||
/// @0x00481250</c>'s title (name + score, <c>" (%d)\n"</c>) and bonus line
|
||||
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — a
|
||||
/// PARTIAL port: the description (<c>SkillBase._description</c>) and
|
||||
/// <c>MakeSkillFormula @0x00480e10</c>'s computed formula text are not
|
||||
/// reachable from this page's current data surface; see
|
||||
/// <see cref="RefreshInfoBox"/>'s own doc.</item>
|
||||
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — see the
|
||||
/// closeout paragraph below for the description/formula completion.</item>
|
||||
/// <item>R2-4c (scrollbar): the listbox's own authored scrollbar link
|
||||
/// (<see cref="AcDream.App.UI.UiTemplateListBox.ScrollbarElementId"/>, dat
|
||||
/// property <c>0x72</c>) is now wired to
|
||||
|
|
@ -108,9 +102,84 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <see cref="GetCosts"/> — no new data needed.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213
|
||||
/// CLOSED, R2-4b implemented:</b> <see cref="AcDream.Core.CharGen.ChargenSkillDetail"/>
|
||||
/// threads <c>SkillBase.MinLevel</c>/<c>Description</c>/<c>Formula</c> from
|
||||
/// the global SkillTable through <see cref="AcDream.Core.CharGen.ChargenOptions.TryGetSkillDetail"/>
|
||||
/// (Content's <c>ChargenTableReader.Project</c> populates it — these three
|
||||
/// fields have NO per-heritage override in retail, unlike costs). Row
|
||||
/// building now groups every costable skill into <see cref="SkillBucket"/>
|
||||
/// (Specialized/Trained/UseableUntrained/UnuseableUntrained,
|
||||
/// <c>UpdateSkillEntry</c>'s own <c>iMinlevel <= 1</c> useable-vs-
|
||||
/// unuseable-untrained test) and sorts each bucket's rows alphabetically by
|
||||
/// name (<c>InsertEntrySorted</c>'s <c>wcscmp</c> compare, ported as
|
||||
/// <c>string.CompareOrdinal</c>), inserting one <c>Templates[0]</c> header
|
||||
/// row per bucket (caption child <c>0x100002f6</c>, a <see cref="UiButton"/>
|
||||
/// per the same <c>UIElement_Button</c>-is-<c>DynamicCast(0xc)</c>-compatible-
|
||||
/// with-Text quirk GF-4b already used) ahead of that bucket's own
|
||||
/// <c>Templates[1]</c> skill rows — matching <c>DoSkillRecords</c>'s own
|
||||
/// unconditional 4-header-then-populate build order exactly. Headers are
|
||||
/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever
|
||||
/// disappears just because it has zero rows this round). Advancing/
|
||||
/// retreating a skill moves its row between buckets: <see cref="Refresh"/>
|
||||
/// detects a bucket change per-row (cheap: <see cref="ComputeBucket"/>
|
||||
/// against each row's OWN cached <see cref="SkillRow.Bucket"/>) rather than
|
||||
/// reproducing retail's incremental <c>InsertEntrySorted</c> single-row
|
||||
/// move — a full <see cref="RebuildRows"/> achieves the SAME observable
|
||||
/// bucket/sort placement every tick a change is detected, with the current
|
||||
/// selection explicitly preserved across that rebuild (unlike a heritage
|
||||
/// change, which clears it, matching retail's own roster invalidation).
|
||||
/// <see cref="RefreshInfoBox"/>'s own doc covers the description/formula
|
||||
/// completion.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CharacterCreationSkillsPage : IDisposable
|
||||
{
|
||||
/// <summary>Retail's four skill buckets, in <c>DoSkillRecords</c>'s own
|
||||
/// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained
|
||||
/// — top to bottom in the listbox).</summary>
|
||||
private enum SkillBucket
|
||||
{
|
||||
Specialized,
|
||||
Trained,
|
||||
UseableUntrained,
|
||||
UnuseableUntrained,
|
||||
}
|
||||
|
||||
/// <summary>Bucket header row string-table keys, in
|
||||
/// <see cref="SkillBucket"/> order — <c>DoSkillRecords</c>'
|
||||
/// <c>compute_str_hash</c> calls (<c>ID_CharGen_Specialized</c> etc.).</summary>
|
||||
private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder =
|
||||
[
|
||||
(SkillBucket.Specialized, "ID_CharGen_Specialized"),
|
||||
(SkillBucket.Trained, "ID_CharGen_Trained"),
|
||||
(SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"),
|
||||
(SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"),
|
||||
];
|
||||
|
||||
/// <summary><c>UpdateSkillEntry @0x00480bf0</c>'s own bucket test:
|
||||
/// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every
|
||||
/// other <see cref="ChargenSkillAdvancementClass"/> value — Inactive is
|
||||
/// unreachable for any row this page ever lists, since every listed
|
||||
/// skill is costable and <c>RuntimeCharacterCreationState.ResetSkillLevelsLocked</c>
|
||||
/// always seeds a costable skill's slot at Untrained-or-better, kept
|
||||
/// here only for the same defensive completeness as retail's own
|
||||
/// switch) split on <c>iMinlevel <= 1</c>.</summary>
|
||||
private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch
|
||||
{
|
||||
ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized,
|
||||
ChargenSkillAdvancementClass.Trained => SkillBucket.Trained,
|
||||
_ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained,
|
||||
};
|
||||
|
||||
/// <summary>Retail's own bucket-header caption child
|
||||
/// (<c>0x100002f6</c>, live-DAT-measured as a <see cref="UiButton"/> —
|
||||
/// the same <c>UIElement_Button</c>-is-Text-compatible quirk GF-4b
|
||||
/// already ported).</summary>
|
||||
private const uint HeaderCaptionElementId = 0x100002F6u;
|
||||
|
||||
/// <summary>Retail's own row-name id (set once at row build; retail
|
||||
/// never re-writes it on refresh either — <c>DoSkillRecords</c>'
|
||||
/// <c>UIElement_Text::SetText(id_2, &var_138)</c> at
|
||||
|
|
@ -165,10 +234,15 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
/// every tick, resolved once at build time rather than re-walked per
|
||||
/// refresh. <see cref="UnselectedNameColor"/> is the row's OWN authored
|
||||
/// (DAT-default) name color, captured at build time so R2-4a's
|
||||
/// selection highlight can restore it exactly on deselect.</summary>
|
||||
/// selection highlight can restore it exactly on deselect.
|
||||
/// <see cref="Bucket"/> is the bucket this row was LAST built into —
|
||||
/// <see cref="Refresh"/> compares it against a fresh
|
||||
/// <see cref="ComputeBucket"/> call every tick to detect an
|
||||
/// advance/retreat that needs a re-bucket.</summary>
|
||||
private readonly record struct SkillRow(
|
||||
UiElement Root,
|
||||
uint SkillId,
|
||||
SkillBucket Bucket,
|
||||
UiText? NameText,
|
||||
UiText? LevelText,
|
||||
UiText? UpCostText,
|
||||
|
|
@ -237,11 +311,32 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
IRuntimeCharacterCreationView view,
|
||||
RuntimeCharacterCreationSnapshot snapshot)
|
||||
{
|
||||
if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId)
|
||||
bool heritageChanged = !_rowsBuilt || _lastHeritageId != snapshot.HeritageId;
|
||||
// Group 2 closeout: an advance/retreat can move a row into a
|
||||
// different bucket (UpdateSkillEntry's own re-bucket-on-level-
|
||||
// change) — detect that cheaply against each row's own cached
|
||||
// Bucket before paying for a full rebuild.
|
||||
bool bucketsChanged = !heritageChanged && AnyRowBucketChanged(view);
|
||||
if (heritageChanged || bucketsChanged)
|
||||
{
|
||||
// Only a HERITAGE change invalidates the current selection
|
||||
// (retail's own roster-replace semantics) — a bucket move keeps
|
||||
// the same skill selected, just relocated within the list.
|
||||
uint? preservedSkillId = heritageChanged ? null : _selectedSkillId;
|
||||
RebuildRows(view, snapshot.HeritageId);
|
||||
_lastHeritageId = snapshot.HeritageId;
|
||||
_rowsBuilt = true;
|
||||
if (preservedSkillId is { } skillId)
|
||||
{
|
||||
foreach (SkillRow candidate in _rows)
|
||||
{
|
||||
if (candidate.SkillId != skillId)
|
||||
continue;
|
||||
_selectedSkillId = skillId;
|
||||
ApplySelectionHighlight();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SkillRow row in _rows)
|
||||
|
|
@ -253,6 +348,23 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Group 2 closeout: true when any CURRENTLY BUILT row's live
|
||||
/// bucket (recomputed from its skill's present level/MinLevel) no
|
||||
/// longer matches the bucket it was last built into.</summary>
|
||||
private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view)
|
||||
{
|
||||
foreach (SkillRow row in _rows)
|
||||
{
|
||||
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
|
||||
uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail)
|
||||
? detail.MinLevel
|
||||
: 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback.
|
||||
if (ComputeBucket(level, minLevel) != row.Bucket)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
|
||||
{
|
||||
foreach (SkillRow row in _rows)
|
||||
|
|
@ -264,8 +376,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
_rows.Clear();
|
||||
_list?.Flush();
|
||||
|
||||
// The skill list is rebuilding under a (possibly new) heritage —
|
||||
// any previously selected skill id may no longer exist as a row.
|
||||
// The skill list is rebuilding — the caller (Refresh) decides
|
||||
// whether to restore _selectedSkillId afterward (preserved across a
|
||||
// bucket-move rebuild, cleared across a heritage change).
|
||||
_selectedSkillId = null;
|
||||
ClearInfoBox();
|
||||
|
||||
|
|
@ -277,64 +390,111 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// Templates[1] (0x100002FF) is the REAL skill row — see this
|
||||
// class's own doc comment for the full byte trace.
|
||||
UiTemplateListEntry template = _list.Templates[1];
|
||||
// Group 2 closeout: gather every costable skill's (id, name, bucket)
|
||||
// first, group by bucket, sort each bucket alphabetically by name
|
||||
// (InsertEntrySorted's own wcscmp compare), THEN build rows in
|
||||
// DoSkillRecords' own header-then-rows-per-bucket order.
|
||||
var byBucket = new Dictionary<SkillBucket, List<(uint SkillId, string Name)>>(4)
|
||||
{
|
||||
[SkillBucket.Specialized] = [],
|
||||
[SkillBucket.Trained] = [],
|
||||
[SkillBucket.UseableUntrained] = [],
|
||||
[SkillBucket.UnuseableUntrained] = [],
|
||||
};
|
||||
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
|
||||
{
|
||||
if (!IsCostable(heritage, view.Options, skillId))
|
||||
continue;
|
||||
if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId)
|
||||
is not { } rowRoot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_list.AddPrebuiltRow(rowRoot);
|
||||
|
||||
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
|
||||
if (nameText is not null)
|
||||
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
|
||||
// Captured AFTER SetLine (which never touches DefaultColor —
|
||||
// it's read lazily inside the LinesProvider closure) so this is
|
||||
// the row's own DAT-authored default color, for R2-4a's
|
||||
// selection highlight to restore on deselect.
|
||||
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
|
||||
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
|
||||
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
|
||||
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
|
||||
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
|
||||
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
|
||||
|
||||
uint capturedSkillId = skillId;
|
||||
// R2-4a: retail re-selects the row after an arrow click too
|
||||
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
|
||||
// call following IncreaseSkillLevel/DecreaseSkillLevel).
|
||||
if (upButton is not null)
|
||||
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
|
||||
if (downButton is not null)
|
||||
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
|
||||
|
||||
// R2-4a: the row-click equivalent of retail's listbox-level
|
||||
// selection notification (idElement==0x100003f7 &&
|
||||
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
|
||||
// has no generic selection mechanism of its own (see its class
|
||||
// doc), so this page opts the row in directly. Templates[1]
|
||||
// (0x100002FF) resolves through DatWidgetFactory's Type-3
|
||||
// (generic-container) fallback arm to UiDatElement, which
|
||||
// already carries a page-opt-in OnClick/ClickThrough seam for
|
||||
// exactly this — "generic decoration; behavioral widgets opt
|
||||
// back in" (UiDatElement's own doc).
|
||||
if (rowRoot is UiDatElement datRow)
|
||||
{
|
||||
datRow.ClickThrough = false;
|
||||
datRow.OnClick = () => SelectRow(capturedSkillId);
|
||||
}
|
||||
|
||||
_rows.Add(new SkillRow(
|
||||
rowRoot, skillId, nameText, levelText, upCostText, downCostText,
|
||||
upButton, downButton, unselectedColor));
|
||||
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
|
||||
uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail)
|
||||
? detail.MinLevel
|
||||
: 1u;
|
||||
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
|
||||
byBucket[ComputeBucket(level, minLevel)].Add((skillId, name));
|
||||
}
|
||||
foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values)
|
||||
bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||
|
||||
if (_list.Templates.Count < 1)
|
||||
return;
|
||||
UiTemplateListEntry headerTemplate = _list.Templates[0];
|
||||
// Templates[1] (0x100002FF) is the REAL skill row — see this
|
||||
// class's own doc comment for the full byte trace.
|
||||
UiTemplateListEntry rowTemplate = _list.Templates[1];
|
||||
|
||||
foreach ((SkillBucket bucket, string stringKey) in BucketOrder)
|
||||
{
|
||||
BuildHeaderRow(headerTemplate, stringKey);
|
||||
foreach ((uint skillId, _) in byBucket[bucket])
|
||||
BuildSkillRow(rowTemplate, skillId, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds one <c>Templates[0]</c> bucket-header row and writes
|
||||
/// its caption (<see cref="HeaderCaptionElementId"/>) from the string
|
||||
/// table — <c>DoSkillRecords</c>'s own unconditional 4-header build,
|
||||
/// regardless of whether the bucket ends up with any rows.</summary>
|
||||
private void BuildHeaderRow(UiTemplateListEntry template, string stringKey)
|
||||
{
|
||||
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot)
|
||||
return;
|
||||
_list.AddPrebuiltRow(headerRoot);
|
||||
if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption
|
||||
&& _bindings.ResolveText?.Invoke(stringKey) is { } text)
|
||||
{
|
||||
caption.Label = text;
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket)
|
||||
{
|
||||
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot)
|
||||
return;
|
||||
|
||||
_list.AddPrebuiltRow(rowRoot);
|
||||
|
||||
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
|
||||
if (nameText is not null)
|
||||
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
|
||||
// Captured AFTER SetLine (which never touches DefaultColor —
|
||||
// it's read lazily inside the LinesProvider closure) so this is
|
||||
// the row's own DAT-authored default color, for R2-4a's
|
||||
// selection highlight to restore on deselect.
|
||||
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
|
||||
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
|
||||
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
|
||||
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
|
||||
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
|
||||
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
|
||||
|
||||
uint capturedSkillId = skillId;
|
||||
// R2-4a: retail re-selects the row after an arrow click too
|
||||
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
|
||||
// call following IncreaseSkillLevel/DecreaseSkillLevel).
|
||||
if (upButton is not null)
|
||||
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
|
||||
if (downButton is not null)
|
||||
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
|
||||
|
||||
// R2-4a: the row-click equivalent of retail's listbox-level
|
||||
// selection notification (idElement==0x100003f7 &&
|
||||
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
|
||||
// has no generic selection mechanism of its own (see its class
|
||||
// doc), so this page opts the row in directly. Templates[1]
|
||||
// (0x100002FF) resolves through DatWidgetFactory's Type-3
|
||||
// (generic-container) fallback arm to UiDatElement, which
|
||||
// already carries a page-opt-in OnClick/ClickThrough seam for
|
||||
// exactly this — "generic decoration; behavioral widgets opt
|
||||
// back in" (UiDatElement's own doc).
|
||||
if (rowRoot is UiDatElement datRow)
|
||||
{
|
||||
datRow.ClickThrough = false;
|
||||
datRow.OnClick = () => SelectRow(capturedSkillId);
|
||||
}
|
||||
|
||||
_rows.Add(new SkillRow(
|
||||
rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText,
|
||||
upButton, downButton, unselectedColor));
|
||||
}
|
||||
|
||||
private void RefreshRowValues(
|
||||
|
|
@ -488,13 +648,24 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
if (_disposed)
|
||||
return;
|
||||
_selectedSkillId = skillId;
|
||||
ApplySelectionHighlight();
|
||||
if (_bindings.View() is { } view)
|
||||
RefreshInfoBox(view, view.Snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Applies <see cref="SelectedNameColor"/>/<see cref="SkillRow.UnselectedNameColor"/>
|
||||
/// to every row's name text based on <see cref="_selectedSkillId"/> —
|
||||
/// factored out of <see cref="SelectRow"/> so <see cref="Refresh"/> can
|
||||
/// re-apply it after a bucket-move rebuild restores a preserved
|
||||
/// selection onto the NEW row objects (a rebuild discards the old ones,
|
||||
/// so the highlight must be re-painted, not merely remembered).</summary>
|
||||
private void ApplySelectionHighlight()
|
||||
{
|
||||
foreach (SkillRow row in _rows)
|
||||
{
|
||||
if (row.NameText is { } nameText)
|
||||
nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
|
||||
nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
|
||||
}
|
||||
if (_bindings.View() is { } view)
|
||||
RefreshInfoBox(view, view.Snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -504,26 +675,45 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
/// when nothing is selected (retail's own <c>arg2==0</c>/lookup-miss
|
||||
/// arms, both <c>UIElement_Text::ClearAllText</c>). Title is the skill
|
||||
/// name plus its current score (<c>" (%d)\n"</c>, e.g. "Loyalty (5)").
|
||||
/// Body is level-gated bonus text
|
||||
/// Body is: DESCRIPTION, then level-gated bonus text
|
||||
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c> —
|
||||
/// TWO spaces before the number, matching the compiled literal
|
||||
/// verbatim) only.
|
||||
/// verbatim), then <see cref="ComposeFormula"/>'s "Formula : ..." line —
|
||||
/// <c>eax_2[7]</c>/<c>eax_2[8]</c> off the row's cached
|
||||
/// <c>tagSkillRecord</c>, byte-traced against <c>tagSkillRecord</c>'s
|
||||
/// own field order (<c>acclient.h</c>). Routed through
|
||||
/// <see cref="DatRichText.Compose"/> (escape-normalize + word-wrap, the
|
||||
/// SAME composer the description pages use) since the description text
|
||||
/// can run long enough to need wrapping in this box's width; composed
|
||||
/// ONCE per call (not per-frame — this method itself only runs when
|
||||
/// <see cref="Refresh"/>'s caller detects a revision change) and handed
|
||||
/// to <see cref="UiText.LinesProvider"/> as a closed-over, already-built
|
||||
/// list, matching the F11 no-per-frame-recompute discipline.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>PARTIAL PORT — see the batch report:</b> retail's body ALSO
|
||||
/// prepends the skill's DESCRIPTION (<c>SkillBase._description</c>,
|
||||
/// read via <c>eax_2[7]</c> off the row's own cached
|
||||
/// <c>tagSkillRecord</c>) and appends
|
||||
/// <c>MakeSkillFormula @0x00480e10</c>'s computed "Formula : ..." text
|
||||
/// (attribute names + weighted-formula arithmetic, sourced from
|
||||
/// <c>SkillBase._formula</c>). Neither is reachable from this page's
|
||||
/// current data surface: <see cref="AcDream.Core.CharGen.ChargenOptions"/>
|
||||
/// carries per-skill COSTS only (never description/formula), and
|
||||
/// <see cref="CharacterCreationRuntimeBindings"/> has no resolver for
|
||||
/// either (unlike <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>,
|
||||
/// which already exists for the score). Porting them needs a new
|
||||
/// binding of that same shape, backed by the global SkillTable — out of
|
||||
/// this file's edit contract for this batch.
|
||||
/// <b>Group 2 closeout (Campaign CC gate round 1):</b> DESCRIPTION is a
|
||||
/// byte-verified port (<c>SkillBase._description</c>, read directly off
|
||||
/// the DAT) and the ONLY segment routed through
|
||||
/// <see cref="DatRichText.Compose"/>'s word-wrap — description text can
|
||||
/// run arbitrarily long, unlike the bonus/formula lines below. The bonus
|
||||
/// line (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
|
||||
/// TWO spaces before the number, matching the compiled literal
|
||||
/// verbatim) and <see cref="ComposeFormula"/>'s result are each added as
|
||||
/// their OWN single, UNWRAPPED <see cref="UiText.Line"/> — deliberately
|
||||
/// bypassing <c>DatRichText.Compose</c> for these two, since its
|
||||
/// word-splitting wrap (<see cref="UiText.WrapWords"/>) collapses
|
||||
/// consecutive spaces when it rejoins tokens, which would silently
|
||||
/// mangle the bonus line's own authored double-space formatting (caught
|
||||
/// by <c>SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine</c>
|
||||
/// during this closeout — routing it through the wrapper the first time
|
||||
/// produced "Training Bonus +5 ", single space, trailing artifact from
|
||||
/// the wrapper's own newline-as-empty-paragraph handling). <see cref="ComposeFormula"/>'s
|
||||
/// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH
|
||||
/// CONFIDENCE (every piece is a directly-read compiled string literal or
|
||||
/// a field the DatReaderWriter binding already exposes by name); the
|
||||
/// CONNECTOR text between a two-attribute formula's two terms is a
|
||||
/// documented approximation (register AP-231) — see that method's own
|
||||
/// doc.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
|
||||
|
|
@ -549,10 +739,109 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
|
||||
_ => string.Empty,
|
||||
};
|
||||
SetLine(text, bonus);
|
||||
|
||||
bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail);
|
||||
var lines = new List<UiText.Line>();
|
||||
if (hasDetail && !string.IsNullOrEmpty(detail.Description))
|
||||
{
|
||||
lines.AddRange(DatRichText.Compose(
|
||||
text, [new DatRichText.Segment(detail.Description, text.DefaultColor)]));
|
||||
}
|
||||
if (bonus.Length > 0)
|
||||
lines.Add(new UiText.Line(bonus, text.DefaultColor));
|
||||
if (hasDetail)
|
||||
lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor));
|
||||
|
||||
text.LinesProvider = () => lines;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c> — retail's
|
||||
/// formula-text composition. HIGH CONFIDENCE (directly read from
|
||||
/// compiled string literals plus the field layout
|
||||
/// <see cref="AcDream.Core.CharGen.ChargenSkillFormula"/> shares with
|
||||
/// the DatReaderWriter binding's own <c>SkillFormula</c> struct): the
|
||||
/// <c>"Formula : "</c> prefix, the per-attribute <c>"(%u x %s)"</c>-vs-
|
||||
/// bare-name choice (a term's own multiplier <c>> 1</c> gets the
|
||||
/// parenthesized multiply form, else just the attribute's name — the
|
||||
/// exact <c>eax_6 <= 1</c>/<c>ebx_3 <= 1</c> gate), the
|
||||
/// <c>" / %u"</c> divisor suffix (gated on <c>Divisor != 1</c>, the
|
||||
/// exact <c>__saved_ebp_11 != 1</c> gate), and the <c>" +%u"</c>
|
||||
/// additive-bonus suffix (gated on <c>AdditiveBonus != 0</c>, the exact
|
||||
/// <c>__saved_ebp_12 != 0</c> gate).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>LOWER CONFIDENCE, disclosed rather than silently guessed
|
||||
/// (register AP-231):</b> the connector text between a two-attribute
|
||||
/// formula's own two terms. This port renders <c>" + "</c> — the
|
||||
/// well-known "(Attr1 + Attr2) / N" shape most published AC skill
|
||||
/// formulas use — but the decompiled function's own two candidate
|
||||
/// connector literals (<c>data_7a01a4</c>, appended between the terms;
|
||||
/// <c>data_797584</c>, appended again immediately after BOTH terms are
|
||||
/// present) could not be recovered byte-exact by this session's
|
||||
/// static-only tooling (no live cdb attach, no running Ghidra MCP
|
||||
/// instance): both sit behind reference-counted <c>PStringBase</c>
|
||||
/// appends whose actual wide-character content Binary Ninja's HLIL does
|
||||
/// not surface as a literal, and the surrounding control flow (a
|
||||
/// <c>goto</c>-based re-convergence between the single-attribute and
|
||||
/// dual-attribute code paths) left <c>data_797584</c>'s exact role
|
||||
/// ambiguous enough that this port does NOT invent a second connector
|
||||
/// for it — a two-attribute skill's formula therefore renders as
|
||||
/// <c>"Formula : (2 x Strength) + Endurance / 4 +2"</c>-shaped text
|
||||
/// that is very likely retail-correct in STRUCTURE but not yet
|
||||
/// byte-verified against a live capture. Single-attribute formulas (the
|
||||
/// majority of skills) are unaffected by this gap.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static string ComposeFormula(ChargenSkillFormula formula)
|
||||
{
|
||||
bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0;
|
||||
bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0;
|
||||
|
||||
var builder = new StringBuilder("Formula : ");
|
||||
if (attribute1Active)
|
||||
{
|
||||
AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1);
|
||||
if (attribute2Active)
|
||||
builder.Append(" + ");
|
||||
}
|
||||
if (attribute2Active)
|
||||
AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2);
|
||||
|
||||
if (formula.Divisor != 1)
|
||||
builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}");
|
||||
if (formula.AdditiveBonus != 0)
|
||||
builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId)
|
||||
{
|
||||
string name = AttributeName((ChargenAttributeId)attributeId);
|
||||
if (multiplier > 1)
|
||||
builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})");
|
||||
else
|
||||
builder.Append(name);
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
|
||||
/// verbatim — retail hardcodes these six literals directly (not a
|
||||
/// DAT/localization lookup). Duplicated locally from
|
||||
/// <c>CharacterCreationProfessionPage</c>'s own private copy rather than
|
||||
/// extracted to a shared helper — six lines, two call sites, not worth
|
||||
/// a new file for this closeout's scope.</summary>
|
||||
private static string AttributeName(ChargenAttributeId id) => id switch
|
||||
{
|
||||
ChargenAttributeId.Strength => "Strength",
|
||||
ChargenAttributeId.Endurance => "Endurance",
|
||||
ChargenAttributeId.Quickness => "Quickness",
|
||||
ChargenAttributeId.Coordination => "Coordination",
|
||||
ChargenAttributeId.Focus => "Focus",
|
||||
ChargenAttributeId.Self => "Self",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private void ClearInfoBox()
|
||||
{
|
||||
if (_infoTitle is { } title) SetLine(title, string.Empty);
|
||||
|
|
|
|||
|
|
@ -81,22 +81,39 @@ public static class ChargenTableReader
|
|||
heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value);
|
||||
|
||||
var globalSkillCosts = new Dictionary<uint, ChargenSkillCost>(skillTable?.Skills.Count ?? 0);
|
||||
// Group 2 (Campaign CC gate round 1 closeout): SkillBase.MinLevel/
|
||||
// Description/Formula — GLOBAL only, no per-heritage counterpart
|
||||
// (see ChargenSkillDetail's own doc).
|
||||
var globalSkillDetails = new Dictionary<uint, ChargenSkillDetail>(skillTable?.Skills.Count ?? 0);
|
||||
if (skillTable is not null)
|
||||
{
|
||||
foreach (KeyValuePair<DatReaderWriter.Enums.SkillId, SkillBase> pair in skillTable.Skills)
|
||||
{
|
||||
uint skillId = (uint)pair.Key;
|
||||
SkillBase skill = pair.Value;
|
||||
globalSkillCosts[skillId] = new ChargenSkillCost(
|
||||
skillId,
|
||||
pair.Value.TrainedCost,
|
||||
pair.Value.SpecializedCost);
|
||||
skill.TrainedCost,
|
||||
skill.SpecializedCost);
|
||||
globalSkillDetails[skillId] = new ChargenSkillDetail(
|
||||
skillId,
|
||||
skill.MinLevel,
|
||||
skill.Description.Value,
|
||||
new ChargenSkillFormula(
|
||||
skill.Formula.AdditiveBonus,
|
||||
skill.Formula.Attribute1Multiplier,
|
||||
skill.Formula.Attribute2Multiplier,
|
||||
skill.Formula.Divisor,
|
||||
(uint)skill.Formula.Attribute1,
|
||||
(uint)skill.Formula.Attribute2));
|
||||
}
|
||||
}
|
||||
|
||||
return new ChargenOptions(
|
||||
Array.AsReadOnly(starterAreas),
|
||||
heritagesById.ToFrozenDictionary(),
|
||||
globalSkillCosts.ToFrozenDictionary());
|
||||
globalSkillCosts.ToFrozenDictionary(),
|
||||
globalSkillDetails.ToFrozenDictionary());
|
||||
}
|
||||
|
||||
private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area)
|
||||
|
|
|
|||
|
|
@ -26,10 +26,21 @@ namespace AcDream.Core.CharGen;
|
|||
/// per-gender appearance option lists, skill costs — hangs off this one
|
||||
/// root.
|
||||
/// </summary>
|
||||
/// <param name="GlobalSkillDetailsBySkillId">
|
||||
/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's
|
||||
/// MinLevel/Description/Formula per skill (see <see cref="ChargenSkillDetail"/>'s
|
||||
/// own doc for why these are GLOBAL-only, unlike <paramref name="GlobalSkillCostsBySkillId"/>
|
||||
/// which also has a per-heritage counterpart). Defaults to null (not an
|
||||
/// empty dictionary) so every pre-existing caller that builds a
|
||||
/// <see cref="ChargenOptions"/> without this parameter — five test fixtures
|
||||
/// plus <c>ChargenOptions.Empty</c> below — compiles and behaves exactly as
|
||||
/// before; <see cref="TryGetSkillDetail"/> treats null the same as "empty."
|
||||
/// </param>
|
||||
public sealed record ChargenOptions(
|
||||
IReadOnlyList<ChargenStarterArea> StarterAreas,
|
||||
IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById,
|
||||
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId)
|
||||
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId,
|
||||
IReadOnlyDictionary<uint, ChargenSkillDetail>? GlobalSkillDetailsBySkillId = null)
|
||||
{
|
||||
public static ChargenOptions Empty { get; } = new(
|
||||
Array.Empty<ChargenStarterArea>(),
|
||||
|
|
@ -49,4 +60,13 @@ public sealed record ChargenOptions(
|
|||
area = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="GlobalSkillDetailsBySkillId"/>'s own doc.</summary>
|
||||
public bool TryGetSkillDetail(uint skillId, [MaybeNullWhen(false)] out ChargenSkillDetail detail)
|
||||
{
|
||||
if (GlobalSkillDetailsBySkillId is { } details && details.TryGetValue(skillId, out detail))
|
||||
return true;
|
||||
detail = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,53 @@ public enum ChargenSkillAdvancementClass : uint
|
|||
/// </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>
|
||||
|
|
|
|||
|
|
@ -335,12 +335,20 @@ public sealed class CharacterCreationUiControllerTests
|
|||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
|
||||
.OnClick!();
|
||||
|
||||
IReadOnlyList<UiElement> rows = environment.SkillsList().ViewportForTest!.Children;
|
||||
IReadOnlyList<UiElement> children = environment.SkillsList().ViewportForTest!.Children;
|
||||
// Group 2 closeout: the listbox now ALSO carries the four bucket-
|
||||
// header rows (DoSkillRecords' own unconditional 4-header build,
|
||||
// present regardless of whether a bucket is empty) — filter to
|
||||
// genuine skill rows (carry a 0x10000301 name descendant; headers
|
||||
// carry only 0x100002f6) before counting.
|
||||
List<UiElement> skillRows = [.. children.Where(
|
||||
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is not null)];
|
||||
Assert.Equal(4, children.Count - skillRows.Count); // four bucket headers, always built.
|
||||
// Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
|
||||
// SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
|
||||
Assert.Equal(3, rows.Count);
|
||||
Assert.Equal(3, skillRows.Count);
|
||||
|
||||
UiElement row = Assert.Single(rows, candidate =>
|
||||
UiElement row = Assert.Single(skillRows, candidate =>
|
||||
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
|
||||
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
|
||||
|
|
@ -363,10 +371,21 @@ public sealed class CharacterCreationUiControllerTests
|
|||
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
|
||||
// ~1045), so force one the same way the file's other post-click
|
||||
// refresh assertions do.
|
||||
//
|
||||
// Group 2 closeout: advancing also moves the row from the
|
||||
// UseableUntrained bucket to the Trained bucket, which rebuilds
|
||||
// every row — row/upCost/downCost captured above are now stale, so
|
||||
// re-fetch by name (the SAME lookup every other test in this file
|
||||
// uses post-rebuild) instead of reusing them.
|
||||
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!();
|
||||
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
||||
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
|
||||
environment.Controller.Tick();
|
||||
row = Assert.Single(environment.SkillsList().ViewportForTest!.Children, candidate =>
|
||||
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
|
||||
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
|
||||
downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
|
||||
Assert.Equal("4", JoinedText(upCost));
|
||||
Assert.Equal("2", JoinedText(downCost));
|
||||
}
|
||||
|
|
@ -377,10 +396,11 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter
|
||||
/// white") and the info title
|
||||
/// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix)
|
||||
/// populates. Untrained/Inactive carries no bonus line, so the info
|
||||
/// TEXT pane stays blank (the still-missing description/formula halves
|
||||
/// — see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
|
||||
/// doc).</summary>
|
||||
/// populates. Group 2 closeout: Untrained/Inactive carries no BONUS
|
||||
/// line, but the info TEXT pane is no longer blank — the DESCRIPTION
|
||||
/// and <c>MakeSkillFormula</c> lines both render regardless of level
|
||||
/// (see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
|
||||
/// doc for the composition order).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
|
||||
{
|
||||
|
|
@ -404,7 +424,13 @@ public sealed class CharacterCreationUiControllerTests
|
|||
string expectedTitle =
|
||||
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
|
||||
Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
|
||||
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText()));
|
||||
// SkillTrainOnly's fixture detail: description "A test skill
|
||||
// description.", formula (2 x Strength) / 4 +2, no bonus line
|
||||
// (Untrained). JoinedText's own single-space join collapses the
|
||||
// description's own word-wrapped line break.
|
||||
Assert.Equal(
|
||||
"A test skill description. Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
}
|
||||
|
||||
/// <summary>R2-4a: retail re-selects the row after an arrow click too
|
||||
|
|
@ -413,7 +439,11 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks
|
||||
/// the level-gated bonus line as the skill advances (the TWO-space
|
||||
/// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
|
||||
/// matching the compiled string verbatim).</summary>
|
||||
/// matching the compiled string verbatim, unwrapped end to end — see
|
||||
/// <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own doc for
|
||||
/// why the bonus line is never routed through the word-wrap the
|
||||
/// description segment gets). Group 2 closeout: the description and
|
||||
/// formula lines now bracket the bonus line on every assertion below.</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
|
||||
{
|
||||
|
|
@ -426,11 +456,20 @@ public sealed class CharacterCreationUiControllerTests
|
|||
|
||||
up.OnClick!(); // Untrained/Inactive -> Trained.
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText()));
|
||||
Assert.Equal(
|
||||
"A test skill description. Training Bonus +5 Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
|
||||
// Group 2 closeout: the Untrained -> Trained move above re-buckets
|
||||
// the row (rebuilding every row and nulling the OLD button's
|
||||
// OnClick as teardown) — re-fetch by name before the second click
|
||||
// instead of reusing the pre-rebuild `up` reference.
|
||||
(up, _) = environment.SkillRowArrows(SkillTrainOnly);
|
||||
up.OnClick!(); // Trained -> Specialized.
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText()));
|
||||
Assert.Equal(
|
||||
"A test skill description. Specialization Bonus +10 Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
}
|
||||
|
||||
/// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own
|
||||
|
|
@ -503,17 +542,26 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
|
||||
Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
|
||||
|
||||
// Group 2 closeout: advancing a skill can move its row into a NEW
|
||||
// bucket (UpdateSkillEntry's own re-bucket), which rebuilds every
|
||||
// row — up/down/freeUp/freeDown are re-fetched by name after each
|
||||
// level-changing click instead of reusing the pre-click widget
|
||||
// references, which would otherwise be silently stale (never
|
||||
// refreshed again once their row is discarded).
|
||||
up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
|
||||
BumpRevisionAndTick(environment);
|
||||
(up, down) = environment.SkillRowArrows(SkillTrainOnly);
|
||||
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
|
||||
|
||||
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
|
||||
BumpRevisionAndTick(environment);
|
||||
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
|
||||
|
||||
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
|
||||
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
|
||||
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
|
||||
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
|
||||
}
|
||||
|
|
@ -534,6 +582,96 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
|
||||
}
|
||||
|
||||
// ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ──
|
||||
|
||||
/// <summary><c>DoSkillRecords</c>'s own unconditional 4-header build —
|
||||
/// every bucket header is present, in Specialized/Trained/
|
||||
/// UseableUntrained/UnuseableUntrained order, even though this
|
||||
/// fixture's three skills leave the Specialized bucket empty.</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Specialized"] = "Specialized";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
List<string> headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children
|
||||
.Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null)
|
||||
.Select(candidate => Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)];
|
||||
|
||||
Assert.Equal(
|
||||
["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"],
|
||||
headerCaptions);
|
||||
}
|
||||
|
||||
/// <summary><c>UpdateSkillEntry</c>'s own <c>iMinlevel <= 1</c> split:
|
||||
/// while Untrained, SkillTrainOnly (fixture MinLevel 1) is useable and
|
||||
/// SkillSpecializable (fixture MinLevel 2) is not — they land in
|
||||
/// DIFFERENT buckets even though both start Untrained (the default,
|
||||
/// unset, <c>FakeView.GetSkillLevel</c> state).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_UntrainedSkill_BucketsByMinLevel()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
|
||||
int useableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
|
||||
int unuseableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Unuseable Untrained");
|
||||
int trainOnlyRowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
int specializableRowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable));
|
||||
|
||||
Assert.InRange(trainOnlyRowIndex, useableHeaderIndex + 1, unuseableHeaderIndex - 1);
|
||||
Assert.True(specializableRowIndex > unuseableHeaderIndex);
|
||||
}
|
||||
|
||||
/// <summary>Advancing a skill re-buckets its row — the row's position
|
||||
/// moves from the UseableUntrained section to the Trained section,
|
||||
/// matching <c>UpdateSkillEntry</c>'s own remove-and-reinsert (ported
|
||||
/// here as a detected full rebuild, not an incremental single-row
|
||||
/// move — see <see cref="CharacterCreationSkillsPage"/>'s own class doc
|
||||
/// for why that substitution is faithful to the OBSERVABLE result).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); // Untrained -> Trained.
|
||||
BumpRevisionAndTick(environment);
|
||||
|
||||
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
|
||||
int trainedHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Trained");
|
||||
int useableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
|
||||
int rowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
|
||||
Assert.InRange(rowIndex, trainedHeaderIndex + 1, useableHeaderIndex - 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TownButton_SelectsTheLiteralStartAreaIndex()
|
||||
{
|
||||
|
|
@ -2374,6 +2512,31 @@ public sealed class CharacterCreationUiControllerTests
|
|||
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
|
||||
};
|
||||
|
||||
// Group 2 closeout: global SkillTable detail (MinLevel/
|
||||
// Description/Formula) — SkillTrainOnly is useable while
|
||||
// Untrained (MinLevel 1) and carries a real description +
|
||||
// single-attribute formula for the info-box completion tests;
|
||||
// SkillSpecializable requires Trained first (MinLevel 2), the
|
||||
// useable-vs-unuseable-untrained bucket split's own test case.
|
||||
var skillDetails = new Dictionary<uint, ChargenSkillDetail>
|
||||
{
|
||||
[SkillTrainOnly] = new ChargenSkillDetail(
|
||||
SkillTrainOnly,
|
||||
MinLevel: 1u,
|
||||
Description: "A test skill description.",
|
||||
Formula: new ChargenSkillFormula(
|
||||
AdditiveBonus: 2,
|
||||
Attribute1Multiplier: 2,
|
||||
Attribute2Multiplier: 0,
|
||||
Divisor: 4,
|
||||
Attribute1: (uint)ChargenAttributeId.Strength,
|
||||
Attribute2: 0u)),
|
||||
[SkillSpecializable] = new ChargenSkillDetail(
|
||||
SkillSpecializable, MinLevel: 2u, Description: string.Empty, Formula: default),
|
||||
[SkillFreeTrained] = new ChargenSkillDetail(
|
||||
SkillFreeTrained, MinLevel: 1u, Description: string.Empty, Formula: default),
|
||||
};
|
||||
|
||||
var aluvian = new ChargenHeritageOptions(
|
||||
AluvianId,
|
||||
"Aluvian",
|
||||
|
|
@ -2426,7 +2589,8 @@ public sealed class CharacterCreationUiControllerTests
|
|||
[AluvianId] = aluvian,
|
||||
[OlthoiId] = olthoi,
|
||||
},
|
||||
new Dictionary<uint, ChargenSkillCost>());
|
||||
new Dictionary<uint, ChargenSkillCost>(),
|
||||
skillDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2806,16 +2970,22 @@ public sealed class CharacterCreationUiControllerTests
|
|||
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
|
||||
}
|
||||
|
||||
return LayoutImporter.Build(
|
||||
new ElementInfo
|
||||
{
|
||||
Id = templateElementId,
|
||||
Type = 1u,
|
||||
Width = 280f,
|
||||
Height = 16f,
|
||||
},
|
||||
_ => (0u, 0, 0),
|
||||
null).Root;
|
||||
// Group 2 closeout: Templates[0] (0x100002F4) — retail's own
|
||||
// bucket-header row, now consumed by the four-bucket rebuild. A
|
||||
// plain container root (Type 3, same shape as Templates[1]'s own
|
||||
// row — a Type-1 UiButton root would CONSUME its own children and
|
||||
// hide the caption), carrying the caption child (0x100002f6, a
|
||||
// UiButton, read via .Label — CharacterCreationSkillsPage's own
|
||||
// HeaderCaptionElementId doc).
|
||||
var header = new ElementInfo
|
||||
{
|
||||
Id = templateElementId,
|
||||
Type = 3u,
|
||||
Width = 280f,
|
||||
Height = 16f,
|
||||
};
|
||||
header.Children.Add(ButtonInfo(0x100002F6u));
|
||||
return LayoutImporter.Build(header, _ => (0u, 0, 0), null).Root;
|
||||
}
|
||||
|
||||
// ── Summary page fixture (CC5) ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -277,6 +277,54 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC gate round 1 closeout (Group 2): pins
|
||||
/// <see cref="ChargenOptions.GlobalSkillDetailsBySkillId"/>'s real
|
||||
/// shape against the installed DAT — same 38-entry population as
|
||||
/// <see cref="ChargenOptions.GlobalSkillCostsBySkillId"/> (both read the
|
||||
/// SAME SkillTable loop), MinLevel distributed 23 at <c><= 1</c>
|
||||
/// (useable while Untrained) / 15 at exactly <c>2</c> (Trained
|
||||
/// required), per the Batch F investigation's own recorded finding, and
|
||||
/// never above 2 — <c>UpdateSkillEntry</c>'s own bucket test only ever
|
||||
/// distinguishes <c><= 1</c> from <c>> 1</c>, so a MinLevel of 3
|
||||
/// would be observationally identical to 2 but is worth flagging if a
|
||||
/// future DAT drop ever introduces one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage()
|
||||
{
|
||||
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(options.GlobalSkillCostsBySkillId.Count, options.GlobalSkillDetailsBySkillId!.Count);
|
||||
Assert.Equal(38, options.GlobalSkillDetailsBySkillId.Count);
|
||||
|
||||
int useableUntrained = 0;
|
||||
int trainedRequired = 0;
|
||||
bool anyDescriptionNonEmpty = false;
|
||||
foreach (ChargenSkillDetail detail in options.GlobalSkillDetailsBySkillId.Values)
|
||||
{
|
||||
Assert.True(detail.MinLevel <= 2u, $"skill {detail.SkillId}: MinLevel {detail.MinLevel} exceeds Trained(2).");
|
||||
if (detail.MinLevel <= 1u)
|
||||
useableUntrained++;
|
||||
else
|
||||
trainedRequired++;
|
||||
anyDescriptionNonEmpty |= !string.IsNullOrEmpty(detail.Description);
|
||||
}
|
||||
|
||||
Assert.Equal(23, useableUntrained);
|
||||
Assert.Equal(15, trainedRequired);
|
||||
Assert.True(anyDescriptionNonEmpty, "Expected at least one skill to carry a non-empty description.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F5's strengthened installed-DAT gate. <see cref="ChargenGenderOptions.HasAnyAppearanceOptions"/>
|
||||
/// only proves an OR across eight lists for at least one gender per
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue