using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
///
/// Bindings the retail character-creation screen (gmCharGenMainUI)
/// needs beyond the borrowed view: generation-capturing command wrappers,
/// mirroring 's shape exactly.
/// Every Func here is a late-bound seam (Campaign CC — see
/// feedback_resolve_deferred_funcs_per_call.md): callers MUST resolve
/// it per-call, never capture the delegate once at mount time.
///
/// Campaign CC slice CC4: the retail
/// transition is Create Character (0x100003A0) →
/// QueueUIMode(0x1000000b), but that button stays ghosted until CC7's
/// closing move. This flag is the interim env/test-only open seam
/// (ACDREAM_OPEN_CHARGEN=1 → )
/// so the screen can be exercised before the real button is wired.
public sealed record CharacterCreationRuntimeBindings(
Func View,
Func SelectHeritage,
Func SelectGender,
Func SelectTemplate,
Func SetAttribute,
Func SetAttributeLock,
Func TrainSkill,
Func SpecializeSkill,
Func UntrainSkill,
Func SelectStartArea,
Func Finish,
Action RequestExit,
/// Campaign CC slice CC6b-MOUNT: the Appearance page's nine
/// spin controls and nine color swatches.
Func? SetAppearanceIndex = null,
/// CC6b-MOUNT: the Appearance page's shade scrollbar.
Func? SetShade = null,
/// DAT string lookup (table 0x23000002, the SAME table
/// every other ID_CharGen_*/ID_Character* key resolves
/// through) — used by the Heritage page's composed description text.
/// degrades to the heritage's own DAT
/// Name field instead of the full composed copy.
Func? ResolveText = null,
/// Campaign CC slice CC5: the Summary page's name field
/// commit (gmCGSummaryPage::ListenToElementMessage's
/// CharGenState::SetName call).
Func? SetName = null,
/// CC5: dismisses a surfaced 0xF643 rejection after its
/// dialog closes (RuntimeCharacterCreationState.TryAcknowledgeRejection).
Func? AcknowledgeRejection = null,
/// CC5: the screen-open roll
/// (gmCharGenMainUI's ctor-time RandomizeCharacter call)
/// and the Summary page's Random button.
Func? RandomizeCharacter = null,
/// CC5: the Appearance page's Random button on its Face
/// sub-tab.
Func? RandomizeAppearance = null,
/// CC5: the Appearance page's Random button on its Clothes
/// sub-tab.
Func? RandomizeClothing = null,
bool OpenOnStart = false);
///
/// Projects Runtime's borrowed
/// through retail gmCharGenMainUI'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 (0x100003d4) page root
/// is fully LIVE as of CC6b-MOUNT ();
/// only the Summary (0x100003d6) page root remains mounted but
/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only
/// at CC6b-MOUNT).
///
///
/// Decomp anchors: root construction + child resolution
/// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 (root element
/// 0x100003cc from enum 0x10000039); page switching
/// gmCharGenMainUI::SetProgressState @ 0x004e7a10 (the Olthoi
/// tab-hiding + redirect logic); nav dispatch
/// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450; exit
/// confirmation gmCharGenMainUI::DoExit @ 0x004e8650; randomize
/// dispatch gmCharGenMainUI::DoRandom @ 0x004e7d70.
///
///
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;
/// Retail's gmCharGenMainUI::ECGProgress enum values —
/// used verbatim as the master page's per-page state ids
/// (0x10000025 + (page - 1)) and the tab-hide/redirect math in
/// .
internal enum Page
{
Heritage = 1,
Profession = 2,
Skills = 3,
Appearance = 4,
Town = 5,
Summary = 6,
}
internal sealed record DialogStrings(
string ExitWarning,
/// Campaign CC slice CC5: ID_CharGen_NoNameWarning —
/// DoFinish's empty-name refusal dialog.
string NoNameWarning,
/// CC5: ID_CharGen_CreditWarning —
/// MakeCreditWarningDialog's unspent-attribute-credits
/// confirmation.
string CreditWarning,
/// CC5: ID_CharGen_RandomizeWarning —
/// MakeRandomizeWarningDialog's Summary-page Random
/// confirmation.
string RandomizeWarning,
/// CC5: ID_CharGen_NameTooLong —
/// gmCGSummaryPage::DoNameLimitDialog's name-field-too-long
/// notice.
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 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;
/// The authored Appearance-page viewport (0x100003bb) —
/// CC6b-MOUNT's composition root assigns its Renderer once the
/// graphics backend exists (mirrors the paperdoll's own late
/// viewport.Renderer = ... assignment).
internal UiViewport? AppearanceViewport => _appearancePage.Viewport;
/// CC6b-MOUNT: the late-bound zoom/rotate control surface —
/// see 's own
/// doc comment for why this is assigned after construction rather than
/// threaded through the ctor.
internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl
{
get => _appearancePage.PreviewControl;
set => _appearancePage.PreviewControl = value;
}
/// Gates the Appearance preview's per-frame work on whether
/// that specific page — AND the whole chargen screen — is the one
/// currently showing. Close() only ever hides ,
/// 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.
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
/// Campaign CC slice CC5: the authored Summary-page viewport
/// (0x10000406) — its OWN gmCG3DView instance, distinct
/// from the Appearance page's (see this class's own class doc on the
/// decomp citation).
internal UiViewport? SummaryViewport => _summaryPage.Viewport;
/// CC5: the Summary preview's late-bound control surface. No
/// zoom/rotate buttons bind against it — see
/// 's
/// doc comment.
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
{
get => _summaryPage.PreviewControl;
set => _summaryPage.PreviewControl = value;
}
/// CC5: same shape as ,
/// for the Summary page.
internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible;
internal static CharacterCreationUiController? CreateDetached(
UiRoot host,
ImportedLayout layout,
Func 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);
}
/// Opens the screen at retail's authored default page
/// (gmCharGenMainUI::gmCharGenMainUI's trailing
/// SetProgressState(this, ECG_HERTAGE)). Declares the fixed
/// canvas on this exact activation edge through 's
/// arbiter — matching 's
/// own one-shot declare — not per-tick; /
/// / revoke it back out
/// symmetrically, and the canvas stays set for as long as ANY other
/// declarer (e.g. character-management underneath) remains active.
internal void Open()
{
if (_disposed)
return;
_isOpen = true;
_host.DeclareFixedCanvas(this, _authoredCanvas);
RollOpeningCharacter();
ApplyProgressState(Page.Heritage);
}
///
/// Campaign CC slice CC5: ports gmCharGenMainUI's ctor-time roll
/// (~0x004e81f5-0x004e8218) — CharGenState::RandomizeCharacter
/// (state, hasToD) @ 0x005c6d80 runs BEFORE any page constructs,
/// retiring AP-214's honest-blank deviation (retail's chargen screen is
/// never actually blank on open). Then reproduces
/// gmCGAppearancePage::InitializePage's own gender-read-then-FLIP
/// (~0x004802da-0x00480303, decomp-confirmed:
/// mGender==1 -> SetGender(2), mGender==2 -> SetGender(1)) —
/// a genuine, always-firing retail quirk that runs immediately AFTER
/// RandomizeCharacter 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 — the closest
/// analogue to "runs once per screen-open" this architecture has — is
/// where both the roll and the flip belong.
///
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;
}
}
/// Ports gmCharGenMainUI::MakeRandomizeWarningDialog @
/// 0x004e8a90 (ID_CharGen_RandomizeWarning) +
/// CloseRandomizeWarningDialog @ 0x004e8400's own confirm arm
/// (arg2 != 0 -> DoRandom(this), which on THIS second call
/// takes DoRandom's Summary case directly — no re-entrant
/// warning, since the gate lives in the button-click dispatcher above,
/// not inside DoRandom itself).
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) ─────────────────────
/// SHOW ids (label_4e9673, three SetVisible(1) calls) —
/// verbatim off the decompiled switch's case list at
/// 0x004e9450.
private static readonly IReadOnlySet HeritageTabShowButtonIds = new HashSet
{
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu,
0x100005C4u, 0x100005E8u,
};
/// HIDE ids (@0x004e96b9, three SetVisible(0) calls) —
/// the Olthoi/OlthoiAcid heritage buttons.
private static readonly IReadOnlySet HeritageTabHideButtonIds = new HashSet
{
0x100005C7u, 0x100005C8u,
};
///
/// Ports gmCharGenMainUI::ListenToElementMessage @ 0x004e9450'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 '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
/// (0x100005f1) 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.
///
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) ─────────────────
/// The Finish button's ordinary click — retail's arg2 = 1
/// call site (0x004E9579). Re-checks the current page defensively,
/// mirroring ListenToElementMessage's own
/// m_eProgressState != ECG_SUMMARY no-op guard.
private void OnFinish()
{
if (_disposed || _currentPage != Page.Summary)
return;
TryFinish(confirmedUnspentCredits: false);
}
///
/// Sends via
/// (which itself calls RuntimeCharacterCreationState.TryBeginFinish);
/// on a LOCAL refusal, surfaces retail's own dialog for the two refusal
/// reasons retail dialogs at all (NoName ->
/// ID_CharGen_NoNameWarning; AttributeCreditsUnspent ->
/// the credit-warning confirm, whose OWN confirm re-invokes this method
/// with — retail's
/// arg2 == 0 call site, 0x004E98BB). The remaining local
/// refusals (HeritageOrGenderUnset, AlreadyPending,
/// RosterFull) have no retail dialog citation — retail's own
/// DoFinish silently falls through to its final return 0
/// 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.
///
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();
}
/// Ports the empty-name half of DoFinish
/// (ID_CharGen_NoNameWarning, @0x004e91dd) — a plain
/// informational dialog, no confirm/cancel semantics.
private void ShowNoNameWarningDialog()
{
if (_noNameWarningDialogContext != 0u)
return;
_noNameWarningDialogContext = _dialogs.MakeMessage(
_strings.NoNameWarning,
data =>
{
_ = data;
_noNameWarningDialogContext = 0u;
});
}
/// Ports gmCharGenMainUI::MakeCreditWarningDialog @
/// 0x004e8870 (ID_CharGen_CreditWarning) — on confirm,
/// re-invokes with
/// confirmedUnspentCredits: true, retail's DoFinish(this, 0)
/// call at RecvNotice_CloseDialog @0x004e98bb.
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) ──────────────────────────────────────────────────────
/// Ports the four rejection-dialog mappings from
/// Handle_CharGenVerificationResponse's per-case switch (restated
/// on 's own doc
/// comment); Pending/Undef never reach this method (CC3's
/// ApplyCreationResponse treats them as a silent state reset with
/// no produced at all).
/// Dedups against the LAST rejection instance already shown so a
/// same-value re-check on a later (this method runs
/// every tick, not just on revision change) doesn't reopen the dialog
/// the player already dismissed.
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;
}
}
}