@ NONODREAMORPI

SEGA DREAMCAST LOCAL DIAL-UP ARCHITECTURE
TARGET: Local Docker (ch0ww/q3dc)
SPEED: 28.8 KBPS Stable
SERIAL: Bare-Metal DB9

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.

DIAGNOSTIC SCHEMATIC: LVI & DIAL-TONE INJECTOR 12V DC POWER DFPLAYER MINI MP3 DIAL BTN PC MODEM (Multi-Tech) DREAMCAST MODEM 330Ω RESISTOR (CURRENT LIMITER) DC BLOCKING CAPACITOR (0.1µF - 1µF) COUPLING CAPACITOR (0.47µF FILM) COMMON GROUND (TIP/RETURN)

How the Pieces Work Together

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:

  1. Turn on your external PC modem.
  2. Run the script. It will open the COM port and say "Waiting 3 seconds...".
  3. Press your physical DFPlayer button to start the dial tone, and immediately tell Quake 3 to connect.
  4. The script instantly forces the PC modem to answer the call, hands the connection to Linux, and steps out of the way.
  5. 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)