From 396e71a7270b4d09cbf3ea4adaf44e82624df236 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 28 Sep 2026 00:06:44 -0500 Subject: [PATCH] Read a game's INFO name only from inside a closed block mud.paroxysmrpg.com:3000 takes INFO as a character name. It replies "Illegal name, try another." and then its own prompt, `Name: ESC[40;0;37m`, with a trailing colour reset after the colon. `LoginCommandReading.MeaningfulName` read every `Name:` line anywhere in the reply, so that escape sequence became the listing's name and minted /g/40-0-37m (submitted 2026-09-20, renamed to Paroxysm by hand 2026-09-28). The same reading also admits the address in `CatalogueBinder`, which should only happen when the server actually named itself. `ConnectedPlayers` already refused anything outside a block that both opened and closed, for this reason. Both readers now take their lines from one `InfoBlock` helper that strips CSI colour codes first, so a coloured value still reads cleanly and a value made only of colour codes reads as empty. Tests: the measured Paroxysm bytes, an echoed `Name: INFO`, and an unterminated block are all refused. A coloured name inside a block reads without its codes. MUI.Crawl.Tests 606/606, MUI.Crawler.Tests 351/351. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../LoginFlow/LoginCommandReading.cs | 53 +++++++++++++------ .../LoginFlow/LoginCommandReadingTests.cs | 38 +++++++++++++ 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs index 8822272e..79d016cf 100644 --- a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs +++ b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs @@ -85,13 +85,16 @@ public static partial class LoginCommandReading /// /// Counterpart to MsspDefaults.MeaningfulName for codebases (RhostMUSH among them) that /// answer INFO but offer no MSSP. Same filter, same reason: Name: PennMUSH merely - /// restates the codebase and is not an identification. + /// restates the codebase and is not an identification. Read only from inside a closed block, as + /// is: a game that takes INFO as a character name answers + /// with its own Name: prompt, and mud.paroxysmrpg.com's — a colour reset after the colon — + /// was once listed as the game's name. /// public static string? MeaningfulName(string? info, string? version) { var codebase = MeaningfulCodebase(info, version); - foreach (var line in Lines(info)) + foreach (var line in InfoBlock(info) ?? []) { if (!TrySplitLabelled(line, out var label, out var value) || !NameLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) @@ -120,39 +123,51 @@ public static partial class LoginCommandReading /// Uptime: is never read numerically — PennMUSH writes it as a ctime string, not a number. /// public static int? ConnectedPlayers(string? info) + { + foreach (var line in InfoBlock(info) ?? []) + { + if (TrySplitLabelled(line, out var label, out var value) + && label.Equals(ConnectedLabel, StringComparison.OrdinalIgnoreCase) + // NumberStyles.None refuses a sign — a negative count means we misread the value, not + // a small number. + && int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var players)) + { + return players; + } + } + + return null; + } + + /// + /// The lines strictly inside the first INFO block, colour codes removed, or null when no + /// block both opened and closed. + /// + private static List? InfoBlock(string? info) { if (string.IsNullOrWhiteSpace(info)) { return null; } - var inside = false; - int? connected = null; + List? inside = null; - foreach (var raw in info.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) + foreach (var raw in AnsiEscape().Replace(info, string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) { var line = raw.Trim(); - if (!inside) + if (inside is null) { - inside = InfoBlockStart().IsMatch(line); + inside = InfoBlockStart().IsMatch(line) ? [] : null; continue; } if (InfoBlockEnd().IsMatch(line)) { - return connected; + return inside; } - if (connected is null - && TrySplitLabelled(line, out var label, out var value) - && label.Equals(ConnectedLabel, StringComparison.OrdinalIgnoreCase) - // NumberStyles.None refuses a sign — a negative count means we misread the value, not - // a small number. - && int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var players)) - { - connected = players; - } + inside.Add(line); } return null; @@ -418,6 +433,10 @@ private static IEnumerable FamiliesIn(IEnumerable lines) => private const string ConnectedLabel = "Connected"; // `### Begin INFO 1.1` (PennMUSH, RhostMUSH, TinyMUX) and `## BEGIN INFO 1.1` (Evennia). + // The same CSI shape WhoParser strips: SGR colour and the cursor/erase codes some prompts carry. + [GeneratedRegex(@"\x1B\[[0-9;?]*[ -/]*[@-~]")] + private static partial Regex AnsiEscape(); + [GeneratedRegex(@"^#{2,}\s*begin\s+info\b", RegexOptions.IgnoreCase)] private static partial Regex InfoBlockStart(); diff --git a/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs b/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs index 99e2b2b0..c04fb2a3 100644 --- a/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs +++ b/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs @@ -225,6 +225,44 @@ public async Task AnInfoNameThatIsOnlyTheCodebaseOrAPlaceholderIsRefused(string await Assert.That(LoginCommandReading.MeaningfulName(info, null)).IsNull(); } + /// + /// A login prompt that answers INFO by asking again is not an INFO block, however + /// much its prompt looks like a Name: field. + /// + /// + /// Measured at mud.paroxysmrpg.com:3000 on 2026-09-28: it takes INFO as a character name + /// and replies Illegal name, try another. then Name: ESC[40;0;37m — its prompt with + /// a trailing colour reset. Read as a field, the escape sequence became the listing's name and + /// slug (/g/40-0-37m). The count reader already demanded a closed block for the same + /// reason; the name reader now asks the same of it. + /// + [Test] + [Arguments("Illegal name, try another.\r\nName: \u001B[40;0;37m")] + [Arguments("Illegal name, try another.\nName: INFO")] + [Arguments("### Begin INFO 1\nName: Convergence MUSH\nConnected: 64")] + public async Task ANameOutsideAClosedInfoBlockIsNotRead(string info) + { + await Assert.That(LoginCommandReading.MeaningfulName(info, null)).IsNull(); + } + + /// A colour code around the value is presentation, not part of the name. + [Test] + public async Task AnInfoNameIsReadWithoutItsColourCodes() + { + const string Info = "### Begin INFO 1\nName: \u001B[1;36mConvergence MUSH\u001B[0m\n### End INFO"; + + await Assert.That(LoginCommandReading.MeaningfulName(Info, null)).IsEqualTo("Convergence MUSH"); + } + + /// A value that is nothing but colour codes names nothing. + [Test] + public async Task AnInfoNameThatIsOnlyAColourCodeIsRefused() + { + const string Info = "### Begin INFO 1\nName: \u001B[40;0;37m\n### End INFO"; + + await Assert.That(LoginCommandReading.MeaningfulName(Info, null)).IsNull(); + } + /// No INFO, or one that never names anything, yields nothing. [Test] public async Task AnInfoBlockWithNoNameYieldsNothing()