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/.mp3files. 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 connectCore.Supports.Set [...]— announce supported modulesChar.Name {name, fullname}— at loginChar.Vitals {hp, maxhp, mp, maxmp}— when changedChar.StatusVars— on loginRoom.Info {num, name, area, exits: {n, s, e, w, ...}}— on movementRoom.Players [{name}, ...]— on movementComm.Channel.Text {channel, talker, text}— for say/emote/page/channels
- MTTS / TTYPE (telnet opt 24) — replaces the fragile
TERM=xterm-256-basicopt-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 ontell()/print()(or a newemit(category, text, payload)helper). Does not change the semantictellvsprintvswritesplit — categories are metadata, not a fourth output channel. - Per-category coloring in rich mode: static dict, user-overridable via a new
COLORSsession 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=Falsefix. - 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.pysession_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 EORafter prompt when negotiated.
- Extend
moo/sdk/output.py—send_gmcp(obj, module, data),play_sound(obj, name, volume, priority), thinsend_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 aProperty.savehook (Property.saveis 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.Textarrive in Mudlet's GMCP debug view. Connect viasshelnet+ TinTin++; verify same events and observable GA in#debug TELNET. Confirm MTTS auto-selects raw mode for Mudlet withoutTERMmagic. - Accessibility smoke: load a minimal Mudlet sound pack triggering on
Comm.Channel.Text; send asay; 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.