オセロの棋譜データベースWTHORの読み込み方

# はじめに
本記事では、フランスのオセロ連盟が公開しているオセロの棋譜データベース[WTHOR](http://www.ffothello.org/informatique/la-base-wthor/)について、フォーマットと読み込み方を説明したいと思います。このデータベースは毎年2000試合程度の棋譜が集められているため、使いこなせればきっとオセロの棋力向上に役立つと思います。

最初に結論を書いておくと、今回WTHORをJavaScriptで読み込むことができるようになったので、CSVに変換するプログラムを以下に公開しておきました。ぜひご利用ください。

- [変換用サイト](https://lavox.github.io/wthor.html)
- [ソースコード](https://github.com/lavox/lavox.github.io/blob/master/wthor.html)

ファイルの細かい話はどうでも良いからCSVに変換だけできれば良いという方は、以下の文章は読み飛ばしていただいても構いません。（でもできれば読んでくださいね）

# WTHORの構成
本題に入りましょう。WTHORの構成については、フランスオセロ連盟に[フォーマットの公式解説](http://www.ffothello.org/wthor/Format_WThor.pdf)のPDFが置いてあります。また、[日本語の解説記事](http://hp.vector.co.jp/authors/VA015468/platina/algo/append_a.html)もありました。これらの記事と、実物のファイルの中身を見ながら読み解いてみました。

※公式ドキュメントは当然のようにフランス語で書かれており残念ながら私はフランス語がわからないため、翻訳サービスを使用しました

## 3種類のファイル
WTHORは3種類のファイルから成り立っています。
<dl>
  <dt>.jouファイル</dt>
  <dd>プレーヤー名を格納したデータベースファイルです。フランス語のjoueurから来ている拡張子のようで、プレーヤーの意味だそうです。今回調べて初めて知りました。</dd>
  <dt>.trnファイル</dt>
  <dd>大会名を格納したデータベースファイルです。これは英語のtournamentから類推できますが、フランス語ではtournoiで、大会という意味だそうです。</dd>
  <dt>.wtbファイル</dt>
  <dd>試合の棋譜を格納したデータベースファイルです。年ごとに置いてあります。拡張子の由来はよくわかりませんでした。</dd>
</dl>

公式説明を見ると、これ以外にも10×10の棋譜用のファイルとか、ソリティア用のファイルとかもあるようですが、今回は対象外とします(よくわかっていないですし)

## ファイル共通の情報
### ファイル形式について
WTHORを構成するファイルはバイナリデータなので、残念ながらテキストエディタで開いても人間には読めません。定型のフォーマットで数値や文字列が格納されたファイルとなっています。

数値は1バイト、2バイト、4バイトのケースがありますが、[リトルエンディアン](https://ja.wikipedia.org/wiki/エンディアン#リトルエンディアン)で格納されているので読み込むときは注意が必要です。

文字列は固定長になっており、null終端されています。なので格納できる文字の実際の長さは、定義上のサイズよりも1文字分短くなっています。エンコーディングは公式ドキュメントにも明記されていないように思われますが、アルファベット以外に長音記号等のついた文字も含まれているようなので、ひとまずiso-8859-2とみなしておけばうまく読み込めているようです(要確認)。

### ヘッダ情報
3種類のファイルとも、以下の計16バイトのヘッダ情報が共通でついています。

| 項目名                            | 型     | サイズ   |
|:---------------------------------|:-------|:--------|
| ファイルを作成した年の上2桁           | 数値   | 1バイト  |
| ファイルを作成した年の下2桁           | 数値   | 1バイト  |
| ファイルを作成した月                 | 数値   | 1バイト  |
| ファイルを作成した日                 | 数値   | 1バイト  |
| 試合数(.wtbのみ)                   | 数値   | 4バイト  |
| レコード数(.jou, .trnのみ)          | 数値   | 2バイト  |
| 試合の行われた年(.wtbのみ)           | 数値   | 2バイト  |
| 盤のサイズ(.wtbのみ)                | 数値   | 1バイト  |
| 試合タイプ(.wtbのみ)                | 数値   | 1バイト  |
| 深さ(.wtbのみ)                     | 数値   | 1バイト  |
| (予約)                            | 数値   | 1バイト  |

「盤のサイズ」は通常のオセロの棋譜データベースの場合は8が入っているようです。ドキュメントから見ると0が入る可能性もありそうですが、ひとまず8×8のオセロの棋譜のデータベースとして配布されているファイルを読み込む上ではあまり気にする必要はなさそうです。。

「試合タイプ」はオセロの場合は0固定のようなので、これも無視して良いでしょう。

「深さ」はこのあと出てくる.wtbファイルの項目「黒番の石数理論値」に関係しています。例えば「深さ」が24の場合、終盤24マス空きの状態から両者最善を打った場合の最終的な黒の石数が、.wtbファイルの「黒番の石数理論値」に入ります。「深さ」は何マス空きの状態から計算するかの定義になります。

## .jouファイルのフォーマット
上記で説明した16バイトのヘッダ情報の後、「レコード数」の分だけ以下の情報が繰り返し格納されています。

| 項目名                            | 型     | サイズ   |
|:---------------------------------|:-------|:--------|
| プレーヤー名                       | 文字列  | 20バイト |

## .trnファイルのフォーマット
.jouファイル同様、16バイトのヘッダ情報の後、「レコード数」の分だけ以下の情報が繰り返し格納されています。

| 項目名                            | 型     | サイズ   |
|:---------------------------------|:-------|:--------|
| 大会名                            | 文字列  | 26バイト |

## .wtbファイルのフォーマット
これが棋譜の本体の情報になります。.jouファイルや.trnファイルと同様に、先頭の16バイトはヘッダ情報になります。その後、「試合数」(レコード数ではないので注意)の分だけ以下の情報が繰り返し格納されています。

| 項目名                            | 型     | サイズ      |
|:---------------------------------|:-------|:-----------|
| 大会番号                           | 数値   | 2バイト     |
| 黒番プレーヤー番号                   | 数値   | 2バイト     |
| 白番プレーヤー番号                   | 数値   | 2バイト     |
| 黒番の石数                         | 数値   | 1バイト     |
| 黒番の石数理論値                    | 数値   | 1バイト     |
| 棋譜                              | 数値   | 1バイト×60  |

大会番号はその試合が行われた大会が.trnファイルの何番目(0始まり)に格納されている大会かを表しています。

黒番プレーヤー番号と白番プレーヤー番号は、それぞれが.jouファイルの何番目(0始まり)に格納されているプレーヤーかを表しています。

黒番の石数はその試合の試合結果を黒番側の石数で表したものです。0〜64の値が入ります。

黒番の石数理論値はヘッダのところでも説明しましたが、「深さ」分の空きマスから両者最善を打った場合の試合結果を黒番側の石数で表したものになります。つまり、「深さ」分の空きマス時点での形勢ということですね。

棋譜は1手目〜60手目までが1バイトずつの情報で格納されています。それぞれの手は10進数表記で一の位の1,2,...,8が列a,b,...,hを表し、十の位の1,2,...,8が行1,2,...,8を表します。つまり、a1 = 11, h1 = 18, a8 = 81, h8 = 88となっています。

空きマスが残って試合が終わった場合も、60手に満たない分は0で埋められて60バイト分になっているので、1試合分のデータは大会番号等も含めて合計68バイトで固定ということになります。

情報は以上なので、残念ながら試合がいつ行われたについては、.wtbファイルのヘッダにある年のみしか情報がなく、月日についてはわからないということになります。

# JavaScriptでWTHORを読み込む
WTHORの内容がわかったところで、読み込むプログラムを作ってみたいと思います。最初はPythonで書こうかと思ったのですが、せっかくなのでブラウザ上で実行できるようにと思って、JavaScriptで書いてみることにしました。ついでなので、勉強のためにVue.jsとVuetifyを使ってみることにしました。

以下の仕様のプログラムを作ることにします。

- ローカルPC上にある.jou, .trn, .wtbファイルを読み込む
- 読み込んだファイルをCSV形式に変換してローカルPC上にファイルとして保存する

この仕様を実現するためには、以下の技術要素が必要です。

- ローカルPC上のファイルにアクセスする方法(1)
- バイナリファイルを読む方法(2)
- ローカルPC上にファイルを保存する方法(3)

作ったプログラムの主要部分は以下の通りです。上記の技術要素部分(1)〜(3)に対応して番号でコメントを入れています。

```html
<!DOCTYPE html>
<html>
<head>
  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
  <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
  <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
</head>
<body>
  <div id="app">
    <v-app>
      <v-content>
        <v-container>
            <v-card class="mx-auto" max-width="344">
                <v-card-title>WTHORファイル変換</v-card-title>
                <v-card-text>
                    <v-file-input id="jou_file" label=".jou ファイル" accept=".jou"></v-file-input>
                    <v-file-input id="trn_file" label=".trn ファイル" accept=".trn"></v-file-input>
                    <v-file-input id="wtb_file" label=".wtb ファイル" accept=".wtb"></v-file-input>
                </v-card-text>
                <v-card-actions>
                    <v-btn color="primary" v-on:click="convert()">変換</v-btn>
                </v-card-actions>
            </v-card>
        </v-container>
    </v-content>
    </v-app>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script>
new Vue({
    el: '#app',
    vuetify: new Vuetify(),
    methods: {
        convert: function() {
            // バイナリ列をiso-8859-2で文字列にデコードするためのDecoder (2)
            const decoder = new TextDecoder('iso-8859-2');
            // プレーヤー名保持用配列
            let players = [];
            // 大会名保持用配列
            let tournaments = [];
            // 試合情報保持用配列
            let games = [];

            // .jouファイル読み込み用
            let jouFile = document.getElementById('jou_file').files[0]; // (1)
            let jouReader = new FileReader(); // (1)
            jouReader.onload = function(event) {
                let data = event.target.result; // (2)
                // ヘッダ読み込み
                let header = new WthorHeader(data);
                let idx = WthorHeader.HEADER_SIZE; // 16バイト
                // データ読み込み (2)
                for ( let i = 0 ; i < header.count ; i++ ) {
                    players.push(decoder.decode((new Uint8Array(data, idx, 20)).filter(elem => elem != 0)));
                    idx += 20;
                }

                // .trnファイル読み込み開始 (1)(2)
                trnReader.readAsArrayBuffer(trnFile);
            }

            // .trnファイル読み込み用
            let trnFile = document.getElementById('trn_file').files[0]; // (1)
            let trnReader = new FileReader(); // (1)
            trnReader.onload = function(event) {
                let data = event.target.result; // (2)
                // ヘッダ読み込み
                let header = new WthorHeader(data);
                let idx = WthorHeader.HEADER_SIZE; // 16バイト
                // データ読み込み (2)
                for ( let i = 0 ; i < header.count ; i++ ) {
                    tournaments.push(decoder.decode((new Uint8Array(data, idx, 26)).filter(elem => elem != 0)));
                    idx += 26;
                }

                // .wtbファイル読み込み開始 (1)(2)
                wtbReader.readAsArrayBuffer(wtbFile);
            }

            // .wtbフィル読み込み用
            let wtbFile = document.getElementById('wtb_file').files[0]; // (1)
            let wtbReader = new FileReader(); // (1)
            wtbReader.onload = function(event) {
                let data = event.target.result; // (2)
                let dv = new DataView(data); // (2)
                // ヘッダ読み込み
                let header = new WthorHeader(data);

                let idx = WthorHeader.HEADER_SIZE; // 16バイト
                // データ読み込み (2)
                for ( let i = 0 ; i < header.gameCount ; i++ ) {
                    // 大会ID
                    tournamentId = dv.getUint16(idx, true); idx += 2;
                    // 黒番プレーヤーID
                    blackId = dv.getUint16(idx, true); idx += 2;
                    // 白番プレーヤーID
                    whiteId = dv.getUint16(idx, true); idx += 2;
                    // 黒番の石数
                    blackScore = dv.getUint8(idx, true); idx += 1;
                    // 黒番の石数理論値(終盤depth手が最善だった場合)
                    theoreticalBlackScore = dv.getUint8(idx, true); idx += 1;
                    transcript = ''
                    // 60手分の棋譜を読み込み
                    for ( let j = 0 ; j < 60 ; j++ ) {
                        move = dv.getUint8(idx, true); idx += 1;
                        if ( move >= 11 && move <= 88 ) {
                            // 一の位 'a' = 97
                            transcript += String.fromCharCode((move % 10) + 96)
                            // 十の位 '1' = 49
                            transcript += String.fromCharCode(Math.floor(move / 10) + 48)
                        }
                    }
                    games.push({
                        tournamentId: tournamentId,
                        tournamentName: tournamentId < tournaments.length ? tournaments[tournamentId] : '?',
                        blackPlayerId: blackId,
                        blackPlayerName: blackId < players.length ? players[blackId] : '?',
                        whitePlayerId: whiteId,
                        whitePlayerName: whiteId < players.length ? players[whiteId] : '?',
                        blackScore: blackScore,
                        theoreticalBlackScore: theoreticalBlackScore,
                        transcript: transcript
                    });
                }
                outputCsv(games);
            }

            // 結果出力用 (3)
            let outputCsv = function(games) {
                // CSVのヘッダ行
                let csv = 'tournamentId,tournamentName,blackPlayerId,blackPlayerName,whitePlayerId,whitePlayerName,blackScore,blackTheoreticalScore,transcript\n';
                // 各行の生成
                games.forEach( elem => {
                    csv += elem.tournamentId + ',';
                    csv += elem.tournamentName + ',';
                    csv += elem.blackPlayerId + ',';
                    csv += elem.blackPlayerName + ',';
                    csv += elem.whitePlayerId + ',';
                    csv += elem.whitePlayerName + ',';
                    csv += elem.blackScore + ',';
                    csv += elem.theoreticalBlackScore + ',';
                    csv += elem.transcript + '\n';
                })
                // ファイルの出力
                let blob = new Blob(['\ufeff', csv], { "type" : "text/csv" });
                let link = document.createElement('a');
                link.href = window.URL.createObjectURL(blob);
                link.download = 'wthor.csv';
                link.click();
            }

            // .jouファイル読み込み開始 (1)(2)
            jouReader.readAsArrayBuffer(jouFile);
        },
    }
})

// ヘッダ情報読み込み用 (2)
class WthorHeader {
static get HEADER_SIZE() { return 16; }
    constructor ( data ) {
        let dv = new DataView(data); 
        let idx = 0;
        this.createCentry = dv.getUint8(idx, true); idx += 1;
        this.createYear = dv.getUint8(idx, true); idx += 1;
        this.createMonth = dv.getUint8(idx, true); idx += 1;
        this.createDay = dv.getUint8(idx, true); idx += 1;
        this.gameCount = dv.getUint32(idx, true); idx += 4;
        this.count = dv.getUint16(idx, true); idx += 2;
        this.year = dv.getUint16(idx, true); idx += 2;
        this.boardSize = dv.getUint8(idx, true); idx += 1;
        this.type = dv.getUint8(idx, true); idx += 1;
        this.depth = dv.getUint8(idx, true); idx += 1;
    }
}

</script>
</body>
</html>
```

少しだけ補足しておきます。

数値データを読み込む際にサイズに応じて``getUint8``,``getUint16``,``getUint32``を使用しています。この引数の1つ目は読み込み開始位置、2つ目の``true``はリトルエンディアンであることを表しています。

バイト列をiso-8859-2で文字列として読み込む際に、null終端を除去するために``filter``を使用しています。本当は最初にnullが出てきた以降は全て取り除くべきなのでしょうが、実用上問題なさそうなので、雑な実装でnullを全て取り除く形にしています。

CSVファイルを保存する際に出てきている``\ufeff``というのはバイトオーダーマークです。

## 棋譜Box形式への対応
せっかくなので、筆者が開発しているiOSの棋譜管理アプリ「棋譜Box」で一括インポートできるCSVファイルの形式への変換機能も実装してみました。実装したファイル全体は以下のURLに置いてあります。

- [変換用サイト](https://lavox.github.io/wthor.html)
- [ソースコード](https://github.com/lavox/lavox.github.io/blob/master/wthor.html)

棋譜Box形式のCSVファイルについて注意事項です。

- 試合日について、前半で説明したように月日の情報は不明であるため、.wtbファイルのヘッダに記録されている年の1月1日に行われたことにしています
- プレーヤーの段級位は不明なので、未定義としています
- 試合番号は、同一の大会で出現順に1から連番を振っています
- 試合結果は全て'A'としています(棋譜Boxに取り込む際に自動計算されます)

# 終わりに
これでWTHORファイルをJavaScriptで扱えることができるようになったので、この[ソースコード](https://github.com/lavox/lavox.github.io/blob/master/wthor.html)をベースに書き換えることでいろいろな形に変換することができるようになりました。

棋譜をうまく使って、皆様のオセロの棋力向上に役立てていただけると幸いです。

# 参考サイト
- [JavaScriptでファイル処理! JSONやCSVなどのファイルを読み込もう](https://www.sejuku.net/blog/32532)
- [JavaScript FileReaderを使って、ファイルをバイナリで読み込む](https://qiita.com/PianoScoreJP/items/2732b03dc19e1d682d9b)
- [Typed Arrays: ブラウザでバイナリデータを扱う](https://www.html5rocks.com/ja/tutorials/webgl/typed_arrays/)
- [JavaScript、Node.js で文字列とバイト列の相互変換](https://qiita.com/masakielastic/items/8eb4bf4efc2310ee7baf)
- [BOMやBlobを理解してJavaScriptでCSVを出力する](https://qiita.com/megadreams14/items/b4521308d5be65f0c544)