From 1c539b8b6f32e103813b3855fea2f7e176c09327 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 05:13:08 +0000 Subject: [PATCH] Negotiate Pueblo and decode its ANSI colour Register TelnetNegotiationCore's PuebloProtocol. A server's hello is answered with PUEBLOCLIENT 2.50 when the world is auto or pueblo and MXP is not already on; the server's start sequence switches an auto world to PuebloParser. The parser swap is shared with MXP and only happens off the plain ANSI parser, so the first markup negotiated keeps the connection. Both handshake lines are cleared from the pending prompt buffer. PuebloParser gains the escape state machine MxpParser has, decoding SGR through SgrCodes and consuming every other sequence. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WXyHEoxwX8GurTHxwPinQY --- CHANGELOG.md | 3 + CLAUDE.md | 25 ++- .../Protocols/PuebloParser.cs | 166 +++++++++++++++++- src/SharpMUTerm.Core/Session/WorldSession.cs | 22 ++- src/SharpMUTerm.Core/Telnet/ITelnetSession.cs | 6 + src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 50 ++++++ .../Protocols/PuebloParserTests.cs | 48 ++++- .../Session/FakeTelnetSession.cs | 3 + .../Session/WorldSessionSettingsTests.cs | 49 ++++++ .../Telnet/PuebloHandshakeTests.cs | 73 ++++++++ .../FakeTelnetSession.cs | 3 + .../RecordingTelnetSession.cs | 1 + 12 files changed, 427 insertions(+), 22 deletions(-) create mode 100644 tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 110228ac..a8382fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Fixed +- 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. - A character's on-disconnect commands are now sent, before the client closes the connection itself (Disconnect, Reconnect or quitting). They were saved and never used. - Lua scripts now run. A trigger set's script files are loaded from the `scripts` folder beside diff --git a/CLAUDE.md b/CLAUDE.md index 1d8a72b6..54f5bb78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1743,8 +1743,8 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] is parser state too: MXP has to be negotiated again on the new socket to take the session off ANSI. Left alone, a server's `ESC[6z` made the *next* connection start in secure default, and the black-on-black above survived the reconnect that is the obvious way a user tries to get rid of it. - - **`AnsiParser` and `MxpParser` hold near-duplicate escape state machines, deliberately - unconsolidated — so a fix to one has to be applied to the other, with its own test in each suite.** + - **`AnsiParser`, `MxpParser` and `PuebloParser` hold near-duplicate escape state machines, deliberately + unconsolidated — so a fix to one has to be applied to the others, with its own test in each suite.** (Same states, same CSI ranges, same OSC/DCS/ST handling; `MxpParser`'s differs only by the `z` line-tag arm and by disarming TEMP SECURE on the way out.) This was weighed and kept: extracting a shared scanner is a structural refactor, and it was not going into a fix wave that already carried @@ -1785,12 +1785,21 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] written while the mode never reverted in production, where "lands on an OPEN line" was not true of a session a server had ever secured. Closing the buffering hole itself needs its own change at the telnet layer and is out of this scope. -- **`PuebloParser` decodes no ANSI, and nothing else decodes it for it.** Its header used to say escapes - were "handled upstream" — the same false premise `MxpParser` carried, in the same words, and there is - no upstream either way: a session gets one parser. So a Pueblo world's colour arrives on screen as a - literal `[0;33m`. The behaviour clause was accurate; only the justification was invented. The fix - is the one `MxpParser` already had — its own escape state machine over `SgrCodes` — and until someone - does it this is a known gap rather than a design. +- **`PuebloParser` decodes ANSI itself, through `SgrCodes`**, for `MxpParser`'s reason: a session gets one + parser and nothing chains another in front of it. Until it did, a Pueblo world's colour arrived as a + literal `[0;33m`. Its escape machine is the third copy (see the paragraph on the duplicates above). +- **Pueblo is negotiated the way MXP is** (`PuebloProtocol`, TelnetNegotiationCore; `TelnetSession`'s + `OnPuebloOffered`/`OnPuebloEnabled`; `WorldSession.UpgradeParser`). Pueblo has no telnet option: the server + prints `This world is Pueblo 1.10 Enhanced.`, the client answers with the text line `PUEBLOCLIENT 2.50`, + and the server's `` switches the connection. Three rules: + - **The answer goes only to a server that said hello, and only from `auto` or `pueblo`** + (`TelnetSessionOptions.AnnouncePueblo`). It is a line at a login prompt, where a server without Pueblo + reads it as a name — the unsolicited-negotiation rule, in text. + - **The first markup a connection negotiates keeps it.** The parser is swapped only off the plain + `AnsiParser`, and the hello is not answered once MXP is on, so a server offering both is not flipped + between them by the order the handshakes finish in. + - **The library consumes both lines; `_pending` is cleared in both callbacks**, because `CallbackOnByteAsync` + has already collected their bytes and a prompt boundary would otherwise flush them as text. - **``/`` are answered, and the `SUPPORTS` list is held to the same rule as the MTTS bit vector: an honest claim, not an aspirational one.** The spec requires both replies to go out as a secure-tagged line (`ESC[1z` prefix, `MxpParser.SecureLinePrefix`) specifically so a server can tell diff --git a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs index 99c5d6ec..af9e029c 100644 --- a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs +++ b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs @@ -22,26 +22,29 @@ namespace SharpMUTerm.Core.Protocols; /// deciding whether a world is Pueblo-enabled is a session concern handled elsewhere. /// /// -/// ANSI/VT escape sequences are not decoded, and nothing else decodes them either. An ESC -/// (0x1b) byte is passed through untouched into the span text — so a Pueblo world's colour arrives on -/// screen as a literal <ESC>[0;33m. This used to claim the escapes were "handled -/// upstream"; there is no upstream. WorldSession.CreateParser hands a session one parser -/// for its content format, never a chain, which is the same false premise -/// carried until it grew its own escape state machine (through -/// ). Doing the same here is the fix; it is a known gap -/// rather than a design, and the behaviour clause above is what actually happens today. +/// ANSI is decoded here, through , because a session has one parser and nothing +/// chains another in front of it ('s reason, in the same words). SGR changes the style; +/// every other CSI, OSC, DCS/SOS/PM/APC string and two-byte escape is consumed. The escape state machine is a +/// near-copy of 's and 's, deliberately not shared — a fix to +/// one belongs in all three, with its own test in each suite. /// /// public sealed class PuebloParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; + private const int MaxSequenceLength = 128; private enum State { Ground, Tag, Entity, + Escape, + EscapeIntermediate, + Csi, + Osc, + OscEscape, } /// A saved rendition frame, pushed when an element opens and restored when it closes. @@ -68,6 +71,7 @@ public Frame(string name, TextStyle style, SpanInteraction? interaction) private readonly List _lineSpans = new(); private readonly StringBuilder _tag = new(); private readonly StringBuilder _entity = new(); + private readonly StringBuilder _seq = new(); private readonly List _stack = new(); /// The rendition state that will apply to the next printed character. @@ -102,6 +106,13 @@ public IReadOnlyList Feed(ReadOnlySpan text) /// public StyledLine? Flush() { + // The telnet layer strips the terminator, so this is the line boundary in production, and an + // escape cannot span it — see Process. + if (IsEscapeState(_state)) + { + EndSequence(); + } + FlushRun(); CloseInteractionsAtBoundary(); if (_lineSpans.Count == 0) @@ -124,11 +135,19 @@ public void Reset() _lineSpans.Clear(); _tag.Clear(); _entity.Clear(); + _seq.Clear(); _stack.Clear(); } private void Process(char ch, ref List? lines) { + // A newline ends the line whatever sequence is in flight, or an unterminated ESC ] that a player + // typed would swallow every following line. MxpParser and AnsiParser carry the same rule. + if (ch == '\n' && IsEscapeState(_state)) + { + EndSequence(); + } + switch (_state) { case State.Ground: @@ -142,9 +161,135 @@ private void Process(char ch, ref List? lines) case State.Entity: ProcessEntity(ch, ref lines); break; + + case State.Escape: + ProcessEscape(ch); + break; + + case State.EscapeIntermediate: + // The single trailing byte of e.g. ESC ( B. + EndSequence(); + break; + + case State.Csi: + ProcessCsi(ch); + break; + + case State.Osc: + ProcessOsc(ch); + break; + + case State.OscEscape: + // ESC inside a string sequence; a following '\' is the ST terminator. + if (ch == '\\') + { + EndSequence(); + } + else + { + _state = State.Osc; + } + + break; } } + private static bool IsEscapeState(State state) => + state is State.Escape or State.EscapeIntermediate or State.Csi or State.Osc or State.OscEscape; + + private void ProcessEscape(char ch) + { + switch (ch) + { + case '[': + _seq.Clear(); + _state = State.Csi; + break; + + case ']': + case 'P': // DCS + case 'X': // SOS + case '^': // PM + case '_': // APC + // String sequences, terminated by BEL or ST — consumed so the payload never shows. + _seq.Clear(); + _state = State.Osc; + break; + + case '(': + case ')': + case '*': + case '+': + case '#': + case '%': + _state = State.EscapeIntermediate; + break; + + default: + // A two-byte escape (ESC c, ESC M, …), consumed. + EndSequence(); + break; + } + } + + private void ProcessCsi(char ch) + { + if (ch is >= '\x40' and <= '\x7e') + { + if (ch == 'm') + { + // Text so far keeps the style it was written in. + FlushRun(); + _current = SgrCodes.Apply(_current, _seq.ToString()); + } + + // Every other final byte — cursor movement, erase — means nothing to a line-oriented view. + EndSequence(); + return; + } + + if (ch is >= '\x20' and <= '\x3f') + { + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + return; + } + + // A control character inside the sequence aborts it as malformed. + EndSequence(); + } + + private void ProcessOsc(char ch) + { + switch (ch) + { + case '\x07': + EndSequence(); + break; + + case '\x1b': + _state = State.OscEscape; + break; + + default: + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + break; + } + } + + private void EndSequence() + { + _seq.Clear(); + _state = State.Ground; + } + private void ProcessGround(char ch, ref List? lines) { switch (ch) @@ -167,6 +312,11 @@ private void ProcessGround(char ch, ref List? lines) // Carriage returns are dropped; scrollback is line-based. break; + case '\x1b': + _seq.Clear(); + _state = State.Escape; + break; + default: _run.Append(ch); break; diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 2fe275c2..435c8839 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -262,9 +262,23 @@ public async Task SendGmcpAsync(string package, string json) return true; } - private void OnMxpEnabled(object? sender, EventArgs e) + private void OnMxpEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Mxp); + + /// + /// Upgrades to the Pueblo parser when the server switches the connection to Pueblo — the same rule as + /// MXP's, above: only from the automatic default. + /// + private void OnPuebloEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Pueblo); + + /// + /// The one parser swap. Only from and only off the plain ANSI parser, so + /// the first markup a connection negotiates keeps it: a server that offers both MXP and Pueblo is + /// not switched back and forth by the order its handshakes finish in. (The telnet layer also declines to + /// answer a Pueblo hello once MXP is on.) + /// + private void UpgradeParser(ContentFormat format) { - if (World.ContentFormat != ContentFormat.Ansi || _parser is MxpParser) + if (World.ContentFormat != ContentFormat.Ansi || _parser is not AnsiParser) { return; } @@ -274,7 +288,7 @@ private void OnMxpEnabled(object? sender, EventArgs e) ProcessOutputLine(tail); } - _parser = NewParser(ContentFormat.Mxp); + _parser = NewParser(format); } /// @@ -456,6 +470,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) telnet.EncodingChanged += OnEncodingChanged; telnet.Disconnected += OnDisconnected; telnet.MxpEnabled += OnMxpEnabled; + telnet.PuebloEnabled += OnPuebloEnabled; try { @@ -980,6 +995,7 @@ private ITelnetSession DefaultSessionFactory(ConnectionOptions options) options: new TelnetSessionOptions { GmcpModules = World.GmcpModules.ToArray(), + AnnouncePueblo = World.ContentFormat is ContentFormat.Ansi or ContentFormat.Pueblo, CharsetOrder = order, EncodingOverride = encodingOverride, KeepaliveInterval = TelnetSessionOptions.ResolveKeepalive(World.KeepaliveSeconds), diff --git a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs index b40ca485..946d415d 100644 --- a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs @@ -39,6 +39,12 @@ public interface ITelnetSession : IAsyncDisposable /// event EventHandler? MxpEnabled; + /// + /// Raised once the server has switched this connection to Pueblo (its start sequence, after our + /// PUEBLOCLIENT). The of Pueblo. + /// + event EventHandler? PuebloEnabled; + /// /// Raised once the client has agreed to the server's MSDP, so a consumer can ask it to REPORT the /// variables it wants. MSDP servers commonly send nothing until asked. diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index fdbf72a7..f120fffd 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -111,6 +111,17 @@ public sealed class TelnetSessionOptions /// public IReadOnlyList GmcpModules { get; init; } = []; + /// + /// Whether to answer a server's Pueblo hello (This world is Pueblo 1.10 Enhanced.) with + /// PUEBLOCLIENT. Pueblo has no telnet option: the answer is a line of text, so it is sent only to a + /// server that has announced Pueblo, and only when nothing else (MXP) already owns the markup. The world's + /// content format decides it — see WorldSession. + /// + public bool AnnouncePueblo { get; init; } = true; + + /// The version this client announces as a Pueblo client; PennMUSH reads only the word. + public const string PuebloVersion = "2.50"; + /// /// Turns a world's configured keepalive seconds into an interval the telnet library will accept, /// or null when there should be none. @@ -372,6 +383,7 @@ private Encoding? Negotiated public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; public event EventHandler? MsdpEnabled; + public event EventHandler? PuebloEnabled; public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -384,6 +396,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport. await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); _msdpAgreed = false; + _mxpOn = false; try { @@ -432,6 +445,7 @@ private Task BuildInterpreterAsync() .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) + .AddPlugin().OnPuebloOffered(OnPuebloOfferedAsync).OnPuebloEnabled(OnPuebloEnabledAsync) .AddPlugin().OnCompressionEnabled(OnCompressionAsync); // An idle keepalive, when the world asks for one: IAC NOP after the configured silence, so a @@ -674,8 +688,44 @@ private ValueTask OnCompressionAsync(int version, bool enabled) return ValueTask.CompletedTask; } + /// + /// The server's Pueblo hello: answered with PUEBLOCLIENT unless the world says otherwise or MXP is + /// already in use. The hello is consumed by the library, so its bytes are dropped from the pending line + /// here — they were never going to be submitted, and a prompt boundary would otherwise flush them. + /// + private async ValueTask OnPuebloOfferedAsync() + { + _pending.Clear(); + if (!_options.AnnouncePueblo || _mxpOn || _interpreter?.PluginManager?.GetPlugin() is not { } pueblo) + { + _logger.LogInformation("The server offered Pueblo; not announcing (content format or MXP)."); + return; + } + + try + { + await pueblo.AnnounceAsync(TelnetSessionOptions.PuebloVersion).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not answer the server's Pueblo hello."); + } + } + + /// The server's Pueblo start sequence, consumed by the library like the hello. + private ValueTask OnPuebloEnabledAsync(PuebloClient client) + { + _pending.Clear(); + _logger.LogInformation("Pueblo negotiated."); + PuebloEnabled?.Invoke(this, EventArgs.Empty); + return ValueTask.CompletedTask; + } + + private volatile bool _mxpOn; + private ValueTask OnMxpEnabledAsync() { + _mxpOn = true; _logger.LogInformation("MXP negotiated."); MxpEnabled?.Invoke(this, EventArgs.Empty); return ValueTask.CompletedTask; diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs index 3e20d4c1..e64e2124 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs @@ -293,11 +293,53 @@ public async Task StrayLessThan_WithoutClose_IsEmittedLiterally() await Assert.That(line.Text).IsEqualTo("love <3"); } + /// + /// ANSI is decoded here, because nothing in front of this parser decodes it: a Pueblo world's colour used + /// to arrive as a literal <ESC>[33m. + /// [Test] - public async Task Esc_IsPassedThroughUntouched() + public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() { - var line = ParseSingleLine("a\x1bb\n"); - await Assert.That(line.Text).IsEqualTo("a\x1bb"); + var line = ParseSingleLine("\x1b[33mYellow\x1b[0m plain\n"); + + await Assert.That(line.Text).IsEqualTo("Yellow plain"); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); + await Assert.That(line.Spans[^1].Style.Foreground).IsEqualTo(TerminalColor.Default); + } + + [Test] + public async Task Ansi_AndPuebloMarkupComposeOnOneSpan() + { + var line = ParseSingleLine("\x1b[31mred bold\n"); + + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(1)); + await Assert.That(line.Spans[0].Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Ansi_OtherSequencesAreConsumed() + { + // Cursor movement, an OSC title (BEL- and ST-terminated), a charset select and a two-byte escape. + var line = ParseSingleLine("a\x1b[2Kb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); + + await Assert.That(line.Text).IsEqualTo("abcdef"); + } + + /// + /// An unterminated string escape a player typed ends with the line, in production's shape: fed without + /// its newline (the telnet layer strips it), then flushed. + /// + [Test] + public async Task Ansi_AnUnterminatedOscEndsWithTheLine() + { + var parser = new PuebloParser(); + parser.Feed("before\x1b]0;never closed"); + var first = parser.Flush(); + parser.Feed("next line"); + var second = parser.Flush(); + + await Assert.That(first!.Text).IsEqualTo("before"); + await Assert.That(second!.Text).IsEqualTo("next line"); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs index 6424962b..df28c582 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs @@ -23,6 +23,7 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; public event EventHandler? MsdpEnabled; public List<(string Variable, string Value)> SentMsdp { get; } = new(); @@ -88,6 +89,8 @@ public void EmitEncoding(SessionEncoding encoding) public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs index d7e954c2..9157a08c 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs @@ -86,4 +86,53 @@ public async Task EmojiSwitchesChangedBetweenConnectionsApplyToTheNextOne() await Assert.That(lines.Any(l => l.Text == "one :fire:")).IsTrue(); await Assert.That(lines.Any(l => l.Text.StartsWith("two", StringComparison.Ordinal) && l.Text.Contains("🔥", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task PuebloNegotiationSwitchesAnAutomaticWorldsParser() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.EmitLine("before"); + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + var lines = session.Scrollback.Snapshot(); + await Assert.That(lines.Any(l => l.Text == "before")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "after")).IsTrue(); + } + + /// + /// The first markup a connection negotiates keeps it: MXP after Pueblo leaves the Pueblo parser in place. + /// SEND tells them apart — a fresh MXP parser refuses it on an open line and echoes it, Pueblo + /// strips it. + /// + [Test] + public async Task MxpAfterPuebloDoesNotSwitchAgain() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.RaiseMxpEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsTrue(); + } + + [Test] + public async Task AnExplicitMxpWorldIgnoresPueblo() + { + var world = World(); + world.ContentFormat = ContentFormat.Mxp; + var telnet = new FakeTelnetSession(); + var session = new WorldSession(world, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsFalse(); + } } diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs new file mode 100644 index 00000000..d7865a73 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs @@ -0,0 +1,73 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// The Pueblo handshake from the client's side, as PennMUSH frames it: the server's hello is answered with +/// PUEBLOCLIENT, the server's start sequence switches the connection, and neither line reaches the +/// output. Pueblo is text at a login prompt, so nothing is said to a server that did not say hello first. +/// +public class PuebloHandshakeTests +{ + private const string Hello = "This world is Pueblo 1.10 Enhanced.\r\n"; + private const string Start = "\n"; + + private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text); + + [Test] + public async Task TheHelloIsAnsweredAndTheStartSwitchesTheConnection() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello + "Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var lines = new List(); + var enabled = 0; + session.OutputReceived += (_, e) => { lock (lines) lines.Add(e.Text); }; + session.PuebloEnabled += (_, _) => Interlocked.Increment(ref enabled); + await session.ConnectAsync(); + + await Assert.That(await transport.WaitForSentAsync(Ascii("PUEBLOCLIENT 2.50\r\n"))).IsTrue(); + + transport.SendToClient(Ascii(Start + "bold\r\n")); + for (var i = 0; i < 100 && Volatile.Read(ref enabled) == 0; i++) + { + await Task.Delay(10); + } + + await Assert.That(Volatile.Read(ref enabled)).IsEqualTo(1); + await Task.Delay(50); + string[] seen; + lock (lines) + { + seen = [.. lines]; + } + + await Assert.That(seen).Contains("Welcome"); + await Assert.That(seen).Contains("bold"); + await Assert.That(seen.Any(l => l.Contains("Pueblo", StringComparison.Ordinal) || l.Contains("xch_", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task ASilentServerHearsNothing() + { + var transport = new ScriptedTransport { Greeting = Ascii("Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } + + [Test] + public async Task AWorldThatSaysNoDoesNotAnswer() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello) }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { AnnouncePueblo = false }); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs index efc455f7..dc37fcd6 100644 --- a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs @@ -23,6 +23,7 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; public event EventHandler? MsdpEnabled; public List<(string Variable, string Value)> SentMsdp { get; } = new(); @@ -88,6 +89,8 @@ public void EmitEncoding(SessionEncoding encoding) public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); diff --git a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs index 8f592dfc..ab4d200b 100644 --- a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs +++ b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs @@ -65,6 +65,7 @@ public IReadOnlyList Lines #pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? MsspReceived; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; #pragma warning restore CS0067 public event EventHandler? MsdpEnabled;