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
/// 0x100002ed — gmCGProfessionPage::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();
}
}