feat(chargen): Campaign CC slice CC7 — end-to-end create flow + connected checklist

Create button un-ghosts: retail's exact gate (gmCharacterManagementUI::
UpdateButtons @0x004ec240, roster count < allowed slot count) ported into
RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the
chargen screen through the same CharacterCreationUiController.Open() seam
the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on
chargen needed no new return-path code — character-management is never
hidden while chargen is open on top of it — verified end-to-end by a new
cross-controller test rather than left as an inspection claim.

Full-flow test coverage: a new comprehensive test decodes every 0xF656
field (including the trailing checksum, recomputed via the production
CharacterCreate.ComputeChecksum) against a fully populated creation
(heritage/gender/all appearance slots/template/explicit skill command/
town/name); a new Theory drives the remaining six 0xF643 rejection codes
through the real wire decode path, closing the gap between the
already-covered isolated state-machine Theory and an actual WorldSession
round trip.

Launcher payload cycle: two new tests drive a real Runtime create/reject
through the real SessionStatusWriter (wired exactly as
LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read
the result back with the real Launcher.Core StatusFileTailer/
StatusEventParser — closing the one gap CC2's own per-layer tests never
reached. No gap was found in production wiring itself: GameWindow already
constructs a real, non-null SessionStatusWriter for both hosts.

Also fixes 4 pre-existing LiveSessionControllerTests assertions that
compared a full RuntimeCharacterSelectionButtons record and would have
failed once CanCreate started being computed; corrects register row
AP-211 to reflect that its own predicted resolution (the Create-button
gate landing) has now happened — both layers are intentionally kept as
retail-matching enforcement plus defense-in-depth, not one superseding
the other.

Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's
connected-gate script covering both the launcher and dev-shortcut launch
paths, the six-page create flow, every Finish outcome, and the known
cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/
224/226/228) so they aren't mistaken for new bugs during the gate.

Gates: full solution Release build green; Runtime 1735/0 (was 1726/0,
+9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged),
Launcher.Core 324/0, one full-solution pass across every project clean
(no known flakes reproduced this run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 02:35:21 +02:00
parent bb22ee8bde
commit 9cf6c52283
10 changed files with 957 additions and 37 deletions

View file

@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons(
bool CanDelete,
bool CanRestore,
bool DeleteVisible,
bool RestoreVisible)
bool RestoreVisible,
/// <summary>
/// Campaign CC slice CC7: retail's Create-character gate
/// (<c>gmCharacterManagementUI::UpdateButtons @ 0x004ec240</c>,
/// ~0x004ec319-0x004ec32e) — unconditional on selection, purely
/// <c>_charSet.set_.m_num &lt; _charSet.numAllowedCharacters_</c> (the
/// live roster count against the allowed-slot ceiling). Mirrors
/// <see cref="RuntimeCharacterSelectionSnapshot.RosterCount"/> &lt;
/// <see cref="RuntimeCharacterSelectionSnapshot.SlotCount"/>.
/// </summary>
bool CanCreate = false)
{
public static RuntimeCharacterSelectionButtons None { get; } =
new(false, false, false, true, false);
@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex)
{
// gmCharacterManagementUI::UpdateButtons @ 0x004ec240's Create gate
// is unconditional on the delete/selection state below it — it only
// ever compares the live roster count against the allowed-slot
// ceiling (~0x004ec319-0x004ec32e:
// `if (_charSet.set_.m_num < _charSet.numAllowedCharacters_)
// SetState(1); else SetState(0xd);`).
bool canCreate = _entries.Length < _slotCount;
if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
{
return RuntimeCharacterSelectionButtons.None;
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
}
if (selectedIndex < 0)
return RuntimeCharacterSelectionButtons.None;
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
if (selected.IsPendingDelete)
@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
CanDelete: false,
CanRestore: !_restoreResponseArmed,
DeleteVisible: false,
RestoreVisible: true);
RestoreVisible: true,
CanCreate: canCreate);
}
return new RuntimeCharacterSelectionButtons(
@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
CanDelete: selected.CanEnter,
CanRestore: false,
DeleteVisible: true,
RestoreVisible: false);
RestoreVisible: false,
CanCreate: canCreate);
}
private int FindDisplayIndex(uint characterId)