feat(chat): CT-A2 — parse retail's inline chat tag markup

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:

    <Tell:IIDString:1342177290:Dww>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>, <Tell> and <anything> 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 07:23:07 +02:00
parent cf41b27c9a
commit 2621fbf4a7
2 changed files with 265 additions and 0 deletions

View file

@ -0,0 +1,118 @@
using AcDream.Core.Chat;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Retail's inline chat tag markup — the mechanism behind the green, clickable
/// speaker name.
/// </summary>
public sealed class ChatTagMarkupTests
{
/// <summary>A retail tell line, exactly as the client composes it.</summary>
private const string TellLine =
@"<Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, ""hello""";
[Fact]
public void ARetailTellLineSplitsIntoATaggedNameAndPlainRemainder()
{
IReadOnlyList<ChatTextSpan> 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>", "<Tell>", "<anything>" })
{
IReadOnlyList<ChatTextSpan> spans =
ChatTagMarkup.Parse($"<Tell:IIDString:1:A>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("<Tell:IIDString:notanumber:Dww>x", "a non-numeric id")]
[InlineData("<Tell:IIDString:1>x", "no name")]
[InlineData("<Tell:IIDString:>x", "empty payload")]
[InlineData("<Tell:DID:1:x>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("<Tell:IIDString:5:Odd:Name>x")[0].Tag!.Value;
Assert.True(tag.TryGetIidString(out uint id, out string name));
Assert.Equal(5u, id);
Assert.Equal("Odd:Name", name);
}
}