acdream/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Erik 0e71d3b829 feat(app): Campaign CC slice CC4 — chargen screen shell + Heritage/Profession/Skills/Town pages
Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.

Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.

Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.

Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.

Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 17:45:51 +02:00

650 lines
24 KiB
C#

using System.Numerics;
using AcDream.Core.CharGen;
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>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,
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. The Appearance (<c>0x100003d4</c>) and Summary
/// (<c>0x100003d6</c>) page roots are mounted but content-inert — CC6/CC5
/// fill them (register TS-82).
///
/// <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);
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 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;
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 — see that controller's own comment.
// Both screens author the identical extent, so it is safe for both
// controllers to independently (idempotently) push the SAME value
// to the shared UiRoot.FixedCanvasSize; this controller therefore
// never NULLS it back out on close (see Deactivate/Close), leaving
// char-management's own per-tick set as the surviving owner once
// this screen is not the active one.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings);
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
// 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;
// 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;
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.FixedCanvasSize = _authoredCanvas;
_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);
_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>).</summary>
internal void Open()
{
if (_disposed)
return;
_isOpen = true;
ApplyProgressState(Page.Heritage);
}
private void Close()
{
_isOpen = false;
Root.Visible = false;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
CloseAllDialogs(suppressCallbacks: true);
}
finally
{
_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();
_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
// 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).
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.Town:
_townPage.Randomize(view);
break;
}
}
// ── 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): retail refuses on Skills (no
// RandomizeSkills primitive ported — AP-212) and on Summary
// (MakeRandomizeWarningDialog is CC5's); Appearance is this round's
// placeholder.
_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;
_lastRevision = long.MinValue;
Tick();
}
private void SetMasterPageState(uint stateId)
{
if (_masterPage is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
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;
}
private void Deactivate()
{
if (_active)
{
_active = false;
_isOpen = false;
_openOnStartConsumed = false;
Root.Visible = false;
}
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);
}
}
finally
{
_suppressDialogCallbacks = previous;
}
}
}