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:
parent
cf41b27c9a
commit
2621fbf4a7
2 changed files with 265 additions and 0 deletions
147
src/AcDream.Core/Chat/ChatTagMarkup.cs
Normal file
147
src/AcDream.Core/Chat/ChatTagMarkup.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// One retail text tag: the <c>TYPE</c>, <c>FORMAT</c> and <c>DATA</c> of a
|
||||
/// <c><TYPE:FORMAT:DATA></c> marker.
|
||||
/// </summary>
|
||||
/// <param name="Type">
|
||||
/// The tag kind, e.g. <c>Tell</c>. Retail resolves this through the DAT
|
||||
/// EnumMapper (category <c>0x18</c>) to the numeric type its colour rule keys
|
||||
/// on; only <c>Tell</c> is confirmed in the build we target.
|
||||
/// </param>
|
||||
/// <param name="Format">
|
||||
/// The payload shape, e.g. <c>IIDString</c>. Retail has four
|
||||
/// (<c>DID</c>, <c>IID</c>, <c>IIDEnum</c>, <c>IIDString</c>) but only
|
||||
/// <c>IIDString</c> has a listener.
|
||||
/// </param>
|
||||
/// <param name="Data">Everything after the second colon, unparsed.</param>
|
||||
public readonly record struct ChatTextTag(string Type, string Format, string Data)
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>IIDString</c> payload: an object id and a name. Returns
|
||||
/// <see langword="false"/> for any other format, or a malformed payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The name may itself contain colons, so only the FIRST colon separates
|
||||
/// the id from the name — retail's
|
||||
/// <c>TextTag_IIDString::ParseStartTag @0x00478910</c> reads the id then
|
||||
/// takes the remainder verbatim.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One stretch of chat text, and the tag covering it (if any).</summary>
|
||||
public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag);
|
||||
|
||||
/// <summary>
|
||||
/// Parses retail's inline chat tag markup into spans.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail's client composes chat lines with the markup already embedded —
|
||||
/// <c>ClientCommunicationSystem::Handle_Communication__HearSpeech
|
||||
/// @0x005712A0</c> sprintf's it — and the text element recognises it while
|
||||
/// appending (<c>UIElement_Text::InqGlyphs @0x00468EA0</c>), calling
|
||||
/// <c>TextTagFactory::MakeTag @0x00478480</c> per marker. A tagged speaker
|
||||
/// name arrives looking like:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// <Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, "hello"
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// <b>What closes a tag is the absence of a colon, not the backslash.</b>
|
||||
/// <c>MakeTag</c> requires a <c>:</c> to succeed, so ANY bracketed text it
|
||||
/// cannot parse closes the open tag — the backslash in retail's own closer
|
||||
/// (<c>TextTag::BuildEndTag @0x00479190</c>) 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ChatTagMarkup
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits <paramref name="text"/> into spans, consuming the markers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An unterminated <c><</c> (no closing <c>></c> anywhere after it)
|
||||
/// is ordinary text — there is no marker to consume.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<ChatTextSpan> Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return Array.Empty<ChatTextSpan>();
|
||||
if (text.IndexOf('<') < 0)
|
||||
return new[] { new ChatTextSpan(text, null) };
|
||||
|
||||
var spans = new List<ChatTextSpan>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The marker's tag, or <see langword="null"/> if it does not parse as one
|
||||
/// — which is retail's close.
|
||||
/// </summary>
|
||||
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)..]);
|
||||
}
|
||||
}
|
||||
118
tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs
Normal file
118
tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue