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; }

View file

@ -18,5 +18,12 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
<!-- Campaign CC slice CC7: test-only cross-assembly reference for the
Runtime-state-transition -> SessionStatusWriter -> Launcher.Core
StatusFileTailer end-to-end assertion (mirrors the LA1+LA3
precedent of a cross-assembly test enforcing a shared contract).
AcDream.Runtime itself does not, and must not, reference
AcDream.Launcher.Core. -->
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -6,6 +6,7 @@ using AcDream.Core.CharGen;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Launcher.Core.Status;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Runtime.Tests.CharGen;
@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
/// <summary>
/// Campaign CC slice CC7 item 3: when set, forwards exactly the way
/// production hosts do (<c>LiveSessionRuntimeFactory.Create</c>'s
/// own <c>CharacterCreated</c>/<c>CreationFailed</c> delegates,
/// <c>HeadlessSessionHost</c>'s identical pair) — the SAME real
/// <see cref="SessionStatusWriter"/> a launcher-composed session
/// would use, not a re-implemented shape.
/// </summary>
public SessionStatusWriter? Writer { get; set; }
public string SessionId { get; set; } = "s1";
public LiveSessionBinding BindSession(WorldSession session) =>
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
EnteredWorld.Add(selection);
public void DetachSession(WorldSession session) { }
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
{
Created.Add(identity);
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name);
}
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection)
{
Failed.Add(rejection);
Writer?.CreationFailed(
SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName);
}
}
private static LiveSessionConnectOptions LiveOptions() => new(
@ -399,6 +418,259 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
}
/// <summary>
/// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the
/// campaign closeout asks for — a fully populated creation (heritage,
/// gender, appearance across every one of the fourteen style/color
/// slots and all six shades, template, an EXPLICIT skill command beyond
/// what the template alone applies, an explicit town/start-area
/// selection, name) sent through a REAL <see cref="WorldSession"/>, then
/// decoded field-by-field — including the trailing checksum, which the
/// pre-existing <see cref="Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName"/>
/// test above never checked — against exactly the shape
/// <c>CharacterCreateInfo.Unpack</c>/<c>Appearance.Unpack</c> parse (see
/// <see cref="CharacterCreate"/>'s own doc comment for the ACE
/// cross-reference). The checksum is recomputed via the SAME production
/// <see cref="CharacterCreate.ComputeChecksum"/> formula rather than
/// re-deriving the sum a second time by hand in the test.
/// </summary>
[Fact]
public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape()
{
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
StartAwaitingSelection();
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted);
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted);
// Explicit skill command beyond the template's own Normal/Primary
// lists (SkillFreeTrained costs 0 to train — no credit-budget risk).
Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted);
// Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)].
Assert.True(controller.SelectStartArea(generation, 1).Accepted);
Assert.True(controller.SetName(generation, "FullChar").Accepted);
WorldSession session = operations.Sessions[0];
byte[]? captured = null;
session.GameMessageCapture = (body, _) => captured = body;
Assert.True(controller.Finish(generation).Accepted);
Assert.NotNull(captured);
DecodedFullRequest decoded = DecodeCreateRequestFull(captured!);
Assert.Equal("testaccount", decoded.AccountName);
Assert.Equal(1u, decoded.Constant);
CharacterCreate.Request r = decoded.Request;
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage);
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender);
Assert.Equal(0u, r.Appearance.EyesStrip);
Assert.Equal(0u, r.Appearance.NoseStrip);
Assert.Equal(0u, r.Appearance.MouthStrip);
Assert.Equal(1u, r.Appearance.HairColor);
Assert.Equal(1u, r.Appearance.EyeColor);
Assert.Equal(1u, r.Appearance.HairStyle);
Assert.Equal(0u, r.Appearance.HeadgearStyle);
Assert.Equal(2u, r.Appearance.HeadgearColor);
Assert.Equal(0u, r.Appearance.ShirtStyle);
Assert.Equal(1u, r.Appearance.ShirtColor);
Assert.Equal(0u, r.Appearance.TrousersStyle);
Assert.Equal(0u, r.Appearance.TrousersColor);
Assert.Equal(0u, r.Appearance.FootwearStyle);
Assert.Equal(2u, r.Appearance.FootwearColor);
Assert.Equal(0.25, r.Appearance.SkinShade);
Assert.Equal(0.5, r.Appearance.HairShade);
Assert.Equal(0.75, r.Appearance.HeadgearShade);
Assert.Equal(0.1, r.Appearance.ShirtShade);
Assert.Equal(0.9, r.Appearance.TrousersShade);
Assert.Equal(0.6, r.Appearance.FootwearShade);
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template);
Assert.Equal(16u, r.Attributes.Strength);
Assert.Equal(10u, r.Attributes.Endurance);
Assert.Equal(10u, r.Attributes.Coordination);
Assert.Equal(10u, r.Attributes.Quickness);
Assert.Equal(10u, r.Attributes.Focus);
Assert.Equal(10u, r.Attributes.Self);
Assert.Equal(0u, r.Slot);
// classId: register AP-209's documented placeholder — ACE ignores
// this field (retail's DAT DID lookup has no Core equivalent).
Assert.Equal(0u, r.ClassId);
Assert.Equal(
(uint)CharacterCreate.SkillAdvancementClassCount,
(uint)decoded.SkillAdvancementClasses.Length);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Trained,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Specialized,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Trained,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]);
Assert.Equal("FullChar", r.Name);
Assert.Equal(1u, r.StartArea);
Assert.False(r.IsAdmin);
Assert.False(r.IsEnvoy);
// The trailing checksum (CG_Pack@0x005c74c3's final store) — never
// read by ACE, sent for byte fidelity with a genuine retail client.
Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum);
}
/// <summary>
/// Campaign CC slice CC7 item 2: the remaining <c>0xF643</c> rejection
/// codes beyond NameInUse (already covered by
/// <see cref="Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection"/>
/// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection
/// instead of a silent reset) holds over the REAL wire decode path, not
/// just the isolated state-machine
/// <c>RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...</c>
/// theory.
/// </summary>
[Theory]
[InlineData(CharGenVerificationResponse.Code.Pending)]
[InlineData(CharGenVerificationResponse.Code.NameBanned)]
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
[InlineData(CharGenVerificationResponse.Code.Undef)]
public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect(
CharGenVerificationResponse.Code code)
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty));
Assert.Single(host.Failed);
Assert.Equal((uint)code, host.Failed[0].RawCode);
Assert.Equal(code, host.Failed[0].Code);
Assert.Equal(code.ToString(), host.Failed[0].Reason);
Assert.Equal("NewChar", host.Failed[0].AttemptedName);
Assert.Empty(host.Created);
Assert.False(controller.IsInWorld);
Assert.Empty(operations.EnterWorldByGuidCalls);
Assert.Empty(host.EnteredWorld);
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
}
/// <summary>
/// Campaign CC slice CC7 item 3: the launcher payload cycle end to end —
/// a REAL Runtime state transition (Finish -&gt; real
/// <see cref="WorldSession"/> -&gt; real inbound <c>0xF643</c> Ok reply)
/// through the REAL <see cref="SessionStatusWriter"/>, wired exactly the
/// way <c>LiveSessionRuntimeFactory.Create</c> (App host) and
/// <c>HeadlessSessionHost</c> wire it (see <see cref="TestHost"/>'s own
/// doc comment), to the REAL Launcher.Core
/// <see cref="StatusFileTailer"/>'s parsed event. This is the piece
/// CC2's own tests never reached: <c>SessionStatusWriterTests</c> calls
/// the writer directly and asserts raw JSON; <c>StatusEventParserTests</c>
/// parses a hand-written JSON literal; neither drives a create through
/// Runtime first, so a wiring gap between Runtime's own state machine
/// and the writer (or between the writer's bytes and the tailer's
/// parser) would not have been caught by either.
/// </summary>
[Fact]
public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer()
{
string path = Path.Combine(
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
try
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
host.Writer = new SessionStatusWriter(path);
host.SessionId = "cc7-session";
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket(
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
// Runtime's own side of the contract already fired.
Assert.Single(host.Created);
var tailer = new StatusFileTailer(path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
CharacterCreatedStatusEvent created =
Assert.Single(events.OfType<CharacterCreatedStatusEvent>());
Assert.Equal("cc7-session", created.SessionId);
Assert.Equal(0x50001234u, created.Guid);
Assert.Equal("NewChar", created.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
/// <summary>Sibling of the Ok test above for the non-Ok half of the
/// contract (<c>creationFailed{code,reason,name}</c>).</summary>
[Fact]
public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer()
{
string path = Path.Combine(
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
try
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
host.Writer = new SessionStatusWriter(path);
host.SessionId = "cc7-session";
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket(
(uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty));
Assert.Single(host.Failed);
var tailer = new StatusFileTailer(path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
CreationFailedStatusEvent failed =
Assert.Single(events.OfType<CreationFailedStatusEvent>());
Assert.Equal("cc7-session", failed.SessionId);
Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("NewChar", failed.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(
@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests
accountName, heritage, gender, template, strength, name, numSkills, skills);
}
/// <summary>Campaign CC slice CC7: the FULL field set — every byte of
/// <see cref="CharacterCreate.BuildRequestBody"/>'s layout, unlike
/// <see cref="CapturedCreateRequest"/>/<see cref="DecodeCreateRequest"/>
/// above which only samples a handful of fields.</summary>
private readonly record struct DecodedFullRequest(
string AccountName,
uint Constant,
CharacterCreate.Request Request,
uint[] SkillAdvancementClasses,
uint Checksum);
private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
Assert.Equal(CharacterCreate.Opcode, opcode);
string accountName = ReadString16L(body, ref pos);
uint constant = ReadU32(body, ref pos);
uint heritage = ReadU32(body, ref pos);
uint gender = ReadU32(body, ref pos);
uint eyesStrip = ReadU32(body, ref pos);
uint noseStrip = ReadU32(body, ref pos);
uint mouthStrip = ReadU32(body, ref pos);
uint hairColor = ReadU32(body, ref pos);
uint eyeColor = ReadU32(body, ref pos);
uint hairStyle = ReadU32(body, ref pos);
uint headgearStyle = ReadU32(body, ref pos);
uint headgearColor = ReadU32(body, ref pos);
uint shirtStyle = ReadU32(body, ref pos);
uint shirtColor = ReadU32(body, ref pos);
uint trousersStyle = ReadU32(body, ref pos);
uint trousersColor = ReadU32(body, ref pos);
uint footwearStyle = ReadU32(body, ref pos);
uint footwearColor = ReadU32(body, ref pos);
double skinShade = ReadF64(body, ref pos);
double hairShade = ReadF64(body, ref pos);
double headgearShade = ReadF64(body, ref pos);
double shirtShade = ReadF64(body, ref pos);
double trousersShade = ReadF64(body, ref pos);
double footwearShade = ReadF64(body, ref pos);
uint template = ReadU32(body, ref pos);
uint strength = ReadU32(body, ref pos);
uint endurance = ReadU32(body, ref pos);
uint coordination = ReadU32(body, ref pos);
uint quickness = ReadU32(body, ref pos);
uint focus = ReadU32(body, ref pos);
uint self = ReadU32(body, ref pos);
uint slot = ReadU32(body, ref pos);
uint classId = ReadU32(body, ref pos);
uint numSkills = ReadU32(body, ref pos);
var skills = new uint[numSkills];
for (int i = 0; i < numSkills; i++)
skills[i] = ReadU32(body, ref pos);
string name = ReadString16L(body, ref pos);
uint startArea = ReadU32(body, ref pos);
uint isAdmin = ReadU32(body, ref pos);
uint isEnvoy = ReadU32(body, ref pos);
uint checksum = ReadU32(body, ref pos);
// Nothing left over, nothing missing — the layout is exhaustive.
Assert.Equal(body.Length, pos);
var request = new CharacterCreate.Request(
heritage,
gender,
new CharacterCreate.Appearance(
eyesStrip,
noseStrip,
mouthStrip,
hairColor,
eyeColor,
hairStyle,
headgearStyle,
headgearColor,
shirtStyle,
shirtColor,
trousersStyle,
trousersColor,
footwearStyle,
footwearColor,
skinShade,
hairShade,
headgearShade,
shirtShade,
trousersShade,
footwearShade),
template,
new CharacterCreate.Attributes(
strength, endurance, coordination, quickness, focus, self),
slot,
classId,
name,
startArea,
isAdmin != 0u,
isEnvoy != 0u);
return new DecodedFullRequest(accountName, constant, request, skills, checksum);
}
private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
{
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));

View file

@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// Campaign CC slice CC7: this fixture's roster (2 characters) is
// below its SlotCount (11), so retail's Create gate
// (gmCharacterManagementUI::UpdateButtons) stays enabled through
// the whole delete-request/acknowledge sequence below — CanCreate
// is independent of the delete-in-flight buttons this test is
// actually pinning.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
if (acknowledgeBeforeCompletion)
@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.True(controller.CharacterSelection.TryGet(
0x50000001u,
@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}
@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal(
RuntimeCommandStatus.Rejected,
@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}