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

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

View file

@ -996,6 +996,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.GameRuntime.CharacterCreationSelectStartArea,
late.GameRuntime.CharacterCreationFinish,
RequestExit: () => { },
SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex,
SetShade: late.GameRuntime.CharacterCreationSetShade,
ResolveText: key =>
{
lock (d.DatLock)

View file

@ -227,6 +227,20 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) =>
commands.CharacterCreation.Finish(generation, confirmUnspentCredits));
// ── Campaign CC slice CC6b-MOUNT: Appearance page commands ───────────
public RuntimeCommandResult CharacterCreationSetAppearanceIndex(
ChargenAppearanceSlot slot,
uint index) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SetAppearanceIndex(generation, slot, index));
public RuntimeCommandResult CharacterCreationSetShade(
ChargenShadeSlot slot,
double value) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SetShade(generation, slot, value));
// ── 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

@ -125,6 +125,13 @@ internal sealed record LivePresentationResult(
PaperdollFramePresenter? PaperdollPresenter,
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
// Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview —
// the renderer (leased/disposed) and the controller (per-frame owner +
// late-bound zoom/rotate control surface) are separate fields because
// ChargenPreviewController does not own the renderer's lifetime (it is
// a leased composition resource, mirroring PaperdollViewportRenderer).
ChargenPreviewRenderer? ChargenPreviewRenderer,
ChargenPreviewController? ChargenPreviewController,
WbFrustum EnvCellFrustum,
EnvCellRenderer? EnvCellRenderer,
LandblockPresentationPipeline LandblockPipeline,
@ -985,6 +992,67 @@ internal sealed class LivePresentationCompositionPhase
new RetailCreatureAppraisalCloneFactory(
new LiveCreatureAppraisalEntityLookup(liveEntities)));
}
// Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview.
// Same "both arms exist, needs a dispatcher + the retained-UI
// viewport widget" shape as paperdoll/creature-appraisal above —
// this is the THIRD private creature viewport, not a new pattern.
CompositionAcquisitionScope.CompositionAcquisitionLease<
ChargenPreviewRenderer>? chargenPreviewLease = null;
ChargenPreviewController? chargenPreviewController = null;
if (dispatcherLease.Resource is { } chargenDispatcher
&& interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)
{
var chargenCamera = new ChargenPreviewCamera();
chargenPreviewLease = scope.Acquire(
"chargen preview viewport",
() => new ChargenPreviewRenderer(
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuDevice,
host.GpuFrameLifetime,
chargenDispatcher,
foundation.SceneLighting!,
foundation.TextureCache,
foundation.MeshAdapter!,
camera: chargenCamera),
static value => value.Dispose());
IUiViewportRenderer? previousChargenRenderer = chargenViewport.Renderer;
chargenViewport.Renderer = chargenPreviewLease.Resource;
bindings.AdoptRelease(
"chargen preview viewport target",
() =>
{
if (ReferenceEquals(chargenViewport.Renderer, chargenPreviewLease.Resource))
chargenViewport.Renderer = previousChargenRenderer;
});
var chargenCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats);
chargenPreviewController = new ChargenPreviewController(
chargenPreviewLease.Resource,
chargenCamera,
new RetailChargenPreviewFrameView(
chargenViewport,
new RetailChargenPreviewPageVisibility(interaction.RetainedUi.Runtime)),
content.Dats,
content.AnimationLoader,
chargenCatalog,
chargenCatalog,
d.DatLock);
interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController;
bindings.AdoptRelease(
"chargen preview control",
() =>
{
if (ReferenceEquals(
interaction.RetainedUi.Runtime.ChargenPreviewControl,
chargenPreviewController))
{
interaction.RetainedUi.Runtime.ChargenPreviewControl = null;
}
});
}
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
var envCellFrustum = new WbFrustum();
@ -1291,6 +1359,8 @@ internal sealed class LivePresentationCompositionPhase
paperdollPresenter,
creatureAppraisalLease?.Resource,
creatureAppraisalPresenter,
chargenPreviewLease?.Resource,
chargenPreviewController,
envCellFrustum,
envCellLease.Resource,
landblockPipeline,

View file

@ -157,6 +157,20 @@ internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamer
_camera = new ChargenPreviewCamera(heritageId);
}
/// <summary>
/// CC6b-MOUNT seam: wraps an EXTERNALLY-owned <see cref="ChargenPreviewCamera"/>
/// instead of constructing a private one. <see cref="ChargenPreviewZoomController"/>
/// needs a settable <see cref="ChargenPreviewCamera.Eye"/> to tween — the
/// other constructor's private <c>_camera</c> field is unreachable from
/// outside this class, so the page-mount composition (which owns the
/// zoom controller) must supply the SAME camera instance both this
/// adapter and the zoom controller mutate/read.
/// </summary>
public ChargenPreviewViewportCamera(ChargenPreviewCamera camera)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
}
public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
public Vector3 Eye => _camera.Eye;

View file

@ -0,0 +1,298 @@
using System.Diagnostics;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter;
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over
/// the CC6a/CC6b-PRE preview foundation. <see cref="CharacterCreationAppearancePage"/>
/// is constructed BEFORE the graphical presentation pipeline exists (early
/// retained-UI composition — see <see cref="AcDream.App.UI.Layout.CharacterCreationRuntimeBindings"/>'s
/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against
/// this interface's default no-op-until-assigned shape rather than a
/// concrete renderer reference. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// constructs the real <see cref="ChargenPreviewController"/> once the
/// graphics backend exists and assigns it onto the page — mirroring exactly
/// how the paperdoll's <c>viewport.Renderer = paperdollLease.Resource</c>
/// late-assignment already works for a DIFFERENT screen's viewport.
/// </summary>
internal interface IChargenPreviewControl
{
/// <summary>
/// Recomposes and rebuilds the preview entity when the heritage/gender/
/// appearance selection actually changed since the last call (a cheap
/// no-op otherwise). Returns false when the selection cannot be
/// resolved/built (heritage or gender not yet chosen, or a missing dat
/// resource) — the caller (the page) simply leaves the previous frame on
/// screen, matching <c>PaperdollFramePresenter</c>'s own
/// "keep the successful doll, retry next visible frame" precedent.
/// </summary>
bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection);
void ZoomIn();
void ZoomOut();
void RotateClockwise();
void RotateCounterClockwise();
}
/// <summary>Gates the preview's per-frame work on whether the Appearance
/// PAGE (not just the leaf viewport widget) is the currently visible page —
/// mirrors <c>IPaperdollInventoryVisibility</c>'s outer-frame gate.</summary>
internal interface IChargenPreviewPageVisibility
{
bool IsVisible { get; }
}
/// <summary>CC6b-MOUNT: narrow seam mirroring <c>IPaperdollFrameView</c> so
/// <see cref="ChargenPreviewController"/> can be exercised with a fake view
/// in tests.</summary>
internal interface IChargenPreviewFrameView
{
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
}
/// <summary>Thin adapter over <c>RetailUiRuntime.IsChargenPreviewPageVisible</c>
/// — narrowed to <see cref="IChargenPreviewPageVisibility"/> so this
/// Rendering-namespace class doesn't need a direct dependency on the
/// UI/Layout-namespace <c>RetailUiRuntime</c> type beyond the one property
/// read.</summary>
internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility
{
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
}
/// <summary>Retained-UI visibility + texture publication, mirroring
/// <c>RetailPaperdollFrameView</c>.</summary>
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
{
private readonly UiViewport _viewport;
private readonly IChargenPreviewPageVisibility _page;
public RetailChargenPreviewFrameView(
UiViewport viewport,
IChargenPreviewPageVisibility page)
{
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
_page = page ?? throw new ArgumentNullException(nameof(page));
}
public bool TryGetVisibleSize(out int width, out int height)
{
width = 0;
height = 0;
if (!_viewport.Visible || !_page.IsVisible)
return false;
width = (int)_viewport.Width;
height = (int)_viewport.Height;
return true;
}
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
}
/// <summary>
/// The real, dat-touching implementation of <see cref="IChargenPreviewControl"/>
/// plus the per-frame <see cref="IPrivateEntityViewportFrame"/> owner —
/// constructed once in <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// (same composition scope <c>RetailPaperdollPoseApplicator</c> is built in,
/// which has the real <c>content.Dats</c>/<c>content.AnimationLoader</c>/
/// <c>d.DatLock</c>) and assigned onto the already-mounted Appearance page.
///
/// <para>
/// <b>Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap):</b>
/// <see cref="ChargenPreviewRenderer"/> only ever built its OWN private
/// <see cref="ChargenPreviewCamera"/> with no injection seam, but
/// <see cref="ChargenPreviewZoomController"/> needs a SETTABLE camera to
/// tween. This class owns the ONE <see cref="ChargenPreviewCamera"/>
/// instance and hands it to the renderer via the new
/// <see cref="ChargenPreviewViewportCamera(ChargenPreviewCamera)"/> overload,
/// so both the renderer's draw and the zoom controller's tween read/write
/// the exact same eye position.
/// </para>
///
/// <para>
/// <b>Rebuild vs per-frame ownership split, decomp-cited (retail
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>):</b> the camera SNAPS to
/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER
/// change (the two confirmed direct call sites of the outer <c>Update</c> —
/// <c>InitializePage</c> and the two gender-button handlers,
/// <c>ListenToElementMessage</c> cases <c>0x9d</c>/<c>0x9e</c>) — spin/color/
/// shade changes call the narrower <c>SetSelection</c>/<c>SetColor</c>/
/// <c>SetShade</c> instead, none of which touch <c>m_vectCurPosition</c>.
/// <see cref="Rebuild"/> reproduces that split: it always recomposes the
/// ObjDesc/mesh (every appearance field feeds <c>gmCG3DView::Update</c>'s
/// rebuild eventually), but only resets the camera when heritage or gender
/// actually changed. <c>m_fCurHeading</c> (this class's
/// <see cref="ChargenPreviewRotationController"/>) and <c>m_bZoomedIn</c>
/// (read through <see cref="ChargenPreviewAnimator.IsZoomedIn"/>) both live
/// on the PAGE in retail and are NEVER reset by <c>Update</c> — so a fresh
/// <see cref="ChargenPreviewAnimator"/> (unavoidable: it owns the resolved
/// drawable-part list, which changes with the mesh) is immediately restored
/// to the PREVIOUS zoom state, and the current accumulated heading is passed
/// into the rebuild rather than resetting to the retail default.
/// </para>
/// </summary>
internal sealed class ChargenPreviewController :
IChargenPreviewControl,
IPrivateEntityViewportFrame,
IDisposable
{
private readonly IChargenPreviewRenderer _renderer;
private readonly IChargenPreviewFrameView _view;
private readonly ChargenPreviewCamera _camera;
private readonly ChargenPreviewRotationController _rotation;
private readonly IDatReaderWriter _dats;
private readonly IAnimationLoader _animations;
private readonly IChargenPalSetSource _palSets;
private readonly IChargenClothingTableSource _clothingTables;
private readonly object _datLock;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private ChargenPreviewAnimator? _animator;
private ChargenPreviewZoomController? _zoom;
private double _lastElapsedSeconds;
private bool _hasComposed;
private uint _lastHeritageId;
private int _lastGenderKey = -1;
private ChargenAppearanceSelection _lastSelection;
private bool _disposed;
/// <param name="camera">The SAME instance passed to the
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
/// parameter — see this class's own doc comment on why the renderer and
/// the zoom controller must share one mutable camera.</param>
public ChargenPreviewController(
IChargenPreviewRenderer renderer,
ChargenPreviewCamera camera,
IChargenPreviewFrameView view,
IDatReaderWriter dats,
IAnimationLoader animations,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
object datLock)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_view = view ?? throw new ArgumentNullException(nameof(view));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_rotation = new ChargenPreviewRotationController();
}
/// <summary>Test-observability seam only — production callers use
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/>.</summary>
internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false;
/// <summary>Test-observability seam only.</summary>
internal Vector3 CameraEye => _camera.Eye;
public bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection)
{
if (_disposed)
return false;
if (_hasComposed
&& heritageId == _lastHeritageId
&& genderKey == _lastGenderKey
&& selection.Equals(_lastSelection))
{
return true;
}
if (!ChargenAppearanceFactory.TryCompose(
options, heritageId, genderKey, selection,
_palSets, _clothingTables, out ChargenAppearanceResult result))
{
return false;
}
Quaternion heading = MoveToMath.SetHeading(
Quaternion.Identity, _rotation.HeadingDegrees);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
_dats, _animations, result, heritageId, heading, _datLock);
if (build is null)
return false;
bool wasZoomedIn = _animator?.IsZoomedIn ?? false;
_animator = new ChargenPreviewAnimator(build);
if (wasZoomedIn)
_animator.SetZoomedIn(true);
bool heritageOrGenderChanged =
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
if (heritageOrGenderChanged)
_camera.SetHeritage(heritageId);
// ChargenPreviewZoomController's animator dependency is required at
// construction (fix round F2) — a fresh animator means a fresh
// controller, but it reads IsZoomedIn straight through the animator
// we just restored above, so zoom state itself survives the swap.
_zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator);
_renderer.SetPreview(_animator.Entity);
_hasComposed = true;
_lastHeritageId = heritageId;
_lastGenderKey = genderKey;
_lastSelection = selection;
return true;
}
public void ZoomIn() => _zoom?.ZoomIn();
public void ZoomOut() => _zoom?.ZoomOut();
public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise);
public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise);
public void Render()
{
if (_disposed || !_view.TryGetVisibleSize(out int width, out int height))
return;
double now = _clock.Elapsed.TotalSeconds;
float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds);
_lastElapsedSeconds = now;
_animator?.Tick(deltaSeconds);
_rotation.Tick(now);
_zoom?.Tick(now);
if (_animator is not null)
_animator.Entity.Rotation = _rotation.ToOrientation();
_view.SetTextureHandle(_renderer.Render(width, height));
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// The renderer itself is a leased composition resource disposed by
// the composition root (mirrors PaperdollViewportRenderer — this
// class does not own its lifetime, only its per-frame drive).
}
}

View file

@ -5,6 +5,18 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// CC6b-MOUNT: narrow seam mirroring <c>IPaperdollDollRenderer</c> so
/// <see cref="ChargenPreviewController"/>'s rebuild/render logic can be
/// exercised with a fake in tests without a live GPU device.
/// </summary>
internal interface IChargenPreviewRenderer
{
void SetPreview(WorldEntity? entity);
uint Render(int width, int height);
}
/// <summary>
/// Chargen-specific facade over the shared private creature viewport
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
@ -44,6 +56,7 @@ namespace AcDream.App.Rendering;
/// </summary>
internal sealed class ChargenPreviewRenderer :
IUiViewportRenderer,
IChargenPreviewRenderer,
IDisposable
{
private readonly PrivateEntityViewportRenderer _renderer;
@ -57,9 +70,17 @@ internal sealed class ChargenPreviewRenderer :
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint heritageId = 0u)
uint heritageId = 0u,
ChargenPreviewCamera? camera = null)
{
_camera = new ChargenPreviewViewportCamera(heritageId);
// CC6b-MOUNT: when a caller supplies its own camera instance (the
// page-mount composition, which needs a SETTABLE camera for
// ChargenPreviewZoomController to tween — see
// ChargenPreviewController's own doc comment), wrap that exact
// instance instead of building a private, unreachable one.
_camera = camera is not null
? new ChargenPreviewViewportCamera(camera)
: new ChargenPreviewViewportCamera(heritageId);
_renderer = new PrivateEntityViewportRenderer(
scope,
device,

View file

@ -46,12 +46,54 @@ internal sealed class ChargenPreviewRotationController
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
private bool _rotating;
/// <summary>
/// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's
/// value. <c>gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0</c> sets
/// <c>m_fCurHeading = 0f</c> at <c>0x0047CDAC</c>, but
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> — which always
/// runs immediately afterward, before the page is ever visible — writes
/// <c>m_fCurHeading = 180f</c> at <c>0x00480235</c> and pushes it into the
/// view via <c>gmCG3DView::SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x0048023F</c>. No player-visible frame of chargen's Appearance
/// preview is EVER rendered at the ctor's 0° — 180° is the only heading a
/// user actually sees. The same override, independently, is what every
/// other <c>gmCG3DView</c> owner does for ITS own instance:
/// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate
/// viewport/page, CC5's scope, not this one) and
/// <c>gmBarberUI::PostInit</c> (~<c>0x004DE330</c>, pushed at
/// <c>0x004E03B5</c>) both call the identical
/// <c>SetPlayerHeading(m_p3DView, 180f)</c> for their own pages. Since
/// this controller — like retail's <c>m_fCurHeading</c> — is itself the
/// PAGE-level heading owner (not the view's), matching the value every
/// real page converges on before its first frame is the retail-faithful
/// choice; requiring every future mount site to remember a separate
/// "seed to 180" call would be a trap (a forgotten seed silently faces
/// the character away from the camera).
/// </summary>
public const float RetailDefaultHeadingDegrees = 180f;
public bool IsRotating => _rotating;
public ChargenRotateDirection Direction => _direction;
/// <summary>Retail's <c>m_fCurHeading</c>, degrees, ctor default 0 —
/// applied to the preview entity via <c>MoveToMath.SetHeading</c>
/// (<c>CPhysicsObj::set_heading</c>'s exact port).</summary>
/// <param name="initialHeadingDegrees">Defaults to
/// <see cref="RetailDefaultHeadingDegrees"/> (see that constant's doc for
/// the full ctor-vs-InitializePage citation) — the value every real
/// mount site should get for free. Tests that exercise the pure
/// rotation/wrap arithmetic pass <c>0f</c> explicitly for simpler
/// relative-delta assertions; that is a test convenience, not a second
/// retail-cited default.</param>
public ChargenPreviewRotationController(
float initialHeadingDegrees = RetailDefaultHeadingDegrees)
{
HeadingDegrees = initialHeadingDegrees;
}
/// <summary>Retail's <c>m_fCurHeading</c>, degrees — applied to the
/// preview entity via <c>MoveToMath.SetHeading</c>
/// (<c>CPhysicsObj::set_heading</c>'s exact port). See
/// <see cref="RetailDefaultHeadingDegrees"/> for why this controller's
/// parameterless-constructor default is 180, not the ctor's raw 0.
/// </summary>
public float HeadingDegrees { get; private set; }
/// <summary>

View file

@ -429,6 +429,11 @@ public sealed class GameWindow :
_creatureAppraisalViewportRenderer;
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
_creatureAppraisalFramePresenter;
// Campaign CC slice CC6b-MOUNT — the chargen Appearance-page preview,
// same guard/shutdown shape as the paperdoll/creature-appraisal
// viewports above.
private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer;
private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController;
// 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;
@ -1077,6 +1082,8 @@ public sealed class GameWindow :
|| _paperdollFramePresenter is not null
|| _creatureAppraisalViewportRenderer is not null
|| _creatureAppraisalFramePresenter is not null
|| _chargenPreviewRenderer is not null
|| _chargenPreviewController is not null
|| _envCellRenderer is not null
|| _envCellFrustum is not null
|| _landblockPresentationPipeline is not null
@ -1114,6 +1121,8 @@ public sealed class GameWindow :
_paperdollFramePresenter = result.PaperdollPresenter;
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
_chargenPreviewController = result.ChargenPreviewController;
_envCellFrustum = result.EnvCellFrustum;
_envCellRenderer = result.EnvCellRenderer;
_landblockPresentationPipeline = result.LandblockPipeline;
@ -1759,6 +1768,8 @@ public sealed class GameWindow :
_portalTunnelFallback,
_paperdollViewportRenderer,
_creatureAppraisalViewportRenderer,
_chargenPreviewRenderer,
_chargenPreviewController,
_wbDrawDispatcher,
_envCellRenderer,
_portalDepthMask,

View file

@ -112,6 +112,8 @@ internal sealed record RenderShutdownRoots(
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
PaperdollViewportRenderer? Paperdoll,
CreatureAppraisalViewportRenderer? CreatureAppraisal,
ChargenPreviewRenderer? ChargenPreview,
ChargenPreviewController? ChargenPreviewController,
WbDrawDispatcher? DrawDispatcher,
EnvCellRenderer? EnvironmentCells,
PortalDepthMaskRenderer? PortalDepthMask,
@ -489,6 +491,8 @@ internal static class GameWindowShutdownManifest
Hard(
"creature appraisal viewport",
() => render.CreatureAppraisal?.Dispose()),
Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()),
Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()),
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),

View file

@ -0,0 +1,692 @@
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 Appearance page (<c>gmCGAppearancePage</c>, root <c>0x100003d4</c>) —
/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors:
/// <c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c> (widget ids, the
/// 180° initial heading), <c>::ListenToElementMessage @ 0x0047EF30</c> (EVERY
/// dispatch this page implements — gender buttons at cases <c>0x9d</c>/
/// <c>0x9e</c>; Face/Clothes sub-tabs at <c>0x9f</c>/<c>0xa0</c>; the nine
/// spin controls' decrement/increment arrow children, keyed by PARENT id, at
/// cases <c>0</c>/<c>1</c>; the same nine spins' own BODY click (select-as-
/// current-part, no index change) at cases <c>0xa5</c>-<c>0xa9</c> and the
/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches
/// at cases <c>5</c>-<c>0xd</c> -&gt; <c>SetColor(0..8)</c>; the shade
/// scrollbar at case <c>0x17</c> -&gt; <c>SetShade</c>; rotate at
/// <c>0x19</c>/<c>0x1a</c>; zoom at <c>0x1b</c>/<c>0x1c</c>),
/// <c>::SetColor @ 0x0047DD50</c> and <c>::SetShade @ 0x0047C860</c> (the
/// per-part color/shade routing table this page's <see cref="Part"/> switch
/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and
/// EYES having no shade at all), <c>::Update @ 0x0047E8F0</c> (the heritage
/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable
/// gate).
///
/// <para>
/// <b>Spin arrow geometry (live-DAT probe, CC6b-MOUNT):</b> every one of the
/// nine spin elements (<c>0x100003af-b3</c>, <c>b5-b8</c>) is uniformly
/// 200px wide with two LOCALLY-reused arrow child ids (<c>0x1000030a</c>
/// decrement at local x=[80,127), <c>0x1000030b</c> increment at
/// x=[127,174)) that <c>DatWidgetFactory</c>'s <c>UiButton</c> consumes into
/// one flat clickable leaf — there is no separate addressable arrow widget
/// to bind. This page reproduces retail's two-arrow-plus-body-click shape
/// entirely through <see cref="UiButton.OnClickAt"/>'s local x coordinate
/// (no new <c>DatWidgetFactory</c> widget type needed — see this campaign's
/// color-wheel scouting finding below, which reached the identical "existing
/// types suffice" conclusion for the whole page).
/// </para>
///
/// <para>
/// <b>Color-wheel family scouting (campaign plan risk item 4, RESOLVED):</b>
/// a live-DAT probe (<c>CharacterCreationLiveDatTests</c>) found every
/// color-wheel-family id resolves through EXISTING <c>DatWidgetFactory</c>
/// mappings: the nine swatch buttons (<c>0x1000030f-0x10000317</c>, retail's
/// <c>SetColor(0..8)</c> targets) author Type 1 -&gt; <c>UiButton</c>; their
/// nine Type-3 companion "selected" overlays (<c>0x10000318-0x10000320</c>,
/// retail's <c>m_tColorWheel[...][0x10][iCurColor*7]-&gt;SetVisible</c>
/// highlight ring) and the GradCircle (<c>0x1000030e</c>) author Type 3 -&gt;
/// <c>UiDatElement</c>; the shade scrollbar (<c>0x10000321</c>) authors
/// Type 0xB -&gt; <c>UiScrollbar</c>, matching the decomp's own
/// <c>DynamicCast(0xb)</c>. NO new widget type was added. This page uses the
/// swatch buttons' own <see cref="UiButton.Selected"/> state for the
/// highlight instead of toggling the separate companion overlay elements —
/// a documented substitution (same class as AD-103's swallowed-child
/// pattern), not a pixel-identical port of retail's own two-widget
/// mechanism.
/// </para>
///
/// <para>
/// <b>Icon-only style lists have no name string (register-worthy scope
/// cut):</b> CC1's <see cref="ChargenHairStyle"/>/<see cref="ChargenEyeStrip"/>/
/// <see cref="ChargenFaceStrip"/> carry an <c>IconId</c>, not a name — retail
/// shows an actual icon thumbnail in these four spins (hair/eyes/nose/
/// mouth). Icon rendering is out of this round's scope; the spin shows a
/// 1-based ordinal instead. The four clothing spins (headgear/shirt/
/// trousers/footwear) DO carry a real <see cref="ChargenGearOption.Name"/>
/// and show it directly.
/// </para>
/// </summary>
internal sealed class CharacterCreationAppearancePage : IDisposable
{
private const uint Unset = RuntimeCharacterCreationAppearance.Unset;
internal enum Part
{
Hair = 1,
Eyes = 2,
Nose = 3,
Mouth = 4,
Skin = 5,
Headgear = 6,
Shirt = 7,
Trousers = 8,
Footwear = 9,
}
private enum Choice
{
Face,
Clothes,
}
internal const uint FemaleButtonId = 0x100003A7u;
internal const uint MaleButtonId = 0x100003A8u;
internal const uint FaceButtonId = 0x100003A9u;
internal const uint ClothesButtonId = 0x100003AAu;
internal const uint FaceChoicesId = 0x100003AEu;
internal const uint ClothesChoicesId = 0x100003B4u;
internal const uint HairSpinId = 0x100003AFu;
internal const uint EyesSpinId = 0x100003B0u;
internal const uint NoseSpinId = 0x100003B1u;
internal const uint MouthSpinId = 0x100003B2u;
internal const uint SkinSpinId = 0x100003B3u;
internal const uint HeadgearSpinId = 0x100003B5u;
internal const uint ShirtSpinId = 0x100003B6u;
internal const uint TrousersSpinId = 0x100003B7u;
internal const uint FootwearSpinId = 0x100003B8u;
internal const uint RotateClockwiseId = 0x10000323u;
internal const uint RotateCounterClockwiseId = 0x10000324u;
internal const uint ZoomInId = 0x10000325u;
internal const uint ZoomOutId = 0x10000326u;
internal const uint GradCircleId = 0x1000030Eu;
internal const uint ShadeScrollId = 0x10000321u;
internal const uint ViewportId = 0x100003BBu;
/// <summary>Retail's nine <c>SetColor(0..8)</c> swatch buttons, in
/// index order — verbatim off <c>ListenToElementMessage</c>'s cases
/// <c>5</c>-<c>0xd</c> (<c>elementId - 0x1000030a</c>).</summary>
internal static readonly uint[] SwatchIds =
[
0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u,
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
];
/// <summary>Live-DAT-measured arrow geometry, uniform across all nine
/// spins (every one is 200px wide): decrement child at local
/// x=[80,127), increment child at x=[127,174). Anything outside both
/// zones is the spin's own BODY click (retail cases <c>0xa5</c>-<c>0xa9</c>
/// and their headgear/shirt/trousers/footwear mirrors).</summary>
private const float DecrementZoneStart = 80f;
private const float IncrementZoneStart = 127f;
private const float IncrementZoneEnd = 174f;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiButton? _femaleButton;
private readonly UiButton? _maleButton;
private readonly UiButton? _faceButton;
private readonly UiButton? _clothesButton;
private readonly UiElement? _faceChoices;
private readonly UiElement? _clothesChoices;
private readonly Dictionary<Part, UiButton> _spins = [];
private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length];
private readonly UiScrollbar? _shadeScroll;
private readonly UiButton? _rotateClockwise;
private readonly UiButton? _rotateCounterClockwise;
private readonly UiButton? _zoomIn;
private readonly UiButton? _zoomOut;
private Choice _currentChoice = Choice.Face;
private Part _currentPart = Part.Hair;
private bool _eyesArrowsDisabled;
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>0x100003bb</c>) — the composition
/// root assigns its <c>Renderer</c> once the graphics backend exists,
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
/// assignment.</summary>
internal UiViewport? Viewport { get; }
internal CharacterCreationAppearancePage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings)
{
_bindings = bindings;
_femaleButton = Find<UiButton>(pageRoot, FemaleButtonId);
if (_femaleButton is not null)
_femaleButton.OnClick = () => _bindings.SelectGender(2u);
_maleButton = Find<UiButton>(pageRoot, MaleButtonId);
if (_maleButton is not null)
_maleButton.OnClick = () => _bindings.SelectGender(1u);
_faceButton = Find<UiButton>(pageRoot, FaceButtonId);
if (_faceButton is not null)
_faceButton.OnClick = () => SelectChoice(Choice.Face);
_clothesButton = Find<UiButton>(pageRoot, ClothesButtonId);
if (_clothesButton is not null)
_clothesButton.OnClick = () => SelectChoice(Choice.Clothes);
_faceChoices = Find<UiElement>(pageRoot, FaceChoicesId);
_clothesChoices = Find<UiElement>(pageRoot, ClothesChoicesId);
BindSpin(pageRoot, HairSpinId, Part.Hair);
BindSpin(pageRoot, EyesSpinId, Part.Eyes);
BindSpin(pageRoot, NoseSpinId, Part.Nose);
BindSpin(pageRoot, MouthSpinId, Part.Mouth);
BindSpin(pageRoot, SkinSpinId, Part.Skin);
BindSpin(pageRoot, HeadgearSpinId, Part.Headgear);
BindSpin(pageRoot, ShirtSpinId, Part.Shirt);
BindSpin(pageRoot, TrousersSpinId, Part.Trousers);
BindSpin(pageRoot, FootwearSpinId, Part.Footwear);
for (int i = 0; i < SwatchIds.Length; i++)
{
UiButton? swatch = Find<UiButton>(pageRoot, SwatchIds[i]);
if (swatch is null)
continue;
int index = i;
swatch.OnClick = () => SelectColor(index);
_swatches[i] = swatch;
}
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = SetShadeFromScalar;
Viewport = Find<UiViewport>(pageRoot, ViewportId);
_rotateClockwise = Find<UiButton>(pageRoot, RotateClockwiseId);
if (_rotateClockwise is not null)
_rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise();
_rotateCounterClockwise = Find<UiButton>(pageRoot, RotateCounterClockwiseId);
if (_rotateCounterClockwise is not null)
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
_zoomIn = Find<UiButton>(pageRoot, ZoomInId);
if (_zoomIn is not null)
_zoomIn.OnClick = () => PreviewControl?.ZoomIn();
_zoomOut = Find<UiButton>(pageRoot, ZoomOutId);
if (_zoomOut is not null)
_zoomOut.OnClick = () => PreviewControl?.ZoomOut();
ApplyChoiceVisibility();
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (_disposed)
return;
if (_femaleButton is not null)
_femaleButton.Selected = snapshot.GenderKey == 2u;
if (_maleButton is not null)
_maleButton.Selected = snapshot.GenderKey == 1u;
// gmCGAppearancePage::Update @ ~0x0047EB46-0x0047EE95: heritage
// 6 (Gearknight) / 0xc (Olthoi) / 0xd (OlthoiAcid) hide the Clothes
// sub-tab (and, with it, every clothing spin behind it), hide the
// Nose/Mouth spins directly, and disable the Eyes spin's arrows —
// none of these three heritages have separate clothing, nose, or
// mouth strip choices.
bool clothesHidden = IsClothesHiddenHeritage(snapshot.HeritageId);
if (_clothesButton is not null)
_clothesButton.Visible = !clothesHidden;
if (_spins.TryGetValue(Part.Nose, out UiButton? noseSpin))
noseSpin.Visible = !clothesHidden;
if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin))
mouthSpin.Visible = !clothesHidden;
_eyesArrowsDisabled = clothesHidden;
if (clothesHidden && _currentChoice == Choice.Clothes)
{
// Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes
// unreachable so the page never gets stuck showing a hidden tab.
_currentChoice = Choice.Face;
_currentPart = Part.Hair;
}
ApplyChoiceVisibility();
if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
RefreshSpins(gender, snapshot.Appearance);
RefreshColorAndShadeControls(view, snapshot);
RebuildPreview(view, snapshot);
}
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
private void SelectChoice(Choice choice)
{
if (_disposed)
return;
_currentChoice = choice;
// gmCGAppearancePage::ListenToElementMessage cases 0x9f/0xa0:
// Face -> SetSelection(ECG_PARTS_HAIR); Clothes ->
// SetSelection(ECG_PARTS_HEADGEAR).
_currentPart = choice == Choice.Face ? Part.Hair : Part.Headgear;
ApplyChoiceVisibility();
RefreshColorAndShadeControlsFromLatestSnapshot();
}
private void ApplyChoiceVisibility()
{
if (_faceChoices is not null)
_faceChoices.Visible = _currentChoice == Choice.Face;
if (_clothesChoices is not null)
_clothesChoices.Visible = _currentChoice == Choice.Clothes;
if (_faceButton is not null)
_faceButton.Selected = _currentChoice == Choice.Face;
if (_clothesButton is not null)
_clothesButton.Selected = _currentChoice == Choice.Clothes;
}
// ── Spins (style cycling + select-as-current-part) ─────────────────
private void BindSpin(UiElement pageRoot, uint id, Part part)
{
UiButton? spin = Find<UiButton>(pageRoot, id);
if (spin is null)
return;
_spins[part] = spin;
if (part == Part.Skin)
{
// Skin has no style index at all — retail disables its arrow
// children outright (SetAttribute_Bool(...,0xd,1) in
// InitializePage/Update's heritage branches). Every click just
// selects Skin as the current part for the color/shade controls.
spin.OnClickAt = (_, _) => SelectPart(Part.Skin);
return;
}
spin.OnClickAt = (x, _) =>
{
if (x >= DecrementZoneStart && x < IncrementZoneStart)
CycleStyle(part, -1);
else if (x >= IncrementZoneStart && x < IncrementZoneEnd)
CycleStyle(part, +1);
else
SelectPart(part);
};
}
private void SelectPart(Part part)
{
if (_disposed)
return;
_currentPart = part;
RefreshColorAndShadeControlsFromLatestSnapshot();
}
private void CycleStyle(Part part, int delta)
{
if (_disposed)
return;
if (part == Part.Eyes && _eyesArrowsDisabled)
{
SelectPart(part);
return;
}
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
return;
ChargenAppearanceSlot? slot = StyleSlotFor(part);
if (slot is null)
{
SelectPart(part);
return;
}
int count = StyleCount(part, gender);
uint current = StyleCurrent(part, snapshot.Appearance);
// Headgear alone allows the Unset ("no headgear") ring position —
// CharGenState::SetHeadgearStyle's decomp-derived (count+1)-position
// ring (0..count-1, Unset); every other style spin cycles [0,count).
uint next = CycleIndex(current, delta, count, allowUnset: part == Part.Headgear);
_bindings.SetAppearanceIndex?.Invoke(slot.Value, next);
SelectPart(part);
}
/// <summary>
/// Retail's decomp-derived wrap: <paramref name="allowUnset"/> reproduces
/// <c>CharGenState::SetHeadgearStyle</c>'s literal signed-int32 ring of
/// <paramref name="count"/>+1 positions (every real index, plus
/// <see cref="Unset"/> — decrementing from index 0 lands on Unset,
/// incrementing from Unset lands on index 0, matching
/// <c>ListenToElementMessage</c>'s cases <c>6</c> exactly). Every other
/// style spin has no decomp-observable Unset-cycling case (retail always
/// has a real 0-based index by the time the user can click — see
/// AP-214's <c>RandomizeCharacter</c>-at-open finding, which acdream
/// does not port this round) — an Unset start there is an edge case
/// retail itself never reaches, so the first click either direction just
/// starts cycling from index 0 rather than reconstructing an unfounded
/// wrap direction.
/// </summary>
internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset)
{
if (count <= 0)
return Unset;
if (allowUnset)
{
int cur = current == Unset ? count : (int)current;
int size = count + 1;
int next = Mod(cur + delta, size);
return next == count ? Unset : (uint)next;
}
if (current == Unset)
return 0u;
return (uint)Mod((int)current + delta, count);
}
private static int Mod(int value, int modulus) =>
((value % modulus) + modulus) % modulus;
// ── Color swatches + shade scroll ───────────────────────────────────
private void SelectColor(int index)
{
if (_disposed)
return;
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
return;
ChargenAppearanceSlot? slot = ColorSlotFor(_currentPart);
if (slot is null)
return;
// gmCGAppearancePage::ListenToElementMessage's swatch cases each
// gate on the current part's own color-list length before calling
// SetColor — a swatch beyond the list clicks through to nothing.
int count = ColorCount(_currentPart, gender);
if (index >= count)
return;
_bindings.SetAppearanceIndex?.Invoke(slot.Value, (uint)index);
}
private void SetShadeFromScalar(float scalar)
{
if (_disposed)
return;
ChargenShadeSlot? slot = ShadeSlotFor(_currentPart);
if (slot is null)
return;
_bindings.SetShade?.Invoke(slot.Value, scalar);
}
private void RefreshColorAndShadeControlsFromLatestSnapshot()
{
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is not null)
RefreshColorAndShadeControls(view, view.Snapshot);
}
private void RefreshColorAndShadeControls(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
for (int i = 0; i < _swatches.Length; i++)
{
if (_swatches[i] is { } swatch)
swatch.Selected = colorSlot is not null && currentColor == (uint)i;
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null)
return;
_shadeScroll.Enabled = shadeSlot is not null;
if (shadeSlot is { } slot)
{
double shade = ShadeCurrent(slot, snapshot.Appearance);
float scalar = shade < 0.0 ? 0f : (float)Math.Clamp(shade, 0.0, 1.0);
_shadeScroll.SetScalarPosition(scalar);
}
}
// ── Spin labels ──────────────────────────────────────────────────
private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a)
{
SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle);
SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip);
SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip);
SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip);
SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle);
SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle);
SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle);
SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle);
}
private void SetStyleSpinLabel(Part part, int count, uint index)
{
if (!_spins.TryGetValue(part, out UiButton? spin))
return;
spin.Label = index != Unset && index < (uint)count
? (index + 1).ToString(CultureInfo.InvariantCulture)
: "-";
}
private void SetGearSpinLabel(Part part, IReadOnlyList<ChargenGearOption> options, uint index)
{
if (!_spins.TryGetValue(part, out UiButton? spin))
return;
spin.Label = index != Unset && index < (uint)options.Count
? options[(int)index].Name
: "None";
}
// ── Preview rebuild ──────────────────────────────────────────────
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);
}
// ── Per-part routing tables (retail SetColor @0x0047DD50 / SetShade @0x0047C860) ──
private static ChargenAppearanceSlot? StyleSlotFor(Part part) => part switch
{
Part.Hair => ChargenAppearanceSlot.HairStyle,
Part.Eyes => ChargenAppearanceSlot.EyesStrip,
Part.Nose => ChargenAppearanceSlot.NoseStrip,
Part.Mouth => ChargenAppearanceSlot.MouthStrip,
Part.Headgear => ChargenAppearanceSlot.HeadgearStyle,
Part.Shirt => ChargenAppearanceSlot.ShirtStyle,
Part.Trousers => ChargenAppearanceSlot.TrousersStyle,
Part.Footwear => ChargenAppearanceSlot.FootwearStyle,
_ => null, // Skin.
};
/// <summary>Retail's SIX colorable parts (<c>SetColor</c>'s cases
/// <c>0,1,5,6,7,8</c>) — Nose/Mouth/Skin have no color list at all.</summary>
private static ChargenAppearanceSlot? ColorSlotFor(Part part) => part switch
{
Part.Hair => ChargenAppearanceSlot.HairColor,
Part.Eyes => ChargenAppearanceSlot.EyeColor,
Part.Headgear => ChargenAppearanceSlot.HeadgearColor,
Part.Shirt => ChargenAppearanceSlot.ShirtColor,
Part.Trousers => ChargenAppearanceSlot.TrousersColor,
Part.Footwear => ChargenAppearanceSlot.FootwearColor,
_ => null,
};
/// <summary>Retail's <c>SetShade</c> switch: Hair has its own shade;
/// Nose/Mouth/Skin ALL route to skin shade (cases 2/3/4 share one body
/// in the decompiled switch — a genuine retail quirk, not a porting
/// shortcut); Eyes has NO case at all (eye color has no shade
/// indirection anywhere in this campaign's model).</summary>
private static ChargenShadeSlot? ShadeSlotFor(Part part) => part switch
{
Part.Hair => ChargenShadeSlot.Hair,
Part.Nose => ChargenShadeSlot.Skin,
Part.Mouth => ChargenShadeSlot.Skin,
Part.Skin => ChargenShadeSlot.Skin,
Part.Headgear => ChargenShadeSlot.Headgear,
Part.Shirt => ChargenShadeSlot.Shirt,
Part.Trousers => ChargenShadeSlot.Trousers,
Part.Footwear => ChargenShadeSlot.Footwear,
_ => null, // Eyes.
};
private static int StyleCount(Part part, ChargenGenderOptions gender) => part switch
{
Part.Hair => gender.HairStyles.Count,
Part.Eyes => gender.EyeStrips.Count,
Part.Nose => gender.NoseStrips.Count,
Part.Mouth => gender.MouthStrips.Count,
Part.Headgear => gender.Headgears.Count,
Part.Shirt => gender.Shirts.Count,
Part.Trousers => gender.Pants.Count,
Part.Footwear => gender.Footwear.Count,
_ => 0,
};
/// <summary>Hair/Eyes have their own real per-gender color lists;
/// the four clothing slots share the gender's single
/// <see cref="ChargenGenderOptions.ClothingColors"/> list (register
/// AP-208).</summary>
private static int ColorCount(Part part, ChargenGenderOptions gender) => part switch
{
Part.Hair => gender.HairColors.Count,
Part.Eyes => gender.EyeColors.Count,
Part.Headgear or Part.Shirt or Part.Trousers or Part.Footwear =>
gender.ClothingColors.Count,
_ => 0,
};
private static uint StyleCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch
{
Part.Hair => a.HairStyle,
Part.Eyes => a.EyesStrip,
Part.Nose => a.NoseStrip,
Part.Mouth => a.MouthStrip,
Part.Headgear => a.HeadgearStyle,
Part.Shirt => a.ShirtStyle,
Part.Trousers => a.TrousersStyle,
Part.Footwear => a.FootwearStyle,
_ => Unset,
};
private static uint ColorCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch
{
Part.Hair => a.HairColor,
Part.Eyes => a.EyeColor,
Part.Headgear => a.HeadgearColor,
Part.Shirt => a.ShirtColor,
Part.Trousers => a.TrousersColor,
Part.Footwear => a.FootwearColor,
_ => Unset,
};
private static double ShadeCurrent(ChargenShadeSlot slot, RuntimeCharacterCreationAppearance a) => slot switch
{
ChargenShadeSlot.Skin => a.SkinShade,
ChargenShadeSlot.Hair => a.HairShade,
ChargenShadeSlot.Headgear => a.HeadgearShade,
ChargenShadeSlot.Shirt => a.ShirtShade,
ChargenShadeSlot.Trousers => a.TrousersShade,
ChargenShadeSlot.Footwear => a.FootwearShade,
_ => 0.0,
};
private static bool IsClothesHiddenHeritage(uint heritageId) =>
heritageId == (uint)ChargenHeritageGroup.Gearknight
|| heritageId == (uint)ChargenHeritageGroup.Olthoi
|| heritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
private static bool TryGetGender(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender)
{
gender = null;
if (snapshot.HeritageId == 0u || snapshot.GenderKey == 0u)
return false;
if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage))
return false;
return heritage.GendersByKey.TryGetValue((int)snapshot.GenderKey, out gender);
}
private static T? Find<T>(UiElement root, uint id) where T : UiElement =>
UiElement.FindDescendant(root, id) as T;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_femaleButton is not null) _femaleButton.OnClick = null;
if (_maleButton is not null) _maleButton.OnClick = null;
if (_faceButton is not null) _faceButton.OnClick = null;
if (_clothesButton is not null) _clothesButton.OnClick = null;
foreach (UiButton spin in _spins.Values)
spin.OnClickAt = null;
_spins.Clear();
foreach (UiButton? swatch in _swatches)
{
if (swatch is not null)
swatch.OnClick = null;
}
if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = null;
if (_rotateClockwise is not null) _rotateClockwise.OnClick = null;
if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = null;
if (_zoomIn is not null) _zoomIn.OnClick = null;
if (_zoomOut is not null) _zoomOut.OnClick = null;
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.
PreviewControl = null;
}
}

View file

@ -137,30 +137,29 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
Select(chosen);
}
/// <summary>
/// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's
/// real gender buttons (<c>0x100003a7</c>/<c>0x100003a8</c>) now exist,
/// so this no longer needs to auto-select a gender to keep the
/// Profession/Skills/Town pages usable — gender is a real player choice.
/// Retail's own default here is genuinely NOT blank: <c>CharGenState::
/// Reset @ 0x005C68A0</c> calls <c>SetGender(this, 0)</c> (unset), but
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> calls
/// <c>CharGenState::RandomizeCharacter</c> (0x005c6d80) BEFORE any page
/// constructs — retail's chargen screen always opens with a fully
/// RANDOM heritage/gender/appearance/clothing/template/start-area
/// already rolled (see the ~0x004e81f5-0x004e8218 ctor call, ahead of
/// every page's own <c>InitializePage</c>). acdream does not port
/// <c>RandomizeCharacter</c> this round (register AP-214, the same
/// unported-primitive gap AP-212 already tracks for the Random button)
/// — so acdream's screen opens honestly blank instead, and gender is now
/// the player's first real choice on the Appearance page.
/// </summary>
private void Select(uint heritageId)
{
if (_disposed)
return;
RuntimeCommandResult result = _bindings.SelectHeritage(heritageId);
if (!result.Accepted)
return;
// CC4 interim default (register AD-101): the Profession/Skills/Town
// pages this slice builds need heritage+gender both selected
// (RuntimeCharacterCreationState.TrySelectTemplate's gate), but
// gender selection lives on the Appearance page (0x100003a7/a8),
// which stays an inert placeholder until CC6b. Auto-select the
// heritage's first available gender so those pages remain usable;
// CC6b's real gender buttons supersede this and the row retires
// then.
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is not null
&& view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
&& heritage.GendersByKey.Count > 0)
{
int genderKey = heritage.GendersByKey.Keys.Min();
_bindings.SelectGender((uint)genderKey);
}
_bindings.SelectHeritage(heritageId);
}
/// <summary>

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);
}
}

View file

@ -622,6 +622,31 @@ public sealed class RetailUiRuntime : IDisposable
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted.
/// Mirrors <see cref="PaperdollViewportWidget"/>'s own computed-through
/// shape.</summary>
internal UiViewport? ChargenPreviewViewportWidget =>
CharacterCreationController?.AppearanceViewport;
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface the
/// composition root assigns once the graphics backend exists.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl
{
get => CharacterCreationController?.AppearancePreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePreviewControl = value;
}
}
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
/// the one currently showing — false, safely, before the screen mounts.
/// </summary>
internal bool IsChargenPreviewPageVisible =>
CharacterCreationController?.IsAppearancePageVisible ?? false;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);