diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d5428eb..b404dd6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1312,6 +1312,10 @@ public sealed class GameWindow : IDisposable
uint senderGuid = _playerServerGuid != 0u
? _playerServerGuid
: playerGuid;
+ Console.WriteLine(
+ $"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
+ $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
+ $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}");
liveSession.SendTurbineChatTo(
roomId: turbine.Value.RoomId,
chatType: turbine.Value.ChatType,
@@ -1326,7 +1330,22 @@ public sealed class GameWindow : IDisposable
}
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
- if (resolved is null) return;
+ if (resolved is null)
+ {
+ // Diagnostic: the user picked a channel kind that
+ // (a) isn't a Turbine channel TurbineChatState
+ // knows about and (b) has no legacy ChatChannel
+ // mapping. Most common cause: TurbineChat hasn't
+ // been enabled yet (server didn't send 0x0295)
+ // and the kind is General/Trade/LFG/etc.
+ Console.WriteLine(
+ $"chat: SendChatCmd kind={cmd.Channel} dropped " +
+ $"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
+ return;
+ }
+ Console.WriteLine(
+ $"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
+ $"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index db1c3d7..f6c5b2b 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -92,6 +92,18 @@ public static class GameEventWiring
societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom,
societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom,
societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom);
+
+ // Diagnostic: confirm the channel ids landed. Without
+ // this print there's no easy way to tell from the live
+ // log whether ACE actually sent 0x0295 or whether
+ // outbound /g /trade /lfg are silently falling back to
+ // the (broken) legacy ChatChannel path.
+ Console.WriteLine(
+ $"chat: SetTurbineChatChannels parsed enabled={turbineChat.Enabled} " +
+ $"general=0x{p.Value.GeneralRoom:X8} trade=0x{p.Value.TradeRoom:X8} " +
+ $"lfg=0x{p.Value.LfgRoom:X8} roleplay=0x{p.Value.RoleplayRoom:X8} " +
+ $"society=0x{p.Value.SocietyRoom:X8} olthoi=0x{p.Value.OlthoiRoom:X8} " +
+ $"allegiance=0x{p.Value.AllegianceRoom:X8}");
});
}
diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs
index 06e31ce..c2e9aaa 100644
--- a/src/AcDream.Core/Chat/ChatLog.cs
+++ b/src/AcDream.Core/Chat/ChatLog.cs
@@ -111,9 +111,15 @@ public sealed class ChatLog
///
public void OnWeenieError(uint errorId, string? param)
{
- string text = string.IsNullOrEmpty(param)
- ? $"WeenieError 0x{errorId:X4}"
- : $"WeenieError 0x{errorId:X4}: {param}";
+ // Phase I (post-launch fix): translate the wire code into the
+ // retail-faithful template via WeenieErrorMessages. Many codes
+ // are *informational* (e.g. 0x051B "You have entered the X
+ // channel.", 0x051D "Turbine Chat is enabled.") not errors;
+ // the old "WeenieError 0xNNNN" framing was misleading. Unknown
+ // codes still fall back to the raw "WeenieError 0xNNNN[: param]"
+ // form so nothing is silently lost. See
+ // WeenieErrorMessages.Format for the templates + lookup table.
+ string text = WeenieErrorMessages.Format(errorId, param);
Append(new ChatEntry(
Kind: ChatKind.System,
Sender: "",
diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs
new file mode 100644
index 0000000..b5af01e
--- /dev/null
+++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs
@@ -0,0 +1,153 @@
+using System.Collections.Generic;
+
+namespace AcDream.Core.Chat;
+
+///
+/// Translates ACE WeenieError + WeenieErrorWithString codes
+/// into the human-readable templates the retail client showed. The
+/// retail client baked these strings into string_table.bin; for
+/// our purposes we mirror ACE's enum-doc comments
+/// (references/ACE/Source/ACE.Entity/Enum/WeenieError.cs +
+/// WeenieErrorWithString.cs) since they preserve the original
+/// templates verbatim, including the literal _ placeholder where
+/// the parameter goes.
+///
+///
+/// Why we need this: Many of the codes ACE sends — especially
+/// the high-frequency ones the user actually sees in normal play —
+/// are informational, not error-level (e.g. 0x051B =
+/// "You have entered the X channel.", 0x051D = "Turbine Chat
+/// is enabled."). Displaying them as WeenieError 0xNNNN is
+/// noisy and misleading. With the proper template they read as the
+/// retail player would have seen them.
+///
+///
+///
+/// We don't translate every code — only the ~30 most likely to appear
+/// in a normal session. Unknown codes fall back to the
+/// WeenieError 0xNNNN[: param] form so nothing is silently
+/// lost. New codes can be added in 30 seconds when the user reports
+/// one.
+///
+///
+public static class WeenieErrorMessages
+{
+ ///
+ /// Format a WeenieError / WeenieErrorWithString into a human-readable
+ /// system-message string.
+ ///
+ /// The wire error code.
+ /// The interpolated substring (null for plain
+ /// WeenieError, set for WeenieErrorWithString).
+ public static string Format(uint errorCode, string? param)
+ {
+ // WeenieErrorWithString templates use the literal underscore
+ // character `_` as the placeholder for the param. We
+ // substitute it for `param` if present, otherwise drop it.
+ if (param is not null && WithStringTemplates.TryGetValue(errorCode, out var withTemplate))
+ {
+ return withTemplate.Replace("_", param);
+ }
+
+ if (NoParamTemplates.TryGetValue(errorCode, out var template))
+ {
+ // Some "no-param" codes do still arrive with a meaningless
+ // param string; ignore it.
+ return template;
+ }
+
+ // Unknown code — fall back to the raw form.
+ return string.IsNullOrEmpty(param)
+ ? $"WeenieError 0x{errorCode:X4}"
+ : $"WeenieError 0x{errorCode:X4}: {param}";
+ }
+
+ ///
+ /// Codes from WeenieError (no param). Templates copied
+ /// verbatim from ACE enum-doc comments.
+ ///
+ private static readonly Dictionary NoParamTemplates = new()
+ {
+ // Tell-related
+ [0x052B] = "That person is not available now.", // CharacterNotAvailable
+
+ // Chat-channel related
+ [0x051D] = "Turbine Chat is enabled.", // TurbineChatIsEnabled
+
+ // Trade
+ [0x0529] = "Trade Complete!", // TradeComplete
+
+ // Allegiance
+ [0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved
+ [0x0497] = "Your patron's Allegiance to you has been broken!",
+ // YourPatronsAllegianceHasBeenBroken
+ [0x0535] = "You do not have the authority within your allegiance to do that.",
+
+ // Movement / teleport / housing
+ [0x0498] = "You have moved too far!", // YouHaveMovedTooFar
+ [0x0499] = "That is not a valid destination!", // TeleToInvalidPosition
+ [0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.",
+
+ // Fellowship
+ [0x0528] = "The fellowship is locked; you cannot open locked fellowships.",
+
+ // Player flavour
+ [0x0526] = "You chicken out.", // YouChickenOut
+ };
+
+ ///
+ /// Codes from WeenieErrorWithString. Templates copied
+ /// verbatim from ACE enum-doc comments. The _ placeholder
+ /// is substituted with the param at format time.
+ ///
+ private static readonly Dictionary WithStringTemplates = new()
+ {
+ // Channel join / leave (high frequency at login)
+ [0x051B] = "You have entered the _ channel.", // YouHaveEnteredThe_Channel
+ [0x051C] = "You have left the _ channel.", // YouHaveLeftThe_Channel
+
+ // Chat-server failures
+ [0x051E] = "_ will not receive your message, please use urgent assistance to speak with an in-game representative.",
+ [0x051F] = "Message Blocked: _", // MessageBlocked_
+
+ // Hear / loud-list
+ [0x0521] = "_ has been added to the list of people you can hear.",
+ [0x0522] = "_ has been removed from the list of people you can hear.",
+ [0x0525] = "You fail to remove _ from your loud list.",
+
+ // Snooping (admin)
+ [0x052C] = "You are now snooping on _.",
+ [0x052D] = "You are no longer snooping on _.",
+ [0x052E] = "You fail to snoop on _.",
+ [0x052F] = "_ attempted to snoop on you.",
+ [0x0551] = "You are not listening to the _ channel.",
+
+ // Allegiance
+ [0x046A] = "_ doesn't know what to do with that.",
+ [0x0413] = "_ is already one of your followers.",
+ [0x0416] = "_ cannot have any more Vassals.",
+ [0x03EF] = "_ is not accepting gifts right now.",
+
+ // Combat / spell failures
+ [0x004E] = "You fail to affect _ because you cannot affect anyone!",
+ [0x004F] = "You fail to affect _ because they cannot be harmed!",
+ [0x0050] = "You fail to affect _ because beneficial spells do not affect them!",
+ [0x0051] = "You fail to affect _ because you are not a player killer!",
+ [0x0052] = "You fail to affect _ because they are not a player killer!",
+ [0x0053] = "You fail to affect _ because you are not the same sort of player killer as them!",
+ [0x0054] = "You fail to affect _ because you are acting across a house boundary!",
+
+ // Inventory / etiquette
+ [0x001E] = "_ is too busy to accept gifts right now.",
+ [0x002B] = "_ cannot carry anymore.",
+
+ // Fellowship
+ [0x0517] = "_ is not close enough to your level.",
+ [0x0518] = "This fellowship is locked; _ cannot be recruited into the fellowship.",
+
+ // Hooks
+ [0x0510] = "Maximum number of _ hooked.",
+ [0x0514] = "Maximum number of _ hooked until one is removed.",
+ [0x0515] = "You no longer have the maximum number of _ hooked. You may hook additional.",
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
index 1df2bab..eea4925 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
@@ -143,6 +143,14 @@ public static class ChatInputParser
target = rest.Substring(0, targetEnd);
message = rest.Substring(targetEnd + 1).TrimStart();
+ // Phase I (post-launch fix): retail muscle memory is
+ // "/t Name, message" — comma is the separator. Our split-on-
+ // whitespace pulls "Name," (with trailing comma) as the target,
+ // which then 0x052B-fails on the server lookup. Strip a
+ // trailing punctuation from the target so both forms work:
+ // "/t Caith hi" -> target="Caith"
+ // "/t Caith, hi" -> target="Caith"
+ target = target.TrimEnd(',', ';', ':', '.', '!', '?');
if (target.Length == 0 || message.Length == 0) return false;
return true;
}
diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
new file mode 100644
index 0000000..edf47fc
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
@@ -0,0 +1,112 @@
+using AcDream.Core.Chat;
+
+namespace AcDream.Core.Tests.Chat;
+
+///
+/// Tests for . The retail client showed
+/// these as plain-language strings; we mirror that via templated lookup.
+/// Filed after the 2026-04-25 live launch where the user saw cryptic
+/// "WeenieError 0x051B" in chat for what was actually a friendly login
+/// notification.
+///
+public sealed class WeenieErrorMessagesTests
+{
+ // ── known codes — informational, parameterised ───────────────────
+
+ [Fact]
+ public void Format_YouHaveEnteredChannel_SubstitutesParam()
+ {
+ // 0x051B = WeenieErrorWithString.YouHaveEnteredThe_Channel.
+ // Template "You have entered the _ channel." with `_` placeholder.
+ Assert.Equal(
+ "You have entered the General channel.",
+ WeenieErrorMessages.Format(0x051B, "General"));
+ }
+
+ [Fact]
+ public void Format_YouHaveEnteredChannel_WorksForEachChannelName()
+ {
+ Assert.Equal("You have entered the Trade channel.", WeenieErrorMessages.Format(0x051B, "Trade"));
+ Assert.Equal("You have entered the LFG channel.", WeenieErrorMessages.Format(0x051B, "LFG"));
+ Assert.Equal("You have entered the Roleplay channel.",WeenieErrorMessages.Format(0x051B, "Roleplay"));
+ }
+
+ [Fact]
+ public void Format_YouHaveLeftChannel_SubstitutesParam()
+ {
+ Assert.Equal(
+ "You have left the General channel.",
+ WeenieErrorMessages.Format(0x051C, "General"));
+ }
+
+ // ── known codes — informational, no parameter ────────────────────
+
+ [Fact]
+ public void Format_TurbineChatIsEnabled_NoParamForm()
+ {
+ // 0x051D came in WeenieError (no param) form at login.
+ Assert.Equal(
+ "Turbine Chat is enabled.",
+ WeenieErrorMessages.Format(0x051D, param: null));
+ }
+
+ // ── known codes — error-level ────────────────────────────────────
+
+ [Fact]
+ public void Format_CharacterNotAvailable_NoParam()
+ {
+ // 0x052B fired by the server when a Tell target lookup fails
+ // (e.g. the user typed "/t je, hello" → server got "je," → no
+ // character). Should read like the retail message.
+ Assert.Equal(
+ "That person is not available now.",
+ WeenieErrorMessages.Format(0x052B, param: null));
+ }
+
+ [Fact]
+ public void Format_TradeComplete()
+ {
+ Assert.Equal("Trade Complete!", WeenieErrorMessages.Format(0x0529, null));
+ }
+
+ // ── unknown codes — graceful fallback preserves debug info ───────
+
+ [Fact]
+ public void Format_UnknownCode_NoParam_FallsBackToHexForm()
+ {
+ Assert.Equal("WeenieError 0xABCD", WeenieErrorMessages.Format(0xABCD, null));
+ }
+
+ [Fact]
+ public void Format_UnknownCode_WithParam_FallsBackToColonForm()
+ {
+ Assert.Equal(
+ "WeenieError 0xDEAD: Mana Stone",
+ WeenieErrorMessages.Format(0xDEAD, "Mana Stone"));
+ }
+
+ [Fact]
+ public void Format_UnknownCode_EmptyParam_StaysAsHexOnly()
+ {
+ // Empty string param shouldn't add a stray colon.
+ Assert.Equal("WeenieError 0xCAFE", WeenieErrorMessages.Format(0xCAFE, ""));
+ }
+
+ // ── parameterised templates with non-trivial params ──────────────
+
+ [Fact]
+ public void Format_HearListAdded_SubstitutesParam()
+ {
+ Assert.Equal(
+ "Caith has been added to the list of people you can hear.",
+ WeenieErrorMessages.Format(0x0521, "Caith"));
+ }
+
+ [Fact]
+ public void Format_FailToAffectCannotBeHarmed_SubstitutesParam()
+ {
+ Assert.Equal(
+ "You fail to affect Drudge because they cannot be harmed!",
+ WeenieErrorMessages.Format(0x004F, "Drudge"));
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
index 0ca465e..9bfb368 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
@@ -71,6 +71,30 @@ public sealed class ChatInputParserTests
Assert.Equal("Lancelot hello", parsed.Value.Text);
}
+ [Theory]
+ [InlineData("/t Caith, hi", "Caith")]
+ [InlineData("/t Caith: hi", "Caith")]
+ [InlineData("/t Caith; hi", "Caith")]
+ [InlineData("/t Caith. hi", "Caith")]
+ [InlineData("/t Caith! hi", "Caith")]
+ [InlineData("/t Caith? hi", "Caith")]
+ [InlineData("/tell Caith, hi", "Caith")]
+ public void TellPrefix_StripsTrailingPunctuationFromTarget(string raw, string expectedTarget)
+ {
+ // Retail muscle memory: "/t Name, message" — comma is the
+ // separator. Filed after a 2026-04-25 live-launch session
+ // where typing "/t je, hello" produced target="je," and the
+ // server responded with WeenieError 0x052B (CharacterNotAvailable)
+ // because no character "je," exists. Strip a trailing
+ // ,;:.!? from the target so both forms work.
+ var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
+ Assert.Equal(expectedTarget, parsed.Value.TargetName);
+ Assert.Equal("hi", parsed.Value.Text);
+ }
+
// -- Reply aliases ---------------------------------------------------
[Fact]