From 90b0ab3da688d040480f4375db65868f5f5cc500 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 11 Sep 2026 01:15:05 -0500 Subject: [PATCH 1/4] Read a stream started with MCCP v1's marker (TelnetNegotiationCore 2.17.0) Children of the Night (176.9.151.147:7702) answers our DO COMPRESS2 by starting its stream behind MCCP v1's IAC SB COMPRESS WILL SE. TelnetNegotiationCore skipped that as an unknown subnegotiation, so the zlib behind it reached the page as text: the connect screen ended in a line of binary noise, INFO and VERSION "replies" were noise, and the MSSP report, sent inside the stream, never arrived. 2.17.0 inflates it (HarryCordewener/TelnetNegotiationCore#98) and reports the stream as version 1, so MCCP1 joins MCCP2/MCCP3 in both vocabularies that fold a versioned option into the capability: MuLikeness's MU*-only signals and CapabilityFields' aliases. The probe test drives a raw server sending the live game's bytes in its order, and fails on 2.14.0. The jump from 2.14.0 also takes in 2.15's MSDP rewrite and 2.16's MXP start marker; all five suites pass against it. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 2 +- .../Persistence/Fields/FieldRegistry.cs | 5 +- src/MUI.Crawl/Shared/MuLikeness.cs | 3 +- src/MUI.Crawl/Telnet/Negotiation.cs | 5 +- .../Persistence/Fields/FieldRegistryTests.cs | 1 + .../MUI.Crawl.Tests/Shared/MuLikenessTests.cs | 16 +++ .../Telnet/ProbeSessionTests.cs | 130 ++++++++++++++++++ 7 files changed, 158 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0bf6899e..d282a2eb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,7 +15,7 @@ A probe of a 2.4 KB screen falls from 10.9 MB allocated to 3.1 MB. Churn, not retention: the live set was flat throughout, which is why nothing here ever measured a leak. --> - + diff --git a/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs b/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs index 5e9b5d43..e42ef4a3 100644 --- a/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs +++ b/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs @@ -32,7 +32,9 @@ public static class CapabilityFields /// Two vocabularies name the same capability differently: the handshake negotiates the telnet /// option (MCCP2), MSSP's variable is the version-less MCCP. Without this alias /// each spelling gets its own row, and the point of holding measured beside declared — the - /// comparison — cannot happen across two rows. + /// comparison — cannot happen across two rows. MCCP1 is the same stream announced with + /// v1's marker, observed at 176.9.151.147:7702 (Children of the Night), which starts it + /// that way in answer to our DO COMPRESS2. /// /// /// SSL is the same shape between two MSSP variables rather than two protocols: the spec @@ -49,6 +51,7 @@ public static class CapabilityFields private static readonly IReadOnlyDictionary Aliases = new Dictionary(StringComparer.Ordinal) { + ["mccp1"] = "MCCP", ["mccp2"] = "MCCP", ["ssl"] = "TLS", }; diff --git a/src/MUI.Crawl/Shared/MuLikeness.cs b/src/MUI.Crawl/Shared/MuLikeness.cs index 2711f1d9..fff2219f 100644 --- a/src/MUI.Crawl/Shared/MuLikeness.cs +++ b/src/MUI.Crawl/Shared/MuLikeness.cs @@ -21,7 +21,7 @@ public static class MuLikeness /// /// Generic telnet options (TTYPE, NAWS, CHARSET, NEW-ENVIRON, EOR, SUPPRESS GO AHEAD, ECHO) are /// deliberately excluded — every telnet daemon negotiates them, so admitting one would publish - /// any host that speaks telnet at all. MCCP2/MCCP3 fold into mccp (matching + /// any host that speaks telnet at all. MCCP1/MCCP2/MCCP3 fold into mccp (matching /// CapabilityFields) because the option is versioned and the capability is not; the alias /// is repeated rather than shared since MUI.Crawl deliberately has no reference to /// MUI.Catalog. @@ -35,6 +35,7 @@ public static class MuLikeness ["MXP"] = "mxp", ["MSP"] = "msp", ["MCCP"] = "mccp", + ["MCCP1"] = "mccp", ["MCCP2"] = "mccp", ["MCCP3"] = "mccp", ["ATCP"] = "atcp", diff --git a/src/MUI.Crawl/Telnet/Negotiation.cs b/src/MUI.Crawl/Telnet/Negotiation.cs index a4aa19d8..d1f7c470 100644 --- a/src/MUI.Crawl/Telnet/Negotiation.cs +++ b/src/MUI.Crawl/Telnet/Negotiation.cs @@ -39,7 +39,10 @@ public sealed record Negotiation /// public IReadOnlyList McpPackages { get; init; } = []; - /// MCCP compression, and which version, when the server negotiated it. + /// + /// MCCP compression, and which version, when the server negotiated it. 1 is not a negotiated v1 — + /// the probe refuses that — but a server that answered DO COMPRESS2 with v1's marker. + /// public int? CompressionVersion { get; init; } /// diff --git a/tests/MUI.Catalog.Tests/Persistence/Fields/FieldRegistryTests.cs b/tests/MUI.Catalog.Tests/Persistence/Fields/FieldRegistryTests.cs index 3f4d7635..f7a4e6e2 100644 --- a/tests/MUI.Catalog.Tests/Persistence/Fields/FieldRegistryTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/Fields/FieldRegistryTests.cs @@ -169,6 +169,7 @@ public async Task TwoVocabulariesForOneCapabilityArriveAtOneField() // The handshake names the telnet option it negotiated, MSSP names the feature: MCCP2 against // MCCP, SSL against TLS. Left apart, each produces a half-empty row that reads as absence. await Assert.That(CapabilityFields.Canonical("MCCP2")).IsEqualTo("MCCP"); + await Assert.That(CapabilityFields.Canonical("MCCP1")).IsEqualTo("MCCP"); await Assert.That(CapabilityFields.Canonical("SSL")).IsEqualTo("TLS"); await Assert.That(CapabilityFields.Canonical("ssl")).IsEqualTo("TLS"); diff --git a/tests/MUI.Crawl.Tests/Shared/MuLikenessTests.cs b/tests/MUI.Crawl.Tests/Shared/MuLikenessTests.cs index af6e52bd..3706fd9d 100644 --- a/tests/MUI.Crawl.Tests/Shared/MuLikenessTests.cs +++ b/tests/MUI.Crawl.Tests/Shared/MuLikenessTests.cs @@ -215,6 +215,22 @@ public async Task SignalsAreReportedInOneOrderSoTheStoredRecordIsStable() await Assert.That(MuLikeness.Signals(probe)).IsEquivalentTo(["mssp", "gmcp", "mccp", "who"]); } + /// + /// A stream started with MCCP v1's marker is MCCP, whichever marker announced it. + /// + /// + /// 176.9.151.147:7702 answers DO COMPRESS2 with v1's marker, and the probe records + /// the version the wire named. Unfolded, MCCP1 would be a telnet option this list does not + /// know, and the one MU*-only signal the session carried would be dropped. + /// + [Test] + public async Task AStreamAnnouncedWithTheV1MarkerIsStillMccp() + { + var probe = Probe(offered: ["MCCP1"]); + + await Assert.That(MuLikeness.Signals(probe)).IsEquivalentTo(["mccp"]); + } + private static ProbeResult Probe( MsspOutcome msspOutcome = MsspOutcome.NotOffered, string[]? offered = null, diff --git a/tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs b/tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs index d857f58e..06acd095 100644 --- a/tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs +++ b/tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs @@ -961,6 +961,34 @@ await Assert.That(LoginCommandReading.MeaningfulCodebase(result.Info, result.Ver await Assert.That(LoginCommandReading.ConnectedPlayers(result.Info)).IsEqualTo(7); } + /// + /// A server that answers DO COMPRESS2 with MCCP v1's marker is still read. + /// + /// + /// Children of the Night (176.9.151.147:7702) offers both versions, is told + /// DONT COMPRESS and DO COMPRESS2, and starts its stream behind + /// IAC SB COMPRESS WILL SE anyway. Before TelnetNegotiationCore 2.17.0 that marker was + /// skipped as an unknown subnegotiation and the zlib behind it reached the page as text: its + /// connect screen ended in a line of binary noise, and its MSSP report, sent inside the stream, + /// never arrived at all. + /// + [Test] + public async Task AStreamStartedWithTheV1MarkerIsInflated() + { + await using var game = new Mccp1Game(); + + var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target); + + await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered); + await Assert.That(result.OfferedOptions).Contains("MCCP1"); + await Assert.That(result.Negotiation.CompressionVersion).IsEqualTo(1); + + // The report only exists inside the compressed stream, so reading it proves the inflation. + await Assert.That(result.MsspField("NAME")).IsEqualTo("Children of the Night"); + await Assert.That(result.Banner).Contains("What name shall we carve into your forehead?"); + await Assert.That(result.Banner).DoesNotContain("\uFFFD"); + } + /// /// And the unterminated line survives compression too, which is what the newline was for. /// @@ -2423,6 +2451,108 @@ public async ValueTask DisposeAsync() } } + /// + /// Children of the Night's handshake, byte for byte, from a raw socket. + /// + /// + /// Hand-written rather than TelnetNegotiationCore in Server mode, because the library's server + /// only ever sends MCCP2's marker, and the shape under test is a server that does not: this one + /// offers COMPRESS and COMPRESS2, prints its connect screen in the clear, and on + /// DO COMPRESS2 starts one zlib stream behind v1's IAC SB COMPRESS WILL SE — the + /// order and bytes captured off the live server on 2026-09-11. Its MSSP report goes inside the + /// stream, where the real one's does. + /// + private sealed class Mccp1Game : IAsyncDisposable + { + private const byte Iac = 255, Will = 251, Do = 253, Sb = 250, Se = 240; + private const byte Compress = 85, Compress2 = 86, Mssp = 70, MsspVar = 1, MsspVal = 2; + + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stopping = new(); + private readonly Task _serving; + + public Mccp1Game() + { + _listener.Start(); + _serving = ServeAsync(); + } + + public ProbeTarget Target => new( + IPAddress.Loopback.ToString(), + ((IPEndPoint)_listener.LocalEndpoint).Port); + + private async Task ServeAsync() + { + try + { + using var client = await _listener.AcceptTcpClientAsync(_stopping.Token); + var stream = client.GetStream(); + + await stream.WriteAsync(Encoding.ASCII.GetBytes("\n\rAttempting to detect client, please wait...\r\n\r\n") + .Concat(new[] { Iac, Will, Compress }) + .Concat(Encoding.ASCII.GetBytes( + "::Children of the Night 4.5::\n\r\n\rWhat name shall we carve into your forehead? ")) + .Concat(new[] { Iac, Will, Mssp, Iac, Will, Compress2 }) + .ToArray(), _stopping.Token); + + // Both answers, however the client splits them across writes. + var heard = new List(); + var buffer = new byte[512]; + while (!(Contains(heard, [Iac, Do, Compress2]) && Contains(heard, [Iac, Do, Mssp]))) + { + var read = await stream.ReadAsync(buffer, _stopping.Token); + if (read == 0) + { + return; + } + + heard.AddRange(buffer.AsSpan(0, read).ToArray()); + } + + await stream.WriteAsync(new byte[] { Iac, Sb, Compress, Will, Se }, _stopping.Token); + + await using var deflate = new System.IO.Compression.ZLibStream( + stream, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true); + deflate.Write([Iac, Sb, Mssp]); + foreach (var (variable, value) in new[] { ("NAME", "Children of the Night"), ("PLAYERS", "3") }) + { + deflate.Write([MsspVar, .. Encoding.ASCII.GetBytes(variable), MsspVal, .. Encoding.ASCII.GetBytes(value)]); + } + + deflate.Write([Iac, Se]); + await deflate.FlushAsync(_stopping.Token); + + // Hold the session open, reading and discarding, until the probe hangs up. + while (await stream.ReadAsync(buffer, _stopping.Token) > 0) + { + } + } + catch (OperationCanceledException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + + private static bool Contains(List haystack, byte[] needle) => + System.Runtime.InteropServices.CollectionsMarshal.AsSpan(haystack).IndexOf(needle) >= 0; + + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync(); + _listener.Stop(); + await _serving; + _stopping.Dispose(); + } + } + /// /// The server side of an MSDP round trip, built from TelnetNegotiationCore's own Server-mode /// interpreter rather than a hand-rolled reimplementation of MSDP's wire framing. From e198a483557ef7609494bf91cd5e43086be052a3 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 11 Sep 2026 01:15:05 -0500 Subject: [PATCH 2/4] Stop typing INFO and VERSION at a Dystopia game that has already named itself With its MSSP report readable, Children of the Night publishes a meaningful NAME, a PLAYERS count and CODEBASE "Dystopia 1.4" -- everything MsspSelfDescription asks for, except that the engine clause only counts engines LoginCommandReading knows, and it did not know Dystopia. So the probe went on typing INFO at its name prompt, which took it as a character name ("Your sure that you want INFO engraved on your tombstone (Y/N)?") and then took VERSION as the answer. Dystopia is the Merc -> GodWars line's released codebase, a family word in the same sense Merc and ROM already are. No game in the catalogue is titled with it. Re-probed live: neither command is sent. Co-Authored-By: Claude Opus 5 --- .../LoginFlow/LoginCommandReading.cs | 5 ++++ .../Mssp/MsspSelfDescriptionTests.cs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs index fdee4121..3323d7c4 100644 --- a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs +++ b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs @@ -59,6 +59,11 @@ public static partial class LoginCommandReading ["tbamud"] = "tbaMUD", ["rom"] = "ROM", ["merc"] = "Merc", + + // The Merc → GodWars line's released codebase. Children of the Night (176.9.151.147:7702) + // declares CODEBASE "Dystopia 1.4" and takes whatever reaches its name prompt as a character + // name, INFO included; knowing the word is what lets MsspSelfDescription stop typing at it. + ["dystopia"] = "Dystopia", }; /// diff --git a/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs index 203654e9..dfbebfb5 100644 --- a/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs +++ b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs @@ -155,6 +155,29 @@ public async Task AMudlibTheReaderKnowsCountsAsTheEngine() await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue(); } + /// + /// Dystopia is an engine in the sense the gate means, and a game that says so is not typed at. + /// + /// + /// Children of the Night (176.9.151.147:7702) publishes this report — abridged — and reads + /// whatever arrives at its name prompt as a character name: INFO came back as "Your sure + /// that you want INFO engraved on your tombstone (Y/N)?", with VERSION then taken as the + /// answer. Dystopia is the Merc → GodWars line's own codebase, released as such, in the same + /// sense Merc and ROM already on the list are; the report had already said + /// everything either command could. + /// + [Test] + public async Task ADystopiaGameThatNamedItselfIsNotAskedAgain() + { + var report = Report( + ("NAME", ["Children of the Night"]), + ("CODEBASE", ["Dystopia 1.4"]), + ("FAMILY", ["DikuMUD"]), + ("PLAYERS", ["0"])); + + await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue(); + } + [Test] public async Task AMudlibOutsideTheFamilyListLeavesTheQuestionsWorthAsking() { From 4476b898635268211d80e96e7149afcd46d326e6 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 11 Sep 2026 12:26:06 -0500 Subject: [PATCH 3/4] Stop typing INFO and VERSION at Children of the Night 4.5 too The 4.5 server (omen.genesismuds.com:2251) is the same codebase line as 5.0 and answers INFO at its name prompt the same way -- "Your sure that you want INFO engraved on your tombstone (Y/N)?" -- but its MSSP report declares CODEBASE "Mindcloud3", which the engine vocabulary did not know, so the gate still let both commands through. Mindcloud is the GodWars line's released codebase in the same sense Dystopia is; FamilyWord already allows a digit after the word. No catalogue game is titled with it. Re-probed live: neither command is sent to either server. Co-Authored-By: Claude Opus 5 --- .../LoginFlow/LoginCommandReading.cs | 9 ++++--- .../Mssp/MsspSelfDescriptionTests.cs | 24 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs index 3323d7c4..8822272e 100644 --- a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs +++ b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs @@ -60,10 +60,13 @@ public static partial class LoginCommandReading ["rom"] = "ROM", ["merc"] = "Merc", - // The Merc → GodWars line's released codebase. Children of the Night (176.9.151.147:7702) - // declares CODEBASE "Dystopia 1.4" and takes whatever reaches its name prompt as a character - // name, INFO included; knowing the word is what lets MsspSelfDescription stop typing at it. + // The Merc → GodWars line's released codebases. Children of the Night declares both, one per + // version -- CODEBASE "Dystopia 1.4" at 176.9.151.147:7702 (5.0) and "Mindcloud3" at + // omen.genesismuds.com:2251 (4.5) -- and takes whatever reaches its name prompt as a character + // name, INFO included; knowing the words is what lets MsspSelfDescription stop typing at it. + // FamilyWord allows a digit after the word, so "mindcloud" matches "Mindcloud3". ["dystopia"] = "Dystopia", + ["mindcloud"] = "Mindcloud", }; /// diff --git a/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs index dfbebfb5..5bea04eb 100644 --- a/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs +++ b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs @@ -156,23 +156,27 @@ public async Task AMudlibTheReaderKnowsCountsAsTheEngine() } /// - /// Dystopia is an engine in the sense the gate means, and a game that says so is not typed at. + /// Dystopia and Mindcloud are engines in the sense the gate means, and a game that says so is not + /// typed at. /// /// - /// Children of the Night (176.9.151.147:7702) publishes this report — abridged — and reads - /// whatever arrives at its name prompt as a character name: INFO came back as "Your sure - /// that you want INFO engraved on your tombstone (Y/N)?", with VERSION then taken as the - /// answer. Dystopia is the Merc → GodWars line's own codebase, released as such, in the same - /// sense Merc and ROM already on the list are; the report had already said - /// everything either command could. + /// Both Children of the Night servers publish these reports — abridged — and read whatever arrives + /// at their name prompt as a character name: INFO came back as "Your sure that you want INFO + /// engraved on your tombstone (Y/N)?", with VERSION then taken as the answer. 5.0, at + /// 176.9.151.147:7702, declares Dystopia 1.4; 4.5, at + /// omen.genesismuds.com:2251, declares Mindcloud3. Both are the Merc → GodWars + /// line's own released codebases, in the same sense Merc and ROM already on the list + /// are; the report had already said everything either command could. /// [Test] - public async Task ADystopiaGameThatNamedItselfIsNotAskedAgain() + [Arguments("Dystopia 1.4", "DikuMUD")] + [Arguments("Mindcloud3", "Diku")] + public async Task AGodWarsLineGameThatNamedItselfIsNotAskedAgain(string codebase, string family) { var report = Report( ("NAME", ["Children of the Night"]), - ("CODEBASE", ["Dystopia 1.4"]), - ("FAMILY", ["DikuMUD"]), + ("CODEBASE", [codebase]), + ("FAMILY", [family]), ("PLAYERS", ["0"])); await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue(); From 3c9688d0f8c6a2cb7b6cb37a7890a0dcd91b517b Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 11 Sep 2026 12:26:12 -0500 Subject: [PATCH 4/4] Pin TelnetNegotiationCore 3.0.0, the release the MCCP v1 fix ships in 2.17.0 was never released: the fix (HarryCordewener/TelnetNegotiationCore#98) went out folded into 3.0.0, alongside .NET 11 support and OneOf replaced by C# 15 unions. 3.0.0 still ships a net10.0 build, and this branch builds against it on the .NET 10 SDK with no source change. Tested against 3.0.0's exact code packed locally as 3.0.0-local-v3: clean build on SDK 10.0.400, and all six suites pass -- Catalog 643, Crawl 570, Discovery 336, I3 13, Crawler 342, Web 1250, with MUI_REQUIRE_POSTGRES=1. Live probes of both Children of the Night servers read MCCP1, MSDP and MSSP and prove UTF-8. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d282a2eb..a52f69a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,8 +14,12 @@ 2.14.0 stops firing the state machine for ordinary text, which is most of a connect screen. A probe of a 2.4 KB screen falls from 10.9 MB allocated to 3.1 MB. Churn, not retention: the live set was flat throughout, which is why nothing here ever measured a leak. + + 3.0.0 inflates a stream started with MCCP v1's marker, which both Children of the Night servers + send in answer to DO COMPRESS2; before it, everything after their connect screens, MSSP report + included, arrived as zlib read as text. --> - +