acdream/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs
Erik 5eaad2c88c 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>
2026-08-15 12:49:52 +02:00

308 lines
12 KiB
C#

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