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

@ -540,7 +540,8 @@ internal sealed class FrameRootCompositionPhase
new PrivateEntityViewportFrameGroup(
live.PaperdollPresenter,
live.CreatureAppraisalPresenter,
live.ChargenPreviewController),
live.ChargenPreviewController,
live.SummaryPreviewController),
retainedGameplayUi,
// The ImGui developer-tools frontend was removed at Campaign V
// slice V11; this optional hook is unbound until a follow-up

View file

@ -1007,6 +1007,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DatStringResolver.ComputeHash(key));
}
},
SetName: late.GameRuntime.CharacterCreationSetName,
AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection,
RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter,
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
: null);
RetailUiRuntime runtime = lease.Mount(

View file

@ -241,6 +241,28 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) =>
commands.CharacterCreation.SetShade(generation, slot, value));
// ── Campaign CC slice CC5: Summary page + RandomizeCharacter commands ──
public RuntimeCommandResult CharacterCreationSetName(string name) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SetName(generation, name));
public RuntimeCommandResult CharacterCreationAcknowledgeRejection() =>
Invoke((commands, generation) =>
commands.CharacterCreation.AcknowledgeRejection(generation));
public RuntimeCommandResult CharacterCreationRandomizeCharacter() =>
Invoke((commands, generation) =>
commands.CharacterCreation.RandomizeCharacter(generation));
public RuntimeCommandResult CharacterCreationRandomizeAppearance() =>
Invoke((commands, generation) =>
commands.CharacterCreation.RandomizeAppearance(generation));
public RuntimeCommandResult CharacterCreationRandomizeClothing() =>
Invoke((commands, generation) =>
commands.CharacterCreation.RandomizeClothing(generation));
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
// Same "capture view+commands under one generation" shape as every
// method above — a displaced session (reconnect mid-click) can never

View file

@ -132,6 +132,11 @@ internal sealed record LivePresentationResult(
// a leased composition resource, mirroring PaperdollViewportRenderer).
ChargenPreviewRenderer? ChargenPreviewRenderer,
ChargenPreviewController? ChargenPreviewController,
// Campaign CC slice CC5: the Summary page's own gmCG3DView instance —
// a SEPARATE leased renderer/controller pair, same split reasoning as
// the Appearance preview fields immediately above.
ChargenPreviewRenderer? SummaryPreviewRenderer,
ChargenPreviewController? SummaryPreviewController,
WbFrustum EnvCellFrustum,
EnvCellRenderer? EnvCellRenderer,
LandblockPresentationPipeline LandblockPipeline,
@ -1102,6 +1107,82 @@ internal sealed class LivePresentationCompositionPhase
+ "time — the Appearance page's zoom/rotate controls and "
+ "3D preview will not function this session.");
}
// Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance
// (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE
// instance from the Appearance page's own during the CC6b-MOUNT
// review) — same one-shot binding shape as the Appearance preview
// immediately above (AP-221's own disposition applies here too: a
// DAT/resource read not ready on this exact composition frame means
// the Summary preview stays permanently unbound for the session,
// same tracked follow-up as the Appearance preview). No zoom/rotate
// control surface is wired — retail's Summary page has no such
// buttons (only <c>StartAnimation</c>'s idle loop and a fixed 180°
// heading), so this controller's ZoomIn/RotateClockwise etc. simply
// never get called.
CompositionAcquisitionScope.CompositionAcquisitionLease<
ChargenPreviewRenderer>? summaryPreviewLease = null;
ChargenPreviewController? summaryPreviewController = null;
if (dispatcherLease.Resource is { } summaryDispatcher
&& interaction.RetainedUi?.Runtime.SummaryPreviewViewportWidget is { } summaryViewport)
{
var summaryCamera = new ChargenPreviewCamera();
summaryPreviewLease = scope.Acquire(
"summary preview viewport",
() => new ChargenPreviewRenderer(
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuDevice,
host.GpuFrameLifetime,
summaryDispatcher,
foundation.SceneLighting!,
foundation.TextureCache,
foundation.MeshAdapter!,
camera: summaryCamera),
static value => value.Dispose());
IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer;
summaryViewport.Renderer = summaryPreviewLease.Resource;
bindings.AdoptRelease(
"summary preview viewport target",
() =>
{
if (ReferenceEquals(summaryViewport.Renderer, summaryPreviewLease.Resource))
summaryViewport.Renderer = previousSummaryRenderer;
});
var summaryCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats);
summaryPreviewController = new ChargenPreviewController(
summaryPreviewLease.Resource,
summaryCamera,
new RetailChargenPreviewFrameView(
summaryViewport,
new RetailSummaryPreviewPageVisibility(interaction.RetainedUi.Runtime)),
content.Dats,
content.AnimationLoader,
summaryCatalog,
summaryCatalog,
d.DatLock);
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
bindings.AdoptRelease(
"summary preview control",
() =>
{
if (ReferenceEquals(
interaction.RetainedUi.Runtime.SummaryPreviewControl,
summaryPreviewController))
{
interaction.RetainedUi.Runtime.SummaryPreviewControl = null;
}
});
}
else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null)
{
Console.WriteLine(
"[UI] summary preview viewport unavailable at composition "
+ "time — the Summary page's 3D preview will not function "
+ "this session.");
}
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
var envCellFrustum = new WbFrustum();
@ -1410,6 +1491,8 @@ internal sealed class LivePresentationCompositionPhase
creatureAppraisalPresenter,
chargenPreviewLease?.Resource,
chargenPreviewController,
summaryPreviewLease?.Resource,
summaryPreviewController,
envCellFrustum,
envCellLease.Resource,
landblockPipeline,

View file

@ -78,6 +78,19 @@ internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVi
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
}
/// <summary>Campaign CC slice CC5: the Summary page's own visibility gate —
/// same shape as <see cref="RetailChargenPreviewPageVisibility"/>, reading
/// <c>RetailUiRuntime.IsSummaryPreviewPageVisible</c> instead.</summary>
internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility
{
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public bool IsVisible => _runtime.IsSummaryPreviewPageVisible;
}
/// <summary>Retained-UI visibility + texture publication, mirroring
/// <c>RetailPaperdollFrameView</c>.</summary>
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView

View file

@ -434,6 +434,10 @@ public sealed class GameWindow :
// viewports above.
private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer;
private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController;
// Campaign CC slice CC5: the Summary page's own gmCG3DView instance —
// a SEPARATE renderer/controller pair from the Appearance preview above.
private AcDream.App.Rendering.ChargenPreviewRenderer? _summaryPreviewRenderer;
private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
@ -1123,6 +1127,8 @@ public sealed class GameWindow :
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
_chargenPreviewController = result.ChargenPreviewController;
_summaryPreviewRenderer = result.SummaryPreviewRenderer;
_summaryPreviewController = result.SummaryPreviewController;
_envCellFrustum = result.EnvCellFrustum;
_envCellRenderer = result.EnvCellRenderer;
_landblockPresentationPipeline = result.LandblockPipeline;
@ -1770,6 +1776,8 @@ public sealed class GameWindow :
_creatureAppraisalViewportRenderer,
_chargenPreviewRenderer,
_chargenPreviewController,
_summaryPreviewRenderer,
_summaryPreviewController,
_wbDrawDispatcher,
_envCellRenderer,
_portalDepthMask,

View file

@ -114,6 +114,14 @@ internal sealed record RenderShutdownRoots(
CreatureAppraisalViewportRenderer? CreatureAppraisal,
ChargenPreviewRenderer? ChargenPreview,
ChargenPreviewController? ChargenPreviewController,
// Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance —
// same guard/shutdown shape as the Appearance-page preview above (a
// SEPARATE renderer/controller pair, not a shared one — retail's own
// gmCGSummaryPage::InitializePage @0x0047bbf0 constructs its own
// gmCG3DView, confirmed a distinct instance from the Appearance page's
// during the CC6b-MOUNT review).
ChargenPreviewRenderer? SummaryPreview,
ChargenPreviewController? SummaryPreviewController,
WbDrawDispatcher? DrawDispatcher,
EnvCellRenderer? EnvironmentCells,
PortalDepthMaskRenderer? PortalDepthMask,
@ -493,6 +501,8 @@ internal static class GameWindowShutdownManifest
() => render.CreatureAppraisal?.Dispose()),
Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()),
Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()),
Hard("summary preview control", () => render.SummaryPreviewController?.Dispose()),
Hard("summary preview viewport", () => render.SummaryPreview?.Dispose()),
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),

View file

@ -544,6 +544,21 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterCreation(
commands => commands.AcknowledgeRejection(expectedGeneration));
public RuntimeCommandResult RandomizeCharacter(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterCreation(
commands => commands.RandomizeCharacter(expectedGeneration));
public RuntimeCommandResult RandomizeAppearance(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterCreation(
commands => commands.RandomizeAppearance(expectedGeneration));
public RuntimeCommandResult RandomizeClothing(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterCreation(
commands => commands.RandomizeClothing(expectedGeneration));
}
private sealed class AdapterCharacterCreationObserver(

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()

View file

@ -460,6 +460,30 @@ public interface IRuntimeCharacterCreationCommands
RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration);
// ── Campaign CC slice CC5: RandomizeCharacter port ──────────────────
/// <summary>Retail's ctor-time <c>CharGenState::RandomizeCharacter</c>
/// roll (mirrored at the App layer's screen-open edge) and the Summary
/// page's Random button (<c>gmCharGenMainUI::DoRandom</c> case 5, behind
/// the caller's own <c>ID_CharGen_RandomizeWarning</c> confirmation) —
/// see <see cref="Session.RuntimeCharacterCreationState.TryRandomizeCharacter"/>.</summary>
RuntimeCommandResult RandomizeCharacter(
RuntimeGenerationToken expectedGeneration);
/// <summary>The Appearance page's Random button while its Face sub-tab
/// is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c>
/// arm) — see
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeAppearance"/>.</summary>
RuntimeCommandResult RandomizeAppearance(
RuntimeGenerationToken expectedGeneration);
/// <summary>The Appearance page's Random button while its Clothes
/// sub-tab is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's
/// <c>RandomizeClothing(state, 1)</c> arm) — see
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeClothing"/>.</summary>
RuntimeCommandResult RandomizeClothing(
RuntimeGenerationToken expectedGeneration);
}
public interface IGameRuntimeCommands

View file

@ -1657,6 +1657,45 @@ public sealed class LiveSessionController
}
}
public RuntimeCommandResult RandomizeCharacter(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeCharacter());
}
}
public RuntimeCommandResult RandomizeAppearance(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeAppearance());
}
}
public RuntimeCommandResult RandomizeClothing(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeClothing());
}
}
private RuntimeCommandStatus ValidateCharacterCreationCommand(
RuntimeGenerationToken expectedGeneration)
{

View file

@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance(
/// evaluates before a Finish click is allowed to reach the wire, plus the
/// campaign's client-side slot cap (risk item 3 — retail's UI, not
/// <c>DoFinish</c> itself, refuses when the roster is already full versus
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side).
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side), plus
/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment)
/// <see cref="HeritageOrGenderUnset"/> — an acdream-ONLY addition with no
/// direct <c>DoFinish</c> citation (register AP-223): retail's own
/// <c>DoFinish</c> never checks heritage/gender because it can't reach a
/// state where either is unset — <c>gmCharGenMainUI</c>'s constructor calls
/// <c>CharGenState::RandomizeCharacter</c> before any page (including
/// Summary/Finish) exists, so a real heritage+gender selection is an
/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once
/// <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/> is
/// wired at the App layer's screen-open edge (mirroring that same ctor
/// call), this refusal is normally unreachable through the ordinary UI —
/// it exists as a defensive backstop for any caller (a headless bot, a
/// future direct command) that can reach <c>Finish</c> without that
/// open-edge roll ever having run.
/// </summary>
public readonly record struct RuntimeCharacterCreationLocalRefusal(
bool NoName,
bool AttributeCreditsUnspent,
bool AlreadyPending,
bool RosterFull)
bool RosterFull,
bool HeritageOrGenderUnset = false)
{
public bool Any =>
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull;
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull
|| HeritageOrGenderUnset;
public static RuntimeCharacterCreationLocalRefusal None { get; } = default;
}
@ -487,27 +503,38 @@ public sealed class RuntimeCharacterCreationState : IDisposable
if (_disposed || !_active)
return false;
_heritageId = heritageId;
_totalAttributeCredits = heritage.AttributeCredits;
_totalSkillCredits = heritage.SkillCredits;
_remainingSkillCredits = checked((int)heritage.SkillCredits);
ApplyTemplateLocked(heritage);
RandomizeStartAreaLocked(heritage);
ConstrainAppearanceByGenderLocked();
RecomputeRemainingAttributeCreditsLocked();
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
// our own gated skill commands, but kept for parity with a
// heritage switch that leaves stale skill picks over-budget.
if (RecomputeSkillSpendLocked(heritage) < 0)
ResetSkillLevelsLocked(heritage);
SetHeritageGroupLocked(heritageId, heritage);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>
/// The locked body of <c>CharGenState::SetHeritageGroup @ 0x005C67A0</c> —
/// factored out of <see cref="TrySelectHeritage"/> (Campaign CC slice
/// CC5) so <see cref="RandomizeCharacterLocked"/>'s own heritage roll
/// can reuse it without re-entering <see cref="_gate"/>.
/// </summary>
private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage)
{
_heritageId = heritageId;
_totalAttributeCredits = heritage.AttributeCredits;
_totalSkillCredits = heritage.SkillCredits;
_remainingSkillCredits = checked((int)heritage.SkillCredits);
ApplyTemplateLocked(heritage);
RandomizeStartAreaLocked(heritage);
ConstrainAppearanceByGenderLocked();
RecomputeRemainingAttributeCreditsLocked();
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
// our own gated skill commands, but kept for parity with a
// heritage switch that leaves stale skill picks over-budget.
if (RecomputeSkillSpendLocked(heritage) < 0)
ResetSkillLevelsLocked(heritage);
}
/// <summary>Ports <c>CharGenState::SetGender @ 0x005C64A0</c>: clamps
/// every appearance index into the new gender's option-list bounds. The
/// four <c>SetXStyle(this, this-&gt;XStyle)</c> re-invocations retail
@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
return false;
}
_genderKey = genderKey;
ConstrainAppearanceByGenderLocked();
SetGenderLocked(genderKey);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>The locked body of <c>CharGenState::SetGender @
/// 0x005C64A0</c> — factored out of <see cref="TrySelectGender"/>
/// (Campaign CC slice CC5) so <see cref="RandomizeCharacterLocked"/>'s
/// own gender roll can reuse it.</summary>
private void SetGenderLocked(uint genderKey)
{
_genderKey = genderKey;
ConstrainAppearanceByGenderLocked();
}
/// <summary>
/// Ports the seven Profession-page buttons, each of which calls
/// <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> — template
@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable
: value;
}
// ── Randomize (Campaign CC slice CC5) ───────────────────────────────
// Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named
// sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/
// RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/
// RandomizeTemplate/RandomizeStartArea — register AP-212's own citation
// list). The RNG primitive both retail's own RandInt(int) and
// RandInt(int,int) overloads reduce to is decompiled verbatim at
// 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count);
// RandInt(count,exclude) loops the same roll until it differs from
// exclude (a no-op when count<=1, matching retail's own early-return —
// ported as RandomizeIndexExcludingLocked below). CharGenState's own
// vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union —
// "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms
// RandomizeAppearance's vtable-indirected calls are this SAME RandInt
// pair, not a distinct algorithm.
/// <summary>Ports <c>Random::RollDice(int,int) @ 0x0042c5c0</c>: returns
/// <paramref name="min"/> unchanged when the two bounds are equal
/// (matching retail's own <c>arg2==arg1</c> fast path), otherwise a
/// uniform pick over the INCLUSIVE range
/// <c>[min(min,max), max(min,max)]</c>.</summary>
private int RollDiceLocked(int min, int max)
{
if (min == max)
return min;
int lo = Math.Min(min, max);
int hi = Math.Max(min, max);
return lo + _random.Next(hi - lo + 1);
}
/// <summary>Ports <c>RandInt(int,int) @ 0x00684420</c> exactly: for
/// <paramref name="count"/> &lt;= 1 there is only one possible outcome,
/// so retail returns 0 immediately WITHOUT ever comparing against
/// <paramref name="exclude"/> (the guard that keeps the do/while loop
/// below from spinning forever); otherwise re-rolls uniformly in
/// <c>[0,count)</c> until the result differs from
/// <paramref name="exclude"/> — an <paramref name="exclude"/> outside
/// <c>[0,count)</c> (e.g. <see cref="RuntimeCharacterCreationAppearance.Unset"/>
/// on a freshly-<see cref="ClearSessionState"/>'d field) can never match,
/// so the loop always exits on its first iteration and this degrades to
/// a plain uniform pick.</summary>
private uint RandomizeIndexExcludingLocked(int count, uint exclude)
{
if (count <= 1)
return 0u;
uint result;
do
{
result = (uint)_random.Next(count);
} while (result == exclude);
return result;
}
/// <summary>Ports <c>CharGenState::RandomizeAppearance(this, 0) @
/// 0x005c4f10</c> — every real call site in the retail binary passes
/// <c>arg2 == 0</c> (an exhaustive grep of every <c>RandomizeAppearance</c>
/// call found none with <c>arg2 != 0</c>), so the <c>arg2 != 0</c> arm
/// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead
/// code and is not ported. Each field is only rolled when its list is
/// non-empty (retail's own per-field <c>if (count != 0)</c> guards);
/// <c>skinShade</c>/<c>hairShade</c> are <c>vtable-&gt;GetRandomReal()</c>
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
/// other Randomize* function below uses explicitly inline.</summary>
private void RandomizeAppearanceLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
RuntimeCharacterCreationAppearance a = _appearance;
if (gender.EyeStrips.Count > 0)
a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) };
if (gender.NoseStrips.Count > 0)
a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) };
if (gender.MouthStrips.Count > 0)
a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) };
a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() };
if (gender.HairColors.Count > 0)
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
if (gender.EyeColors.Count > 0)
a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) };
if (gender.HairStyles.Count > 0)
a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) };
_appearance = a;
}
/// <summary>
/// Ports <c>CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10</c>.
/// Headgear alone gets the <c>count+1</c>-position Unset ring
/// (<c>CharacterCreationAppearancePage.CycleIndex</c>'s own already-cited
/// sibling finding): <paramref name="excludeCurrent"/> false (every
/// <c>RandomizeCharacter</c> call site, <c>arg2==0</c>) rolls a plain
/// uniform <c>RandInt(count+1)</c>; true (<c>RandomizeClothing(state,1)</c>'s
/// own Appearance-page Random-button case) excludes the current style
/// via <c>RandInt(count+1, headgearStyle+1)</c> — the <c>+1</c>
/// reindexes Unset (retail's signed <c>-1</c>) to <c>0</c> so the
/// exclude comparison stays in <c>[0,count]</c>. Color uses the SAME
/// <see cref="AppearanceSlotCountLocked"/> shared-<c>ClothingColors</c>
/// approximation (register AP-208) every other clothing slot's color
/// count already uses, not retail's own per-heritage
/// <c>numHeadgearColors</c> field acdream's model does not carry.
/// </summary>
private void RandomizeHeadgearLocked(bool excludeCurrent)
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Headgears.Count;
if (styleCount > 0)
{
uint current = _appearance.HeadgearStyle;
int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset
? 0
: (int)current + 1;
int rolled = excludeCurrent
? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne)
: _random.Next(styleCount + 1);
uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1);
_appearance = _appearance with { HeadgearStyle = newStyle };
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
};
}
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
/// unlike headgear, retail's shirt/trousers/footwear randomizers take no
/// <c>arg2</c> and always exclude the current style/color.</summary>
private void RandomizeShirtLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Shirts.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
};
}
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
private void RandomizeTrousersLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Pants.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
};
}
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
private void RandomizeFootwearLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Footwear.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
};
}
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
/// 0x005c6770</c>: headgear (with <paramref name="excludeCurrent"/>
/// forwarded), then shirt/trousers/footwear (always exclude-current,
/// they take no <c>arg2</c>).</summary>
private void RandomizeClothingLocked(bool excludeCurrent)
{
RandomizeHeadgearLocked(excludeCurrent);
RandomizeShirtLocked();
RandomizeTrousersLocked();
RandomizeFootwearLocked();
}
/// <summary>
/// Ports <c>CharGenState::RandomizeTemplate @ 0x005c6500</c>. The two
/// Olthoi heritages force template 0 unconditionally
/// (<c>this-&gt;template_ = 1; ApplyTemplate(this);</c> in retail — but
/// <c>ApplyTemplate</c>'s own Olthoi branch immediately re-forces
/// <c>template_ = 0</c> regardless, so the intermediate write to 1 is
/// observably a no-op; this port skips straight to
/// <see cref="ApplyTemplateLocked"/>, which already carries that force).
/// Otherwise, when the heritage has more than one template (Custom plus
/// at least one preset), picks <c>RandInt(count-1, template_-1) + 1</c> —
/// a uniform pick over indices <c>[1, count-1]</c> (retail's own
/// preset templates, NEVER index 0/Custom) excluding the CURRENT
/// template (offset by <c>-1</c> to align with the shifted range; an
/// Unset/<c>0xFFFFFFFF</c> current value wraps far outside <c>[0,count-1)</c>
/// and can never match, so a fresh roll off a Reset state is
/// unconstrained).
/// </summary>
private void RandomizeTemplateLocked()
{
if (_heritageId == 0 || _genderKey == 0)
return;
if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage))
return;
if (_heritageId == (uint)ChargenHeritageGroup.Olthoi
|| _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid)
{
ApplyTemplateLocked(heritage);
return;
}
int count = heritage.Templates.Count;
if (count <= 1)
return;
uint excludeShifted = unchecked(_template - 1u);
uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u;
_template = picked;
ApplyTemplateLocked(heritage);
}
/// <summary>
/// Ports <c>CharGenState::RandomizeCharacter(this, hasToD) @
/// 0x005c6d80</c>: <see cref="ClearSessionState"/> (retail's own
/// <c>Reset()</c>), roll a heritage
/// (<c>SetHeritageGroup(RollDice(1, hasToD?4:3))</c> — heritage ids
/// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/
/// Viamontian, <see cref="ChargenHeritageGroup"/>'s own numbering); a
/// "random" character in retail is deliberately always human, never one
/// of the other nine heritages — a genuine retail quirk, not a porting
/// shortcut), roll a gender (<c>SetGender(RollDice(1,2))</c>), then
/// appearance/headgear/shirt/trousers/footwear/template/start-area, in
/// that exact order. acdream has no account/DLC-ownership signal (AD-102's
/// already-established convention: every installed heritage/town ships
/// unconditionally selectable, matching what a ToD-owning account would
/// see) — this reuses that SAME convention rather than inventing a
/// second one, so the heritage roll always uses the 4-heritage bound.
/// <c>SetHeritageGroupLocked</c> already rolls a starting area once as
/// part of its own <c>RandomizeStartAreaLocked</c> call (mirroring
/// retail's own <c>SetHeritageGroup</c>); the explicit
/// <c>RandomizeStartAreaLocked</c> call at the end re-rolls it a SECOND
/// time, matching retail's own redundant double-roll exactly (harmless —
/// each roll is independently uniform over the same list).
/// </summary>
private void RandomizeCharacterLocked()
{
ClearSessionState();
uint heritageId = (uint)RollDiceLocked(1, 4);
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
SetHeritageGroupLocked(heritageId, heritage);
uint genderKey = (uint)RollDiceLocked(1, 2);
SetGenderLocked(genderKey);
RandomizeAppearanceLocked();
RandomizeHeadgearLocked(excludeCurrent: false);
RandomizeShirtLocked();
RandomizeTrousersLocked();
RandomizeFootwearLocked();
RandomizeTemplateLocked();
if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage))
RandomizeStartAreaLocked(finalHeritage);
}
/// <summary>Public command surface for <see cref="RandomizeCharacterLocked"/> —
/// consumed by the App layer's screen-open edge (mirrors
/// <c>gmCharGenMainUI</c>'s ctor-time roll, retiring AP-214's
/// honest-blank deviation) and the Summary page's Random button
/// (<c>gmCharGenMainUI::DoRandom</c> case 5, behind the
/// <c>ID_CharGen_RandomizeWarning</c> confirmation the App layer
/// owns).</summary>
internal bool TryRandomizeCharacter()
{
lock (_gate)
{
if (_disposed || !_active)
return false;
RandomizeCharacterLocked();
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>Public command surface for <see cref="RandomizeAppearanceLocked"/> —
/// the Appearance page's Random button when its Face sub-tab is showing
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c> arm).</summary>
internal bool TryRandomizeAppearance()
{
lock (_gate)
{
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
return false;
RandomizeAppearanceLocked();
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>Public command surface for <see cref="RandomizeClothingLocked"/>
/// with <c>excludeCurrent: true</c> — the Appearance page's Random
/// button when its Clothes sub-tab is showing
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's
/// <c>RandomizeClothing(state, 1)</c> arm).</summary>
internal bool TryRandomizeClothing()
{
lock (_gate)
{
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
return false;
RandomizeClothingLocked(excludeCurrent: true);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
// ── Town / name / slot ─────────────────────────────────────────────
/// <summary>Ports <c>CharGenState::SetStartArea @ 0x005C4000</c> — bounds
@ -1290,16 +1682,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
refusal = trimmed.Length == 0
? new RuntimeCharacterCreationLocalRefusal(
NoName: true, false, false, false)
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
: _heritageId == 0 || _genderKey == 0
? new RuntimeCharacterCreationLocalRefusal(
false, AttributeCreditsUnspent: true, false, false)
: _verificationPending
false, false, false, false, HeritageOrGenderUnset: true)
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
? new RuntimeCharacterCreationLocalRefusal(
false, false, AlreadyPending: true, false)
: slotCount > 0 && rosterCount >= slotCount
false, AttributeCreditsUnspent: true, false, false)
: _verificationPending
? new RuntimeCharacterCreationLocalRefusal(
false, false, false, RosterFull: true)
: RuntimeCharacterCreationLocalRefusal.None;
false, false, AlreadyPending: true, false)
: slotCount > 0 && rosterCount >= slotCount
? new RuntimeCharacterCreationLocalRefusal(
false, false, false, RosterFull: true)
: RuntimeCharacterCreationLocalRefusal.None;
_lastLocalRefusal = refusal;
accepted = !refusal.Any;