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

View file

@ -199,6 +199,49 @@ public sealed class WorldSessionCharacterCreationTests
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]
public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed()
{

View file

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

View file

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

View file

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