From 388e693f8c43823575d1b78f102e0dac7260434b Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 22:18:46 -0500 Subject: [PATCH] Add MTTS capability parsing module with client color detection Parses MTTS bitfield values from telnetlib3 ttype3 into a ClientCaps dataclass. Includes color_depth property that returns the best available color mode (truecolor, 256, or 16) based on client capabilities. --- docs/lessons/charset-vs-mtts.txt | 26 +++++-- src/mudlib/caps.py | 90 ++++++++++++++++++++++++ tests/test_caps.py | 117 +++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 src/mudlib/caps.py create mode 100644 tests/test_caps.py diff --git a/docs/lessons/charset-vs-mtts.txt b/docs/lessons/charset-vs-mtts.txt index dbee5ad..bc4e928 100644 --- a/docs/lessons/charset-vs-mtts.txt +++ b/docs/lessons/charset-vs-mtts.txt @@ -43,12 +43,26 @@ for MUD clients — they prefer MTTS. meanwhile, in the same negotiation, tintin++ already told us via MTTS: - ttype3: MTTS 2825 - bit 0 (1) = ANSI color - bit 3 (8) = UTF-8 - bit 8 (256) = 256 colors - bit 9 (512) = OSC color palette - bit 11 (2048) = true color + ttype3: MTTS 2825 = 0b101100001001 + bit 0 (1) = ANSI + bit 1 (2) = VT100 + bit 2 (4) = UTF-8 + bit 3 (8) = 256 COLORS [SET] + bit 4 (16) = MOUSE TRACKING + bit 5 (32) = OSC COLOR PALETTE + bit 6 (64) = SCREEN READER + bit 7 (128) = PROXY + bit 8 (256) = TRUECOLOR [SET] + bit 9 (512) = MNES [SET] + bit 10 (1024) = MSLP + bit 11 (2048) = SSL [SET] + + for MTTS 2825: bits 0, 3, 8, 9, 11 are set + +NOTE: The original version of this doc had incorrect MTTS bit mappings (copied +from an unreliable source). This was corrected on 2026-02-07 to match the +actual MTTS spec from tintin.mudhalla.net/protocols/mtts/. The wrong values +caused a bug in caps.py that misinterpreted client capabilities. two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem uses. RFC 2066 CHARSET is technically correct but practically ignored. diff --git a/src/mudlib/caps.py b/src/mudlib/caps.py new file mode 100644 index 0000000..0eae15e --- /dev/null +++ b/src/mudlib/caps.py @@ -0,0 +1,90 @@ +"""MTTS (Mud Terminal Type Standard) capability parsing. + +Parses MTTS bitfield values from telnetlib3's ttype3 into a structured dataclass. +MTTS is advertised via TTYPE round 3 as "MTTS " where the number is a +bitfield of client capabilities. + +See docs/lessons/charset-vs-mtts.txt for protocol details. +""" + +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class ClientCaps: + """Parsed client capabilities from MTTS bitfield.""" + + ansi: bool = False + vt100: bool = False + utf8: bool = False + colors_256: bool = False + mouse_tracking: bool = False + osc_color_palette: bool = False + screen_reader: bool = False + proxy: bool = False + truecolor: bool = False + mnes: bool = False + mslp: bool = False + ssl: bool = False + + @property + def color_depth(self) -> Literal["truecolor", "256", "16"]: + """Return best available color mode: truecolor, 256, or 16.""" + if self.truecolor: + return "truecolor" + if self.colors_256: + return "256" + return "16" + + +def parse_mtts(ttype3: str | None) -> ClientCaps: + """Parse MTTS capability string into ClientCaps dataclass. + + Args: + ttype3: String from writer.get_extra_info("ttype3"), e.g., "MTTS 2825" + + Returns: + ClientCaps with parsed capabilities. Returns defaults for + None/empty/invalid input. + + Bit values from MTTS spec (tintin.mudhalla.net/protocols/mtts/): + Bit 0 (1) = ANSI + Bit 1 (2) = VT100 + Bit 2 (4) = UTF-8 + Bit 3 (8) = 256 COLORS + Bit 4 (16) = MOUSE TRACKING + Bit 5 (32) = OSC COLOR PALETTE + Bit 6 (64) = SCREEN READER + Bit 7 (128) = PROXY + Bit 8 (256) = TRUECOLOR + Bit 9 (512) = MNES + Bit 10 (1024) = MSLP + Bit 11 (2048) = SSL + """ + if not ttype3: + return ClientCaps() + + parts = ttype3.split() + if len(parts) != 2 or parts[0] != "MTTS": + return ClientCaps() + + try: + value = int(parts[1]) + except ValueError: + return ClientCaps() + + return ClientCaps( + ansi=bool(value & 1), + vt100=bool(value & 2), + utf8=bool(value & 4), + colors_256=bool(value & 8), + mouse_tracking=bool(value & 16), + osc_color_palette=bool(value & 32), + screen_reader=bool(value & 64), + proxy=bool(value & 128), + truecolor=bool(value & 256), + mnes=bool(value & 512), + mslp=bool(value & 1024), + ssl=bool(value & 2048), + ) diff --git a/tests/test_caps.py b/tests/test_caps.py new file mode 100644 index 0000000..ec4c4c7 --- /dev/null +++ b/tests/test_caps.py @@ -0,0 +1,117 @@ +"""Tests for MTTS capability parsing.""" + +from mudlib.caps import parse_mtts + + +def test_parse_mtts_tintin(): + """Parse tintin++ MTTS 2825 correctly. + + 2825 in binary = 0b101100001001 + Bits set: 0, 3, 8, 9, 11 + = ANSI(1) + 256colors(8) + truecolor(256) + MNES(512) + SSL(2048) + """ + caps = parse_mtts("MTTS 2825") + assert caps.ansi is True + assert caps.vt100 is False + assert caps.utf8 is False + assert caps.colors_256 is True + assert caps.mouse_tracking is False + assert caps.osc_color_palette is False + assert caps.screen_reader is False + assert caps.proxy is False + assert caps.truecolor is True + assert caps.mnes is True + assert caps.mslp is False + assert caps.ssl is True + assert caps.color_depth == "truecolor" + + +def test_parse_mtts_basic(): + """Parse MTTS 137 (ANSI + 256colors + proxy).""" + # 137 = 1 (ANSI) + 8 (256colors) + 128 (proxy) + caps = parse_mtts("MTTS 137") + assert caps.ansi is True + assert caps.utf8 is False + assert caps.colors_256 is True + assert caps.proxy is True + assert caps.truecolor is False + assert caps.color_depth == "256" + + +def test_parse_mtts_zero(): + """Parse MTTS 0 (nothing supported).""" + caps = parse_mtts("MTTS 0") + assert caps.ansi is False + assert caps.utf8 is False + assert caps.colors_256 is False + assert caps.truecolor is False + assert caps.color_depth == "16" + + +def test_parse_mtts_256_colors_only(): + """Parse MTTS with JUST 256 colors (no truecolor).""" + # 9 = 1 (ANSI) + 8 (256colors) + caps = parse_mtts("MTTS 9") + assert caps.ansi is True + assert caps.utf8 is False + assert caps.colors_256 is True + assert caps.truecolor is False + assert caps.color_depth == "256" + + +def test_parse_mtts_utf8_no_colors(): + """Parse MTTS with UTF-8 but no extended colors.""" + # 5 = 1 (ANSI) + 4 (UTF-8) + caps = parse_mtts("MTTS 5") + assert caps.ansi is True + assert caps.utf8 is True + assert caps.colors_256 is False + assert caps.truecolor is False + assert caps.color_depth == "16" + + +def test_parse_mtts_empty(): + """Handle empty string gracefully.""" + caps = parse_mtts("") + assert caps.ansi is False + assert caps.utf8 is False + assert caps.color_depth == "16" + + +def test_parse_mtts_none(): + """Handle None gracefully.""" + caps = parse_mtts(None) + assert caps.ansi is False + assert caps.utf8 is False + assert caps.color_depth == "16" + + +def test_parse_mtts_non_mtts(): + """Handle non-MTTS ttype3 strings (e.g., UNKNOWN-TERMINAL).""" + caps = parse_mtts("UNKNOWN-TERMINAL") + assert caps.ansi is False + assert caps.utf8 is False + assert caps.color_depth == "16" + + +def test_parse_mtts_malformed(): + """Handle malformed MTTS strings.""" + caps = parse_mtts("MTTS") + assert caps.ansi is False + assert caps.utf8 is False + assert caps.color_depth == "16" + + +def test_color_depth_priority(): + """Verify color_depth returns best available mode.""" + # truecolor wins + caps = parse_mtts("MTTS 2825") + assert caps.color_depth == "truecolor" + + # 256 wins over 16 + caps = parse_mtts("MTTS 9") + assert caps.color_depth == "256" + + # fallback to 16 + caps = parse_mtts("MTTS 5") # 1 (ANSI) + 4 (UTF-8) + assert caps.color_depth == "16"