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.