feat(app): Campaign CC slice CC4 — chargen screen shell + Heritage/Profession/Skills/Town pages

Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.

Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.

Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.

Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.

Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:45:51 +02:00
parent 3a6b7e3115
commit 0e71d3b829
27 changed files with 3344 additions and 12 deletions

View file

@ -0,0 +1,264 @@
using System.Globalization;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Profession page (<c>gmCGProfessionPage</c>, root <c>0x100003d2</c>) —
/// seven template buttons and the six attribute sliders. Decomp anchors:
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c> (slider/display
/// element ids), <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>
/// (template-index -&gt; button-id map, cited on <c>ChargenTemplate</c>),
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x00482450</c>
/// (avail/health/stamina/mana display sourcing).
/// </summary>
internal sealed class CharacterCreationProfessionPage : IDisposable
{
/// <summary>Template button id -&gt; template index, verbatim off
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
/// button-highlight dispatch (also the doc comment on
/// <c>ChargenTemplate</c>): 0 is Custom/Adventurer, and the six preset
/// buttons do NOT sit in template-index order.</summary>
private static readonly IReadOnlyDictionary<uint, uint> TemplateByButtonId =
new Dictionary<uint, uint>
{
[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
};
/// <summary>
/// Attribute id -&gt; slider container element id, verbatim off
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c>:
/// <c>m_tSliderArray[N].pAttribField = GetChildRecursive(this,
/// id)</c> for N=1..6 against ids <c>0x100003e6, e7, e9, e8, ea, eb</c>
/// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is
/// slider index 4/Coordination), matching
/// <see cref="ChargenAttributeId"/>'s own documented 3/4 swap.
/// </summary>
private static readonly IReadOnlyDictionary<ChargenAttributeId, uint> SliderContainerByAttribute =
new Dictionary<ChargenAttributeId, uint>
{
[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);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Dictionary<UiButton, uint> _templateButtons = [];
private readonly Dictionary<ChargenAttributeId, SliderWidgets> _sliders = [];
private readonly UiButton? _availableValue;
private readonly UiButton? _healthValue;
private readonly UiButton? _staminaValue;
private readonly UiButton? _manaValue;
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);
}
_sliders[attribute] = new SliderWidgets(lockButton, slider, value);
}
// Live-DAT probe (CharacterCreationLiveDatTests): every one of the
// four display containers (0x100003e2..e5) authors as a Button
// whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by
// UiButton.ConsumesDatChildren — the same "consumed child -> use
// the button's own Label" substitution the Skills page's credits
// meter needed (see CharacterCreationSkillsPage's ctor comment).
// Retail's own DynamicCast(0xc) on the CHILD (not the container)
// still stands as ground truth for the container's ROLE; only
// acdream's widget-level addressability differs (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;
}
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);
float scalar = (value - ChargenAttributeMath.AttributeMin)
/ (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin);
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);
}
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;
display.Label = value.ToString(CultureInfo.InvariantCulture);
}
private void SelectTemplate(uint templateIndex)
{
if (_disposed)
return;
_bindings.SelectTemplate(templateIndex);
}
private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar)
{
if (_disposed)
return;
int value = ChargenAttributeMath.AttributeMin
+ (int)MathF.Round(
scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin),
MidpointRounding.AwayFromZero);
_bindings.SetAttribute(attribute, value);
}
/// <summary>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.</summary>
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();
}
}