using System.Globalization; using AcDream.App.Rendering; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.App.UI.Layout; /// /// The Appearance page (gmCGAppearancePage, root 0x100003d4) — /// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors: /// gmCGAppearancePage::InitializePage @ 0x0047FDD0 (widget ids, the /// 180° initial heading), ::ListenToElementMessage @ 0x0047EF30 (EVERY /// dispatch this page implements — gender buttons at cases 0x9d/ /// 0x9e; Face/Clothes sub-tabs at 0x9f/0xa0; the nine /// spin controls' decrement/increment arrow children, keyed by PARENT id, at /// cases 0/1; the same nine spins' own BODY click (select-as- /// current-part, no index change) at cases 0xa5-0xa9 and the /// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches /// at cases 5-0xd -> SetColor(0..8); the shade /// scrollbar at case 0x17 -> SetShade; rotate at /// 0x19/0x1a; zoom at 0x1b/0x1c), /// ::SetColor @ 0x0047DD50 and ::SetShade @ 0x0047C860 (the /// per-part color/shade routing table this page's switch /// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and /// EYES having no shade at all), ::Update @ 0x0047E8F0 (the heritage /// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable /// gate). /// /// /// Spin arrow geometry (live-DAT probe, CC6b-MOUNT): every one of the /// nine spin elements (0x100003af-b3, b5-b8) is uniformly /// 200px wide with two LOCALLY-reused arrow child ids (0x1000030a /// decrement at local x=[80,127), 0x1000030b increment at /// x=[127,174)) that DatWidgetFactory's UiButton 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 's local x coordinate /// (no new DatWidgetFactory widget type needed — see this campaign's /// color-wheel scouting finding below, which reached the identical "existing /// types suffice" conclusion for the whole page). /// /// /// /// Color-wheel family scouting (campaign plan risk item 4, RESOLVED): /// a live-DAT probe (CharacterCreationLiveDatTests) found every /// color-wheel-family id resolves through EXISTING DatWidgetFactory /// mappings: the nine swatch buttons (0x1000030f-0x10000317, retail's /// SetColor(0..8) targets) author Type 1 -> UiButton; their /// nine Type-3 companion "selected" overlays (0x10000318-0x10000320, /// retail's m_tColorWheel[...][0x10][iCurColor*7]->SetVisible /// highlight ring) and the GradCircle (0x1000030e) author Type 3 -> /// UiDatElement; the shade scrollbar (0x10000321) authors /// Type 0xB -> UiScrollbar, matching the decomp's own /// DynamicCast(0xb). NO new widget type was added. This page uses the /// swatch buttons' own 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. /// /// /// /// Icon-only style lists have no name string (register-worthy scope /// cut): CC1's // /// carry an IconId, 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 /// and show it directly. /// /// 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; /// Retail's nine SetColor(0..8) swatch buttons, in /// index order — verbatim off ListenToElementMessage's cases /// 5-0xd (elementId - 0x1000030a). internal static readonly uint[] SwatchIds = [ 0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u, 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, ]; /// 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 0xa5-0xa9 /// and their headgear/shirt/trousers/footwear mirrors). 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 _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; /// Late-bound preview control seam — see /// 's own doc comment for why this /// page cannot receive the real renderer at construction time. internal IChargenPreviewControl? PreviewControl { get; set; } /// The authored viewport (0x100003bb) — the composition /// root assigns its Renderer once the graphics backend exists, /// mirroring the paperdoll's own late viewport.Renderer = ... /// assignment. internal UiViewport? Viewport { get; } internal CharacterCreationAppearancePage( UiElement pageRoot, CharacterCreationRuntimeBindings bindings) { _bindings = bindings; _femaleButton = Find(pageRoot, FemaleButtonId); if (_femaleButton is not null) _femaleButton.OnClick = () => _bindings.SelectGender(2u); _maleButton = Find(pageRoot, MaleButtonId); if (_maleButton is not null) _maleButton.OnClick = () => _bindings.SelectGender(1u); _faceButton = Find(pageRoot, FaceButtonId); if (_faceButton is not null) _faceButton.OnClick = () => SelectChoice(Choice.Face); _clothesButton = Find(pageRoot, ClothesButtonId); if (_clothesButton is not null) _clothesButton.OnClick = () => SelectChoice(Choice.Clothes); _faceChoices = Find(pageRoot, FaceChoicesId); _clothesChoices = Find(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(pageRoot, SwatchIds[i]); if (swatch is null) continue; int index = i; swatch.OnClick = () => SelectColor(index); _swatches[i] = swatch; } _shadeScroll = Find(pageRoot, ShadeScrollId); if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; Viewport = Find(pageRoot, ViewportId); _rotateClockwise = Find(pageRoot, RotateClockwiseId); if (_rotateClockwise is not null) _rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise(); _rotateCounterClockwise = Find(pageRoot, RotateCounterClockwiseId); if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); _zoomIn = Find(pageRoot, ZoomInId); if (_zoomIn is not null) _zoomIn.OnClick = () => PreviewControl?.ZoomIn(); _zoomOut = Find(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(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); } /// /// Retail's decomp-derived wrap: reproduces /// CharGenState::SetHeadgearStyle's literal signed-int32 ring of /// +1 positions (every real index, plus /// — decrementing from index 0 lands on Unset, /// incrementing from Unset lands on index 0, matching /// ListenToElementMessage's cases 6 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 RandomizeCharacter-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. /// 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 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. }; /// Retail's SIX colorable parts (SetColor's cases /// 0,1,5,6,7,8) — Nose/Mouth/Skin have no color list at all. 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, }; /// Retail's SetShade 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). 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, }; /// Hair/Eyes have their own real per-gender color lists; /// the four clothing slots share the gender's single /// list (register /// AP-208). 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(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; } }