diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 74c4682f..ccdf8f7b 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -363,6 +363,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// its own authored background sprite (0x0600113A); same reasoning as the
// transcript above.
c.Input.SpriteResolve = resolve;
+ // Campaign CT slice B2: expand "/r " to "@tell {LastTeller}, " as the
+ // space lands, so the player sees the target before pressing enter.
+ c.Input.TextReplacer = text =>
+ ChatTextReplacements.Expand(text, vm.LastIncomingTellSender);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(
text, vm, busProvider(), c._activeChannel, c._tellTarget);
diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs
index 43746a14..7f025c5f 100644
--- a/src/AcDream.App/UI/UiField.cs
+++ b/src/AcDream.App/UI/UiField.cs
@@ -156,6 +156,19 @@ public sealed class UiField : UiElement
// ── Editing primitives ──────────────────────────────────────────────
+ ///
+ /// Offered the field's text each time a SPACE is typed at the end of it;
+ /// return a replacement to rewrite the field, or to
+ /// leave it alone.
+ ///
+ ///
+ /// Retail's typed-abbreviation expansion — "/r " becoming
+ /// "@tell {LastTeller}, " — lives here rather than in the submit path
+ /// because the player is meant to SEE who they are about to reply to
+ /// before pressing enter.
+ ///
+ public Func? TextReplacer { get; set; }
+
public void InsertChar(char c)
{
if (!Editable) return;
@@ -169,6 +182,19 @@ public sealed class UiField : UiElement
_text = _text.Insert(_caret, c.ToString());
_caret++;
_historyIndex = -1;
+
+ // Retail expands a typed abbreviation the moment the SPACE lands —
+ // ChatInterface::HandleTextReplacements @0x004F50D0 runs off the
+ // character-typed broadcast, keyed on 0x20. Only on space, and only
+ // when the caret is at the end, so it cannot rewrite text a player is
+ // editing in the middle of.
+ if (c == ' '
+ && TextReplacer is { } replace
+ && _caret == _text.Length
+ && replace(_text) is { } replacement)
+ {
+ SetText(replacement);
+ }
}
public void Backspace()
diff --git a/src/AcDream.Runtime/Chat/ChatTextReplacements.cs b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs
new file mode 100644
index 00000000..6a8ff460
--- /dev/null
+++ b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs
@@ -0,0 +1,76 @@
+namespace AcDream.Runtime.Chat;
+
+///
+/// Retail's typed-abbreviation expansion in the chat entry.
+///
+///
+///
+/// Typing /r rewrites the entry to @tell {LastTeller}, the
+/// moment the space lands, so the player SEES who they are replying to before
+/// pressing enter. ChatInterface::HandleTextReplacements @0x004F50D0
+/// runs off the character-typed broadcast and delegates to
+/// SetReplyTextInChatBox @0x004F4760.
+///
+///
+/// This is display sugar, not routing: /r hello already SENT correctly
+/// without it (ChatInputParser's reply aliases). What was missing is
+/// that the player could not see the target.
+///
+///
+public static class ChatTextReplacements
+{
+ ///
+ /// The reply abbreviations, recovered from the constant pool rather than
+ /// the decompiled listing — Binary Ninja renders these as bare
+ /// data_* references.
+ ///
+ ///
+ /// data_7C4C70 = "r ", data_7C4C68 = "rp ",
+ /// data_7C4C58 = "reply ". Retail stores them WITHOUT the
+ /// leading prefix character and checks that separately, which is why both
+ /// prefixes work.
+ ///
+ private static readonly string[] ReplyVerbs = ["r", "rp", "reply"];
+
+ ///
+ /// Retail accepts either command prefix here —
+ /// SetReplyTextInChatBox tests the first character against
+ /// 0x2F ('/') OR 0x40 ('@') before comparing the verb.
+ ///
+ private const string CommandPrefixes = "/@";
+
+ ///
+ /// The expansion for , or
+ /// when it is not a reply abbreviation.
+ ///
+ ///
+ /// The last person to tell us. When absent, retail performs NO expansion —
+ /// it leaves the text alone rather than producing a tell addressed to
+ /// nobody, and the ordinary submit path is what reports
+ /// "Someone must @tell you first!".
+ ///
+ public static string? Expand(string? text, string? lastTeller)
+ {
+ if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(lastTeller))
+ return null;
+ if (CommandPrefixes.IndexOf(text[0]) < 0)
+ return null;
+
+ // The trailing space is part of the trigger: retail expands ON the
+ // space, so "/r" alone must be left alone — the player may still be
+ // typing "/roleplay".
+ foreach (string verb in ReplyVerbs)
+ {
+ if (text.Length == verb.Length + 2
+ && text[^1] == ' '
+ && string.Compare(
+ text, 1, verb, 0, verb.Length,
+ StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ return $"@tell {lastTeller}, ";
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs
index 21abf165..3184f7d9 100644
--- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs
@@ -200,4 +200,62 @@ public class UiFieldTests
Assert.Equal("a\nb", input.Text);
Assert.Equal(0, submissions);
}
+
+ // ── CT-B2: typed-abbreviation expansion ─────────────────────────────
+
+ [Fact]
+ public void TypingASpaceOffersTheTextToTheReplacer()
+ {
+ var input = new UiField();
+ input.TextReplacer = text => text == "/r " ? "@tell Dww, " : null;
+
+ foreach (char c in "/r ")
+ input.InsertChar(c);
+
+ Assert.Equal("@tell Dww, ", input.Text);
+ Assert.Equal("@tell Dww, ".Length, input.CaretPos);
+ }
+
+ [Fact]
+ public void ANonSpaceCharacterNeverTriggersTheReplacer()
+ {
+ // Retail keys the expansion on 0x20 specifically; running it on every
+ // keystroke would rewrite text out from under someone mid-word.
+ int calls = 0;
+ var input = new UiField();
+ input.TextReplacer = _ => { calls++; return null; };
+
+ foreach (char c in "/reply")
+ input.InsertChar(c);
+
+ Assert.Equal(0, calls);
+ }
+
+ [Fact]
+ public void EditingInTheMiddleOfALineIsNotRewritten()
+ {
+ // The caret is not at the end, so the player is editing existing text
+ // rather than typing an abbreviation — expanding here would corrupt a
+ // sentence they are part way through fixing.
+ var input = new UiField();
+ input.SetText("/r hello");
+ input.MoveCaret(-5); // caret sits just after "/r"
+ input.TextReplacer = _ => "@tell Dww, ";
+
+ input.InsertChar(' ');
+
+ Assert.Equal("/r hello", input.Text);
+ }
+
+ [Fact]
+ public void AReplacerReturningNullLeavesTheTextExactlyAsTyped()
+ {
+ var input = new UiField();
+ input.TextReplacer = _ => null;
+
+ foreach (char c in "hi ")
+ input.InsertChar(c);
+
+ Assert.Equal("hi ", input.Text);
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs
new file mode 100644
index 00000000..36080908
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs
@@ -0,0 +1,71 @@
+using AcDream.Runtime.Chat;
+
+namespace AcDream.Runtime.Tests.Chat;
+
+///
+/// Campaign CT slice B2: retail expands a typed reply abbreviation the moment
+/// the space lands, so the player can see who they are replying to.
+///
+public sealed class ChatTextReplacementsTests
+{
+ private const string LastTeller = "Dww";
+
+ [Theory]
+ // Recovered from the constant pool: data_7C4C70 "r ", data_7C4C68 "rp ",
+ // data_7C4C58 "reply " — and SetReplyTextInChatBox accepts either command
+ // prefix, testing the first character against '/' (0x2F) or '@' (0x40).
+ [InlineData("/r ")]
+ [InlineData("/rp ")]
+ [InlineData("/reply ")]
+ [InlineData("@r ")]
+ [InlineData("@rp ")]
+ [InlineData("@reply ")]
+ [InlineData("/R ")]
+ public void AReplyAbbreviationExpandsToATellAtTheLastTeller(string typed)
+ => Assert.Equal("@tell Dww, ", ChatTextReplacements.Expand(typed, LastTeller));
+
+ [Fact]
+ public void TheExpansionEndsInASpaceSoTypingContinuesCleanly()
+ {
+ // Without it the first character the player types joins the comma.
+ string expanded = ChatTextReplacements.Expand("/r ", LastTeller)!;
+ Assert.EndsWith(", ", expanded);
+ }
+
+ [Theory]
+ // Still being typed — "/r" could yet become "/roleplay", so expanding
+ // before the space would hijack a different command mid-word.
+ [InlineData("/r")]
+ [InlineData("/rep")]
+ // A longer verb that merely STARTS with a reply verb.
+ [InlineData("/roleplay ")]
+ [InlineData("/rt ")]
+ // Already has a message: the trigger is the abbreviation ALONE.
+ [InlineData("/r hello ")]
+ // Not a command at all.
+ [InlineData("r ")]
+ [InlineData("hello ")]
+ [InlineData("")]
+ public void AnythingElseIsLeftAlone(string typed)
+ => Assert.Null(ChatTextReplacements.Expand(typed, LastTeller));
+
+ [Fact]
+ public void WithNobodyToReplyToNothingIsRewritten()
+ {
+ // Retail performs no expansion rather than producing a tell addressed
+ // to nobody; the ordinary submit path is what says
+ // "Someone must @tell you first!".
+ Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: null));
+ Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: string.Empty));
+ }
+
+ [Fact]
+ public void ANameWithSpacesIsPreserved()
+ {
+ // AC names can be multiple words, and retail's own tell syntax needs
+ // the comma precisely because of that.
+ Assert.Equal(
+ "@tell Aunt Agatha, ",
+ ChatTextReplacements.Expand("/r ", "Aunt Agatha"));
+ }
+}