CC2 review fix round: latch scope narrowed, AD-100, creationFailed reason key

F1 (MEDIUM): the correlation-latch docs claimed replies are never
misattributed; in truth an overlapping send OVERWRITES the latch and the
first reply routes to the newest request's event. Narrowed all three doc
sites to the exact contract (single outstanding request; overlap refusal
is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule)
and pinned the overwrite behavior with
OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest.

F2 (LOW): filed register AD-100 for the drop-unless-armed deviation —
retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate
and processes whatever arrives against its persistent verification state.

F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse
(IsCharacterNameAvailable runs twice; the first callback's return exits
only the lambda), so the second reply hitting the drop path during a
connected gate is EXPECTED, not a defect.

F4 (LOW): creationFailed's enum-member key renamed name -> reason and the
ATTEMPTED character name added as name, before any consumer shipped —
one status vocabulary must not give the same key two meanings
(characterCreated.name is a character name). Contract, writer, tailer,
and shape-pinning tests updated in lockstep.

F5 (LOW): the thread-id probe-note pointer now cites
ProbeNetLogOutbound's doc comment, where the note actually lives.

Fidelity fold (reviewer's positive note): the latch is retail's OWN
discriminator one layer down — 0x0055E8B0 case 1 branches on
GetVerificationState()==PENDING (create) vs not (restore) — now cited in
both the latch doc and CharGenVerificationResponse.cs.

Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 13:10:24 +02:00
parent 5eaad2c88c
commit e77ebf100f
12 changed files with 143 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -170,7 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, `enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
`pluginFailed{plugin,error}`, `pluginFailed{plugin,error}`,
`loginCommandFailed{commandIndex,command,error}`, `loginCommandFailed{commandIndex,command,error}`,
`characterCreated{guid,name}`, `creationFailed{code,name}`, `characterCreated{guid,name}`, `creationFailed{code,reason,name}`,
`disconnected{reason}`, `disconnected{reason}`,
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` `exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH (ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
@ -189,11 +189,14 @@ that payload's own field names and to read distinctly from
`enteredWorld` — a freshly created character is logged straight in by `enteredWorld` — a freshly created character is logged straight in by
retail without a fresh `characterList` (see that type's doc comment), so retail without a fresh `characterList` (see that type's doc comment), so
`characterCreated` can precede an `enteredWorld` for the same character `characterCreated` can precede an `enteredWorld` for the same character
rather than replacing it. `creationFailed{code,name}` fires on any non-Ok rather than replacing it. `creationFailed{code,reason,name}` fires on any
reply: `code` is the raw wire `CharGenVerificationResponse.Code` value, non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code`
`name` is that code's enum member name (e.g. `"NameInUse"`) so a reader value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a
gets a stable readable reason without hard-coding the numeric mapping reader gets a stable readable reason without hard-coding the numeric
itself. mapping itself, and `name` is the ATTEMPTED character name so a launcher
can render "the name Bob is taken". (CC2 review F4: the enum member
originally rode the `name` key, colliding in meaning with
`characterCreated.name`; renamed before any consumer shipped.)
`loginCommandFailed.commandIndex` is the zero-based index in the configured `loginCommandFailed.commandIndex` is the zero-based index in the configured
`loginCommands` array. `command` is the exact configured line and `error` is `loginCommands` array. `command` is the exact configured line and `error` is

View file

@ -25,6 +25,13 @@ namespace AcDream.Core.Net.Messages;
/// from "create response" by opcode or shape alone — <c>WorldSession</c> /// from "create response" by opcode or shape alone — <c>WorldSession</c>
/// disambiguates by tracking which outbound request (restore vs. create) it /// disambiguates by tracking which outbound request (restore vs. create) it
/// is awaiting a reply to (see <c>WorldSession</c>'s awaiting-request latch). /// is awaiting a reply to (see <c>WorldSession</c>'s awaiting-request latch).
/// That latch is not merely a reasonable design — it is retail's OWN
/// mechanism: <c>Handle_CharGenVerificationResponse@0x0055E8B0</c> case 1
/// branches on the client's persistent chargen state,
/// <c>GetVerificationState() == PENDING</c> → new <c>CharacterIdentity</c>
/// + <c>AddIdentity</c> (a create it initiated), else → unpack into the
/// existing identity at <c>slot</c> (a restore). Same discriminator, one
/// layer down (CC2 review's fidelity note).
/// </para> /// </para>
/// ///
/// <para> /// <para>

View file

@ -123,6 +123,20 @@ namespace AcDream.Core.Net.Messages;
/// warns about for restore. <c>WorldSession</c>'s awaiting-request latch /// warns about for restore. <c>WorldSession</c>'s awaiting-request latch
/// must never assume a reply is coming. /// must never assume a reply is coming.
/// </para> /// </para>
///
/// <para>
/// <b>ACE double-sends <c>NameInUse</c> (CC2 review F3).</b>
/// <c>CharacterHandler.CharacterCreateEx</c> calls
/// <c>IsCharacterNameAvailable</c> TWICE — once at the top and once after
/// <c>PlayerFactory.Create</c> — and the first callback's <c>return</c>
/// exits only the lambda, so a duplicate name yields TWO <c>0xF643</c>
/// <c>NameInUse</c> replies. The first consumes the latch; the second hits
/// <c>WorldSession</c>'s unrequested-response drop path (register AD-100)
/// and logs "unexpected CharacterGenerationVerificationResponse". During a
/// connected gate against ACE that log line is EXPECTED after a
/// duplicate-name rejection, not an acdream defect — and CC3's verification
/// gate must not treat the second reply as an error.
/// </para>
/// </summary> /// </summary>
public static class CharacterCreate public static class CharacterCreate
{ {

View file

@ -723,16 +723,35 @@ public sealed class WorldSession : IDisposable
/// create requests share that opcode on the wire (see /// create requests share that opcode on the wire (see
/// <see cref="CharGenVerificationResponse"/>'s doc comment) with no /// <see cref="CharGenVerificationResponse"/>'s doc comment) with no
/// self-describing discriminant, so this latch is the only thing that /// self-describing discriminant, so this latch is the only thing that
/// tells the dispatcher which event to fire. Set by /// tells the dispatcher which event to fire. Retail's own discriminator
/// is structurally the same latch: <c>Handle_CharGenVerificationResponse
/// @0x0055E8B0</c> case 1 branches on
/// <c>GetVerificationState() == PENDING</c> → new CharacterIdentity +
/// AddIdentity (create) versus not-pending → unpack into the existing
/// identity at <c>slot</c> (restore). Set by
/// <see cref="SendRestoreCharacter"/> / <see cref="SendCharacterCreation"/> /// <see cref="SendRestoreCharacter"/> / <see cref="SendCharacterCreation"/>
/// immediately before the send; cleared the moment a matching 0xF643 is /// immediately before the send; cleared the moment a matching 0xF643 is
/// dispatched (success OR parse failure — a malformed reply must not /// dispatched (success OR parse failure — a malformed reply must not
/// wedge the latch open forever) and on session teardown /// wedge the latch open forever) and on session teardown
/// (<see cref="Dispose"/>). Read/written only from the caller's frame /// (<see cref="Dispose"/>).
/// thread — the same single-threaded invariant every other per-session ///
/// field here (e.g. <see cref="_lastCharacterSelectionError"/>) relies /// <para>SCOPE, stated exactly (CC2 review F1): this latch correlates
/// on; <see cref="ProcessDatagram"/> is never invoked concurrently with /// the SINGLE outstanding request. It does NOT refuse overlapping
/// a send (see the class doc comment's thread-id probe note). /// requests — a second send while one is outstanding OVERWRITES the
/// latch and the first request's reply is then delivered to the wrong
/// event. Refusing overlap is the CALLER's job, exactly as in retail:
/// <c>gmCharGenMainUI::DoFinish@0x004e9170</c> only sends when the
/// verification state is UNDEF (CC3's Runtime verification gate owns
/// that rule here). The overwrite behavior is pinned by
/// <c>WorldSessionCharacterCreationTests</c> so CC3 cannot silently
/// regress against it.</para>
///
/// <para>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 <see cref="ProbeNetLogOutbound"/>'s doc comment — the
/// #260 thread-id probe note; CC2 review F5 corrected this pointer).</para>
/// </summary> /// </summary>
private enum PendingCharGenVerificationRequest private enum PendingCharGenVerificationRequest
{ {
@ -2306,9 +2325,12 @@ public sealed class WorldSession : IDisposable
/// <summary> /// <summary>
/// Send retail CharacterRestore through the control queue. This is /// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids. /// deliberately non-blocking because ACE silently drops unknown guids.
/// Arms the awaiting-request latch as <c>Restore</c> BEFORE the send so /// Arms the awaiting-request latch as <c>Restore</c> BEFORE the send;
/// a reply that arrives on a later Tick is never misattributed to a /// the latch correlates the SINGLE outstanding request — a second
/// different request (Campaign CC CC2). /// create/restore sent while this one is outstanding overwrites it, and
/// refusing that overlap is the caller's job (CC3's verification gate).
/// See <see cref="PendingCharGenVerificationRequest"/> (Campaign CC
/// CC2).
/// </summary> /// </summary>
public void SendRestoreCharacter(uint characterId) public void SendRestoreCharacter(uint characterId)
{ {
@ -2325,7 +2347,10 @@ public sealed class WorldSession : IDisposable
/// non-blocking, matching <see cref="SendRestoreCharacter"/> — ACE /// non-blocking, matching <see cref="SendRestoreCharacter"/> — ACE
/// silently drops a request whose packed account name doesn't match the /// silently drops a request whose packed account name doesn't match the
/// session's own account. Arms the awaiting-request latch as /// session's own account. Arms the awaiting-request latch as
/// <c>Create</c> BEFORE the send (Campaign CC CC2). /// <c>Create</c> BEFORE the send; the latch correlates the SINGLE
/// outstanding request — overlap refusal is the caller's job (CC3's
/// verification gate; see
/// <see cref="PendingCharGenVerificationRequest"/>) (Campaign CC CC2).
/// </summary> /// </summary>
public void SendCharacterCreation( public void SendCharacterCreation(
string accountName, string accountName,

View file

@ -64,13 +64,19 @@ public sealed record CharacterCreatedStatusEvent : StatusEvent
/// <summary> /// <summary>
/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate. /// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate.
/// <see cref="Code"/> is the raw wire /// <see cref="Code"/> is the raw wire
/// <c>CharGenVerificationResponse.Code</c> value; <see cref="Name"/> is that /// <c>CharGenVerificationResponse.Code</c> value; <see cref="Reason"/> is
/// code's enum member name (e.g. <c>"NameInUse"</c>). /// that code's enum member name (e.g. <c>"NameInUse"</c>);
/// <see cref="Name"/> is the ATTEMPTED character name. The enum member
/// rode the <c>name</c> key until the CC2 review (F4) — same key,
/// different meaning than <c>characterCreated.name</c> — renamed before
/// any consumer shipped.
/// </summary> /// </summary>
public sealed record CreationFailedStatusEvent : StatusEvent public sealed record CreationFailedStatusEvent : StatusEvent
{ {
public required uint Code { get; init; } public required uint Code { get; init; }
public required string Reason { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
} }

View file

@ -272,6 +272,7 @@ public static class StatusEventParser
T = t, T = t,
SessionId = sessionId, SessionId = sessionId,
Code = RequireUInt32(root, "code"), Code = RequireUInt32(root, "code"),
Reason = RequireString(root, "reason"),
Name = RequireString(root, "name"), Name = RequireString(root, "name"),
}; };

View file

@ -232,12 +232,17 @@ public sealed class SessionStatusWriter
/// <summary> /// <summary>
/// Campaign CC CC2: a non-Ok <c>0xF643</c> response to an outbound /// Campaign CC CC2: a non-Ok <c>0xF643</c> response to an outbound
/// CharacterCreate. <paramref name="code"/> is the raw wire /// CharacterCreate. <paramref name="code"/> is the raw wire
/// <c>CharGenVerificationResponse.Code</c> value; <paramref name="name"/> /// <c>CharGenVerificationResponse.Code</c> value; <paramref name="reason"/>
/// is that code's enum member name (e.g. <c>"NameInUse"</c>) so a /// is that code's enum member name (e.g. <c>"NameInUse"</c>) so a
/// launcher can render a readable reason without hard-coding the /// launcher can render a readable reason without hard-coding the
/// server's numeric-to-dialog mapping itself. /// server's numeric-to-dialog mapping itself; <paramref name="name"/>
/// is the ATTEMPTED character name — the thing a launcher most wants to
/// show ("the name Bob is taken"). The key was <c>name</c> for the enum
/// member until the CC2 review (F4): <c>characterCreated.name</c> is a
/// character name, and one status vocabulary must not give the same key
/// two meanings. Renamed before any consumer shipped.
/// </summary> /// </summary>
public void CreationFailed(string sessionId, uint code, string name) => public void CreationFailed(string sessionId, uint code, string reason, string name) =>
Write(new Write(new
{ {
v = VocabularyVersion, v = VocabularyVersion,
@ -245,6 +250,7 @@ public sealed class SessionStatusWriter
t = Now(), t = Now(),
sessionId, sessionId,
code, code,
reason,
name, name,
}); });

View file

@ -199,6 +199,49 @@ public sealed class WorldSessionCharacterCreationTests
Assert.Empty(createEvents); Assert.Empty(createEvents);
} }
/// <summary>
/// CC2 review F1: pins the latch's stated scope EXACTLY. The latch
/// correlates the single outstanding request and does NOT refuse
/// overlap — a second send while one is outstanding OVERWRITES it, so
/// the first request's reply is delivered to the second request's
/// event. Refusing overlap is the caller's job (CC3's Runtime
/// verification gate, mirroring retail's DoFinish UNDEF-state gate).
/// If CC3 (or anyone) changes this transport-level behavior, this test
/// must change WITH it, deliberately.
/// </summary>
[Fact]
public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
Assert.Equal(PendingLatch.Create, ReadPendingLatch(session));
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
// This reply is semantically the RESTORE's — but the overwritten
// latch routes it to the create event. That is the documented
// overwrite behavior, pinned here.
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000001u,
"Restored"));
InvokeProcessDatagram(session, packet);
Assert.Empty(restoreEvents);
Assert.Single(createEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact] [Fact]
public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed() public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed()
{ {

View file

@ -88,16 +88,18 @@ public sealed class StatusEventParserTests
var failed = Assert.IsType<CreationFailedStatusEvent>( var failed = Assert.IsType<CreationFailedStatusEvent>(
StatusEventParser.Parse( StatusEventParser.Parse(
"""{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""")); """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""));
Assert.Equal(3u, failed.Code); Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Name); Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
} }
[Theory] [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\",\"name\":\"NewChar\"}")]
[InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")] [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\",\"reason\":\"NameInUse\",\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3}")] [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"reason\":\"NameInUse\"}")]
public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line) public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line)
{ {
var malformed = Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line)); var malformed = Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));

View file

@ -195,7 +195,7 @@ public sealed class StatusFileTailerTests : IDisposable
AppendShared( AppendShared(
"""{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""" """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}"""
+ "\n" + "\n"
+ """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""" + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""
+ "\n"); + "\n");
var tailer = new StatusFileTailer(_path); var tailer = new StatusFileTailer(_path);
@ -207,7 +207,8 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.Equal("NewChar", created.Name); Assert.Equal("NewChar", created.Name);
var failed = Assert.IsType<CreationFailedStatusEvent>(events[1]); var failed = Assert.IsType<CreationFailedStatusEvent>(events[1]);
Assert.Equal(3u, failed.Code); Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Name); Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
} }
[Fact] [Fact]

View file

@ -110,7 +110,7 @@ public sealed class SessionStatusWriterTests
writer.PluginFailed("s1", "acdream.bad", "failed"); writer.PluginFailed("s1", "acdream.bad", "failed");
writer.LoginCommandFailed("s1", 0, "", "unknown command"); writer.LoginCommandFailed("s1", 0, "", "unknown command");
writer.CharacterCreated("s1", 0x50000001u, "NewChar"); writer.CharacterCreated("s1", 0x50000001u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse"); writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
writer.Disconnected("s1", "stopped"); writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed"); writer.Exited("s1", 0, "disposed");
@ -132,7 +132,7 @@ public sealed class SessionStatusWriterTests
var writer = new SessionStatusWriter(file.Path); var writer = new SessionStatusWriter(file.Path);
writer.CharacterCreated("s1", 0x50000010u, "NewChar"); writer.CharacterCreated("s1", 0x50000010u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse"); writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
string[] lines = File.ReadAllLines(file.Path); string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(2, lines.Length); Assert.Equal(2, lines.Length);
@ -149,8 +149,10 @@ public sealed class SessionStatusWriterTests
Assert.Equal("creationFailed", failed.GetProperty("e").GetString()); Assert.Equal("creationFailed", failed.GetProperty("e").GetString());
Assert.Equal("s1", failed.GetProperty("sessionId").GetString()); Assert.Equal("s1", failed.GetProperty("sessionId").GetString());
Assert.Equal(3u, failed.GetProperty("code").GetUInt32()); Assert.Equal(3u, failed.GetProperty("code").GetUInt32());
Assert.Equal("NameInUse", failed.GetProperty("name").GetString()); Assert.Equal("NameInUse", failed.GetProperty("reason").GetString());
AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name"); Assert.Equal("Bob", failed.GetProperty("name").GetString());
AssertExactProperties(
lines[1], "v", "e", "t", "sessionId", "code", "reason", "name");
} }
[Fact] [Fact]