Four visual residuals from the lead's own live-client captures of 1.0.2-cc.m, all root-caused via decomp + live-DAT evidence: - R4-1: Skills credits value overlapped mid-caption again. Root cause was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child rect (the child is base-inherited across four sibling buttons of differing widths, so its baked-in OriginalParentWidth diverges from the actual 231px-wide Skills credits button) plus an HJustify.Right value child mapped to Center instead of a real far-edge Right. - R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of drawing once — DrawTiled was reused for a small fixed marker graphic whose native size is far smaller than the track-proportional thumb rect. New DrawThumbMarker draws exactly one native-size instance. - R4-3: the skills info-box formula line clipped past the surrounding gold frame's own authored bottom edge (the pane's own raw box is 20px taller than the frame that visually contains it) — clamp the pane's Height to the frame's bottom (register AD-105, since retail's ShowSkillsText has no code relationship to the frame to cite). - R4-4: the Appearance help text started mid-sentence — the box was never touched by its page controller, so it kept UiText's chat-style PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is vacuously true on its first-ever overflow transition, pinning the first render to the bottom. Set PreserveEndOnLayout=false (a static top-oriented report, not a transcript) and wired the box's own nested authored scrollbar, never wired before. App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime 1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented Core.Net NakEmission full-solution-only flake, confirmed standalone-pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1335 lines
63 KiB
C#
1335 lines
63 KiB
C#
using System.Numerics;
|
|
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>
|
|
///
|
|
/// <para>
|
|
/// <b>The real color wheel (Campaign CC gate round 1 Batch G, R2-5,
|
|
/// register AP-216/AP-217 — CLOSEOUT (Group 1) makes it visually live):</b>
|
|
/// retail's <c>DoColorSpots @0x0047d850</c> / <c>DoGradDisk @0x0047da90</c>
|
|
/// paint each swatch and the gradient disc with an ACTUAL representative
|
|
/// color sampled from the real DAT palette data
|
|
/// (<c>AcDream.Core.CharGen.ChargenSwatchColorResolver</c> ports the
|
|
/// computation — see its own doc for the two color-source shapes and the
|
|
/// clothing PalSet lookup) via a genuine multiplicative sprite tint —
|
|
/// retail's own <c>SurfaceWindow::BlitAndColor(..., Blit_Multiply,
|
|
/// color)</c>. <see cref="PalSetSource"/>/<see cref="ClothingTableSource"/>/
|
|
/// <see cref="PaletteColorSource"/> are late-bound composition seams (same
|
|
/// pattern as <see cref="PreviewControl"/>) a DAT-backed catalog wires in
|
|
/// after construction (<c>CharacterCreationUiController.AppearancePalSetSource</c>
|
|
/// etc., assigned once by <c>LivePresentationComposition</c> alongside the
|
|
/// existing <c>AppearancePreviewControl</c> wiring). Each swatch
|
|
/// (<see cref="UiButton"/>) and the gradient disc (<see cref="UiDatElement"/>,
|
|
/// resolved via <see cref="_gradCircle"/>) now set their OWN
|
|
/// <see cref="UiButton.Tint"/>/<see cref="UiDatElement.Tint"/> directly —
|
|
/// the earlier flat-fill <c>ChargenSwatchColorTile</c> overlay (Batch G's
|
|
/// documented approximation, since neither widget exposed a tint hook yet)
|
|
/// is retired: a flat opaque rectangle drawn ON TOP of a sprite can never
|
|
/// reproduce a multiply blend, only a genuine per-instance sprite tint can,
|
|
/// so this closeout replaces the overlay outright rather than layering a
|
|
/// tint UNDER it.
|
|
/// </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>
|
|
/// R4-4 (Campaign CC gate round 1 re-test 3): the framed instructions
|
|
/// box (the SAME gold corner/edge sprite family the Skills info-box
|
|
/// frame uses, <c>0x100002de-e3</c>/<c>0x100000e8</c>/<c>0xea</c> —
|
|
/// live-DAT-confirmed identical children). Its own <c>P0x17</c> caption
|
|
/// is the FULL static help paragraph (no <c>gmCGAppearancePage</c>
|
|
/// runtime composition exists for it — unlike Town/Summary's
|
|
/// <c>SetTownString</c>/<c>SetHowToText</c>, this text is purely
|
|
/// DAT-authored, confirmed by the absence of any matching function in
|
|
/// the named decomp).
|
|
/// </summary>
|
|
internal const uint HelpTextId = 0x100003ABu;
|
|
|
|
/// <summary>The help box's own NESTED scrollbar child (live-DAT-
|
|
/// confirmed a direct child of <see cref="HelpTextId"/>, the SAME
|
|
/// structural id/nesting shape as
|
|
/// <c>CharacterCreationSummaryPage.HowToScrollRelativeId</c>'s own
|
|
/// how-to box scrollbar).</summary>
|
|
private const uint HelpScrollRelativeId = 0x100002E7u;
|
|
|
|
/// <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>
|
|
/// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion
|
|
/// "selected" overlay elements, one per <see cref="SwatchIds"/> entry at
|
|
/// the SAME index — retail <c>gmCGAppearancePage::InitializePage</c>'s
|
|
/// own id-pair table (<c>@0x004800ff-00480164</c>, the switch that fills
|
|
/// <c>m_tColorWheel[i][0x10]</c>/<c>[0x14]</c>-ish offsets with the
|
|
/// swatch/overlay id pair per index) resolves the SAME nine ids this
|
|
/// array carries, in the SAME index order. <c>SetColor</c> (case
|
|
/// <c>0x0047DD50</c>, <c>iCurColor</c> assignment) is retail's actual
|
|
/// click-feedback mechanism — <c>SetVisible</c> on the overlay at
|
|
/// <c>iCurColor</c>'s index, NOT a state swap on the swatch itself (the
|
|
/// swatch buttons author only an unnamed DirectState sprite; measured
|
|
/// against the installed dat, they have NO Normal/Highlight media at
|
|
/// all, so <see cref="UiButton.Selected"/> was always a complete no-op
|
|
/// here — see <see cref="RefreshColorAndShadeControls"/>). Live-DAT-
|
|
/// probe-confirmed siblings of the swatches under the same color-wheel
|
|
/// container (<c>0x100003B9</c>), each roughly centered on its paired
|
|
/// swatch's own rect.
|
|
/// </summary>
|
|
internal static readonly uint[] SwatchOverlayIds =
|
|
[
|
|
0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu,
|
|
0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u,
|
|
];
|
|
|
|
/// <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 UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length];
|
|
private readonly UiScrollbar? _shadeScroll;
|
|
private readonly UiButton? _rotateClockwise;
|
|
private readonly UiButton? _rotateCounterClockwise;
|
|
private readonly UiButton? _zoomIn;
|
|
private readonly UiButton? _zoomOut;
|
|
private readonly UiText? _helpText;
|
|
|
|
/// <summary>The gradient disc (<c>0x1000030e</c>) — Type 3 in the
|
|
/// authored dat, so <see cref="UiDatElement"/> (not the base
|
|
/// <see cref="UiElement"/>) is what <see cref="Find{T}"/> resolves;
|
|
/// typed concretely (post-closeout Group 1) so <see cref="RefreshColorAndShadeControls"/>
|
|
/// can set <see cref="UiDatElement.Tint"/> directly.</summary>
|
|
private readonly UiDatElement? _gradCircle;
|
|
|
|
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>
|
|
/// R2-5 late-bound seams (same pattern as <see cref="PreviewControl"/>
|
|
/// above) for the real color-wheel mechanism — null (the default)
|
|
/// leaves every swatch/the gradient disc showing ONLY its authored
|
|
/// static art, i.e. this page's pre-Batch-G behavior. Closeout Group 1
|
|
/// wires a DAT-backed <c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>
|
|
/// (which already implements all three interfaces) into these three
|
|
/// properties via <c>CharacterCreationUiController.AppearancePalSetSource</c>/
|
|
/// <c>AppearanceClothingTableSource</c>/<c>AppearancePaletteColorSource</c>,
|
|
/// mirroring how <see cref="PreviewControl"/> itself gets wired in from
|
|
/// outside this class.
|
|
/// </summary>
|
|
internal IChargenPalSetSource? PalSetSource { get; set; }
|
|
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
|
|
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
|
|
|
|
/// <summary>
|
|
/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) late-bound seam, same
|
|
/// pattern as the three above: null (the default) leaves every swatch/
|
|
/// the gradient disc falling back to their ordinary FaceFileOverride/
|
|
/// ActiveFile draw — live-DAT-confirmed, each swatch and the grad
|
|
/// circle DOES author its own DirectState sprite (the RAW,
|
|
/// un-recolored spot/gradDisk template respectively — the exact same
|
|
/// RenderSurface DIDs <c>ChargenColorSpotComposer</c> resolves by
|
|
/// enum), so the unwired fallback shows that authored art untinted
|
|
/// (Tint stays Vector4.One when no color data exists), not literally
|
|
/// nothing. Wired by the composition root to a
|
|
/// <see cref="ChargenColorSpotComposer"/> (same site as
|
|
/// <see cref="PalSetSource"/> et al) once a <c>TextureCache</c> exists.
|
|
/// See <see cref="UiButton.ColorKeyFaceResolver"/>'s own doc for WHY a
|
|
/// fourth seam is needed beyond the three color-computation ones above:
|
|
/// those three answer "what RGB is this swatch", this one answers
|
|
/// "what actual bitmap should this element's face show" — a materially
|
|
/// different question once the answer can no
|
|
/// longer be a plain multiply-tint (see R3-5's finding).
|
|
/// </summary>
|
|
internal IChargenSwatchTextureSource? SwatchTextureSource { 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;
|
|
}
|
|
|
|
for (int i = 0; i < SwatchOverlayIds.Length; i++)
|
|
_swatchOverlays[i] = Find<UiElement>(pageRoot, SwatchOverlayIds[i]);
|
|
|
|
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
|
|
if (_shadeScroll is not null)
|
|
_shadeScroll.ScalarChanged = SetShadeFromScalar;
|
|
|
|
_gradCircle = Find<UiDatElement>(pageRoot, GradCircleId);
|
|
|
|
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();
|
|
// GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00
|
|
// (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6)
|
|
// (Highlight), ZoomOutButton->SetState(1) (Normal) — a
|
|
// mutual-exclusive pair. Re-derived from InitializePage
|
|
// @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction,
|
|
// @0x004802c3): NO explicit initial SetState call exists
|
|
// for either button, so both start at their DAT-authored
|
|
// "Normal" default (live-DAT-probe-confirmed) until the
|
|
// first real zoom click — this port does not force an
|
|
// initial Highlight.
|
|
_zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight);
|
|
_zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal);
|
|
};
|
|
_zoomOut = Find<UiButton>(pageRoot, ZoomOutId);
|
|
if (_zoomOut is not null)
|
|
_zoomOut.OnClick = () =>
|
|
{
|
|
PreviewControl?.ZoomOut();
|
|
// GF-10: gmCGAppearancePage::ZoomOut @0x0047D050
|
|
// (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton
|
|
// -> Highlight(6), ZoomInButton -> Normal(1).
|
|
_zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight);
|
|
_zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal);
|
|
};
|
|
|
|
// R4-4 (Campaign CC gate round 1 re-test 3): the help box's static
|
|
// paragraph starts mid-sentence because this port never touched
|
|
// this element at all — it built through the plain DatWidgetFactory
|
|
// import path with UiText's own chat-style default
|
|
// (PreserveEndOnLayout=true, "keep a view that is already at the
|
|
// end pinned there" — see that property's own doc: "Chat uses the
|
|
// default; top-oriented reports such as Character Information
|
|
// disable it"). This box's content overflows its own view (a full
|
|
// multi-paragraph instructions block in a 292px-tall frame), and
|
|
// UiScrollable.SetExtents's own wasAtEnd check is vacuously true
|
|
// the very first time a Scroll model transitions from its
|
|
// zero-initialized state (ContentHeight=0/ViewHeight=0/ScrollY=0 ->
|
|
// MaxScroll=0 -> AtEnd=(0>=0)=true) to real overflowing content —
|
|
// with PreserveEndOnLayout still true, that spuriously pins the
|
|
// FIRST-EVER render to the bottom, hiding the opening paragraph
|
|
// exactly as reported ("right arrows next to the article of
|
|
// clothing..." is mid-way through the third paragraph, not the
|
|
// first). This is a static instructions box, not a chat transcript
|
|
// — the SAME top-oriented-report shape PreserveEndOnLayout's own
|
|
// doc already carves out. Also wires the box's own nested authored
|
|
// scrollbar (property 0x72, live-DAT-confirmed a direct child) —
|
|
// NEVER wired by this page before — so a user can still reach the
|
|
// rest of the text if it doesn't fully fit, the SAME
|
|
// scrollbar.Model = text.Scroll linkage
|
|
// CharacterCreationSummaryPage's how-to box already uses.
|
|
_helpText = Find<UiText>(pageRoot, HelpTextId);
|
|
if (_helpText is not null)
|
|
{
|
|
_helpText.PreserveEndOnLayout = false;
|
|
if (Find<UiScrollbar>(_helpText, HelpScrollRelativeId) is { } helpScroll)
|
|
helpScroll.Model = _helpText.Scroll;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Fix round F3: retail's Gearknight branch
|
|
// (@0x0047eac6/0x0047eacf) and Olthoi/OlthoiAcid branch
|
|
// (@0x0047ee32/0x0047ee3b) both call SetChoice(ECG_CHOICE_FACE)
|
|
// + SetSelection(ECG_PARTS_HAIR) UNCONDITIONALLY — every single
|
|
// time Update runs while the heritage hides Clothes, not only
|
|
// when the Clothes tab happened to be showing. A conditional
|
|
// gate here (checking _currentChoice == Choice.Clothes) missed
|
|
// the case where _currentPart was Nose or Mouth — both ALSO
|
|
// hidden by this same branch — while _currentChoice was still
|
|
// Face: acdream would leave the hidden Nose/Mouth part driving
|
|
// the shade control; retail always snaps back to Hair.
|
|
_currentChoice = Choice.Face;
|
|
_currentPart = Part.Hair;
|
|
}
|
|
ApplyChoiceVisibility();
|
|
|
|
// GF-6: heritage-flavored, index-independent — no gender needed.
|
|
RefreshSpinCaptions(snapshot.HeritageId);
|
|
|
|
RefreshColorAndShadeControls(view, snapshot);
|
|
RebuildPreview(view, snapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC5: ports the Appearance case of
|
|
/// <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c> (case 3) —
|
|
/// <c>m_eCurType == ECG_CHOICE_CLOTHES ->
|
|
/// CharGenState::RandomizeClothing(state, 1)</c>, else
|
|
/// <c>CharGenState::RandomizeAppearance(state, 0)</c>. Retires the
|
|
/// Appearance half of register AP-212 (the primitives are now real,
|
|
/// faithful ports — see <c>RuntimeCharacterCreationState</c>'s own
|
|
/// Randomize section — not a uniform-pick approximation).
|
|
/// </summary>
|
|
internal void Randomize()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
if (_currentChoice == Choice.Clothes)
|
|
_bindings.RandomizeClothing?.Invoke();
|
|
else
|
|
_bindings.RandomizeAppearance?.Invoke();
|
|
}
|
|
|
|
// ── 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;
|
|
NormalizeChoiceOnSelect(part);
|
|
_currentPart = part;
|
|
RefreshColorAndShadeControlsFromLatestSnapshot();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round F1: ports retail's spin BODY-click normalize-and-write-back
|
|
/// — <c>gmCGAppearancePage::ListenToElementMessage</c> cases <c>0xa5</c>-
|
|
/// <c>0xa9</c> (hair/eyes/nose/mouth/skin, <c>@0x0047f04b-0x0047f1bf</c>)
|
|
/// and <c>0xab</c>-<c>0xae</c> (headgear/shirt/trousers/footwear,
|
|
/// <c>@0x0047f212-0x0047f3ac</c>) each re-clamp the part's current index
|
|
/// into <c>[0, count)</c> BEFORE selecting it as current, not just read
|
|
/// it. Retail's rule (Hair's case 0xa5 is representative,
|
|
/// <c>@0x0047f051-0x0047f081</c> plus the shared tail at
|
|
/// <c>label_47f065</c>/<c>label_47f6d9</c>): <c>cur >= count -> 0</c>;
|
|
/// <c>cur < 0 -> count-1</c>. Headgear's own case (0xab,
|
|
/// <c>@0x0047f218-0x0047f23e</c>) excludes its <c>0xffffffff</c> Unset
|
|
/// sentinel from the "cur < 0" branch
|
|
/// (<c>iCurrentChoice < 0 && iCurrentChoice != 0xffffffff</c>),
|
|
/// so an Unset headgear survives a body click untouched; every other
|
|
/// indexed spin has no such exclusion, so an Unset (AP-214 honest-blank)
|
|
/// style wraps to <c>count-1</c> on the FIRST body click — the same
|
|
/// count-1 wrap <see cref="CycleIndex"/>'s own decrement-from-Unset fix
|
|
/// (F1's sibling finding) applies. Skin (case 0xa9,
|
|
/// <c>@0x0047f1bf-0x0047f1fb</c>) normalizes its local cache too but
|
|
/// never writes back (no <c>CharGenState</c> field for Skin — acdream:
|
|
/// no <see cref="StyleSlotFor"/> case), matching this method's no-op
|
|
/// early return for it. In acdream there is no separate UI-local cache
|
|
/// to desync from the persisted index (unlike retail's <c>m_tChoices</c>)
|
|
/// — <see cref="RuntimeCharacterCreationState.TrySetAppearanceIndex"/>
|
|
/// already rejects any out-of-range write and
|
|
/// <c>ConstrainAppearanceByGenderLocked</c> already clamps on every
|
|
/// gender change — so the ONLY reachable out-of-range case here is
|
|
/// Unset itself; the >=count branch is kept for completeness/fidelity
|
|
/// with retail's own defensive shape, not because acdream can hit it.
|
|
/// </summary>
|
|
private void NormalizeChoiceOnSelect(Part part)
|
|
{
|
|
ChargenAppearanceSlot? slot = StyleSlotFor(part);
|
|
if (slot is null)
|
|
return; // Skin: retail normalizes locally but never writes back.
|
|
|
|
IRuntimeCharacterCreationView? view = _bindings.View();
|
|
if (view is null)
|
|
return;
|
|
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
|
|
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
|
|
return;
|
|
|
|
int count = StyleCount(part, gender);
|
|
if (count <= 0)
|
|
return;
|
|
uint current = StyleCurrent(part, snapshot.Appearance);
|
|
|
|
uint normalized;
|
|
if (part == Part.Headgear)
|
|
{
|
|
// 0x0047f218/0x0047f226: cur >= count -> Unset; Unset itself
|
|
// (cur < 0 as signed int32) is explicitly excluded from the
|
|
// "cur < 0 -> count-1" branch, so it stays Unset.
|
|
if (current != Unset && current >= (uint)count)
|
|
normalized = Unset;
|
|
else
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
// 0x0047f04b family: cur >= count -> 0; cur < 0 -> count-1.
|
|
// Unset (0xFFFFFFFF) reads as -1 in retail's signed int32 store,
|
|
// so it takes the "cur < 0" branch same as any other negative.
|
|
if (current != Unset && current >= (uint)count)
|
|
normalized = 0u;
|
|
else if (current == Unset)
|
|
normalized = (uint)(count - 1);
|
|
else
|
|
return;
|
|
}
|
|
|
|
_bindings.SetAppearanceIndex?.Invoke(slot.Value, normalized);
|
|
}
|
|
|
|
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).
|
|
///
|
|
/// <para>
|
|
/// <b>Fix round F1:</b> every OTHER style spin ALSO has a decomp-
|
|
/// observable Unset-cycling case — it lives in the same switch the
|
|
/// headgear ring was ported from, at the shared decrement tail
|
|
/// (<c>label_47f065</c>/<c>label_47f6d9</c>, reached from Hair's
|
|
/// decrement case <c>@0x0047f465-0x0047f486</c> and, inlined per-part,
|
|
/// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases
|
|
/// <c>@0x0047f491-0x0047f65c</c>): decrementing FROM Unset
|
|
/// (<c>cur=-1</c> as signed int32) computes <c>new = cur - 1 = -2</c>,
|
|
/// which is <c>< 0</c>, so it wraps to <c>count - 1</c> — the SAME
|
|
/// "wrap to the last index" shape headgear's own ring uses, just without
|
|
/// headgear's extra Unset ring position. Incrementing FROM Unset
|
|
/// computes <c>new = -1 + 1 = 0</c>, which is already in
|
|
/// <c>[0, count)</c>, so it lands on style 0 — this half was already
|
|
/// correct. The prior doc here claimed "no decomp-observable
|
|
/// Unset-cycling case" and picked index 0 for BOTH directions; the
|
|
/// decomp refutes that for decrement. This matters in practice: AP-214's
|
|
/// honest-blank open leaves every non-headgear index Unset, so the
|
|
/// FIRST left-arrow click a user makes on this page hits this exact
|
|
/// path.
|
|
/// </para>
|
|
/// </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)
|
|
{
|
|
// Retail's per-part decrement/increment cases each recompute
|
|
// `new = cur + delta` on the RAW signed int32 (Unset = -1) and
|
|
// apply a SINGLE-STEP clamp (not a full modulo): new < 0 wraps
|
|
// to count-1, new >= count wraps to 0. Since every real caller
|
|
// only ever passes delta = -1/+1 here, evaluating that one-step
|
|
// clamp directly (rather than routing Unset through the general
|
|
// Mod() below, which assumes a valid starting index) reproduces
|
|
// retail exactly for both directions.
|
|
int fromUnset = -1 + delta;
|
|
if (fromUnset < 0)
|
|
return (uint)(count - 1);
|
|
if (fromUnset >= count)
|
|
return 0u;
|
|
return (uint)fromUnset;
|
|
}
|
|
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)
|
|
{
|
|
// Fix round F2 item 2: gmCGAppearancePage::SetSelection
|
|
// @0x0047e260 resets the PREVIOUS current-part spin to state 1
|
|
// (@0x0047e306, this->m_pCurSelection->vtable->SetState(1)) and sets
|
|
// the NEW one to state 6 (@0x0047e837,
|
|
// this->m_pCurSelection->vtable->SetState(6)) — a literal highlight
|
|
// toggle. UiButtonStateMachine.Normal/Highlight are already retail's
|
|
// own numeric ids 1/6 (see that class); IUiDatStateful.TrySetRetailState
|
|
// is the established seam for pushing a raw retail state id
|
|
// (CharacterCreationUiController.SetMasterPageState's own pattern).
|
|
foreach ((Part spinPart, UiButton spin) in _spins)
|
|
{
|
|
spin.TrySetRetailState(
|
|
spinPart == _currentPart
|
|
? UiButtonStateMachine.Highlight
|
|
: UiButtonStateMachine.Normal);
|
|
}
|
|
|
|
// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch
|
|
// click feedback is the companion overlay's visibility (SetColor
|
|
// @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible),
|
|
// not a state swap on the swatch button — measured against the
|
|
// installed dat, the nine swatches author only a DirectState sprite
|
|
// with no Normal/Highlight media at all, so a prior
|
|
// swatch.Selected assignment here was a permanent no-op (see
|
|
// SwatchOverlayIds' own doc comment). Exactly one overlay is
|
|
// visible: the one at the current part's own selected color index.
|
|
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
|
|
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
|
|
for (int i = 0; i < _swatchOverlays.Length; i++)
|
|
{
|
|
if (_swatchOverlays[i] is { } overlay)
|
|
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
|
|
}
|
|
|
|
// AP-216 (Campaign CC gate round 1 Batch C PARTIAL -> Batch G,
|
|
// R2-5, FULL): retail's DoColorSpots @0x0047d850 blits ACTUAL-color
|
|
// art for each valid swatch and BLANK art for any swatch beyond the
|
|
// current part's real color count. The "beyond count" half shipped
|
|
// at Batch C (hiding a swatch the part's color list doesn't have);
|
|
// this batch adds the "actual color" half via
|
|
// ChargenSwatchColorResolver (see this page's own class doc).
|
|
//
|
|
// displayCount diverges from the interactive colorSlot/colorCount
|
|
// pairing for exactly one family: Nose/Mouth/Skin (colorSlot ==
|
|
// null, per ColorSlotFor's own doc) still get ONE representative
|
|
// swatch in retail — SetSelection's Nose/Mouth/Skin cases each hard-
|
|
// code var_1e0 = 1 (@0x0047e456/0x0047e4b7/0x0047e510) even though
|
|
// no ListenToElementMessage case ever makes that swatch clickable
|
|
// (SetColor's switch has no case for those three parts either).
|
|
bool swatchGenderResolved = TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender);
|
|
int colorCount = colorSlot is not null && swatchGenderResolved
|
|
? ColorCount(_currentPart, swatchGender!)
|
|
: 0;
|
|
int displayCount = colorSlot is not null ? colorCount : 1;
|
|
|
|
ChargenSwatchRgb?[] swatchColors = swatchGenderResolved
|
|
? ComputeSwatchColors(swatchGender!, snapshot.Appearance)
|
|
: new ChargenSwatchRgb?[SwatchIds.Length];
|
|
|
|
for (int i = 0; i < _swatches.Length; i++)
|
|
{
|
|
if (_swatches[i] is not { } swatch)
|
|
continue;
|
|
bool visible = i < displayCount;
|
|
// R3-5 correction: retail's own pColor->SetVisible(1)
|
|
// (DoColorSpots' own loop) is UNCONDITIONAL for all 9 swatches
|
|
// — beyond displayCount shows the BLANK/blocked art (still
|
|
// visible), never hides the element. Batch C's own "beyond
|
|
// count -> hide" half is retired by this correction.
|
|
swatch.Visible = true;
|
|
// Closeout Group 1 (Tint) + R3-5 correction (ColorKeyFaceResolver):
|
|
// Tint keeps communicating "this swatch's color is X" for every
|
|
// existing reader/test; the resolver is the SEPARATE decision
|
|
// of which pre-baked bitmap actually draws (see
|
|
// UiButton.ColorKeyFaceResolver's own doc for why a multiply
|
|
// over the authored sprite is retail-wrong here).
|
|
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
|
|
swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One;
|
|
swatch.ColorKeyFaceResolver = BuildSwatchTextureResolver(visible, rgb);
|
|
}
|
|
|
|
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL) + R3-6 correction:
|
|
// gmCGAppearancePage::DoGradDisk @0x0047da90 blits the "grad plug"
|
|
// icon for Eyes (DoGradDisk(this, 1), called from SetSelection
|
|
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
|
|
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
|
|
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
|
|
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
|
|
// (SetSelection hard-codes eyeColor = 0 for those three cases,
|
|
// matching displayCount's own reasoning above). R3-6: SetSelection
|
|
// @0x0047e260's own Eyes/non-Eyes branches (@0x0047e859-0047e878)
|
|
// call ONLY DoGradDisk + m_pShadeScroll->SetVisible — NEITHER
|
|
// branch ever calls m_pGradCircle->SetVisible; the disc element
|
|
// itself is never hidden for Eyes, only its CONTENT (source image)
|
|
// changes. The prior "_gradCircle.Visible = !isEyes" line was a
|
|
// misreading — corrected to always-visible.
|
|
if (_gradCircle is not null)
|
|
{
|
|
bool isEyes = _currentPart == Part.Eyes;
|
|
_gradCircle.Visible = true;
|
|
// Closeout Group 1: same Tint mechanism as the swatches above —
|
|
// the gradient disc's own authored art is multiplied by the
|
|
// currently-selected swatch's color instead of an overlay child.
|
|
int gradIndex = isEyes
|
|
? -1
|
|
: colorSlot is null
|
|
? 0
|
|
: (int)ColorCurrent(_currentPart, snapshot.Appearance);
|
|
ChargenSwatchRgb? gradColor =
|
|
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
|
|
_gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One;
|
|
// R3-6: the disc's own authored media is empty (live-DAT-
|
|
// confirmed) — supply the missing base bitmap. Non-Eyes shows
|
|
// gradDisk (multiplied by Tint above, matching retail's own
|
|
// Blit_Multiply); Eyes shows the static plug icon UNTINTED
|
|
// (Tint is already Vector4.One for Eyes via gradIndex==-1
|
|
// above, matching retail's plain Blit_Normal).
|
|
uint gradTexture = SwatchTextureSource is { } textures
|
|
? (isEyes ? textures.GradPlugTexture : textures.GradDiskTexture)
|
|
: 0u;
|
|
_gradCircle.RuntimeImageTexture = gradTexture;
|
|
}
|
|
|
|
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
|
|
if (_shadeScroll is null)
|
|
return;
|
|
// Fix round F2 item 3: gmCGAppearancePage::SetSelection HIDES the
|
|
// shade scrollbar for Eyes (@0x0047e862, SetVisible(0) — Eyes has no
|
|
// shade case in SetShade at all) and shows it otherwise
|
|
// (@0x0047e878, SetVisible(1)) — retail never DISABLES it, it
|
|
// removes it from the layout entirely.
|
|
_shadeScroll.Visible = 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3-5: which pre-baked bitmap (if any) a swatch's own
|
|
/// <see cref="UiButton.ColorKeyFaceResolver"/> should resolve to on
|
|
/// THIS refresh. A fresh closure per call (not a cached delegate) so a
|
|
/// later <see cref="SwatchTextureSource"/> assignment (the composition
|
|
/// root wires it once the graphics backend exists, strictly after this
|
|
/// page's own construction — same ordering as
|
|
/// <see cref="PreviewControl"/>) is picked up the next time the
|
|
/// resolver actually RUNS (at draw time), not frozen at the OLD (null)
|
|
/// value from an earlier refresh.
|
|
/// <list type="bullet">
|
|
/// <item><description>Beyond <c>displayCount</c> (<paramref name="visible"/>
|
|
/// false): always resolve to the BLOCKED/blank art — retail shows this
|
|
/// for every swatch its current color count doesn't reach.</description></item>
|
|
/// <item><description>In range with a real color: resolve to that
|
|
/// color's own baked spot.</description></item>
|
|
/// <item><description>In range but no color data yet (palette seams
|
|
/// unwired): null — falls back to the ordinary FaceFileOverride/
|
|
/// ActiveFile draw, which shows the swatch's own AUTHORED DirectState
|
|
/// sprite (the raw, un-recolored spot template — live-DAT-confirmed
|
|
/// present, see <see cref="SwatchTextureSource"/>'s own doc) untinted,
|
|
/// matching this page's pre-R3-5 "fully inert until wired"
|
|
/// disposition for the OTHER three palette seams.</description></item>
|
|
/// </list>
|
|
/// </summary>
|
|
private Func<uint>? BuildSwatchTextureResolver(bool visible, ChargenSwatchRgb? rgb)
|
|
{
|
|
if (!visible)
|
|
return () => SwatchTextureSource?.BlankSpotTexture ?? 0u;
|
|
if (rgb is { } c)
|
|
return () => SwatchTextureSource?.GetActiveSpotTexture(c) ?? 0u;
|
|
return null;
|
|
}
|
|
|
|
// ── Real swatch/gradient colors (R2-5) ──────────────────────────────
|
|
|
|
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
|
|
|
|
/// <summary>
|
|
/// Computes one representative <see cref="ChargenSwatchRgb"/> per
|
|
/// swatch slot (0..8, matching <see cref="SwatchIds"/>'s own order) for
|
|
/// <see cref="_currentPart"/>, or an all-null array wherever the
|
|
/// palette-resolution seams (<see cref="PalSetSource"/>/
|
|
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/>)
|
|
/// aren't wired yet — see this page's own class doc +
|
|
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver"/>'s doc
|
|
/// for the retail mechanism each branch below ports.
|
|
/// </summary>
|
|
private ChargenSwatchRgb?[] ComputeSwatchColors(
|
|
ChargenGenderOptions gender, RuntimeCharacterCreationAppearance appearance)
|
|
{
|
|
if (PalSetSource is not { } palSets || PaletteColorSource is not { } colors)
|
|
return EmptySwatchColors;
|
|
|
|
var result = new ChargenSwatchRgb?[SwatchIds.Length];
|
|
switch (_currentPart)
|
|
{
|
|
case Part.Hair:
|
|
FillPalSetFamily(result, gender.HairColors, palSets, colors, ChargenSwatchColorResolver.HairSampleIndex);
|
|
break;
|
|
case Part.Eyes:
|
|
FillDirectFamily(result, gender.EyeColors, colors, ChargenSwatchColorResolver.EyeSampleIndex);
|
|
break;
|
|
case Part.Nose:
|
|
case Part.Mouth:
|
|
case Part.Skin:
|
|
// Retail: ONE representative swatch sourced from the
|
|
// single skin PalSet (SetSelection's Nose/Mouth/Skin cases,
|
|
// @0x0047e488/0x0047e4e9/0x0047e542 — all three set
|
|
// __return = 0xb0 against the same skinPalSetID DBObj get).
|
|
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
|
palSets, colors, gender.SkinPalSetId,
|
|
ChargenSwatchColorResolver.SkinFamilySampleIndex, out ChargenSwatchRgb skin))
|
|
{
|
|
result[0] = skin;
|
|
}
|
|
break;
|
|
case Part.Headgear:
|
|
FillClothingFamily(result, gender, gender.Headgears, appearance.HeadgearStyle, palSets, colors);
|
|
break;
|
|
case Part.Shirt:
|
|
FillClothingFamily(result, gender, gender.Shirts, appearance.ShirtStyle, palSets, colors);
|
|
break;
|
|
case Part.Trousers:
|
|
FillClothingFamily(result, gender, gender.Pants, appearance.TrousersStyle, palSets, colors);
|
|
break;
|
|
case Part.Footwear:
|
|
FillClothingFamily(result, gender, gender.Footwear, appearance.FootwearStyle, palSets, colors);
|
|
break;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Hair's shape: one PalSet id per swatch index, straight off
|
|
/// <see cref="ChargenGenderOptions.HairColors"/> (already the exact
|
|
/// list <see cref="AcDream.Core.CharGen.ChargenAppearanceFactory"/>
|
|
/// indexes for the SAME selection when composing the 3D preview).</summary>
|
|
private static void FillPalSetFamily(
|
|
ChargenSwatchRgb?[] result,
|
|
IReadOnlyList<uint> palSetIds,
|
|
IChargenPalSetSource palSets,
|
|
IChargenPaletteColorSource colors,
|
|
int sampleIndex)
|
|
{
|
|
int count = Math.Min(result.Length, palSetIds.Count);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
|
palSets, colors, palSetIds[i], sampleIndex, out ChargenSwatchRgb c))
|
|
{
|
|
result[i] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Eyes' shape: one Palette id per swatch index DIRECTLY off
|
|
/// <see cref="ChargenGenderOptions.EyeColors"/> — no PalSet
|
|
/// indirection, no averaging (see <see cref="ChargenPalSet"/>'s own
|
|
/// doc for why Eyes is the one exception).</summary>
|
|
private static void FillDirectFamily(
|
|
ChargenSwatchRgb?[] result,
|
|
IReadOnlyList<uint> paletteIds,
|
|
IChargenPaletteColorSource colors,
|
|
int sampleIndex)
|
|
{
|
|
int count = Math.Min(result.Length, paletteIds.Count);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (ChargenSwatchColorResolver.TryGetDirectColor(colors, paletteIds[i], sampleIndex, out ChargenSwatchRgb c))
|
|
result[i] = c;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares
|
|
/// the SAME <see cref="ChargenGenderOptions.ClothingColors"/> template-id
|
|
/// list (register AP-208), resolved against the CURRENTLY EQUIPPED
|
|
/// garment's own ClothingTable — see
|
|
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>'s
|
|
/// own doc for why a direct by-id lookup reproduces retail's
|
|
/// <c>StoreColorInformation</c> result without needing its own array-
|
|
/// building order.
|
|
/// </summary>
|
|
private void FillClothingFamily(
|
|
ChargenSwatchRgb?[] result,
|
|
ChargenGenderOptions gender,
|
|
IReadOnlyList<ChargenGearOption> gearOptions,
|
|
uint styleIndex,
|
|
IChargenPalSetSource palSets,
|
|
IChargenPaletteColorSource colors)
|
|
{
|
|
if (ClothingTableSource is not { } clothingTables)
|
|
return;
|
|
// Retail: an Unset ("no garment") style leaves numHeadgearColors
|
|
// (etc) at its CharGenState::SetHeadgearStyle @0x005c5350 reset
|
|
// value of 0 — no garment equipped means no dye choices to show.
|
|
if (styleIndex == Unset || styleIndex >= (uint)gearOptions.Count)
|
|
return;
|
|
|
|
uint clothingTableId = gearOptions[(int)styleIndex].ClothingTableId;
|
|
IReadOnlyList<uint> clothingColors = gender.ClothingColors;
|
|
int count = Math.Min(result.Length, clothingColors.Count);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (!ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
|
clothingTables, clothingTableId, clothingColors[i], out uint palSetId))
|
|
{
|
|
continue;
|
|
}
|
|
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
|
palSets, colors, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb c))
|
|
{
|
|
result[i] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static Vector4 ToTintColor(ChargenSwatchRgb rgb) =>
|
|
new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
|
|
|
|
// ── Spin captions ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// GF-6/AP-218 (Campaign CC gate round 1 Batch C):
|
|
/// <c>gmCGAppearancePage::Update @ 0x0047e8f0</c> writes the Hair/Eyes/
|
|
/// Skin spins' caption to a heritage-flavored STATIC string via
|
|
/// <c>UIElement_Text::SetStringInfoWithFont</c> — never an index or a
|
|
/// style name. Normal heritage: <c>ID_CharGen_HairStyle</c>/
|
|
/// <c>_Eyes</c>/<c>_Skin</c> (@0x0047ebad/0x0047ebe3/0x0047ec6a).
|
|
/// Gearknight (heritage 6): <c>ID_CharGen_GearText_HairButton</c>/
|
|
/// <c>_EyesButton</c>/<c>_SkinButton</c> (@0x0047e9ef/0x0047ea25/
|
|
/// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd):
|
|
/// <c>ID_CharGen_OlthoiText_HairButton</c>/<c>_EyesButton</c>/
|
|
/// <c>_SkinButton</c> (@0x0047ed5b/0x0047ed91/0x0047ee15). The other
|
|
/// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER
|
|
/// touched by <c>Update</c> — their DAT-authored static caption
|
|
/// (already resolved at build time by
|
|
/// <c>DatWidgetFactory.BuildButton</c>'s own P0x17 lift) is left
|
|
/// alone. Retail shows NO per-style index or name anywhere on this
|
|
/// page — the live 3D preview is the player's only feedback for which
|
|
/// style/gear is currently selected; acdream's own prior "1-based
|
|
/// ordinal"/gear-name substitution here was never a retail behavior
|
|
/// (register AP-218, retired by this fix; AP-215's own icon-thumbnail
|
|
/// item stays open — a DIFFERENT gap, see that row's own text).
|
|
/// </summary>
|
|
private void RefreshSpinCaptions(uint heritageId)
|
|
{
|
|
(string hairKey, string eyesKey, string skinKey) = heritageId switch
|
|
{
|
|
(uint)ChargenHeritageGroup.Gearknight => (
|
|
"ID_CharGen_GearText_HairButton",
|
|
"ID_CharGen_GearText_EyesButton",
|
|
"ID_CharGen_GearText_SkinButton"),
|
|
(uint)ChargenHeritageGroup.Olthoi or (uint)ChargenHeritageGroup.OlthoiAcid => (
|
|
"ID_CharGen_OlthoiText_HairButton",
|
|
"ID_CharGen_OlthoiText_EyesButton",
|
|
"ID_CharGen_OlthoiText_SkinButton"),
|
|
_ => ("ID_CharGen_HairStyle", "ID_CharGen_Eyes", "ID_CharGen_Skin"),
|
|
};
|
|
|
|
SetSpinCaption(Part.Hair, hairKey);
|
|
SetSpinCaption(Part.Eyes, eyesKey);
|
|
SetSpinCaption(Part.Skin, skinKey);
|
|
}
|
|
|
|
private void SetSpinCaption(Part part, string key)
|
|
{
|
|
if (!_spins.TryGetValue(part, out UiButton? spin))
|
|
return;
|
|
if (_bindings.ResolveText?.Invoke(key) is { } text)
|
|
spin.Label = text;
|
|
}
|
|
|
|
// ── 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;
|
|
// R2-5: same ownership shape as PreviewControl above — these are
|
|
// borrowed references into a DAT-backed catalog the composition
|
|
// root owns, not this page's own resources.
|
|
PalSetSource = null;
|
|
ClothingTableSource = null;
|
|
PaletteColorSource = null;
|
|
}
|
|
}
|