diff --git a/Directory.Packages.props b/Directory.Packages.props index 0bf6899e..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. --> - + 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/LoginFlow/LoginCommandReading.cs b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs index fdee4121..8822272e 100644 --- a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs +++ b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs @@ -59,6 +59,14 @@ public static partial class LoginCommandReading ["tbamud"] = "tbaMUD", ["rom"] = "ROM", ["merc"] = "Merc", + + // 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/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/Mssp/MsspSelfDescriptionTests.cs b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs index 203654e9..5bea04eb 100644 --- a/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs +++ b/tests/MUI.Crawl.Tests/Mssp/MsspSelfDescriptionTests.cs @@ -155,6 +155,33 @@ public async Task AMudlibTheReaderKnowsCountsAsTheEngine() await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue(); } + /// + /// Dystopia and Mindcloud are engines in the sense the gate means, and a game that says so is not + /// typed at. + /// + /// + /// 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] + [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", [codebase]), + ("FAMILY", [family]), + ("PLAYERS", ["0"])); + + await Assert.That(MsspSelfDescription.AnswersTheLoginCommands(report)).IsTrue(); + } + [Test] public async Task AMudlibOutsideTheFamilyListLeavesTheQuestionsWorthAsking() { 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.