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:
parent
11374484dc
commit
34c6fceab0
20 changed files with 2109 additions and 57 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -539,7 +539,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
renderFrameResources,
|
renderFrameResources,
|
||||||
new PrivateEntityViewportFrameGroup(
|
new PrivateEntityViewportFrameGroup(
|
||||||
live.PaperdollPresenter,
|
live.PaperdollPresenter,
|
||||||
live.CreatureAppraisalPresenter),
|
live.CreatureAppraisalPresenter,
|
||||||
|
live.ChargenPreviewController),
|
||||||
retainedGameplayUi,
|
retainedGameplayUi,
|
||||||
// The ImGui developer-tools frontend was removed at Campaign V
|
// The ImGui developer-tools frontend was removed at Campaign V
|
||||||
// slice V11; this optional hook is unbound until a follow-up
|
// slice V11; this optional hook is unbound until a follow-up
|
||||||
|
|
|
||||||
|
|
@ -996,6 +996,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
late.GameRuntime.CharacterCreationSelectStartArea,
|
late.GameRuntime.CharacterCreationSelectStartArea,
|
||||||
late.GameRuntime.CharacterCreationFinish,
|
late.GameRuntime.CharacterCreationFinish,
|
||||||
RequestExit: () => { },
|
RequestExit: () => { },
|
||||||
|
SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex,
|
||||||
|
SetShade: late.GameRuntime.CharacterCreationSetShade,
|
||||||
ResolveText: key =>
|
ResolveText: key =>
|
||||||
{
|
{
|
||||||
lock (d.DatLock)
|
lock (d.DatLock)
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,20 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
Invoke((commands, generation) =>
|
Invoke((commands, generation) =>
|
||||||
commands.CharacterCreation.Finish(generation, confirmUnspentCredits));
|
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 ─────────────────
|
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||||
// Same "capture view+commands under one generation" shape as every
|
// Same "capture view+commands under one generation" shape as every
|
||||||
// method above — a displaced session (reconnect mid-click) can never
|
// method above — a displaced session (reconnect mid-click) can never
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,13 @@ internal sealed record LivePresentationResult(
|
||||||
PaperdollFramePresenter? PaperdollPresenter,
|
PaperdollFramePresenter? PaperdollPresenter,
|
||||||
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
|
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
|
||||||
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
|
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,
|
WbFrustum EnvCellFrustum,
|
||||||
EnvCellRenderer? EnvCellRenderer,
|
EnvCellRenderer? EnvCellRenderer,
|
||||||
LandblockPresentationPipeline LandblockPipeline,
|
LandblockPresentationPipeline LandblockPipeline,
|
||||||
|
|
@ -985,6 +992,67 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
new RetailCreatureAppraisalCloneFactory(
|
new RetailCreatureAppraisalCloneFactory(
|
||||||
new LiveCreatureAppraisalEntityLookup(liveEntities)));
|
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);
|
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
|
||||||
|
|
||||||
var envCellFrustum = new WbFrustum();
|
var envCellFrustum = new WbFrustum();
|
||||||
|
|
@ -1291,6 +1359,8 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
paperdollPresenter,
|
paperdollPresenter,
|
||||||
creatureAppraisalLease?.Resource,
|
creatureAppraisalLease?.Resource,
|
||||||
creatureAppraisalPresenter,
|
creatureAppraisalPresenter,
|
||||||
|
chargenPreviewLease?.Resource,
|
||||||
|
chargenPreviewController,
|
||||||
envCellFrustum,
|
envCellFrustum,
|
||||||
envCellLease.Resource,
|
envCellLease.Resource,
|
||||||
landblockPipeline,
|
landblockPipeline,
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,20 @@ internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamer
|
||||||
_camera = new ChargenPreviewCamera(heritageId);
|
_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 void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId);
|
||||||
|
|
||||||
public Vector3 Eye => _camera.Eye;
|
public Vector3 Eye => _camera.Eye;
|
||||||
|
|
|
||||||
298
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal file
298
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal 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).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,18 @@ using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
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>
|
/// <summary>
|
||||||
/// Chargen-specific facade over the shared private creature viewport
|
/// Chargen-specific facade over the shared private creature viewport
|
||||||
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
|
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
|
||||||
|
|
@ -44,6 +56,7 @@ namespace AcDream.App.Rendering;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ChargenPreviewRenderer :
|
internal sealed class ChargenPreviewRenderer :
|
||||||
IUiViewportRenderer,
|
IUiViewportRenderer,
|
||||||
|
IChargenPreviewRenderer,
|
||||||
IDisposable
|
IDisposable
|
||||||
{
|
{
|
||||||
private readonly PrivateEntityViewportRenderer _renderer;
|
private readonly PrivateEntityViewportRenderer _renderer;
|
||||||
|
|
@ -57,9 +70,17 @@ internal sealed class ChargenPreviewRenderer :
|
||||||
SceneLightingUboBinding lightUbo,
|
SceneLightingUboBinding lightUbo,
|
||||||
IEntityTextureLifetime textureLifetime,
|
IEntityTextureLifetime textureLifetime,
|
||||||
IWbMeshAdapter meshAdapter,
|
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(
|
_renderer = new PrivateEntityViewportRenderer(
|
||||||
scope,
|
scope,
|
||||||
device,
|
device,
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,54 @@ internal sealed class ChargenPreviewRotationController
|
||||||
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
|
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
|
||||||
private bool _rotating;
|
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 bool IsRotating => _rotating;
|
||||||
public ChargenRotateDirection Direction => _direction;
|
public ChargenRotateDirection Direction => _direction;
|
||||||
|
|
||||||
/// <summary>Retail's <c>m_fCurHeading</c>, degrees, ctor default 0 —
|
/// <param name="initialHeadingDegrees">Defaults to
|
||||||
/// applied to the preview entity via <c>MoveToMath.SetHeading</c>
|
/// <see cref="RetailDefaultHeadingDegrees"/> (see that constant's doc for
|
||||||
/// (<c>CPhysicsObj::set_heading</c>'s exact port).</summary>
|
/// 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; }
|
public float HeadingDegrees { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,11 @@ public sealed class GameWindow :
|
||||||
_creatureAppraisalViewportRenderer;
|
_creatureAppraisalViewportRenderer;
|
||||||
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
|
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
|
||||||
_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.
|
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||||
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
||||||
|
|
@ -1077,6 +1082,8 @@ public sealed class GameWindow :
|
||||||
|| _paperdollFramePresenter is not null
|
|| _paperdollFramePresenter is not null
|
||||||
|| _creatureAppraisalViewportRenderer is not null
|
|| _creatureAppraisalViewportRenderer is not null
|
||||||
|| _creatureAppraisalFramePresenter is not null
|
|| _creatureAppraisalFramePresenter is not null
|
||||||
|
|| _chargenPreviewRenderer is not null
|
||||||
|
|| _chargenPreviewController is not null
|
||||||
|| _envCellRenderer is not null
|
|| _envCellRenderer is not null
|
||||||
|| _envCellFrustum is not null
|
|| _envCellFrustum is not null
|
||||||
|| _landblockPresentationPipeline is not null
|
|| _landblockPresentationPipeline is not null
|
||||||
|
|
@ -1114,6 +1121,8 @@ public sealed class GameWindow :
|
||||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||||
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
|
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
|
||||||
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
||||||
|
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
|
||||||
|
_chargenPreviewController = result.ChargenPreviewController;
|
||||||
_envCellFrustum = result.EnvCellFrustum;
|
_envCellFrustum = result.EnvCellFrustum;
|
||||||
_envCellRenderer = result.EnvCellRenderer;
|
_envCellRenderer = result.EnvCellRenderer;
|
||||||
_landblockPresentationPipeline = result.LandblockPipeline;
|
_landblockPresentationPipeline = result.LandblockPipeline;
|
||||||
|
|
@ -1759,6 +1768,8 @@ public sealed class GameWindow :
|
||||||
_portalTunnelFallback,
|
_portalTunnelFallback,
|
||||||
_paperdollViewportRenderer,
|
_paperdollViewportRenderer,
|
||||||
_creatureAppraisalViewportRenderer,
|
_creatureAppraisalViewportRenderer,
|
||||||
|
_chargenPreviewRenderer,
|
||||||
|
_chargenPreviewController,
|
||||||
_wbDrawDispatcher,
|
_wbDrawDispatcher,
|
||||||
_envCellRenderer,
|
_envCellRenderer,
|
||||||
_portalDepthMask,
|
_portalDepthMask,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ internal sealed record RenderShutdownRoots(
|
||||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||||
PaperdollViewportRenderer? Paperdoll,
|
PaperdollViewportRenderer? Paperdoll,
|
||||||
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
||||||
|
ChargenPreviewRenderer? ChargenPreview,
|
||||||
|
ChargenPreviewController? ChargenPreviewController,
|
||||||
WbDrawDispatcher? DrawDispatcher,
|
WbDrawDispatcher? DrawDispatcher,
|
||||||
EnvCellRenderer? EnvironmentCells,
|
EnvCellRenderer? EnvironmentCells,
|
||||||
PortalDepthMaskRenderer? PortalDepthMask,
|
PortalDepthMaskRenderer? PortalDepthMask,
|
||||||
|
|
@ -489,6 +491,8 @@ internal static class GameWindowShutdownManifest
|
||||||
Hard(
|
Hard(
|
||||||
"creature appraisal viewport",
|
"creature appraisal viewport",
|
||||||
() => render.CreatureAppraisal?.Dispose()),
|
() => 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("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
||||||
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
||||||
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
||||||
|
|
|
||||||
692
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal file
692
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal 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> -> <c>SetColor(0..8)</c>; the shade
|
||||||
|
/// scrollbar at case <c>0x17</c> -> <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 -> <c>UiButton</c>; their
|
||||||
|
/// nine Type-3 companion "selected" overlays (<c>0x10000318-0x10000320</c>,
|
||||||
|
/// retail's <c>m_tColorWheel[...][0x10][iCurColor*7]->SetVisible</c>
|
||||||
|
/// highlight ring) and the GradCircle (<c>0x1000030e</c>) author Type 3 ->
|
||||||
|
/// <c>UiDatElement</c>; the shade scrollbar (<c>0x10000321</c>) authors
|
||||||
|
/// Type 0xB -> <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -137,30 +137,29 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
||||||
Select(chosen);
|
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)
|
private void Select(uint heritageId)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
RuntimeCommandResult result = _bindings.SelectHeritage(heritageId);
|
_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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ public sealed record CharacterCreationRuntimeBindings(
|
||||||
Func<int, RuntimeCommandResult> SelectStartArea,
|
Func<int, RuntimeCommandResult> SelectStartArea,
|
||||||
Func<bool, RuntimeCommandResult> Finish,
|
Func<bool, RuntimeCommandResult> Finish,
|
||||||
Action RequestExit,
|
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
|
/// <summary>DAT string lookup (table <c>0x23000002</c>, the SAME table
|
||||||
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
|
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
|
||||||
/// through) — used by the Heritage page's composed description text.
|
/// 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 CharacterCreationProfessionPage _professionPage;
|
||||||
private readonly CharacterCreationSkillsPage _skillsPage;
|
private readonly CharacterCreationSkillsPage _skillsPage;
|
||||||
private readonly CharacterCreationTownPage _townPage;
|
private readonly CharacterCreationTownPage _townPage;
|
||||||
|
private readonly CharacterCreationAppearancePage _appearancePage;
|
||||||
|
|
||||||
private Vector2 _authoredCanvas;
|
private Vector2 _authoredCanvas;
|
||||||
private RuntimeGenerationToken _lastGeneration;
|
private RuntimeGenerationToken _lastGeneration;
|
||||||
|
|
@ -218,6 +224,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
|
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
|
||||||
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
||||||
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
||||||
|
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
||||||
|
|
||||||
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
||||||
_back.OnClick = OnBack;
|
_back.OnClick = OnBack;
|
||||||
|
|
@ -247,6 +254,30 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
|
|
||||||
internal UiElement Root => _layout.Root;
|
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(
|
internal static CharacterCreationUiController? CreateDetached(
|
||||||
UiRoot host,
|
UiRoot host,
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
|
|
@ -370,6 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_professionPage.Refresh(view, snapshot);
|
_professionPage.Refresh(view, snapshot);
|
||||||
_skillsPage.Refresh(view, snapshot);
|
_skillsPage.Refresh(view, snapshot);
|
||||||
_townPage.Refresh(view, snapshot);
|
_townPage.Refresh(view, snapshot);
|
||||||
|
_appearancePage.Refresh(view, snapshot);
|
||||||
_lastGeneration = snapshot.Generation;
|
_lastGeneration = snapshot.Generation;
|
||||||
_lastRevision = snapshot.Revision;
|
_lastRevision = snapshot.Revision;
|
||||||
}
|
}
|
||||||
|
|
@ -437,6 +469,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_professionPage.Dispose();
|
_professionPage.Dispose();
|
||||||
_skillsPage.Dispose();
|
_skillsPage.Dispose();
|
||||||
_townPage.Dispose();
|
_townPage.Dispose();
|
||||||
|
_appearancePage.Dispose();
|
||||||
_host.RemoveChild(Root);
|
_host.RemoveChild(Root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -622,6 +622,31 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
internal CharacterCreationUiController? CharacterCreationController =>
|
internal CharacterCreationUiController? CharacterCreationController =>
|
||||||
_characterCreationMount?.Controller;
|
_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)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(bindings);
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Content.CharGen;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC slice CC6b-MOUNT — installed-DAT gate for
|
||||||
|
/// <see cref="ChargenPreviewController"/>'s rebuild/render logic, using fake
|
||||||
|
/// <see cref="IChargenPreviewRenderer"/>/<see cref="IChargenPreviewFrameView"/>
|
||||||
|
/// implementations (no live GPU device needed — mirrors
|
||||||
|
/// <c>PaperdollFramePresenterTests</c>'s recording-fake pattern) against a
|
||||||
|
/// REAL dat-backed <see cref="ChargenOptions"/>/<see cref="ChargenAppearanceCatalog"/>
|
||||||
|
/// so <c>ChargenAppearanceFactory.TryCompose</c> and
|
||||||
|
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargenPreviewControllerTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public ChargenPreviewControllerTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint AluvianId = 1u;
|
||||||
|
private const uint GearknightId = 6u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rebuild_SameSelectionTwice_IsANoOpSecondTime()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView();
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, new ChargenPreviewCamera(), view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
|
||||||
|
ChargenAppearanceSelection selection = DefaultSelection(options, AluvianId, 1);
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, selection));
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, selection));
|
||||||
|
|
||||||
|
Assert.Equal(1, renderer.SetPreviewCallCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView();
|
||||||
|
var camera = new ChargenPreviewCamera();
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, camera, view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
|
||||||
|
Assert.True(controller.Rebuild(
|
||||||
|
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
|
||||||
|
// Poke the SAME camera instance the controller shares (this is
|
||||||
|
// exactly the injection seam ChargenPreviewController's own doc
|
||||||
|
// comment describes) — simulates the camera having drifted away
|
||||||
|
// from the heritage default (e.g. mid zoom-out tween).
|
||||||
|
camera.Eye = new Vector3(0f, -99f, 99f);
|
||||||
|
|
||||||
|
if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight)
|
||||||
|
|| gearknight!.GendersByKey.Count == 0)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: installed dat has no Gearknight gender to switch to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int gearknightGender = gearknight.GendersByKey.Keys.First();
|
||||||
|
Assert.True(controller.Rebuild(
|
||||||
|
options, GearknightId, gearknightGender,
|
||||||
|
DefaultSelection(options, GearknightId, gearknightGender)));
|
||||||
|
|
||||||
|
Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(GearknightId), controller.CameraEye);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decomp-cited (<c>gmCGAppearancePage::Update</c>'s two confirmed direct
|
||||||
|
/// call sites — <c>InitializePage</c> and the gender-button handlers —
|
||||||
|
/// vs the narrower <c>SetSelection</c>/<c>SetColor</c>/<c>SetShade</c>
|
||||||
|
/// every spin/color/shade change goes through instead): a rebuild that
|
||||||
|
/// changes ONLY the appearance selection (same heritage, same gender)
|
||||||
|
/// must NOT snap the camera back to the heritage default.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView();
|
||||||
|
var camera = new ChargenPreviewCamera();
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, camera, view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
|
||||||
|
ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1);
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, first));
|
||||||
|
var pokedEye = new Vector3(0f, -99f, 99f);
|
||||||
|
camera.Eye = pokedEye;
|
||||||
|
|
||||||
|
ChargenAppearanceSelection second = first with { SkinShade = 0.9 };
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, second));
|
||||||
|
|
||||||
|
Assert.Equal(pokedEye, controller.CameraEye);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView();
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, new ChargenPreviewCamera(), view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
|
||||||
|
ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1);
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, first));
|
||||||
|
controller.ZoomIn();
|
||||||
|
Assert.True(controller.IsZoomedIn);
|
||||||
|
|
||||||
|
// A DIFFERENT selection, same heritage/gender — retail's
|
||||||
|
// gmCGAppearancePage::Update only resets the camera POSITION on
|
||||||
|
// heritage/gender change; m_bZoomedIn is untouched by spin/color/
|
||||||
|
// shade edits.
|
||||||
|
ChargenAppearanceSelection second = first with { SkinShade = 0.9 };
|
||||||
|
Assert.True(controller.Rebuild(options, AluvianId, 1, second));
|
||||||
|
|
||||||
|
Assert.True(controller.IsZoomedIn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView();
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, new ChargenPreviewCamera(), view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
|
||||||
|
Assert.True(controller.Rebuild(
|
||||||
|
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
|
||||||
|
Assert.NotNull(renderer.LastEntity);
|
||||||
|
|
||||||
|
controller.Render();
|
||||||
|
|
||||||
|
Quaternion expected = MoveToMath.SetHeading(
|
||||||
|
Quaternion.Identity, ChargenPreviewRotationController.RetailDefaultHeadingDegrees);
|
||||||
|
Quaternion actual = renderer.LastEntity!.Rotation;
|
||||||
|
Assert.Equal(expected.X, actual.X, 4);
|
||||||
|
Assert.Equal(expected.Y, actual.Y, 4);
|
||||||
|
Assert.Equal(expected.Z, actual.Z, 4);
|
||||||
|
Assert.Equal(expected.W, actual.W, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication()
|
||||||
|
{
|
||||||
|
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
|
||||||
|
return;
|
||||||
|
using (dats)
|
||||||
|
using (adapter)
|
||||||
|
{
|
||||||
|
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
|
||||||
|
var renderer = new FakeChargenRenderer();
|
||||||
|
var view = new FakeChargenView { Visible = false };
|
||||||
|
var controller = new ChargenPreviewController(
|
||||||
|
renderer, new ChargenPreviewCamera(), view,
|
||||||
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
||||||
|
Assert.True(controller.Rebuild(
|
||||||
|
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
|
||||||
|
|
||||||
|
controller.Render();
|
||||||
|
|
||||||
|
Assert.Equal(0, renderer.RenderCallCount);
|
||||||
|
Assert.Null(view.LastTextureHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargenAppearanceSelection DefaultSelection(
|
||||||
|
ChargenOptions options, uint heritageId, int genderKey)
|
||||||
|
{
|
||||||
|
Assert.True(options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage));
|
||||||
|
Assert.True(heritage!.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender));
|
||||||
|
return ChargenAppearanceSelection.Default with
|
||||||
|
{
|
||||||
|
HairStyle = gender!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
|
||||||
|
SkinShade = 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) =>
|
||||||
|
(ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats));
|
||||||
|
|
||||||
|
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
||||||
|
{
|
||||||
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dats unavailable");
|
||||||
|
dats = null;
|
||||||
|
adapter = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
adapter = new DatCollectionAdapter(dats);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeChargenRenderer : IChargenPreviewRenderer
|
||||||
|
{
|
||||||
|
public WorldEntity? LastEntity { get; private set; }
|
||||||
|
public int SetPreviewCallCount { get; private set; }
|
||||||
|
public int RenderCallCount { get; private set; }
|
||||||
|
|
||||||
|
public void SetPreview(WorldEntity? entity)
|
||||||
|
{
|
||||||
|
LastEntity = entity;
|
||||||
|
SetPreviewCallCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint Render(int width, int height)
|
||||||
|
{
|
||||||
|
RenderCallCount++;
|
||||||
|
return 42u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeChargenView : IChargenPreviewFrameView
|
||||||
|
{
|
||||||
|
public bool Visible { get; set; } = true;
|
||||||
|
public int Width { get; set; } = 128;
|
||||||
|
public int Height { get; set; } = 128;
|
||||||
|
public uint? LastTextureHandle { get; private set; }
|
||||||
|
|
||||||
|
public bool TryGetVisibleSize(out int width, out int height)
|
||||||
|
{
|
||||||
|
width = Width;
|
||||||
|
height = Height;
|
||||||
|
return Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTextureHandle(uint textureHandle) => LastTextureHandle = textureHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,32 @@ namespace AcDream.App.Tests.Rendering;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChargenPreviewRotationControllerTests
|
public sealed class ChargenPreviewRotationControllerTests
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// CC6b-MOUNT: retail's OPERATIVE starting heading is 180, not the ctor's
|
||||||
|
/// raw 0 — <c>gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC</c> sets
|
||||||
|
/// <c>m_fCurHeading = 0f</c>, but <c>InitializePage @0x0047FDD0</c> always
|
||||||
|
/// runs immediately afterward (before the page is ever visible) and
|
||||||
|
/// overrides it to <c>180f</c> at <c>0x00480235</c>, pushed via
|
||||||
|
/// <c>SetPlayerHeading</c> at <c>0x0048023F</c>. No player-visible chargen
|
||||||
|
/// Appearance frame is ever rendered at 0°. This is the seam a real mount
|
||||||
|
/// site experiences (the parameterless constructor), pinned here so a
|
||||||
|
/// future consumer can't silently regress to facing the character away
|
||||||
|
/// from the camera. See <see cref="ChargenPreviewRotationController.RetailDefaultHeadingDegrees"/>
|
||||||
|
/// for the full citation, including the cross-confirming
|
||||||
|
/// <c>gmCGSummaryPage</c>/<c>gmBarberUI</c> sibling call sites.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DefaultConstructor_StartsAtRetailsOperative180DegreeHeading()
|
||||||
|
{
|
||||||
|
var controller = new ChargenPreviewRotationController();
|
||||||
|
|
||||||
|
Assert.Equal(180f, controller.HeadingDegrees);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Toggle_StartsRotatingInTheGivenDirection()
|
public void Toggle_StartsRotatingInTheGivenDirection()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
|
|
||||||
Assert.True(controller.IsRotating);
|
Assert.True(controller.IsRotating);
|
||||||
|
|
@ -24,7 +46,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Toggle_SameDirectionWhileRotating_Stops()
|
public void Toggle_SameDirectionWhileRotating_Stops()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
|
|
||||||
|
|
@ -34,7 +56,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating()
|
public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
||||||
|
|
||||||
|
|
@ -45,7 +67,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_WhileNotRotating_IsANoOp()
|
public void Tick_WhileNotRotating_IsANoOp()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Tick(100.0);
|
controller.Tick(100.0);
|
||||||
|
|
||||||
Assert.Equal(0f, controller.HeadingDegrees);
|
Assert.Equal(0f, controller.HeadingDegrees);
|
||||||
|
|
@ -57,7 +79,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
// Rotate() invalidates m_dLastRotateTime so the very first DoRotation
|
// Rotate() invalidates m_dLastRotateTime so the very first DoRotation
|
||||||
// tick resets it to "now" rather than computing a huge jump from a
|
// tick resets it to "now" rather than computing a huge jump from a
|
||||||
// stale/never-set timestamp.
|
// stale/never-set timestamp.
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Tick(1000.0);
|
controller.Tick(1000.0);
|
||||||
|
|
||||||
|
|
@ -71,8 +93,10 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
// Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid
|
// Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid
|
||||||
// guard, same as retail's own sentinel check would if Timer::cur_time
|
// guard, same as retail's own sentinel check would if Timer::cur_time
|
||||||
// could ever read exactly zero — never in practice, so tests avoid
|
// could ever read exactly zero — never in practice, so tests avoid
|
||||||
// it too).
|
// it too). Explicit 0f baseline keeps the relative-delta assertion
|
||||||
var controller = new ChargenPreviewRotationController();
|
// below simple; the retail-default seam has its own dedicated test
|
||||||
|
// above.
|
||||||
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
|
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
|
||||||
controller.Tick(11.5); // half a revolution at 3 s/rev.
|
controller.Tick(11.5); // half a revolution at 3 s/rev.
|
||||||
|
|
@ -83,7 +107,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive()
|
public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
||||||
controller.Tick(10.0);
|
controller.Tick(10.0);
|
||||||
controller.Tick(11.5); // would go to -180, wraps to +180.
|
controller.Tick(11.5); // would go to -180, wraps to +180.
|
||||||
|
|
@ -94,7 +118,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_AccumulatesAcrossMultipleTicks()
|
public void Tick_AccumulatesAcrossMultipleTicks()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Tick(10.0);
|
controller.Tick(10.0);
|
||||||
controller.Tick(10.5); // +60 deg.
|
controller.Tick(10.5); // +60 deg.
|
||||||
|
|
@ -113,7 +137,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360()
|
public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||||
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
|
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
|
||||||
controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60.
|
controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60.
|
||||||
|
|
@ -124,7 +148,7 @@ public sealed class ChargenPreviewRotationControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToOrientation_AtZeroHeading_IsIdentity()
|
public void ToOrientation_AtZeroHeading_IsIdentity()
|
||||||
{
|
{
|
||||||
var controller = new ChargenPreviewRotationController();
|
var controller = new ChargenPreviewRotationController(0f);
|
||||||
Quaternion orientation = controller.ToOrientation();
|
Quaternion orientation = controller.ToOrientation();
|
||||||
|
|
||||||
Assert.Equal(Quaternion.Identity.X, orientation.X, 4);
|
Assert.Equal(Quaternion.Identity.X, orientation.X, 4);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
|
@ -314,6 +315,131 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC slice CC6b-MOUNT — the Appearance page's full authored
|
||||||
|
/// widget catalog. Pins the campaign plan's risk item 4 finding (the
|
||||||
|
/// color-wheel/gradient family resolves through EXISTING
|
||||||
|
/// <c>DatWidgetFactory</c> mappings; no new widget type was needed —
|
||||||
|
/// see <see cref="CharacterCreationAppearancePage"/>'s own class doc)
|
||||||
|
/// against the real installed DAT: gender/Face/Clothes buttons, all
|
||||||
|
/// nine spins, all nine color swatches, the shade scrollbar, and the
|
||||||
|
/// viewport.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.FemaleButtonId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.MaleButtonId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.FaceButtonId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ClothesButtonId);
|
||||||
|
Assert.IsAssignableFrom<UiElement>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.FaceChoicesId));
|
||||||
|
Assert.IsAssignableFrom<UiElement>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ClothesChoicesId));
|
||||||
|
|
||||||
|
foreach (uint spinId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.HairSpinId,
|
||||||
|
CharacterCreationAppearancePage.EyesSpinId,
|
||||||
|
CharacterCreationAppearancePage.NoseSpinId,
|
||||||
|
CharacterCreationAppearancePage.MouthSpinId,
|
||||||
|
CharacterCreationAppearancePage.SkinSpinId,
|
||||||
|
CharacterCreationAppearancePage.HeadgearSpinId,
|
||||||
|
CharacterCreationAppearancePage.ShirtSpinId,
|
||||||
|
CharacterCreationAppearancePage.TrousersSpinId,
|
||||||
|
CharacterCreationAppearancePage.FootwearSpinId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
AssertButton(appearanceRoot, spinId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every color-wheel-family id resolves through EXISTING
|
||||||
|
// DatWidgetFactory mappings (Button=1, Scrollbar=0xB, the generic
|
||||||
|
// Type-3 fallback) — the risk-item-4 scouting result, pinned.
|
||||||
|
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
||||||
|
AssertButton(appearanceRoot, swatchId);
|
||||||
|
Assert.IsType<UiScrollbar>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId));
|
||||||
|
Assert.IsType<UiDatElement>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId));
|
||||||
|
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateClockwiseId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateCounterClockwiseId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomInId);
|
||||||
|
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomOutId);
|
||||||
|
|
||||||
|
Assert.IsType<UiViewport>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ViewportId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live-DAT-measured arrow geometry the page's spin OnClickAt zones are
|
||||||
|
/// built from — every one of the nine spins is uniformly 200px wide
|
||||||
|
/// with the two locally-reused arrow child ids
|
||||||
|
/// (<c>0x1000030a</c> decrement / <c>0x1000030b</c> increment) at the
|
||||||
|
/// SAME local positions. If a future DAT revision changes this shared
|
||||||
|
/// template's geometry, this test (not a silent behavior change) is
|
||||||
|
/// where it shows up.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
|
||||||
|
LayoutImporter.ImportInfos(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId));
|
||||||
|
ElementInfo? appearanceInfo = FindInfo(
|
||||||
|
rootInfo, CharacterCreationUiController.AppearancePageElementId);
|
||||||
|
Assert.NotNull(appearanceInfo);
|
||||||
|
|
||||||
|
foreach (uint spinId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.HairSpinId,
|
||||||
|
CharacterCreationAppearancePage.EyesSpinId,
|
||||||
|
CharacterCreationAppearancePage.NoseSpinId,
|
||||||
|
CharacterCreationAppearancePage.MouthSpinId,
|
||||||
|
CharacterCreationAppearancePage.SkinSpinId,
|
||||||
|
CharacterCreationAppearancePage.HeadgearSpinId,
|
||||||
|
CharacterCreationAppearancePage.ShirtSpinId,
|
||||||
|
CharacterCreationAppearancePage.TrousersSpinId,
|
||||||
|
CharacterCreationAppearancePage.FootwearSpinId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ElementInfo? spin = FindInfo(appearanceInfo!, spinId);
|
||||||
|
Assert.NotNull(spin);
|
||||||
|
Assert.Equal(200f, spin!.Width);
|
||||||
|
|
||||||
|
ElementInfo? decrement = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Au);
|
||||||
|
ElementInfo? increment = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Bu);
|
||||||
|
Assert.NotNull(decrement);
|
||||||
|
Assert.NotNull(increment);
|
||||||
|
Assert.Equal(80f, decrement!.X);
|
||||||
|
Assert.Equal(127f, increment!.X);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||||
|
{
|
||||||
|
if (node.Id == id) return node;
|
||||||
|
foreach (ElementInfo child in node.Children)
|
||||||
|
{
|
||||||
|
ElementInfo? found = FindInfo(child, id);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host)
|
private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host)
|
||||||
{
|
{
|
||||||
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
|
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
|
||||||
|
|
@ -331,6 +457,9 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
private static void AssertButton(ImportedLayout layout, uint elementId) =>
|
private static void AssertButton(ImportedLayout layout, uint elementId) =>
|
||||||
Assert.IsType<UiButton>(layout.FindElement(elementId));
|
Assert.IsType<UiButton>(layout.FindElement(elementId));
|
||||||
|
|
||||||
|
private static void AssertButton(UiElement root, uint elementId) =>
|
||||||
|
Assert.IsType<UiButton>(UiElement.FindDescendant(root, elementId));
|
||||||
|
|
||||||
private static ImportedLayout BuildSelected(
|
private static ImportedLayout BuildSelected(
|
||||||
IDatReaderWriter dats,
|
IDatReaderWriter dats,
|
||||||
uint layoutDid,
|
uint layoutDid,
|
||||||
|
|
|
||||||
|
|
@ -167,10 +167,12 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>gmCGHeritagePage::ListenToElementMessage @ 0x00483860's
|
/// <summary>gmCGHeritagePage::ListenToElementMessage @ 0x00483860's
|
||||||
/// per-button SetHeritageGroup literal, plus CC4's interim
|
/// per-button SetHeritageGroup literal. AD-101 RETIRED at CC6b-MOUNT:
|
||||||
/// auto-gender-select seam (register AD-100).</summary>
|
/// a heritage click no longer auto-selects a gender — the Appearance
|
||||||
|
/// page's real gender buttons are the only gender-selection path now
|
||||||
|
/// (see <see cref="AppearanceGenderButton_SelectsGender"/>).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender()
|
public void HeritageButton_SelectsHeritage_WithNoGenderSideEffect()
|
||||||
{
|
{
|
||||||
using var environment = new EnvironmentHarness();
|
using var environment = new EnvironmentHarness();
|
||||||
environment.Controller.Open();
|
environment.Controller.Open();
|
||||||
|
|
@ -178,7 +180,7 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
environment.Button(0x100003BFu).OnClick!(); // Aluvian
|
environment.Button(0x100003BFu).OnClick!(); // Aluvian
|
||||||
|
|
||||||
Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage);
|
Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage);
|
||||||
Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender);
|
Assert.Equal(0u, environment.Runtime.LastSelectedGender);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi
|
/// <summary>gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi
|
||||||
|
|
@ -491,6 +493,251 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
Assert.Null(environment.Host.FixedCanvasSize);
|
Assert.Null(environment.Host.FixedCanvasSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campaign CC slice CC6b-MOUNT: Appearance page ───────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceGenderButton_SelectsGender()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||||
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
|
||||||
|
Assert.Equal(1u, environment.Runtime.LastSelectedGender);
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!();
|
||||||
|
Assert.Equal(2u, environment.Runtime.LastSelectedGender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spin arrow geometry (live-DAT-measured, see
|
||||||
|
/// <see cref="CharacterCreationAppearancePage"/>'s own class doc):
|
||||||
|
/// x=[80,127) is the decrement child, x=[127,174) is the increment
|
||||||
|
/// child.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
|
||||||
|
|
||||||
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
||||||
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
// Non-headgear spins have no decomp-observable Unset-cycling case
|
||||||
|
// (retail always has a real 0-based index by the time the user can
|
||||||
|
// click — CycleIndex's own citation) — a first click from Unset in
|
||||||
|
// EITHER direction just starts cycling at style 0, not a ring.
|
||||||
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10);
|
||||||
|
|
||||||
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
||||||
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CharGenState::SetHeadgearStyle's decomp-derived
|
||||||
|
/// (count+1)-position ring: incrementing from Unset lands on style 0;
|
||||||
|
/// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY
|
||||||
|
/// spin with this ring — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
|
||||||
|
/// own citation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment
|
||||||
|
Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot);
|
||||||
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement
|
||||||
|
Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure wrap-semantics unit tests for
|
||||||
|
/// <see cref="CharacterCreationAppearancePage.CycleIndex"/> — the
|
||||||
|
/// decomp-derived arithmetic every spin's OnClickAt zone drives.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0u, +1, 3, false, 1u)]
|
||||||
|
[InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end.
|
||||||
|
[InlineData(1u, -1, 3, false, 0u)]
|
||||||
|
[InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start.
|
||||||
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)]
|
||||||
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)]
|
||||||
|
[InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset.
|
||||||
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0.
|
||||||
|
[InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset.
|
||||||
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last.
|
||||||
|
public void CycleIndex_MatchesRetailsDecompDerivedWrap(
|
||||||
|
uint current, int delta, int count, bool allowUnset, uint expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
expected,
|
||||||
|
CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Skin has no style index — retail disables its arrow
|
||||||
|
/// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin
|
||||||
|
/// spin only selects it as the current part.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10);
|
||||||
|
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10);
|
||||||
|
|
||||||
|
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the
|
||||||
|
/// Clothes sub-tab and the Nose/Mouth spins all hide.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
environment.Runtime.SelectHeritageDirect(OlthoiId);
|
||||||
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
||||||
|
|
||||||
|
Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible);
|
||||||
|
Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible);
|
||||||
|
Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fixture's Hair color list has 3 entries (indices 0-2) — a
|
||||||
|
/// swatch beyond that never reaches SetAppearanceIndex, matching
|
||||||
|
/// retail's own <c>iNumColors > N</c> gate.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
// Part defaults to Hair on construction — no extra click needed.
|
||||||
|
environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!();
|
||||||
|
|
||||||
|
Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot);
|
||||||
|
Assert.Equal(1u, environment.Runtime.LastAppearanceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSwatch_BeyondColorCount_IsANoOp()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3.
|
||||||
|
|
||||||
|
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
|
||||||
|
environment.ShadeScroll().ScalarChanged!(0.75f);
|
||||||
|
|
||||||
|
Assert.Equal(ChargenShadeSlot.Hair, environment.Runtime.LastShadeSlot);
|
||||||
|
Assert.Equal(0.75, environment.Runtime.LastShadeValue, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nose/Mouth/Skin all route the shade scroll to SKIN shade —
|
||||||
|
/// SetShade's cases 2/3/4 share one body in the decompiled switch.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceShadeScroll_ForNoseOrMouth_RoutesToSkinShade()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
// Select Nose as the current part: click its body (outside the
|
||||||
|
// arrow zones), which never changes the style index.
|
||||||
|
environment.Button(CharacterCreationAppearancePage.NoseSpinId).OnClickAt!(10, 10);
|
||||||
|
|
||||||
|
environment.ShadeScroll().ScalarChanged!(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(ChargenShadeSlot.Skin, environment.Runtime.LastShadeSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceZoomAndRotateButtons_DelegateToThePreviewControl()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
var preview = new FakeChargenPreviewControl();
|
||||||
|
environment.Controller.AppearancePreviewControl = preview;
|
||||||
|
|
||||||
|
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.ZoomOutId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.RotateCounterClockwiseId).OnClick!();
|
||||||
|
|
||||||
|
Assert.Equal(1, preview.ZoomInCalls);
|
||||||
|
Assert.Equal(1, preview.ZoomOutCalls);
|
||||||
|
Assert.Equal(1, preview.RotateClockwiseCalls);
|
||||||
|
Assert.Equal(1, preview.RotateCounterClockwiseCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceZoomButtons_WithNoPreviewControlAssignedYet_AreHarmlessNoOps()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
|
||||||
|
// The page mounts before the graphics backend exists — every zoom/
|
||||||
|
// rotate click before LivePresentationComposition assigns a real
|
||||||
|
// control must be a silent no-op, not a NullReferenceException.
|
||||||
|
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SelectAluvianMale(EnvironmentHarness environment)
|
||||||
|
{
|
||||||
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||||
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
||||||
|
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl
|
||||||
|
{
|
||||||
|
public int ZoomInCalls { get; private set; }
|
||||||
|
public int ZoomOutCalls { get; private set; }
|
||||||
|
public int RotateClockwiseCalls { get; private set; }
|
||||||
|
public int RotateCounterClockwiseCalls { get; private set; }
|
||||||
|
|
||||||
|
public bool Rebuild(
|
||||||
|
ChargenOptions options,
|
||||||
|
uint heritageId,
|
||||||
|
int genderKey,
|
||||||
|
ChargenAppearanceSelection selection) => true;
|
||||||
|
|
||||||
|
public void ZoomIn() => ZoomInCalls++;
|
||||||
|
public void ZoomOut() => ZoomOutCalls++;
|
||||||
|
public void RotateClockwise() => RotateClockwiseCalls++;
|
||||||
|
public void RotateCounterClockwise() => RotateCounterClockwiseCalls++;
|
||||||
|
}
|
||||||
|
|
||||||
private static void BumpRevisionAndTick(EnvironmentHarness environment)
|
private static void BumpRevisionAndTick(EnvironmentHarness environment)
|
||||||
{
|
{
|
||||||
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
||||||
|
|
@ -552,6 +799,9 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
public UiTemplateListBox SkillsList() =>
|
public UiTemplateListBox SkillsList() =>
|
||||||
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
|
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
|
||||||
|
|
||||||
|
public UiScrollbar ShadeScroll() =>
|
||||||
|
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
|
||||||
|
|
||||||
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
|
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
|
||||||
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
|
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
|
||||||
/// button ids off the layout the factory's <c>createLayout</c>
|
/// button ids off the layout the factory's <c>createLayout</c>
|
||||||
|
|
@ -599,6 +849,8 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
SelectStartArea,
|
SelectStartArea,
|
||||||
_ => Result(RuntimeCommandStatus.Accepted),
|
_ => Result(RuntimeCommandStatus.Accepted),
|
||||||
() => RequestExitCalls++,
|
() => RequestExitCalls++,
|
||||||
|
SetAppearanceIndex: SetAppearanceIndex,
|
||||||
|
SetShade: SetShade,
|
||||||
ResolveText: _ => null,
|
ResolveText: _ => null,
|
||||||
OpenOnStart: false);
|
OpenOnStart: false);
|
||||||
}
|
}
|
||||||
|
|
@ -613,6 +865,11 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
public ChargenAttributeId LastAttributeSet { get; private set; }
|
public ChargenAttributeId LastAttributeSet { get; private set; }
|
||||||
public int LastAttributeValue { get; private set; }
|
public int LastAttributeValue { get; private set; }
|
||||||
public int LastSelectedStartArea { get; private set; } = -1;
|
public int LastSelectedStartArea { get; private set; } = -1;
|
||||||
|
public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; }
|
||||||
|
public uint LastAppearanceIndex { get; private set; }
|
||||||
|
public int AppearanceIndexCallCount { get; private set; }
|
||||||
|
public ChargenShadeSlot? LastShadeSlot { get; private set; }
|
||||||
|
public double LastShadeValue { get; private set; }
|
||||||
|
|
||||||
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
|
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
|
||||||
|
|
||||||
|
|
@ -662,6 +919,44 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
return Result(RuntimeCommandStatus.Accepted);
|
return Result(RuntimeCommandStatus.Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private RuntimeCommandResult SetAppearanceIndex(ChargenAppearanceSlot slot, uint index)
|
||||||
|
{
|
||||||
|
LastAppearanceSlot = slot;
|
||||||
|
LastAppearanceIndex = index;
|
||||||
|
AppearanceIndexCallCount++;
|
||||||
|
View.Snapshot = View.Snapshot with { Appearance = WithAppearanceIndex(View.Snapshot.Appearance, slot, index) };
|
||||||
|
return Result(RuntimeCommandStatus.Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeCommandResult SetShade(ChargenShadeSlot slot, double value)
|
||||||
|
{
|
||||||
|
LastShadeSlot = slot;
|
||||||
|
LastShadeValue = value;
|
||||||
|
return Result(RuntimeCommandStatus.Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeCharacterCreationAppearance WithAppearanceIndex(
|
||||||
|
RuntimeCharacterCreationAppearance a,
|
||||||
|
ChargenAppearanceSlot slot,
|
||||||
|
uint index) => slot switch
|
||||||
|
{
|
||||||
|
ChargenAppearanceSlot.EyesStrip => a with { EyesStrip = index },
|
||||||
|
ChargenAppearanceSlot.NoseStrip => a with { NoseStrip = index },
|
||||||
|
ChargenAppearanceSlot.MouthStrip => a with { MouthStrip = index },
|
||||||
|
ChargenAppearanceSlot.HairStyle => a with { HairStyle = index },
|
||||||
|
ChargenAppearanceSlot.HairColor => a with { HairColor = index },
|
||||||
|
ChargenAppearanceSlot.EyeColor => a with { EyeColor = index },
|
||||||
|
ChargenAppearanceSlot.HeadgearStyle => a with { HeadgearStyle = index },
|
||||||
|
ChargenAppearanceSlot.HeadgearColor => a with { HeadgearColor = index },
|
||||||
|
ChargenAppearanceSlot.ShirtStyle => a with { ShirtStyle = index },
|
||||||
|
ChargenAppearanceSlot.ShirtColor => a with { ShirtColor = index },
|
||||||
|
ChargenAppearanceSlot.TrousersStyle => a with { TrousersStyle = index },
|
||||||
|
ChargenAppearanceSlot.TrousersColor => a with { TrousersColor = index },
|
||||||
|
ChargenAppearanceSlot.FootwearStyle => a with { FootwearStyle = index },
|
||||||
|
ChargenAppearanceSlot.FootwearColor => a with { FootwearColor = index },
|
||||||
|
_ => a,
|
||||||
|
};
|
||||||
|
|
||||||
private static RuntimeCommandResult Result(RuntimeCommandStatus status) =>
|
private static RuntimeCommandResult Result(RuntimeCommandStatus status) =>
|
||||||
new(status, Generation);
|
new(status, Generation);
|
||||||
|
|
||||||
|
|
@ -680,17 +975,34 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
MotionTableId: 0u,
|
MotionTableId: 0u,
|
||||||
CombatTableId: 0u,
|
CombatTableId: 0u,
|
||||||
BaseObjDesc: ChargenObjDesc.Empty,
|
BaseObjDesc: ChargenObjDesc.Empty,
|
||||||
HairColors: [],
|
// Campaign CC slice CC6b-MOUNT: non-empty appearance lists
|
||||||
HairStyles: [],
|
// so the Appearance page's spin-cycle/wrap and swatch/shade
|
||||||
EyeColors: [],
|
// dispatch tests have real option counts to exercise (the
|
||||||
EyeStrips: [],
|
// CC4 fixture left these empty since no page read them yet).
|
||||||
NoseStrips: [],
|
HairColors: [0x1000u, 0x1001u, 0x1002u],
|
||||||
MouthStrips: [],
|
HairStyles:
|
||||||
Headgears: [],
|
[
|
||||||
Shirts: [],
|
new ChargenHairStyle(IconId: 1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
|
||||||
Pants: [],
|
new ChargenHairStyle(IconId: 2u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
|
||||||
Footwear: [],
|
new ChargenHairStyle(IconId: 3u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
|
||||||
ClothingColors: []);
|
],
|
||||||
|
EyeColors: [0x2000u, 0x2001u],
|
||||||
|
EyeStrips:
|
||||||
|
[
|
||||||
|
new ChargenEyeStrip(IconId: 1u, BaldIconId: 1u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty),
|
||||||
|
new ChargenEyeStrip(IconId: 2u, BaldIconId: 2u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty),
|
||||||
|
],
|
||||||
|
NoseStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)],
|
||||||
|
MouthStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)],
|
||||||
|
Headgears:
|
||||||
|
[
|
||||||
|
new ChargenGearOption("Cloth Cap", ClothingTableId: 1u, WeenieDefaultId: 1u),
|
||||||
|
new ChargenGearOption("Leather Cap", ClothingTableId: 2u, WeenieDefaultId: 2u),
|
||||||
|
],
|
||||||
|
Shirts: [new ChargenGearOption("Tunic", ClothingTableId: 3u, WeenieDefaultId: 3u)],
|
||||||
|
Pants: [new ChargenGearOption("Trousers", ClothingTableId: 4u, WeenieDefaultId: 4u)],
|
||||||
|
Footwear: [new ChargenGearOption("Boots", ClothingTableId: 5u, WeenieDefaultId: 5u)],
|
||||||
|
ClothingColors: [0x3000u, 0x3001u, 0x3002u]);
|
||||||
|
|
||||||
var templates = new List<ChargenTemplate>
|
var templates = new List<ChargenTemplate>
|
||||||
{
|
{
|
||||||
|
|
@ -841,7 +1153,7 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
root.Children.Add(BuildHeritagePage());
|
root.Children.Add(BuildHeritagePage());
|
||||||
root.Children.Add(BuildProfessionPage());
|
root.Children.Add(BuildProfessionPage());
|
||||||
root.Children.Add(BuildSkillsPage());
|
root.Children.Add(BuildSkillsPage());
|
||||||
root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId));
|
root.Children.Add(BuildAppearancePage());
|
||||||
root.Children.Add(BuildTownPage());
|
root.Children.Add(BuildTownPage());
|
||||||
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
|
||||||
|
|
||||||
|
|
@ -947,6 +1259,82 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page fixture.
|
||||||
|
/// Spin geometry (each 200px wide, arrow children at the live-DAT-
|
||||||
|
/// measured x=80/127 — see <see cref="CharacterCreationAppearancePage"/>'s
|
||||||
|
/// own doc comment) mirrors the real installed layout exactly so the
|
||||||
|
/// same OnClickAt zone math this page uses in production is what these
|
||||||
|
/// tests exercise.</summary>
|
||||||
|
private static ElementInfo BuildAppearancePage()
|
||||||
|
{
|
||||||
|
var page = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterCreationUiController.AppearancePageElementId,
|
||||||
|
Type = 3u,
|
||||||
|
Width = 800f,
|
||||||
|
Height = 500f,
|
||||||
|
};
|
||||||
|
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FemaleButtonId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.MaleButtonId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FaceButtonId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ClothesButtonId));
|
||||||
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.FaceChoicesId));
|
||||||
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.ClothesChoicesId));
|
||||||
|
|
||||||
|
foreach (uint spinId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.HairSpinId,
|
||||||
|
CharacterCreationAppearancePage.EyesSpinId,
|
||||||
|
CharacterCreationAppearancePage.NoseSpinId,
|
||||||
|
CharacterCreationAppearancePage.MouthSpinId,
|
||||||
|
CharacterCreationAppearancePage.SkinSpinId,
|
||||||
|
CharacterCreationAppearancePage.HeadgearSpinId,
|
||||||
|
CharacterCreationAppearancePage.ShirtSpinId,
|
||||||
|
CharacterCreationAppearancePage.TrousersSpinId,
|
||||||
|
CharacterCreationAppearancePage.FootwearSpinId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
page.Children.Add(SpinInfo(spinId));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
||||||
|
page.Children.Add(ButtonInfo(swatchId));
|
||||||
|
|
||||||
|
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
|
||||||
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
|
||||||
|
|
||||||
|
var viewport = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterCreationAppearancePage.ViewportId,
|
||||||
|
Type = 0xDu,
|
||||||
|
Width = 300f,
|
||||||
|
Height = 300f,
|
||||||
|
};
|
||||||
|
page.Children.Add(viewport);
|
||||||
|
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
||||||
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo SpinInfo(uint id)
|
||||||
|
{
|
||||||
|
var spin = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Type = 1u,
|
||||||
|
Width = 200f,
|
||||||
|
Height = 24f,
|
||||||
|
};
|
||||||
|
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
|
||||||
|
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
|
||||||
|
return spin;
|
||||||
|
}
|
||||||
|
|
||||||
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
|
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
|
||||||
LayoutImporter.Build(
|
LayoutImporter.Build(
|
||||||
new ElementInfo
|
new ElementInfo
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue