diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
index fa430f7c..21e9071d 100644
--- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
+++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
@@ -65,6 +65,23 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see 's own doc).
/// Callers pass their transcript's .
///
+ ///
+ /// The colour retail gives the timestamp prefix — chat colour index
+ /// 0x0C, which
+ /// ChatInterface::BuildChatColorLookupTable @0x004F31C0 fills with
+ /// colorGrey.
+ ///
+ ///
+ /// Read from the same table every message colour comes from rather than
+ /// hard-coded, so it cannot drift from the rest of the palette. This one
+ /// IS a table index, unlike the tagged-name colour, which is authored per
+ /// element (property 0x1D) and deliberately lives elsewhere.
+ ///
+ private static Vector4 TimestampColor =>
+ RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
+ ? grey
+ : new Vector4(0.5f, 0.5f, 0.5f, 1f);
+
///
/// Retail's transcript character budget:
/// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640
@@ -156,11 +173,23 @@ internal static class ChatTranscriptRenderer
if (to <= from)
continue;
+ // Retail appends the timestamp at a FIXED colour index (0x0C)
+ // rather than the message's, so it stays grey whatever colour the
+ // line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes
+ // 0xc for that run and the line's own type for the body.
bool tagged = span.Tag is not null;
- sawTag |= tagged;
+ bool stamped = span.Role == ChatSpanRole.Timestamp;
+ sawTag |= tagged || stamped;
+
+ Vector4 color = tagged
+ ? tagColor
+ : stamped
+ ? TimestampColor
+ : lineColor;
+
runs.Add(new UiText.TextRun(
span.Text.Substring(from - spanStart, to - from),
- tagged ? tagColor : lineColor));
+ color));
}
return sawTag ? runs : null;
diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs
index 7f025c5f..60636d11 100644
--- a/src/AcDream.App/UI/UiField.cs
+++ b/src/AcDream.App/UI/UiField.cs
@@ -815,6 +815,21 @@ public sealed class UiField : UiElement
bool shift = Selectable && ShiftHeld();
switch (key)
{
+ // Campaign CT slice C2. Retail maps Escape to input action
+ // 0x0B, which runs ChatInterface::DeactivateChatEntry
+ // @0x004F2FC0: RelinquishFocus, then Deactivate. It does
+ // NOT clear the field — whatever you had typed is still
+ // there when you come back.
+ //
+ // Without this the key was a complete no-op: there is no
+ // Escape case, and a focused field also reports
+ // IsEditControl, which makes UiRoot skip its own fallback
+ // and the input dispatcher withhold game actions. So the
+ // player had no way out except the mouse.
+ case Silk.NET.Input.Key.Escape:
+ FindRoot()?.SetKeyboardFocus(null);
+ return true;
+
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
if (!OneLine)
diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs
index edf77892..cd136601 100644
--- a/src/AcDream.Core/Chat/ChatTagMarkup.cs
+++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs
@@ -51,8 +51,26 @@ public readonly record struct ChatTextTag(string Type, string Format, string Dat
}
}
+/// What a span IS, for colouring purposes.
+public enum ChatSpanRole
+{
+ /// Ordinary message text; takes the line's own colour.
+ Body,
+
+ ///
+ /// The leading timestamp. Retail appends it as its own run at a FIXED
+ /// colour index (0x0C) rather than the line's
+ /// (ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640),
+ /// so it stays grey whatever colour the message is.
+ ///
+ Timestamp,
+}
+
/// One stretch of chat text, and the tag covering it (if any).
-public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag);
+public readonly record struct ChatTextSpan(
+ string Text,
+ ChatTextTag? Tag,
+ ChatSpanRole Role = ChatSpanRole.Body);
///
/// Parses retail's inline chat tag markup into spans.
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
index 5c17d437..30081845 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
@@ -424,8 +424,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
// Spans is the sidecar that remembers which stretch was the
// speaker's name. Campaign CT slice A3: this is the point where
// sender identity used to die.
+ bool tagged = ShouldTagSender(entry);
string markup = FormatEntryTagged(entry);
- IReadOnlyList? spans = ShouldTagSender(entry)
+ IReadOnlyList? spans = tagged
? ChatTagMarkup.Parse(markup)
: null;
string text = spans is null
@@ -435,13 +436,14 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
if (timestamps)
{
string prefix = ChatLog.FormatTimestampPrefix(entry.Received);
+ // The stamp is its own run: retail appends it at a FIXED
+ // colour index rather than the message's, so it stays grey
+ // whatever colour the line is. That means a timestamped line
+ // needs spans even when its sender is not tagged.
+ spans = new[] { new ChatTextSpan(prefix, null, ChatSpanRole.Timestamp) }
+ .Concat(spans ?? new[] { new ChatTextSpan(text, null) })
+ .ToArray();
text = prefix + text;
- // The prefix is its own untagged stretch in front, so the
- // spans keep lining up with the visible text.
- if (spans is not null)
- spans = new[] { new ChatTextSpan(prefix, null) }
- .Concat(spans)
- .ToArray();
}
lines[i] = new FormattedLine(
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
index 664c3a6c..8e705d6d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
@@ -228,4 +228,68 @@ public sealed class ChatTranscriptRunsTests
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);
+
+ // ── CT-C3: the timestamp is its own colour ──────────────────────────
+
+ [Fact]
+ public void TheTimestampIsGreyWhateverColourTheMessageIs()
+ {
+ // Retail appends the stamp at a FIXED colour index (0x0C, colorGrey)
+ // rather than the message's, so a red combat line and a white say line
+ // carry the same grey stamp.
+ var spans = new[]
+ {
+ new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
+ new ChatTextSpan("Dww says, \"hi\"", null),
+ };
+
+ IReadOnlyList? runs = ChatTranscriptRenderer.RunsForFragment(
+ spans,
+ fragmentStart: 0,
+ fragmentLength: spans[0].Text.Length + spans[1].Text.Length,
+ LineColor,
+ TagGreen);
+
+ Assert.NotNull(runs);
+ Assert.Equal(2, runs!.Count);
+ Assert.Equal("13:05:09 ", runs[0].Text);
+ Assert.NotEqual(LineColor, runs[0].Color); // NOT the message colour
+ Assert.Equal(LineColor, runs[1].Color); // the body still is
+ }
+
+ [Fact]
+ public void ATimestampedLineGetsRunsEvenWithNoTaggedSender()
+ {
+ // The stamp alone is reason enough to need runs — before CT-C3 a line
+ // only got them when its sender was tagged, so an untagged line's
+ // stamp took the message colour.
+ var spans = new[]
+ {
+ new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
+ new ChatTextSpan("Welcome.", null),
+ };
+
+ Assert.NotNull(ChatTranscriptRenderer.RunsForFragment(
+ spans, 0, spans[0].Text.Length + spans[1].Text.Length,
+ LineColor, TagGreen));
+ }
+
+ [Fact]
+ public void ATimestampAndATaggedNameKeepSeparateColours()
+ {
+ var spans = new[]
+ {
+ new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
+ new ChatTextSpan("Dww", new ChatTextTag("Tell", "IIDString", "1:Dww")),
+ new ChatTextSpan(" tells you", null),
+ };
+
+ IReadOnlyList runs = ChatTranscriptRenderer.RunsForFragment(
+ spans, 0, 9 + 3 + 10, LineColor, TagGreen)!;
+
+ Assert.Equal(3, runs.Count);
+ Assert.Equal(TagGreen, runs[1].Color);
+ Assert.NotEqual(TagGreen, runs[0].Color);
+ Assert.NotEqual(runs[0].Color, runs[2].Color);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs
index 3184f7d9..3141e1f1 100644
--- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs
@@ -258,4 +258,33 @@ public class UiFieldTests
Assert.Equal("hi ", input.Text);
}
+
+ // ── CT-C2: Escape leaves the chat entry ─────────────────────────────
+
+ [Fact]
+ public void EscapeIsHandledAndKeepsWhatWasTyped()
+ {
+ // Retail's DeactivateChatEntry @0x004F2FC0 relinquishes focus and
+ // deactivates; it does NOT clear the field, so a half-written message
+ // survives stepping away from the bar.
+ var input = new UiField();
+ input.SetText("half written");
+
+ bool handled = input.OnEvent(new UiEvent(
+ 0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape));
+
+ Assert.True(handled);
+ Assert.Equal("half written", input.Text);
+ }
+
+ [Fact]
+ public void EscapeIsIgnoredWhenTheFieldIsNotEditable()
+ {
+ // A read-only field returns early before the key switch, so Escape
+ // must not acquire behaviour there.
+ var input = new UiField { Editable = false };
+
+ Assert.True(input.OnEvent(new UiEvent(
+ 0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape)));
+ }
}