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
|
|
@ -170,6 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens
|
|||
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
|
||||
`pluginFailed{plugin,error}`,
|
||||
`loginCommandFailed{commandIndex,command,error}`,
|
||||
`characterCreated{guid,name}`, `creationFailed{code,name}`,
|
||||
`disconnected{reason}`,
|
||||
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
|
||||
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
|
||||
|
|
@ -177,6 +178,23 @@ sides. Unknown `e` values must parse to a typed Unknown event, never
|
|||
throw; a known `e` with a wrong payload shape should be distinguishable
|
||||
from an unknown `e` (LA3 review finding 12).
|
||||
|
||||
**Campaign CC CC2 amendment (this section is the contract; the writer and
|
||||
tailer below implement it, in that order):** `characterCreated{guid,name}`
|
||||
fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request —
|
||||
`guid`/`name` come straight off the shared `0xF643`
|
||||
`CharGenVerificationResponse` Ok identity payload
|
||||
(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately
|
||||
named `guid`/`name` rather than `characterId`/`characterName` to mirror
|
||||
that payload's own field names and to read distinctly from
|
||||
`enteredWorld` — a freshly created character is logged straight in by
|
||||
retail without a fresh `characterList` (see that type's doc comment), so
|
||||
`characterCreated` can precede an `enteredWorld` for the same character
|
||||
rather than replacing it. `creationFailed{code,name}` fires on any non-Ok
|
||||
reply: `code` is the raw wire `CharGenVerificationResponse.Code` value,
|
||||
`name` is that code's enum member name (e.g. `"NameInUse"`) so a reader
|
||||
gets a stable readable reason without hard-coding the numeric mapping
|
||||
itself.
|
||||
|
||||
`loginCommandFailed.commandIndex` is the zero-based index in the configured
|
||||
`loginCommands` array. `command` is the exact configured line and `error` is
|
||||
the isolated parser/router/handler failure. The event is observational: the
|
||||
|
|
@ -214,7 +232,8 @@ Three pieces, one slice, because they share the session-config/status seam:
|
|||
config; absent → permanent no-op sink). Versioned event
|
||||
vocabulary (`"v":1`): `started`, `connected`, `characterList`,
|
||||
`enteredWorld`, `pluginLoaded`/`pluginFailed`,
|
||||
`loginCommandFailed`, `disconnected`, `exited`.
|
||||
`loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC
|
||||
CC2), `disconnected`, `exited`.
|
||||
Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL
|
||||
sink with four kinds (lifecycle/failure/event/resources) and NO per-session
|
||||
file — the status writer is a second, separate sink, not a rework of the
|
||||
|
|
|
|||
145
src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
Normal file
145
src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Shared parser for opcode <c>0xF643</c> — retail's
|
||||
/// <c>CharacterGenerationVerificationResponse</c> shape, which BOTH
|
||||
/// <see cref="CharacterRestore"/> (opcode <c>0xF7D9</c> request) and
|
||||
/// <see cref="CharacterCreate"/> (opcode <c>0xF656</c> request) receive on
|
||||
/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by
|
||||
/// ACE's own <c>GameMessageOpcode.cs</c> declaring both
|
||||
/// <c>CharacterCreateResponse = 0xF643</c> and
|
||||
/// <c>CharacterRestoreResponse = 0xF643, // This is a duplicate...</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Campaign CC CC2:</b> this type is the promotion of the parse logic
|
||||
/// that used to live only in <see cref="CharacterRestore.Parse"/> (Campaign
|
||||
/// LA slice LA7a). Character creation now exists (<see cref="CharacterCreate"/>),
|
||||
/// so the two message families that collide on this opcode are both real and
|
||||
/// both need it — <see cref="CharacterRestore"/> keeps its own
|
||||
/// <see cref="CharacterRestore.Parsed"/> shape for source compatibility and
|
||||
/// delegates to this type internally; new code (the create response,
|
||||
/// <c>WorldSession.CharacterCreateResponseReceived</c>) consumes
|
||||
/// <see cref="Parsed"/> directly. A caller cannot tell "restore response"
|
||||
/// from "create response" by opcode or shape alone — <c>WorldSession</c>
|
||||
/// disambiguates by tracking which outbound request (restore vs. create) it
|
||||
/// is awaiting a reply to (see <c>WorldSession</c>'s awaiting-request latch).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout, verbatim from ACE's <c>GameMessageCharacterCreateResponse.cs</c>
|
||||
/// / <c>GameMessageCharacterRestore.cs</c> (both write the identical shape)
|
||||
/// and cross-checked against holtburger's
|
||||
/// <c>CharacterCreateResponseData::unpack</c>
|
||||
/// (<c>holtburger-protocol/src/messages/character/types.rs:379-410</c>):
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// u32 opcode (0xF643)
|
||||
/// u32 code (CharacterGenerationVerificationResponse)
|
||||
/// -- only when code == Ok --
|
||||
/// u32 guid
|
||||
/// String16L name
|
||||
/// u32 secondsGreyedOut
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Code"/> is a verbatim port of ACE's
|
||||
/// <c>CharacterGenerationVerificationResponse</c> enum
|
||||
/// (<c>ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs</c>),
|
||||
/// which is itself retail's own dialog dispatch table
|
||||
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c>): <c>NameInUse</c> →
|
||||
/// <c>ID_Character_Err_NameReserved</c>, <c>NameBanned</c> →
|
||||
/// <c>ID_Character_Err_NameBanned</c>, <c>Corrupt</c>/<c>DatabaseDown</c> →
|
||||
/// <c>ID_Character_Err_NameDBDown</c>, <c>AdminPrivilegeDenied</c> →
|
||||
/// <c>ID_Character_Err_NameAdminDenied</c>. <c>Pending</c>/<c>Undef</c>
|
||||
/// retail treats as a silent state reset with no dialog — notably ACE sends
|
||||
/// <c>Pending</c> for a disabled-Olthoi rejection
|
||||
/// (<c>CharacterHandler.CharacterCreateEx</c>,
|
||||
/// <c>olthoi_play_disabled</c> branch), so that specific rejection is
|
||||
/// invisible to the retail-faithful client too; this is a retail quirk to
|
||||
/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job
|
||||
/// (App layer), not this Core.Net type's.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharGenVerificationResponse
|
||||
{
|
||||
public const uint ResponseOpcode = 0xF643u;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim port of ACE's <c>CharacterGenerationVerificationResponse</c>
|
||||
/// enum, which is retail's own <c>Handle_CharGenVerificationResponse</c>
|
||||
/// dispatch table.
|
||||
/// </summary>
|
||||
public enum Code : uint
|
||||
{
|
||||
Undef = 0,
|
||||
Ok = 1,
|
||||
Pending = 2,
|
||||
NameInUse = 3,
|
||||
NameBanned = 4,
|
||||
Corrupt = 5,
|
||||
DatabaseDown = 6,
|
||||
AdminPrivilegeDenied = 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed <c>0xF643</c> body. <see cref="Guid"/>, <see cref="Name"/>, and
|
||||
/// <see cref="SecondsGreyedOut"/> are only populated when
|
||||
/// <see cref="RawCode"/> equals <see cref="Code.Ok"/> — retail omits
|
||||
/// them entirely on the wire otherwise (both
|
||||
/// <c>GameMessageCharacterCreateResponse</c> and
|
||||
/// <c>GameMessageCharacterRestore</c> gate the trailing fields on
|
||||
/// <c>response == ... .Ok</c>).
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint RawCode,
|
||||
uint? Guid,
|
||||
string? Name,
|
||||
uint? SecondsGreyedOut)
|
||||
{
|
||||
/// <summary>
|
||||
/// Best-effort named view of <see cref="RawCode"/>. A plain enum
|
||||
/// cast never throws in C#, so this is safe even for a value retail
|
||||
/// never defined — always trust <see cref="RawCode"/> as the source
|
||||
/// of truth.
|
||||
/// </summary>
|
||||
public Code AsCode => (Code)RawCode;
|
||||
|
||||
/// <summary>True when the trailing identity fields are present.</summary>
|
||||
public bool IsOk => RawCode == (uint)Code.Ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a <c>0xF643</c> body. <paramref name="body"/> must start with
|
||||
/// the 4-byte opcode.
|
||||
/// </summary>
|
||||
public static Parsed Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int pos = 0;
|
||||
|
||||
uint opcode = ReadU32(body, ref pos);
|
||||
if (opcode != ResponseOpcode)
|
||||
throw new FormatException(
|
||||
$"expected CharacterGenerationVerificationResponse opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}");
|
||||
|
||||
uint rawCode = ReadU32(body, ref pos);
|
||||
if (rawCode != (uint)Code.Ok)
|
||||
return new Parsed(rawCode, null, null, null);
|
||||
|
||||
uint guid = ReadU32(body, ref pos);
|
||||
string name = StringReader.ReadString16L(body, ref pos);
|
||||
uint secondsGreyedOut = ReadU32(body, ref pos);
|
||||
|
||||
return new Parsed(rawCode, guid, name, secondsGreyedOut);
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
|
||||
pos += 4;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
305
src/AcDream.Core.Net/Messages/CharacterCreate.cs
Normal file
305
src/AcDream.Core.Net/Messages/CharacterCreate.cs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail character-creation request (opcode <c>0xF656</c>). Campaign CC
|
||||
/// slice CC2 — the outbound half of retail creation; the shared <c>0xF643</c>
|
||||
/// response is <see cref="CharGenVerificationResponse"/> (see that type's doc
|
||||
/// comment for the two-family opcode collision with
|
||||
/// <see cref="CharacterRestore"/>, and <c>WorldSession</c>'s awaiting-request
|
||||
/// latch for how the two are disambiguated on receipt).
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout ported byte-for-byte from
|
||||
/// <c>Proto_UI::SendCharGenResult@0x00546a70</c> (packs the account name,
|
||||
/// then calls <c>ACCharGenResult::Pack@0x005c7570</c> →
|
||||
/// <c>ACCharGenResult::CG_Pack@0x005c7200</c>) and cross-checked against
|
||||
/// ACE's <c>CharacterCreateInfo.Unpack</c> / <c>Appearance.Unpack</c>
|
||||
/// (<c>ACE.Entity/CharacterCreateInfo.cs</c>, <c>ACE.Entity/Appearance.cs</c>)
|
||||
/// and holtburger's <c>CharacterCreateRequestData</c>
|
||||
/// (<c>holtburger-protocol/src/messages/character/types.rs:236-369</c>),
|
||||
/// which agree on every field and its order:
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// u32 opcode (0xF656)
|
||||
/// String16L accountName (packed OUTSIDE CG_Pack, by SendCharGenResult itself)
|
||||
/// -- ACCharGenResult::CG_Pack body --
|
||||
/// u32 constant (always 1 — CG_Pack@0x005c7208)
|
||||
/// u32 heritage
|
||||
/// u32 gender
|
||||
/// u32 eyesStrip
|
||||
/// u32 noseStrip
|
||||
/// u32 mouthStrip
|
||||
/// u32 hairColor
|
||||
/// u32 eyeColor
|
||||
/// u32 hairStyle
|
||||
/// u32 headgearStyle
|
||||
/// u32 headgearColor
|
||||
/// u32 shirtStyle
|
||||
/// u32 shirtColor
|
||||
/// u32 trousersStyle
|
||||
/// u32 trousersColor
|
||||
/// u32 footwearStyle
|
||||
/// u32 footwearColor
|
||||
/// f64 skinShade
|
||||
/// f64 hairShade
|
||||
/// f64 headgearShade
|
||||
/// f64 shirtShade
|
||||
/// f64 trousersShade
|
||||
/// f64 footwearShade
|
||||
/// u32 template
|
||||
/// u32 strength
|
||||
/// u32 endurance
|
||||
/// u32 coordination
|
||||
/// u32 quickness
|
||||
/// u32 focus
|
||||
/// u32 self
|
||||
/// u32 slot (ACE: CharacterSlot — NOT the character guid)
|
||||
/// u32 classId
|
||||
/// u32 numSkills (MUST be exactly <see cref="SkillAdvancementClassCount"/>)
|
||||
/// u32[] skillAdvancementClasses (numSkills entries)
|
||||
/// String16L name
|
||||
/// u32 startArea
|
||||
/// u32 isAdmin
|
||||
/// u32 isEnvoy (ACE: IsSentinel)
|
||||
/// u32 checksum (see <see cref="ComputeChecksum"/>)
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The 55-slot invariant.</b> ACE's <c>PlayerFactory.Create</c>
|
||||
/// (reached from <c>CharacterHandler.CharacterCreateEx</c>) rejects a
|
||||
/// client/server skill-table mismatch by TERMINATING the session
|
||||
/// (<c>PlayerFactory.CreateResult.ClientServerSkillsMismatch</c> →
|
||||
/// <c>session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...)</c>)
|
||||
/// — there is no graceful recovery from sending the wrong count. Retail's
|
||||
/// live skill table has exactly <see cref="SkillAdvancementClassCount"/>
|
||||
/// (55) skills, so <see cref="BuildRequestBody"/> takes
|
||||
/// <c>skillAdvancementClasses</c> as a <see cref="ReadOnlySpan{T}"/> and
|
||||
/// throws <see cref="ArgumentException"/> for any length other than 55 —
|
||||
/// structurally impossible to send anything else through this builder.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The trailing checksum.</b> Retail computes and sends it
|
||||
/// (<c>CG_Pack@0x005c74c3</c>, the final <c>*(uint32_t*)ecx_33 =
|
||||
/// (ebx_18 + self)</c> store); ACE's <c>CharacterCreateInfo.Unpack</c> never
|
||||
/// reads it (the reader consumes <c>isSentinel</c> and stops — see
|
||||
/// <c>ACE.Entity/CharacterCreateInfo.cs:67</c>) and holtburger's
|
||||
/// <c>CharacterCreateRequestData::unpack</c> agrees (its field list ends at
|
||||
/// <c>is_sentinel</c>, no checksum read). We compute and send it anyway for
|
||||
/// byte fidelity with a genuine retail client. Decompiled accumulation
|
||||
/// order (<c>CG_Pack@0x005c7213</c>-<c>0x005c74c3</c>) sums EXACTLY:
|
||||
/// heritage, gender, the three appearance strips (eyes/nose/mouth),
|
||||
/// hairColor, eyeColor, hairStyle, headgearStyle, shirtStyle, trousersStyle,
|
||||
/// footwearStyle, template, and the six attributes (strength through self).
|
||||
/// Notably ABSENT from the sum despite being adjacent fields on the wire:
|
||||
/// headgearColor, shirtColor, trousersColor, footwearColor, all six f64
|
||||
/// shades, slot, and classId — <see cref="ComputeChecksum"/> mirrors that
|
||||
/// exact (and exactly that) field set. u32 addition is commutative and
|
||||
/// associative modulo 2^32, so summation order does not affect the result;
|
||||
/// <see cref="ComputeChecksum"/> orders the terms for readability, not
|
||||
/// wire fidelity.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Routing.</b> <c>Proto_UI::SendCharGenResult</c> sends via
|
||||
/// <c>Proto_UI::SendToLogon@0x00546b03</c> — the SAME queue as
|
||||
/// <see cref="CharacterDelete"/>'s request
|
||||
/// (<c>Proto_UI::SendDeleteCharacter@0x00546b83</c>, also <c>SendToLogon</c>)
|
||||
/// and <c>CharacterEnterWorld</c>'s request
|
||||
/// (<c>Proto_UI::SendEnterWorld@0x00546c12</c>). <c>WorldSession</c>'s outbound
|
||||
/// helper, <c>SendCharacterCreation</c>, sends on
|
||||
/// <c>GameMessageGroup.LoginQueue</c> — the same queue
|
||||
/// <c>WorldSession.SendDeleteCharacter</c> already uses.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Account-name gate.</b> ACE's <c>CharacterCreate</c> handler
|
||||
/// (<c>CharacterHandler.cs:27-32</c>) silently drops the request when the
|
||||
/// packed account name doesn't match <c>session.Account</c> — the same
|
||||
/// silent-no-reply shape <see cref="CharacterRestore"/>'s doc comment already
|
||||
/// warns about for restore. <c>WorldSession</c>'s awaiting-request latch
|
||||
/// must never assume a reply is coming.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharacterCreate
|
||||
{
|
||||
public const uint Opcode = 0xF656u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's live skill-advancement-class table size. ACE terminates the
|
||||
/// session on any other count — see the class doc comment.
|
||||
/// </summary>
|
||||
public const int SkillAdvancementClassCount = 55;
|
||||
|
||||
/// <summary>
|
||||
/// The fourteen style/color strip fields plus the six f64 shade fields —
|
||||
/// <c>Appearance.Unpack</c>'s exact field set and order
|
||||
/// (<c>ACE.Entity/Appearance.cs</c>).
|
||||
/// </summary>
|
||||
public readonly record struct Appearance(
|
||||
uint EyesStrip,
|
||||
uint NoseStrip,
|
||||
uint MouthStrip,
|
||||
uint HairColor,
|
||||
uint EyeColor,
|
||||
uint HairStyle,
|
||||
uint HeadgearStyle,
|
||||
uint HeadgearColor,
|
||||
uint ShirtStyle,
|
||||
uint ShirtColor,
|
||||
uint TrousersStyle,
|
||||
uint TrousersColor,
|
||||
uint FootwearStyle,
|
||||
uint FootwearColor,
|
||||
double SkinShade,
|
||||
double HairShade,
|
||||
double HeadgearShade,
|
||||
double ShirtShade,
|
||||
double TrousersShade,
|
||||
double FootwearShade);
|
||||
|
||||
/// <summary>The six primary attributes, retail's fixed str/end/coord/quick/focus/self order.</summary>
|
||||
public readonly record struct Attributes(
|
||||
uint Strength,
|
||||
uint Endurance,
|
||||
uint Coordination,
|
||||
uint Quickness,
|
||||
uint Focus,
|
||||
uint Self);
|
||||
|
||||
/// <summary>
|
||||
/// Every field of an outbound CharacterCreate EXCEPT the account name
|
||||
/// (a separate <see cref="BuildRequestBody"/> parameter, packed outside
|
||||
/// <c>CG_Pack</c> — see the class doc comment) and the skill-advancement
|
||||
/// array (a <see cref="ReadOnlySpan{T}"/> parameter so its length is
|
||||
/// validated at the call site rather than smuggled through a record
|
||||
/// field of unbounded size).
|
||||
/// </summary>
|
||||
public readonly record struct Request(
|
||||
uint Heritage,
|
||||
uint Gender,
|
||||
Appearance Appearance,
|
||||
uint Template,
|
||||
Attributes Attributes,
|
||||
uint Slot,
|
||||
uint ClassId,
|
||||
string Name,
|
||||
uint StartArea,
|
||||
bool IsAdmin,
|
||||
bool IsEnvoy);
|
||||
|
||||
/// <summary>
|
||||
/// Build the body bytes for an outbound <c>CharacterCreate</c> request.
|
||||
/// See the class doc comment for the exact byte layout.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="skillAdvancementClasses"/>.Length is not exactly
|
||||
/// <see cref="SkillAdvancementClassCount"/> — ACE terminates the session
|
||||
/// on any other count, so this builder refuses to construct the request
|
||||
/// at all rather than send something retail-invalid.
|
||||
/// </exception>
|
||||
public static byte[] BuildRequestBody(
|
||||
string accountName,
|
||||
Request request,
|
||||
ReadOnlySpan<uint> skillAdvancementClasses)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(accountName);
|
||||
ArgumentNullException.ThrowIfNull(request.Name);
|
||||
if (skillAdvancementClasses.Length != SkillAdvancementClassCount)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"retail's CG_Pack numSkills must be exactly "
|
||||
+ $"{SkillAdvancementClassCount} — ACE terminates the session "
|
||||
+ "(PlayerFactory.CreateResult.ClientServerSkillsMismatch) on "
|
||||
+ $"any other count. Got {skillAdvancementClasses.Length}.",
|
||||
nameof(skillAdvancementClasses));
|
||||
}
|
||||
|
||||
Appearance appearance = request.Appearance;
|
||||
Attributes attributes = request.Attributes;
|
||||
|
||||
var w = new PacketWriter(
|
||||
256 + (skillAdvancementClasses.Length * 4) + (request.Name.Length * 2));
|
||||
w.WriteUInt32(Opcode);
|
||||
w.WriteString16L(accountName);
|
||||
|
||||
// -- ACCharGenResult::CG_Pack body --
|
||||
w.WriteUInt32(1u); // CG_Pack@0x005c7208 constant
|
||||
w.WriteUInt32(request.Heritage);
|
||||
w.WriteUInt32(request.Gender);
|
||||
w.WriteUInt32(appearance.EyesStrip);
|
||||
w.WriteUInt32(appearance.NoseStrip);
|
||||
w.WriteUInt32(appearance.MouthStrip);
|
||||
w.WriteUInt32(appearance.HairColor);
|
||||
w.WriteUInt32(appearance.EyeColor);
|
||||
w.WriteUInt32(appearance.HairStyle);
|
||||
w.WriteUInt32(appearance.HeadgearStyle);
|
||||
w.WriteUInt32(appearance.HeadgearColor);
|
||||
w.WriteUInt32(appearance.ShirtStyle);
|
||||
w.WriteUInt32(appearance.ShirtColor);
|
||||
w.WriteUInt32(appearance.TrousersStyle);
|
||||
w.WriteUInt32(appearance.TrousersColor);
|
||||
w.WriteUInt32(appearance.FootwearStyle);
|
||||
w.WriteUInt32(appearance.FootwearColor);
|
||||
w.WriteDouble(appearance.SkinShade);
|
||||
w.WriteDouble(appearance.HairShade);
|
||||
w.WriteDouble(appearance.HeadgearShade);
|
||||
w.WriteDouble(appearance.ShirtShade);
|
||||
w.WriteDouble(appearance.TrousersShade);
|
||||
w.WriteDouble(appearance.FootwearShade);
|
||||
w.WriteUInt32(request.Template);
|
||||
w.WriteUInt32(attributes.Strength);
|
||||
w.WriteUInt32(attributes.Endurance);
|
||||
w.WriteUInt32(attributes.Coordination);
|
||||
w.WriteUInt32(attributes.Quickness);
|
||||
w.WriteUInt32(attributes.Focus);
|
||||
w.WriteUInt32(attributes.Self);
|
||||
w.WriteUInt32(request.Slot);
|
||||
w.WriteUInt32(request.ClassId);
|
||||
w.WriteUInt32((uint)skillAdvancementClasses.Length);
|
||||
foreach (uint skill in skillAdvancementClasses)
|
||||
w.WriteUInt32(skill);
|
||||
w.WriteString16L(request.Name);
|
||||
w.WriteUInt32(request.StartArea);
|
||||
w.WriteUInt32(request.IsAdmin ? 1u : 0u);
|
||||
w.WriteUInt32(request.IsEnvoy ? 1u : 0u);
|
||||
w.WriteUInt32(ComputeChecksum(request));
|
||||
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's trailing checksum field — see the class doc comment for the
|
||||
/// exact decompiled accumulation and the fields deliberately absent from
|
||||
/// it. ACE never reads this field; acdream sends it for byte fidelity
|
||||
/// with a genuine retail client.
|
||||
/// </summary>
|
||||
public static uint ComputeChecksum(Request request)
|
||||
{
|
||||
Appearance a = request.Appearance;
|
||||
Attributes b = request.Attributes;
|
||||
return unchecked(
|
||||
request.Heritage
|
||||
+ request.Gender
|
||||
+ a.EyesStrip
|
||||
+ a.NoseStrip
|
||||
+ a.MouthStrip
|
||||
+ a.HairColor
|
||||
+ a.EyeColor
|
||||
+ a.HairStyle
|
||||
+ a.HeadgearStyle
|
||||
+ a.ShirtStyle
|
||||
+ a.TrousersStyle
|
||||
+ a.FootwearStyle
|
||||
+ request.Template
|
||||
+ b.Strength
|
||||
+ b.Endurance
|
||||
+ b.Coordination
|
||||
+ b.Quickness
|
||||
+ b.Focus
|
||||
+ b.Self);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
|
@ -75,11 +74,28 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// fields are read only when <c>verificationFlag == 1</c>. Because the two
|
||||
/// message families are wire-identical when they collide, a caller cannot
|
||||
/// tell "restore response" from "create response" by opcode or shape
|
||||
/// alone — it must track which outbound request (this file's
|
||||
/// <see cref="BuildRequestBody"/> vs. a future CharacterCreate) it is
|
||||
/// awaiting a reply to. Character creation is out of this campaign's scope
|
||||
/// (design spec §7 non-goals); this type does not attempt to disambiguate
|
||||
/// the two families itself.
|
||||
/// alone — it must track which outbound request
|
||||
/// (<see cref="BuildRequestBody"/> vs.
|
||||
/// <see cref="AcDream.Core.Net.Messages.CharacterCreate.BuildRequestBody"/>)
|
||||
/// it is awaiting a reply to.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Campaign CC CC2 update:</b> character creation now exists
|
||||
/// (<see cref="AcDream.Core.Net.Messages.CharacterCreate"/>), so the
|
||||
/// disambiguation this doc comment used to defer is real work now, done by
|
||||
/// <c>WorldSession</c>'s awaiting-request latch (set by
|
||||
/// <c>WorldSession.SendRestoreCharacter</c> /
|
||||
/// <c>WorldSession.SendCharacterCreation</c>, cleared on the matching
|
||||
/// response), which routes each 0xF643 to
|
||||
/// <c>WorldSession.CharacterRestoreReceived</c> or
|
||||
/// <c>WorldSession.CharacterCreateResponseReceived</c> accordingly and drops
|
||||
/// (rather than misattributes) a 0xF643 with no outstanding request. The
|
||||
/// wire parse itself is now shared: <see cref="Parse"/> delegates to
|
||||
/// <see cref="CharGenVerificationResponse.Parse"/>, which both families
|
||||
/// consume. This type's own <see cref="Parsed"/> shape and <see cref="Parse"/>
|
||||
/// signature are UNCHANGED by that refactor — every existing caller and test
|
||||
/// keeps working exactly as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharacterRestore
|
||||
|
|
@ -119,32 +135,14 @@ public static class CharacterRestore
|
|||
|
||||
/// <summary>
|
||||
/// Parse a <c>CharacterRestore</c> response body (opcode <c>0xF643</c>).
|
||||
/// <paramref name="body"/> must start with the 4-byte opcode.
|
||||
/// <paramref name="body"/> must start with the 4-byte opcode. Delegates
|
||||
/// to the shared <see cref="CharGenVerificationResponse.Parse"/> (Campaign
|
||||
/// CC CC2); this type's <see cref="Parsed"/> shape and this method's
|
||||
/// exception behavior are unchanged from before that refactor.
|
||||
/// </summary>
|
||||
public static Parsed Parse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int pos = 0;
|
||||
|
||||
uint opcode = ReadU32(body, ref pos);
|
||||
if (opcode != ResponseOpcode)
|
||||
throw new FormatException($"expected CharacterRestore response opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}");
|
||||
|
||||
uint verificationFlag = ReadU32(body, ref pos);
|
||||
if (verificationFlag != 1u)
|
||||
return new Parsed(verificationFlag, null, null, null);
|
||||
|
||||
uint guid = ReadU32(body, ref pos);
|
||||
string name = StringReader.ReadString16L(body, ref pos);
|
||||
uint secondsGreyedOut = ReadU32(body, ref pos);
|
||||
|
||||
return new Parsed(verificationFlag, guid, name, secondsGreyedOut);
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
|
||||
pos += 4;
|
||||
return value;
|
||||
CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body);
|
||||
return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ public sealed class PacketWriter
|
|||
_position += 4;
|
||||
}
|
||||
|
||||
public void WriteDouble(double value)
|
||||
{
|
||||
EnsureCapacity(8);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan(_position), value);
|
||||
_position += 8;
|
||||
}
|
||||
|
||||
/// <summary>Pad with zeros so the buffer length is a multiple of 4.</summary>
|
||||
public void AlignTo4()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -598,6 +598,16 @@ public sealed class WorldSession : IDisposable
|
|||
public event Action<CharacterList.Parsed>? CharacterListReceived;
|
||||
public event Action? CharacterDeleteAcknowledged;
|
||||
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: fires when a <c>0xF643</c>
|
||||
/// (<see cref="CharGenVerificationResponse"/>) response arrives while
|
||||
/// this session's awaiting-request latch says <c>Create</c> — i.e. the
|
||||
/// reply to <see cref="SendCharacterCreation"/>. See
|
||||
/// <see cref="CharGenVerificationResponse"/>'s doc comment for the
|
||||
/// opcode collision with <see cref="CharacterRestoreReceived"/> and how
|
||||
/// the two are disambiguated.
|
||||
/// </summary>
|
||||
public event Action<CharGenVerificationResponse.Parsed>? CharacterCreateResponseReceived;
|
||||
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
|
||||
/// <summary>
|
||||
/// Campaign LA gate round 2 finding 3: ACE sends this in the same batch
|
||||
|
|
@ -706,6 +716,41 @@ public sealed class WorldSession : IDisposable
|
|||
public ServerName.Parsed? ServerInfo { get; private set; }
|
||||
private CharacterError.Parsed? _lastCharacterSelectionError;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: which outbound character-generation request (if any)
|
||||
/// this session is awaiting a <c>0xF643</c>
|
||||
/// (<see cref="CharGenVerificationResponse"/>) reply to. Restore and
|
||||
/// create requests share that opcode on the wire (see
|
||||
/// <see cref="CharGenVerificationResponse"/>'s doc comment) with no
|
||||
/// self-describing discriminant, so this latch is the only thing that
|
||||
/// tells the dispatcher which event to fire. Set by
|
||||
/// <see cref="SendRestoreCharacter"/> / <see cref="SendCharacterCreation"/>
|
||||
/// immediately before the send; cleared the moment a matching 0xF643 is
|
||||
/// dispatched (success OR parse failure — a malformed reply must not
|
||||
/// wedge the latch open forever) and on session teardown
|
||||
/// (<see cref="Dispose"/>). Read/written only from the caller's frame
|
||||
/// thread — the same single-threaded invariant every other per-session
|
||||
/// field here (e.g. <see cref="_lastCharacterSelectionError"/>) relies
|
||||
/// on; <see cref="ProcessDatagram"/> is never invoked concurrently with
|
||||
/// a send (see the class doc comment's thread-id probe note).
|
||||
/// </summary>
|
||||
private enum PendingCharGenVerificationRequest
|
||||
{
|
||||
None,
|
||||
Restore,
|
||||
Create,
|
||||
}
|
||||
|
||||
private PendingCharGenVerificationRequest _pendingCharGenVerification =
|
||||
PendingCharGenVerificationRequest.None;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot guard so an unexpected 0xF643 (no outstanding create/restore
|
||||
/// request) logs exactly once per session rather than spamming on a
|
||||
/// misbehaving or replaying server.
|
||||
/// </summary>
|
||||
private bool _loggedUnexpectedCharGenVerificationResponse;
|
||||
|
||||
private readonly IWorldSessionTransport _net;
|
||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||
private long _lastPingRequestTicks;
|
||||
|
|
@ -1823,18 +1868,56 @@ public sealed class WorldSession : IDisposable
|
|||
{
|
||||
CharacterDeleteAcknowledged?.Invoke();
|
||||
}
|
||||
else if (op == CharacterRestore.ResponseOpcode)
|
||||
else if (op == CharGenVerificationResponse.ResponseOpcode)
|
||||
{
|
||||
CharacterRestore.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharacterRestore.Parse(body);
|
||||
}
|
||||
catch
|
||||
// Campaign CC CC2: this opcode is a genuine retail reuse
|
||||
// between CharacterRestore and CharacterCreate responses
|
||||
// (see CharGenVerificationResponse's doc comment) — the
|
||||
// awaiting-request latch is the only thing that tells us
|
||||
// which family a given 0xF643 belongs to. Clear it before
|
||||
// parsing (not after) so a malformed reply can never leave
|
||||
// the latch stuck open, awaiting a response that will now
|
||||
// never come and misattributing whatever arrives next.
|
||||
PendingCharGenVerificationRequest awaited = _pendingCharGenVerification;
|
||||
if (awaited == PendingCharGenVerificationRequest.None)
|
||||
{
|
||||
if (!_loggedUnexpectedCharGenVerificationResponse)
|
||||
{
|
||||
_loggedUnexpectedCharGenVerificationResponse = true;
|
||||
Console.Error.WriteLine(
|
||||
"[session] unexpected CharacterGenerationVerificationResponse "
|
||||
+ "(0xF643) with no outstanding create/restore request — dropped.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
CharacterRestoreReceived?.Invoke(parsed);
|
||||
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
|
||||
|
||||
if (awaited == PendingCharGenVerificationRequest.Restore)
|
||||
{
|
||||
CharacterRestore.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharacterRestore.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CharacterRestoreReceived?.Invoke(parsed);
|
||||
}
|
||||
else
|
||||
{
|
||||
CharGenVerificationResponse.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharGenVerificationResponse.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CharacterCreateResponseReceived?.Invoke(parsed);
|
||||
}
|
||||
}
|
||||
else if (op == CharacterError.Opcode)
|
||||
{
|
||||
|
|
@ -2223,9 +2306,39 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>
|
||||
/// Send retail CharacterRestore through the control queue. This is
|
||||
/// deliberately non-blocking because ACE silently drops unknown guids.
|
||||
/// Arms the awaiting-request latch as <c>Restore</c> BEFORE the send so
|
||||
/// a reply that arrives on a later Tick is never misattributed to a
|
||||
/// different request (Campaign CC CC2).
|
||||
/// </summary>
|
||||
public void SendRestoreCharacter(uint characterId) =>
|
||||
public void SendRestoreCharacter(uint characterId)
|
||||
{
|
||||
_pendingCharGenVerification = PendingCharGenVerificationRequest.Restore;
|
||||
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail CharacterCreate (opcode <c>0xF656</c>) through the
|
||||
/// login/logon queue — <c>Proto_UI::SendCharGenResult</c> routes via
|
||||
/// <c>SendToLogon</c>, the same queue
|
||||
/// <see cref="SendDeleteCharacter"/> uses (see
|
||||
/// <see cref="CharacterCreate"/>'s class doc comment). Deliberately
|
||||
/// non-blocking, matching <see cref="SendRestoreCharacter"/> — ACE
|
||||
/// silently drops a request whose packed account name doesn't match the
|
||||
/// session's own account. Arms the awaiting-request latch as
|
||||
/// <c>Create</c> BEFORE the send (Campaign CC CC2).
|
||||
/// </summary>
|
||||
public void SendCharacterCreation(
|
||||
string accountName,
|
||||
CharacterCreate.Request request,
|
||||
ReadOnlySpan<uint> skillAdvancementClasses)
|
||||
{
|
||||
byte[] body = CharacterCreate.BuildRequestBody(
|
||||
accountName,
|
||||
request,
|
||||
skillAdvancementClasses);
|
||||
_pendingCharGenVerification = PendingCharGenVerificationRequest.Create;
|
||||
SendGameMessage(body, GameMessageGroup.LoginQueue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
|
||||
|
|
@ -3177,6 +3290,13 @@ public sealed class WorldSession : IDisposable
|
|||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||
return;
|
||||
|
||||
// Campaign CC CC2: a teardown mid-flight must not leave a stale
|
||||
// Restore/Create latch behind it — this session object is never
|
||||
// reused (a fresh WorldSession is constructed per connection
|
||||
// attempt), but clearing here keeps the invariant "no outstanding
|
||||
// request survives teardown" true rather than merely true-in-practice.
|
||||
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
|
||||
|
||||
SessionShutdownPlan shutdown = BuildShutdownPlan(
|
||||
CurrentState,
|
||||
_transportNegotiated,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,36 @@ public sealed record EnteredWorldStatusEvent : StatusEvent
|
|||
public required string CharacterName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode
|
||||
/// <c>0xF656</c>). <see cref="Guid"/>/<see cref="Name"/> mirror the shared
|
||||
/// <c>0xF643</c> <c>CharGenVerificationResponse</c> Ok identity payload's own
|
||||
/// field names — deliberately distinct from <see cref="EnteredWorldStatusEvent"/>'s
|
||||
/// <c>characterId</c>/<c>characterName</c>, since retail logs a freshly
|
||||
/// created character straight in without a fresh <c>characterList</c>, so
|
||||
/// this event can precede an <see cref="EnteredWorldStatusEvent"/> for the
|
||||
/// same character rather than replace it.
|
||||
/// </summary>
|
||||
public sealed record CharacterCreatedStatusEvent : StatusEvent
|
||||
{
|
||||
public required uint Guid { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate.
|
||||
/// <see cref="Code"/> is the raw wire
|
||||
/// <c>CharGenVerificationResponse.Code</c> value; <see cref="Name"/> is that
|
||||
/// code's enum member name (e.g. <c>"NameInUse"</c>).
|
||||
/// </summary>
|
||||
public sealed record CreationFailedStatusEvent : StatusEvent
|
||||
{
|
||||
public required uint Code { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PluginLoadedStatusEvent : StatusEvent
|
||||
{
|
||||
public required string Plugin { get; init; }
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ public static class StatusEventParser
|
|||
ParsePluginFailed(root, v, e, t, sessionId),
|
||||
"loginCommandFailed" =>
|
||||
ParseLoginCommandFailed(root, v, e, t, sessionId),
|
||||
"characterCreated" =>
|
||||
ParseCharacterCreated(root, v, e, t, sessionId),
|
||||
"creationFailed" =>
|
||||
ParseCreationFailed(root, v, e, t, sessionId),
|
||||
"disconnected" =>
|
||||
ParseDisconnected(root, v, e, t, sessionId),
|
||||
"exited" =>
|
||||
|
|
@ -131,6 +135,8 @@ public static class StatusEventParser
|
|||
"pluginLoaded" or
|
||||
"pluginFailed" or
|
||||
"loginCommandFailed" or
|
||||
"characterCreated" or
|
||||
"creationFailed" or
|
||||
"disconnected" or
|
||||
"exited";
|
||||
|
||||
|
|
@ -237,6 +243,38 @@ public static class StatusEventParser
|
|||
CharacterName = RequireString(root, "characterName"),
|
||||
};
|
||||
|
||||
private static StatusEvent ParseCharacterCreated(
|
||||
JsonElement root,
|
||||
int v,
|
||||
string e,
|
||||
DateTimeOffset t,
|
||||
string sessionId) =>
|
||||
new CharacterCreatedStatusEvent
|
||||
{
|
||||
V = v,
|
||||
E = e,
|
||||
T = t,
|
||||
SessionId = sessionId,
|
||||
Guid = RequireUInt32(root, "guid"),
|
||||
Name = RequireString(root, "name"),
|
||||
};
|
||||
|
||||
private static StatusEvent ParseCreationFailed(
|
||||
JsonElement root,
|
||||
int v,
|
||||
string e,
|
||||
DateTimeOffset t,
|
||||
string sessionId) =>
|
||||
new CreationFailedStatusEvent
|
||||
{
|
||||
V = v,
|
||||
E = e,
|
||||
T = t,
|
||||
SessionId = sessionId,
|
||||
Code = RequireUInt32(root, "code"),
|
||||
Name = RequireString(root, "name"),
|
||||
};
|
||||
|
||||
private static StatusEvent ParsePluginLoaded(
|
||||
JsonElement root,
|
||||
int v,
|
||||
|
|
|
|||
|
|
@ -207,6 +207,47 @@ public sealed class SessionStatusWriter
|
|||
characterName,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: the retail <c>0xF643</c> Ok response to an outbound
|
||||
/// CharacterCreate — see
|
||||
/// <c>AcDream.Core.Net.Messages.CharGenVerificationResponse</c>. <paramref name="guid"/>
|
||||
/// and <paramref name="name"/> come straight off that response's Ok
|
||||
/// identity payload. This is a distinct event from <see cref="EnteredWorld"/>:
|
||||
/// retail logs a freshly created character straight in without a fresh
|
||||
/// CharacterList (see the shared response type's doc comment), so a
|
||||
/// caller can expect this event to precede an eventual
|
||||
/// <see cref="EnteredWorld"/> for the same character, not replace it.
|
||||
/// </summary>
|
||||
public void CharacterCreated(string sessionId, uint guid, string name) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "characterCreated",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
guid,
|
||||
name,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC2: a non-Ok <c>0xF643</c> response to an outbound
|
||||
/// CharacterCreate. <paramref name="code"/> is the raw wire
|
||||
/// <c>CharGenVerificationResponse.Code</c> value; <paramref name="name"/>
|
||||
/// is that code's enum member name (e.g. <c>"NameInUse"</c>) so a
|
||||
/// launcher can render a readable reason without hard-coding the
|
||||
/// server's numeric-to-dialog mapping itself.
|
||||
/// </summary>
|
||||
public void CreationFailed(string sessionId, uint code, string name) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "creationFailed",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
code,
|
||||
name,
|
||||
});
|
||||
|
||||
public void PluginLoaded(string sessionId, string plugin) =>
|
||||
Write(new
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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