Fixes every finding from the dual-lens review of 34c6fceab0 (architectural
PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation
against docs/research/named-retail/acclient_2013_pseudo_c.txt directly
rather than trusting the reviewer's transcription.
Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on
0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same
switch the headgear ring was ported from) computes new=cur-1=-2 on the raw
signed int32, which wraps to count-1 — matching headgear's own ring shape.
Also ports the spin body-click normalize-and-write-back retail's cases
0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped
entirely. Flips the one test that pinned the wrong expectation and adds
select-zone coverage no prior test isolated.
Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset
SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes
was showing — a conditional gate stranded Nose/Mouth as the current part
under a Face-tab session.
Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the
plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading
citation conflated PostInit with InitializePage; Random's Appearance
disable was mislabeled a placeholder when it's really AP-212's unported
RandomizeAppearance/RandomizeClothing gap; the master-page doc still called
the Appearance page content-inert after this campaign made it real.
Visual substitutions widened (F2): AP-215 named only two of the Appearance
page's swatch/spin substitutions. Ports the two cheap ones directly —
current-part highlight via SetSelection's SetState(1)/SetState(6), routed
through the existing UiButtonStateMachine.Normal/Highlight ids and
IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed
ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0)
for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots,
the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin
MoveTo reposition, the Gearknight-boundary randomize calls) as new register
rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215
already named the GradCircle.
Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called
ChargenAppearanceFactory.TryCompose outside _datLock while the very next
line correctly locked TryBuildAnimated — CC6a's own F4 class of bug,
reintroduced at this catalog's first production call site. Wrapped in the
same lock; documented the invariant on ChargenAppearanceCatalog itself.
One-shot preview mount (F8): LivePresentationComposition reads
ChargenPreviewViewportWidget once, but its underlying mount
(CharacterCreationUiMountCoordinator) is explicitly retryable while this
GPU-resource composition pass is not — unlike PaperdollViewportWidget,
which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was
false. Retrofitting cross-frame retry here would mean restructuring this
composition's one-shot contract for every private viewport (paperdoll,
creature appraisal) and FrameRootComposition's fixed frame-group array —
out of this round's blast radius. Corrected the doc and made the failure
loud (a diagnostic log) instead of silent.
Dispose leak (F9): ChargenPreviewController.Dispose left the preview
WorldEntity referenced by the leased renderer until the renderer's own,
later disposal. Releases it on its own teardown now.
Test-quality items (F10, F11, F13): pinned the spin arrow widths
(47px, both arrows) the 174 zone boundary is derived from, plus a
controller test for the previously-uncovered select zone. Measured the
shade scrollbar's authored orientation instead of assuming it — it is
VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed
scalar-mode mouse events when Horizontal was true, so the shade control
never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar
mirroring the existing horizontal scalar path. Converted
ChargenPreviewControllerTests from silent-pass [Fact] to the shared
InstalledDatFactAttribute skip-reporting pattern.
Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local
refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no
heritage/gender gate — currently latent since Finish stays hard-disabled
this round. Amended the campaign plan's CC5 slice scope to require BOTH a
heritage/gender refusal AND a real RandomizeCharacter port before the
connected user gate opens Finish; noted the interaction on AP-214's own
register row. No CC5 implementation in this commit.
Gates: dotnet build -c Release green across the full solution. App suite
(Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite
1713/0 — both clean across repeated runs. A full-solution run surfaced
three pre-existing, previously-documented flakes unrelated to this change
(Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests
#402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing
in isolation, consistent with their known full-suite-parallelism-timing
history; none touch any file this commit changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
759 lines
30 KiB
C#
759 lines
30 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.CharGen;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Bindings the retail character-creation screen (<c>gmCharGenMainUI</c>)
|
|
/// needs beyond the borrowed view: generation-capturing command wrappers,
|
|
/// mirroring <see cref="CharacterSelectionRuntimeBindings"/>'s shape exactly.
|
|
/// Every <c>Func</c> here is a late-bound seam (Campaign CC — see
|
|
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>): callers MUST resolve
|
|
/// it per-call, never capture the delegate once at mount time.
|
|
/// </summary>
|
|
/// <param name="OpenOnStart">Campaign CC slice CC4: the retail
|
|
/// transition is Create Character (<c>0x100003A0</c>) →
|
|
/// <c>QueueUIMode(0x1000000b)</c>, but that button stays ghosted until CC7's
|
|
/// closing move. This flag is the interim env/test-only open seam
|
|
/// (<c>ACDREAM_OPEN_CHARGEN=1</c> → <see cref="AcDream.App.RuntimeOptions.OpenCharacterCreationOnStart"/>)
|
|
/// so the screen can be exercised before the real button is wired.</param>
|
|
public sealed record CharacterCreationRuntimeBindings(
|
|
Func<IRuntimeCharacterCreationView?> View,
|
|
Func<uint, RuntimeCommandResult> SelectHeritage,
|
|
Func<uint, RuntimeCommandResult> SelectGender,
|
|
Func<uint, RuntimeCommandResult> SelectTemplate,
|
|
Func<ChargenAttributeId, int, RuntimeCommandResult> SetAttribute,
|
|
Func<ChargenAttributeId, bool, RuntimeCommandResult> SetAttributeLock,
|
|
Func<uint, RuntimeCommandResult> TrainSkill,
|
|
Func<uint, RuntimeCommandResult> SpecializeSkill,
|
|
Func<uint, RuntimeCommandResult> UntrainSkill,
|
|
Func<int, RuntimeCommandResult> SelectStartArea,
|
|
Func<bool, RuntimeCommandResult> Finish,
|
|
Action RequestExit,
|
|
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's nine
|
|
/// spin controls and nine color swatches.</summary>
|
|
Func<ChargenAppearanceSlot, uint, RuntimeCommandResult>? SetAppearanceIndex = null,
|
|
/// <summary>CC6b-MOUNT: the Appearance page's shade scrollbar.</summary>
|
|
Func<ChargenShadeSlot, double, RuntimeCommandResult>? SetShade = null,
|
|
/// <summary>DAT string lookup (table <c>0x23000002</c>, the SAME table
|
|
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
|
|
/// through) — used by the Heritage page's composed description text.
|
|
/// <see langword="null"/> degrades to the heritage's own DAT
|
|
/// <c>Name</c> field instead of the full composed copy.</summary>
|
|
Func<string, string?>? ResolveText = null,
|
|
bool OpenOnStart = false);
|
|
|
|
/// <summary>
|
|
/// Projects Runtime's borrowed <see cref="IRuntimeCharacterCreationView"/>
|
|
/// through retail <c>gmCharGenMainUI</c>'s authored retained layout — the
|
|
/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/
|
|
/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this
|
|
/// slice builds. Fix round F6: the Appearance (<c>0x100003d4</c>) page root
|
|
/// is fully LIVE as of CC6b-MOUNT (<see cref="CharacterCreationAppearancePage"/>);
|
|
/// only the Summary (<c>0x100003d6</c>) page root remains mounted but
|
|
/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only
|
|
/// at CC6b-MOUNT).
|
|
///
|
|
/// <para>
|
|
/// Decomp anchors: root construction + child resolution
|
|
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> (root element
|
|
/// <c>0x100003cc</c> from enum <c>0x10000039</c>); page switching
|
|
/// <c>gmCharGenMainUI::SetProgressState @ 0x004e7a10</c> (the Olthoi
|
|
/// tab-hiding + redirect logic); nav dispatch
|
|
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>; exit
|
|
/// confirmation <c>gmCharGenMainUI::DoExit @ 0x004e8650</c>; randomize
|
|
/// dispatch <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class CharacterCreationUiController : IDisposable
|
|
{
|
|
internal const uint RootEnum = 0x10000039u;
|
|
internal const uint RootElementId = 0x100003CCu;
|
|
internal const uint ProgressBarElementId = 0x100003CEu;
|
|
internal const uint BackElementId = 0x100003C6u;
|
|
internal const uint NextElementId = 0x100003C7u;
|
|
internal const uint FinishElementId = 0x100003C8u;
|
|
internal const uint HelpElementId = 0x100003C9u;
|
|
internal const uint ExitElementId = 0x100003CAu;
|
|
internal const uint RandomElementId = 0x100003CBu;
|
|
internal const uint MasterPageElementId = 0x100003D0u;
|
|
internal const uint HeritagePageElementId = 0x100003D1u;
|
|
internal const uint ProfessionPageElementId = 0x100003D2u;
|
|
internal const uint SkillsPageElementId = 0x100003D3u;
|
|
internal const uint AppearancePageElementId = 0x100003D4u;
|
|
internal const uint TownPageElementId = 0x100003D5u;
|
|
internal const uint SummaryPageElementId = 0x100003D6u;
|
|
internal const uint HeritageTabElementId = 0x100003EFu;
|
|
internal const uint ProfessionTabElementId = 0x100003F0u;
|
|
internal const uint SkillsTabElementId = 0x100003F1u;
|
|
internal const uint AppearanceTabElementId = 0x100003F2u;
|
|
internal const uint TownTabElementId = 0x100003F3u;
|
|
internal const uint SummaryTabElementId = 0x100003F4u;
|
|
|
|
/// <summary>Retail's <c>gmCharGenMainUI::ECGProgress</c> enum values —
|
|
/// used verbatim as the master page's per-page state ids
|
|
/// (<c>0x10000025 + (page - 1)</c>) and the tab-hide/redirect math in
|
|
/// <see cref="ApplyProgressState"/>.</summary>
|
|
internal enum Page
|
|
{
|
|
Heritage = 1,
|
|
Profession = 2,
|
|
Skills = 3,
|
|
Appearance = 4,
|
|
Town = 5,
|
|
Summary = 6,
|
|
}
|
|
|
|
internal sealed record DialogStrings(string ExitWarning);
|
|
|
|
private readonly UiRoot _host;
|
|
private readonly ImportedLayout _layout;
|
|
private readonly UiElement _progressBar;
|
|
private readonly UiButton _back;
|
|
private readonly UiButton _next;
|
|
private readonly UiButton _finish;
|
|
private readonly UiButton _help;
|
|
private readonly UiButton _exit;
|
|
private readonly UiButton _random;
|
|
private readonly UiElement _masterPage;
|
|
private readonly UiElement _heritagePageRoot;
|
|
private readonly UiElement _professionPageRoot;
|
|
private readonly UiElement _skillsPageRoot;
|
|
private readonly UiElement _appearancePageRoot;
|
|
private readonly UiElement _townPageRoot;
|
|
private readonly UiElement _summaryPageRoot;
|
|
private readonly UiButton _heritageTab;
|
|
private readonly UiButton _professionTab;
|
|
private readonly UiButton _skillsTab;
|
|
private readonly UiButton _appearanceTab;
|
|
private readonly UiButton _townTab;
|
|
private readonly UiButton _summaryTab;
|
|
private readonly RetailDialogFactory _dialogs;
|
|
private readonly CharacterCreationRuntimeBindings _bindings;
|
|
private readonly DialogStrings _strings;
|
|
private readonly CharacterCreationHeritagePage _heritagePage;
|
|
private readonly CharacterCreationProfessionPage _professionPage;
|
|
private readonly CharacterCreationSkillsPage _skillsPage;
|
|
private readonly CharacterCreationTownPage _townPage;
|
|
private readonly CharacterCreationAppearancePage _appearancePage;
|
|
|
|
private Vector2 _authoredCanvas;
|
|
private RuntimeGenerationToken _lastGeneration;
|
|
private long _lastRevision = long.MinValue;
|
|
private Page _currentPage = Page.Heritage;
|
|
private bool _active;
|
|
private bool _isOpen;
|
|
private bool _openOnStartConsumed;
|
|
private uint _exitDialogContext;
|
|
private bool _suppressDialogCallbacks;
|
|
private bool _disposed;
|
|
|
|
private CharacterCreationUiController(
|
|
UiRoot host,
|
|
ImportedLayout layout,
|
|
UiElement progressBar,
|
|
UiButton back,
|
|
UiButton next,
|
|
UiButton finish,
|
|
UiButton help,
|
|
UiButton exit,
|
|
UiButton random,
|
|
UiElement masterPage,
|
|
UiElement heritagePageRoot,
|
|
UiElement professionPageRoot,
|
|
UiElement skillsPageRoot,
|
|
UiElement appearancePageRoot,
|
|
UiElement townPageRoot,
|
|
UiElement summaryPageRoot,
|
|
UiButton heritageTab,
|
|
UiButton professionTab,
|
|
UiButton skillsTab,
|
|
UiButton appearanceTab,
|
|
UiButton townTab,
|
|
UiButton summaryTab,
|
|
Func<uint, uint, UiElement?> templateResolver,
|
|
RetailDialogFactory dialogs,
|
|
CharacterCreationRuntimeBindings bindings,
|
|
DialogStrings strings)
|
|
{
|
|
_host = host;
|
|
_layout = layout;
|
|
_progressBar = progressBar;
|
|
_back = back;
|
|
_next = next;
|
|
_finish = finish;
|
|
_help = help;
|
|
_exit = exit;
|
|
_random = random;
|
|
_masterPage = masterPage;
|
|
_heritagePageRoot = heritagePageRoot;
|
|
_professionPageRoot = professionPageRoot;
|
|
_skillsPageRoot = skillsPageRoot;
|
|
_appearancePageRoot = appearancePageRoot;
|
|
_townPageRoot = townPageRoot;
|
|
_summaryPageRoot = summaryPageRoot;
|
|
_heritageTab = heritageTab;
|
|
_professionTab = professionTab;
|
|
_skillsTab = skillsTab;
|
|
_appearanceTab = appearanceTab;
|
|
_townTab = townTab;
|
|
_summaryTab = summaryTab;
|
|
_dialogs = dialogs;
|
|
_bindings = bindings;
|
|
_strings = strings;
|
|
|
|
Root.Left = 0f;
|
|
Root.Top = 0f;
|
|
Root.ClickThrough = false;
|
|
Root.Visible = false;
|
|
// AD-98: the same authored 800x600 fixed-canvas treatment as the
|
|
// character-management screen. Both controllers DECLARE/REVOKE
|
|
// through UiRoot's owner-scoped arbiter (review fix round R1,
|
|
// 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly —
|
|
// char-management can be simultaneously active underneath this
|
|
// screen, and a raw write from either controller is a last-writer-
|
|
// wins race with no owner (the F1 fix's own Close() null wiped
|
|
// char-management's still-active canvas out from under it). See
|
|
// Open/Close/Deactivate/Dispose below for the matching declare/
|
|
// revoke pair.
|
|
_authoredCanvas = new Vector2(
|
|
Root.Width > 0f ? Root.Width : 800f,
|
|
Root.Height > 0f ? Root.Height : 600f);
|
|
|
|
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings, ApplyHeritageTabRestore);
|
|
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
|
|
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
|
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
|
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
|
|
|
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
|
_back.OnClick = OnBack;
|
|
_next.OnClick = OnNext;
|
|
// Finish (0x100003c8) stays ghosted this round: Summary
|
|
// (0x100003d6) is CC5's placeholder, and DoFinish's real gate
|
|
// sequence lives in RuntimeCharacterCreationState.TryBeginFinish —
|
|
// wiring the button here without a Summary page to confirm/collect
|
|
// the name would let a click reach the wire with an empty name and
|
|
// silently refuse. No OnClick handler; _finish.Enabled stays false
|
|
// (see ApplyProgressState).
|
|
_finish.OnClick = null;
|
|
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
|
|
// ListenToElementMessage switch (case 0x100003c9 falls straight
|
|
// through to the base UIFramework handler) — retail has no custom
|
|
// help action here either; leave it a no-op.
|
|
_help.OnClick = null;
|
|
_exit.OnClick = OnExit;
|
|
_random.OnClick = OnRandom;
|
|
_heritageTab.OnClick = () => ApplyProgressState(Page.Heritage);
|
|
_professionTab.OnClick = () => ApplyProgressState(Page.Profession);
|
|
_skillsTab.OnClick = () => ApplyProgressState(Page.Skills);
|
|
_appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance);
|
|
_townTab.OnClick = () => ApplyProgressState(Page.Town);
|
|
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
|
|
}
|
|
|
|
internal UiElement Root => _layout.Root;
|
|
|
|
/// <summary>The authored Appearance-page viewport (<c>0x100003bb</c>) —
|
|
/// CC6b-MOUNT's composition root assigns its <c>Renderer</c> once the
|
|
/// graphics backend exists (mirrors the paperdoll's own late
|
|
/// <c>viewport.Renderer = ...</c> assignment).</summary>
|
|
internal UiViewport? AppearanceViewport => _appearancePage.Viewport;
|
|
|
|
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface —
|
|
/// see <see cref="AcDream.App.Rendering.IChargenPreviewControl"/>'s own
|
|
/// doc comment for why this is assigned after construction rather than
|
|
/// threaded through the ctor.</summary>
|
|
internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl
|
|
{
|
|
get => _appearancePage.PreviewControl;
|
|
set => _appearancePage.PreviewControl = value;
|
|
}
|
|
|
|
/// <summary>Gates the Appearance preview's per-frame work on whether
|
|
/// that specific page — AND the whole chargen screen — is the one
|
|
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,
|
|
/// not the individual page roots, so a page-root-only check would stay
|
|
/// true after the screen closes on the Appearance page. Mirrors the
|
|
/// paperdoll's own outer-inventory-frame gate.</summary>
|
|
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
|
|
|
|
internal static CharacterCreationUiController? CreateDetached(
|
|
UiRoot host,
|
|
ImportedLayout layout,
|
|
Func<uint, uint, UiElement?> templateResolver,
|
|
RetailDialogFactory dialogs,
|
|
CharacterCreationRuntimeBindings bindings,
|
|
DialogStrings strings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
ArgumentNullException.ThrowIfNull(layout);
|
|
ArgumentNullException.ThrowIfNull(templateResolver);
|
|
ArgumentNullException.ThrowIfNull(dialogs);
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
ArgumentNullException.ThrowIfNull(strings);
|
|
|
|
if (layout.Root.DatElementId != RootElementId
|
|
|| layout.FindElement(ProgressBarElementId) is not { } progressBar
|
|
|| layout.FindElement(BackElementId) is not UiButton back
|
|
|| layout.FindElement(NextElementId) is not UiButton next
|
|
|| layout.FindElement(FinishElementId) is not UiButton finish
|
|
|| layout.FindElement(HelpElementId) is not UiButton help
|
|
|| layout.FindElement(ExitElementId) is not UiButton exit
|
|
|| layout.FindElement(RandomElementId) is not UiButton random
|
|
|| layout.FindElement(MasterPageElementId) is not { } masterPage
|
|
|| layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot
|
|
|| layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot
|
|
|| layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot
|
|
|| layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot
|
|
|| layout.FindElement(TownPageElementId) is not { } townPageRoot
|
|
|| layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot
|
|
|| layout.FindElement(HeritageTabElementId) is not UiButton heritageTab
|
|
|| layout.FindElement(ProfessionTabElementId) is not UiButton professionTab
|
|
|| layout.FindElement(SkillsTabElementId) is not UiButton skillsTab
|
|
|| layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab
|
|
|| layout.FindElement(TownTabElementId) is not UiButton townTab
|
|
|| layout.FindElement(SummaryTabElementId) is not UiButton summaryTab)
|
|
{
|
|
Console.WriteLine(
|
|
"[UI] character creation: the authored root/master-shell contract is incomplete.");
|
|
return null;
|
|
}
|
|
|
|
return new CharacterCreationUiController(
|
|
host,
|
|
layout,
|
|
progressBar,
|
|
back,
|
|
next,
|
|
finish,
|
|
help,
|
|
exit,
|
|
random,
|
|
masterPage,
|
|
heritagePageRoot,
|
|
professionPageRoot,
|
|
skillsPageRoot,
|
|
appearancePageRoot,
|
|
townPageRoot,
|
|
summaryPageRoot,
|
|
heritageTab,
|
|
professionTab,
|
|
skillsTab,
|
|
appearanceTab,
|
|
townTab,
|
|
summaryTab,
|
|
templateResolver,
|
|
dialogs,
|
|
bindings,
|
|
strings);
|
|
}
|
|
|
|
internal void AttachAndTick()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (Root.Parent is null)
|
|
_host.AddChild(Root);
|
|
Tick();
|
|
}
|
|
|
|
internal void Tick()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
IRuntimeCharacterCreationView? view = _bindings.View();
|
|
RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default;
|
|
if (view is null || !snapshot.IsActive)
|
|
{
|
|
Deactivate();
|
|
_lastGeneration = snapshot.Generation;
|
|
_lastRevision = snapshot.Revision;
|
|
return;
|
|
}
|
|
|
|
if (!_active)
|
|
{
|
|
_active = true;
|
|
// CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real
|
|
// Create-button transition is CC7's. Fires once per mount.
|
|
if (_bindings.OpenOnStart && !_openOnStartConsumed)
|
|
{
|
|
_openOnStartConsumed = true;
|
|
Open();
|
|
}
|
|
}
|
|
|
|
if (_isOpen)
|
|
{
|
|
Root.Visible = true;
|
|
_host.BringToFront(Root);
|
|
}
|
|
else
|
|
{
|
|
Root.Visible = false;
|
|
}
|
|
|
|
if (_lastGeneration != snapshot.Generation
|
|
|| _lastRevision != snapshot.Revision)
|
|
{
|
|
_heritagePage.Refresh(view, snapshot);
|
|
_professionPage.Refresh(view, snapshot);
|
|
_skillsPage.Refresh(view, snapshot);
|
|
_townPage.Refresh(view, snapshot);
|
|
_appearancePage.Refresh(view, snapshot);
|
|
_lastGeneration = snapshot.Generation;
|
|
_lastRevision = snapshot.Revision;
|
|
}
|
|
|
|
ReconcileDialogs(snapshot);
|
|
}
|
|
|
|
/// <summary>Opens the screen at retail's authored default page
|
|
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
|
|
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Declares the fixed
|
|
/// canvas on this exact activation edge through <see cref="UiRoot"/>'s
|
|
/// arbiter — matching <see cref="CharacterManagementUiController"/>'s
|
|
/// own one-shot declare — not per-tick; <see cref="Close"/>/
|
|
/// <see cref="Deactivate"/>/<see cref="Dispose"/> revoke it back out
|
|
/// symmetrically, and the canvas stays set for as long as ANY other
|
|
/// declarer (e.g. character-management underneath) remains active.</summary>
|
|
internal void Open()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_isOpen = true;
|
|
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
|
ApplyProgressState(Page.Heritage);
|
|
}
|
|
|
|
private void Close()
|
|
{
|
|
if (!_isOpen)
|
|
return;
|
|
_isOpen = false;
|
|
Root.Visible = false;
|
|
_host.RevokeFixedCanvas(this);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
try
|
|
{
|
|
CloseAllDialogs(suppressCallbacks: true);
|
|
}
|
|
finally
|
|
{
|
|
// Matches CharacterManagementUiController.Dispose's own
|
|
// unconditional revoke — defends against disposing while
|
|
// _isOpen (Close() is not otherwise called on this path). Idle
|
|
// if Close() already revoked (RevokeFixedCanvas is a no-op for
|
|
// an owner that already revoked).
|
|
_host.RevokeFixedCanvas(this);
|
|
_back.OnClick = null;
|
|
_next.OnClick = null;
|
|
_finish.OnClick = null;
|
|
_help.OnClick = null;
|
|
_exit.OnClick = null;
|
|
_random.OnClick = null;
|
|
_heritageTab.OnClick = null;
|
|
_professionTab.OnClick = null;
|
|
_skillsTab.OnClick = null;
|
|
_appearanceTab.OnClick = null;
|
|
_townTab.OnClick = null;
|
|
_summaryTab.OnClick = null;
|
|
_heritagePage.Dispose();
|
|
_professionPage.Dispose();
|
|
_skillsPage.Dispose();
|
|
_townPage.Dispose();
|
|
_appearancePage.Dispose();
|
|
_host.RemoveChild(Root);
|
|
}
|
|
}
|
|
|
|
// ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ──
|
|
|
|
private void OnBack()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
if (_currentPage <= Page.Heritage)
|
|
{
|
|
OnExit();
|
|
return;
|
|
}
|
|
ApplyProgressState(_currentPage - 1);
|
|
}
|
|
|
|
private void OnNext()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
if (_currentPage < Page.Summary)
|
|
ApplyProgressState(_currentPage + 1);
|
|
}
|
|
|
|
private void OnExit()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
// gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit
|
|
// click while the confirmation is already open is a no-op.
|
|
if (_exitDialogContext != 0u)
|
|
return;
|
|
|
|
_exitDialogContext = _dialogs.MakeConfirmation(
|
|
_strings.ExitWarning,
|
|
data =>
|
|
{
|
|
_exitDialogContext = 0u;
|
|
if (_disposed || _suppressDialogCallbacks)
|
|
return;
|
|
|
|
// RecvNotice_CloseDialog @ 0x004e9780's exit-context branch:
|
|
// confirm -> QueueUIMode(0x1000000a) (leave chargen). Our
|
|
// equivalent is closing this screen; whatever mounted the
|
|
// character-management screen already keeps re-drawing it
|
|
// underneath (this screen only BringToFront's itself while
|
|
// open — see Tick).
|
|
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
|
{
|
|
Close();
|
|
_bindings.RequestExit();
|
|
}
|
|
});
|
|
}
|
|
|
|
private void OnRandom()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
|
|
// are ported below; Skills' CharGenState::RandomizeSkills and the
|
|
// Summary randomize-warning dialog have no CC3 primitive/page yet
|
|
// this round — register AP-212 covers both gaps, and _random.Enabled
|
|
// already keeps the control ghosted on those pages (ApplyProgressState).
|
|
IRuntimeCharacterCreationView? view = _bindings.View();
|
|
if (view is null)
|
|
return;
|
|
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
|
|
|
|
switch (_currentPage)
|
|
{
|
|
case Page.Heritage:
|
|
_heritagePage.Randomize(snapshot);
|
|
break;
|
|
case Page.Profession:
|
|
_professionPage.Randomize(snapshot);
|
|
break;
|
|
case Page.Town:
|
|
_townPage.Randomize(view);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
|
|
|
|
private void ApplyProgressState(Page target)
|
|
{
|
|
_heritagePageRoot.Visible = false;
|
|
_professionPageRoot.Visible = false;
|
|
_skillsPageRoot.Visible = false;
|
|
_appearancePageRoot.Visible = false;
|
|
_townPageRoot.Visible = false;
|
|
_summaryPageRoot.Visible = false;
|
|
_next.Visible = true;
|
|
_finish.Visible = false;
|
|
|
|
Page previous = _currentPage;
|
|
_currentPage = target;
|
|
_heritageTab.Selected = false;
|
|
_professionTab.Selected = false;
|
|
_skillsTab.Selected = false;
|
|
_appearanceTab.Selected = false;
|
|
_townTab.Selected = false;
|
|
_summaryTab.Selected = false;
|
|
|
|
uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u;
|
|
bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi
|
|
|| heritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
|
|
if (isOlthoi)
|
|
{
|
|
_professionTab.Visible = false;
|
|
_skillsTab.Visible = false;
|
|
_townTab.Visible = false;
|
|
if (_currentPage < previous)
|
|
{
|
|
if (_currentPage is Page.Profession or Page.Skills)
|
|
_currentPage = Page.Heritage;
|
|
else if (_currentPage == Page.Town)
|
|
_currentPage = Page.Appearance;
|
|
}
|
|
else
|
|
{
|
|
if (_currentPage is Page.Profession or Page.Skills)
|
|
_currentPage = Page.Appearance;
|
|
else if (_currentPage == Page.Town)
|
|
_currentPage = Page.Summary;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_professionTab.Visible = true;
|
|
_skillsTab.Visible = true;
|
|
_townTab.Visible = true;
|
|
}
|
|
|
|
SetMasterPageState(0x10000025u + (uint)_currentPage - 1u);
|
|
switch (_currentPage)
|
|
{
|
|
case Page.Heritage:
|
|
_heritagePageRoot.Visible = true;
|
|
_heritageTab.Selected = true;
|
|
break;
|
|
case Page.Profession:
|
|
_professionPageRoot.Visible = true;
|
|
_professionTab.Selected = true;
|
|
break;
|
|
case Page.Skills:
|
|
_skillsPageRoot.Visible = true;
|
|
_skillsTab.Selected = true;
|
|
break;
|
|
case Page.Appearance:
|
|
_appearancePageRoot.Visible = true;
|
|
_appearanceTab.Selected = true;
|
|
break;
|
|
case Page.Town:
|
|
_townPageRoot.Visible = true;
|
|
_townTab.Selected = true;
|
|
break;
|
|
case Page.Summary:
|
|
_summaryPageRoot.Visible = true;
|
|
_summaryTab.Selected = true;
|
|
_next.Visible = false;
|
|
_finish.Visible = true;
|
|
break;
|
|
}
|
|
|
|
// Random (0x100003cb): 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;
|
|
|
|
_lastRevision = long.MinValue;
|
|
Tick();
|
|
}
|
|
|
|
private void SetMasterPageState(uint stateId)
|
|
{
|
|
if (_masterPage is IUiDatStateful stateful)
|
|
stateful.TrySetRetailState(stateId);
|
|
}
|
|
|
|
// ── Heritage tab-restore (gmCharGenMainUI::ListenToElementMessage @ ────
|
|
// ── 0x004e9450, the heritage-button bubble arm) ─────────────────────
|
|
|
|
/// <summary>SHOW ids (label_4e9673, three <c>SetVisible(1)</c> calls) —
|
|
/// verbatim off the decompiled switch's case list at
|
|
/// <c>0x004e9450</c>.</summary>
|
|
private static readonly IReadOnlySet<uint> HeritageTabShowButtonIds = new HashSet<uint>
|
|
{
|
|
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
|
|
0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu,
|
|
0x100005C4u, 0x100005E8u,
|
|
};
|
|
|
|
/// <summary>HIDE ids (@0x004e96b9, three <c>SetVisible(0)</c> calls) —
|
|
/// the Olthoi/OlthoiAcid heritage buttons.</summary>
|
|
private static readonly IReadOnlySet<uint> HeritageTabHideButtonIds = new HashSet<uint>
|
|
{
|
|
0x100005C7u, 0x100005C8u,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Ports <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
|
|
/// heritage-button tab-restore arm: heritage-button clicks bubble to
|
|
/// the master shell and SYNCHRONOUSLY show/hide the Profession/Skills/
|
|
/// Town tabs, independent of <see cref="ApplyProgressState"/>'s own
|
|
/// tab-visibility recompute at page-switch time (that recompute only
|
|
/// runs when Back/Next/a tab is clicked — not on every heritage pick).
|
|
/// Retail quirk reproduced faithfully: Lugian's button id
|
|
/// (<c>0x100005f1</c>) sits OUTSIDE both the SHOW and HIDE case lists
|
|
/// in the decompiled switch, so clicking Lugian neither restores nor
|
|
/// hides the tabs — a genuine retail bug (the tabs stay in whatever
|
|
/// state the PREVIOUS heritage selection left them), not an acdream
|
|
/// omission. Review fix round F3 (2026-08-15): this arm was entirely
|
|
/// unported — before this fix, selecting a human heritage right after
|
|
/// Olthoi/OlthoiAcid left the tabs hidden until the next Back/Next/tab
|
|
/// click recomputed them.
|
|
/// </summary>
|
|
private void ApplyHeritageTabRestore(uint buttonElementId)
|
|
{
|
|
if (HeritageTabShowButtonIds.Contains(buttonElementId))
|
|
{
|
|
_professionTab.Visible = true;
|
|
_skillsTab.Visible = true;
|
|
_townTab.Visible = true;
|
|
}
|
|
else if (HeritageTabHideButtonIds.Contains(buttonElementId))
|
|
{
|
|
_professionTab.Visible = false;
|
|
_skillsTab.Visible = false;
|
|
_townTab.Visible = false;
|
|
}
|
|
// Else (including Lugian, 0x100005f1): no-op, matching retail.
|
|
}
|
|
|
|
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
|
|
{
|
|
// Local-refusal / rejection surfacing is CC5's Summary-page job
|
|
// (the Finish gate only fires from that page). This round only
|
|
// needs the exit-confirmation dialog reconciled against disposal.
|
|
_ = snapshot;
|
|
}
|
|
|
|
private void Deactivate()
|
|
{
|
|
if (_active)
|
|
{
|
|
_active = false;
|
|
_openOnStartConsumed = false;
|
|
Close();
|
|
}
|
|
CloseAllDialogs(suppressCallbacks: true);
|
|
}
|
|
|
|
private void CloseAllDialogs(bool suppressCallbacks)
|
|
{
|
|
bool previous = _suppressDialogCallbacks;
|
|
_suppressDialogCallbacks |= suppressCallbacks;
|
|
try
|
|
{
|
|
if (_exitDialogContext != 0u)
|
|
{
|
|
uint closing = _exitDialogContext;
|
|
_exitDialogContext = 0u;
|
|
_dialogs.CloseDialog(closing);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_suppressDialogCallbacks = previous;
|
|
}
|
|
}
|
|
}
|