feat(chargen): Campaign CC slice CC6b-MOUNT — Appearance page + preview mount

The page-mount half CC6b-PRE deferred: CharacterCreationAppearancePage
(gender buttons, Face/Clothes sub-tabs, nine spin controls with retail's
decrement/increment/select-as-current-part OnClickAt zones, nine color
swatches, shade scrollbar, zoom/rotate wiring) plus ChargenPreviewController,
which bridges the ChargenPreviewRenderer/ChargenPreviewZoomController
camera-injection gap CC6a/CC6b-PRE left open and mounts as the third private
creature viewport beside paperdoll/creature-appraisal.

Color-wheel scouting (campaign risk item 4): live-DAT probe found every
color-wheel-family id resolves through existing DatWidgetFactory mappings
(Button/Scrollbar/generic fallback) — no new widget type needed.

The @140355 gender-flip-on-init oddity (risk item 5): resolved via decomp
alone — gmCharGenMainUI's own ctor calls CharGenState::RandomizeCharacter
before any page constructs, so retail's chargen screen is never actually
blank on open; the Appearance page's gender-flip code always fires against
a real, randomly-rolled gender. Filed AP-214 (acdream doesn't port
RandomizeCharacter this round, so it opens honestly blank instead) and
AP-215 (two narrow visual substitutions: swatch .Selected highlight vs
retail's separate overlay, ordinal labels vs retail's icon-only spins).

AD-101 retired: the Heritage page's auto-gender-select interim default is
deleted now that the Appearance page's real gender buttons exist. TS-82
narrowed to Summary-only.

Scope addendum: ChargenPreviewRotationController's parameterless-constructor
default changes from 0f to a new RetailDefaultHeadingDegrees=180f constant
(retail's InitializePage override, not the ctor's raw 0) — every real
gmCG3DView owner converges on 180 before its first frame, so a controller
defaulting to 0 was a trap for future consumers.

Runtime 1713/0, Core 4786/1 skip, Content 147/0, App 5220/3 skips (Release,
ACDREAM_PROBE_LIVE_MOUNT=1) — zero failures across two clean full-solution
runs; the one Core.Net.Tests NakEmissionTests flake observed on a third run
is the same pre-existing, previously-documented timing flake (zero files
under src/AcDream.Core.Net/ touched, passes 100% in isolation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 21:00:10 +02:00
parent 11374484dc
commit 34c6fceab0
20 changed files with 2109 additions and 57 deletions

View file

@ -32,6 +32,11 @@ public sealed record CharacterCreationRuntimeBindings(
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.
@ -130,6 +135,7 @@ internal sealed class CharacterCreationUiController : IDisposable
private readonly CharacterCreationProfessionPage _professionPage;
private readonly CharacterCreationSkillsPage _skillsPage;
private readonly CharacterCreationTownPage _townPage;
private readonly CharacterCreationAppearancePage _appearancePage;
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration;
@ -218,6 +224,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_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;
@ -247,6 +254,30 @@ internal sealed class CharacterCreationUiController : IDisposable
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,
@ -370,6 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_professionPage.Refresh(view, snapshot);
_skillsPage.Refresh(view, snapshot);
_townPage.Refresh(view, snapshot);
_appearancePage.Refresh(view, snapshot);
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
@ -437,6 +469,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_professionPage.Dispose();
_skillsPage.Dispose();
_townPage.Dispose();
_appearancePage.Dispose();
_host.RemoveChild(Root);
}
}