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>
383 lines
18 KiB
C#
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 -> 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 -> 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 -> 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->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 < 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();
|
|
}
|
|
}
|