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

@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests
CharacterManagementUiController.RestoreElementId);
Assert.True(create.Visible);
Assert.False(create.Enabled);
Assert.Null(create.OnClick);
// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @
// 0x004ec240's Create gate — 3 characters against SlotCount 5.
Assert.True(create.Enabled);
Assert.NotNull(create.OnClick);
create.OnClick!();
Assert.Equal(1, environment.Runtime.RequestCreateCalls);
Assert.True(enter.Enabled);
Assert.True(delete.Visible);
Assert.True(delete.Enabled);
@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests
Assert.True(restore.Enabled);
}
/// <summary>
/// Campaign CC slice CC7: <c>gmCharacterManagementUI::UpdateButtons @
/// 0x004ec240</c>'s Create gate (~0x004ec319-0x004ec32e) is purely
/// <c>_charSet.set_.m_num &lt; _charSet.numAllowedCharacters_</c> — a
/// full roster (roster count == the allowed-slot ceiling) ghosts Create
/// exactly like retail, and refilling below the ceiling un-ghosts it
/// again on the next Tick.
/// </summary>
[Fact]
public void CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton create = environment.Button(
CharacterManagementUiController.CreateElementId);
// The fixture's SlotCount is 5 — five characters exactly fills it.
RuntimeCharacterSelectionEntry[] full = Enumerable.Range(0, 5)
.Select(index => new RuntimeCharacterSelectionEntry(
index,
(uint)(0x50000200 + index),
$"Full {index:D2}",
0u))
.ToArray();
environment.Runtime.ReplaceRoster(full, highlightedCharacterId: full[0].CharacterId);
controller.Tick();
Assert.True(create.Visible);
Assert.False(create.Enabled);
RuntimeCharacterSelectionEntry[] belowCeiling = full[..4];
environment.Runtime.ReplaceRoster(
belowCeiling,
highlightedCharacterId: belowCeiling[0].CharacterId);
controller.Tick();
Assert.True(create.Enabled);
}
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests
ConfirmDelete,
Restore,
Cancel,
RequestExit);
RequestExit,
RequestCreate);
}
public FakeView View { get; } = new();
@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests
public int CancelCalls { get; private set; }
public int RestoreCalls { get; private set; }
public int RequestExitCalls { get; private set; }
public int RequestCreateCalls { get; private set; }
/// <summary>Campaign CC slice CC7: the fixture's fixed allowed-slot
/// ceiling — mirrors <c>Snapshot</c>'s own hard-coded
/// <c>SlotCount: 5</c> so <see cref="ButtonsFor"/> computes the SAME
/// roster-vs-slot gate the real <c>RuntimeCharacterSelectionState.BuildButtons</c>
/// does, instead of a fixture-only shortcut.</summary>
private const int SlotCount = 5;
public RuntimeCommandStatus RestoreStatus { get; set; } =
RuntimeCommandStatus.Accepted;
public bool ThrowOnRestore { get; set; }
@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests
RuntimeCharacterSelectionButtons buttons = operation is
RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
? RuntimeCharacterSelectionButtons.None
? RuntimeCharacterSelectionButtons.None with
{ CanCreate = View.Entries.Length < SlotCount }
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
Update(snapshot => snapshot with
{
@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests
{
PendingDeleteCharacterId = 0u,
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
Buttons = RuntimeCharacterSelectionButtons.None,
Buttons = RuntimeCharacterSelectionButtons.None with
{ CanCreate = View.Entries.Length < SlotCount },
});
return Result(RuntimeCommandStatus.Accepted, id);
}
@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests
false,
false,
false,
true),
true,
View.Entries.Length < SlotCount),
});
AfterRestoreProjection?.Invoke();
return Result(RuntimeCommandStatus.Accepted, id);
@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests
private void RequestExit() => RequestExitCalls++;
private void RequestCreate() => RequestCreateCalls++;
/// <summary>Campaign CC slice CC7: mirrors
/// <c>RuntimeCharacterSelectionState.BuildButtons</c>'s own
/// unconditional <c>CanCreate</c> computation — roster length
/// against <see cref="SlotCount"/> — so every branch below carries
/// the SAME real gate the production state machine does, not a
/// fixture-only shortcut.</summary>
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
{
bool canCreate = View.Entries.Length < SlotCount;
RuntimeCharacterSelectionEntry? selected = View.Entries
.Cast<RuntimeCharacterSelectionEntry?>()
.FirstOrDefault(entry => entry?.CharacterId == characterId);
if (selected is null)
return RuntimeCharacterSelectionButtons.None;
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
if (selected.Value.IsPendingDelete)
{
return new RuntimeCharacterSelectionButtons(
@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests
false,
true,
false,
true);
true,
canCreate);
}
return new RuntimeCharacterSelectionButtons(
true,
true,
false,
true,
false);
false,
canCreate);
}
private void Update(

View file

@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
Assert.Null(environment.Host.FixedCanvasSize);
}
/// <summary>
/// Campaign CC slice CC7 item 1: the real Create-button wire — retail's
/// <c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>QueueUIMode(0x1000000b)</c> — and the return path on
/// chargen Exit (<c>DoExit @ 0x004e8650</c> -&gt;
/// <c>QueueUIMode(0x1000000a)</c>). Character-management is never
/// hidden by chargen opening on top of it (see the canvas-arbiter test
/// above), so "return to character management" needs no separate
/// Runtime action beyond chargen's own <c>Close()</c> — this proves
/// that architecture claim end to end rather than by inspection alone.
/// </summary>
[Fact]
public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement()
{
using var environment = new TwoControllerHarness();
// Character-management is active and visible before Create is ever
// clicked (AttachAndTick already ran in its own harness ctor).
Assert.True(environment.Management.Controller.Root.Visible);
Assert.False(environment.Chargen.Controller.Root.Visible);
UiButton create = environment.Management.Button(
CharacterManagementUiController.CreateElementId);
Assert.True(create.Enabled);
create.OnClick!();
environment.Chargen.Controller.Tick();
Assert.True(environment.Chargen.Controller.Root.Visible);
// Character-management stays active/visible underneath -- chargen
// opening on top never deactivates or hides it.
Assert.True(environment.Management.Controller.Root.Visible);
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
.OnClick!();
environment.Chargen.ConfirmActiveDialog(confirmed: true);
Assert.False(environment.Chargen.Controller.Root.Visible);
// No separate "return" action was needed -- management was never
// hidden, so it is simply what remains visible.
Assert.True(environment.Management.Controller.Root.Visible);
}
// ── Fixture: one shared UiRoot, both controllers ────────────────────
private sealed class TwoControllerHarness : IDisposable
@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
public TwoControllerHarness()
{
Host = new UiRoot { Width = 800f, Height = 600f };
Management = new ManagementHarness(Host);
// Campaign CC slice CC7: chargen must exist FIRST so
// ManagementHarness can wire its Create button straight to the
// real CharacterCreationUiController.Open() — the same shape
// RetailUiRuntime.ConfigureCharacterManagement() uses in
// production (a lazily-resolved lambda closing over the OTHER
// controller, since bindings are always built before both
// controllers exist).
Chargen = new ChargenHarness(Host);
Management = new ManagementHarness(Host, Chargen.Controller.Open);
}
public UiRoot Host { get; }
@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
{
private readonly RetailDialogFactory _dialogs;
public ManagementHarness(UiRoot host)
public ManagementHarness(UiRoot host, Action requestCreate)
{
ImportedLayout screen = BuildManagementScreen();
Runtime = new ManagementFakeRuntime();
Screen = BuildManagementScreen();
Runtime = new ManagementFakeRuntime(requestCreate);
_dialogs = new RetailDialogFactory(
host,
type => RetailDialogFactoryTests.BuildDialogLayout(type));
Controller = Assert.IsType<CharacterManagementUiController>(
CharacterManagementUiController.Bind(
host,
screen,
Screen,
static (_, _) => BuildRow(),
_dialogs,
Runtime.Bindings,
@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
"Are you sure you want to leave?")));
}
public ImportedLayout Screen { get; }
public ManagementFakeRuntime Runtime { get; }
public CharacterManagementUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public void Dispose()
{
Controller.Dispose();
@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
private static readonly RuntimeGenerationToken Generation = new(11u);
private readonly FakeManagementView _view = new();
public ManagementFakeRuntime()
public ManagementFakeRuntime(Action requestCreate)
{
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false));
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
Bindings = new CharacterSelectionRuntimeBindings(
View: () => _view,
Highlight: _ => Result(),
@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
ConfirmDelete: Result,
Restore: Result,
Cancel: Result,
RequestExit: () => { });
RequestExit: () => { },
RequestCreate: requestCreate);
}
public CharacterSelectionRuntimeBindings Bindings { get; }