diff --git a/CHANGELOG.md b/CHANGELOG.md index cebe737..7c939f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,9 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Fixed +- MXP pictures draw again. Since the TelnetNegotiationCore update the client stopped answering a + server's MXP `` and `` questions, so a game such as SharpMUSH decided it could not show + `` and sent the picture's address as text instead. - Pueblo worlds work. The client answers a server's Pueblo greeting and switches to Pueblo markup when the server does, and Pueblo worlds now show their ANSI colours instead of the escape codes. A world set to `mxp` or `ansi` on F5 does not answer the greeting. diff --git a/src/SharpMUTerm.Core/Protocols/MxpParser.cs b/src/SharpMUTerm.Core/Protocols/MxpParser.cs index 1142c40..93c213a 100644 --- a/src/SharpMUTerm.Core/Protocols/MxpParser.cs +++ b/src/SharpMUTerm.Core/Protocols/MxpParser.cs @@ -150,7 +150,16 @@ private sealed record Frame public event EventHandler? ClientReply; /// What this client answers a VERSION request with. - private const string ClientName = "SharpMUTerm"; + public const string ClientName = "SharpMUTerm"; + + /// The MXP revision a VERSION reply names (see ). + public const string MxpRevision = "1.0"; + + /// The style-sheet version a VERSION reply names: none is ever in effect. + public const string StyleVersion = "0"; + + /// The client version a VERSION reply names: a fixed placeholder. + public const string ClientVersion = "1"; /// /// Spec: "the response is sent as a secure-tagged line: <ESC>[1z prefix with newline @@ -163,7 +172,9 @@ private sealed record Frame private const string SecureLinePrefix = "\x1b[1z"; /// - /// The tags this parser genuinely implements, in the spec's +tag form. + /// The tags this parser genuinely implements, by name. The telnet layer answers a server's + /// <SUPPORT> from this list too (), because the library takes + /// that line out of the stream before this parser can see it. /// /// /// An honest list, and deliberately not an aspirational one: a SUPPORTS answer is a claim a @@ -173,9 +184,85 @@ private sealed record Frame /// has no case for it — it falls to the default and is silently /// dropped — so it is deliberately not claimed here. /// - private static readonly string[] SupportedTags = - ["+b", "+i", "+u", "+s", "+color", "+font", "+send", "+a", "+br", "+image", - "+!element", "+!entity", "+var", "+gauge", "+stat", "+sound", "+music", "+frame", "+dest"]; + public static IReadOnlyList SupportedTags { get; } = + ["b", "i", "u", "s", "color", "font", "send", "a", "br", "image", + "!element", "!entity", "var", "gauge", "stat", "sound", "music", "frame", "dest"]; + + /// + /// The attributes a handler here reads, by tag, for a <SUPPORT tag.attr> or + /// <SUPPORT tag.*>. The same honesty rule as : an attribute + /// is listed only when its handler acts on it, so image.ismap, read and ignored, is not. + /// A supported tag missing here has no attribute this client claims. + /// + public static IReadOnlyDictionary SupportedAttributes { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["color"] = ["fore", "back"], + ["send"] = ["href", "hint", "prompt"], + ["a"] = ["href", "hint"], + ["image"] = ["fname", "url", "t", "w", "h"], + ["gauge"] = ["entity", "max", "caption"], + ["stat"] = ["entity", "max", "caption"], + ["sound"] = ["fname", "v", "l", "p", "t", "u"], + ["music"] = ["fname", "v", "l", "c", "t", "u"], + ["frame"] = ["name", "action", "title"], + ["dest"] = ["name"], + }; + + /// + /// The answer to a <SUPPORT> that asked about : every tag in + /// when it asked about nothing, otherwise each entry it named, as one this + /// client renders or one it does not. tag.attr is supported when + /// lists it, and tag.* is answered with each attribute listed for the tag, as the specification + /// asks; a tag this client does not render is answered -tag.*. + /// + public static (IReadOnlyList Supported, IReadOnlyList Unsupported) AnswerSupport(IReadOnlyList asked) + { + if (asked.Count == 0) + { + return (SupportedTags, []); + } + + var supported = new List(); + var unsupported = new List(); + foreach (var entry in asked) + { + var name = entry.Trim('"', '\''); + if (name.Length == 0) + { + continue; + } + + var dot = name.IndexOf('.', StringComparison.Ordinal); + if (dot < 0) + { + (SupportedTags.Contains(name, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name); + continue; + } + + var tag = name[..dot]; + var attribute = name[(dot + 1)..]; + var known = SupportedTags.Contains(tag, StringComparer.OrdinalIgnoreCase); + var attributes = known ? SupportedAttributes.GetValueOrDefault(tag) ?? [] : []; + if (attribute == "*") + { + if (known) + { + supported.AddRange(attributes.Select(a => $"{tag}.{a}")); + } + else + { + unsupported.Add(name); + } + } + else + { + (attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name); + } + } + + return (supported, unsupported); + } /// True when a partial line, an open tag/entity, or unclosed markup is buffered. public bool HasPendingContent => @@ -1019,12 +1106,12 @@ private void HandleOpener(string name, string attrs) /// private void HandleVersionRequest() { - ClientReply?.Invoke(this, $"{SecureLinePrefix}"); + ClientReply?.Invoke(this, $"{SecureLinePrefix}"); } private void HandleSupportRequest() { - ClientReply?.Invoke(this, $"{SecureLinePrefix}"); + ClientReply?.Invoke(this, $"{SecureLinePrefix} "+" + t))}>"); } /// diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index f16b48b..ac95a25 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SharpMUTerm.Core.Telnet.Mssp; +using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Transport; using TelnetNegotiationCore.Builders; using TelnetNegotiationCore.Gmcp; @@ -473,6 +474,7 @@ private Task BuildInterpreterAsync() .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) + .OnMxpSupportRequested(OnMxpSupportRequestedAsync).OnMxpVersionRequested(OnMxpVersionRequestedAsync) .AddPlugin().OnPuebloOffered(OnPuebloOfferedAsync).OnPuebloEnabled(OnPuebloEnabledAsync) .AddPlugin().OnCompressionEnabled(OnCompressionAsync) // MCP is in-band and the server opens it (#$#mcp version), so registering it sends nothing to a @@ -754,6 +756,52 @@ private ValueTask OnPuebloEnabledAsync(PuebloClient client) private volatile bool _mxpOn; + private MXPProtocol? Mxp => _interpreter?.PluginManager?.GetPlugin(); + + /// + /// A server's <SUPPORT>. Once MXP mode has started the library takes the line out of the + /// stream and sends nothing unless asked to, so never sees it. Left unanswered, a + /// server that waits for the reply (SharpMUSH does) records that this client renders none of the tags it + /// asked about, and sends a picture's address as text instead of an <IMAGE>. + /// + private async ValueTask OnMxpSupportRequestedAsync(IReadOnlyList asked) + { + if (Mxp is not { } mxp) + { + return; + } + + var (supported, unsupported) = MxpParser.AnswerSupport(asked); + try + { + await mxp.SendSupportsAsync(supported, unsupported).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not answer the server's MXP SUPPORT request."); + } + } + + /// A server's <VERSION>, taken out of the stream by the library like <SUPPORT>. + private async ValueTask OnMxpVersionRequestedAsync() + { + if (Mxp is not { } mxp) + { + return; + } + + try + { + await mxp.SendVersionAsync(new MxpVersion( + MxpParser.MxpRevision, MxpParser.StyleVersion, MxpParser.ClientName, MxpParser.ClientVersion, Registered: null)) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not answer the server's MXP VERSION request."); + } + } + // ----- MCP ---------------------------------------------------------------------- private MudClientProtocol? Mcp => _interpreter?.PluginManager?.GetPlugin(); diff --git a/src/SharpMUTerm.Tui/ComposeOverlay.cs b/src/SharpMUTerm.Tui/ComposeOverlay.cs index 3928396..2739eb7 100644 --- a/src/SharpMUTerm.Tui/ComposeOverlay.cs +++ b/src/SharpMUTerm.Tui/ComposeOverlay.cs @@ -83,8 +83,12 @@ internal ComposeOverlay(ConsoleWindowSystem system, Func? spellin internal bool IsOpen => _window is not null; - /// The buffer as it stands, or empty when the window is shut. Internal for the tests. - internal string Body => _editor?.GetContent() ?? string.Empty; + /// + /// The buffer as it stands, or empty when the window is shut, with its lines broken by \n. The editor + /// joins them with , so on Windows a post or an edit would otherwise carry + /// CRLF in one host and LF in the other. + /// + internal string Body => _editor?.GetContent().Replace("\r\n", "\n", StringComparison.Ordinal) ?? string.Empty; /// The escaping mode as it stands — the window's own toggle, not a saved setting. internal ComposeEscaping Escaping => _escaping; diff --git a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs index a9f94c7..0aef640 100644 --- a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs +++ b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs @@ -156,14 +156,7 @@ public static ClientHost ForHarness( { if (driver is null) { - return Terminal(capabilities) with - { - Clipboard = clipboard, - OpenUrl = openUrl, - FocusReporting = focusReporting ?? PlatformDecodesFocusReports, - Audio = null, // a test never makes a noise - Presence = null, // nor talks to Discord - }; + return HarnessTerminal(capabilities, new NetConsoleDriver(RenderMode.Buffer), focusReporting, openUrl, clipboard); } var inline = driver is HeadlessConsoleDriver; @@ -176,4 +169,24 @@ public static ClientHost ForHarness( RunsInline = inline, }; } + + /// + /// with no driver of its own: the terminal host on , with + /// what a test overrides. Apart so a test can read it on a driver that needs no console, because the + /// framework's own driver throws on Windows when stdin and stdout are redirected, as they are in CI. + /// + internal static ClientHost HarnessTerminal( + TerminalCapabilities capabilities, + IConsoleDriver driver, + bool? focusReporting, + Action? openUrl, + Action? clipboard) => + Terminal(capabilities, driver) with + { + Clipboard = clipboard, + OpenUrl = openUrl, + FocusReporting = focusReporting ?? PlatformDecodesFocusReports, + Audio = null, // a test never makes a noise + Presence = null, // nor talks to Discord + }; } diff --git a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs index 858631d..5cc3b44 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs @@ -13,7 +13,7 @@ namespace SharpMUTerm.Avalonia.Tests; /// /// Serialised: a headless frame is captured by redirecting the process-global Console.Out. [NotInParallel] -public class HostParityTests +public partial class HostParityTests { private const int Width = 140; private const int Height = 40; @@ -104,7 +104,7 @@ private static List Differences(string ansi, ScreenFrame frame, int rows { var expected = FrameGrid.Decode(ansi, Width, Height)[y]; var actual = frame.RowText(y); - if (!string.Equals(expected, actual, StringComparison.Ordinal)) + if (!string.Equals(expected, actual, StringComparison.Ordinal) && !DifferOnlyInStamp(expected, actual)) { differences.Add($"row {y}:\n terminal |{expected}|\n window |{actual}|"); continue; @@ -124,6 +124,33 @@ private static List Differences(string ansi, ScreenFrame frame, int rows return differences; } + /// + /// True when two rows differ only in the time a message was stamped with: the first hh:mm:ss in + /// each, at the same column, with every other cell equal. The client's messages are stamped by the + /// logger's wall clock, which no test pins, and the two hosts draw a second apart often enough to fail on + /// the second changing. A clock in a message's own text comes after the stamp and is still compared. + /// + internal static bool DifferOnlyInStamp(string expected, string actual) + { + var a = ClockTime().Match(expected); + var b = ClockTime().Match(actual); + return a.Success && b.Success && a.Index == b.Index && expected.Length == actual.Length + && string.Equals(expected[..a.Index], actual[..a.Index], StringComparison.Ordinal) + && string.Equals(expected[(a.Index + a.Length)..], actual[(b.Index + b.Length)..], StringComparison.Ordinal); + } + + [System.Text.RegularExpressions.GeneratedRegex(@"\b\d{2}:\d{2}:\d{2}\b")] + private static partial System.Text.RegularExpressions.Regex ClockTime(); + + /// Only the stamp is allowed to differ: a clock in a message's text is compared like any other text. + [Test] + [Arguments("│ 10:00:00 info hello", "│ 10:00:01 info hello", true)] + [Arguments("│ 10:00:00 info at 12:34:56", "│ 10:00:00 info at 12:35:56", false)] + [Arguments("│ 10:00:00 info hello", "│ 10:00:01 info hullo", false)] + [Arguments("│ 10:00:00 info hello", "│ 10:00:01 info hello", false)] + public async Task OnlyTheStampMayDifferBetweenHosts(string terminal, string window, bool same) => + await Assert.That(DifferOnlyInStamp(terminal, window)).IsEqualTo(same); + public static IEnumerable Hosts() => ["terminal", "window"]; private static (HostKeys Keys, UsageLauncher Launcher) HostNamed(string name) => name == "window" diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs new file mode 100644 index 0000000..fa85776 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs @@ -0,0 +1,113 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// A server's <SUPPORT> and <VERSION> are answered. Since TelnetNegotiationCore 4.3 +/// the library takes both lines out of the stream once MXP mode has started and answers neither by itself, so +/// never sees them. Unanswered, SharpMUSH recorded that this client renders none of the +/// tags it asked about and sent a picture's address as text instead of an <IMAGE>. +/// +public class MxpCapabilityTests +{ + private const byte Iac = 255, Will = 251, Do = 253, Sb = 250, Se = 240, Mxp = 91; + + private static async Task InMxpModeAsync(TelnetSession session, ScriptedTransport transport) + { + await session.ConnectAsync(); + await Assert.That(await transport.WaitForSentAsync([Iac, Do, Mxp])).IsTrue(); + transport.SendToClient([Iac, Sb, Mxp, Iac, Se]); + return transport; + } + + private static byte[] Line(string text) => Encoding.UTF8.GetBytes(text + "\r\n"); + + [Test] + public async Task ASupportRequestIsAnsweredWithWhatTheParserRenders() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Mxp] }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await InMxpModeAsync(session, transport); + + transport.SendToClient(Line("\x1b[1z")); + + await Assert.That(await WaitForTextAsync(transport, "")); + + await Assert.That(await WaitForTextAsync(transport, "")); + + await Assert.That(await WaitForTextAsync(transport, " WaitForTextAsync(ScriptedTransport transport, string text) + { + for (var i = 0; i < 200; i++) + { + if (Encoding.UTF8.GetString(transport.Sent).Contains(text, StringComparison.Ordinal)) + { + return true; + } + + await Task.Delay(10); + } + + return false; + } + + /// The reply line that holds , from the secure-line prefix in front of it. + private static string Reply(ScriptedTransport transport, string start) + { + var sent = Encoding.UTF8.GetString(transport.Sent); + var at = sent.IndexOf(start, StringComparison.Ordinal); + var prefix = sent.LastIndexOf("\x1b[", at, StringComparison.Ordinal); + var end = sent.IndexOf('\n', at); + return sent[(prefix < 0 ? at : prefix)..(end < 0 ? sent.Length : end)].TrimEnd('\r'); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/SoundTests.cs b/tests/SharpMUTerm.Tui.Tests/SoundTests.cs index 6750a80..28346f3 100644 --- a/tests/SharpMUTerm.Tui.Tests/SoundTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/SoundTests.cs @@ -180,9 +180,14 @@ public async Task TheF7SoundRowsAreThere() [Test] public async Task TheTestHarnessHasNoDevice() { - var host = ClientHost.ForHarness(Headless, null, null, null, null); + // The terminal arm on a headless driver: the framework's own driver cannot be built with stdio + // redirected on Windows, which is how CI runs the suites. + using var driver = new HeadlessConsoleDriver(80, 24); + var host = ClientHost.HarnessTerminal(Headless, driver, null, null, null); + await Assert.That(host.Name).IsEqualTo("terminal"); await Assert.That(host.Audio is null).IsTrue(); + await Assert.That(host.Presence is null).IsTrue(); } /// A device that records what it was asked to play.