せっかなくので、やってみた。

日々のあんなこと、こんなこと、せっかくなのでやってみた

X68のレースゲーム OverTakeでネットワーク対戦してみた。(前編)

この記事は令和の時代もなお続くX68000用ディスクマガジン「ハリキリマガジン」の 38号(2019年7月)に収録していただいた内容に修正を加えたものです。文章が長くなったので、前編・後編に分割しています。

はじめに

OverTakeはZoomから1992年に発売された、F1レースゲームです。
当時、Zoomから発売されるレースゲーム、しかもセガのスーパーモナコGPナムコファイナルラップの様な 疑似3D表示や、チーム、ドライバーが実名で登場するなど気合の入った内容で、雑誌に掲載されていた 開発中の画面からも期待度がかなり高く、発売と共にすぐに買った記憶があります。
実際のゲームの方の感想は今回の題材から外れるので言及しませんが、サウンドに関しては一聴の価値ありなソフトです。 (一応エンディングまでプレイした気もしますが、覚えておりません...)

さて、このOverTakeにはシリアル(RS232Cのクロス)ケーブルでx68同士を接続することにより、 対戦プレイができるモードがあります。
ただ、対戦をするには実機だけでなく、モニターも2台必要となり、ハードウェア環境のハードルが高くて ほとんど試したことがありませんでした。

現在はXM6系のRS232Cに対応したエミュレータもあり、気軽に2台のx68でOverTakeを動かす環境が手に入りました。
しかし実機やエミュレータRS232Cで接続して対戦させるだけではつまらないので、昨今x68関連でも 賑わいのあるRaspberryPiを使用してネットワーク対戦ができるんじゃないか?という事で、実際に試してみました。

f:id:moneci:20191116181024p:plain
ひとりで対戦している様子

どうやってネットワーク対戦させるか

RS232Cで接続された機器同士が通信する際、機器独自に決められた手順でデータの 送信/受信を繰り返すので、RS232Cに送信されたデータをプログラムで受け取り、そのままTCP/IPに流して、 対向のTCP/IPで受け取ったデータを今度はRS232Cに送る事ができれば実現できるのではと安易に考ました。

機器同士はボーレートなどの通信設定を合わせないと通信できないというのは理解していましたが、 この時点では深く考えずにとりあえず x68とRasPiをRS232Cで接続し、RasPiの中でシリアル-TCP/IPを 変換するソフトを動かすという事で進めてみました。
RasPiのGPIOは分からないので、x68とRasPiとの接続はお手軽なUSBシリアルコンバータを使用することにします。

シリアル-TCP/IPを変換するソフトの部分は、実はハードウェアでこれを実現するシリアル-LAN変換アダプタなるものが販売されているのですが、 業務用で数万円もする高価なものなので、今回の為にわざわざ購入するという判断はありませんでした。
TCP/IPRS232Cの通信はJavaで少しやった事があったので、この部分をプログラムでという目論見ですが、 高価なハードでやっている事をソフトで本当にできるのか?という疑問もあります。USBシリアルコンバータも本物のシリアルと挙動の違いがあるかもしれません。
RasPiが1台あるし、まあお試しでプログラムを組むのはタダなので、まずは検証してみることにしました。

とりあえずつなげてみる

ネットに落ちているプログラムを参考にサーバとクライアントのプログラムを作成しました。 名前は安易にSerial2Tcpです。
COMポートをオープンしたあと、TCPソケットをオープンし、対向からのデータを待ちます。
COMポートから受信したデータをTCPソケットに、TCPから受信したデータをCOMポートに送信するという単純なものです。 通信設定はとりあえず、9600bps、データビット8、ストップビット1、パリティなし、フロー制御なしに設定しました。

いざ確認ですが、RasPiは1台しか持ってないのと、この時点ではUSBシリアルコンバータも買っていなかったのでx68含め全て Windows10上のエミュレータで試してみました。
x68のエミュレータにはRS232Cに対応しているXM6の改造版であるXM6TypeGを使用しますが、 TypeGは同時に2つ起動する事はできないので、対向のx68には同じくXM6の改造版であるXM6iを使用しました。
また、TyepGとXM6iを接続させるCOMポートにはNull-modem emulatorを使用しました。
これはWindows上で仮想のCOMポートのペアを作成し、1台のWindowsPCの中だけでシリアル通信を確認できる便利なものです。

Null-modem emulator - Browse /com0com/3.0.0.0 at SourceForge.net

からcom0com-3.0.0.0-i386-and-x64-signed.zipをダウンロードし、COM4、COM8のペアを作成しました。 後で見つけた記事ですが、以下のサイトに手順が分かりやすく解説されています。 qiita.com

ご注意 上記の記事でも説明が追記されていますが、Windows10でcom0comは利用できなくなっています。代替の手段はこちら。→ *1

まずは、Serial2Tcpなしの状態で接続を確認します。
TypeGを起動し、ツール>オプション>ポート を開き、 ポートでCOM4を選択し、「ボーレートを38400bpsに固定する」はよく分からないのでチェックなしにしてから、 Human68Kのシステムディスクを入れてリセットします。XM6iも同様の手順でポートにCOM8を設定します。

これで2台のx68がクロスケーブルで接続された状態になります。
それぞれSWITCH.Xを起動し、Serial2TcpのCOMポート設定と同じ 「9600ボー、8ビット、パリティなし、ストップ1、None」を設定しておきます。

x68のどちらか(受信側)で

A> type aux

を実行し、対向のx68(送信側)から

A> echo Hello > aux

を実行すると、受信側のtype auxの次の行に

Hello

と出力されました。
今度は受信側と送信側の手順を逆にして試します。
(ちなみに、type auxを終了する事が出来ないので、いつもCOPYキーを押してプリンタ割込みで終了させています。)

これでエミュレータ上でのシリアル通信が確認できました。
次はいよいよシリアル-TCP/IP変換を試します。
新たにNull-modem emulatorでCOM5-COM9のペアを作成しておき、接続の構成は以下の様にしました。

TypeG(COM4) - Null-modem(COM8) - Serial2TcpServer - Serial2TcpClient - Null-modem(COM9) - XM6i(COM5)

TypeGとXM6iのCOMポートの割り当ても、TypeG=COM4、XM6i=COM5に変更しておきます。

まず、

Download - Rxtx

からrxtx-2.2pre2-bins.zipをダウンロードし、RXTXComm.jarと環境にあったrxtxSerial.dllを 適当な場所(プロジェクトフォルダ/lib など)にコピーし、コピー先のディレクトリを環境変数%PATH%に追加します。

次にコマンドプロンプトを2つ起動し、以下のコマンドでサーバを起動します。
パラメータは ポート番号、シリアルポート名、ボーレートです。

java -cp ".\bin;.\lib\RXTXcomm.jar" serial2tcp.Serial2TcpServer 9999 COM8 9600

もう一つのコマンドプロンプトで同様にrxtxSerial.dllへのパスを通した後、以下でクライアントを起動します。
パラメータは サーバ名(IP)、ポート番号、シリアルポート名、ボーレートです。

java -cp ".\bin;.\lib\RXTXcomm.jar" serial2tcp.Serial2TcpClient localhost 9999 COM9 9600

無事接続できれば、サーバ側に"accept"が出力されます。
これで準備完了です。先ほどと同様にx68のどちらか(受信側)で

A> type aux

を実行し、対向のx68(送信側)から

A> echo Hello > aux

を実行すると...
動きました!ばっちりです。Serial2Tcpのログ出力にも、送受信した文字コードが表示されています。
数万円もするハードでやっていることが簡単なプログラムで実現できました。

ということで長くなったのでとりあえずここまで。
Serial2Tcpのソースを張り付けて、続きは次の記事へ。

yatte-mita.hateblo.jp

BaseSerial.java

package serial2tcp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import gnu.io.CommPort;
import gnu.io.CommPortIdentifier;
import gnu.io.SerialPort;
import gnu.io.SerialPortEvent;
import gnu.io.SerialPortEventListener;

/**
 * シリアル通信を処理する為の基底クラスです
 */
public class BaseSerial {

    /** シリアルポートオブジェクト */
    private SerialPort serialPort;

    /** TCPからの入力データのストリーム */
    protected InputStream tcpIn;

    /** TCPへの出力データのストリーム */
    protected OutputStream tcpOut;

    /** シリアルポートからの入力データのストリーム */
    private InputStream serialIn;

    /** シリアルポートへの出力データのストリーム */
    private OutputStream serialOut;

    /** データビット */
    private final static int SERIAL_DATABIT = SerialPort.DATABITS_8;

    /** ストップビット */
    private final static int SERIAL_STOPBIT = SerialPort.STOPBITS_1;

    /** パリティ */
    private final static int SERIAL_PARITY = SerialPort.PARITY_NONE;

    /** フロー制御 */
    //private final static int SERIAL_FLOW_CONTROL = SerialPort.FLOWCONTROL_XONXOFF_IN | SerialPort.FLOWCONTROL_XONXOFF_OUT; // ソフトフロー制御
    //private final static int SERIAL_FLOW_CONTROL = SerialPort.FLOWCONTROL_RTSCTS_IN | SerialPort.FLOWCONTROL_RTSCTS_OUT; // ハードフロー制御
    private final static int SERIAL_FLOW_CONTROL = SerialPort.FLOWCONTROL_NONE; // フロー制御なし

    /** DTR有効か */
    private final static boolean SERIAL_ENABLE_DTR = false;

    /** RTS有効か */
    private final static boolean SERIAL_ENABLE_RTS = false;

    /**
     * シリアルポートに接続します。
     * @param portName
     * @param speed
     * @throws Exception
     */
    protected void connect(String portName, int speed) throws Exception {
        CommPortIdentifier portIdentifier = CommPortIdentifier.getPortIdentifier(portName);
        if (portIdentifier.isCurrentlyOwned()) {
            System.out.println("Error: Port is currently in use");
        } else {
            CommPort commPort = portIdentifier.open(this.getClass().getName(), 2000);
            if (commPort instanceof SerialPort) {
                serialPort = (SerialPort) commPort;
                serialPort.setSerialPortParams(speed, SERIAL_DATABIT, SERIAL_STOPBIT, SERIAL_PARITY);
                serialPort.setFlowControlMode(SERIAL_FLOW_CONTROL);
                serialIn = serialPort.getInputStream();
                serialOut = serialPort.getOutputStream();
                // Buffered系では正しくデータ通信できなかった
                //serialIn = new BufferedInputStream(serialPort.getInputStream());
                //serialOut = new BufferedOutputStream(serialPort.getOutputStream());
                System.out.println("connected to " + portName + " speed: " + speed);

                // DTR/RTS設定
                serialPort.setDTR(SERIAL_ENABLE_DTR);
                serialPort.setRTS(SERIAL_ENABLE_RTS);
            } else {
                System.out.println("Error: Only serial ports are handled by this example.");
            }
        }
    }

    /**
     * シリアルポートのlistenを開始
     * @throws Exception
     */
    protected void runConnection() throws Exception {
        // シリアルポート書込み用のスレッド開始
        (new Thread(new SerialWriter())).start();
        serialPort.addEventListener(new SerialReader());
        serialPort.notifyOnDataAvailable(true);
    }

    /**
     * シリアルポートからデータを受信した時に起動するリスナーです。
     */
    private class SerialReader implements SerialPortEventListener {
        @Override
        public void serialEvent(SerialPortEvent event) {
            // イベントの種類で処理を分ける(実際に動かしたところ、DATA_AVAILABLE以外のイベントは発生しなかった。)
            switch (event.getEventType()) {
            case SerialPortEvent.BI:
            case SerialPortEvent.OE:
            case SerialPortEvent.FE:
            case SerialPortEvent.PE:
            case SerialPortEvent.CD:
            case SerialPortEvent.CTS:
            case SerialPortEvent.DSR:
            case SerialPortEvent.RI:
            case SerialPortEvent.OUTPUT_BUFFER_EMPTY:
                // Data Available 以外のイベントは処理しない
                System.out.print("not Data Available event " + event.getEventType() + "\n");
                break;
            case SerialPortEvent.DATA_AVAILABLE:
                // Data Available の処理
                int data = 0;
                try {
                    while ((data = serialIn.read()) > -1) {
                        // Buffered系では正しくデータ通信できなかった
                        //String byteData = ByteBuffer.wrap(ByteBuffer.allocate(4).putInt(data).array()).order(ByteOrder.BIG_ENDIAN).toString();
                        //String byteData = ByteBuffer.allocate(4).putInt(data).toString();
                        //System.out.print("data received from serial in: " + byteData + "\n");
                        System.out.print("data received from serial in: " + data + "\n");
                        tcpOut.write(data);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                    closeAndExit();
                }
                break;
            }
        }
    }

    /**
     * シリアルポートにデータを送信するスレッドです。
     */
    private class SerialWriter implements Runnable {
        public void run() {
            int data = 0;
            try {
                while ((data = tcpIn.read()) > -1) {
                    System.out.print("received from tcp in: " + data + "\n");
                    serialOut.write(data);
                }
            } catch (IOException e) {
                e.printStackTrace();
                closeAndExit();
            }
        }
    }

    /**
     * 各リソースを閉じて終了します。
     */
    private void closeAndExit() {
        try {
            if (tcpIn != null) tcpIn.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            if (tcpOut != null) tcpOut.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            if (serialIn != null) serialIn.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            if (serialOut != null) serialOut.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (serialPort != null) serialPort.close();

        System.exit(-1);
    }
}

Serial2TcpServer.java

package serial2tcp;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * シリアル-TCP変換のサーバです。
 */
public class Serial2TcpServer extends BaseSerial {

    /** サーバソケット */
    private ServerSocket server;

    /** クライアントソケット */
    private Socket socket;

    /**
     * メインルーチン
     * @param args ソケットのポート番号, COMポート名, シリアルの通信速度
     */
    private void start(String[] args) {
        try {
            // パラメータチェックは省略
            int tcpPort = Integer.parseInt(args[0]);
            String comPort = args[1];
            int speed = Integer.parseInt(args[2]);

            connect(comPort, speed);
            initSocket(tcpPort);
            runConnection();
            while (true) {
                // 空ループ
                Thread.sleep(1000);
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(-1);
        } finally {
            try {
                if (socket != null) socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (server != null) server.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * サーバソケットを作成し、クライアントからの接続を待ちます。
     * @param port
     * @throws IOException
     */
    private void initSocket(int port) throws IOException {
        System.out.println("wating for client connection...");
        server = new ServerSocket(port);
        // クライアントからの通信開始要求が来るまで待機
        socket = server.accept();
        tcpIn = socket.getInputStream();
        tcpOut = socket.getOutputStream();
        System.out.println("accept");
    }

    public static void main(String[] args) {
        Serial2TcpServer server = new Serial2TcpServer();
        server.start(args);
    }
}

Serial2TcpClient.java

package serial2tcp;

import java.io.IOException;
import java.net.Socket;

/**
 * シリアル-TCP変換のクライアントです。
 */
public class Serial2TcpClient extends BaseSerial {

    /** クライアントソケット */
    private Socket socket = null;

    /**
     * メインルーチン
     * @param args サーバ名, ソケットのポート番号, COMポート名, シリアルの通信速度
     */
    private void start(String[] args) {
        try {
            // パラメータチェックは省略
            String server = args[0];
            int tcpPort = Integer.parseInt(args[1]);
            String comPort = args[2];
            int speed = Integer.parseInt(args[3]);

            connect(comPort, speed);
            initSocket(server, tcpPort);
            runConnection();
            while (true) {
                // 空ループ
                Thread.sleep(1000);
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(-1);
        } finally {
            try {
                if (socket != null) socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * socketを作成し、サーバに接続します。
     * @param server
     * @param port
     * @throws Exception
     */
    private void initSocket(String server, int port) throws Exception {
        System.out.println("connect to server " + server + "...");
        socket = new Socket(server, port);
        tcpIn = socket.getInputStream();
        tcpOut = socket.getOutputStream();
    }

    public static void main(String[] args) {
        Serial2TcpClient client = new Serial2TcpClient();
        client.start(args);
    }

}

*1:あらかじめWindows8にcom0comをインストールしていて、Windows10にアップデート した場合は、引き続き使用できた様ですが、新規にWindows10をインストールしたPCでは、 ポートは作成できるものの、デバイスマネージャーで com0com のポートを確認すると、
「このデバイスに必要なドライバーのデジタル署名を検証できません。...」
の表示となり、ポートが認識されない状態となってしまいました。

代替の仮想シリアルポートを探したところ、以下のFree Virtual Serial Portsというソフトが利用できました

FREE Virtual Serial Ports driver, Rs-232 null modem emulator

有償版とフリー版があり、フリー版は

  • 作成した仮想シリアルポートを保存できない(起動時毎回作成)
  • 作成できるシリアルポートのペアは1組だけ

の制約があるため、XM6TypeGとXM6iを同時に利用することが できなくなってしまいましたが、実機とXM6TypeGの組み合わせでは問題なく動作しました。 (フリー版のバージョン4.02.00.0482を使用)

一応イギリスの企業が作っている様なので怪しい類ではないかと思いますが、 利用される方は自己責任でお願いします。