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

@ -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).
}
}