Merge campaign-cc2: CC2 CharacterCreate wire, review-closed

CC2 review PASS (checksum term set confirmed against the CG_Pack
accumulator; account-outside-body and GetPackSize=172 independently
proven), fix round e77ebf10 (F1 latch scope + pin test, AD-100, ACE
double-NameInUse note, creationFailed reason/name split, pointer fix,
retail-discriminator citations), narrow re-review CLOSED, residual
anchor fix 95e95bb6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 13:39:08 +02:00
commit 55fc51ed8c
17 changed files with 1660 additions and 41 deletions

View file

@ -0,0 +1,110 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign CC CC2: the shared 0xF643 parser both CharacterRestore and
/// CharacterCreate responses consume. See <see cref="CharacterRestoreTests"/>
/// for the pre-existing CharacterRestore-shaped coverage that must survive
/// this type's promotion unchanged.
/// </summary>
public sealed class CharGenVerificationResponseTests
{
[Fact]
public void Parse_Ok_PopulatesIdentityPayload()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write((uint)CharGenVerificationResponse.Code.Ok)
.WriteGuid(0x5000000Bu)
.WriteString16L("+NewChar")
.Write(0u);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(1u, parsed.RawCode);
Assert.Equal(CharGenVerificationResponse.Code.Ok, parsed.AsCode);
Assert.True(parsed.IsOk);
Assert.Equal(0x5000000Bu, parsed.Guid);
Assert.Equal("+NewChar", parsed.Name);
Assert.Equal(0u, parsed.SecondsGreyedOut);
}
[Theory]
[InlineData(0u, CharGenVerificationResponse.Code.Undef)]
[InlineData(2u, CharGenVerificationResponse.Code.Pending)]
[InlineData(3u, CharGenVerificationResponse.Code.NameInUse)]
[InlineData(4u, CharGenVerificationResponse.Code.NameBanned)]
[InlineData(5u, CharGenVerificationResponse.Code.Corrupt)]
[InlineData(6u, CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(7u, CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
public void Parse_EveryNonOkCode_IsFlagOnlyWithNullTrailingFields(
uint rawCode,
CharGenVerificationResponse.Code expectedCode)
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(rawCode);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(rawCode, parsed.RawCode);
Assert.Equal(expectedCode, parsed.AsCode);
Assert.False(parsed.IsOk);
Assert.Null(parsed.Guid);
Assert.Null(parsed.Name);
Assert.Null(parsed.SecondsGreyedOut);
}
[Fact]
public void Parse_UnknownCode_NeverThrowsOnTheCast()
{
// A plain enum cast never throws in C# — a private-server or future
// retail revision sending a code we haven't named yet must not crash
// the parser.
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(99u);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(99u, parsed.RawCode);
Assert.Equal((CharGenVerificationResponse.Code)99u, parsed.AsCode);
Assert.False(parsed.IsOk);
}
[Fact]
public void Parse_WrongOpcode_Throws()
{
byte[] bytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(bytes));
}
[Fact]
public void Parse_TruncatedAfterCode_Throws()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write((uint)CharGenVerificationResponse.Code.Ok);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(w.ToArray()));
}
[Fact]
public void Parse_TruncatedBeforeCode_Throws()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(w.ToArray()));
}
[Fact]
public void ResponseOpcode_MatchesCharacterRestoresResponseOpcode()
{
// The whole point of this type: both families collide on the exact
// same wire opcode.
Assert.Equal(CharacterRestore.ResponseOpcode, CharGenVerificationResponse.ResponseOpcode);
}
}

View file

@ -0,0 +1,308 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign CC CC2: byte-exact coverage for the outbound CharacterCreate
/// (0xF656) builder — field order, the 55-slot skill-advancement invariant,
/// and the trailing checksum's exact retail accumulation set (see
/// <see cref="CharacterCreate"/>'s class doc comment for the decompiled
/// source of truth).
/// </summary>
public sealed class CharacterCreateTests
{
private static uint[] MakeSkills(uint seed = 0)
{
var skills = new uint[CharacterCreate.SkillAdvancementClassCount];
for (int i = 0; i < skills.Length; i++)
skills[i] = seed + (uint)i;
return skills;
}
private static CharacterCreate.Request MakeRequest() => new(
Heritage: 1u,
Gender: 0u,
Appearance: new CharacterCreate.Appearance(
EyesStrip: 2u,
NoseStrip: 3u,
MouthStrip: 4u,
HairColor: 5u,
EyeColor: 6u,
HairStyle: 7u,
HeadgearStyle: 8u,
HeadgearColor: 9u,
ShirtStyle: 10u,
ShirtColor: 11u,
TrousersStyle: 12u,
TrousersColor: 13u,
FootwearStyle: 14u,
FootwearColor: 15u,
SkinShade: 0.1,
HairShade: 0.2,
HeadgearShade: 0.3,
ShirtShade: 0.4,
TrousersShade: 0.5,
FootwearShade: 0.6),
Template: 16u,
Attributes: new CharacterCreate.Attributes(
Strength: 17u,
Endurance: 18u,
Coordination: 19u,
Quickness: 20u,
Focus: 21u,
Self: 22u),
Slot: 0u,
ClassId: 1u,
Name: "Testcdream",
StartArea: 23u,
IsAdmin: false,
IsEnvoy: false);
[Fact]
public void BuildRequestBody_Layout_MatchesRetailCGPackFieldOrder()
{
CharacterCreate.Request request = MakeRequest();
uint[] skills = MakeSkills();
byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, skills);
int pos = 0;
uint ReadU32()
{
uint v = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
pos += 4;
return v;
}
double ReadF64()
{
double v = BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(pos));
pos += 8;
return v;
}
string ReadString16L()
{
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
pos += 2;
string s = Encoding.ASCII.GetString(body, pos, len);
pos += len;
int recordSize = 2 + len;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return s;
}
Assert.Equal(CharacterCreate.Opcode, ReadU32());
Assert.Equal("testaccount", ReadString16L());
Assert.Equal(1u, ReadU32()); // CG_Pack@0x005c7208 constant
Assert.Equal(request.Heritage, ReadU32());
Assert.Equal(request.Gender, ReadU32());
Assert.Equal(request.Appearance.EyesStrip, ReadU32());
Assert.Equal(request.Appearance.NoseStrip, ReadU32());
Assert.Equal(request.Appearance.MouthStrip, ReadU32());
Assert.Equal(request.Appearance.HairColor, ReadU32());
Assert.Equal(request.Appearance.EyeColor, ReadU32());
Assert.Equal(request.Appearance.HairStyle, ReadU32());
Assert.Equal(request.Appearance.HeadgearStyle, ReadU32());
Assert.Equal(request.Appearance.HeadgearColor, ReadU32());
Assert.Equal(request.Appearance.ShirtStyle, ReadU32());
Assert.Equal(request.Appearance.ShirtColor, ReadU32());
Assert.Equal(request.Appearance.TrousersStyle, ReadU32());
Assert.Equal(request.Appearance.TrousersColor, ReadU32());
Assert.Equal(request.Appearance.FootwearStyle, ReadU32());
Assert.Equal(request.Appearance.FootwearColor, ReadU32());
Assert.Equal(request.Appearance.SkinShade, ReadF64());
Assert.Equal(request.Appearance.HairShade, ReadF64());
Assert.Equal(request.Appearance.HeadgearShade, ReadF64());
Assert.Equal(request.Appearance.ShirtShade, ReadF64());
Assert.Equal(request.Appearance.TrousersShade, ReadF64());
Assert.Equal(request.Appearance.FootwearShade, ReadF64());
Assert.Equal(request.Template, ReadU32());
Assert.Equal(request.Attributes.Strength, ReadU32());
Assert.Equal(request.Attributes.Endurance, ReadU32());
Assert.Equal(request.Attributes.Coordination, ReadU32());
Assert.Equal(request.Attributes.Quickness, ReadU32());
Assert.Equal(request.Attributes.Focus, ReadU32());
Assert.Equal(request.Attributes.Self, ReadU32());
Assert.Equal(request.Slot, ReadU32());
Assert.Equal(request.ClassId, ReadU32());
uint numSkills = ReadU32();
Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, numSkills);
for (int i = 0; i < skills.Length; i++)
Assert.Equal(skills[i], ReadU32());
Assert.Equal(request.Name, ReadString16L());
Assert.Equal(request.StartArea, ReadU32());
Assert.Equal(0u, ReadU32()); // isAdmin
Assert.Equal(0u, ReadU32()); // isEnvoy
uint checksum = ReadU32();
Assert.Equal(CharacterCreate.ComputeChecksum(request), checksum);
Assert.Equal(pos, body.Length);
}
[Fact]
public void BuildRequestBody_ExactByteSequence_ShortAccountAndName()
{
// Minimal fixture with distinct short strings, hand-checked padding.
CharacterCreate.Request request = new(
Heritage: 1u,
Gender: 0u,
Appearance: default,
Template: 0u,
Attributes: default,
Slot: 0u,
ClassId: 1u,
Name: "ab",
StartArea: 0u,
IsAdmin: false,
IsEnvoy: false);
uint[] skills = new uint[CharacterCreate.SkillAdvancementClassCount];
byte[] body = CharacterCreate.BuildRequestBody("cd", request, skills);
int pos = 0;
Assert.Equal(CharacterCreate.Opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
// String16L("cd") = u16(2) + 2 bytes, already 4-byte aligned.
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2;
Assert.Equal("cd", Encoding.ASCII.GetString(body, pos, 2)); pos += 2;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // constant
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // heritage
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // gender
// 14 appearance strip/color u32 fields, all zero (default).
pos += 14 * 4;
// 6 f64 shades, all zero (default).
pos += 6 * 8;
pos += 4; // template
pos += 6 * 4; // attributes
pos += 4; // slot
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // classId
Assert.Equal(
(uint)CharacterCreate.SkillAdvancementClassCount,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)));
pos += 4;
pos += CharacterCreate.SkillAdvancementClassCount * 4;
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2;
Assert.Equal("ab", Encoding.ASCII.GetString(body, pos, 2)); pos += 2;
pos += 4; // startArea
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isAdmin
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isEnvoy
// Checksum: heritage(1) + gender(0) + 3 strips(0) + hairColor(0) +
// eyeColor(0) + hairStyle(0) + headgearStyle(0) + shirtStyle(0) +
// trousersStyle(0) + footwearStyle(0) + template(0) + 6 attrs(0) = 1.
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
Assert.Equal(pos, body.Length);
}
[Fact]
public void ComputeChecksum_ExactRetailAccumulationSet()
{
// CG_Pack@0x005c7213-0x005c74c3: heritage, gender, the three
// appearance strips, hairColor, eyeColor, hairStyle, headgearStyle,
// shirtStyle, trousersStyle, footwearStyle, template, and the six
// attributes — nineteen terms, u32 wraparound addition.
CharacterCreate.Request request = MakeRequest();
uint expected = unchecked(
request.Heritage
+ request.Gender
+ request.Appearance.EyesStrip
+ request.Appearance.NoseStrip
+ request.Appearance.MouthStrip
+ request.Appearance.HairColor
+ request.Appearance.EyeColor
+ request.Appearance.HairStyle
+ request.Appearance.HeadgearStyle
+ request.Appearance.ShirtStyle
+ request.Appearance.TrousersStyle
+ request.Appearance.FootwearStyle
+ request.Template
+ request.Attributes.Strength
+ request.Attributes.Endurance
+ request.Attributes.Coordination
+ request.Attributes.Quickness
+ request.Attributes.Focus
+ request.Attributes.Self);
Assert.Equal(expected, CharacterCreate.ComputeChecksum(request));
// Concretely: 1+0+2+3+4+5+6+7+8+10+12+14+16+17+18+19+20+21+22 = 205.
Assert.Equal(205u, expected);
}
[Fact]
public void ComputeChecksum_ExcludesColorFieldsShadesSlotAndClassId()
{
// These fields sit adjacent to summed fields on the wire but the
// decompiled CG_Pack accumulation (0x005c7213-0x005c74c3) never
// touches them — mutating only these must not move the checksum.
CharacterCreate.Request baseline = MakeRequest();
uint baselineChecksum = CharacterCreate.ComputeChecksum(baseline);
CharacterCreate.Request mutated = baseline with
{
Appearance = baseline.Appearance with
{
HeadgearColor = baseline.Appearance.HeadgearColor + 1000u,
ShirtColor = baseline.Appearance.ShirtColor + 1000u,
TrousersColor = baseline.Appearance.TrousersColor + 1000u,
FootwearColor = baseline.Appearance.FootwearColor + 1000u,
SkinShade = baseline.Appearance.SkinShade + 5.0,
HairShade = baseline.Appearance.HairShade + 5.0,
},
Slot = baseline.Slot + 7u,
ClassId = baseline.ClassId + 7u,
};
Assert.Equal(baselineChecksum, CharacterCreate.ComputeChecksum(mutated));
}
[Fact]
public void BuildRequestBody_SkillCountOtherThan55_Throws()
{
CharacterCreate.Request request = MakeRequest();
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills().AsSpan(0, 54)));
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, new uint[56]));
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, ReadOnlySpan<uint>.Empty));
}
[Fact]
public void BuildRequestBody_NullAccountName_Throws()
{
CharacterCreate.Request request = MakeRequest();
Assert.Throws<ArgumentNullException>(() =>
CharacterCreate.BuildRequestBody(null!, request, MakeSkills()));
}
[Fact]
public void BuildRequestBody_NullCharacterName_Throws()
{
CharacterCreate.Request request = MakeRequest() with { Name = null! };
Assert.Throws<ArgumentNullException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills()));
}
[Fact]
public void BuildRequestBody_AdminAndEnvoyFlags_EncodeAsOneOrZero()
{
CharacterCreate.Request request = MakeRequest() with { IsAdmin = true, IsEnvoy = true };
byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills());
// isAdmin and isEnvoy are the two u32s immediately before the
// trailing checksum.
uint isEnvoy = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 8));
uint isAdmin = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 12));
Assert.Equal(1u, isAdmin);
Assert.Equal(1u, isEnvoy);
}
}

View file

@ -0,0 +1,330 @@
using System.Net;
using System.Reflection;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Tests.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Campaign CC CC2: the awaiting-request latch that disambiguates the two
/// message families sharing opcode 0xF643 (see
/// <see cref="CharGenVerificationResponse"/>'s doc comment) — create-then-
/// response routes to the create event, a plain restore is unaffected, an
/// unexpected/uncorrelated response is dropped rather than misattributed,
/// and teardown clears the latch. See
/// <see cref="WorldSessionCharacterSelectionTests"/> for the general
/// character-management wire-order coverage this file complements.
/// </summary>
public sealed class WorldSessionCharacterCreationTests
{
private sealed class NullTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromCanceled<NetReceiveResult>(cancellationToken);
public void Dispose() { }
}
private static WorldSession CreateSession() =>
new(
new IPEndPoint(IPAddress.Loopback, 9000),
new NullTransport());
private static CharacterCreate.Request MakeCreateRequest() => new(
Heritage: 1u,
Gender: 0u,
Appearance: default,
Template: 0u,
Attributes: default,
Slot: 0u,
ClassId: 1u,
Name: "NewChar",
StartArea: 0u,
IsAdmin: false,
IsEnvoy: false);
private static byte[] BuildVerificationResponseBody(uint code, uint guid, string name) =>
code == (uint)CharGenVerificationResponse.Code.Ok
? AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(code)
.WriteGuid(guid)
.WriteString16L(name)
.Write(0u)
.ToArray()
: AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(code)
.ToArray();
private static byte[] BuildPacket(params byte[][] messages)
{
int length = messages.Sum(message =>
MessageFragmentHeader.Size + message.Length);
var fragments = new byte[length];
int position = 0;
uint sequence = 1u;
foreach (byte[] message in messages)
{
position += GameMessageFragment.WriteSingleFragment(
fragments.AsSpan(position),
sequence++,
GameMessageGroup.UIQueue,
message);
}
return PacketCodec.Encode(
new PacketHeader
{
Sequence = 1u,
Flags = PacketHeaderFlags.BlobFragments,
},
fragments,
outboundIsaac: null);
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(
"ProcessDatagram",
BindingFlags.NonPublic | BindingFlags.Instance)!;
method.Invoke(session, [new ReadOnlyMemory<byte>(datagram), null, true]);
}
private static PendingLatch ReadPendingLatch(WorldSession session)
{
FieldInfo field = typeof(WorldSession).GetField(
"_pendingCharGenVerification",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (PendingLatch)field.GetValue(session)!;
}
// Mirrors WorldSession's private PendingCharGenVerificationRequest enum
// by name/ordinal — read via reflection above so the test doesn't need
// InternalsVisibleTo for a single private enum.
private enum PendingLatch { None, Restore, Create }
[Fact]
public void SendCharacterCreation_ThenOkResponse_RoutesToCreateEventNotRestore()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
var createEvents = new List<CharGenVerificationResponse.Parsed>();
var restoreEvents = new List<CharacterRestore.Parsed>();
session.CharacterCreateResponseReceived += createEvents.Add;
session.CharacterRestoreReceived += restoreEvents.Add;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000010u,
"NewChar"));
InvokeProcessDatagram(session, packet);
CharGenVerificationResponse.Parsed created = Assert.Single(createEvents);
Assert.True(created.IsOk);
Assert.Equal(0x50000010u, created.Guid);
Assert.Equal("NewChar", created.Name);
Assert.Empty(restoreEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void SendCharacterCreation_ThenFailureResponse_RoutesToCreateEventWithNullIdentity()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
CharGenVerificationResponse.Parsed? created = null;
session.CharacterCreateResponseReceived += parsed => created = parsed;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.NameInUse,
guid: 0u,
name: string.Empty));
InvokeProcessDatagram(session, packet);
Assert.NotNull(created);
Assert.Equal(CharGenVerificationResponse.Code.NameInUse, created!.Value.AsCode);
Assert.False(created.Value.IsOk);
Assert.Null(created.Value.Guid);
}
[Fact]
public void SendRestoreCharacter_ThenResponse_StillRoutesToRestoreEvent()
{
// Regression guard: the correlation latch must not break the
// pre-existing restore-only flow that predates Campaign CC.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000001u,
"Restored"));
InvokeProcessDatagram(session, packet);
CharacterRestore.Parsed restored = Assert.Single(restoreEvents);
Assert.Equal(0x50000001u, restored.Guid);
Assert.Equal("Restored", restored.Name);
Assert.Empty(createEvents);
}
/// <summary>
/// CC2 review F1: pins the latch's stated scope EXACTLY. The latch
/// correlates the single outstanding request and does NOT refuse
/// overlap — a second send while one is outstanding OVERWRITES it, so
/// the first request's reply is delivered to the second request's
/// event. Refusing overlap is the caller's job (CC3's Runtime
/// verification gate, mirroring retail's DoFinish UNDEF-state gate).
/// If CC3 (or anyone) changes this transport-level behavior, this test
/// must change WITH it, deliberately.
/// </summary>
[Fact]
public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
Assert.Equal(PendingLatch.Create, ReadPendingLatch(session));
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
// This reply is semantically the RESTORE's — but the overwritten
// latch routes it to the create event. That is the documented
// overwrite behavior, pinned here.
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000001u,
"Restored"));
InvokeProcessDatagram(session, packet);
Assert.Empty(restoreEvents);
Assert.Single(createEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed()
{
using WorldSession session = CreateSession();
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
// No SendRestoreCharacter / SendCharacterCreation call precedes this
// — the latch is None.
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000099u,
"Stray"));
InvokeProcessDatagram(session, packet);
Assert.Empty(restoreEvents);
Assert.Empty(createEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void SecondResponse_AfterFirstAlreadyConsumed_IsDroppedNotMisattributed()
{
// A create request is satisfied; a SECOND, uncorrelated 0xF643
// arriving afterward (e.g. a stray/replayed packet) must not be
// misread as a reply to anything.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterCreateResponseReceived += createEvents.Add;
byte[] first = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar"));
InvokeProcessDatagram(session, first);
Assert.Single(createEvents);
byte[] second = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok, 0x50000011u, "Stray"));
InvokeProcessDatagram(session, second);
// Still exactly one — the second reply was dropped, not appended.
Assert.Single(createEvents);
}
[Fact]
public void Dispose_ClearsTheOutstandingLatch()
{
WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
Assert.Equal(PendingLatch.Restore, ReadPendingLatch(session));
session.Dispose();
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void BuildRequestBody_InvalidSkillCount_DoesNotArmTheLatch()
{
// The latch is armed AFTER the body is built (SendCharacterCreation
// builds first), so a builder-level throw (wrong skill count) must
// leave no outstanding request behind — nothing was actually sent.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
Assert.Throws<ArgumentException>(() =>
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[10]));
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
}

View file

@ -61,6 +61,13 @@ public sealed class WorldSessionCharacterSelectionTests
public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters()
{
using var session = CreateSession();
// Campaign CC CC2: a restore response only dispatches when the
// session actually has an outstanding restore request armed — see
// WorldSessionCharacterCreationTests for the correlation-specific
// coverage (create routing, no-outstanding drop, teardown clears).
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
var events = new List<string>();
session.CharacterListReceived += roster =>
events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");

View file

@ -77,6 +77,37 @@ public sealed class StatusEventParserTests
Assert.Equal("boom", failed.Error);
}
[Fact]
public void ParsesCharacterCreatedAndCreationFailed()
{
var created = Assert.IsType<CharacterCreatedStatusEvent>(
StatusEventParser.Parse(
"""{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}"""));
Assert.Equal(1342177296u, created.Guid);
Assert.Equal("NewChar", created.Name);
var failed = Assert.IsType<CreationFailedStatusEvent>(
StatusEventParser.Parse(
"""{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""));
Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
}
[Theory]
[InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NewChar\"}")]
[InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"reason\":\"NameInUse\",\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"reason\":\"NameInUse\"}")]
public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line)
{
var malformed = Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
Assert.Equal("s1", malformed.SessionId);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Fact]
public void ParsesLoginCommandFailed()
{

View file

@ -183,6 +183,34 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.Empty(events);
}
/// <summary>
/// Campaign CC CC2: the two creation-flow events round-trip through the
/// actual file-tailing pipeline (not just <see cref="StatusEventParser"/>
/// in isolation) — matching the exact camelCase shape
/// <c>AcDream.Runtime.Session.SessionStatusWriter</c> writes.
/// </summary>
[Fact]
public void TailsCharacterCreatedAndCreationFailedEvents()
{
AppendShared(
"""{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}"""
+ "\n"
+ """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""
+ "\n");
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Equal(2, events.Count);
var created = Assert.IsType<CharacterCreatedStatusEvent>(events[0]);
Assert.Equal(1342177296u, created.Guid);
Assert.Equal("NewChar", created.Name);
var failed = Assert.IsType<CreationFailedStatusEvent>(events[1]);
Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
}
[Fact]
public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
{

View file

@ -109,6 +109,8 @@ public sealed class SessionStatusWriterTests
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.LoginCommandFailed("s1", 0, "", "unknown command");
writer.CharacterCreated("s1", 0x50000001u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@ -116,6 +118,43 @@ public sealed class SessionStatusWriterTests
Assert.False(File.Exists(file.Path));
}
/// <summary>
/// Campaign CC CC2: pins the exact shape of the two new creation-flow
/// status events, added to the LA1 vocabulary alongside
/// <c>CharacterCreate</c> (opcode 0xF656) — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> §LA1's amended
/// status-vocabulary text.
/// </summary>
[Fact]
public void CharacterCreatedAndCreationFailed_WriteThePinnedShape()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.CharacterCreated("s1", 0x50000010u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(2, lines.Length);
JsonElement created = Parse(lines[0]);
Assert.Equal(1, created.GetProperty("v").GetInt32());
Assert.Equal("characterCreated", created.GetProperty("e").GetString());
Assert.Equal("s1", created.GetProperty("sessionId").GetString());
Assert.Equal(0x50000010u, created.GetProperty("guid").GetUInt32());
Assert.Equal("NewChar", created.GetProperty("name").GetString());
AssertExactProperties(lines[0], "v", "e", "t", "sessionId", "guid", "name");
JsonElement failed = Parse(lines[1]);
Assert.Equal("creationFailed", failed.GetProperty("e").GetString());
Assert.Equal("s1", failed.GetProperty("sessionId").GetString());
Assert.Equal(3u, failed.GetProperty("code").GetUInt32());
Assert.Equal("NameInUse", failed.GetProperty("reason").GetString());
Assert.Equal("Bob", failed.GetProperty("name").GetString());
AssertExactProperties(
lines[1], "v", "e", "t", "sessionId", "code", "reason", "name");
}
[Fact]
public void BlankPathIsTreatedAsAbsent()
{