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

@ -178,6 +178,7 @@ internal sealed class ChargenPreviewController :
private readonly IChargenPalSetSource _palSets;
private readonly IChargenClothingTableSource _clothingTables;
private readonly object _datLock;
private readonly bool _useZoomedOutEye;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private ChargenPreviewAnimator? _animator;
@ -193,6 +194,19 @@ internal sealed class ChargenPreviewController :
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
/// parameter — see this class's own doc comment on why the renderer and
/// the zoom controller must share one mutable camera.</param>
/// <param name="useZoomedOutEye">Review fix round F5 (2026-08-16):
/// <see langword="false"/> (the default) reproduces the Appearance
/// page's own zoomed-IN default eye
/// (<c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>,
/// <see cref="ChargenPreviewCamera.ResolveDefaultEye"/>).
/// <see langword="true"/> reproduces the Summary page's own eye
/// (<c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>, byte-decoded
/// eye literal <c>(0, -2.5, 0.95)</c> at <c>~0x0047bd14-0x0047bd44</c> —
/// exactly <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/>'s
/// default-heritage value, NOT the zoomed-in one this controller used
/// before the fix). Summary has no zoom buttons at all (retail's own
/// viewport there is fixed-framing), so this is a permanent camera
/// profile for the controller's whole lifetime, not a toggle.</param>
public ChargenPreviewController(
IChargenPreviewRenderer renderer,
ChargenPreviewCamera camera,
@ -201,7 +215,8 @@ internal sealed class ChargenPreviewController :
IAnimationLoader animations,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
object datLock)
object datLock,
bool useZoomedOutEye = false)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
@ -211,7 +226,15 @@ internal sealed class ChargenPreviewController :
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_useZoomedOutEye = useZoomedOutEye;
_rotation = new ChargenPreviewRotationController();
// Seed the eye NOW, matching whatever the first Rebuild's own
// heritageOrGenderChanged branch below would otherwise defer until
// the first successful compose — avoids one frame of the wrong
// (Appearance-profile) eye if this controller ever renders before
// Rebuild's first call succeeds.
if (_useZoomedOutEye)
_camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u);
}
/// <summary>Test-observability seam only — production callers use
@ -275,7 +298,14 @@ internal sealed class ChargenPreviewController :
bool heritageOrGenderChanged =
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
if (heritageOrGenderChanged)
_camera.SetHeritage(heritageId);
{
// F5: the Summary controller (_useZoomedOutEye) re-derives the
// FIXED zoomed-out eye per heritage instead of SetHeritage's
// zoomed-in default — see the ctor param's own doc comment.
_camera.Eye = _useZoomedOutEye
? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId)
: ChargenPreviewCamera.ResolveDefaultEye(heritageId);
}
// ChargenPreviewZoomController's animator dependency is required at
// construction (fix round F2) — a fresh animator means a fresh