909 lines
46 KiB
C#
909 lines
46 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using System.Text;
|
|
using AcDream.Core.CharGen;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// panes <c>0x100003fb</c>/<c>0x100003fc</c>),
|
|
/// <c>gmCGSkillsPage::UpdateCreditsMeter
|
|
/// @ 0x004808f0</c> (credits display is the raw
|
|
/// <c>remainingSkillCredits</c> — no formula). The 16 skill ids uncostable
|
|
/// in BOTH the heritage's own list and the global SkillTable (retail's own
|
|
/// skills listbox never lists them either — CC1's
|
|
/// <c>ChargenTableReaderInstalledDatTests</c>) are filtered out via the
|
|
/// same two-tier presence check <c>RuntimeCharacterCreationState</c>'s
|
|
/// <c>TryGetSkillCost</c> uses.
|
|
///
|
|
/// <para>
|
|
/// <b>GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16):</b>
|
|
/// <c>RebuildRows</c> used to require <c>Templates[0]</c>'s resolved root to
|
|
/// be a <c>UiButton</c> and treat its own Label as the row's whole content —
|
|
/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and
|
|
/// <c>gmCGSkillsPage::DoSkillRecords @ 0x004817e0</c>:
|
|
/// <c>Templates[0]</c> (<c>0x100002F4</c>, 3 children) is retail's own
|
|
/// bucket-HEADER row (Specialized/Trained/UseableUntrained/
|
|
/// UnuseableUntrained — unused by this port's flat-list simplification,
|
|
/// AP-213), and the REAL skill row is <c>Templates[1]</c>
|
|
/// (<c>0x100002FF</c>, a plain container root, 7 children). Byte-traced
|
|
/// through <c>DoSkillRecords</c>' own <c>GetChildRecursive</c> calls +
|
|
/// <c>tagSkillRecord</c>'s copy-constructor field order
|
|
/// (<c>acclient.h</c> struct <c>gmCGSkillsPage::tagSkillRecord</c>):
|
|
/// <c>0x10000301</c> = the skill NAME (set once at row build, never
|
|
/// refreshed — retail has no per-refresh name write either),
|
|
/// <c>0x10000302</c> = <c>pSkillLevelText</c> (the numeric skill SCORE,
|
|
/// <c>CharGenState::GetSkillScore</c>), <c>0x10000303</c> =
|
|
/// <c>pUpCostText</c>, <c>0x10000306</c> = <c>pDownCostText</c>,
|
|
/// <c>0x10000304</c> = <c>pSkillUpButton</c> (fires
|
|
/// <c>IncreaseSkillLevel</c> on plain click,
|
|
/// <c>ListenToElementMessage @0x004814c0</c> case <c>0x10000304</c>),
|
|
/// <c>0x10000305</c> = <c>pSkillDownButton</c> (fires
|
|
/// <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.
|
|
/// </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) 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(...,
|
|
/// 1)</c> re-select) now selects that skill — the row's NAME text swaps to
|
|
/// <see cref="SelectedNameColor"/> (best-derived "brighter white" per the
|
|
/// 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>) — 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
|
|
/// <see cref="AcDream.App.UI.UiTemplateListBox.Scroll"/> — the SAME
|
|
/// page-level <c>UiScrollbar.Model</c> linkage every other
|
|
/// <c>UiTemplateListBox</c> owner uses (no widget change).</item>
|
|
/// <item>Review F1 (cost text): <c>SetSkillText</c>'s Untrained down-cost
|
|
/// (<c>@0x00480877</c>) and Specialized up-cost (<c>@0x0048067f</c>) are
|
|
/// literal <c>"0"</c>, unconditional — the prior port rendered blank
|
|
/// (<c>null</c>) instead. The <c><0x3e7</c> (999) blank gate exists
|
|
/// ONLY on the up-cost at Untrained (<c>@0x00480819</c>) and Trained
|
|
/// (<c>@0x0048071f</c>); every down-cost write is unconditional
|
|
/// (<c>@0x00480877</c>/<c>@0x00480780</c>/<c>@0x004806c1</c>), including
|
|
/// Trained's raw <c>iTrainCost</c> even when it would exceed 999.</item>
|
|
/// <item>Review F2 (arrow states): <c>SetSkillText</c> ends every branch
|
|
/// driving <c>pSkillUpButton</c>/<c>pSkillDownButton</c> through its own
|
|
/// custom Ghosted/Enabled state pair (<see cref="ArrowGhostedStateId"/>/
|
|
/// <see cref="ArrowEnabledStateId"/> — raw ids via
|
|
/// <see cref="IUiDatStateful.TrySetRetailState"/>, the SAME "authored
|
|
/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
|
|
/// <c>remainingSkillCredits</c> vs. the advance cost and is ALWAYS ghosted
|
|
/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained
|
|
/// (nothing below it) and otherwise gated on <c>bUntrainable</c>/
|
|
/// <c>bUnspecializable</c> — re-derived from <c>DoSkillRecords</c>'s own
|
|
/// tagSkillRecord build (<c>@0x00480e40</c>-region) as "the row's OWN
|
|
/// effective trained/specialized cost is nonzero" (a free/heritage-granted
|
|
/// skill or specialization locks its own down arrow), using the SAME
|
|
/// heritage-then-global cost this page already resolves via
|
|
/// <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
|
|
/// <c>0x00481d5d</c> runs OUTSIDE the per-refresh <c>SetSkillText</c>
|
|
/// call).</summary>
|
|
private const uint RowNameTextId = 0x10000301u;
|
|
|
|
/// <summary><c>tagSkillRecord::pSkillLevelText</c> — the numeric skill
|
|
/// SCORE (<c>SetSkillText @0x00480600</c>'s
|
|
/// <c>CharGenState::GetSkillScore</c> call, "%d" format).</summary>
|
|
private const uint RowLevelTextId = 0x10000302u;
|
|
|
|
/// <summary><c>tagSkillRecord::pUpCostText</c>.</summary>
|
|
private const uint RowUpCostTextId = 0x10000303u;
|
|
|
|
/// <summary><c>tagSkillRecord::pDownCostText</c>.</summary>
|
|
private const uint RowDownCostTextId = 0x10000306u;
|
|
|
|
/// <summary><c>tagSkillRecord::pSkillUpButton</c> —
|
|
/// <c>ListenToElementMessage</c>'s case <c>0x10000304</c> fires
|
|
/// <c>IncreaseSkillLevel</c> on a plain click (<c>idMessage==1</c>).</summary>
|
|
private const uint RowUpButtonId = 0x10000304u;
|
|
|
|
/// <summary><c>tagSkillRecord::pSkillDownButton</c> — same dispatcher's
|
|
/// case <c>0x10000305</c> fires <c>DecreaseSkillLevel</c>.</summary>
|
|
private const uint RowDownButtonId = 0x10000305u;
|
|
|
|
/// <summary>Retail's own custom Ghosted state id for
|
|
/// <c>pSkillUpButton</c>/<c>pSkillDownButton</c> (<c>SetSkillText</c>'s
|
|
/// own <c>SetState(0x1000001a)</c> calls) — distinct from the standard
|
|
/// <c>UiButtonStateMachine.Ghosted</c> (13) numbering; the same
|
|
/// "authored custom pair, raw retail id" shape as GF-1's
|
|
/// Unselected/Selected (<c>0x10000016</c>/<c>0x10000017</c>).</summary>
|
|
private const uint ArrowGhostedStateId = 0x1000001Au;
|
|
|
|
/// <summary>Retail's own custom Enabled state id for the same two
|
|
/// buttons (<c>SetState(0x1000001b)</c>).</summary>
|
|
private const uint ArrowEnabledStateId = 0x1000001Bu;
|
|
|
|
/// <summary>R2-4a row-selection highlight: pure white. Re-derived from
|
|
/// the GF-11b precedent (list-caption color swap Normal
|
|
/// <c>(218,167,85)</c> -> Highlight/white <c>(255,255,255)</c> on
|
|
/// selection) plus the user's own report ("retail selection turns the
|
|
/// row brighter white") absent a skills-row-specific cdb capture — the
|
|
/// direction (unselected -> brighter/whiter) is directly evidenced;
|
|
/// the exact target RGB is the best available derivation, not a live
|
|
/// measurement.</summary>
|
|
private static readonly Vector4 SelectedNameColor = Vector4.One;
|
|
|
|
/// <summary>One built skill row: the resolved <c>Templates[1]</c>
|
|
/// subtree plus the child widgets <see cref="RefreshRowValues"/> needs
|
|
/// 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.
|
|
/// <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,
|
|
UiText? DownCostText,
|
|
UiButton? UpButton,
|
|
UiButton? DownButton,
|
|
Vector4 UnselectedNameColor);
|
|
|
|
private readonly CharacterCreationRuntimeBindings _bindings;
|
|
private readonly UiTemplateListBox? _list;
|
|
private readonly UiButton? _credits;
|
|
private readonly UiText? _infoTitle;
|
|
private readonly UiText? _infoText;
|
|
private readonly List<SkillRow> _rows = [];
|
|
private uint _lastHeritageId;
|
|
private uint? _selectedSkillId;
|
|
private bool _rowsBuilt;
|
|
private bool _disposed;
|
|
|
|
internal CharacterCreationSkillsPage(
|
|
UiElement pageRoot,
|
|
CharacterCreationRuntimeBindings bindings,
|
|
Func<uint, uint, UiElement?> templateResolver)
|
|
{
|
|
_bindings = bindings;
|
|
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
|
|
if (_list is not null)
|
|
_list.TemplateResolver = templateResolver;
|
|
|
|
// R2-4c (Batch F): wire the listbox's own authored scrollbar (dat
|
|
// property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME
|
|
// page-level Model linkage every other UiTemplateListBox owner uses
|
|
// (ConfigOptionsPageController, SocialFriendsPageController, et
|
|
// al.) — no widget change, just resolving the id the importer
|
|
// already read and pointing its Model at this listbox's own Scroll.
|
|
if (_list is not null
|
|
&& _list.ScrollbarElementId != 0
|
|
&& UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar)
|
|
{
|
|
scrollbar.Model = _list.Scroll;
|
|
}
|
|
|
|
// Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
|
|
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
|
|
// dat CHILD of button 0x100003f9, not as a standalone descendant of
|
|
// the page root. UiButton.ConsumesDatChildren swallows it before it
|
|
// becomes an addressable widget (the same reason UiMeter's overlay
|
|
// text needed an explicit carve-out in LayoutImporter) — the
|
|
// faithful substitute is the button's own Label, which is exactly
|
|
// the mechanism our factory already uses to surface a consumed
|
|
// Type-12 child's text (register AD-103).
|
|
//
|
|
// GF-4a (Campaign CC gate round 1 Batch C): this used to clobber
|
|
// the button's authored "Available Skill Credits" caption (retail's
|
|
// own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the
|
|
// caption text — gmCGSkillsPage::InitializePage @0x00481e1c).
|
|
// DatWidgetFactory.BuildButton now surfaces that media-less Type-12
|
|
// child as UiButton.ValueLabel, coexisting with Label — see
|
|
// Refresh below.
|
|
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
|
|
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
|
|
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
|
|
|
|
// The two panes author no 0x15. The shared importer now applies
|
|
// retail's constructor default (raw 4 -> Top), so no page-local
|
|
// justification correction is needed.
|
|
|
|
// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
|
|
// own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT-
|
|
// measured) extends 20px PAST the bottom of the gold decorative
|
|
// frame that visually contains BOTH info panes (0x100003fa, Y=430
|
|
// H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family
|
|
// Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own
|
|
// ShowSkillsText @0x00481250 has NO code relationship between the
|
|
// text panes and this frame (SetText only; no clip/size handoff),
|
|
// and the frame's 8 children carry no dat property linking them to
|
|
// 0x100003fc either — so the frame's own geometry is the only
|
|
// authored ground truth for "the visible box," and this port's
|
|
// multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height))
|
|
// was using the WRONG (larger, unbounded) Height, letting a long
|
|
// skill's formula line draw into blank page space below the frame's
|
|
// own border instead of being contained by it. Clamped to the
|
|
// frame's own bottom edge (never grows it — additive, defensive if a
|
|
// future dat re-extract makes the frame taller than the pane).
|
|
// Scoped exactly like the VJustify.Top correction above: this is
|
|
// NOT the client-wide "does a text pane's clip account for a
|
|
// sibling decorative frame" mechanism (no evidence any other pane in
|
|
// this codebase has the SAME independently-authored-taller-than-its-
|
|
// frame shape), so a general import-time fix is unwarranted here.
|
|
if (_infoText is { } clampedInfoText
|
|
&& UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame)
|
|
{
|
|
float frameBottom = frame.Top + frame.Height;
|
|
float paneBottom = clampedInfoText.Top + clampedInfoText.Height;
|
|
if (frameBottom < paneBottom)
|
|
clampedInfoText.Height = frameBottom - clampedInfoText.Top;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gold decorative frame (Type 12, 8 sprite children — the SAME
|
|
/// GF-12 corner/edge family) that visually contains BOTH info panes
|
|
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) — see the R4-3 clamp above.
|
|
/// </summary>
|
|
private const uint InfoBoxFrameElementId = 0x100003FAu;
|
|
|
|
internal void Refresh(
|
|
IRuntimeCharacterCreationView view,
|
|
RuntimeCharacterCreationSnapshot snapshot)
|
|
{
|
|
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)
|
|
RefreshRowValues(row, view, snapshot);
|
|
|
|
RefreshInfoBox(view, snapshot);
|
|
|
|
if (_credits is { } credits)
|
|
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)
|
|
{
|
|
if (row.UpButton is not null) row.UpButton.OnClick = null;
|
|
if (row.DownButton is not null) row.DownButton.OnClick = null;
|
|
if (row.Root is UiDatElement datRoot) datRoot.OnClick = null;
|
|
}
|
|
_rows.Clear();
|
|
_list?.Flush();
|
|
|
|
// 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();
|
|
|
|
if (_list is null
|
|
|| _list.Templates.Count < 2
|
|
|| _list.TemplateResolver is null
|
|
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
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(
|
|
SkillRow row,
|
|
IRuntimeCharacterCreationView view,
|
|
RuntimeCharacterCreationSnapshot snapshot)
|
|
{
|
|
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
|
|
(int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId);
|
|
uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u;
|
|
|
|
if (row.LevelText is { } levelText)
|
|
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
|
|
|
|
// Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact
|
|
// per-state cost text + arrow-enable pair — see this class's own
|
|
// header doc for the full byte trace of every address cited below.
|
|
string upCostText;
|
|
string downCostText;
|
|
bool upEnabled;
|
|
bool downEnabled;
|
|
switch (level)
|
|
{
|
|
case ChargenSkillAdvancementClass.Specialized:
|
|
// @0x0048067f: up = literal "0", unconditional (nothing
|
|
// above Specialized). @0x004806c1: down = specCost-
|
|
// trainCost, UNCONDITIONAL (no 999-blank gate).
|
|
// @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c +
|
|
// @0x004807f1/@0x004807f4: down arrow enabled iff
|
|
// bUnspecializable — re-derived as specializedCost != 0
|
|
// (a free/heritage-granted specialization, cost 0, locks
|
|
// its own down arrow — DoSkillRecords zeroes
|
|
// bUnspecializable exactly there, @0x00480e40 region).
|
|
upCostText = "0";
|
|
downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture);
|
|
upEnabled = false;
|
|
downEnabled = specializedCost != 0;
|
|
break;
|
|
case ChargenSkillAdvancementClass.Trained:
|
|
// @0x0048071f: up = specCost-trainCost, blank if >=999.
|
|
// @0x00480780: down = trainCost, UNCONDITIONAL (no gate,
|
|
// even past 999). @0x004807ce: up arrow enabled iff
|
|
// remainingSkillCredits >= specCost-trainCost.
|
|
// @0x004807ec + @0x004807f1/@0x004807f4: down arrow
|
|
// enabled iff bUntrainable — re-derived as trainedCost != 0
|
|
// (same free-skill-locks-the-down-arrow rule, mirrored on
|
|
// the trained cost).
|
|
upCostText = FormatGatedCost(specializedCost - trainedCost);
|
|
downCostText = trainedCost.ToString(CultureInfo.InvariantCulture);
|
|
upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost;
|
|
downEnabled = trainedCost != 0;
|
|
break;
|
|
default:
|
|
// Untrained/Inactive. @0x00480819: up = trainCost, blank if
|
|
// >=999. @0x00480877: down = literal "0", unconditional.
|
|
// @0x004808b3: down arrow ALWAYS ghosted (nothing below
|
|
// Untrained). @0x004808d1: up arrow enabled iff
|
|
// remainingSkillCredits >= trainCost.
|
|
upCostText = FormatGatedCost(trainedCost);
|
|
downCostText = "0";
|
|
upEnabled = snapshot.RemainingSkillCredits >= trainedCost;
|
|
downEnabled = false;
|
|
break;
|
|
}
|
|
|
|
if (row.UpCostText is { } upCostTextWidget)
|
|
SetLine(upCostTextWidget, upCostText);
|
|
if (row.DownCostText is { } downCostTextWidget)
|
|
SetLine(downCostTextWidget, downCostText);
|
|
row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
|
|
row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
|
|
}
|
|
|
|
/// <summary>The up-cost-only 999 blank gate (<c>< 0x3e7</c>,
|
|
/// <c>data_794320</c> — an empty <c>PStringBase</c>). Never applied to a
|
|
/// down-cost or a literal "0" write — see the per-branch citations in
|
|
/// <see cref="RefreshRowValues"/>.</summary>
|
|
private static string FormatGatedCost(int cost) =>
|
|
cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty;
|
|
|
|
private static void SetLine(UiText text, string content) =>
|
|
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
|
|
|
|
/// <summary>Same dictionary-presence gate as
|
|
/// <c>RuntimeCharacterCreationState.TryGetSkillCost</c> — heritage list
|
|
/// first, global SkillTable fallback.</summary>
|
|
private static bool IsCostable(
|
|
ChargenHeritageOptions heritage,
|
|
ChargenOptions options,
|
|
uint skillId) =>
|
|
heritage.SkillCostsBySkillId.ContainsKey(skillId)
|
|
|| options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
|
|
|
|
private static (int Trained, int Specialized) GetCosts(
|
|
IRuntimeCharacterCreationView view,
|
|
uint heritageId,
|
|
uint skillId)
|
|
{
|
|
if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
|
{
|
|
if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
|
|
return (cost.NormalCost, cost.PrimaryCost);
|
|
}
|
|
if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global))
|
|
return (global.NormalCost, global.PrimaryCost);
|
|
return (0, 0);
|
|
}
|
|
|
|
/// <summary><c>pSkillUpButton</c> click: <c>IncreaseSkillLevel
|
|
/// @0x00480ca0</c> — Untrained/Inactive -> Trained,
|
|
/// Trained -> Specialized.</summary>
|
|
private void Advance(uint skillId)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
|
|
?? ChargenSkillAdvancementClass.Inactive;
|
|
if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained)
|
|
_bindings.TrainSkill(skillId);
|
|
else if (level == ChargenSkillAdvancementClass.Trained)
|
|
_bindings.SpecializeSkill(skillId);
|
|
}
|
|
|
|
/// <summary><c>pSkillDownButton</c> click: <c>DecreaseSkillLevel
|
|
/// @0x00480d60</c> — Specialized -> Trained,
|
|
/// Trained -> Untrained.</summary>
|
|
private void Retreat(uint skillId)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
|
|
?? ChargenSkillAdvancementClass.Inactive;
|
|
if (level == ChargenSkillAdvancementClass.Specialized)
|
|
_bindings.TrainSkill(skillId);
|
|
else if (level == ChargenSkillAdvancementClass.Trained)
|
|
_bindings.UntrainSkill(skillId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-4a: row click / arrow click selection — the port's equivalent of
|
|
/// retail's listbox-level <c>SetSelectedItem</c> notification (see
|
|
/// <see cref="RebuildRows"/>'s own wiring doc). Applies the highlight
|
|
/// to every row (so the PREVIOUSLY selected row also gets restored to
|
|
/// its own <see cref="SkillRow.UnselectedNameColor"/>) and refreshes
|
|
/// the info panes for the newly selected skill. <see cref="_bindings"/>'
|
|
/// <c>View</c> is resolved fresh here, never cached, per
|
|
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>.
|
|
/// </summary>
|
|
private void SelectRow(uint skillId)
|
|
{
|
|
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 == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>gmCGSkillsPage::ShowSkillsText @0x00481250</c> — writes
|
|
/// <c>m_pInfoBoxTitle</c> (<c>0x100003fb</c>) and <c>m_pInfoBoxText</c>
|
|
/// (<c>0x100003fc</c>) for the currently selected skill, or clears both
|
|
/// 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: 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), 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>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)
|
|
{
|
|
if (_selectedSkillId is not { } skillId)
|
|
{
|
|
ClearInfoBox();
|
|
return;
|
|
}
|
|
|
|
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
|
|
uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u;
|
|
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
|
|
|
|
if (_infoTitle is { } title)
|
|
SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})");
|
|
|
|
if (_infoText is { } text)
|
|
{
|
|
string bonus = level switch
|
|
{
|
|
ChargenSkillAdvancementClass.Trained => "Training Bonus +5",
|
|
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
|
|
_ => string.Empty,
|
|
};
|
|
|
|
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);
|
|
if (_infoText is { } text) SetLine(text, string.Empty);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
foreach (SkillRow row in _rows)
|
|
{
|
|
if (row.UpButton is not null) row.UpButton.OnClick = null;
|
|
if (row.DownButton is not null) row.DownButton.OnClick = null;
|
|
if (row.Root is UiDatElement datRow) datRow.OnClick = null;
|
|
}
|
|
_rows.Clear();
|
|
_list?.Flush();
|
|
if (_list is not null)
|
|
_list.TemplateResolver = null;
|
|
}
|
|
}
|