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

@ -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
{