feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port

Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage
(name field with NameInputFilter + the retail commit-on-focus-lost/submit
dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the
REAL three-row-template listbox confirmed against the installed EoR dat
before writing any page code, and Summary's own independent gmCG3DView
preview instance wired through a second ChargenPreviewController pair
mirroring the Appearance page's exact composition shape).

Ports CharGenState::RandomizeCharacter and its six sub-primitives into
RuntimeCharacterCreationState — not approximated: the RandInt/RollDice
semantics are independently confirmed from both the decompiled RNG bodies
and the CharGenStateVtbl union struct in acclient.h. Three consumers:
the chargen screen's open-roll (retiring AP-214's honest-blank deviation
and reproducing the Appearance page's gender-flip-on-init quirk), the
Summary page's Random button (behind the retail randomize-warning
confirm), and the Appearance page's Random button (narrowing AP-212 to
just Heritage/Profession/Town's still-approximated rolls and Skills'
still-unported RandomizeSkills).

Wires the Finish button (previously ghosted) with retail's NoName/
CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset
local refusal to TryBeginFinish (register AP-223) as a defensive backstop
now that the screen-open roll normally makes it unreachable, and wires
the four ID_Character_Err_* rejection dialogs for the 0xF643 response
codes CC3 already parsed but nothing displayed.

Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225
filed (heritage/gender Finish refusal, Summary's two-bucket skill-list
narrowing, the 32-vs-33 name-length threshold reconciliation).

Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0
unchanged, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 00:01:07 +02:00
parent 6114b2dda2
commit 34e3a534be
22 changed files with 2217 additions and 79 deletions

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@ -43,6 +44,23 @@ public sealed record CharacterCreationRuntimeBindings(
/// <see langword="null"/> degrades to the heritage's own DAT
/// <c>Name</c> field instead of the full composed copy.</summary>
Func<string, string?>? ResolveText = null,
/// <summary>Campaign CC slice CC5: the Summary page's name field
/// commit (<c>gmCGSummaryPage::ListenToElementMessage</c>'s
/// <c>CharGenState::SetName</c> call).</summary>
Func<string, RuntimeCommandResult>? SetName = null,
/// <summary>CC5: dismisses a surfaced <c>0xF643</c> rejection after its
/// dialog closes (<c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>).</summary>
Func<RuntimeCommandResult>? AcknowledgeRejection = null,
/// <summary>CC5: the screen-open roll
/// (<c>gmCharGenMainUI</c>'s ctor-time <c>RandomizeCharacter</c> call)
/// and the Summary page's Random button.</summary>
Func<RuntimeCommandResult>? RandomizeCharacter = null,
/// <summary>CC5: the Appearance page's Random button on its Face
/// sub-tab.</summary>
Func<RuntimeCommandResult>? RandomizeAppearance = null,
/// <summary>CC5: the Appearance page's Random button on its Clothes
/// sub-tab.</summary>
Func<RuntimeCommandResult>? RandomizeClothing = null,
bool OpenOnStart = false);
/// <summary>
@ -106,7 +124,23 @@ internal sealed class CharacterCreationUiController : IDisposable
Summary = 6,
}
internal sealed record DialogStrings(string ExitWarning);
internal sealed record DialogStrings(
string ExitWarning,
/// <summary>Campaign CC slice CC5: <c>ID_CharGen_NoNameWarning</c> —
/// <c>DoFinish</c>'s empty-name refusal dialog.</summary>
string NoNameWarning,
/// <summary>CC5: <c>ID_CharGen_CreditWarning</c> —
/// <c>MakeCreditWarningDialog</c>'s unspent-attribute-credits
/// confirmation.</summary>
string CreditWarning,
/// <summary>CC5: <c>ID_CharGen_RandomizeWarning</c> —
/// <c>MakeRandomizeWarningDialog</c>'s Summary-page Random
/// confirmation.</summary>
string RandomizeWarning,
/// <summary>CC5: <c>ID_CharGen_NameTooLong</c> —
/// <c>gmCGSummaryPage::DoNameLimitDialog</c>'s name-field-too-long
/// notice.</summary>
string NameTooLong);
private readonly UiRoot _host;
private readonly ImportedLayout _layout;
@ -138,6 +172,7 @@ internal sealed class CharacterCreationUiController : IDisposable
private readonly CharacterCreationSkillsPage _skillsPage;
private readonly CharacterCreationTownPage _townPage;
private readonly CharacterCreationAppearancePage _appearancePage;
private readonly CharacterCreationSummaryPage _summaryPage;
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration;
@ -147,6 +182,13 @@ internal sealed class CharacterCreationUiController : IDisposable
private bool _isOpen;
private bool _openOnStartConsumed;
private uint _exitDialogContext;
// Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/
// m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same
// one-outstanding-dialog-at-a-time guard shape as _exitDialogContext.
private uint _creditWarningDialogContext;
private uint _randomizeWarningDialogContext;
private uint _noNameWarningDialogContext;
private RuntimeCharacterCreationRejection? _lastShownRejection;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -227,18 +269,18 @@ internal sealed class CharacterCreationUiController : IDisposable
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
_summaryPage = new CharacterCreationSummaryPage(
summaryPageRoot, bindings, dialogs, strings.NameTooLong);
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
_back.OnClick = OnBack;
_next.OnClick = OnNext;
// Finish (0x100003c8) stays ghosted this round: Summary
// (0x100003d6) is CC5's placeholder, and DoFinish's real gate
// sequence lives in RuntimeCharacterCreationState.TryBeginFinish —
// wiring the button here without a Summary page to confirm/collect
// the name would let a click reach the wire with an empty name and
// silently refuse. No OnClick handler; _finish.Enabled stays false
// (see ApplyProgressState).
_finish.OnClick = null;
// Finish (0x100003c8): retail enables it on Summary only
// (ListenToElementMessage's case 0x100003c8 no-ops unless
// m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState
// gates _finish.Enabled the same way. OnFinish itself re-checks the
// current page defensively (mirroring that same retail guard).
_finish.OnClick = OnFinish;
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
// ListenToElementMessage switch (case 0x100003c9 falls straight
// through to the base UIFramework handler) — retail has no custom
@ -280,6 +322,26 @@ internal sealed class CharacterCreationUiController : IDisposable
/// paperdoll's own outer-inventory-frame gate.</summary>
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
/// <summary>Campaign CC slice CC5: the authored Summary-page viewport
/// (<c>0x10000406</c>) — its OWN <c>gmCG3DView</c> instance, distinct
/// from the Appearance page's (see this class's own class doc on the
/// decomp citation).</summary>
internal UiViewport? SummaryViewport => _summaryPage.Viewport;
/// <summary>CC5: the Summary preview's late-bound control surface. No
/// zoom/rotate buttons bind against it — see
/// <see cref="AcDream.App.Rendering.RetailSummaryPreviewPageVisibility"/>'s
/// doc comment.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
{
get => _summaryPage.PreviewControl;
set => _summaryPage.PreviewControl = value;
}
/// <summary>CC5: same shape as <see cref="IsAppearancePageVisible"/>,
/// for the Summary page.</summary>
internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible;
internal static CharacterCreationUiController? CreateDetached(
UiRoot host,
ImportedLayout layout,
@ -404,6 +466,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_skillsPage.Refresh(view, snapshot);
_townPage.Refresh(view, snapshot);
_appearancePage.Refresh(view, snapshot);
_summaryPage.Refresh(view, snapshot);
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
@ -426,9 +489,40 @@ internal sealed class CharacterCreationUiController : IDisposable
return;
_isOpen = true;
_host.DeclareFixedCanvas(this, _authoredCanvas);
RollOpeningCharacter();
ApplyProgressState(Page.Heritage);
}
/// <summary>
/// Campaign CC slice CC5: ports <c>gmCharGenMainUI</c>'s ctor-time roll
/// (<c>~0x004e81f5-0x004e8218</c>) — <c>CharGenState::RandomizeCharacter
/// (state, hasToD) @ 0x005c6d80</c> runs BEFORE any page constructs,
/// retiring AP-214's honest-blank deviation (retail's chargen screen is
/// never actually blank on open). Then reproduces
/// <c>gmCGAppearancePage::InitializePage</c>'s own gender-read-then-FLIP
/// (<c>~0x004802da-0x00480303</c>, decomp-confirmed:
/// <c>mGender==1 -&gt; SetGender(2)</c>, <c>mGender==2 -&gt; SetGender(1)</c>) —
/// a genuine, always-firing retail quirk that runs immediately AFTER
/// <c>RandomizeCharacter</c> already assigned a real (non-zero) gender.
/// Retail's whole UI tree (every page, including Appearance) is
/// reconstructed fresh each time the chargen screen opens, so the flip
/// fires once per visit there; acdream's pages are built once at mount
/// time and only toggle visibility, so <see cref="Open"/> — the closest
/// analogue to "runs once per screen-open" this architecture has — is
/// where both the roll and the flip belong.
/// </summary>
private void RollOpeningCharacter()
{
if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted)
return;
uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u;
if (gender == 1u)
_bindings.SelectGender(2u);
else if (gender == 2u)
_bindings.SelectGender(1u);
}
private void Close()
{
if (!_isOpen)
@ -472,6 +566,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_skillsPage.Dispose();
_townPage.Dispose();
_appearancePage.Dispose();
_summaryPage.Dispose();
_host.RemoveChild(Root);
}
}
@ -535,10 +630,16 @@ internal sealed class CharacterCreationUiController : IDisposable
return;
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
// are ported below; Skills' CharGenState::RandomizeSkills and the
// Summary randomize-warning dialog have no CC3 primitive/page yet
// this round — register AP-212 covers both gaps, and _random.Enabled
// already keeps the control ghosted on those pages (ApplyProgressState).
// still use the AP-212 uniform-pick approximation (unchanged this
// slice); Appearance now delegates to the page's own real
// RandomizeAppearance/RandomizeClothing primitives (CC5); Skills'
// CharGenState::RandomizeSkills remains unported (AP-212, narrowed) —
// _random.Enabled already keeps the control ghosted there
// (ApplyProgressState). Summary goes through
// gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first —
// that dialog + its confirm-triggered RandomizeCharacter call are
// gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's),
// so they live here on the master controller.
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
@ -552,12 +653,44 @@ internal sealed class CharacterCreationUiController : IDisposable
case Page.Profession:
_professionPage.Randomize(snapshot);
break;
case Page.Appearance:
_appearancePage.Randomize();
break;
case Page.Town:
_townPage.Randomize(view);
break;
case Page.Summary:
ShowRandomizeWarningDialog();
break;
}
}
/// <summary>Ports <c>gmCharGenMainUI::MakeRandomizeWarningDialog @
/// 0x004e8a90</c> (<c>ID_CharGen_RandomizeWarning</c>) +
/// <c>CloseRandomizeWarningDialog @ 0x004e8400</c>'s own confirm arm
/// (<c>arg2 != 0 -&gt; DoRandom(this)</c>, which on THIS second call
/// takes <c>DoRandom</c>'s Summary case directly — no re-entrant
/// warning, since the gate lives in the button-click dispatcher above,
/// not inside <c>DoRandom</c> itself).</summary>
private void ShowRandomizeWarningDialog()
{
// MakeRandomizeWarningDialog's own guard: a second click while the
// dialog is already open is a no-op.
if (_randomizeWarningDialogContext != 0u)
return;
_randomizeWarningDialogContext = _dialogs.MakeConfirmation(
_strings.RandomizeWarning,
data =>
{
_randomizeWarningDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
_bindings.RandomizeCharacter?.Invoke();
});
}
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
private void ApplyProgressState(Page target)
@ -641,19 +774,14 @@ internal sealed class CharacterCreationUiController : IDisposable
break;
}
// Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70
// case 3 fully ENABLES Random on Appearance (RandomizeClothing when
// m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this
// is NOT a placeholder gap the way the old comment claimed. The
// disable here rests on the SAME unported-primitive gap AP-212
// tracks for Skills (no RandomizeSkills) and Summary (no
// RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are
// two more of AP-212's six named-but-unported primitives.
_random.Enabled = _currentPage
is not (Page.Skills or Page.Appearance or Page.Summary);
// Finish stays ghosted regardless of page — Summary is a
// placeholder this round (see the ctor comment on _finish.OnClick).
_finish.Enabled = false;
// Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing
// (Appearance) and RandomizeCharacter (Summary), retiring both gaps
// AP-212 used to track for those two pages — only Skills'
// RandomizeSkills remains unported (AP-212, narrowed).
_random.Enabled = _currentPage is not Page.Skills;
// Finish (0x100003c8): retail enables it on Summary only
// (ListenToElementMessage's case 0x100003c8 no-ops off Summary).
_finish.Enabled = _currentPage == Page.Summary;
_lastRevision = long.MinValue;
Tick();
@ -719,12 +847,130 @@ internal sealed class CharacterCreationUiController : IDisposable
// Else (including Lugian, 0x100005f1): no-op, matching retail.
}
// ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ─────────────────
/// <summary>The Finish button's ordinary click — retail's <c>arg2 = 1</c>
/// call site (<c>0x004E9579</c>). Re-checks the current page defensively,
/// mirroring <c>ListenToElementMessage</c>'s own
/// <c>m_eProgressState != ECG_SUMMARY</c> no-op guard.</summary>
private void OnFinish()
{
if (_disposed || _currentPage != Page.Summary)
return;
TryFinish(confirmedUnspentCredits: false);
}
/// <summary>
/// Sends via <see cref="CharacterCreationRuntimeBindings.Finish"/>
/// (which itself calls <c>RuntimeCharacterCreationState.TryBeginFinish</c>);
/// on a LOCAL refusal, surfaces retail's own dialog for the two refusal
/// reasons retail dialogs at all (<c>NoName</c> ->
/// <c>ID_CharGen_NoNameWarning</c>; <c>AttributeCreditsUnspent</c> ->
/// the credit-warning confirm, whose OWN confirm re-invokes this method
/// with <paramref name="confirmedUnspentCredits"/> — retail's
/// <c>arg2 == 0</c> call site, <c>0x004E98BB</c>). The remaining local
/// refusals (<c>HeritageOrGenderUnset</c>, <c>AlreadyPending</c>,
/// <c>RosterFull</c>) have no retail dialog citation — retail's own
/// <c>DoFinish</c> silently falls through to its final <c>return 0</c>
/// for an already-Pending double-click, and the other two are
/// acdream-only additions (register AP-223, AP-211) with the same
/// silent-refusal shape.
/// </summary>
private void TryFinish(bool confirmedUnspentCredits)
{
if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected)
return;
RuntimeCharacterCreationLocalRefusal refusal =
_bindings.View()?.Snapshot.LastLocalRefusal ?? default;
if (refusal.NoName)
ShowNoNameWarningDialog();
else if (refusal.AttributeCreditsUnspent)
ShowCreditWarningDialog();
}
/// <summary>Ports the empty-name half of <c>DoFinish</c>
/// (<c>ID_CharGen_NoNameWarning</c>, <c>@0x004e91dd</c>) — a plain
/// informational dialog, no confirm/cancel semantics.</summary>
private void ShowNoNameWarningDialog()
{
if (_noNameWarningDialogContext != 0u)
return;
_noNameWarningDialogContext = _dialogs.MakeMessage(
_strings.NoNameWarning,
data =>
{
_ = data;
_noNameWarningDialogContext = 0u;
});
}
/// <summary>Ports <c>gmCharGenMainUI::MakeCreditWarningDialog @
/// 0x004e8870</c> (<c>ID_CharGen_CreditWarning</c>) — on confirm,
/// re-invokes <see cref="TryFinish"/> with
/// <c>confirmedUnspentCredits: true</c>, retail's <c>DoFinish(this, 0)</c>
/// call at <c>RecvNotice_CloseDialog @0x004e98bb</c>.</summary>
private void ShowCreditWarningDialog()
{
if (_creditWarningDialogContext != 0u)
return;
_creditWarningDialogContext = _dialogs.MakeConfirmation(
_strings.CreditWarning,
data =>
{
_creditWarningDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
TryFinish(confirmedUnspentCredits: true);
});
}
// ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ──
// ── 0x0055E8B0) ──────────────────────────────────────────────────────
/// <summary>Ports the four rejection-dialog mappings from
/// <c>Handle_CharGenVerificationResponse</c>'s per-case switch (restated
/// on <see cref="RuntimeCharacterCreationRejection"/>'s own doc
/// comment); Pending/Undef never reach this method (CC3's
/// <c>ApplyCreationResponse</c> treats them as a silent state reset with
/// no <see cref="RuntimeCharacterCreationRejection"/> produced at all).
/// Dedups against the LAST rejection instance already shown so a
/// same-value re-check on a later <see cref="Tick"/> (this method runs
/// every tick, not just on revision change) doesn't reopen the dialog
/// the player already dismissed.</summary>
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
{
// Local-refusal / rejection surfacing is CC5's Summary-page job
// (the Finish gate only fires from that page). This round only
// needs the exit-confirmation dialog reconciled against disposal.
_ = snapshot;
RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection;
if (rejection is null)
{
_lastShownRejection = null;
return;
}
if (_lastShownRejection == rejection)
return;
_lastShownRejection = rejection;
string? key = rejection.Value.Code switch
{
CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved",
CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned",
CharGenVerificationResponse.Code.Corrupt
or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown",
CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied",
_ => null,
};
if (key is null)
return;
string? message = _bindings.ResolveText?.Invoke(key);
if (message is null)
return;
_dialogs.MakeMessage(message, data =>
{
_ = data;
_bindings.AcknowledgeRejection?.Invoke();
});
}
private void Deactivate()
@ -750,6 +996,24 @@ internal sealed class CharacterCreationUiController : IDisposable
_exitDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
if (_creditWarningDialogContext != 0u)
{
uint closing = _creditWarningDialogContext;
_creditWarningDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
if (_randomizeWarningDialogContext != 0u)
{
uint closing = _randomizeWarningDialogContext;
_randomizeWarningDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
if (_noNameWarningDialogContext != 0u)
{
uint closing = _noNameWarningDialogContext;
_noNameWarningDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
}
finally
{