acdream/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
Erik bd359d5181 fix(chargen): Campaign CC gate round 1 closeout — Group 3: round review fixes (F4-F11, F14, F16)
The remaining code-bearing findings from the round review, F4-F16 minus
the doc-only items (batched separately):

- F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly
  the 4 Town buttons, confined to chargen; conflicting custom-selection-
  pair + standard Normal/Highlight media — zero found, no gate
  tightening needed; per-state label-color map — 209 matches beyond
  chargen, confirming AP-222's mechanism has always been broadly active
  since it shipped generically in DatWidgetFactory).
- F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now
  honors a child's own AuthoredInvisible flag (a narrow honor scoped to
  exactly that carve-out, not the general #408 client-wide one) — the
  chat transcript's new-text indicator (0x1000048C) was building as a
  visible phantom element retail never shows; verified both directions
  against the gold-frame pieces, which do not author Invisible.
- F7: BoundedProcessOutputCapture.AppendLine combines the line text and
  its trailing newline into one buffer and one file open/write/close
  instead of two.
- F9: corrected a stale comment in RuntimeSettingsTargets — #407 split
  DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the
  fullscreen validator's own narrower list is now DELIBERATELY different
  from the Config dropdown's fuller offering, not the "must match" bug
  the comment described.
- F10: documented (not changed) why the LabelBox path's default 3px
  inset and the face-relative +4px gap in DatWidgetFactory.BuildButton
  are deliberately different numbers — neither carries a retail
  citation, and moving either to match the other would be an unfounded
  guess on a button that currently works correctly.
- F11: Heritage/Profession/Summary/Town description pages now compose
  DatRichText.Compose's result ONCE inside their already revision-gated
  Refresh, caching the built line list instead of re-wrapping on every
  draw call.
- F14: documented (not changed) why PrivateEntityViewportRenderer's
  _animatedIds set carrying a reserved-but-never-drawn backdrop id is
  harmless — BuildDrawEntities already excludes a null/empty backdrop
  from the actual draw list, so the id is never looked up.
- F16: the Summary preview now uses its own render-id pair
  (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/
  0xDA11D036) instead of sharing the Appearance page's
  (0xDA11D032/0xDA11D034) — confirmed by tracing
  FixedEntityTextureOwnerLease through TextureCache to
  CompositeTextureArrayCache's shared owner tracker that both pages'
  previews share ONE process-wide TextureCache, so sharing render ids
  was a real cross-page texture-release collision (either page's own
  re-dress or disposal could release the OTHER page's still-active
  textures), not a theoretical one.

F3's own register bookkeeping (AP-229 addendum) and F12's register/AD
header-count corrections land in the docs-only commit alongside F15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:34:07 +02:00

383 lines
18 KiB
C#

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);
/// <summary>
/// GF-4b: the six slider containers' name-label CHILD, relative id
/// <c>0x100002ed</c> — <c>gmCGProfessionPage::InitializePage
/// @0x00482e1a-0x00482f1d</c> writes <c>CharGenState::GetAttributeName
/// @0x005C3A20</c>'s literal ONCE at page construction (no per-refresh
/// rewrite anywhere in the decomp — <c>UpdateAttributeValues</c> only
/// touches <c>pSlider</c>/<c>pAttribValue</c>, never this id). Live-DAT-
/// measured: this child resolves as Type 1 (<c>UIElement_Button</c>),
/// matching retail's own declared <c>UIElement_Button*</c> field type
/// that still accepts <c>UIElement_Text::SetText</c> — retail's button
/// class carries the same text-rendering capability
/// <see cref="UiButton.Label"/> already is in this port.
/// </summary>
private const uint SliderNameRelativeId = 0x100002EDu;
/// <summary>
/// GF-3: the description textbox — <c>gmCGProfessionPage::InitializePage
/// @0x00483068</c>'s <c>m_pTextBox</c>.
/// </summary>
private const uint DescriptionTextId = 0x100003E0u;
/// <summary>
/// Root 1d: the page's own backdrop (<c>0x100003d8</c>, live-DAT-
/// measured 7 authored states) switches per selected template —
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
/// <c>eax_2-&gt;SetState(...)</c> calls, keyed by <c>ChargenTemplate</c>
/// index (0=Custom..6=Soldier), NOT the button-id map above.
/// </summary>
private static readonly IReadOnlyDictionary<uint, uint> BackdropStateByTemplate =
new Dictionary<uint, uint>
{
[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
};
/// <summary>
/// GF-3: per-template description string id —
/// <c>gmCGProfessionPage::UpdateProfession @0x00482203-0048233d</c>'s
/// per-case <c>var_a4_1</c> literal, resolved through
/// <c>UIElement_Text::SetStringInfo</c> (NOT ...WithFont — a single
/// plain string, no per-run palette color).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> DescriptionKeyByTemplate =
new Dictionary<uint, string>
{
[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<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 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<UiText.Line> 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);
}
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
/// verbatim — retail hardcodes these six literals directly (not a
/// DAT/localization lookup), so this port does too.</summary>
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);
}
/// <summary>
/// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the
/// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa):
/// <c>ebx = _ftol2(param*100); if (ebx &lt; 0xa) ebx = 0xa;
/// SetAttribValue(this, parent, ebx)</c> — 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).
/// </summary>
private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar)
{
if (_disposed)
return;
int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f));
_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();
}
}