acdream/src/AcDream.Core.Net/Messages/CharacterRestore.cs
Erik 5eaad2c88c feat(net,runtime): Campaign CC CC2 — CharacterCreate wire, 0xF643 correlation, creation status events
Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
  Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
  -> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
  then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
  u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
  order), template, 6 attributes, slot, classId, numSkills + exactly 55
  u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
  ArgumentException otherwise — ACE terminates the session on any other
  count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
  String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
  exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
  eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
  template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
  accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
  trousersColor/footwearColor/shades/slot/classId are deliberately absent
  from the sum despite sitting adjacent on the wire. Cross-checked against
  ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
  CharacterCreateRequestData (types.rs:236-369), which agree on every
  field and order. Retail routes via SendToLogon — the same queue
  CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
  out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
  ACE's CharacterGenerationVerificationResponse) plus the conditional
  Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
  CharacterRestore.Parse now delegates to it; CharacterRestore's public
  Parsed shape, Parse signature, and every existing test expectation are
  UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.

WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
  SendRestoreCharacter/the new SendCharacterCreation immediately before
  each send (SendCharacterCreation builds the body first so a skill-count
  throw never arms the latch for a request that was never sent), cleared
  the instant a matching 0xF643 is dispatched (success OR parse failure —
  a malformed reply must never wedge the latch open) and on Dispose.
  0xF643 now routes to CharacterRestoreReceived or the new
  CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
  by that latch; an unexpected 0xF643 with nothing outstanding logs once
  and is dropped, never misattributed. Fixed
  WorldSessionCharacterSelectionTests' restore-dispatch test, which
  previously fed a bare CharacterRestore response with no preceding
  SendRestoreCharacter — that shape is now the "no outstanding request"
  drop path by design.

Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
  vocabulary to add characterCreated{guid,name} (Ok reply identity, named
  to mirror CharGenVerificationResponse's own fields and to read distinct
  from enteredWorld — retail logs a freshly created character straight in
  without a fresh characterList) and creationFailed{code,name} (raw Code
  value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
  contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
  StatusEventParser cases, in lockstep.

Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.

Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:49:52 +02:00

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&lt;char&gt;*</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);
}
}