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:
parent
a975efd1d5
commit
0c8e1e7df1
14 changed files with 720 additions and 141 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue