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>
148 lines
6.6 KiB
C#
148 lines
6.6 KiB
C#
using AcDream.Core.Net.Packets;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Retail character-restore request (opcode <c>0xF7D9</c>) and its response
|
|
/// (opcode <c>0xF643</c>).
|
|
///
|
|
/// <para>
|
|
/// <b>Request — guid-only, an ADAPTATION (register row AD-97).</b> Retail
|
|
/// really does send more than the guid. The PDB-paired binary at
|
|
/// <c>CPlayerSystem::RestoreCharacter@0x0055d760</c> is 26 bytes:
|
|
/// <c>push 0x008173B4; push 0x008173B4; push guid;
|
|
/// call Proto_UI::SendAdminRestoreCharacter@0x00546cf0</c> — two REAL
|
|
/// constant <c>PStringBase<char>*</c> arguments (Binary Ninja renders
|
|
/// them as an uninitialized <c>edx</c> local and <c>this</c>; that
|
|
/// rendering is the artifact, the two <c>push imm32</c> are not).
|
|
/// <c>SendAdminRestoreCharacter</c> packs both
|
|
/// (<c>PStringBase::Pack@0x004fc6f0</c> emits ≥4 bytes even for an empty
|
|
/// string), so retail's request is ≥16 bytes where ours is 8. We send
|
|
/// guid-only because ACE
|
|
/// (<c>CharacterHandler.CharacterRestore</c>,
|
|
/// <c>ACE.Server/Network/Handlers/CharacterHandler.cs:331-385</c>) reads
|
|
/// only <c>ReadUInt32()</c> and ignores any tail, and holtburger
|
|
/// (<c>holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData</c>,
|
|
/// sent from a real client command path) ships guid-only against ACE
|
|
/// successfully. The omitted tail is a recorded retail deviation —
|
|
/// divergence register AD-97.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>LA7b hazards.</b> (1) ACE's restore handler has a SILENT no-reply
|
|
/// path: an unknown guid hits
|
|
/// <c>Characters.SingleOrDefault(...) == null → return;</c> — no 0xF643,
|
|
/// no 0xF659. Selection state must never await a restore reply
|
|
/// unconditionally. (2) Routing: ACE sends the response on
|
|
/// <c>GameMessageGroup.UIQueue</c>; retail transmits the request via
|
|
/// <c>Proto_UI::SendToControl</c> (the delete request goes via
|
|
/// <c>SendToLogon</c>) — relevant when LA7b picks the outbound queue.
|
|
/// </para>
|
|
///
|
|
/// <code>
|
|
/// u32 opcode (0xF7D9)
|
|
/// u32 characterGuid
|
|
/// </code>
|
|
///
|
|
/// <para>
|
|
/// <b>Response — opcode collision with CharacterCreateResponse.</b> ACE's
|
|
/// own <c>GameMessageOpcode.cs</c> declares both
|
|
/// <c>CharacterCreateResponse = 0xF643</c> and
|
|
/// <c>CharacterRestoreResponse = 0xF643, // This is a duplicate...</c> — a
|
|
/// genuine retail opcode reuse, not an ACE bug. <c>GameMessageCharacterRestore</c>
|
|
/// (<c>ACE.Server/Network/GameMessages/Messages/GameMessageCharacterRestore.cs</c>)
|
|
/// unconditionally writes a success shape:
|
|
/// </para>
|
|
///
|
|
/// <code>
|
|
/// u32 opcode (0xF643)
|
|
/// u32 verificationFlag (1 = Ok, matching CharacterGenerationVerificationResponse.Ok)
|
|
/// u32 characterGuid
|
|
/// String16L characterName
|
|
/// u32 secondsGreyedOut
|
|
/// </code>
|
|
///
|
|
/// <para>
|
|
/// But ACE's <c>CharacterRestore</c> handler can ALSO reply on this same
|
|
/// opcode via the character-CREATE response path when restore itself fails
|
|
/// — TWO real branches: <c>NameInUse</c> (the freed name collided) and
|
|
/// <c>Corrupt</c> (<c>SaveCharacter</c> returned false). Both shapes are
|
|
/// flag-only, with NO trailing fields
|
|
/// (<c>GameMessageCharacterCreateResponse.cs</c>: the guid / name /
|
|
/// trailing u32 are only written <c>if (response == ... .Ok)</c>).
|
|
/// <see cref="Parse"/> mirrors that conditionality: the trailing three
|
|
/// 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
|
|
/// (<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
|
|
{
|
|
public const uint RequestOpcode = 0xF7D9u;
|
|
public const uint ResponseOpcode = 0xF643u;
|
|
|
|
/// <summary>
|
|
/// Restore response body. <see cref="Guid"/>, <see cref="Name"/>, and
|
|
/// <see cref="SecondsGreyedOut"/> are only populated when
|
|
/// <see cref="VerificationFlag"/> equals 1 (Ok) — retail omits them
|
|
/// entirely on the wire otherwise (see the collision note above).
|
|
/// </summary>
|
|
public readonly record struct Parsed(
|
|
uint VerificationFlag,
|
|
uint? Guid,
|
|
string? Name,
|
|
uint? SecondsGreyedOut)
|
|
{
|
|
/// <summary>True when the trailing character fields are present.</summary>
|
|
public bool IsOk => VerificationFlag == 1u;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the body bytes for an outbound <c>CharacterRestore</c> request.
|
|
/// Layout: opcode(4) + characterGuid(4). Guid-only — an adaptation of
|
|
/// retail's ≥16-byte shape; see the class doc comment and divergence
|
|
/// register AD-97.
|
|
/// </summary>
|
|
public static byte[] BuildRequestBody(uint characterGuid)
|
|
{
|
|
var w = new PacketWriter(8);
|
|
w.WriteUInt32(RequestOpcode);
|
|
w.WriteUInt32(characterGuid);
|
|
return w.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse a <c>CharacterRestore</c> response body (opcode <c>0xF643</c>).
|
|
/// <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)
|
|
{
|
|
CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body);
|
|
return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut);
|
|
}
|
|
}
|