This guide explains how to build a highly optimized, lag-free analog network bridge. The goal is to connect a 1999 Sega Dreamcast to a modern Linux PC via a telephone wire, allowing you to play Quake 3 Arena against bots or friends hosted on a local Docker container.
1. The Hardware Magic
Modems were designed to connect to giant phone company switches, not to each other. To make the Dreamcast and the PC modem talk directly over a wire on your desk, we have to trick them using a custom electrical circuit.
How the Pieces Work Together
- Faking the Phone Line (The LVI): A real wall jack pushes electricity down the line. We use a 12V DC power supply and a resistor to simulate this power. When the modems detect this 12V, they think they are plugged into a real phone network and are ready to dial.
- The "Stubborn Quake" Dial Tone: Some Dreamcast games will dial blindly, but Quake 3 Arena refuses to dial unless it actually hears a dial tone. We wire a button to a cheap DFPlayer Mini MP3 board. When you press the button, it plays an MP3 of a dial tone straight into the phone wire, tricking Quake 3 into starting the call.
- Protecting the MP3 Player: We put a capacitor between the DFPlayer and the phone wire. This acts as a shield: it lets the audio (the dial tone) pass through, but blocks the 12V DC power so it doesn't fry the cheap MP3 board.
- Connecting the Modems: Another capacitor connects the Dreamcast and the PC modem. This lets the screeching modem data pass back and forth while keeping their internal voltages separate so they don't damage each other.
- The Speed: Because we are wiring two modems directly together without a commercial phone company's digital backbone, the modems will naturally settle at a rock-solid 28.8 kbps connection. Since Quake 3 only needs about 5 kbps to run perfectly, this gives us zero lag and a buttery smooth experience.
2. Stopping the Linux Lag
Using a bare-metal DB9 Serial port on your PC motherboard is much better than a USB adapter, but Linux tries to be "efficient" with it in a way that hurts gaming.
Fixing the Serial Buffer
By default, Linux tells your motherboard's serial chip to hold onto data and wait until it has a good-sized chunk before sending it to the processor. For downloading text files, this is great. For a twitch-shooter like Quake 3, holding onto player movement packets causes severe "rubberbanding" and lag. We fix this by running the following command:
sudo setserial /dev/ttyS0 low_latency
This tells Linux to bypass its holding area and instantly fire every single byte of data to the game server the millisecond it arrives. We also lock the cable speed between the PC and the modem to 115200 baud, ensuring the cable is never a bottleneck.
3. The Network Heist (Routing)
The Linux PC acts as an ISP for the Dreamcast, using a program called pppd to create a network bridge over the phone line. We give it a few specific flags:
| Command | What it does |
|---|---|
| noccp | Turns off data compression. Game data can't be compressed anyway, so this saves the CPU from wasting time trying. |
| noauth | Tells the Linux PC to let the Dreamcast connect immediately without asking for an ISP username or password. |
| ms-dns 46.101.91.123 | Forces the console to use the community-run Dreamcast Live DNS. If the game tries to find a dead Sega server, this redirects it to the fan-hosted revival servers. |
Hijacking the Game Traffic
Quake 3 on the Dreamcast doesn't let you type in the IP address of your local Docker container. It expects to pull a list of servers from the public internet. To force the game to play locally, we set up a trap using IPTables:
sudo iptables -t nat -I PREROUTING 1 -i ppp0 -p udp --dport 27960 -j REDIRECT --to-ports 27960
The Trap: When you select any random server from the game's internet menu, Linux intercepts the connection, throws away the public internet address, and secretly routes the Dreamcast straight into your local Quake 3 Docker container.
If you ever want to play on real public servers again, you just delete the trap:
sudo iptables -t nat -D PREROUTING -i ppp0 -p udp --dport 27960 -j REDIRECT --to-ports 27960
4. The Automation Script (nonodreamorpi.py)
This script is the brain of the operation. We stripped out all the complicated background tasks to make a simple, "single-shot" script. Here is how you use it:
- Turn on your external PC modem.
- Run the script. It will open the COM port and say "Waiting 3 seconds...".
- Press your physical DFPlayer button to start the dial tone, and immediately tell Quake 3 to connect.
- The script instantly forces the PC modem to answer the call, hands the connection to Linux, and steps out of the way.
- When you hang up in the game, the script cleanly shuts itself down.
#!/usr/bin/env python3
import argparse
import serial
import serial.tools.list_ports
import logging
import sys
import time
import subprocess
import psutil
logger = logging.getLogger('nonodreamorpi')
print('nonodreamorpi Fork Of: notdreamnorpi\n'
'https://github.com/samicrusader/notdreamnorpi\n'
'Which is a Fork of Kazade\'s original DreamPi:\n'
'https://github.com/Kazade/dreampi\n'
'--\n'
'Modified for bare-metal, single-shot blind answer.\n'
'--\n')
def find_next_unused_ip(start, end: int = 1):
logger.debug('Finding active network interface...')
interface = None
with open('/proc/net/route') as f:
for line in f.readlines():
iface, dest, _, flags, _, _, _, _, _, _, _, = line.strip().split()
if dest != '00000000' or not int(flags, 16) & 2:
continue
logger.info(f'Network interface found: {iface}')
interface = iface
if not interface:
raise Exception('No active network interfaces were found')
parts = [int(x) for x in start.split(".")]
current_check = parts[-1] - 1
output = subprocess.check_output(['arp', '-a', '-i', interface]).decode()
addresses = tuple()
for i in range(end):
while True:
test_ip = '.'.join([str(x) for x in parts[:3] + [current_check]])
current_check -= 1
if test_ip not in addresses and (f'({test_ip})' not in output or f'({test_ip}) at <incomplete>' in output):
addresses += (test_ip,)
break
if not len(addresses) == 0:
if len(addresses) == 1:
return addresses[0]
else:
return addresses
else:
raise Exception('Unable to find a free IP on the network')
def autoconfigure_ppp(device, speed):
gateway_ip = subprocess.check_output('route -n | grep \'UG[ \t]\' | awk \'{print $2}\'', shell=True).decode()
subnet = gateway_ip.split('.')[:3]
host_ip, client_ip = find_next_unused_ip('.'.join(subnet) + '.100', 2)
logger.info(f'Using host IP address: {host_ip}')
logger.info(f'Using client IP address: {client_ip}')
cmdline = f'/usr/sbin/pppd {device} {speed} {host_ip}:{client_ip} nodetach ms-dns 46.101.91.123 proxyarp ktune noccp noauth'
return cmdline
def detect_device_and_speed():
logging.info('Detecting available modems on COM ports...')
ports = serial.tools.list_ports.comports()
for port, _, _ in sorted(ports):
# 115200 prioritized for the bare-metal serial connection
for speed in [115200]:
logging.debug(f'Trying device {port} at {speed}...')
m = Modem(port, speed)
m.connect()
# Give the hardware DSP a moment to wake up
time.sleep(1.5)
m._serial.write(b'AT\r\n')
time.sleep(1)
if m._serial.readline().strip() == b'AT':
logging.info(f'Hardware modem locked on {port} at {speed} baud.')
m.disconnect()
return port, speed
m.disconnect()
raise Exception('No usable modem was found. Is it powered on and attached?')
class Modem(object):
def __init__(self, device, speed):
self._device = device
self._speed = speed
self._serial = None
@property
def device_name(self):
return self._device
@property
def device_speed(self):
return self._speed
def connect(self):
if self._serial:
self.disconnect()
self._serial = serial.Serial(self._device, self._speed, timeout=0)
def disconnect(self):
if self._serial and self._serial.isOpen():
self._serial.close()
self._serial = None
def reset(self):
self.send_command('ATZ0')
self.send_command('ATE0')
def send_command(self, command, timeout=60):
valid_responses = [b'OK', b'ERROR', b'CONNECT', b'VCON']
final_command = f'{command}\r\n'
self._serial.write(final_command.encode())
start = time.time()
line = bytes()
while True:
new_data = self._serial.readline().strip()
if not new_data:
continue
line = line + new_data
for resp in valid_responses:
if resp in line:
return
if (time.time() - start) > timeout:
raise IOError('Timeout waiting for modem response.')
def main():
# 1. Kill lingering pppd processes
logging.info('Checking for zombie network processes...')
for proc in psutil.process_iter():
try:
if 'pppd' in proc.name().lower():
logging.info(f'Killing old pppd (pid {proc.pid})...')
proc.send_signal(9)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# 2. Find the modem
device_and_speed = None
while not device_and_speed:
try:
device_and_speed = detect_device_and_speed()
except Exception:
logger.warning('Unable to find modem. Retrying in 5 seconds...')
time.sleep(5)
modem = Modem(device_and_speed[0], device_and_speed[1])
cmdline = autoconfigure_ppp(modem.device_name, modem.device_speed)
# 3. Connect and prepare the hardware
modem.connect()
modem.reset()
# 4. The 3-Second Wait & Blind Answer
logger.info('Modem ready. Waiting 3 seconds for operator dial-tone trigger...')
time.sleep(3)
logger.info('Opening the line (Forcing ATA Answer Mode)...')
try:
modem.send_command('ATA')
logger.info('Handshake successful! Handing connection over to Linux network bridge.')
except IOError:
logger.error('Failed to establish connection. Exiting.')
modem.disconnect()
return 1
# 5. Hand off to PPPD
modem.disconnect()
logger.info('--- NETWORK ACTIVE ---')
logger.info('Dreamcast is online. Waiting for game to end or timeout...')
pppd = subprocess.Popen(cmdline.split(' '), stderr=sys.stderr)
try:
pppd.wait()
except KeyboardInterrupt:
pppd.send_signal(9)
# 6. Clean Exit
logger.info('--- CONNECTION SEVERED ---')
logger.info('Hangup detected or pppd terminated. Script exiting cleanly.')
logger.info('NOTE: Please power-cycle your external Multi-Tech modem before running again.')
return 0
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(message)s')
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nScript manually aborted. Exiting.")
sys.exit(0)