diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs
new file mode 100644
index 00000000..edf77892
--- /dev/null
+++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs
@@ -0,0 +1,147 @@
+namespace AcDream.Core.Chat;
+
+///
+/// One retail text tag: the TYPE, FORMAT and DATA of a
+/// <TYPE:FORMAT:DATA> marker.
+///
+///
+/// The tag kind, e.g. Tell. Retail resolves this through the DAT
+/// EnumMapper (category 0x18) to the numeric type its colour rule keys
+/// on; only Tell is confirmed in the build we target.
+///
+///
+/// The payload shape, e.g. IIDString. Retail has four
+/// (DID, IID, IIDEnum, IIDString) but only
+/// IIDString has a listener.
+///
+/// Everything after the second colon, unparsed.
+public readonly record struct ChatTextTag(string Type, string Format, string Data)
+{
+ ///
+ /// The IIDString payload: an object id and a name. Returns
+ /// for any other format, or a malformed payload.
+ ///
+ ///
+ /// The name may itself contain colons, so only the FIRST colon separates
+ /// the id from the name — retail's
+ /// TextTag_IIDString::ParseStartTag @0x00478910 reads the id then
+ /// takes the remainder verbatim.
+ ///
+ public bool TryGetIidString(out uint objectId, out string name)
+ {
+ objectId = 0;
+ name = string.Empty;
+ if (!string.Equals(Format, "IIDString", StringComparison.Ordinal))
+ return false;
+
+ int split = Data.IndexOf(':');
+ if (split <= 0 || split == Data.Length - 1)
+ return false;
+ if (!uint.TryParse(
+ Data.AsSpan(0, split),
+ System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out objectId))
+ {
+ return false;
+ }
+
+ name = Data[(split + 1)..];
+ return true;
+ }
+}
+
+/// One stretch of chat text, and the tag covering it (if any).
+public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag);
+
+///
+/// Parses retail's inline chat tag markup into spans.
+///
+///
+///
+/// Retail's client composes chat lines with the markup already embedded —
+/// ClientCommunicationSystem::Handle_Communication__HearSpeech
+/// @0x005712A0 sprintf's it — and the text element recognises it while
+/// appending (UIElement_Text::InqGlyphs @0x00468EA0), calling
+/// TextTagFactory::MakeTag @0x00478480 per marker. A tagged speaker
+/// name arrives looking like:
+///
+///
+/// <Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, "hello"
+///
+///
+/// What closes a tag is the absence of a colon, not the backslash.
+/// MakeTag requires a : to succeed, so ANY bracketed text it
+/// cannot parse closes the open tag — the backslash in retail's own closer
+/// (TextTag::BuildEndTag @0x00479190) is incidental to that rule, not
+/// the mechanism. Porting "a closer starts with a backslash" would look right
+/// on every retail line and then diverge on everything else.
+///
+///
+public static class ChatTagMarkup
+{
+ ///
+ /// Splits into spans, consuming the markers.
+ ///
+ ///
+ /// An unterminated < (no closing > anywhere after it)
+ /// is ordinary text — there is no marker to consume.
+ ///
+ public static IReadOnlyList Parse(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return Array.Empty();
+ if (text.IndexOf('<') < 0)
+ return new[] { new ChatTextSpan(text, null) };
+
+ var spans = new List();
+ ChatTextTag? open = null;
+ int runStart = 0;
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ if (text[i] != '<')
+ continue;
+
+ int close = text.IndexOf('>', i + 1);
+ if (close < 0)
+ break; // unterminated: the rest is plain text
+
+ // Flush the text before this marker under whatever tag was open.
+ if (i > runStart)
+ spans.Add(new ChatTextSpan(text[runStart..i], open));
+
+ open = TryParseStartTag(text[(i + 1)..close]);
+ runStart = close + 1;
+ i = close;
+ }
+
+ if (runStart < text.Length)
+ spans.Add(new ChatTextSpan(text[runStart..], open));
+
+ return spans;
+ }
+
+ ///
+ /// The marker's tag, or if it does not parse as one
+ /// — which is retail's close.
+ ///
+ private static ChatTextTag? TryParseStartTag(string inner)
+ {
+ int first = inner.IndexOf(':');
+ if (first <= 0 || first == inner.Length - 1)
+ return null;
+
+ int second = inner.IndexOf(':', first + 1);
+ if (second < 0)
+ {
+ // TYPE:FORMAT with no payload. Still a tag — the data is empty.
+ return new ChatTextTag(inner[..first], inner[(first + 1)..], string.Empty);
+ }
+
+ return new ChatTextTag(
+ inner[..first],
+ inner[(first + 1)..second],
+ inner[(second + 1)..]);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs
new file mode 100644
index 00000000..c5ff5046
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs
@@ -0,0 +1,118 @@
+using AcDream.Core.Chat;
+
+namespace AcDream.Core.Tests.Chat;
+
+///
+/// Retail's inline chat tag markup — the mechanism behind the green, clickable
+/// speaker name.
+///
+public sealed class ChatTagMarkupTests
+{
+ /// A retail tell line, exactly as the client composes it.
+ private const string TellLine =
+ @"Dww<\Tell> tells you, ""hello""";
+
+ [Fact]
+ public void ARetailTellLineSplitsIntoATaggedNameAndPlainRemainder()
+ {
+ IReadOnlyList spans = ChatTagMarkup.Parse(TellLine);
+
+ Assert.Equal(2, spans.Count);
+
+ Assert.Equal("Dww", spans[0].Text);
+ Assert.NotNull(spans[0].Tag);
+ Assert.Equal("Tell", spans[0].Tag!.Value.Type);
+ Assert.Equal("IIDString", spans[0].Tag!.Value.Format);
+
+ Assert.Equal(@" tells you, ""hello""", spans[1].Text);
+ Assert.Null(spans[1].Tag);
+ }
+
+ [Fact]
+ public void TheTagCarriesTheSpeakersIdAndName()
+ {
+ ChatTextTag tag = Assert
+ .Single(ChatTagMarkup.Parse(TellLine), s => s.Tag is not null)
+ .Tag!.Value;
+
+ Assert.True(tag.TryGetIidString(out uint objectId, out string name));
+ Assert.Equal(1342177290u, objectId); // 0x5000000A, an AC1 player id
+ Assert.Equal("Dww", name);
+ }
+
+ [Fact]
+ public void AnyBracketedTextThatIsNotATagClosesTheOpenOne()
+ {
+ // This is the actual retail rule: MakeTag @0x00478480 needs a colon to
+ // succeed, and anything it cannot parse closes. The backslash in
+ // retail's own closer is incidental — a port that keyed on the
+ // backslash would look correct on every retail line and diverge on
+ // everything else, so all three of these must close.
+ foreach (string closer in new[] { @"<\Tell>", "", "" })
+ {
+ IReadOnlyList spans =
+ ChatTagMarkup.Parse($"A{closer}B");
+
+ Assert.Equal(2, spans.Count);
+ Assert.Equal("A", spans[0].Text);
+ Assert.NotNull(spans[0].Tag);
+ Assert.Equal("B", spans[1].Text);
+ Assert.Null(spans[1].Tag);
+ }
+ }
+
+ [Fact]
+ public void PlainTextIsOneUntaggedSpan()
+ {
+ ChatTextSpan span = Assert.Single(ChatTagMarkup.Parse("You say, \"hi\""));
+ Assert.Equal("You say, \"hi\"", span.Text);
+ Assert.Null(span.Tag);
+ }
+
+ [Fact]
+ public void AnUnterminatedBracketIsOrdinaryText()
+ {
+ // Nothing to consume, so it must survive verbatim rather than eating
+ // the rest of the player's sentence.
+ ChatTextSpan span = Assert.Single(ChatTagMarkup.Parse("is 3 < 4 really"));
+ Assert.Equal("is 3 < 4 really", span.Text);
+ Assert.Null(span.Tag);
+ }
+
+ [Fact]
+ public void TheConcatenatedSpansAlwaysReproduceTheVisibleLine()
+ {
+ // The renderer draws spans but selects and hit-tests against the flat
+ // line, so the two must agree exactly (UiText.RunsMatchLine enforces
+ // the same contract on the draw side).
+ string visible = string.Concat(
+ ChatTagMarkup.Parse(TellLine).Select(s => s.Text));
+
+ Assert.Equal(@"Dww tells you, ""hello""", visible);
+ }
+
+ [Theory]
+ [InlineData("x", "a non-numeric id")]
+ [InlineData("x", "no name")]
+ [InlineData("x", "empty payload")]
+ [InlineData("x", "a different format")]
+ public void AMalformedOrUnsupportedPayloadYieldsNoIidString(
+ string markup, string why)
+ {
+ ChatTextSpan span = ChatTagMarkup.Parse(markup)[0];
+ Assert.NotNull(span.Tag);
+ Assert.False(span.Tag!.Value.TryGetIidString(out _, out _), why);
+ }
+
+ [Fact]
+ public void ANameContainingAColonSurvivesIntact()
+ {
+ // Only the FIRST colon separates id from name; the rest is the name
+ // verbatim, matching ParseStartTag @0x00478910.
+ ChatTextTag tag = ChatTagMarkup.Parse("x")[0].Tag!.Value;
+
+ Assert.True(tag.TryGetIidString(out uint id, out string name));
+ Assert.Equal(5u, id);
+ Assert.Equal("Odd:Name", name);
+ }
+}