Pascal-to-Goコンバータを使ってGoでZZTを動かす
原文は Ben Hoyt により に公開されました。 このブログを購読する
要約:Adrian Siekierka氏の「Reconstruction of ZZT」を見たことをきっかけに、私は彼のTurbo PascalコードをGoに変換するプログラムを書きました。この記事では、私が作ったPascal-to-Goコンバータと、(完全ではない)ZZTのGo移植について説明します。
私は「ゲーマー」ではありませんが、10代の頃に楽しんでいたゲームのひとつがEpic MegaGamesのZZTでした――1991年に登場した古いテキストモードのDOSゲームです。当時としてもグラフィックスは決して革新的とは言えませんでしたが、ZZTがこれほど成功し、今なおカルト的な人気を保っている理由は、ワールドエディタにあります(シェアウェア版でも無料で付属していました)。
オリジナルのシェアウェア版「Town of ZZT」のタイトル画面はこんな感じでした:

ワールドエディタにはZZT-OOPと呼ばれるスクリプト言語が搭載されており、OOPの「O」はZZTの「オブジェクト」、つまりプログラム可能なゲームキャラクターやロボットを指していました。そのため人々は何千もの独自のワールドを作って共有し、そのうち数百ものワールドがMuseum of ZZT(ZZTワールド版のarchive.orgのようなサイト)でダウンロードでき、オンラインでプレイすることさえできます。
ZZTの再構築
ZZTのオリジナルソースコードはコンピュータのクラッシュで失われてしまいましたが、その後、他の言語でゲームを再現しようとするさまざまな試みがありました。C++によるもの、JavaScriptによる部分的実装、さらにはRustによる正確な再実装まで存在します。また、Adrian Siekierka氏はZZTを実行するためだけの専用DOSエミュレータであるZetaの作者でもあります。
2020年3月、Adrian氏はReconstruction of ZZTを公開しました。これはPascalソースコードをリバースエンジニアリングによって再現したもので、ZZTが当時使っていたオリジナルのTurbo Pascal 5.5でコンパイルすると、バイト単位で完全に同一の.EXEファイルが生成されます。個人的には驚くべき偉業だと思います! 元のHacker Newsでの議論や、Adrian氏が後にどのように成し遂げたかを書いた記事も参照してください。
その後、彼はさらに一歩進めて、他のZZT移植でも使えるよう寛容なライセンスで提供される、ZZTゲームエンジンのポータブルなC言語による再実装であるlibzooも作成しました。
Pascal-to-Goコンバータ
しばらく前、GoでZZTを書こうとしたことがありましたが、少しだけ進めたところで諦めてしまいました――サイドプロジェクトとしてはあまりに大掛かりに思えたのです。しかし、Pascalによる再構築版が公開されたとき、もう一度(お後がよろしいようで)goしてみることにしました。
私は以前からインタプリタやコンパイラをいじるのが好きなので、Adrian氏のPascal再構築版を半自動的にGoへ変換するプログラムが書けないか試してみたいと思いました。そこで、あまり完全ではないTurbo Pascalのパーサと、Pascalの構文木を受け取ってGoの型と構文で書き出すコンバータを書いたのです。
Goの構造や宣言の構文は、実はPascalとかなり共通点が多いため、コンバータ全体の構造はかなりシンプルなものになりました。例えば、OOP.PASにある小さなPascalの関数を見てみましょう:
function WorldGetFlagPosition(name: TString50): integer;
var
i: integer;
begin
WorldGetFlagPosition := -1;
for i := 1 to 10 do begin
if World.Info.Flags[i] = name then
WorldGetFlagPosition := i;
end;
end;変換されたGoのコードがこちらです:
func WorldGetFlagPosition(name string) (WorldGetFlagPosition int16) {
var i int16
WorldGetFlagPosition = -1
for i = 1; i <= 10; i++ {
if World.Info.Flags[i-1] == name {
WorldGetFlagPosition = i
}
}
return
}ほとんど同じですね。コンバータをシンプルに保つため、イディオマティックなGoにしようとするのではなく、Pascalをより忠実に直訳する方式を選びました――プログラムでイディオマティックなGoにするのは難しすぎますし、後で手作業できれいにすればいいと考えたからです。
Pascalでは関数名自体が戻り値として使われるため、それに対応させるためにGoの名前付き戻り値を使っている点に注目してください。バグを避けるため整数サイズは忠実に変換したので、ここでは単なるintではなくint16を返しています。
Pascalでは配列の開始インデックスを0以外にも自由に宣言でき、実際には1から始まることがよくあります。そのため、Pascal版ではFlags[i]となっているものが、Go版ではFlags[i-1]となっています。
最も難しかったのは――そしてほとんど自動化できなかったのは――Turbo Pascalのポインタや文字列と、それに対応するGoの型との違いでした。コンバータではすべての文字列型を単純にstringに変換するようにし、壊れた部分は手作業で修正しました。
Pascalのvarパラメータ(「参照渡し」を実現する方法)はGoではポインタになりますが、もちろんGoでは値を代入する際に明示的に参照外しをする必要があります。例えばこのようなPascalの関数は:
procedure ElementSpinningGunDraw(x, y: integer; var ch: byte);
begin
case CurrentTick mod 8 of
0, 1: ch := 24;
2, 3: ch := 26;
4, 5: ch := 25;
else ch := 27 end;
end;Goではこのようになります:
func ElementSpinningGunDraw(x, y int16, ch *byte) {
switch CurrentTick % 8 {
case 0, 1:
*ch = 24
case 2, 3:
*ch = 26
case 4, 5:
*ch = 25
default:
*ch = 27
}
}変換したGoをビルドする際には、最初の数件だけでなくすべてのエラーを表示するためにgo build -gcflags="-e"を使い、結果をerrors.txtファイルに出力しました。当初このファイルには800件以上のエラーがありましたが、自動変換の作業を終える頃には33件まで減っており、残りは手作業で修正しました。
私のPascal-to-Goコンバータのソースコードはgithub.com/benhoyt/pas2goで公開しています。まずはlexerとparserを作成し、パースしたオリジナルのソース(origディレクトリ)を整形されたPascalとしてparsedディレクトリに出力しました。そしてその2つのディレクトリ間でdiffを取って、Turbo Pascalパーサが正しく動作しているかを確認しました。
パーサが完成した後は、converterに取りかかりました。これは私が書いた中でも決してエレガントなコードではありません。とにかく動くものを作りたかったのです。特に、型を判別する処理とGoのソースコードを出力する処理が混在しています――本格的なトランスパイラを作るなら、これらを2つのフェーズに分離し、まず「型」のデータ構造を構築するはずです。
Pascalの整数型の扱いはGoとはかなり異なります。Goは非常に厳格で、数値型の自動的な型強制を一切行いません。Turbo Pascalが実際にどのように変換を行っていたかについての良いオンライン資料が見つからなかったため、Free Pascalのドキュメントと、DOSBox上で動作するTurbo Pascal 5.5で実際に試すことを組み合わせて、ほとんどのルールを解明できたと思います。
Pascalでは算術演算を行う際にinteger(int16)へ昇格されるようで、代入時には必要に応じて自動的に昇格や切り捨てが行われます。そのためGo版では明示的な変換がずっと多くなります。例えばELEMENTS.PASにある次のようなコードは:
if Difference(Y, Board.Stats[0].Y) <= 2 then begin
shot := BoardShoot(element, X, Y,
Signum(Board.Stats[0].X - X),
0, SHOT_SOURCE_ENEMY);
end;Goでは大量のint16()変換を伴うことになります:
if Difference(int16(stat.Y), int16(Board.Stats[0].Y)) <= 2 {
shot = BoardShoot(element, int16(stat.X), int16(stat.Y),
Signum(int16(Board.Stats[0].X)-int16(stat.X)),
0, SHOT_SOURCE_ENEMY)
}Goへの移植
半自動変換が終わった時点でも、まだ数十件のGoコンパイルエラーが残っていました。最初のステップは、エラーなくコンパイルできるようにすることでした。Goのコードを独自のgithub.com/benhoyt/zztgoリポジトリに移し、errors.txtファイルを順に処理しながら問題を修正し、重要度の低いソースコード(サウンド関連の関数)を削除していきました。
コンパイルが通るようになったら、次は映像機能を追加する必要がありました。現在はtcellを使ったターミナル上の「グラフィックス」を利用しています。本物のZZTのように見せるには、古いDOSのものに合わせてフォントやターミナルの色を調整する必要があります。zztgo版はこんな感じです(かなり近いでしょう!):

また、ZZTワールドのシリアライズ処理も書く必要がありました――Turbo Pascal版ではバイナリ構造体をそのままメモリに読み込んでいるだけでした(昔はリトルエンディアンとパックされた構造体を当然のものとして扱っていました)。
大きなバグを取り除くと、驚くほど多くのゲームプレイがそのまま動きました。物事が本当にうまく収まり始めました。特に覚えているのは2つのコミットで、このコミットでゲームプレイの大部分が動作するようになり、このコミットでゲーム内のワールドエディタがほぼ完全に動作するようになりました。自動コード変換、万歳!
zztgoを自分で実行する
自分で実行するには、Goをインストールし、リポジトリをクローンして、go buildと入力し、./zztgoを実行してください。もう少し本物らしく見せたい場合は、IBM EGAフォントをインストールし、行間をゼロに調整してください。macOSではこのTerminal設定ファイルを使うことができます。
今後の展望
残念ながら、このプロジェクトに費やせる時間は尽きてしまいました。ゲームプレイの大部分は実装されており、まずまず遊べる状態にはなっていますが、フォークしてさらに発展させたいという方は、ぜひどうぞ! 現時点で不足している、あるいは壊れていると分かっている点は以下の通りです:
- タイミング周りのコードは理想とは程遠いものです。現在は次のティックの時刻まで正確に待つのではなく、固定時間だけ
time.Sleep()を呼び出しているだけです。修正はそれほど難しくないはずです。 - サウンドはまったく動作しません。sounds.goにある有用な部分はすべてコメントアウトされています。これを修正するにはかなりの作業が必要で、サウンド/ゲームライブラリを取り込む必要があります。
- ゲームライブラリの話ついでに、描画については(DOSフォントをインストールしたりターミナルの行間をいじったりする代わりに)出力をより細かく制御するために、適切なグラフィックスライブラリを使った方が良いかもしれません。
- ほとんどのZZT-OOPオブジェクトは動作するようですが、Preposterous Machinesのタイトル画面が崩れることからも分かるように、確実にいくつかのバグが残っています。
- EditorTransferBoardはコメントアウトされています。
- 最後に、そして重要な点として、決してイディオマティックなGoとは言えません! コードは非常に「Pascalから自動変換された」感が漂っています(なぜかは分かりませんが)。
とはいえ、このまま公開することにしました――お楽しみください! Pascal-to-Goコンバータを実装し、ゲームプレイが息を吹き返すのを見るのは本当に楽しい経験でした。
記事をランダムに読む
コメント
ログインしてコメントする