acdream/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Erik 34e3a534be 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>
2026-08-16 00:01:07 +02:00

1023 lines
42 KiB
C#

using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Bindings the retail character-creation screen (<c>gmCharGenMainUI</c>)
/// needs beyond the borrowed view: generation-capturing command wrappers,
/// mirroring <see cref="CharacterSelectionRuntimeBindings"/>'s shape exactly.
/// Every <c>Func</c> here is a late-bound seam (Campaign CC — see
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>): callers MUST resolve
/// it per-call, never capture the delegate once at mount time.
/// </summary>
/// <param name="OpenOnStart">Campaign CC slice CC4: the retail
/// transition is Create Character (<c>0x100003A0</c>) →
/// <c>QueueUIMode(0x1000000b)</c>, but that button stays ghosted until CC7's
/// closing move. This flag is the interim env/test-only open seam
/// (<c>ACDREAM_OPEN_CHARGEN=1</c> → <see cref="AcDream.App.RuntimeOptions.OpenCharacterCreationOnStart"/>)
/// so the screen can be exercised before the real button is wired.</param>
public sealed record CharacterCreationRuntimeBindings(
Func<IRuntimeCharacterCreationView?> View,
Func<uint, RuntimeCommandResult> SelectHeritage,
Func<uint, RuntimeCommandResult> SelectGender,
Func<uint, RuntimeCommandResult> SelectTemplate,
Func<ChargenAttributeId, int, RuntimeCommandResult> SetAttribute,
Func<ChargenAttributeId, bool, RuntimeCommandResult> SetAttributeLock,
Func<uint, RuntimeCommandResult> TrainSkill,
Func<uint, RuntimeCommandResult> SpecializeSkill,
Func<uint, RuntimeCommandResult> UntrainSkill,
Func<int, RuntimeCommandResult> SelectStartArea,
Func<bool, RuntimeCommandResult> Finish,
Action RequestExit,
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's nine
/// spin controls and nine color swatches.</summary>
Func<ChargenAppearanceSlot, uint, RuntimeCommandResult>? SetAppearanceIndex = null,
/// <summary>CC6b-MOUNT: the Appearance page's shade scrollbar.</summary>
Func<ChargenShadeSlot, double, RuntimeCommandResult>? SetShade = null,
/// <summary>DAT string lookup (table <c>0x23000002</c>, the SAME table
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
/// through) — used by the Heritage page's composed description text.
/// <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>
/// Projects Runtime's borrowed <see cref="IRuntimeCharacterCreationView"/>
/// through retail <c>gmCharGenMainUI</c>'s authored retained layout — the
/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/
/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this
/// slice builds. Fix round F6: the Appearance (<c>0x100003d4</c>) page root
/// is fully LIVE as of CC6b-MOUNT (<see cref="CharacterCreationAppearancePage"/>);
/// only the Summary (<c>0x100003d6</c>) page root remains mounted but
/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only
/// at CC6b-MOUNT).
///
/// <para>
/// Decomp anchors: root construction + child resolution
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> (root element
/// <c>0x100003cc</c> from enum <c>0x10000039</c>); page switching
/// <c>gmCharGenMainUI::SetProgressState @ 0x004e7a10</c> (the Olthoi
/// tab-hiding + redirect logic); nav dispatch
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>; exit
/// confirmation <c>gmCharGenMainUI::DoExit @ 0x004e8650</c>; randomize
/// dispatch <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c>.
/// </para>
/// </summary>
internal sealed class CharacterCreationUiController : IDisposable
{
internal const uint RootEnum = 0x10000039u;
internal const uint RootElementId = 0x100003CCu;
internal const uint ProgressBarElementId = 0x100003CEu;
internal const uint BackElementId = 0x100003C6u;
internal const uint NextElementId = 0x100003C7u;
internal const uint FinishElementId = 0x100003C8u;
internal const uint HelpElementId = 0x100003C9u;
internal const uint ExitElementId = 0x100003CAu;
internal const uint RandomElementId = 0x100003CBu;
internal const uint MasterPageElementId = 0x100003D0u;
internal const uint HeritagePageElementId = 0x100003D1u;
internal const uint ProfessionPageElementId = 0x100003D2u;
internal const uint SkillsPageElementId = 0x100003D3u;
internal const uint AppearancePageElementId = 0x100003D4u;
internal const uint TownPageElementId = 0x100003D5u;
internal const uint SummaryPageElementId = 0x100003D6u;
internal const uint HeritageTabElementId = 0x100003EFu;
internal const uint ProfessionTabElementId = 0x100003F0u;
internal const uint SkillsTabElementId = 0x100003F1u;
internal const uint AppearanceTabElementId = 0x100003F2u;
internal const uint TownTabElementId = 0x100003F3u;
internal const uint SummaryTabElementId = 0x100003F4u;
/// <summary>Retail's <c>gmCharGenMainUI::ECGProgress</c> enum values —
/// used verbatim as the master page's per-page state ids
/// (<c>0x10000025 + (page - 1)</c>) and the tab-hide/redirect math in
/// <see cref="ApplyProgressState"/>.</summary>
internal enum Page
{
Heritage = 1,
Profession = 2,
Skills = 3,
Appearance = 4,
Town = 5,
Summary = 6,
}
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;
private readonly UiElement _progressBar;
private readonly UiButton _back;
private readonly UiButton _next;
private readonly UiButton _finish;
private readonly UiButton _help;
private readonly UiButton _exit;
private readonly UiButton _random;
private readonly UiElement _masterPage;
private readonly UiElement _heritagePageRoot;
private readonly UiElement _professionPageRoot;
private readonly UiElement _skillsPageRoot;
private readonly UiElement _appearancePageRoot;
private readonly UiElement _townPageRoot;
private readonly UiElement _summaryPageRoot;
private readonly UiButton _heritageTab;
private readonly UiButton _professionTab;
private readonly UiButton _skillsTab;
private readonly UiButton _appearanceTab;
private readonly UiButton _townTab;
private readonly UiButton _summaryTab;
private readonly RetailDialogFactory _dialogs;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly DialogStrings _strings;
private readonly CharacterCreationHeritagePage _heritagePage;
private readonly CharacterCreationProfessionPage _professionPage;
private readonly CharacterCreationSkillsPage _skillsPage;
private readonly CharacterCreationTownPage _townPage;
private readonly CharacterCreationAppearancePage _appearancePage;
private readonly CharacterCreationSummaryPage _summaryPage;
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue;
private Page _currentPage = Page.Heritage;
private bool _active;
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;
private CharacterCreationUiController(
UiRoot host,
ImportedLayout layout,
UiElement progressBar,
UiButton back,
UiButton next,
UiButton finish,
UiButton help,
UiButton exit,
UiButton random,
UiElement masterPage,
UiElement heritagePageRoot,
UiElement professionPageRoot,
UiElement skillsPageRoot,
UiElement appearancePageRoot,
UiElement townPageRoot,
UiElement summaryPageRoot,
UiButton heritageTab,
UiButton professionTab,
UiButton skillsTab,
UiButton appearanceTab,
UiButton townTab,
UiButton summaryTab,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterCreationRuntimeBindings bindings,
DialogStrings strings)
{
_host = host;
_layout = layout;
_progressBar = progressBar;
_back = back;
_next = next;
_finish = finish;
_help = help;
_exit = exit;
_random = random;
_masterPage = masterPage;
_heritagePageRoot = heritagePageRoot;
_professionPageRoot = professionPageRoot;
_skillsPageRoot = skillsPageRoot;
_appearancePageRoot = appearancePageRoot;
_townPageRoot = townPageRoot;
_summaryPageRoot = summaryPageRoot;
_heritageTab = heritageTab;
_professionTab = professionTab;
_skillsTab = skillsTab;
_appearanceTab = appearanceTab;
_townTab = townTab;
_summaryTab = summaryTab;
_dialogs = dialogs;
_bindings = bindings;
_strings = strings;
Root.Left = 0f;
Root.Top = 0f;
Root.ClickThrough = false;
Root.Visible = false;
// AD-98: the same authored 800x600 fixed-canvas treatment as the
// character-management screen. Both controllers DECLARE/REVOKE
// through UiRoot's owner-scoped arbiter (review fix round R1,
// 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly —
// char-management can be simultaneously active underneath this
// screen, and a raw write from either controller is a last-writer-
// wins race with no owner (the F1 fix's own Close() null wiped
// char-management's still-active canvas out from under it). See
// Open/Close/Deactivate/Dispose below for the matching declare/
// revoke pair.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings, ApplyHeritageTabRestore);
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
_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): 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
// help action here either; leave it a no-op.
_help.OnClick = null;
_exit.OnClick = OnExit;
_random.OnClick = OnRandom;
_heritageTab.OnClick = () => ApplyProgressState(Page.Heritage);
_professionTab.OnClick = () => ApplyProgressState(Page.Profession);
_skillsTab.OnClick = () => ApplyProgressState(Page.Skills);
_appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance);
_townTab.OnClick = () => ApplyProgressState(Page.Town);
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
}
internal UiElement Root => _layout.Root;
/// <summary>The authored Appearance-page viewport (<c>0x100003bb</c>) —
/// CC6b-MOUNT's composition root assigns its <c>Renderer</c> once the
/// graphics backend exists (mirrors the paperdoll's own late
/// <c>viewport.Renderer = ...</c> assignment).</summary>
internal UiViewport? AppearanceViewport => _appearancePage.Viewport;
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface —
/// see <see cref="AcDream.App.Rendering.IChargenPreviewControl"/>'s own
/// doc comment for why this is assigned after construction rather than
/// threaded through the ctor.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl
{
get => _appearancePage.PreviewControl;
set => _appearancePage.PreviewControl = value;
}
/// <summary>Gates the Appearance preview's per-frame work on whether
/// that specific page — AND the whole chargen screen — is the one
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,
/// not the individual page roots, so a page-root-only check would stay
/// true after the screen closes on the Appearance page. Mirrors the
/// 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,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterCreationRuntimeBindings bindings,
DialogStrings strings)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(strings);
if (layout.Root.DatElementId != RootElementId
|| layout.FindElement(ProgressBarElementId) is not { } progressBar
|| layout.FindElement(BackElementId) is not UiButton back
|| layout.FindElement(NextElementId) is not UiButton next
|| layout.FindElement(FinishElementId) is not UiButton finish
|| layout.FindElement(HelpElementId) is not UiButton help
|| layout.FindElement(ExitElementId) is not UiButton exit
|| layout.FindElement(RandomElementId) is not UiButton random
|| layout.FindElement(MasterPageElementId) is not { } masterPage
|| layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot
|| layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot
|| layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot
|| layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot
|| layout.FindElement(TownPageElementId) is not { } townPageRoot
|| layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot
|| layout.FindElement(HeritageTabElementId) is not UiButton heritageTab
|| layout.FindElement(ProfessionTabElementId) is not UiButton professionTab
|| layout.FindElement(SkillsTabElementId) is not UiButton skillsTab
|| layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab
|| layout.FindElement(TownTabElementId) is not UiButton townTab
|| layout.FindElement(SummaryTabElementId) is not UiButton summaryTab)
{
Console.WriteLine(
"[UI] character creation: the authored root/master-shell contract is incomplete.");
return null;
}
return new CharacterCreationUiController(
host,
layout,
progressBar,
back,
next,
finish,
help,
exit,
random,
masterPage,
heritagePageRoot,
professionPageRoot,
skillsPageRoot,
appearancePageRoot,
townPageRoot,
summaryPageRoot,
heritageTab,
professionTab,
skillsTab,
appearanceTab,
townTab,
summaryTab,
templateResolver,
dialogs,
bindings,
strings);
}
internal void AttachAndTick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Root.Parent is null)
_host.AddChild(Root);
Tick();
}
internal void Tick()
{
if (_disposed)
return;
IRuntimeCharacterCreationView? view = _bindings.View();
RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
if (!_active)
{
_active = true;
// CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real
// Create-button transition is CC7's. Fires once per mount.
if (_bindings.OpenOnStart && !_openOnStartConsumed)
{
_openOnStartConsumed = true;
Open();
}
}
if (_isOpen)
{
Root.Visible = true;
_host.BringToFront(Root);
}
else
{
Root.Visible = false;
}
if (_lastGeneration != snapshot.Generation
|| _lastRevision != snapshot.Revision)
{
_heritagePage.Refresh(view, snapshot);
_professionPage.Refresh(view, snapshot);
_skillsPage.Refresh(view, snapshot);
_townPage.Refresh(view, snapshot);
_appearancePage.Refresh(view, snapshot);
_summaryPage.Refresh(view, snapshot);
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
ReconcileDialogs(snapshot);
}
/// <summary>Opens the screen at retail's authored default page
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Declares the fixed
/// canvas on this exact activation edge through <see cref="UiRoot"/>'s
/// arbiter — matching <see cref="CharacterManagementUiController"/>'s
/// own one-shot declare — not per-tick; <see cref="Close"/>/
/// <see cref="Deactivate"/>/<see cref="Dispose"/> revoke it back out
/// symmetrically, and the canvas stays set for as long as ANY other
/// declarer (e.g. character-management underneath) remains active.</summary>
internal void Open()
{
if (_disposed)
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)
return;
_isOpen = false;
Root.Visible = false;
_host.RevokeFixedCanvas(this);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
CloseAllDialogs(suppressCallbacks: true);
}
finally
{
// Matches CharacterManagementUiController.Dispose's own
// unconditional revoke — defends against disposing while
// _isOpen (Close() is not otherwise called on this path). Idle
// if Close() already revoked (RevokeFixedCanvas is a no-op for
// an owner that already revoked).
_host.RevokeFixedCanvas(this);
_back.OnClick = null;
_next.OnClick = null;
_finish.OnClick = null;
_help.OnClick = null;
_exit.OnClick = null;
_random.OnClick = null;
_heritageTab.OnClick = null;
_professionTab.OnClick = null;
_skillsTab.OnClick = null;
_appearanceTab.OnClick = null;
_townTab.OnClick = null;
_summaryTab.OnClick = null;
_heritagePage.Dispose();
_professionPage.Dispose();
_skillsPage.Dispose();
_townPage.Dispose();
_appearancePage.Dispose();
_summaryPage.Dispose();
_host.RemoveChild(Root);
}
}
// ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ──
private void OnBack()
{
if (_disposed)
return;
if (_currentPage <= Page.Heritage)
{
OnExit();
return;
}
ApplyProgressState(_currentPage - 1);
}
private void OnNext()
{
if (_disposed)
return;
if (_currentPage < Page.Summary)
ApplyProgressState(_currentPage + 1);
}
private void OnExit()
{
if (_disposed)
return;
// gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit
// click while the confirmation is already open is a no-op.
if (_exitDialogContext != 0u)
return;
_exitDialogContext = _dialogs.MakeConfirmation(
_strings.ExitWarning,
data =>
{
_exitDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
// RecvNotice_CloseDialog @ 0x004e9780's exit-context branch:
// confirm -> QueueUIMode(0x1000000a) (leave chargen). Our
// equivalent is closing this screen; whatever mounted the
// character-management screen already keeps re-drawing it
// underneath (this screen only BringToFront's itself while
// open — see Tick).
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
{
Close();
_bindings.RequestExit();
}
});
}
private void OnRandom()
{
if (_disposed)
return;
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
// 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;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
switch (_currentPage)
{
case Page.Heritage:
_heritagePage.Randomize(snapshot);
break;
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)
{
_heritagePageRoot.Visible = false;
_professionPageRoot.Visible = false;
_skillsPageRoot.Visible = false;
_appearancePageRoot.Visible = false;
_townPageRoot.Visible = false;
_summaryPageRoot.Visible = false;
_next.Visible = true;
_finish.Visible = false;
Page previous = _currentPage;
_currentPage = target;
_heritageTab.Selected = false;
_professionTab.Selected = false;
_skillsTab.Selected = false;
_appearanceTab.Selected = false;
_townTab.Selected = false;
_summaryTab.Selected = false;
uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u;
bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi
|| heritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
if (isOlthoi)
{
_professionTab.Visible = false;
_skillsTab.Visible = false;
_townTab.Visible = false;
if (_currentPage < previous)
{
if (_currentPage is Page.Profession or Page.Skills)
_currentPage = Page.Heritage;
else if (_currentPage == Page.Town)
_currentPage = Page.Appearance;
}
else
{
if (_currentPage is Page.Profession or Page.Skills)
_currentPage = Page.Appearance;
else if (_currentPage == Page.Town)
_currentPage = Page.Summary;
}
}
else
{
_professionTab.Visible = true;
_skillsTab.Visible = true;
_townTab.Visible = true;
}
SetMasterPageState(0x10000025u + (uint)_currentPage - 1u);
switch (_currentPage)
{
case Page.Heritage:
_heritagePageRoot.Visible = true;
_heritageTab.Selected = true;
break;
case Page.Profession:
_professionPageRoot.Visible = true;
_professionTab.Selected = true;
break;
case Page.Skills:
_skillsPageRoot.Visible = true;
_skillsTab.Selected = true;
break;
case Page.Appearance:
_appearancePageRoot.Visible = true;
_appearanceTab.Selected = true;
break;
case Page.Town:
_townPageRoot.Visible = true;
_townTab.Selected = true;
break;
case Page.Summary:
_summaryPageRoot.Visible = true;
_summaryTab.Selected = true;
_next.Visible = false;
_finish.Visible = true;
break;
}
// 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();
}
private void SetMasterPageState(uint stateId)
{
if (_masterPage is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
// ── Heritage tab-restore (gmCharGenMainUI::ListenToElementMessage @ ────
// ── 0x004e9450, the heritage-button bubble arm) ─────────────────────
/// <summary>SHOW ids (label_4e9673, three <c>SetVisible(1)</c> calls) —
/// verbatim off the decompiled switch's case list at
/// <c>0x004e9450</c>.</summary>
private static readonly IReadOnlySet<uint> HeritageTabShowButtonIds = new HashSet<uint>
{
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu,
0x100005C4u, 0x100005E8u,
};
/// <summary>HIDE ids (@0x004e96b9, three <c>SetVisible(0)</c> calls) —
/// the Olthoi/OlthoiAcid heritage buttons.</summary>
private static readonly IReadOnlySet<uint> HeritageTabHideButtonIds = new HashSet<uint>
{
0x100005C7u, 0x100005C8u,
};
/// <summary>
/// Ports <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
/// heritage-button tab-restore arm: heritage-button clicks bubble to
/// the master shell and SYNCHRONOUSLY show/hide the Profession/Skills/
/// Town tabs, independent of <see cref="ApplyProgressState"/>'s own
/// tab-visibility recompute at page-switch time (that recompute only
/// runs when Back/Next/a tab is clicked — not on every heritage pick).
/// Retail quirk reproduced faithfully: Lugian's button id
/// (<c>0x100005f1</c>) sits OUTSIDE both the SHOW and HIDE case lists
/// in the decompiled switch, so clicking Lugian neither restores nor
/// hides the tabs — a genuine retail bug (the tabs stay in whatever
/// state the PREVIOUS heritage selection left them), not an acdream
/// omission. Review fix round F3 (2026-08-15): this arm was entirely
/// unported — before this fix, selecting a human heritage right after
/// Olthoi/OlthoiAcid left the tabs hidden until the next Back/Next/tab
/// click recomputed them.
/// </summary>
private void ApplyHeritageTabRestore(uint buttonElementId)
{
if (HeritageTabShowButtonIds.Contains(buttonElementId))
{
_professionTab.Visible = true;
_skillsTab.Visible = true;
_townTab.Visible = true;
}
else if (HeritageTabHideButtonIds.Contains(buttonElementId))
{
_professionTab.Visible = false;
_skillsTab.Visible = false;
_townTab.Visible = false;
}
// 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)
{
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()
{
if (_active)
{
_active = false;
_openOnStartConsumed = false;
Close();
}
CloseAllDialogs(suppressCallbacks: true);
}
private void CloseAllDialogs(bool suppressCallbacks)
{
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallbacks;
try
{
if (_exitDialogContext != 0u)
{
uint closing = _exitDialogContext;
_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
{
_suppressDialogCallbacks = previous;
}
}
}