fix(chargen): Campaign CC CC5 review fix round — F1-F14

Opus dual-lens review of 34e3a534+a975efd1 returned architectural
PASS-with-items / retail-fidelity FAIL. Every finding fixed:

- F1 (BLOCKER): deleted CharacterCreationSummaryPage's dead
  _suppressNextFieldEvent latch. UiField.SetText never raises
  OnFocusLost/OnSubmit, so the latch never had anything genuine to
  suppress — it stayed armed until the player's own next real commit
  and silently ate their typed name.
- F2: byte-re-derived gmCharGenMainUI::RecvNotice_
  CharGenVerificationResponse @0x004e9030's jump table — Pending is an
  explicit switch case landing on the SAME NameDBDown label as
  Corrupt/DatabaseDown, and Undef/out-of-range falls through the
  function's own unsigned-underflow default arm to that identical
  label. Retail's dispatch has NO silent branch. ApplyCreationResponse
  now produces a real rejection for Pending/Undef instead of a silent
  reset; ReconcileDialogs maps them to NameDBDown. Corrects the wrong
  "retail swallows Pending" claim everywhere it was repeated (plan doc,
  Core.Net doc comment, Runtime doc comments).
- F3: skill rows now use the key/value template with
  CharGenState::GetSkillScore @0x005C4B50 as the value (ported via the
  new RetailSkillFormula.CalculateChargenScore /
  ChargenSkillScoreResolver, wired through a new GetSkillScore
  binding), not template 0/name-only; bucket headers are unconditional.
  Writing this fix's own regression test surfaced a second, more severe
  bug: CharacterCreationSummaryPage never wired _list.TemplateResolver
  at all, so RebuildListbox has been a silent no-op since CC5 shipped —
  fixed by threading templateResolver through the page's constructor,
  matching every sibling UiTemplateListBox owner.
- F4: added the missing _errorMessageDialogContext one-outstanding
  guard to the 0xF643 rejection dialog, matching
  MakeErrorMessageDialog's own guard @0x004e8cc4 and the other four
  sibling dialogs' shape (registered in CloseAllDialogs, suppress-
  callback checked).
- F5: the Summary preview camera now seeds/re-derives retail's
  zoomed-OUT eye (byte-decoded (0,-2.5,0.95) at gmCGSummaryPage::
  InitializePage ~0x0047bd14-0x0047bd44) instead of Appearance's
  zoomed-in default, via a new ChargenPreviewController
  useZoomedOutEye flag.
- F6: retired AP-225 outright — re-derived the ListenToElementMessage
  length gate is NUL-inclusive, so MaxNameLength=32 was always
  byte-correct, not merely internally consistent.
- F7: amended AP-221 to cover the Summary preview's duplicate
  one-shot-composition binding gap (CC5 duplicated the pattern instead
  of closing it).
- F8: byte-decoded GetRandomReal @0x00563940's fmul operand at
  0x007cd650 — an 8-byte double, not a 4-byte float — is EXACTLY
  1.0/32767.0, not 1/32768. Added RollShadeLocked
  (_random.Next(32768) * (1.0/32767.0)) and switched all six shade
  rolls onto it.
- F9: evaluated porting retail's exact empty-name-commit no-op
  (NUL-inclusive length==1 skips SetName entirely) and rejected it —
  it would fight the F1 field-sync model by spontaneously reverting an
  emptied field on the next unrelated revision bump. Kept the clear,
  documented the tradeoff, filed AP-227.
- F11: filed AP-226 documenting retail's static pcProfessions/pcGender/
  pcHeritage/pcTown label tables versus acdream's DAT-sourced labels,
  including the non-human-heritage-renders-bare-"Heritage:" retail
  quirk.
- F12: added exclude-current determinism (count-2 lists), Random-
  clears-name, repeat-identical-rejection-reshows, and RebuildListbox
  content tests (the last one found F3's TemplateResolver bug).
- F13: threaded an optional Random through GameRuntimeDependencies ->
  LiveSessionController -> RuntimeCharacterCreationState, matching the
  existing TimeProvider injection shape, closing the Slice-K
  determinism hazard on a bot-reachable Randomize* command family.
- F14: RandomizeCharacterLocked now assigns _heritageId unconditionally
  before the TryGetHeritage gate, matching retail's SetHeritageGroup
  @0x005C67A0 (mHeritageGroup written before the DAT lookup).

Gates: Runtime 1726/0 (was 1722/0), App 5242/3 skips (was 5240/3),
Headless 166/0, Core.Net 993/994 (the one failure, NakEmissionTests
LossSoak, is a known pre-existing flake — passes standalone), full
solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 01:13:52 +02:00
parent a975efd1d5
commit 0c8e1e7df1
14 changed files with 720 additions and 141 deletions

View file

@ -56,17 +56,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
private const uint KeyTextId = 0x100002FCu;
private const uint ValueTextId = 0x100002FDu;
/// <summary>Retail's <c>name[33]</c> buffer (32 usable chars + null
/// terminator — <c>RuntimeCharacterCreationState.TrySetName</c>'s own
/// already-established storage cap). The decompiled UI-side check at
/// <c>ListenToElementMessage @ 0x0047bfd1</c> compares the raw input
/// length against the literal <c>0x21</c> (33) — one more than this —
/// but that comparison's exact base (visible character count vs. an
/// internal length-prefix accounting the decompiler didn't resolve
/// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps
/// the UI-level reject-and-revert threshold CONSISTENT with the
/// already-reviewed storage cap rather than trusting an ambiguous
/// 1-off decomp literal over that established contract.</summary>
/// <summary>
/// Retail's <c>name[33]</c> buffer (32 usable chars + null terminator —
/// <c>RuntimeCharacterCreationState.TrySetName</c>'s own
/// already-established storage cap). Review fix round F6 (2026-08-16):
/// the decompiled UI-side check at <c>ListenToElementMessage @
/// 0x0047bf40</c> (<c>~0x0047bfd1</c>) compares the field text's
/// <c>m_charbuffer</c> LENGTH FIELD against the literal <c>0x21</c>
/// (33) — that field is confirmed NUL-INCLUSIVE (the SAME method's own
/// empty-field check earlier at <c>0x0047bf93</c> compares that field to
/// <c>1</c>, i.e. an empty string's length reads as 1, not 0). So
/// <c>length &gt; 33</c> is EXACTLY <c>visibleChars &gt; 32</c>: a
/// 32-character name has length 33 (not <c>&gt; 33</c>, accepted), a
/// 33-character name has length 34 (<c>&gt; 33</c>, rejected). This
/// constant was ALWAYS byte-correct, not merely internally consistent
/// with the storage cap it was originally justified against — the
/// earlier "not fully certain" hedge and its AP-225 register row are
/// both retired.
/// </summary>
private const int MaxNameLength = 32;
private readonly CharacterCreationRuntimeBindings _bindings;
@ -75,7 +82,6 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
private readonly UiTemplateListBox? _list;
private readonly UiField? _nameField;
private string _lastCommittedName = string.Empty;
private bool _suppressNextFieldEvent;
private uint _nameTooLongDialogContext;
private bool _disposed;
@ -92,13 +98,27 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
RetailDialogFactory dialogs,
string nameTooLongMessage)
string nameTooLongMessage,
Func<uint, uint, UiElement?> templateResolver)
{
_bindings = bindings;
_dialogs = dialogs;
_nameTooLongMessage = nameTooLongMessage;
_list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
// Review fix round F3 residual (found while adding its own test,
// 2026-08-16): this assignment was MISSING outright — every sibling
// page that owns a UiTemplateListBox (CharacterCreationSkillsPage,
// CharacterManagementUiController, every Options-panel controller)
// wires TemplateResolver in its own constructor; this page never
// did. Without it, ResolveTemplateRow's own `_list.TemplateResolver
// is null` guard made EVERY RebuildListbox call a silent no-op —
// the Summary listbox has never rendered a single row (Profession/
// Gender/Heritage/Town, Attributes, Health/Stamina/Mana/Skill
// Credits, or the skill buckets) since CC5 shipped, independent of
// and masking the F3 template/score fix above.
if (_list is not null)
_list.TemplateResolver = templateResolver;
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
if (_nameField is not null)
@ -137,10 +157,19 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
// Keep the field's displayed text in sync with the committed name
// unless the player is actively typing (a mid-edit Refresh — driven
// by an unrelated selection change elsewhere on the screen — must
// not clobber their in-progress keystrokes).
// not clobber their in-progress keystrokes). Review fix round F1
// (2026-08-16): this used to arm a "_suppressNextFieldEvent" latch
// before calling SetText, on the assumption that SetText raises the
// same commit event a real keystroke/blur would. It does not —
// UiField.SetText (UiField.cs:240-248) only mutates _text/_caret and
// never invokes OnFocusLost/OnSubmit (those fire exclusively from
// OnEvent's own idMessage dispatch, UiField.cs:~313-316/:729). The
// latch therefore never had anything genuine to suppress; it just
// sat armed until the PLAYER's own next real commit, which then hit
// this early-return and silently dropped their typed name. Deleting
// the latch outright (nothing to reproduce) fixes that bug.
if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name)
{
_suppressNextFieldEvent = true;
field.SetText(snapshot.Name);
_lastCommittedName = snapshot.Name;
}
@ -151,15 +180,35 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
/// <summary>
/// Review fix round F9 (2026-08-16), empty-name commit: retail's
/// <c>ListenToElementMessage @ ~0x0047bf93</c> reads a length field that
/// is NUL-INCLUSIVE (confirmed at F6/F2's own byte-decode — an empty
/// field's length is 1, not 0) and gates the ENTIRE commit block —
/// including <c>SetName</c> — behind <c>if (length != 1)</c>. Blurring
/// an EMPTIED field in retail therefore leaves <c>CharGenState.name</c>
/// UNCHANGED (whatever it held before), not cleared; <c>DoFinish</c>
/// later reads that unchanged internal name, so retail's field and its
/// internal state can legitimately show different things after an
/// empty-field blur. This port deliberately does NOT reproduce that:
/// it calls <see cref="CharacterCreationRuntimeBindings.SetName"/>
/// (line below) for every commit including an empty one, so the state
/// always agrees with what the field just showed. Verified this is a
/// genuine, not cosmetic, choice — porting the exact skip would fight
/// <see cref="Refresh"/>'s own field-sync block above (the F1 fix): the
/// NEXT time anything else bumps the Runtime revision (e.g. the player
/// returns to Attributes and changes a slider, then comes back), Refresh
/// would see <c>field.Text ("") != snapshot.Name (the stale unchanged
/// name)</c> and forcibly restore the OLD name into the field — a
/// spontaneous, unexplained repopulation of a field the player
/// deliberately emptied, which retail's own non-continuously-refreshed
/// UI never produces. Register AP-227 records this as a deliberate
/// divergence.
/// </summary>
private void CommitNameFromField(string text)
{
if (_disposed)
return;
if (_suppressNextFieldEvent)
{
_suppressNextFieldEvent = false;
return;
}
if (text.Length > MaxNameLength)
{
@ -236,8 +285,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
AddPair(pairTemplate, "Mana", a.Self);
AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits);
AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained);
AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Trained Skills", ChargenSkillAdvancementClass.Trained);
}
private void AddLine(UiTemplateListEntry template, string text)
@ -283,24 +332,33 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
return row is null ? null : UiElement.FindDescendant(row, childId) as UiText;
}
/// <summary>
/// Review fix round F3 (2026-08-16), byte-decoded against
/// <c>SetSummaryText @ ~0x0047b6be-0x0047b9e0</c>: retail adds each
/// bucket's HEADER row UNCONDITIONALLY, before it ever walks
/// <c>skillRecordList</c> for that bucket (an empty bucket still shows
/// its header) — the previous lazy "only if any skill matched" gate had
/// no decomp support. Each matching skill row uses template 2 (the
/// key/value pair, <c>AddItemFromTemplateList(..., 2, ...)</c> @
/// <c>0x0047b938</c>), not template 0's single line — KEY = the skill
/// name, VALUE = <c>CharGenState::GetSkillScore(state, skill->id)</c> @
/// <c>0x0047b923</c>, ported as <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>.
/// </summary>
private void AddSkillBucket(
UiTemplateListEntry headerTemplate,
UiTemplateListEntry lineTemplate,
UiTemplateListEntry pairTemplate,
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot,
string header,
ChargenSkillAdvancementClass targetClass)
{
bool any = false;
AddHeader(headerTemplate, header);
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (view.GetSkillLevel(skillId) != targetClass)
continue;
if (!any)
{
AddHeader(headerTemplate, header);
any = true;
}
AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId));
uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, targetClass) ?? 0u;
AddPair(pairTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId), (int)score);
}
}
@ -364,6 +422,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
_nameTooLongDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
if (_list is not null)
_list.TemplateResolver = null;
_list?.Flush();
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.

View file

@ -61,6 +61,13 @@ public sealed record CharacterCreationRuntimeBindings(
/// <summary>CC5: the Appearance page's Random button on its Clothes
/// sub-tab.</summary>
Func<RuntimeCommandResult>? RandomizeClothing = null,
/// <summary>CC5 review fix round, F3 (2026-08-16): the Summary page's
/// skill-row VALUE — <c>CharGenState::GetSkillScore @ 0x005C4B50</c>,
/// wired at composition time (<c>AcDream.App.Net.ChargenSkillScoreResolver</c>)
/// so this UI-layer record stays free of a direct DAT/Chorizite
/// dependency, matching <see cref="ResolveText"/>'s own shape.
/// <see langword="null"/> degrades to a "no score available" 0.</summary>
Func<uint, ChargenAttributeValues, ChargenSkillAdvancementClass, uint>? GetSkillScore = null,
bool OpenOnStart = false);
/// <summary>
@ -188,6 +195,13 @@ internal sealed class CharacterCreationUiController : IDisposable
private uint _creditWarningDialogContext;
private uint _randomizeWarningDialogContext;
private uint _noNameWarningDialogContext;
// CC5 review fix round F4 (2026-08-16): gmCharGenMainUI's own
// m_uiErrorMessageContext (MakeErrorMessageDialog @ 0x004e8cb0's guard
// at 0x004e8cc4, assigned at 0x004e8dd3, cleared by the dtor at
// 0x004e83b3 alongside m_uiPleaseWaitContext/m_uiExitContext) — the
// 0xF643 rejection dialog was the only one of the five dialogs this
// controller owns without this same one-outstanding-dialog guard.
private uint _errorMessageDialogContext;
private RuntimeCharacterCreationRejection? _lastShownRejection;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -270,7 +284,7 @@ internal sealed class CharacterCreationUiController : IDisposable
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
_summaryPage = new CharacterCreationSummaryPage(
summaryPageRoot, bindings, dialogs, strings.NameTooLong);
summaryPageRoot, bindings, dialogs, strings.NameTooLong, templateResolver);
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
_back.OnClick = OnBack;
@ -929,16 +943,21 @@ internal sealed class CharacterCreationUiController : IDisposable
// ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ──
// ── 0x0055E8B0) ──────────────────────────────────────────────────────
/// <summary>Ports the four rejection-dialog mappings from
/// <c>Handle_CharGenVerificationResponse</c>'s per-case switch (restated
/// on <see cref="RuntimeCharacterCreationRejection"/>'s own doc
/// comment); Pending/Undef never reach this method (CC3's
/// <c>ApplyCreationResponse</c> treats them as a silent state reset with
/// no <see cref="RuntimeCharacterCreationRejection"/> produced at all).
/// Dedups against the LAST rejection instance already shown so a
/// same-value re-check on a later <see cref="Tick"/> (this method runs
/// every tick, not just on revision change) doesn't reopen the dialog
/// the player already dismissed.</summary>
/// <summary>
/// Ports the COMPLETE rejection-dialog mapping from
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
/// 0x004e9030</c>'s own switch + its <c>(arg2-1) &gt; 6</c>
/// unsigned-underflow default arm (restated on
/// <see cref="RuntimeCharacterCreationRejection"/>'s own doc comment).
/// CC5 review-fix round F2 (2026-08-16): every non-Ok code now reaches
/// this method (<c>RuntimeCharacterCreationState.ApplyCreationResponse</c>
/// no longer special-cases Pending/Undef as a silent reset) and every
/// branch here resolves to a real dialog — retail's dispatch has NO
/// silent case. Dedups against the LAST rejection instance already
/// shown so a same-value re-check on a later <see cref="Tick"/> (this
/// method runs every tick, not just on revision change) doesn't reopen
/// the dialog the player already dismissed.
/// </summary>
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
{
RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection;
@ -951,24 +970,42 @@ internal sealed class CharacterCreationUiController : IDisposable
return;
_lastShownRejection = rejection;
string? key = rejection.Value.Code switch
// MakeErrorMessageDialog's own guard @0x004e8cc4: a context already
// open is a no-op (the SECOND rejection's dialog is silently
// dropped, not queued) — F4's fix, matching the four sibling
// dialogs' shape. _lastShownRejection is already updated above even
// when this guard blocks the dialog, which is retail-faithful: a
// later Tick with the SAME rejection value must not retry it either
// (this scenario is not reachable through the ordinary UI today —
// TryBeginFinish's AlreadyPending refusal means a second Finish
// cannot land while a rejection is still unacknowledged — but the
// guard exists so the SHAPE matches retail's even if a future
// caller reaches it).
if (_errorMessageDialogContext != 0u)
return;
// Pending/Corrupt/DatabaseDown are explicit switch cases in retail's
// own dispatch landing on the SAME "ID_Character_Err_NameDBDown"
// label; Undef and any code outside 1..7 fall through that
// function's unsigned-underflow default arm to the identical label
// — the `_` arm below is that default, not a "no dialog" case.
string key = rejection.Value.Code switch
{
CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved",
CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned",
CharGenVerificationResponse.Code.Corrupt
or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown",
CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied",
_ => null,
_ => "ID_Character_Err_NameDBDown",
};
if (key is null)
return;
string? message = _bindings.ResolveText?.Invoke(key);
if (message is null)
return;
_dialogs.MakeMessage(message, data =>
_errorMessageDialogContext = _dialogs.MakeMessage(message, data =>
{
_errorMessageDialogContext = 0u;
_ = data;
if (_disposed || _suppressDialogCallbacks)
return;
_bindings.AcknowledgeRejection?.Invoke();
});
}
@ -1014,6 +1051,12 @@ internal sealed class CharacterCreationUiController : IDisposable
_noNameWarningDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
if (_errorMessageDialogContext != 0u)
{
uint closing = _errorMessageDialogContext;
_errorMessageDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
}
finally
{