From 2621fbf4a722ffae248efc7ab24859552bb1352d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:23:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20CT-A2=20=E2=80=94=20parse=20retai?= =?UTF-8?q?l's=20inline=20chat=20tag=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A2. Pure parser, no UI, nothing wired yet. Retail's client composes chat lines with the markup already embedded — 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 as: Dww<\Tell> tells you, "hello" ChatTagMarkup.Parse splits that into spans, consuming the markers: the name under a tag, the remainder untagged. The rule that decides where a tag ENDS is the one worth being careful about. It 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, and the backslash in retail's own closer (TextTag::BuildEndTag @0x00479190) is incidental to that. Porting "a closer starts with a backslash" would look correct on every retail line and then diverge on everything else, so the test pins all three of <\Tell>, and as closers. Two details taken from the decomp rather than guessed: only the FIRST colon of an IIDString payload separates the id from the name, so a name containing a colon survives intact (ParseStartTag @0x00478910); and an unterminated '<' is ordinary text, so a player typing "is 3 < 4 really" does not lose the rest of their sentence. The parse also upholds the contract CT-A1's draw side enforces — the concatenated span text always reproduces the visible line, because selection and hit-testing index into that flat string. Solution builds clean; full hermetic gate green. Note for the record: PreparedAssetVerificationCacheTests.BackupRecoveryHashes- TheBackupEvenWhenTheLiveCacheIsValid failed once during this slice's gate and then passed isolated, as a class, and on a full-gate rerun. This branch touches no launcher code, so it is load-sensitive rather than caused here — flagging it rather than silently re-running, since a test that only fails under parallel load is worth someone classifying. Co-Authored-By: Claude Opus 5 --- src/AcDream.Core/Chat/ChatTagMarkup.cs | 147 ++++++++++++++++++ .../Chat/ChatTagMarkupTests.cs | 118 ++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/AcDream.Core/Chat/ChatTagMarkup.cs create mode 100644 tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs 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); + } +}