自作3進CPU

自作三進CPUで限界オセロプログラムを作った話

投稿日:

2026 年 5 月に開催された第 7 回自作 CPU を語る会にて、自作 CPU オセロ大会が行われたため、私も自作の三進 CPU である Libra the Processor で参戦しました。ただし、実機ではハードウェア不良により動かなかったため、シミュレータでの参戦となりました。

これまでに Libra the Processor 用に作ったプログラムは、複雑なものでもトリボナッチ数列計算プログラムぐらいだったので、オセロプログラムほどの複雑なプログラムの作成は初となりました。

特に苦労したのはプログラムサイズの制限で、 Libra the Processor は命令メモリが 5 trit = 243 words しかありません。そのため、極限まで機能を削ってとりあえず合法手を返すだけに専念し、さらに 1 word でも削るためのアドホックな最適化を重ねることでなんとかサイズ制限に収めることができました。( 241 words )

対戦結果は以下の通りで、全敗でしたが最後までエラーなく指しきれただけで十分健闘したと言えると思います。棋譜は https://do2.rojo.jp/shogi/javascript/mania.html のサイトで再生することができます。

  • 先手 Libra the Processor vs. 後手 TD4EX4-2026 (Exit さん)
    23 - 41 で TD4EX4-2026 の勝利
    棋譜:D3C3B3B2B1E3D2C5F3G3H3A1B4D1C1A3E1F1A5F4F5H2H1F2C2G6D6E7E2E6C4G4G5G2A2G1A4A6B5B6H4H5C6C7F6F7H6A7B7D7D8G7G8H7F8E8C8A8B8H8
  • 先手 NLP-16A (ちぇりーたくあん さん) vs. 後手 Libra the Processor
    46 - 18 で NLP-16A の勝利
    棋譜:
    D3C3C4E3F3G3F6B5D2C1A6C5H3E2F1F2D1E1B1B4A5B2A1C2F4A2D6A4G2H2H1G4F5G1B3A3H4H5G5H6B6C6E6E7G6F7A7A8B7D7C7C8G7H8B8D8H7E8F8G8

完成したプログラム

プログラムの仕様は以下のようになっています。

  • 盤面を左上から順番に走査していき、最初に見つけた合法手を返すだけの最低限の AI 。
  • 入出力は MMIO を介して ASCII コードで行う。
  • 最初に "B" または "W" を入力して、 "B" なら先手、 "W" なら後手として動作する。
  • 座標は列を A ~ H 、行を 1 ~ 8 で表し、 "A1" 、 "H4" のように表現する。パスの場合は "P" で表現する。
  • 相手の指した手が合法かどうかのチェックは行わずに信用する。
  • 2 連続でパスが発生すると動作を終了する。勝敗判定は行わない

大会で使用した実際のプログラム全体を掲載します。

命令セットについてはこちらの記事を参照してください。

工夫点

ここからはこのプログラムの工夫点をトピックごとに解説していきます。

番兵を使った盤面管理

オセロは 8 x 8 の盤面で、 1 マスあたり白、黒、空きの 3 つの状態を持ちます。 3 状態を 3 進 1 桁で表せることを上手く使えないかと思いましたが、 trit 操作はプログラムサイズが増えがちなので今回は見送りました。

普通に 1 マスを 1 word で表現する場合、下図左のように連続する 64 word のメモリ領域を使うのが素朴な発想ですが、これだと境界での場合分けが必要となります。例えば、あるマスの 1 つ左のマスにアクセスしたいとき、基本はアドレスを -1 すればよいですが、 A 列の場合はそれだと H 列のマスを参照してしまいます。

このような場合分けを避けるために、図中央のようにダミーのデータを置いておく手法があり、これらのダミーは番兵と呼ばれます。オセロの場合は番兵は空きマスとして扱えば石を返せるかどうかの判定に場合分けが不要になります。

これでもいいのですが、もう一工夫加えると図右のように左右の番兵は共通化できることに気が付きます。例えば、 A2 の左と H1 の右はどちらもアドレス 18 となりますが、番兵に変わりありません。

これでメモリ量が少し減りますが、データメモリサイズには困っていません。嬉しい点はそこではなく、 1 行あたりの要素数が 9 となり、(番兵を含めて)行を 0 ~ 10 、列を 0 ~ 8 としたときにアドレスが address = row * 9 + col と表せる点です。三進数において 9 倍は左シフト 2 回で実行できるため、三進 CPU と非常に相性が良いです。

左:8x8のオセロの盤面を0~63のアドレスで管理する図。
中央:上下左右に番兵を加えて10x10で管理する図。
右:左右の番兵を共通化し、10行x9列+1マスで管理する図。

入力された手をアドレスとして解釈する処理は以下のようになっています。入力された ASCII を row と col に変換した後、シフトと加算でアドレスに変換しています。

番兵列かどうかのチェックはアドレスが 9 の倍数かどうかをチェックすればよく、これは下 2 桁が 00 かどうかで判定できます。以下にその処理を行っているコードを示します。

AND は桁ごとに最小値演算を行うため、 # (-1) と AND を取ることでその桁は # に、 1 と AND を取ることでその桁はそのままとなります。マスクをかけた後は 0 ではなく、 0t###00 と比較することに注意が必要です。

手を出力する際には、アドレスから行と列に分解するわけですが、行と列が上位3桁と下位2桁に分かれているわけではないことに注意が必要です。(一敗)

はじめの数行のアドレスを平衡三進数で表すと下図のようになります。平衡三進数では下 2 桁は 0 ~ 8 ではなく -4 ~ 4 を表すため、 3 桁目への繰り上がりが行と一致していません。

盤面の先頭3行のアドレスを平衡三進数で表した図

これを解決するにはアドレスを -4 ずつずらします。これによって下図のようになり、行と列の値を上位 3 桁と下位 2 桁に分離できます。

盤面の先頭3行のアドレスを-4ずつずらして平衡三進数で表した図

実際のコードは以下のようになっています。まず右シフトによって行を取り出し、その後左シフトで位置を戻してから元の値から引くことで列を取り出します。ここでは address - row * 9 = col を行っているので、得られた列は -4 ~ 4 ではなく 0 ~ 8 であることに注意してください。

今になって思うと、列を 0 ~ 8 ではなく始めから -4 ~ 4 で扱っておけばこの処理がシンプルになりましたね。その場合番兵列の判定は下 2 桁が ## であることになりますが、どうせ定数で比較していたのでコストは変わりません。強いて欠点を言えば配列の開始アドレスが 0 でなくて -4 になって少し気持ち悪いぐらいですね。(アドレス空間は -121 ~ 121 です)

合法手判定・盤面更新

オセロ AI を普通に作ろうと思うと、まず合法手をリストアップして、それぞれの手を何らかの方法で評価して、最も評価値の高いものを選択するという流れになるかと思います。しかしこの限界オセロプログラムはとにかく何かしらの合法手を返すことに専念するので、流れが変わってきます。

盤面を走査して最初に見つけた合法手を選択するのですが、合法手かどうか判断する処理と、そのマスに石を置いて盤面を更新する処理はほとんど同じ処理です。そこで、判断の前にとりあえず石を置いて更新処理を行ってしまい、 OK であればそのままにして、 NG であればもとに戻すという方針で実装しました。

以下に示すのはこの処理の核となる部分で、指定のマスから、指定の方向に、石をひっくり返していく処理です。なおそのマスが空いていることは事前にチェック済みとします。

石をひっくり返せる条件をおさらいすると、①隣接マスが相手の色であり、②(隣接マスを含めて) 1 個以上の相手の石が続き、③その後自分の色があること、となっています。また合法手であることの条件は少なくとも 1 方向で石をひっくり返せることです。

APPLY_DIR_FIRST は①の条件を判定していて、満たしていた場合 APPLY_FLIP に進み、自分の色を指定して FLIP が呼ばれます。FLIP は(反対の色を)指定された色にひっくり返していく処理を反復して、指定した色もしくは空きマスにぶつかってひっくり返せなくなったらその位置と色を返す関数になっています。

FLIP をやってみた結果、止まったマスが自分の色であればその操作は正しかったことになります。この場合、合法手であることを記録した上で、 APPLY_NEXT に進んで次の方向の処理に進みます。(後述)

一方止まったマスが空きマスだった場合(番兵に当たった場合も含む)、この操作は間違っていたことになるので元に戻さなければなりません。この処理を以下に示します。

注目すべきは、ここでも先ほどと同じ FLIP が呼ばれていることです。ただし先ほどとは開始マス、方向、色を変えています。

下の図を見てもらえると分かると思いますが、石を置こうとしているマスと止まったマスはどちらも空きマスで、その間に元々相手の石があったのを自分の色に上書きした状態になっています。したがって、止まったマスの 1 つ手前から逆向きに同じ処理を行うことで元に戻すことができるのです。

FLIPの処理前後の盤面の様子を例示した図。

今書いてて気付きましたが、別に逆方向からやる必要はないですね。最初の FLIP と同じマス、方向で色だけ反転させれば命令が数個減ることでしょう。

いずれにせよ、同じ関数を使いまわせることで命令サイズの削減に大きく寄与しました。

方向列挙

今紹介した指定の方向にひっくり返す処理を 8 方向に対して繰り返すのですが、これを列挙するにも工夫があります。まず 8 つの方向は下図のようにアドレスの差として表現できます。

3x3のマス目に左上から順に-10, -9, -8, -1, 0, 1, 8, 9, 10と数字が並んでいる

高級言語であれば

for (int row = -1; row <= 1; row++) {
  for (int col = -1; col <= 1; col++) {
    if (row == 0 && col == 0) continue;
    int dir = row * 3 + col;
  }
}

とか書くのが基本かなと思うんですが、変数の読み書きやインクリメントは馬鹿にならないコストです。

次に思いつく方法としては、

int dirs[] = {-10, -9, -8, -1, 1, 8, 9, 10};
for (int i = 0; i < 8; i++) {
  int dir = dirs[i];
}

のような方法ですが、最初に方向リストを作成するのに即値命令をたくさん並べる必要があり、結局重たいです。

最終的に出来上がったのは次のコードです。

現在値が 10 だったら終了、 -8 か 1 だったら +7 、それ以外は +1 という計算です。ミソとなるのは -1 から 1 に飛ばずに 0 もそのまま計算している点です。

方向が 0 で APPLY_DIR_FIRST に進んだ場合、隣接マスが自分自身 = 空きマスと判定されるので、ひっくり返せないと判定されるだけです。無駄な計算にはなりますが、場合分けを減らすことでプログラムサイズを削減できます。

その他細かいポイントとしては、 APPLY_NEXT_ADD7APPLY_NEXT_ADD1 の合流のコストを下げるために APPLY_NEXT_ADD7 では +6 をして APPLY_NEXT_ADD1 にフォールスルーしています。…が以下のようにしても命令長は変わらなかったことに今気が付きました。

    CMPI    -8
    JZ      APPLY_NEXT_ADD7
    CMPI    1
    JZ      APPLY_NEXT_ADD7
    ADDI    C, 1
    J       APPLY_NEXT_JOIN
APPLY_NEXT_ADD7:
    ADDI    C, 7
APPLY_NEXT_JOIN:
    POP     A
    J       APPLY_DIR_FIRST

-8 と 1 はどちらも下 2 桁が 01 となることで判定できるので、以下のようにすれば少し命令が削減できると思われるかもしれませんが、これだと A レジスタの値を破壊してしまうため、得にはならないかと思います。

    ANDI    A, 0t###11
    CMPI    0t###01
    JZ      APPLY_NEXT_ADD7

激アドホック最適化

プログラム開発終盤、本当に 1 命令でも削りたい状況だったため、激アドホックな命令削減を行いました。

例えば初期設定で'B'と入力されたら A = -1 、'W' と入力されたら A = 1 としたい場面で、普通に書くと次のようになるかと思います。

    LDI     A, 121  // A = MMIO in
    LDI     B, 121  // 改行読み飛ばし
    CMPI    -34     // 'W' と比較
    JZ      INIT_W
    MOVI    A, -1   // 'B' の場合
    J       INIT_JOIN
INIT_W:
    MOVI    A, 1
INIT_JOIN:

'W' = -34 かどうかを判定するために、 -34 を引いて 0 になるかを見ています。一方、実際のコードでは次のようになっています。

キモとなるのは 35 を足している点です。これによって、'W' = -34 の場合は A = 1 > 0 となり、'B' = -55 の場合は A = -20 < 0 となります。すなわち、'W'かどうかの判定と一緒に A = 1 を生成することができます。

例外処理を一切考えない限界プログラムだからこそ為せる技で、即値命令 2 つ = 4 words の削減となります。

相手の入力がパスの'P'であるかの判定でも同じことをしています。

まとめ

243 words という非常に限られた命令サイズでオセロを実現するために、合法手判定のアルゴリズムを工夫したり、 1 命令でも削減するための局所最適化をたくさん行いました。

個別に紹介はしませんでしたが、即値命令やジャンプ命令は命令長が 2 で重いので、共通即値をレジスタ化したり、フォールスルーによるジャンプ命令削減は当然試しています。

プログラムを書く上で苦労したのはレジスタの割り当てで、 Libra the Processor には A 、 B 、 C の 3 つしかレジスタがない上に A レジスタがオペランドの一方として固定されているので、できるだけメモリやスタックに退避しなくて済むように、移動と計算を同時に行えるように、等を意識しながら割り当てを考えていました。

記事を書いて振り返ると改善できる点がまだまだありましたが、イベント直前に 3 日で作ったにしては上出来なんじゃないかなと思います。

最後までご覧いただきありがとうございました。 Libra the Processor のプログラムを書いてみたいというチャレンジャーがいましたら、シミュレータはブラウザまたは node.js で動かせるので、ぜひ挑戦してみてください。投稿者が泣いて喜びます。

-自作3進CPU

執筆者:


comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


関連記事

【自作三進CPU】Libra the Processor の仕様

現在進行形で作成中の自作&#19 …

3値論理回路を作ってみた【自作3進CPU】

かねてより温めていた計画&#12 …

3進Dフリップフロップを作ってみた【自作3進CPU】

自作 3 進 CPU 計画の第  …