using System.Numerics; 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. /// /// /// /// The real color wheel (Campaign CC gate round 1 Batch G, R2-5, /// register AP-216/AP-217): retail's DoColorSpots @0x0047d850 / /// DoGradDisk @0x0047da90 paint each swatch and the gradient disc /// with an ACTUAL representative color sampled from the real DAT palette /// data (AcDream.Core.CharGen.ChargenSwatchColorResolver ports the /// computation — see its own doc for the two color-source shapes and the /// clothing PalSet lookup). / /// / are /// late-bound composition seams (same pattern as ) /// a DAT-backed catalog wires in after construction; the /// children painted over each swatch/the gradient disc are this batch's /// rendering primitive — see that class's own doc for why it is a flat /// color fill (a documented approximation of retail's actual recolored- /// sprite blit) and the STOPPED shared-file edit that would upgrade it to /// a genuine texture tint. /// /// 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, ]; /// /// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion /// "selected" overlay elements, one per entry at /// the SAME index — retail gmCGAppearancePage::InitializePage's /// own id-pair table (@0x004800ff-00480164, the switch that fills /// m_tColorWheel[i][0x10]/[0x14]-ish offsets with the /// swatch/overlay id pair per index) resolves the SAME nine ids this /// array carries, in the SAME index order. SetColor (case /// 0x0047DD50, iCurColor assignment) is retail's actual /// click-feedback mechanism — SetVisible on the overlay at /// iCurColor'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 was always a complete no-op /// here — see ). Live-DAT- /// probe-confirmed siblings of the swatches under the same color-wheel /// container (0x100003B9), each roughly centered on its paired /// swatch's own rect. /// internal static readonly uint[] SwatchOverlayIds = [ 0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu, 0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u, ]; /// 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 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 UiElement? _gradCircle; /// R2-5: one flat-color tile per swatch, added as an EXTRA /// child of the swatch it decorates (see 's /// own doc) — null wherever the matching entry /// itself is null (nothing to attach to). private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length]; /// R2-5: the gradient disc's own tint tile, an extra child of /// . private readonly ChargenSwatchColorTile? _gradCircleTile; 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; } /// /// R2-5 late-bound seams (same pattern as /// 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, until a /// composition root supplies a DAT-backed /// AcDream.Content.CharGen.ChargenAppearanceCatalog (which /// already implements all three interfaces) for these three /// properties, mirroring how itself gets /// wired in from outside this class. STOPPED (Batch G): that /// assignment is a 3-line addition to /// CharacterCreationUiController.cs, outside this batch's file /// contract — see the batch's handoff notes. /// internal IChargenPalSetSource? PalSetSource { get; set; } internal IChargenClothingTableSource? ClothingTableSource { get; set; } internal IChargenPaletteColorSource? PaletteColorSource { 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; // R2-5: an extra CHILD tile, sized to exactly cover the // swatch's own face — see ChargenSwatchColorTile's own doc for // why this is a flat fill rather than a recolored sprite, and // for why ClickThrough there keeps this from ever swallowing // the swatch's own click. var tile = new ChargenSwatchColorTile { Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height, }; swatch.AddChild(tile); _swatchColorTiles[i] = tile; } for (int i = 0; i < SwatchOverlayIds.Length; i++) _swatchOverlays[i] = Find(pageRoot, SwatchOverlayIds[i]); _shadeScroll = Find(pageRoot, ShadeScrollId); if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; _gradCircle = Find(pageRoot, GradCircleId); if (_gradCircle is not null) { _gradCircleTile = new ChargenSwatchColorTile { Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height, }; _gradCircle.AddChild(_gradCircleTile); } 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(); // 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(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); }; 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); } /// /// Campaign CC slice CC5: ports the Appearance case of /// gmCharGenMainUI::DoRandom @ 0x004e7d70 (case 3) — /// m_eCurType == ECG_CHOICE_CLOTHES -> /// CharGenState::RandomizeClothing(state, 1), else /// CharGenState::RandomizeAppearance(state, 0). Retires the /// Appearance half of register AP-212 (the primitives are now real, /// faithful ports — see RuntimeCharacterCreationState's own /// Randomize section — not a uniform-pick approximation). /// 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(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(); } /// /// Fix round F1: ports retail's spin BODY-click normalize-and-write-back /// — gmCGAppearancePage::ListenToElementMessage cases 0xa5- /// 0xa9 (hair/eyes/nose/mouth/skin, @0x0047f04b-0x0047f1bf) /// and 0xab-0xae (headgear/shirt/trousers/footwear, /// @0x0047f212-0x0047f3ac) each re-clamp the part's current index /// into [0, count) BEFORE selecting it as current, not just read /// it. Retail's rule (Hair's case 0xa5 is representative, /// @0x0047f051-0x0047f081 plus the shared tail at /// label_47f065/label_47f6d9): cur >= count -> 0; /// cur < 0 -> count-1. Headgear's own case (0xab, /// @0x0047f218-0x0047f23e) excludes its 0xffffffff Unset /// sentinel from the "cur < 0" branch /// (iCurrentChoice < 0 && iCurrentChoice != 0xffffffff), /// 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 count-1 on the FIRST body click — the same /// count-1 wrap 's own decrement-from-Unset fix /// (F1's sibling finding) applies. Skin (case 0xa9, /// @0x0047f1bf-0x0047f1fb) normalizes its local cache too but /// never writes back (no CharGenState field for Skin — acdream: /// no 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 m_tChoices) /// — /// already rejects any out-of-range write and /// ConstrainAppearanceByGenderLocked 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. /// 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); } /// /// 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). /// /// /// Fix round F1: 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 /// (label_47f065/label_47f6d9, reached from Hair's /// decrement case @0x0047f465-0x0047f486 and, inlined per-part, /// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases /// @0x0047f491-0x0047f65c): decrementing FROM Unset /// (cur=-1 as signed int32) computes new = cur - 1 = -2, /// which is < 0, so it wraps to count - 1 — 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 new = -1 + 1 = 0, which is already in /// [0, count), 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. /// /// 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; swatch.Visible = visible; if (_swatchColorTiles[i] is { } tile) { ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; tile.Color = rgb is { } c ? ToTintColor(c) : null; tile.Visible = rgb is not null; } } // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL): // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad // plug" 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). if (_gradCircle is not null) { bool isEyes = _currentPart == Part.Eyes; _gradCircle.Visible = !isEyes; if (_gradCircleTile is { } gradTile) { int gradIndex = isEyes ? -1 : colorSlot is null ? 0 : (int)ColorCurrent(_currentPart, snapshot.Appearance); ChargenSwatchRgb? gradColor = gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null; gradTile.Visible = !isEyes && gradColor is not null; } } 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); } } // ── Real swatch/gradient colors (R2-5) ────────────────────────────── private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length]; /// /// Computes one representative per /// swatch slot (0..8, matching 's own order) for /// , or an all-null array wherever the /// palette-resolution seams (/ /// /) /// aren't wired yet — see this page's own class doc + /// 's doc /// for the retail mechanism each branch below ports. /// 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; } /// Hair's shape: one PalSet id per swatch index, straight off /// (already the exact /// list /// indexes for the SAME selection when composing the 3D preview). private static void FillPalSetFamily( ChargenSwatchRgb?[] result, IReadOnlyList 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; } } } /// Eyes' shape: one Palette id per swatch index DIRECTLY off /// — no PalSet /// indirection, no averaging (see 's own /// doc for why Eyes is the one exception). private static void FillDirectFamily( ChargenSwatchRgb?[] result, IReadOnlyList 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; } } /// /// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares /// the SAME template-id /// list (register AP-208), resolved against the CURRENTLY EQUIPPED /// garment's own ClothingTable — see /// 's /// own doc for why a direct by-id lookup reproduces retail's /// StoreColorInformation result without needing its own array- /// building order. /// private void FillClothingFamily( ChargenSwatchRgb?[] result, ChargenGenderOptions gender, IReadOnlyList 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 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 ──────────────────────────────────────────────── /// /// GF-6/AP-218 (Campaign CC gate round 1 Batch C): /// gmCGAppearancePage::Update @ 0x0047e8f0 writes the Hair/Eyes/ /// Skin spins' caption to a heritage-flavored STATIC string via /// UIElement_Text::SetStringInfoWithFont — never an index or a /// style name. Normal heritage: ID_CharGen_HairStyle/ /// _Eyes/_Skin (@0x0047ebad/0x0047ebe3/0x0047ec6a). /// Gearknight (heritage 6): ID_CharGen_GearText_HairButton/ /// _EyesButton/_SkinButton (@0x0047e9ef/0x0047ea25/ /// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd): /// ID_CharGen_OlthoiText_HairButton/_EyesButton/ /// _SkinButton (@0x0047ed5b/0x0047ed91/0x0047ee15). The other /// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER /// touched by Update — their DAT-authored static caption /// (already resolved at build time by /// DatWidgetFactory.BuildButton'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). /// 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. }; /// 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; // 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; } }