using System.Globalization; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.App.UI.Layout; /// /// The Profession page (gmCGProfessionPage, root 0x100003d2) — /// seven template buttons and the six attribute sliders. Decomp anchors: /// gmCGProfessionPage::InitializePage @ 0x00482d50 (slider/display /// element ids), gmCGProfessionPage::UpdateProfession @ 0x004821b0 /// (template-index -> button-id map, cited on ChargenTemplate), /// gmCGProfessionPage::UpdateAttributeValues @ 0x00482450 /// (avail/health/stamina/mana display sourcing). /// internal sealed class CharacterCreationProfessionPage : IDisposable { /// Template button id -> template index, verbatim off /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case /// button-highlight dispatch (also the doc comment on /// ChargenTemplate): 0 is Custom/Adventurer, and the six preset /// buttons do NOT sit in template-index order. private static readonly IReadOnlyDictionary TemplateByButtonId = new Dictionary { [0x100003D9u] = 0u, // Custom / Adventurer [0x100003DAu] = 1u, // Bow Hunter [0x100003DFu] = 2u, // Swashbuckler [0x100003DBu] = 3u, // Life Caster [0x100003DCu] = 4u, // War Caster (aka War Mage) [0x100003DDu] = 5u, // Wayfarer [0x100003DEu] = 6u, // Soldier }; /// /// Attribute id -> slider container element id, verbatim off /// gmCGProfessionPage::InitializePage @ 0x00482d50: /// m_tSliderArray[N].pAttribField = GetChildRecursive(this, /// id) for N=1..6 against ids 0x100003e6, e7, e9, e8, ea, eb /// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is /// slider index 4/Coordination), matching /// 's own documented 3/4 swap. /// private static readonly IReadOnlyDictionary SliderContainerByAttribute = new Dictionary { [ChargenAttributeId.Strength] = 0x100003E6u, [ChargenAttributeId.Endurance] = 0x100003E7u, [ChargenAttributeId.Coordination] = 0x100003E8u, [ChargenAttributeId.Quickness] = 0x100003E9u, [ChargenAttributeId.Focus] = 0x100003EAu, [ChargenAttributeId.Self] = 0x100003EBu, }; // Relative (within-container) child ids, same InitializePage loop: // 0x100002ec = lock UIElement_Button, 0x100002ed = name UIElement_Text // (left at its authored default — see the ctor comment), // 0x100002ee = the UIElement_Scrollbar drag control, 0x100002ef = the // value display. Live-DAT probe (CharacterCreationLiveDatTests): // 0x100002ef imports as a UiField, not UiText — retail's // NumberInputFilter (attached to the sibling name field in the decomp, // @0x00482e36) authors the whole slider row's text sub-elements as // editable-capable; acdream's factory maps that authored shape to // UiField. This also lets the player type an exact value directly. private const uint SliderLockRelativeId = 0x100002ECu; private const uint SliderControlRelativeId = 0x100002EEu; private const uint SliderValueRelativeId = 0x100002EFu; private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); /// /// GF-4b: the six slider containers' name-label CHILD, relative id /// 0x100002edgmCGProfessionPage::InitializePage /// @0x00482e1a-0x00482f1d writes CharGenState::GetAttributeName /// @0x005C3A20's literal ONCE at page construction (no per-refresh /// rewrite anywhere in the decomp — UpdateAttributeValues only /// touches pSlider/pAttribValue, never this id). Live-DAT- /// measured: this child resolves as Type 1 (UIElement_Button), /// matching retail's own declared UIElement_Button* field type /// that still accepts UIElement_Text::SetText — retail's button /// class carries the same text-rendering capability /// already is in this port. /// private const uint SliderNameRelativeId = 0x100002EDu; /// /// GF-3: the description textbox — gmCGProfessionPage::InitializePage /// @0x00483068's m_pTextBox. /// private const uint DescriptionTextId = 0x100003E0u; /// /// Root 1d: the page's own backdrop (0x100003d8, live-DAT- /// measured 7 authored states) switches per selected template — /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case /// eax_2->SetState(...) calls, keyed by ChargenTemplate /// index (0=Custom..6=Soldier), NOT the button-id map above. /// private static readonly IReadOnlyDictionary BackdropStateByTemplate = new Dictionary { [0u] = 0x1000002Bu, // Custom / Adventurer [1u] = 0x1000002Cu, // Bow Hunter [2u] = 0x10000031u, // Swashbuckler [3u] = 0x1000002Du, // Life Caster [4u] = 0x1000002Eu, // War Caster [5u] = 0x1000002Fu, // Wayfarer [6u] = 0x10000030u, // Soldier }; /// /// GF-3: per-template description string id — /// gmCGProfessionPage::UpdateProfession @0x00482203-0048233d's /// per-case var_a4_1 literal, resolved through /// UIElement_Text::SetStringInfo (NOT ...WithFont — a single /// plain string, no per-run palette color). /// private static readonly IReadOnlyDictionary DescriptionKeyByTemplate = new Dictionary { [0u] = "ID_CharGen_CustomText", [1u] = "ID_CharGen_BowText", [2u] = "ID_CharGen_SwashText", [3u] = "ID_CharGen_LifeText", [4u] = "ID_CharGen_WarText", [5u] = "ID_CharGen_WayText", [6u] = "ID_CharGen_SoldierText", }; private readonly CharacterCreationRuntimeBindings _bindings; private readonly Dictionary _templateButtons = []; private readonly Dictionary _sliders = []; private readonly UiButton? _availableValue; private readonly UiButton? _healthValue; private readonly UiButton? _staminaValue; private readonly UiButton? _manaValue; private readonly UiText? _description; private readonly UiElement? _backdrop; private bool _disposed; internal CharacterCreationProfessionPage( UiElement pageRoot, CharacterCreationRuntimeBindings bindings) { _bindings = bindings; foreach ((uint buttonId, uint templateIndex) in TemplateByButtonId) { if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) continue; _templateButtons[button] = templateIndex; button.OnClick = () => SelectTemplate(templateIndex); } foreach ((ChargenAttributeId attribute, uint containerId) in SliderContainerByAttribute) { if (UiElement.FindDescendant(pageRoot, containerId) is not { } container) continue; UiButton? lockButton = UiElement.FindDescendant(container, SliderLockRelativeId) as UiButton; UiScrollbar? slider = UiElement.FindDescendant(container, SliderControlRelativeId) as UiScrollbar; UiField? value = UiElement.FindDescendant(container, SliderValueRelativeId) as UiField; ChargenAttributeId capturedAttribute = attribute; if (lockButton is not null) { lockButton.OnClick = () => ToggleLock(capturedAttribute); } if (slider is not null) { slider.Horizontal = true; slider.ScalarChanged = scalar => SetAttributeFromScalar(capturedAttribute, scalar); } if (value is not null) { value.Editable = true; value.CharacterFilter = char.IsAsciiDigit; value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text); } // GF-4b: the name-label child is static per attribute — retail // writes it exactly once (InitializePage), never on refresh. if (UiElement.FindDescendant(container, SliderNameRelativeId) is UiButton nameLabel) nameLabel.Label = AttributeName(attribute); _sliders[attribute] = new SliderWidgets(lockButton, slider, value); } // GF-4a (Campaign CC gate round 1 Batch C): every one of the four // display buttons (0x100003e2..e5) authors its CAPTION directly as // its own P0x17 and carries a SEPARATE, media-less Type-12 value // child (0x100002f1/0x100002f3 — gmCGProfessionPage::InitializePage // @0x00482f90-0x00483062). DatWidgetFactory.BuildButton now surfaces // that child as UiButton.ValueLabel, coexisting with the authored // Label caption — see that method's own doc comment. Retiring the // prior Label-clobber substitution (register AD-103). _availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton; _healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton; _staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton; _manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton; _description = UiElement.FindDescendant(pageRoot, DescriptionTextId) as UiText; _backdrop = UiElement.FindDescendant(pageRoot, 0x100003D8u); } internal void Refresh( IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) { _ = view; foreach ((UiButton button, uint templateIndex) in _templateButtons) button.Selected = templateIndex == snapshot.Template; foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders) { int value = GetAttribute(snapshot.Attributes, attribute); // gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d: // SetAttribute_Float(pSlider, 0x86, value * 0.00999999978f) — // scalar = value/100, NOT (value-AttributeMin)/(AttributeMax- // AttributeMin). Review fix round F2 (2026-08-15): the earlier // [10,100]<->[0,1] normalization here did not match retail. float scalar = value / 100f; widgets.Slider?.SetScalarPosition(scalar); widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture)); if (widgets.Lock is { } lockButton) lockButton.Selected = snapshot.IsAttributeLocked(attribute); } SetDisplay(_availableValue, snapshot.RemainingAttributeCredits); int endurance = snapshot.Attributes.Endurance; // gmCGProfessionPage::UpdateAttributeValues @ 0x00482450: Health and // Stamina both read CharGenState::GetAttribute(state, 2) // (Endurance); Mana reads attribute 6 (Self). The Health call // alone passes through an FPU divide the decompiler elided // (_ftol2 @ 0x0048262b with no visible operand) — well-established // AC vitals convention (Health = floor(Endurance / 2), Stamina = // Endurance 1:1) is used here; a byte-level x87 trace would be // needed to pin the exact MSVC rounding mode if this ever needs // tighter verification. SetDisplay(_healthValue, endurance / 2); SetDisplay(_staminaValue, endurance); SetDisplay(_manaValue, snapshot.Attributes.Self); // Root 1d: backdrop art per selected template. if (_backdrop is IUiDatStateful backdropStateful && BackdropStateByTemplate.TryGetValue(snapshot.Template, out uint backdropState)) { backdropStateful.TrySetRetailState(backdropState); } // GF-3: description textbox — one plain segment (SetStringInfo, // not ...WithFont), so a single DefaultColor run. if (_description is not null && DescriptionKeyByTemplate.TryGetValue(snapshot.Template, out string? key)) { string? text = _bindings.ResolveText?.Invoke(key); var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) }; // F11 (Campaign CC gate round 1 closeout): compose ONCE here // (Refresh is already revision-gated) instead of re-wrapping on // every draw call — see CharacterCreationHeritagePage.Refresh's // own comment for the full rationale. IReadOnlyList composed = DatRichText.Compose(_description, segments); _description.LinesProvider = () => composed; } } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) { // CharGenState::RandomizeTemplate has no CC3 primitive — the // nearest faithful approximation is a uniform pick over this // heritage's own template list (register AP-212). IRuntimeCharacterCreationView? view = _bindings.View(); if (view is null || !view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage) || heritage.Templates.Count == 0) { return; } SelectTemplate((uint)Random.Shared.Next(heritage.Templates.Count)); } private static int GetAttribute(ChargenAttributeValues values, ChargenAttributeId id) => id switch { ChargenAttributeId.Strength => values.Strength, ChargenAttributeId.Endurance => values.Endurance, ChargenAttributeId.Quickness => values.Quickness, ChargenAttributeId.Coordination => values.Coordination, ChargenAttributeId.Focus => values.Focus, ChargenAttributeId.Self => values.Self, _ => 0, }; private static void SetDisplay(UiButton? display, int value) { if (display is null) return; // GF-4a: the button's OWN P0x17 caption ("Attribute Credits" etc.) // stays in Label; the live number goes in the coexisting value // slot DatWidgetFactory.BuildButton surfaced from the button's // media-less Type-12 child. display.ValueLabel = value.ToString(CultureInfo.InvariantCulture); } /// Ports CharGenState::GetAttributeName @ 0x005C3A20 /// verbatim — retail hardcodes these six literals directly (not a /// DAT/localization lookup), so this port does too. private static string AttributeName(ChargenAttributeId id) => id switch { ChargenAttributeId.Strength => "Strength", ChargenAttributeId.Endurance => "Endurance", ChargenAttributeId.Quickness => "Quickness", ChargenAttributeId.Coordination => "Coordination", ChargenAttributeId.Focus => "Focus", ChargenAttributeId.Self => "Self", _ => string.Empty, }; private void SelectTemplate(uint templateIndex) { if (_disposed) return; _bindings.SelectTemplate(templateIndex); } /// /// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the /// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa): /// ebx = _ftol2(param*100); if (ebx < 0xa) ebx = 0xa; /// SetAttribValue(this, parent, ebx) — truncate (not round) the /// scalar times 100, clamp LOW only to 10, with NO upper clamp/rescale. /// Review fix round F2 (2026-08-15): the earlier /// AttributeMin+Round(scalar*(Max-Min)) formula here did not match /// retail (it only happened to agree with retail at scalar=1). /// private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar) { if (_disposed) return; int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f)); _bindings.SetAttribute(attribute, value); } /// Direct numeric entry via the value field's NumberInputFilter /// (retail @0x00482e36) — an unparsable/empty submission is a no-op /// rather than clamping to a guessed default. private void SetAttributeFromText(ChargenAttributeId attribute, string text) { if (_disposed) return; if (int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) _bindings.SetAttribute(attribute, value); } private void ToggleLock(ChargenAttributeId attribute) { if (_disposed) return; RuntimeCharacterCreationSnapshot? snapshot = _bindings.View()?.Snapshot; bool currentlyLocked = snapshot?.IsAttributeLocked(attribute) ?? false; _bindings.SetAttributeLock(attribute, !currentlyLocked); } public void Dispose() { if (_disposed) return; _disposed = true; foreach (UiButton button in _templateButtons.Keys) button.OnClick = null; _templateButtons.Clear(); foreach (SliderWidgets widgets in _sliders.Values) { if (widgets.Lock is { } lockButton) lockButton.OnClick = null; if (widgets.Slider is { } slider) slider.ScalarChanged = null; if (widgets.Value is { } valueField) valueField.OnSubmit = null; } _sliders.Clear(); } }