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>
308 lines
12 KiB
C#
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);
|
|
}
|
|
}
|