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

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

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

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

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

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

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

View file

@ -282,6 +282,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
RebuildPreview(view, snapshot);
}
/// <summary>
/// Campaign CC slice CC5: ports the Appearance case of
/// <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c> (case 3) —
/// <c>m_eCurType == ECG_CHOICE_CLOTHES -&gt;
/// CharGenState::RandomizeClothing(state, 1)</c>, else
/// <c>CharGenState::RandomizeAppearance(state, 0)</c>. Retires the
/// Appearance half of register AP-212 (the primitives are now real,
/// faithful ports — see <c>RuntimeCharacterCreationState</c>'s own
/// Randomize section — not a uniform-pick approximation).
/// </summary>
internal void Randomize()
{
if (_disposed)
return;
if (_currentChoice == Choice.Clothes)
_bindings.RandomizeClothing?.Invoke();
else
_bindings.RandomizeAppearance?.Invoke();
}
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
private void SelectChoice(Choice choice)

View file

@ -0,0 +1,372 @@
using System.Globalization;
using AcDream.App.Rendering;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Summary page (<c>gmCGSummaryPage</c>, root <c>0x100003d6</c>) —
/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder.
/// Decomp anchors: <c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>
/// (widget ids, its OWN <c>gmCG3DView</c> instance, camera set + 180°
/// heading + <c>StartAnimation</c> — a live idle-animated preview, not a
/// static frozen frame), <c>::SetSummaryText @ 0x0047b1d0</c> (the listbox's
/// three-template row content: template 0 = a single <c>UiText</c> line,
/// template 1 = a category-header <c>UiText</c>, template 2 = a two-column
/// key/value <c>UiText</c> pair — live-DAT-probe-confirmed against the
/// installed EoR dat, resolving DID <c>0x2100004C</c> elements
/// <c>0x100002F8/FA/FB</c>), <c>::ListenToElementMessage @ 0x0047bf40</c>
/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the
/// &gt;32-char <c>ID_CharGen_NameTooLong</c> reject-and-revert path — see
/// <see cref="CommitNameFromField"/>'s own doc comment for the 32-vs-33
/// reconciliation), <c>::DoNameLimitDialog @ 0x0047bd80</c>.
///
/// <para>
/// <b>Listbox content scope cut (register-worthy, AP-213's own precedent):</b>
/// retail's skills section walks FOUR buckets (Specialized/Trained/
/// UseableUntrained/UnuseableUntrained) and lists every skill name in each.
/// This port lists Specialized and Trained only — the two buckets a player
/// actually spent credits on and would review before Finishing — and skips
/// the two Untrained buckets (which would otherwise list the ~50 skills the
/// player did NOT touch, adding volume without decision-relevant
/// information). Health/Stamina/Mana reuse
/// <see cref="CharacterCreationProfessionPage"/>'s own already-cited
/// <c>UpdateAttributeValues @ 0x00482450</c> formulas (Health=Endurance/2,
/// Stamina=Endurance, Mana=Self) rather than this page's OWN
/// <c>SetSummaryText</c> call site, whose two GetAttribute calls for
/// Health/Stamina are decompiler-ambiguous (both show a literal attribute
/// index of 2 — ProfessionPage's site is the cleaner citation).
/// </para>
/// </summary>
internal sealed class CharacterCreationSummaryPage : IDisposable
{
internal const uint ListBoxId = 0x10000400u;
internal const uint ScrollId = 0x10000401u;
internal const uint NameTextId = 0x10000402u;
internal const uint HowToTextId = 0x10000404u;
internal const uint ViewportId = 0x10000406u;
/// <summary>Row-template child ids, live-DAT-probe-confirmed:
/// template 0's single line, template 1's header line, template 2's
/// key/value pair.</summary>
private const uint SingleLineTextId = 0x100002F9u;
private const uint HeaderTextId = 0x100000FEu;
private const uint KeyTextId = 0x100002FCu;
private const uint ValueTextId = 0x100002FDu;
/// <summary>Retail's <c>name[33]</c> buffer (32 usable chars + null
/// terminator — <c>RuntimeCharacterCreationState.TrySetName</c>'s own
/// already-established storage cap). The decompiled UI-side check at
/// <c>ListenToElementMessage @ 0x0047bfd1</c> compares the raw input
/// length against the literal <c>0x21</c> (33) — one more than this —
/// but that comparison's exact base (visible character count vs. an
/// internal length-prefix accounting the decompiler didn't resolve
/// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps
/// the UI-level reject-and-revert threshold CONSISTENT with the
/// already-reviewed storage cap rather than trusting an ambiguous
/// 1-off decomp literal over that established contract.</summary>
private const int MaxNameLength = 32;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly RetailDialogFactory _dialogs;
private readonly string _nameTooLongMessage;
private readonly UiTemplateListBox? _list;
private readonly UiField? _nameField;
private string _lastCommittedName = string.Empty;
private bool _suppressNextFieldEvent;
private uint _nameTooLongDialogContext;
private bool _disposed;
/// <summary>Late-bound preview control seam — see
/// <see cref="IChargenPreviewControl"/>'s own doc comment for why this
/// page cannot receive the real renderer at construction time.</summary>
internal IChargenPreviewControl? PreviewControl { get; set; }
/// <summary>The authored viewport (<c>0x10000406</c>) — Summary's OWN
/// <c>gmCG3DView</c> instance, distinct from the Appearance page's.</summary>
internal UiViewport? Viewport { get; }
internal CharacterCreationSummaryPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
RetailDialogFactory dialogs,
string nameTooLongMessage)
{
_bindings = bindings;
_dialogs = dialogs;
_nameTooLongMessage = nameTooLongMessage;
_list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
if (_nameField is not null)
{
// NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe,
// hyphen — everything else is rejected per keystroke.
_nameField.CharacterFilter = NameInputFilter;
// Deliberately NOT capping UiField.MaxCharacters at MaxNameLength:
// retail's own >32-char check (ListenToElementMessage's own
// GetText().m_charbuffer length compare) only fires at COMMIT
// time (idMessage 0x12/0x44), which means the textbox itself
// accepts MORE than 32 characters while typing — the
// DoNameLimitDialog reject-and-revert path exists specifically
// to catch that post-typing case. A per-keystroke cap here would
// make that whole retail code path structurally unreachable.
// ListenToElementMessage @ 0x0047bf50: the name field commits on
// idMessage 0x12 OR 0x44 — acdream's UiField exposes those two
// triggers as OnFocusLost (clicking/tabbing away) and OnSubmit
// (Enter). Both route through the same commit path.
_nameField.OnFocusLost = CommitNameFromField;
_nameField.OnSubmit = CommitNameFromField;
_nameField.ClearOnSubmit = false;
_nameField.RecordHistory = false;
}
Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (_disposed)
return;
// Keep the field's displayed text in sync with the committed name
// unless the player is actively typing (a mid-edit Refresh — driven
// by an unrelated selection change elsewhere on the screen — must
// not clobber their in-progress keystrokes).
if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name)
{
_suppressNextFieldEvent = true;
field.SetText(snapshot.Name);
_lastCommittedName = snapshot.Name;
}
RebuildListbox(view, snapshot);
RebuildPreview(view, snapshot);
}
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
private void CommitNameFromField(string text)
{
if (_disposed)
return;
if (_suppressNextFieldEvent)
{
_suppressNextFieldEvent = false;
return;
}
if (text.Length > MaxNameLength)
{
// DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong):
// revert the field to the last COMMITTED name rather than the
// rejected input.
_nameField?.SetText(_lastCommittedName);
ShowNameTooLongDialog();
return;
}
_lastCommittedName = text;
_bindings.SetName?.Invoke(text);
}
private void ShowNameTooLongDialog()
{
// DoNameLimitDialog's own guard: a context already open is a no-op.
if (_nameTooLongDialogContext != 0u)
return;
_nameTooLongDialogContext = _dialogs.MakeMessage(
_nameTooLongMessage,
data =>
{
_ = data;
_nameTooLongDialogContext = 0u;
});
}
/// <summary>Ports <c>NameInputFilter @ 0x004663b0</c> exactly: ASCII
/// letters (<c>isalpha</c>), space (<c>0x20</c>), apostrophe
/// (<c>0x27</c>), or hyphen (<c>0x2d</c>).</summary>
private static bool NameInputFilter(char c) =>
(c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-';
// ── Listbox (SetSummaryText @ 0x0047b1d0) ───────────────────────────
private void RebuildListbox(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (_list is null || _list.Templates.Count < 3)
return;
_list.Flush();
if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage))
return;
UiTemplateListEntry lineTemplate = _list.Templates[0];
UiTemplateListEntry headerTemplate = _list.Templates[1];
UiTemplateListEntry pairTemplate = _list.Templates[2];
AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template));
AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey));
AddLine(lineTemplate, "Heritage: " + heritage.Name);
AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea));
AddHeader(headerTemplate, "Attributes");
ChargenAttributeValues a = snapshot.Attributes;
AddPair(pairTemplate, "Strength", a.Strength);
AddPair(pairTemplate, "Endurance", a.Endurance);
AddPair(pairTemplate, "Coordination", a.Coordination);
AddPair(pairTemplate, "Quickness", a.Quickness);
AddPair(pairTemplate, "Focus", a.Focus);
AddPair(pairTemplate, "Self", a.Self);
// CharacterCreationProfessionPage::Refresh's own already-cited
// UpdateAttributeValues formulas (Health=Endurance/2, Stamina=
// Endurance, Mana=Self) — see this class's own doc comment on why
// that citation is used here instead of this page's own
// decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair.
AddPair(pairTemplate, "Health", a.Endurance / 2);
AddPair(pairTemplate, "Stamina", a.Endurance);
AddPair(pairTemplate, "Mana", a.Self);
AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits);
AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained);
}
private void AddLine(UiTemplateListEntry template, string text)
{
if (ResolveTemplateChild(template, SingleLineTextId) is { } child)
SetLine(child, text);
}
private void AddHeader(UiTemplateListEntry template, string text)
{
if (ResolveTemplateChild(template, HeaderTextId) is { } child)
SetLine(child, text);
}
private void AddPair(UiTemplateListEntry template, string key, int value)
{
UiElement? row = ResolveTemplateRow(template);
if (row is null)
return;
if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText)
SetLine(keyText, key);
if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText)
SetLine(valueText, value.ToString(CultureInfo.InvariantCulture));
}
private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
private UiElement? ResolveTemplateRow(UiTemplateListEntry template)
{
if (_list is null || _list.TemplateResolver is null)
return null;
UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId);
if (row is null)
return null;
_list.AddPrebuiltRow(row);
return row;
}
private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId)
{
UiElement? row = ResolveTemplateRow(template);
return row is null ? null : UiElement.FindDescendant(row, childId) as UiText;
}
private void AddSkillBucket(
UiTemplateListEntry headerTemplate,
UiTemplateListEntry lineTemplate,
IRuntimeCharacterCreationView view,
string header,
ChargenSkillAdvancementClass targetClass)
{
bool any = false;
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (view.GetSkillLevel(skillId) != targetClass)
continue;
if (!any)
{
AddHeader(headerTemplate, header);
any = true;
}
AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId));
}
}
private static string ProfessionName(ChargenHeritageOptions heritage, uint template) =>
template != RuntimeCharacterCreationSnapshot.TemplateUnset
&& template < (uint)heritage.Templates.Count
? heritage.Templates[(int)template].Name
: "None";
private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) =>
heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender)
? gender.Name
: "None";
private static string StarterAreaName(ChargenOptions options, int startArea) =>
startArea >= 0 && startArea < options.StarterAreas.Count
? options.StarterAreas[startArea].Name
: "None";
// ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set +
// ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ───
private void RebuildPreview(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (PreviewControl is null
|| snapshot.HeritageId == 0u
|| snapshot.GenderKey == 0u)
{
return;
}
RuntimeCharacterCreationAppearance a = snapshot.Appearance;
var selection = new ChargenAppearanceSelection(
a.EyesStrip, a.NoseStrip, a.MouthStrip,
a.HairStyle, a.HairColor, a.EyeColor,
a.HeadgearStyle, a.HeadgearColor,
a.ShirtStyle, a.ShirtColor,
a.TrousersStyle, a.TrousersColor,
a.FootwearStyle, a.FootwearColor,
a.SkinShade, a.HairShade, a.HeadgearShade,
a.ShirtShade, a.TrousersShade, a.FootwearShade);
PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_nameField is not null)
{
_nameField.OnFocusLost = null;
_nameField.OnSubmit = null;
}
if (_nameTooLongDialogContext != 0u)
{
uint closing = _nameTooLongDialogContext;
_nameTooLongDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
_list?.Flush();
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.
PreviewControl = null;
}
}

View file

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

View file

@ -669,6 +669,34 @@ public sealed class RetailUiRuntime : IDisposable
internal bool IsChargenPreviewPageVisible =>
CharacterCreationController?.IsAppearancePageVisible ?? false;
/// <summary>Campaign CC slice CC5: the Summary page's OWN authored
/// viewport (<c>0x10000406</c>) — same one-shot GPU-composition
/// disposition as <see cref="ChargenPreviewViewportWidget"/> (see that
/// property's own doc comment; AP-221 covers both).</summary>
internal UiViewport? SummaryPreviewViewportWidget =>
CharacterCreationController?.SummaryViewport;
/// <summary>Campaign CC slice CC5: the Summary preview's late-bound
/// control surface. No zoom/rotate buttons bind against it (retail's
/// Summary page has none) — the composition root assigns it purely so
/// <see cref="AcDream.App.Rendering.ChargenPreviewController.Rebuild"/>
/// gets driven per-selection-change the same way the Appearance
/// preview's is.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
{
get => CharacterCreationController?.SummaryPreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.SummaryPreviewControl = value;
}
}
/// <summary>Campaign CC slice CC5: whether the Summary page
/// (specifically) is the one currently showing.</summary>
internal bool IsSummaryPreviewPageVisible =>
CharacterCreationController?.IsSummaryPageVisible ?? false;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
@ -3977,14 +4005,38 @@ public sealed class RetailUiRuntime : IDisposable
}
string? exitWarning;
string? noNameWarning;
string? creditWarning;
string? randomizeWarning;
string? nameTooLong;
lock (_bindings.Assets.DatLock)
{
exitWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_ExitWarning");
noNameWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NoNameWarning");
creditWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_CreditWarning");
randomizeWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_RandomizeWarning");
nameTooLong = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NameTooLong");
}
if (exitWarning is null)
if (exitWarning is null
|| noNameWarning is null
|| creditWarning is null
|| randomizeWarning is null
|| nameTooLong is null)
{
Console.WriteLine(
"[UI] character creation: required retail strings are unavailable.");
@ -4009,7 +4061,8 @@ public sealed class RetailUiRuntime : IDisposable
layoutId,
layout,
ResolveTemplate,
new CharacterCreationUiController.DialogStrings(exitWarning));
new CharacterCreationUiController.DialogStrings(
exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong));
}
private void MountItemCooldowns()