feat(net): Campaign LA LA7a — CharacterDelete/CharacterRestore/CharacterError wire messages
Ports the three character-management wire messages LA7 (design spec §7, plan §11 item 4) identified as missing before the character-select screen (LA8) can be built: delete, restore, and the server error channel. Message types + tests only — no WorldSession/Runtime/UI wiring, that is LA7b. CharacterDelete (0xF655): outbound account+SLOT-INDEX request per Proto_UI::SendDeleteCharacter@0x00546b30 (retail packs the account as String16L then writes the trailing u32 directly after — NOT the character guid; CPlayerSystem::DeleteCharacter@0x0055f830 resolves that slot via CharacterSet::GetSlot before sending). The server's ack reuses the same opcode with an empty body (ACE GameMessageCharacterDelete.cs); a fresh CharacterList follows separately per CharacterHandler.cs:322 — that refresh flow is explicitly out of scope here (LA7b). CharacterRestore (0xF7D9 request / 0xF643 response): guid-only request, per ACE (CharacterHandler.cs:331-385, ReadUInt32 only) and holtburger (CharacterRestoreRequestData, guid-only) independent consensus. The decompiled call site (Proto_UI::SendAdminRestoreCharacter@0x00546cf0) appears to pack two extra strings, but its only caller (CPlayerSystem::RestoreCharacter@0x0055d760) passes an uninitialized local (`class PStringBase<char>* edx;`, never assigned) as the second argument and `this` (a CPlayerSystem*, not a string) as the third — textbook decompiler register-corruption, not real arguments. No divergence-register row: this follows the correct reading of a corrupted decompile, not a deviation from retail (spec §11 item 4). The response reuses opcode 0xF643, a genuine retail collision with CharacterCreateResponse (ACE's own comment: "This is a duplicate...", GameMessageOpcode.cs:42); GameMessageCharacterRestore.cs always writes a success shape (flag=1 + guid + name + secondsGreyedOut), but retail's CharacterRestore handler can also reply via the CharacterCreateResponse path on failure (e.g. NameInUse) with a flag-only body and no trailing fields — the parser mirrors that conditionality instead of assuming the four fields are always present. CharacterError (0xF659): u32 error code, confirmed directly from retail's inbound dispatcher UIQueueManager::ProcessNetBlobData@0x0055b000 -> CPlayerSystem::Handle_CharacterError@0x0055d5d0, which reads `enum charError` straight off the wire. The Code enum is a verbatim port of retail's own enum charError (docs/research/named-retail/acclient.h: 4038-4067, 26 members incl. CHAR_ERROR_NUM_ERRORS) rather than a subset filtered through ACE — retail's header names four members ACE's C# CharacterError enum omits (LoggedOn, NoPremade, AccountInUse, CharacterIsBooted) because ACE's server never sends them, though a genuine retail server could. The 32-bit storage-width compiler sentinel FORCE_charError_32_BIT is deliberately excluded (not a real value). Unknown codes never throw — RawErrorCode always preserves the wire value. Today acdream cannot surface any character-stage server error; this is the first parser for the family. 46 new tests (byte-exact builder assertions, ACE-serializer-shaped parser fixtures via the existing AceWireWriter test helper, all 26 retail error codes round-tripped, unknown/truncated/wrong-opcode handling). Full Core.Net.Tests suite: 951 passed, 0 failed, 0 skipped. Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7a839cba71
commit
6a32f37589
6 changed files with 811 additions and 0 deletions
140
src/AcDream.Core.Net/Messages/CharacterRestore.cs
Normal file
140
src/AcDream.Core.Net/Messages/CharacterRestore.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System.Buffers.Binary;
|
||||
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, by reference consensus.</b> The decompiled call
|
||||
/// site (<c>Proto_UI::SendAdminRestoreCharacter</c> at <c>0x00546cf0</c>,
|
||||
/// declared with three parameters — a u32 and two <c>PStringBase<char></c>
|
||||
/// pointers — and packing two strings after the u32) LOOKS like it sends
|
||||
/// guid + two strings. It does not: its only real caller,
|
||||
/// <c>CPlayerSystem::RestoreCharacter</c> at <c>0x0055d760</c>, declares
|
||||
/// <c>class PStringBase<char>* edx;</c> as a local and passes it
|
||||
/// straight through UNINITIALIZED as the second argument, and passes
|
||||
/// <c>this</c> (a <c>CPlayerSystem*</c>, not a string) as the third. Both
|
||||
/// are textbook decompiler register-corruption artifacts (uninitialized
|
||||
/// register reuse + a mistyped extra parameter from an over-declared
|
||||
/// callee signature), not real arguments the real call site ever
|
||||
/// supplied. ACE
|
||||
/// (<c>CharacterHandler.CharacterRestore</c>,
|
||||
/// <c>ACE.Server/Network/Handlers/CharacterHandler.cs:331-385</c>, reads
|
||||
/// only <c>ReadUInt32()</c>) and holtburger
|
||||
/// (<c>holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData</c>,
|
||||
/// guid-only) independently agree on guid-only. We follow the two
|
||||
/// independent, uncorrupted references (design spec §11 item 4 — wire
|
||||
/// consensus, no divergence-register row needed: this isn't a deviation
|
||||
/// from retail, it's picking the correct reading of a corrupted decompile).
|
||||
/// </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 retail's <c>CharacterRestore</c> handler can ALSO reply on this same
|
||||
/// opcode via the character-CREATE response path when restore itself fails
|
||||
/// (e.g. <c>SendCharacterCreateResponse(session, CharacterGenerationVerificationResponse.NameInUse)</c>
|
||||
/// when the freed name collides) — that shape is 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 (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.
|
||||
/// </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 — see the class doc
|
||||
/// comment for why the decompiled call site's apparent extra strings
|
||||
/// are not real.
|
||||
/// </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.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue