MUD client out-of-band protocol support (GMCP/MSSP/MTTS/MSP) for accessibility

Motivation

Blind MUD players use scripting in fundamentally different ways than sighted players: to solve an audio-bottleneck problem in the screen reader. A line-by-line "wall of sound" from a TTS engine makes combat or chat unusable. The standard fix across the MUD world is a combination of:

  • Gagging — client-side triggers that suppress low-priority repetitive lines (combat swings, weather, movement flavor).
  • Virtual buffers — the client categorizes output (chat, combat, room, system) and lets the user hotkey between them without losing place.
  • Sound packs — thousands of lines of client script that map game events to .wav / .mp3 files. Sound replaces speech for spatial/situational awareness.
  • Status readouts — hotkey-driven speech summaries of vitals ("50/20" for HP/MP).
  • Speedwalking / pathfinding — client-side maps keyed off room IDs.

Every one of those features works dramatically better when the server emits structured out-of-band (OOB) events alongside the human-readable text stream, so the client can categorize/sound-trigger/map without regex-scraping.

This is the path used by every major blind-accessible MUD setup: Mudlet + a sound pack, VIP Mud, MUSH-Z on MUSHclient, TinTin++ on Linux with speech-dispatcher.

Related but distinct from #15 (closed) (terminal-level OSC 133 semantic shell hints for users who SSH in with a screen reader). This issue is about supporting users who connect with a MUD client.

Current gap

!23 (feat/mud-client-support) adds a plain text + ANSI "raw mode" (triggered by TERM=xterm-256-basic). It deliberately stops there — zero telnet option negotiation. No IAC handling either direction, no structured side channels. From a MUD-client perspective the server is currently a dumb line stream.

Transport note: django-moo is SSH-only, but sshelnet bridges plain-telnet clients into the SSH port. Since IAC byte sequences (0xFF prefix) pass through SSH transparently, we can implement the full suite of telnet-subnegotiation MUD protocols without running a second listener.

Proposed scope

Tier 1 — the OOB channel (highest value)

  • IAC state machine (new moo/shell/telnet.py) — inbound parser (DO/DONT/WILL/WONT + SB…SE), outbound encoder, per-session negotiation state. Pure Python, no new deps.
  • GMCP (Generic MUD Communication Protocol, telnet opt 201). The canonical modern OOB protocol. Emit:
    • Core.Hello {name, version} — at connect
    • Core.Supports.Set [...] — announce supported modules
    • Char.Name {name, fullname} — at login
    • Char.Vitals {hp, maxhp, mp, maxmp} — when changed
    • Char.StatusVars — on login
    • Room.Info {num, name, area, exits: {n, s, e, w, ...}} — on movement
    • Room.Players [{name}, ...] — on movement
    • Comm.Channel.Text {channel, talker, text} — for say/emote/page/channels
  • MTTS / TTYPE (telnet opt 24) — replaces the fragile TERM=xterm-256-basic opt-in. Modern MUD clients return a three-stage name/emulation/capability sequence on request. Auto-select raw mode when a real MUD client is detected.

Tier 2 — protocol polish

  • MSSP (telnet opt 70) — mud-list discovery (TMS, Mudlet directory). Respond with server name, codebase, uptime, player count.
  • GA / EOR (telnet opts 249 / 25) — prompt-end signal. Critical for screen readers to know "the server is done talking; focus the input line."
  • CHARSET (telnet opt 42) — lock to UTF-8, prevents mojibake on clients that default Latin-1.

Tier 3 — audio UI

  • MSP (MUD Sound Protocol) — inline !!SOUND(file.wav V=100) and !!MUSIC(...) markers in the text stream.
  • GMCP Client.Media.Play — same event in GMCP form for clients that prefer it (Mudlet).
  • SDK surface: moo.sdk.output.play_sound(obj, name, volume=100, priority=10) — prefers GMCP when negotiated, falls back to MSP.

Protocol-only. No bundled sound assets. Wizards and pack authors wire their own events.

Rich-mode freebie

Category metadata is unavoidable plumbing — something has to tell the OOB encoder "this say output is Comm.Channel.Text." Since it's landing anyway:

  • Optional category= kwarg on tell()/print() (or a new emit(category, text, payload) helper). Does not change the semantic tell vs print vs write split — categories are metadata, not a fourth output channel.
  • Per-category coloring in rich mode: static dict, user-overridable via a new COLORS session setting (e.g. COLORS chat=cyan room=dim.white combat=red). Default empty — no visible change for existing users.

Deferred (out of scope for this issue)

  • /review <category> re-display command — separate design pass, open questions about persistence (in-memory vs Redis, per-session vs durable).
  • Named side-windows (prompt_toolkit split panes) — multi-hour UI work, hostile interaction with existing line_editor=False fix.
  • NAWS — we already capture prompt_toolkit's width; deferred until a non-prompt_toolkit client actually needs it.
  • MCCP2 / MCCP3 (zlib compression) — bandwidth-only, no accessibility value.
  • MXP — largely superseded by GMCP.
  • MSDP — older sibling of GMCP with different encoding. Most blind-user scripts target GMCP now; revisit if evidence shows otherwise.

Architecture

  • New moo/shell/telnet.py — IAC state machine, encoders, negotiation state.
  • Extend moo/shell/server.py session_started — initiate negotiation (WILL GMCP, WILL MSSP, DO TTYPE, WILL EOR, WILL CHARSET) and install the IAC parser on the inbound channel.
  • Extend moo/shell/prompt.py:
    • _session_settings — track negotiated capabilities, mirror to Django cache (same pattern as existing settings).
    • process_messages — handle new "oob" event type; write via a new _chan_write_bytes() helper that skips LF→CRLF translation.
    • Raw-mode prompt render — emit IAC GA / IAC EOR after prompt when negotiated.
  • Extend moo/sdk/output.pysend_gmcp(obj, module, data), play_sound(obj, name, volume, priority), thin send_oob(obj, protocol, payload) underneath.
  • Default verbs — GMCP emission in login, $player.moved, say/emote/page/channels, and vitals-touching verbs. Explicit emits rather than a Property.save hook (Property.save is too hot for verb dispatch).

Verification

  • Unit: moo/shell/tests/test_telnet.py, test_gmcp.py, test_mssp.py — IAC parser round-trip, subnegotiation framing, 0xFF-in-payload escape, partial-buffer handling, wire-level encode correctness.
  • Integration: connect via Mudlet 4.15+ over SSH; verify Core.Hello / Char.Name / Room.Info / Comm.Channel.Text arrive in Mudlet's GMCP debug view. Connect via sshelnet + TinTin++; verify same events and observable GA in #debug TELNET. Confirm MTTS auto-selects raw mode for Mudlet without TERM magic.
  • Accessibility smoke: load a minimal Mudlet sound pack triggering on Comm.Channel.Text; send a say; confirm sound plays. Set up a GMCP gag; confirm filtering works.
  • Regression: uv run pytest -n auto (all 1114+ existing tests). Rich-mode prompt_toolkit sessions must look and behave identically — IAC bytes never leak into rendered output.

Implementation notes

Branch: feat/mud-oob-protocols, stacked on feat/mud-client-support.