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

@ -125,12 +125,16 @@ internal sealed class CharacterManagementUiController : IDisposable
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
// Create Character belongs to a future campaign. Keep retail's
// authored control in place and visibly ghosted; do not hide it or
// invent an action.
// Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
// @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
// (QueueUIMode(0x1000000b) — no gate at click time); the gate lives
// entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state
// (see ApplyButtons below), so the click handler is wired once here
// and Enabled tracks the borrowed snapshot every tick. Starts
// disabled/ghosted until the first real snapshot arrives.
_create.Visible = true;
_create.Enabled = false;
_create.OnClick = null;
_create.OnClick = RequestCreate;
_enter.OnClick = EnterSelected;
_delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected;
@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
{
_create.Visible = true;
_create.Enabled = false;
_create.Enabled = buttons.CanCreate;
_enter.Enabled = buttons.CanEnter;
_delete.Visible = buttons.DeleteVisible;
_delete.Enabled = buttons.CanDelete;
@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable
InvalidateAndTick();
}
/// <summary>
/// Campaign CC slice CC7: <c>ListenToElementMessage</c> case 3 ->
/// <c>QueueUIMode(0x1000000b)</c>. Purely presentational — no Runtime
/// command, no roster/state change here; <see cref="_bindings"/>'
/// <c>RequestCreate</c> is resolved per-call (never captured) so it
/// reflects whatever <see cref="RetailUiRuntime"/> wired at the time of
/// the click, matching every other late-bound seam in this bindings
/// record.
/// </summary>
private void RequestCreate()
{
if (_disposed)
return;
_bindings.RequestCreate?.Invoke();
}
private void EnterSelected()
{
if (_disposed)

View file

@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings(
Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> Cancel,
Action RequestExit);
Action RequestExit,
/// <summary>
/// Campaign CC slice CC7: retail's Create button
/// (<c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>UIFramework::QueueUIMode(this, 0x1000000b)</c>, the
/// <c>gmCharGenMainUI</c> mode). Wired by <see cref="RetailUiRuntime"/>
/// itself (it alone holds both the character-management and
/// character-creation controllers) to
/// <c>CharacterCreationController?.Open()</c> — resolved lazily so
/// mount order between the two screens does not matter.
/// <see langword="null"/> when no chargen screen is mounted (e.g. a
/// headless bot's <c>LiveCharacterSelector</c> path, where
/// <see cref="RetailUiRuntimeBindings.CharacterCreation"/> is also
/// null) — the button then behaves as a no-op click while its own
/// <c>Enabled</c> gate (<see cref="RuntimeCharacterSelectionButtons.CanCreate"/>)
/// still reflects the real roster-vs-slot state.
/// </summary>
Action? RequestCreate = null);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable
if (bindings is null || _characterManagementMount is not null)
return;
// Campaign CC slice CC7: RetailUiRuntime is the one object holding
// BOTH controllers, so it supplies the cross-screen seam locally
// rather than routing it through the externally-composed bindings
// record (which is built before this runtime exists — see
// CharacterSelectionRuntimeBindings.RequestCreate's own doc
// comment). The lambda closes over `this` and reads
// CharacterCreationController per call, so it is safe even though
// ConfigureCharacterCreation() has not run yet at this point (see
// its call site immediately below this method's own caller).
_characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root,
bindings,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
LoadCharacterManagementResources);
}

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)