diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index d2401cde..84c6e8a4 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -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 =>
{
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index ac05cf2f..5401b02d 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -67,19 +67,27 @@ public static class GameEvents
catch { return null; }
}
- /// 0x02EB CommunicationTransientString payload.
- public readonly record struct TransientMessage(string Message, uint ChatType);
-
- public static TransientMessage? ParseTransient(ReadOnlySpan payload)
+ ///
+ /// 0x02EB CommunicationTransientString payload: a bare string, and
+ /// nothing else.
+ ///
+ /// Three oracles agree there is no chat type on this wire. ACE's
+ /// GameEvent/Events/GameEventCommunicationTransientString.cs writes
+ /// exactly one WriteString16L(message). Retail's handler
+ /// ClientCommunicationSystem::Handle_Communication__TransientString
+ /// (0x0057d460) takes a single
+ /// AC1Legacy::PStringBase<char> const* argument. holtburger
+ /// carries no type field for it either.
+ ///
+ /// This parser previously demanded a trailing u32 chatType.
+ /// 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.
+ ///
+ public static string? ParseTransient(ReadOnlySpan 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; }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
index 05cc5289..a254f733 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
@@ -376,17 +376,31 @@ public sealed class GameEventDispatcherTests
[Fact]
public void ParseTransient_RoundTrip()
{
- byte[] msg = MakeString16L("Your spell fizzled!");
- byte[] chatType = new byte[4];
- BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
- byte[] payload = new byte[msg.Length + 4];
- Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
- Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
+ // 0x02EB is a bare string. ACE's
+ // GameEventCommunicationTransientString writes one WriteString16L and
+ // stops; retail's Handle_Communication__TransientString takes a single
+ // string argument. This test used to append a phantom trailing u32
+ // chat type, which is what let the parser's wrong guard look correct.
+ byte[] payload = MakeString16L("Your spell fizzled!");
- var parsed = GameEvents.ParseTransient(payload);
- Assert.NotNull(parsed);
- Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
- Assert.Equal(5u, parsed.Value.ChatType);
+ string? parsed = GameEvents.ParseTransient(payload);
+
+ Assert.Equal("Your spell fizzled!", parsed);
+ }
+
+ ///
+ /// Regression pin: the padded string consumes the whole payload, so a
+ /// parser demanding four more bytes returned null for every real
+ /// transient string the server has ever sent.
+ ///
+ [Fact]
+ public void ParseTransient_ExactAcePayload_IsNotDropped()
+ {
+ foreach (string text in new[] { "", "a", "bb", "ccc", "dddd", "Your spell fizzled!" })
+ {
+ byte[] payload = MakeString16L(text);
+ Assert.Equal(text, GameEvents.ParseTransient(payload));
+ }
}
[Fact]