R2-4/review F1-F2 (gmCGSkillsPage): row click (and arrow click, matching retail's own post-Increase/DecreaseSkillLevel re-select) now selects a skill, highlights its row name, and writes the info panes' title (name + score) and a level-gated bonus line; the description/formula halves stay unported (SkillBase._description/_formula unreachable from this page's current data surface, documented on RefreshInfoBox). The listbox's own authored scrollbar link is wired to its Scroll model (live-DAT-confirmed at 0x100003F8, matching the "+1 from the listbox" hypothesis). Cost text now matches SetSkillText @0x00480600 exactly: Untrained's down-cost and Specialized's up-cost are literal "0", unconditional, where the port previously rendered blank; the 999-blank gate applies to the up-cost only, never to a down-cost. Arrow Ghosted/Enabled state (0x1000001a/ 0x1000001b) is now gated per branch, including bUntrainable/ bUnspecializable re-derived as "this row's own effective cost is nonzero" — no new data needed since the page already resolves that cost. R2-4b (the four-bucket sorted model) is NOT implemented — its Useable- vs-Unuseable-Untrained split reads SkillBase.MinLevel, confirmed present in the installed dat (SkillTable_MinLevelDistribution_NeverExceedsTrained) but not threaded through ChargenOptions/ChargenHeritageOptions/ CharacterCreationRuntimeBindings. AP-213 row records the exact channel a future fix needs. Also live-DAT-pinned: Templates[0]'s header-caption child (0x100002f6) resolves as a UiButton, not UiText, in the real dat — the same UIElement_Button-is-DynamicCast(0xc)-compatible-with-Text quirk already ported for GF-4b's slider labels. App suite (live-DAT env) 5321/3 -> 5328/3 (+7, zero regressions). Runtime 1735/0 unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
578 lines
28 KiB
C#
578 lines
28 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
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>) —
|
|
/// 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
|
|
/// 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 (the
|
|
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
|
|
/// stays registered).
|
|
/// </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).
|
|
/// <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>) — 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>
|
|
/// <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>
|
|
/// </summary>
|
|
internal sealed class CharacterCreationSkillsPage : IDisposable
|
|
{
|
|
/// <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.</summary>
|
|
private readonly record struct SkillRow(
|
|
UiElement Root,
|
|
uint SkillId,
|
|
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;
|
|
}
|
|
|
|
internal void Refresh(
|
|
IRuntimeCharacterCreationView view,
|
|
RuntimeCharacterCreationSnapshot snapshot)
|
|
{
|
|
if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId)
|
|
{
|
|
RebuildRows(view, snapshot.HeritageId);
|
|
_lastHeritageId = snapshot.HeritageId;
|
|
_rowsBuilt = true;
|
|
}
|
|
|
|
foreach (SkillRow row in _rows)
|
|
RefreshRowValues(row, view, snapshot);
|
|
|
|
RefreshInfoBox(view, snapshot);
|
|
|
|
if (_credits is { } credits)
|
|
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
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 under a (possibly new) heritage —
|
|
// any previously selected skill id may no longer exist as a row.
|
|
_selectedSkillId = null;
|
|
ClearInfoBox();
|
|
|
|
if (_list is null
|
|
|| _list.Templates.Count < 2
|
|
|| _list.TemplateResolver is null
|
|
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
|
{
|
|
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];
|
|
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));
|
|
}
|
|
}
|
|
|
|
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;
|
|
foreach (SkillRow row in _rows)
|
|
{
|
|
if (row.NameText is { } nameText)
|
|
nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
|
|
}
|
|
if (_bindings.View() is { } view)
|
|
RefreshInfoBox(view, view.Snapshot);
|
|
}
|
|
|
|
/// <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 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.
|
|
///
|
|
/// <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.
|
|
/// </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,
|
|
};
|
|
SetLine(text, bonus);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|