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

@ -1055,6 +1055,64 @@ public sealed class CharacterCreationUiControllerTests
Assert.False(field.CharacterFilter!('$'));
}
// ── CC5 review fix round F12(d): RebuildListbox content ─────────────
/// <summary>
/// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's
/// actual built rows (not just the header/pair TEMPLATE ids the fixture
/// wires) — a skill row uses the key/value pair template (KEY = skill
/// name, VALUE = <see cref="FakeRuntime.GetSkillScore"/>'s deterministic
/// stand-in), and BOTH bucket headers appear even though only ONE bucket
/// (Trained) has a matching skill — the case that actually exercises
/// "unconditional" (a header shown only because some row happened to
/// match would have passed even with the pre-fix lazy-header bug).
/// </summary>
[Fact]
public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2,
// "Bow") is left Inactive entirely — no skill occupies the
// Specialized bucket.
environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained);
GoToSummary(environment);
UiTemplateListBox listBox = environment.SummaryListBox();
IReadOnlyList<UiElement> rows = Assert.IsType<UiScrollablePanel>(
listBox.ViewportForTest).Children;
// Ids match CharacterCreationSummaryPage's own private
// HeaderTextId/KeyTextId/ValueTextId constants — the same literals
// BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already
// hardcode for the fixture's row templates.
const uint headerTextId = 0x100000FEu;
const uint keyTextId = 0x100002FCu;
const uint valueTextId = 0x100002FDu;
var headers = new List<string>();
var pairs = new List<(string Key, string Value)>();
foreach (UiElement row in rows)
{
if (UiElement.FindDescendant(row, headerTextId) is UiText header)
headers.Add(JoinedText(header));
else if (UiElement.FindDescendant(row, keyTextId) is UiText key
&& UiElement.FindDescendant(row, valueTextId) is UiText value)
{
pairs.Add((JoinedText(key), JoinedText(value)));
}
}
Assert.Contains("Specialized Skills", headers);
Assert.Contains("Trained Skills", headers);
Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10");
Assert.DoesNotContain(pairs, p => p.Key == "Bow");
}
private static string JoinedText(UiText text) =>
string.Join(" ", text.LinesProvider().Select(static line => line.Text));
// ── CC5: 0xF643 rejection dialogs ─────────────────────────────────────
[Fact]
@ -1082,6 +1140,60 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
}
/// <summary>
/// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with
/// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName —
/// e.g. the player retried Finish with the SAME already-taken name)
/// must still show the dialog. <c>ReconcileDialogs</c>' own
/// <c>_lastShownRejection</c> dedup only suppresses re-showing a value
/// that is STILL the current <c>LastRejection</c> across ticks
/// (<see cref="CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog"/>);
/// once <see cref="FakeRuntime.AcknowledgeRejection"/> nulls the
/// snapshot's rejection (mirroring the real
/// <c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>),
/// <c>_lastShownRejection</c> resets to null too, so the identical
/// value arriving a second time is treated as new.
/// </summary>
[Fact]
public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
environment.Controller.Open();
var rejection = new RuntimeCharacterCreationRejection(
3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer");
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
environment.DismissActiveMessageDialog();
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
Assert.False(environment.Dialogs.IsOpen);
Assert.Null(environment.Runtime.View.Snapshot.LastRejection);
// One intervening Tick with LastRejection == null — exactly what
// happens continuously in the real game loop between the
// acknowledge callback and the player's next Finish attempt — lets
// ReconcileDialogs' own `if (rejection is null) _lastShownRejection
// = null;` branch run BEFORE the identical value arrives again.
// Without this, _lastShownRejection still holds the acknowledged
// value and the dedup would (correctly, per its OWN contract)
// suppress a value that never actually went away in between.
environment.Controller.Tick();
// The identical rejection value arrives again.
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is in use.", environment.LastDialogMessage());
environment.DismissActiveMessageDialog();
Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls);
}
[Fact]
public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog()
{
@ -1188,6 +1300,9 @@ public sealed class CharacterCreationUiControllerTests
public UiTemplateListBox SkillsList() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
public UiTemplateListBox SummaryListBox() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
public UiScrollbar ShadeScroll() =>
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
@ -1278,9 +1393,19 @@ public sealed class CharacterCreationUiControllerTests
RandomizeCharacter: RandomizeCharacter,
RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); },
RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); },
GetSkillScore: GetSkillScore,
OpenOnStart: false);
}
/// <summary>F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic
/// stand-in for <c>RetailSkillFormula.CalculateChargenScore</c> —
/// <c>skillId * 10</c> so tests can assert an exact, unambiguous value
/// without needing a real SkillTable.</summary>
private static uint GetSkillScore(
uint skillId,
ChargenAttributeValues attributes,
ChargenSkillAdvancementClass level) => skillId * 10u;
public FakeView View { get; }
public CharacterCreationRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;

View file

@ -668,6 +668,18 @@ public sealed class RuntimeCharacterCreationStateTests
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
// CC5 review-fix round F2 (2026-08-16): Pending/Undef used to be
// asserted as a SILENT reset producing no rejection at all
// (ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection,
// now deleted) — that assertion was wrong. Byte-decoded
// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030:
// Pending is an explicit switch case landing on the SAME
// "ID_Character_Err_NameDBDown" label as Corrupt/DatabaseDown, and
// Undef falls through that function's own unsigned-underflow default
// arm to the identical label — retail's dispatch has no silent branch.
// Both now belong in this same "produces a rejection" theory.
[InlineData(CharGenVerificationResponse.Code.Pending)]
[InlineData(CharGenVerificationResponse.Code.Undef)]
public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName(
CharGenVerificationResponse.Code code)
{
@ -685,24 +697,6 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.Null(state.Snapshot.LastCreated);
}
[Theory]
[InlineData(CharGenVerificationResponse.Code.Pending)]
[InlineData(CharGenVerificationResponse.Code.Undef)]
public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection(
CharGenVerificationResponse.Code code)
{
// ACE sends Pending for a disabled-Olthoi rejection — retail shows
// no dialog. Port as-is.
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)code, null, null, null));
Assert.False(state.Snapshot.VerificationPending);
Assert.Null(state.Snapshot.LastRejection);
Assert.Null(state.Snapshot.LastCreated);
}
[Fact]
public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored()
{
@ -791,6 +785,54 @@ public sealed class RuntimeCharacterCreationStateTests
// RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape.
Assert.NotEqual(0u, snapshot.Template);
Assert.True(snapshot.StartArea is 0 or 1);
// F8 (2026-08-16): every shade is RollShadeLocked's 32768-point
// lattice on [0.0, 1.0] INCLUSIVE (rand() in [0, 32767] * (1/32767)),
// never System.Random.NextDouble()'s continuous [0, 1).
Assert.InRange(snapshot.Appearance.SkinShade, 0.0, 1.0);
Assert.InRange(snapshot.Appearance.HairShade, 0.0, 1.0);
Assert.InRange(snapshot.Appearance.HeadgearShade, 0.0, 1.0);
Assert.InRange(snapshot.Appearance.ShirtShade, 0.0, 1.0);
Assert.InRange(snapshot.Appearance.TrousersShade, 0.0, 1.0);
Assert.InRange(snapshot.Appearance.FootwearShade, 0.0, 1.0);
}
/// <summary>
/// F8 (2026-08-16): pins <see cref="RuntimeCharacterCreationState"/>'s
/// private <c>RollShadeLocked</c> lattice (<c>rand() in [0,32767] *
/// (1.0/32767.0)</c>) deterministically via a fixed <see cref="Random"/>
/// double that always returns its <c>maxValue - 1</c>, confirming the
/// lattice's upper endpoint is EXACTLY reachable as 1.0 — a continuous
/// <see cref="Random.NextDouble"/>-style roll ([0, 1)) could never
/// produce that value.
/// </summary>
[Fact]
public void TryRandomizeCharacter_ShadeLattice_ReachesExactlyOneAtRandomMax()
{
var state = new RuntimeCharacterCreationState(
RuntimeCharacterCreationStateFixture.Build(),
new MaxValueRandom());
state.Begin(new RuntimeGenerationToken(1));
Assert.True(state.TryRandomizeCharacter());
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
Assert.Equal(1.0, a.SkinShade);
Assert.Equal(1.0, a.HairShade);
Assert.Equal(1.0, a.HeadgearShade);
Assert.Equal(1.0, a.ShirtShade);
Assert.Equal(1.0, a.TrousersShade);
Assert.Equal(1.0, a.FootwearShade);
}
/// <summary>Always returns <c>maxValue - 1</c> — the highest value
/// <see cref="Random.Next(int)"/>'s contract permits for any bound, so
/// every ordinary index pick in the randomize chain stays in-bounds
/// while the shade rolls (<c>Next(32768)</c>) land on 32767, the shade
/// lattice's top rung.</summary>
private sealed class MaxValueRandom : Random
{
public override int Next(int maxValue) => maxValue - 1;
}
/// <summary>
@ -894,4 +936,66 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle);
}
/// <summary>
/// F12(a) (CC5 review-fix round, 2026-08-16): pins
/// <c>RandomizeIndexExcludingLocked</c>'s exclude-current re-roll
/// property DETERMINISTICALLY. The fixture's <c>HairStyles</c>/
/// <c>HairColors</c>/<c>EyeColors</c> lists are all COUNT 2
/// (<see cref="RuntimeCharacterCreationStateFixture.Build"/>'s shared
/// gender record), so excluding the current index leaves exactly ONE
/// possible outcome — a second <see cref="RuntimeCharacterCreationState.TryRandomizeAppearance"/>
/// call must flip every one of these three fields to the OTHER index,
/// regardless of which <see cref="Random"/> seed drives the roll. Two
/// different seeds both proving the flip is what makes this a property
/// pin rather than a single-seed coincidence.
/// </summary>
[Theory]
[InlineData(1)]
[InlineData(999)]
public void TryRandomizeAppearance_ExcludeCurrent_OnCountTwoLists_AlwaysFlips(int seed)
{
var state = new RuntimeCharacterCreationState(
RuntimeCharacterCreationStateFixture.Build(),
new Random(seed));
state.Begin(new RuntimeGenerationToken(1));
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
Assert.True(state.TryRandomizeAppearance());
RuntimeCharacterCreationAppearance first = state.Snapshot.Appearance;
Assert.True(state.TryRandomizeAppearance());
RuntimeCharacterCreationAppearance second = state.Snapshot.Appearance;
Assert.True(first.HairStyle is 0u or 1u);
Assert.True(first.HairColor is 0u or 1u);
Assert.True(first.EyeColor is 0u or 1u);
Assert.NotEqual(first.HairStyle, second.HairStyle);
Assert.NotEqual(first.HairColor, second.HairColor);
Assert.NotEqual(first.EyeColor, second.EyeColor);
}
/// <summary>
/// F12(b) (CC5 review-fix round, 2026-08-16): pins
/// <c>RandomizeCharacterLocked</c>'s own <c>ClearSessionState</c>
/// prologue (retail's own <c>Reset()</c> call) at the RUNTIME layer —
/// the Summary page's Random button must clear a committed name, which
/// is exactly the state transition <c>CharacterCreationSummaryPage</c>'s
/// F1 fix (the field-sync <c>_suppressNextFieldEvent</c> removal) has to
/// coexist with correctly.
/// </summary>
[Fact]
public void TryRandomizeCharacter_ClearsAPreviouslyCommittedName()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
Assert.True(state.TrySetName("Bob"));
Assert.Equal("Bob", state.Snapshot.Name);
Assert.True(state.TryRandomizeCharacter());
Assert.Equal(string.Empty, state.Snapshot.Name);
}
}