feat(net,runtime): Campaign CC CC2 — CharacterCreate wire, 0xF643 correlation, creation status events
Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
-> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
order), template, 6 attributes, slot, classId, numSkills + exactly 55
u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
ArgumentException otherwise — ACE terminates the session on any other
count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
trousersColor/footwearColor/shades/slot/classId are deliberately absent
from the sum despite sitting adjacent on the wire. Cross-checked against
ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
CharacterCreateRequestData (types.rs:236-369), which agree on every
field and order. Retail routes via SendToLogon — the same queue
CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
ACE's CharacterGenerationVerificationResponse) plus the conditional
Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
CharacterRestore.Parse now delegates to it; CharacterRestore's public
Parsed shape, Parse signature, and every existing test expectation are
UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.
WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
SendRestoreCharacter/the new SendCharacterCreation immediately before
each send (SendCharacterCreation builds the body first so a skill-count
throw never arms the latch for a request that was never sent), cleared
the instant a matching 0xF643 is dispatched (success OR parse failure —
a malformed reply must never wedge the latch open) and on Dispose.
0xF643 now routes to CharacterRestoreReceived or the new
CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
by that latch; an unexpected 0xF643 with nothing outstanding logs once
and is dropped, never misattributed. Fixed
WorldSessionCharacterSelectionTests' restore-dispatch test, which
previously fed a bare CharacterRestore response with no preceding
SendRestoreCharacter — that shape is now the "no outstanding request"
drop path by design.
Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
vocabulary to add characterCreated{guid,name} (Ok reply identity, named
to mirror CharGenVerificationResponse's own fields and to read distinct
from enteredWorld — retail logs a freshly created character straight in
without a fresh characterList) and creationFailed{code,name} (raw Code
value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
StatusEventParser cases, in lockstep.
Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.
Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ee80138fba
commit
5eaad2c88c
16 changed files with 1548 additions and 40 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
308
tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs
Normal file
308
tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
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);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -77,6 +77,35 @@ 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,"name":"NameInUse"}"""));
|
||||
Assert.Equal(3u, failed.Code);
|
||||
Assert.Equal("NameInUse", 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\",\"name\":\"NameInUse\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3}")]
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -183,6 +183,33 @@ 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,"name":"NameInUse"}"""
|
||||
+ "\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.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
|
|
@ -116,6 +118,41 @@ 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");
|
||||
|
||||
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("name").GetString());
|
||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankPathIsTreatedAsAbsent()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue