fix(net): stop dropping every transient string on a chat type that isn't sent

CommunicationTransientString (0x02EB) required a trailing u32 chat type after
the message. The server does not send one. Because the string is padded to a
four-byte boundary, the remaining length after reading it was always zero, the
guard tripped, and the parser returned null for every transient string the
server has ever sent. Not most. Every one.

Three oracles agree there is no such field. ACE's
GameEventCommunicationTransientString writes exactly one WriteString16L and
stops. Retail's ClientCommunicationSystem::Handle_Communication__TransientString
at 0x0057d460 takes a single PStringBase<char> argument. holtburger carries no
type field for the event either.

ParseTransient now returns the string. The wiring supplies chat type 0, which
is ACE's ChatMessageType.Broadcast and which ACE's own LogTextTypeEnumMapper
comment names "Default" — the honest stand-in for a message the server sends
untyped. What retail's transient strings should actually look like is a
rendering question and belongs with the chat colour work, not here.

The existing round-trip test was itself appending the phantom trailing dword,
which is exactly why the wrong guard looked correct for as long as it did. It
is corrected to the real payload and joined by a case sweeping string lengths
zero through four, so no future padding-residue assumption can hide here again.

Core.Net tests go 654 to 655.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 01:58:02 +02:00
parent 6119364306
commit f416c577d6
3 changed files with 51 additions and 23 deletions

View file

@ -107,8 +107,14 @@ public static class GameEventWiring
});
registrar.Register(GameEventType.CommunicationTransientString, e =>
{
var p = GameEvents.ParseTransient(e.Payload.Span);
if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType);
// 0x02EB carries no chat type on the wire (see ParseTransient).
// 0 is ACE's ChatMessageType.Broadcast, which its own
// LogTextTypeEnumMapper comment names "Default" — the right
// stand-in for a message the server sends untyped. The exact
// retail rendering style for transient strings belongs to the
// chat colour/text work, not to this parser.
var s = GameEvents.ParseTransient(e.Payload.Span);
if (s is not null) chat.OnSystemMessage(s, chatType: 0u);
});
registrar.Register(GameEventType.PopupString, e =>
{

View file

@ -67,19 +67,27 @@ public static class GameEvents
catch { return null; }
}
/// <summary>0x02EB CommunicationTransientString payload.</summary>
public readonly record struct TransientMessage(string Message, uint ChatType);
public static TransientMessage? ParseTransient(ReadOnlySpan<byte> payload)
/// <summary>
/// 0x02EB CommunicationTransientString payload: a bare string, and
/// nothing else.
///
/// <para>Three oracles agree there is no chat type on this wire. ACE's
/// <c>GameEvent/Events/GameEventCommunicationTransientString.cs</c> writes
/// exactly one <c>WriteString16L(message)</c>. Retail's handler
/// <c>ClientCommunicationSystem::Handle_Communication__TransientString</c>
/// (0x0057d460) takes a single
/// <c>AC1Legacy::PStringBase&lt;char&gt; const*</c> argument. holtburger
/// carries no type field for it either.</para>
///
/// <para>This parser previously demanded a trailing <c>u32 chatType</c>.
/// Because the string is padded to a 4-byte boundary, the remaining length
/// was always 0, so the guard tripped and every single transient string
/// was dropped.</para>
/// </summary>
public static string? ParseTransient(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
if (payload.Length - pos < 4) return null;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
return new TransientMessage(message, chatType);
}
try { return ReadString16L(payload, ref pos); }
catch { return null; }
}