Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
send in answer to DO COMPRESS2; before it, everything after their connect screens, MSSP report
included, arrived as zlib read as text.
-->
<PackageVersion Include="TelnetNegotiationCore" Version="4.6.0" />
<PackageVersion Include="TelnetNegotiationCore" Version="4.6.1" />

<!--
No logging, hosting or DI *abstractions* here. The crawler is an in-process BackgroundService in
Expand Down
17 changes: 17 additions & 0 deletions src/MUI.Crawl/Telnet/TelnetProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1642,6 +1642,23 @@ private TelnetInterpreterBuilder Build(
return ValueTask.CompletedTask;
})

// Pueblo's hello, "This world is Pueblo 1.10 Enhanced." (PennMUSH) or "1.0" (TinyMUX,
// RhostMUSH), is the server's half of a negotiation sent as a line of text, and it was
// being stored as the first line of the connect screen. The plugin takes it out of the
// stream, the same as MCP's offer above.
//
// Nothing is sent back. Answering is PUEBLOCLIENT, which is text at a login prompt and
// switches the session to HTML; the probe wants the screen a player without Pueblo sees.
//
// Noted on the offer, as MCP is: the hello is the server saying it speaks Pueblo, and it
// is measured rather than declared, beside MSSP's PUEBLO.
.AddPlugin<PuebloProtocol>()
.OnPuebloOffered(() =>
{
Note("PUEBLO");
return ValueTask.CompletedTask;
})

// What FlushPendingLineAsync used to do by hand, done on the interpreter's own
// byte-processing loop where the line buffer has exactly one writer — and, crucially,
// without pretending the peer sent anything. It retires itself the moment a server
Expand Down
8 changes: 8 additions & 0 deletions src/MUI.Crawl/Telnet/WatchedProtocols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ public override ValueTask OnEnabledAsync()
/// real screen into the WHO phase. The answer is the truth: this client renders nothing, so every
/// entry asked about is refused, and a bare <c>&lt;SUPPORT&gt;</c> gets a reply that claims nothing.
/// It goes out on a secure line, which a server reads as protocol rather than as typing.
/// <para>
/// Entities are decoded too. A server in MXP mode must escape <c>"</c>, <c>&amp;</c>, <c>&lt;</c>
/// and <c>&gt;</c> in its text, and CoffeeMud does from the moment option 91 is agreed, so a client
/// that renders nothing stored <c>&amp;quot;</c> wherever the game had printed a quote
/// (CoffeeMud Hardcore's connect screen, 2026-11-03). Only after MXP mode starts; before that, an
/// entity is text the game meant.
/// </para>
/// </remarks>
internal sealed class Mxp : MXPProtocol
{
Expand All @@ -108,6 +115,7 @@ public Mxp(Action<string> note)
{
_note = note;
OnMxpSupportRequested(asked => SendSupportsAsync([], asked));
DecodeEntities();
}

public override ValueTask OnEnabledAsync()
Expand Down
25 changes: 24 additions & 1 deletion tests/MUI.Crawl.Tests/Telnet/MxpSupportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ public async Task ASupportQuestionWithNoStartMarkerIsStillAnswered()
await Assert.That(result.MxpObserved).IsTrue();
}

/// <summary>
/// Once MXP mode starts, the server escapes its quotes, and the screen is stored with them
/// decoded. CoffeeMud Hardcore's ASCII art arrived as <c>.-&amp;quot;&amp;quot;-.</c>.
/// </summary>
[Test]
public async Task EntitiesInTheScreenAreDecoded()
{
await using var game = new HoldingGame
{
SendsStartMarker = false,
Screen = " .-&quot;&quot;-. Welcome to the Holding Pattern &amp; friends\r\nAccount name: ",
};

var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);

await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered);
await Assert.That(result.Banner).Contains(".-\"\"-. Welcome to the Holding Pattern & friends");
await Assert.That(result.Banner).DoesNotContain("&quot;");
}

/// <summary>
/// Speaks just enough telnet to agree MXP, start it, and ask; then holds its real screen until it
/// is answered or the hold runs out, as CoffeeMud does.
Expand Down Expand Up @@ -96,6 +116,9 @@ public HoldingGame()
/// </summary>
public bool SendsStartMarker { get; init; } = true;

/// <summary>The screen sent once the hold ends.</summary>
public string Screen { get; init; } = "Welcome to the Holding Pattern\r\nAccount name: ";

public ProbeTarget Target => new(
IPAddress.Loopback.ToString(),
((IPEndPoint)_listener.LocalEndpoint).Port);
Expand Down Expand Up @@ -136,7 +159,7 @@ private async Task ServeAsync()
await Task.Delay(20, _stopping.Token);
}

await SendAsync(stream, "Welcome to the Holding Pattern\r\nAccount name: ");
await SendAsync(stream, Screen);
await listening;
}
catch (OperationCanceledException)
Expand Down
29 changes: 29 additions & 0 deletions tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,35 @@ await Assert.That(result.Banner!.Trim())
await Assert.That(result.Negotiation.Supported).Contains("MCP");
}

/// <summary>
/// A MUSH's Pueblo hello is the server's half of a negotiation, so it is taken out of the connect
/// screen and recorded as the game offering Pueblo. Both spellings: PennMUSH names 1.10, TinyMUX
/// and RhostMUSH name 1.0.
/// </summary>
[Test]
[Arguments("1.10")]
[Arguments("1.0")]
public async Task APuebloHelloIsNotPartOfTheConnectScreen(string version)
{
await using var game = new FakeGame
{
Banner = $"This world is Pueblo {version} Enhanced.\r\n"
+ "Welcome to the MUSH.\r\n"
+ "Type 'connect guest guest' to look around.\r\n",
WhoReply = "0 Players logged in.\r\n",
};

var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);

await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered);
await Assert.That(result.Banner).DoesNotContain("Pueblo");
await Assert.That(result.Banner!.Trim())
.IsEqualTo("Welcome to the MUSH.\nType 'connect guest guest' to look around.");
await Assert.That(result.Negotiation.Supported).Contains("PUEBLO");
await Assert.That(game.Received.Any(line => line.StartsWith("PUEBLOCLIENT", StringComparison.Ordinal))).IsFalse()
.Because("answering would switch the session to HTML and type at the login prompt");
}

/// <summary>
/// The offer is evidence that the game speaks MCP, and is recorded as such -- in the quoted
/// spelling here, and in the unquoted one in
Expand Down
Loading