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

@ -26,6 +26,7 @@ using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.Windowing;
@ -656,6 +657,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// outside the lock matches this file's existing pattern
// elsewhere (construct once, lock only around Resolve calls).
var characterCreationStrings = new DatStringResolver(d.Dats);
// CC5 review fix round F3 (2026-08-16): read the global
// SkillTable (portal.dat 0x0E000004 — the SAME file
// ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and
// LiveSessionRuntimeFactory.CreateCharacterBindings already read)
// ONCE at composition time, under the DatLock DatCollection's
// thread-safety contract requires — mirrors LiveSkillCreditResolver's
// own constructor-time load. The resolver itself does no further
// DAT access per call (pure SkillFormula arithmetic), so the
// Summary page's GetSkillScore binding below needs no lock.
SkillTable? chargenSkillTable;
lock (d.DatLock)
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
var bindings = new RetailUiRuntimeBindings(
Host: host,
Assets: assets,
@ -1012,6 +1026,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter,
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
GetSkillScore: chargenSkillScoreResolver.Resolve,
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
: null);
RetailUiRuntime runtime = lease.Mount(

View file

@ -1112,14 +1112,17 @@ internal sealed class LivePresentationCompositionPhase
// (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE
// instance from the Appearance page's own during the CC6b-MOUNT
// review) — same one-shot binding shape as the Appearance preview
// immediately above (AP-221's own disposition applies here too: a
// DAT/resource read not ready on this exact composition frame means
// the Summary preview stays permanently unbound for the session,
// same tracked follow-up as the Appearance preview). No zoom/rotate
// control surface is wired — retail's Summary page has no such
// buttons (only <c>StartAnimation</c>'s idle loop and a fixed 180°
// heading), so this controller's ZoomIn/RotateClockwise etc. simply
// never get called.
// immediately above. Review fix round F7 (2026-08-16): AP-221 is now
// AMENDED to cover this second binding explicitly (it originally
// named CC5 as the slice that should CLOSE the gap; CC5 duplicated
// the pattern here instead) — a DAT/resource read not ready on this
// exact composition frame means the Summary preview stays
// permanently unbound for the session, same tracked follow-up as
// the Appearance preview, now under the same amended row. No
// zoom/rotate control surface is wired — retail's Summary page has
// no such buttons (only <c>StartAnimation</c>'s idle loop and a
// fixed 180° heading), so this controller's ZoomIn/RotateClockwise
// etc. simply never get called.
CompositionAcquisitionScope.CompositionAcquisitionLease<
ChargenPreviewRenderer>? summaryPreviewLease = null;
ChargenPreviewController? summaryPreviewController = null;
@ -1162,7 +1165,12 @@ internal sealed class LivePresentationCompositionPhase
content.AnimationLoader,
summaryCatalog,
summaryCatalog,
d.DatLock);
d.DatLock,
// F5 (2026-08-16): the Summary preview is retail's zoomed-
// OUT full-body framing (gmCGSummaryPage::InitializePage @
// 0x0047bbf0), not the Appearance page's zoomed-in default —
// see ChargenPreviewController's own ctor doc comment.
useZoomedOutEye: true);
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
bindings.AdoptRelease(
"summary preview control",

View file

@ -1,3 +1,4 @@
using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
@ -32,6 +33,45 @@ internal static class RetailSkillFormula
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
return true;
}
/// <summary>
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Ports
/// <c>CharGenState::GetSkillScore @ 0x005C4B50</c>'s FULL behavior, not
/// just the shared <see cref="TryCalculate"/> base: after the formula
/// result, retail adds a level-based bonus keyed off the skill's CURRENT
/// advancement class (<c>edi_1</c> in the decomp) — <c>edi_1 == 2</c>
/// (Trained) → <c>result += 5</c>; <c>edi_1 == 3</c> (Specialized) →
/// <c>result += 10</c> — before returning. The decomp's own gate,
/// <c>if (edi_1 &gt;= var_38)</c> (<c>var_38</c> resolves to
/// <c>SkillBase.MinLevel</c> — a decompiler-mangled local the raw
/// pseudo-C renders as an uninitialized read; DatReaderWriter's own
/// typed <c>SkillBase.MinLevel</c> field is the same value cleanly), is
/// satisfied for both callers of this method (Specialized=3 and
/// Trained=2 are the only two advancement classes CC5's Summary listbox
/// still shows — AP-224 — and no retail-authored skill sets
/// <c>MinLevel</c> above Untrained=1) so it is not reproduced as a
/// separate branch; a future caller passing <see cref="ChargenSkillAdvancementClass.Untrained"/>
/// or <see cref="ChargenSkillAdvancementClass.Inactive"/> would need
/// that gate ported for real.
/// </summary>
public static uint CalculateChargenScore(
SkillBase skillBase,
uint attribute1,
uint attribute2,
ChargenSkillAdvancementClass level)
{
ArgumentNullException.ThrowIfNull(skillBase);
if (!TryCalculate(skillBase.Formula, attribute1, attribute2, out uint result))
return 0u;
return level switch
{
ChargenSkillAdvancementClass.Trained => result + 5u,
ChargenSkillAdvancementClass.Specialized => result + 10u,
_ => result,
};
}
}
/// <summary>
@ -70,3 +110,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
: 0u;
}
}
/// <summary>
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling
/// of <see cref="LiveSkillCreditResolver"/>: resolves
/// <see cref="RetailSkillFormula.CalculateChargenScore"/> against the SAME
/// global <c>SkillTable</c> (portal.dat <c>0x0E000004</c>), fed by a
/// candidate character's CHARGEN attribute spread (<see cref="ChargenAttributeValues"/>,
/// keyed the same way <c>AcDream.Runtime.Session.ChargenAttributeId</c>
/// already does — verified against DatReaderWriter's own
/// <c>DatReaderWriter.Enums.AttributeId</c> generated enum, which carries the
/// identical Strength=1/Endurance=2/Quickness=3/Coordination=4/Focus=5/
/// Self=6 numbering) rather than a live player's server-echoed current
/// attributes. Wired at composition time
/// (<c>InteractionRetainedUiComposition.cs</c>) so
/// <c>CharacterCreationSummaryPage</c> never needs a DAT/Chorizite
/// dependency of its own — same shape as that composition's existing
/// <c>ResolveText</c> binding.
/// </summary>
internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable)
{
public uint Resolve(
uint skillId,
ChargenAttributeValues attributes,
ChargenSkillAdvancementClass level)
{
if (skillTable?.Skills is null
|| !skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
{
return 0u;
}
uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes);
uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes);
return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level);
}
private static uint ResolveAttribute(
DatReaderWriter.Enums.AttributeId attributeId,
ChargenAttributeValues attributes) => attributeId switch
{
DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength),
DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance),
DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness),
DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination),
DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus),
DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self),
_ => 0u,
};
}

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

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
{

View file

@ -56,18 +56,27 @@ namespace AcDream.Core.Net.Messages;
/// <c>CharacterGenerationVerificationResponse</c> enum
/// (<c>ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs</c>),
/// which is itself retail's own dialog dispatch table
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c>): <c>NameInUse</c> →
/// <c>ID_Character_Err_NameReserved</c>, <c>NameBanned</c> →
/// <c>ID_Character_Err_NameBanned</c>, <c>Corrupt</c>/<c>DatabaseDown</c> →
/// <c>ID_Character_Err_NameDBDown</c>, <c>AdminPrivilegeDenied</c> →
/// <c>ID_Character_Err_NameAdminDenied</c>. <c>Pending</c>/<c>Undef</c>
/// retail treats as a silent state reset with no dialog — notably ACE sends
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c> +
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse@0x004e9030</c>'s
/// own jump table). <b>CC5 review-fix round F2 (2026-08-16) correction:</b>
/// every non-Ok code shows a dialog — there is no silent branch.
/// <c>NameInUse</c> → <c>ID_Character_Err_NameReserved</c>,
/// <c>NameBanned</c> → <c>ID_Character_Err_NameBanned</c>,
/// <c>AdminPrivilegeDenied</c> → <c>ID_Character_Err_NameAdminDenied</c>,
/// and <c>Pending</c>/<c>Corrupt</c>/<c>DatabaseDown</c>/<c>Undef</c>/any
/// unrecognized code ALL resolve to <c>ID_Character_Err_NameDBDown</c> —
/// <c>Pending</c> is an explicit switch case landing on that same label,
/// and <c>Undef</c>/out-of-range falls through
/// <c>RecvNotice_CharGenVerificationResponse</c>'s own
/// <c>(arg2-1) &gt; 6</c> unsigned-underflow default arm to the identical
/// label. This corrects an earlier (wrong) reading of the decomp that
/// treated Pending/Undef as a silent state reset — notably ACE sends
/// <c>Pending</c> for a disabled-Olthoi rejection
/// (<c>CharacterHandler.CharacterCreateEx</c>,
/// <c>olthoi_play_disabled</c> branch), so that specific rejection is
/// invisible to the retail-faithful client too; this is a retail quirk to
/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job
/// (App layer), not this Core.Net type's.
/// <c>olthoi_play_disabled</c> branch), so that specific rejection now
/// correctly surfaces the NameDBDown dialog, matching retail, instead of
/// silently resetting verification state. Dialog presentation itself is
/// CC5's job (App layer), not this Core.Net type's.
/// </para>
/// </summary>
public static class CharGenVerificationResponse

View file

@ -19,7 +19,18 @@ public sealed record GameRuntimeDependencies(
ILiveSessionOperations? SessionOperations = null,
Func<double>? CombatTime = null,
uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
int MaximumChatEntries = 500);
int MaximumChatEntries = 500,
// Review fix round F13 (2026-08-16): the shared RNG source for
// process-wide randomize commands reachable from a headless bot
// (RuntimeCharacterCreationState's Randomize* family —
// RandomizeCharacter/RandomizeAppearance/RandomizeClothing, exposed on
// IRuntimeCharacterCreationCommands). null (the default) keeps every
// existing caller's production behavior unchanged (Random.Shared,
// threaded the same way TimeProvider already is here) — this only
// exists so a future deterministic-bot config (Slice K's contract,
// project_linux_headless_bots.md) can supply a seeded Random without a
// second construction path.
Random? Random = null);
[Flags]
public enum GameRuntimeTeardownStage
@ -180,10 +191,12 @@ public sealed class GameRuntime
context.Session = dependencies.SessionOperations is null
? new LiveSessionController(
ProductionLiveSessionOperations.Instance,
dependencies.TimeProvider)
dependencies.TimeProvider,
random: dependencies.Random)
: new LiveSessionController(
dependencies.SessionOperations,
dependencies.TimeProvider);
dependencies.TimeProvider,
random: dependencies.Random);
construction.Own(context.Session);
Fault(
GameRuntimeConstructionPoint.SessionCreated,

View file

@ -455,7 +455,13 @@ public sealed class LiveSessionController
public LiveSessionController(
ILiveSessionOperations operations,
TimeProvider? timeProvider = null,
ChargenOptions? chargenOptions = null)
ChargenOptions? chargenOptions = null,
// Review fix round F13 (2026-08-16): threaded from
// GameRuntimeDependencies.Random the same way timeProvider already
// is — null keeps every existing caller (including this class's own
// parameterless ctor below) on RuntimeCharacterCreationState's own
// Random.Shared default.
Random? random = null)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState(
@ -466,7 +472,7 @@ public sealed class LiveSessionController
// not this one. A caller that never supplies real options simply
// gets an inert chargen surface (every heritage lookup misses).
CharacterCreationState = new RuntimeCharacterCreationState(
chargenOptions ?? ChargenOptions.Empty);
chargenOptions ?? ChargenOptions.Empty, random);
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }

View file

@ -163,18 +163,25 @@ public readonly record struct RuntimeCharacterCreationIdentity(
/// <summary>
/// A non-Ok <c>0xF643</c> response, mapped to retail's dialog family
/// (<c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c>'s per-case dialog
/// dispatch, restated in <see cref="CharGenVerificationResponse"/>'s doc
/// comment): <see cref="CharGenVerificationResponse.Code.NameInUse"/> →
/// NameReserved, <see cref="CharGenVerificationResponse.Code.NameBanned"/> →
/// NameBanned, <see cref="CharGenVerificationResponse.Code.Corrupt"/> /
/// <see cref="CharGenVerificationResponse.Code.DatabaseDown"/> → NameDBDown,
/// <see cref="CharGenVerificationResponse.Code.AdminPrivilegeDenied"/> →
/// NameAdminDenied. <see cref="CharGenVerificationResponse.Code.Pending"/> /
/// <see cref="CharGenVerificationResponse.Code.Undef"/> never produce this
/// record — retail treats them as a silent state reset with no dialog (ACE
/// sends <c>Pending</c> for a disabled-Olthoi rejection; this is a genuine
/// retail quirk, not a bug — port as-is).
/// (<c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c> +
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
/// 0x004e9030</c>'s own switch/jump-table dispatch, restated in
/// <see cref="CharGenVerificationResponse"/>'s doc comment).
/// <b>CC5 review-fix round F2 (2026-08-16) correction:</b> ALL non-Ok codes
/// produce this record — <see cref="CharGenVerificationResponse.Code.NameInUse"/>
/// → NameReserved, <see cref="CharGenVerificationResponse.Code.NameBanned"/> →
/// NameBanned, <see cref="CharGenVerificationResponse.Code.AdminPrivilegeDenied"/> →
/// NameAdminDenied, and <see cref="CharGenVerificationResponse.Code.Pending"/> /
/// <see cref="CharGenVerificationResponse.Code.Corrupt"/> /
/// <see cref="CharGenVerificationResponse.Code.DatabaseDown"/> /
/// <see cref="CharGenVerificationResponse.Code.Undef"/> / any unrecognized
/// code ALL → NameDBDown (Pending is an explicit switch case landing on
/// that same label; Undef/out-of-range falls through the function's own
/// unsigned-underflow default arm to the identical label). An earlier
/// reading of the decomp treated Pending/Undef as producing a silent state
/// reset with NO record and no dialog — that was wrong; retail's dispatch
/// has no silent branch (ACE sends <c>Pending</c> for a disabled-Olthoi
/// rejection, which now correctly surfaces the NameDBDown dialog).
/// </summary>
public readonly record struct RuntimeCharacterCreationRejection(
uint RawCode,
@ -1253,6 +1260,27 @@ public sealed class RuntimeCharacterCreationState : IDisposable
return result;
}
/// <summary>
/// Ports <c>CharGenState::GetRandomReal @ 0x00563940</c> exactly:
/// <c>(double)rand() * (1.0/32767.0)</c>. Review fix round F8
/// (2026-08-16), byte-decoded from the raw PE: the pseudo-C shows only
/// <c>return rand(this);</c> (the decompiler elided the FPU multiply
/// entirely), but the actual machine code is
/// <c>call rand; fild [esp]; fmul qword ptr [0x007cd650]; ret</c> — an
/// 8-BYTE double-precision operand (<c>fmul qword</c>, not <c>dword</c>).
/// The bytes at <c>0x007cd650</c> are <c>80 00 40 00 20 00 00 3f</c>,
/// which as a little-endian IEEE-754 double is EXACTLY
/// <c>1.0/32767.0</c> (bit pattern <c>0x3f00002000400080</c>) — NOT
/// <c>1.0/32768.0</c> (which would be <c>0x3f00000000000000</c>), a
/// prior narrower reading corrects here. Retail's CRT <c>rand()</c>
/// returns <c>[0, RAND_MAX]</c> with <c>RAND_MAX == 0x7FFF == 32767</c>
/// (MSVC), so the shade roll is a 32768-point lattice on
/// <c>[0.0, 1.0]</c> INCLUSIVE (both endpoints reachable) —
/// <see cref="Random.NextDouble()"/>'s continuous <c>[0, 1)</c> is a
/// different distribution entirely.
/// </summary>
private double RollShadeLocked() => _random.Next(32768) * (1.0 / 32767.0);
/// <summary>Ports <c>CharGenState::RandomizeAppearance(this, 0) @
/// 0x005c4f10</c> — every real call site in the retail binary passes
/// <c>arg2 == 0</c> (an exhaustive grep of every <c>RandomizeAppearance</c>
@ -1261,8 +1289,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// code and is not ported. Each field is only rolled when its list is
/// non-empty (retail's own per-field <c>if (count != 0)</c> guards);
/// <c>skinShade</c>/<c>hairShade</c> are <c>vtable-&gt;GetRandomReal()</c>
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
/// other Randomize* function below uses explicitly inline.</summary>
/// — the SAME <see cref="RollShadeLocked"/> shade roll every other
/// Randomize* function below uses.</summary>
private void RandomizeAppearanceLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
@ -1275,7 +1303,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) };
if (gender.MouthStrips.Count > 0)
a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) };
a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() };
a = a with { SkinShade = RollShadeLocked(), HairShade = RollShadeLocked() };
if (gender.HairColors.Count > 0)
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
if (gender.EyeColors.Count > 0)
@ -1328,7 +1356,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
};
}
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
_appearance = _appearance with { HeadgearShade = RollShadeLocked() };
}
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
@ -1354,7 +1382,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
};
}
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
_appearance = _appearance with { ShirtShade = RollShadeLocked() };
}
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
@ -1378,7 +1406,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
};
}
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
_appearance = _appearance with { TrousersShade = RollShadeLocked() };
}
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
@ -1402,7 +1430,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
};
}
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
_appearance = _appearance with { FootwearShade = RollShadeLocked() };
}
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
@ -1486,6 +1514,18 @@ public sealed class RuntimeCharacterCreationState : IDisposable
ClearSessionState();
uint heritageId = (uint)RollDiceLocked(1, 4);
// Review fix round F14 (2026-08-16): retail's SetHeritageGroup @
// 0x005C67A0 writes `this->mHeritageGroup = arg2;` UNCONDITIONALLY
// as its very first statement, before the DAT lookup
// (ACCharGenData::GetHG) that gates the credit/template/start-area
// recompute. Assign the raw field here too, before the
// TryGetHeritage gate below, so a hypothetical DAT-lookup miss
// leaves `_heritageId` set (matching retail's unconditional write)
// instead of the previous half-state where heritage stayed 0 while
// SetGenderLocked below still ran and produced a real gender.
// Unreachable with the installed DAT — every rolled id 1..4 always
// resolves — this is defensive shape-parity only.
_heritageId = heritageId;
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
SetHeritageGroupLocked(heritageId, heritage);
@ -1765,11 +1805,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
}
/// <summary>
/// Ports the four rejection dialog mappings + the silent
/// Pending/Undef reset from <c>Handle_CharGenVerificationResponse @
/// 0x0055E8B0</c>. Idempotent-tolerant to a second, unrequested Ok/reject
/// while nothing is pending (ACE's own double-NameInUse quirk, CC2
/// review F3) — a call that arrives while
/// Ports the full rejection-dialog dispatch from
/// <c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c> +
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
/// 0x004e9030</c>'s own jump table. CC5 review-fix round F2
/// (2026-08-16): every non-Ok code produces a
/// <see cref="RuntimeCharacterCreationRejection"/> — there is no silent
/// branch. Pending previously short-circuited as a bare state reset
/// with no rejection produced; that was a misreading of the decomp
/// (Pending is an explicit switch case in
/// <c>RecvNotice_CharGenVerificationResponse</c> landing on the SAME
/// <c>ID_Character_Err_NameDBDown</c> label as Corrupt/DatabaseDown,
/// and Undef/any out-of-range code falls through that function's own
/// <c>(arg2-1) &gt; 6</c> unsigned-underflow default arm to the
/// identical label) — see the <c>else</c> branch's own inline comment
/// for the full citation. Idempotent-tolerant to a second, unrequested
/// Ok/reject while nothing is pending (ACE's own double-NameInUse
/// quirk, CC2 review F3) — a call that arrives while
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
/// already false is a no-op rather than a second event.
///
@ -1777,13 +1829,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// Campaign CC slice CC3 review-fix round (F6): every branch below only
/// SETS <c>kind</c> inside <c>lock (_gate)</c>; the single
/// <see cref="Publish"/> call happens once, after the lock releases —
/// matching every other public method in this class. The Pending/Undef
/// branch previously published from inside the lock (harmless on its
/// own — <see cref="Publish"/>'s own <c>lock (_gate)</c> is reentrant on
/// the same thread — but inconsistent with the rest of the class and a
/// lock-ordering risk once an observer callback reaches back into
/// caller-held locks, e.g. <c>LiveSessionController._gate</c>, while
/// still inside this one).
/// matching every other public method in this class.
/// </para>
/// </summary>
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
@ -1803,15 +1849,29 @@ public sealed class RuntimeCharacterCreationState : IDisposable
_lastRejection = null;
kind = RuntimeCharacterCreationDeltaKind.Created;
}
else if (response.AsCode is CharGenVerificationResponse.Code.Pending
or CharGenVerificationResponse.Code.Undef)
{
// Silent state reset — retail shows no dialog (ACE sends
// Pending for a disabled-Olthoi rejection; port as-is).
kind = RuntimeCharacterCreationDeltaKind.StateChanged;
}
else
{
// Review fix round F2 (2026-08-16): Pending/Undef used to
// short-circuit here as a silent state reset with no
// rejection produced — that was WRONG. Byte-decoded
// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse
// @0x004e9030's own dispatch: Pending(2) is an explicit
// switch case landing on the SAME "ID_Character_Err_
// NameDBDown" label as Corrupt/DatabaseDown, and Undef(0)
// (plus any code outside 1..7) falls through the function's
// own "(arg2-1) > 6" unsigned-underflow default arm to that
// identical label — retail's dispatch has NO silent branch
// at all; every non-Ok code shows a dialog. Falling through
// to this general rejection branch (instead of a special
// silent-reset arm) now produces a real
// RuntimeCharacterCreationRejection for Pending/Undef too,
// which CharacterCreationUiController.ReconcileDialogs maps
// to that same NameDBDown dialog (see its own doc comment).
// Concrete effect: ACE's disabled-Olthoi Pending rejection
// (CharacterHandler.CharacterCreateEx's olthoi_play_disabled
// branch) now surfaces a visible dialog instead of silently
// resetting verification state with Finish becoming a
// permanent no-op.
string reason = response.AsCode.ToString();
_lastRejection = new RuntimeCharacterCreationRejection(
response.RawCode,