The Case of the Garbled Borders: Fixing SyncTERM Support

September 18, 2026

Every BBS lives or dies by its client support. MuffinTerm works. The web client works. And then a user pointed out that the most popular terminal program in the BBS world — SyncTERM, the client that most of the people dialing into boards in 2026 are actually using — rendered every screen as mojibake. Box borders turned into ┬í┬┐ soup. The login screen was unreadable. Our own sysop confirmed it the same evening from his own machine: “completely messed up.”

This post is the story of chasing that bug down to the byte, and what we found in the drainpipe.

Two alphabets, one wire

First, some background on why this bug was even possible.

Classic BBS clients speak CP437 — the original IBM PC character set, one byte per cell. That’s where the box-drawing characters come from: is byte 0xB3, is 0xC4, and a shaded block is 0xB0. Modern terminal emulators speak UTF-8, where those same glyphs are two and three bytes long. A server has to pick one alphabet per connection and stick with it, because the difference between 0xB3 as a lone byte and the first byte of a UTF-8 pair is interpretation, not content.

Phosphor BBS picks based on who’s asking. MuffinTerm announces itself in its telnet terminal-type negotiation, so we answer it in CP437. Everything else gets UTF-8. That logic has worked for months.

So why did SyncTERM — which announces itself too — get garbage?

What the wire actually said

When the report came in, I did what I always do with rendering bugs: captured the raw bytes. Raw telnet captures of our own login banner showed something alarming — a mix. Hundreds of lone high bytes (CP437-style) coexisting with genuine UTF-8 pairs in a single screen. No wonder every client was confused; the server couldn’t even keep its own story straight.

The first hole we found was in the SSH path: the server decided encoding from the client’s TERM environment variable, and the whitelist accepted ansi, pc3, and pcansi — but not ansi-bbs, which is what SyncTERM sets. Easy fix, three tests. But our reporter was on telnet, not SSH, and the telnet path looked airtight: any terminal type containing SYNCTERM flips the session into CP437 mode. A simulation that answered the telnet negotiation with SYNCTERM produced a picture-perfect CP437 login screen.

The simulation passed. The real client didn’t. That gap between “my scripted handshake works” and “the actual program fails” is where this bug lived for another day.

Turning on the lights

The breakthrough came from instrumenting the real thing. We added debug logging to the telnet handler — connection, negotiation, terminal type, every encoding switch — and had someone connect with genuine SyncTERM.

The log told the whole story in five lines:

  • The screen started in UTF-8.
  • The client’s window size arrived.
  • Then the client’s terminal type arrived.
  • The encoding switched to CP437 — one full round-trip too late.

Here’s the race. The telnet terminal-type protocol (RFC 1091) is a two-step dance. The server asks “please tell me your type.” The client answers “I will tell you” (WILL TTYPE). The server must then ask “what is your type?” (SB TTYPE SEND). Only then does the client send the actual string.

Two problems fell out of that. First, we never sent the second question — so an RFC-compliant client like SyncTERM, which politely waits for it, never told us who it was. MuffinTerm never noticed because it sends its type unprompted, like most modern clients. Second — and sneakier — once we did send the question, the answer came back one network round-trip after the acknowledgment that triggers the first screen render. Our simulated handshake answered instantly over loopback and always beat the deadline. The real client, talking over a real network, lost the race every single time. The login screen went out in UTF-8, the identity landed a moment later, and the CP437 switch fired on a screen that had already been painted.

The fix

The fix has two halves, both in the telnet handler:

  1. Answer the protocol. When a client offers its terminal type, we now immediately send the SEND request asking for the string. One line of protocol compliance — and it has to be written through a proper Netty buffer, a detail our first attempt got wrong in a way that silently did nothing, which is exactly the kind of bug you only catch by testing the wire and not the source code.
  2. Wait one breath for the answer. If a client has acknowledged the connection but hasn’t yet told us its type, we hold the screen start for half a second. That’s imperceptible against the two-second timeout we already give non-responding clients, and it closes the round-trip race for every network latency a BBS is likely to see. Clients that bundle their type with the acknowledgment — like MuffinTerm — still start instantly, because nothing about their path changed.

The verification is the part I’m proudest of. We replayed SyncTERM’s exact packet timing against the fixed server: connect, acknowledge, pause for a round-trip, offer the type, pause again, send the string. Before the fix, 4,792 bytes of UTF-8 screen escaped before the identity arrived. After the fix: zero. The entire banner decodes cleanly as CP437 — 862 single-byte cells, not one UTF-8 pair. Rendered through a CP437 terminal emulator, the login screen comes out pixel-perfect: borders, shading gradient, login box, all of it.

And the real-world test: the reporter connected with SyncTERM the next morning and it just worked.

What we learned

Three things worth carrying forward:

Simulation is not verification. Our scripted handshake passed for a day while the real client failed. The difference was timing, and timing is exactly what a loopback simulation doesn’t reproduce. When a client reports a bug you can’t reproduce, instrument the protocol and watch the real thing — the logs are cheaper than the guessing.

Protocol compliance is a feature, not a formality. The telnet terminal-type negotiation has three steps for a reason. Skipping the middle one works fine with clients that don’t respect the RFC, and silently breaks the ones that do. SyncTERM is the single most popular BBS client in the world precisely because it follows the standards everyone else forgot.

Silent failures need wire-level tests. The first version of the fix wrote bytes the Netty pipeline quietly dropped. Everything compiled, every unit test passed, and no client ever saw the message. The only test that matters for a negotiation is one that reads the actual bytes off the wire and asserts on them — which is what our regression suite does now.

If you’ve been avoiding Phosphor BBS because your client of choice rendered it as soup: give it another dial. SyncTERM users, welcome back.