見出し画像

VGS-Zeroで始めるZ80マシン語ゲームプログラミング


はじめに

VGS-ZeroでZ80のマシン語ゲームプログラミングを始めたい方向けの入門書のようなものを書いてみようと思いました。

VGS-Zeroは、C言語(SDCC)でゲームを開発することができますが、16MHzのZ80で最大限のパフォーマンスを発揮するにはマシン語でのプログラミングを検討してみる価値があるかもしれません。

VGS-Zeroは高速なZ80(16MHz)を搭載していますが、Cコンパイラ(SDCC)があまり高速な最適化をしれくれません。

個人的にはそれだけ(速度面だけ)がネックならまだ良いのですが、SDCCだとコード量が多くなるとコンパイルに要する時間がかなり掛かるのも厄介です。

具体的には、オールC言語で記述したVGS-Zero版Battle Marineのクリーンビルドには数分の時間が掛かるのに対して、オールZ80で記述したゲームギア版Battle Marineのクリーンビルドは1秒ぐらいで終わります。(MacBook Air 2013 + Ubuntu Linux の場合)

サクッとコードを書いて動かせるのは重要。

開発環境の準備

Ubuntu Desktop が動作するパソコンを準備して、VGS-ZeroのFirst Step Guideの手順(コマンド)を実行するだけで開発環境が整います。

# ツールチェインのビルドに必要なミドルウェアをインストール
sudo apt update
sudo apt install build-essential libsdl2-dev libasound2 libasound2-dev snapd

# z88dk をインストール
sudo snap install z88dk --beta

# VGS-Zeroのリポジトリをダウンロード
git clone https://github.com/suzukiplan/vgszero

# example/01_hello-asm のディレクトリへ移動
cd vgszero/example/01_hello-asm

# ビルド & 実行
make

マシン語とはいってもバイナリで入力(ハンドアセンブル)ではなくアセンブリ言語を使用します。

アセンブラは z88dk の z80asm コマンドを使用します。

アセンブラの種類は不問でZ80用アセンブラはかなり豊富に存在するので、各々が使い慣れたアセンブラがあればそれを使っていただければ大丈夫です。(z80asmはsnapでCLIのみでインストールできる点が良い)

First Step Guide のコマンドを全て実行するとマシン語版の「Hello, World!」を表示するシンプルなプログラムが起動します。

Hello, World!

Hello, World!の実装を解析

ソースコード(program.asm)を見てどのような処理でHello, World!を表示しているか解説してみます。

org $0000
.main
    ; 割り込み関連の初期化
    IM 1
    DI

    ; VBLANKを待機
    call wait_vblank

    ; パレットを初期化
    ld bc, 8
    ld hl, palette0_data
    ld de, $9800
    ldir

    ; Bank 1 を Character Pattern Table ($A000) に転送 (DMA)
    ld a, $01
    out ($c0), a

    ; 画面中央付近 (10,12) に "HELLO,WORLD!" を描画
    ld bc, 12
    ld hl, hello_text
    ld de, 394 + $8000 ; 394 = 12 * 32 + 10
    ldir

    ; メインループ
mainloop:
    ; VBLANKを待機
    call wait_vblank

    ; ジョイパッド(1P)の入力を読み取る
    in a, ($A0)
    ld b, a

    ; 左カーソルが押されているかチェック(押されている場合は左スクロール)
    ld hl, $9F02
    and %00100000
    jp nz, mainloop_check_right
    inc (hl)
    jmp mainloop_check_up
mainloop_check_right:
    ; 右カーソルが押されているかチェック(押されている場合は右スクロール)
    ld a, b
    and %00010000
    jp nz, mainloop_check_up
    dec (hl)
mainloop_check_up:
    ; 上カーソルが押されているかチェック(押されている場合は上スクロール)
    ld hl, $9F03
    ld a, b
    and %10000000
    jp nz, mainloop_check_down
    inc (hl)
    jmp mainloop_check_end
mainloop_check_down:
    ; 下カーソルが押されているかチェック(押されている場合は下スクロール)
    ld a, b
    and %01000000
    jp nz, mainloop_check_end
    dec (hl)
mainloop_check_end:
    jmp mainloop

; VBLANKになるまで待機
.wait_vblank
    ld hl, $9F07
wait_vblank_loop:
    ld a, (hl)
    and $80
    jp z, wait_vblank_loop
    ret

palette0_data: defw %0000000000000000, %0001110011100111, %0110001100011000, %0111111111111111
hello_text: defb "HELLO,WORLD!"

org $0000

org $0000

org はアセンブルしたコードの起点アドレスが $0000 (0x0000) からであることを示すz80asmのプリプロセッサです。

ラベル

.main

z88dkの場合、ピリオド+文字列 or 文字列+コロンでラベルを記述できます。

私はピリオド+文字列は「サブルーチンの先頭」、文字列+コロンは「サブルーチン内の分岐」というルールで記述しています。(それが一般的なことなのかは不明)

割り込み禁止

    ; 割り込み関連の初期化
    IM 1
    DI

VGS-Zeroでは基本的に割り込みの機能を使用しないので最初に DI(Disable Interrupt)をしておきます。IM 1(Interrupt Mode 1)は呼ばなくても大丈夫ですね。

VBLANKを待機

    ; VBLANKを待機
    call wait_vblank

次にcall命令で「wait_vblank」というサブルーチンを呼び出しています。

call命令を呼び出すとスタック領域に戻りアドレス(call命令の次の命令のアドレス)を格納してから指定のアドレス(wait_vblank)へ分岐し、分岐先でret命令を呼び出せばスタック領域から取り出した戻りアドレスへ分岐(復帰)することができます。

余談ですが、 BC レジスタへ格納された値のアドレスへジャンプしたい場合、push bc →ret と実装することができます。

push bc   ; スタックに BC を格納
ret       ; スタックから取り出したアドレスへジャンプ

HL、IX、IYレジスタであればレジスタ値へジャンプする命令が存在しますが、BCとDEレジスタから直接ジャンプする命令は無いので、こういう使い方をすることが稀によくあります。

wait_vblankの実装は次のようになっています。

; VBLANKになるまで待機
.wait_vblank
    ld hl, $9F07
wait_vblank_loop:
    ld a, (hl)
    and $80
    jp z, wait_vblank_loop
    ret

HLレジスタに定数値 $9F07 をロード(HL = $9F07)をした後、次のようなループ処理をしています。

  1. HL番地に格納された値をAレジスタに読み込む(ld a, (hl))

  2. Aレジスタの最上位ビット以外をクリア(and $80)

  3. ステップ2の演算結果がゼロなら wait_vblank_loop へ分岐(jp z, wait_vblank_loop)

  4. ステップ2の演算結果がゼロでなかったら呼び出し元へリターン(ret)

この処理を C 言語で書くと次のようになります。

void wait_vblank()
{
    uint8_t* hl = (uint8_t*)0x9F07;
    uint8_t a;
    do {
        a = hl[0];
        a &= 0x80;
    } while (a == 0);
}

wait_vblank はアドレス「0x9F07」に格納された値の先頭ビットがセットされるまで待つ処理ということになります。

VRAMのメモリマップを確認してみると、0x9F07 は VDP Statusで、その先頭ビット(Bit-7)は BL フラグとなっていて、BL は VBLANK が開始するとセットされ、読みだされるとリセットされる仕様です。

VGS-ZeroのVDPは、秒間60回(60fps)の間隔で画面の上から下方向に向かって262本のスキャンラインの描画を行い、200本目のスキャンラインの描画が完了したタイミングでBLがセットされます。

VGS-ZeroのスキャンラインとBLフラグ設定タイミング

スキャンラインがアクティブスクリーンに重なる状態で VRAM の更新を行うと画面に意図しないノイズが発生する可能性があるため、VDP の初期化を行う前にwait_vblankを呼び出して画面ノイズを抑止しています。

パレットを初期化

    ; パレットを初期化
    ld bc, 8
    ld hl, palette0_data
    ld de, $9800
    ldir

LDIR 命令を使って HL (palette0_data) から BC (8) バイトのデータを DE(0x9800 = VRAMのパレットテーブル)へ転送しています。

palette0_data はRGB555形式のワードリテラル(defw; DW)です。

palette0_data: defw %0000000000000000, %0001110011100111, %0110001100011000, %0111111111111111

ちなみに処理性能的にはLDIR命令を使うよりmemcpy命令(DMA)を使う方が高速なので、VGS-Zeroのプログラミングで速度を求める場合はLDIR命令を使うことはありません。(一応使うこともできる程度の存在)

memcpy命令(DMA)だとこのように記述します。

LD BC, $9800          # 転送先アドレス (VRAM)
LD DE, palette0_data  # 転送元アドレス (palette0_data ラベル位置のアドレス)
LD HL, 8              # 転送サイズ
OUT ($C3), A          # memcpy(BC, DE, HL) を実行

だいたいLDIRと同じで、レジスタの割当と0xC3ポートへのOUTを行う点のみ異なります。

レジスタの割当はLDIRに準拠した方が学習コストが低いかもと思ったのですが、memcpyと同じ引数の並び順(dst, src, size)でBC, DE, HLを並べた方が暗記しやすいと思ったのでLDIRとは異なる「私が覚えやすいレジスタの割当」で設計されています。

LDIRよりもDMAを使った方がZ80のトータルの消費クロック数が少なくなり、エミュレータ本体のCPU負荷も軽くなるのでメリットしかありません。(デメリットを挙げるとすればVGS-Zero以外のゲーム機との互換性が無い点)

キャラクタパターンの初期化

    ; Bank 1 を Character Pattern Table ($A000) に転送 (DMA)
    ld a, $01
    out ($c0), a

バンク1に格納されている文字の画像データをキャラクタパターンテーブル(0xA000)に DMA 転送(bank to character pattern)しています。

Hello, World! 描画

    ; 画面中央付近 (10,12) に "HELLO,WORLD!" を描画
    ld bc, 12
    ld hl, hello_text
    ld de, 394 + $8000 ; 394 = 12 * 32 + 10
    ldir

LDIR 命令を使って HL (hello_text ) から BC (12) バイトのデータを DE(0x8000 + 394)へ転送しています。

BGネームテーブル(32x32)の特定の位置(10,12 )からキャラクタデータを書き込みたいので、BGネームテーブルの先頭アドレス(0x8000)から394 (12 * 32+10)を加算した位置を DE に設定しています。

hello_text は ASCII コード形式のバイトリテラル(defb; DB)です。

hello_text: defb "HELLO,WORLD!"

バンク1のキャラクタパターンは、ASCIIコードの並び順通りに文字の画像データが格納されています。

バンク1

これにより画面(BG)に HELLO,WORLD! が表示されます。

メインループ

メインループでは、次の処理を永続的に繰り返します。

  1. wait_vblankをcall(60fpsの間隔でメインループが実行される)

  2. ジョイパッドの入力取得

  3. カーソルの入力状態を見てBGスクロールを実行

    ; メインループ
mainloop:
    ; VBLANKを待機
    call wait_vblank

    ; ジョイパッド(1P)の入力を読み取る
    in a, ($A0)
    ld b, a

    ; 左カーソルが押されているかチェック(押されている場合は左スクロール)
    ld hl, $9F02
    and %00100000
    jp nz, mainloop_check_right
    inc (hl)
    jmp mainloop_check_up
mainloop_check_right:
    ; 右カーソルが押されているかチェック(押されている場合は右スクロール)
    ld a, b
    and %00010000
    jp nz, mainloop_check_up
    dec (hl)
mainloop_check_up:
    ; 上カーソルが押されているかチェック(押されている場合は上スクロール)
    ld hl, $9F03
    ld a, b
    and %10000000
    jp nz, mainloop_check_down
    inc (hl)
    jmp mainloop_check_end
mainloop_check_down:
    ; 下カーソルが押されているかチェック(押されている場合は下スクロール)
    ld a, b
    and %01000000
    jp nz, mainloop_check_end
    dec (hl)
mainloop_check_end:
    jmp mainloop

プログラムのビルド

具体的にどのような手順でビルドをしているかMakefileの内容から解説します。

PROJECT = hello
BMP2CHR = ../../tools/bmp2chr/bmp2chr
MAKEROM = ../../tools/makerom/makerom
MAKEPKG = ../../tools/makepkg/makepkg

all: build
	cd ../../src/sdl2 && make
	../../src/sdl2/vgs0 game.pkg

build: tools game.pkg

clean:
	rm -f *.bin
	rm -f *.o
	rm -f *.chr
	rm -f *.rom
	rm -f game.pkg

tools:
	cd ../../tools && make

game.pkg: ${PROJECT}.rom
	${MAKEPKG} -o game.pkg -r ${PROJECT}.rom

${PROJECT}.rom: font.chr program.bin
	${MAKEROM} ${PROJECT}.rom program.bin font.chr

font.chr: font.bmp
	${BMP2CHR} font.bmp font.chr

program.bin: program.asm
	z80asm -b program.asm

font.chr: font.bmp
	${BMP2CHR} font.bmp font.chr

makeコマンドを実行すると最初に「all」のターゲットが実行され、ターゲットの依存関係の解決を行った後、コマンド群を実行します。

ターゲット: 依存関係
	コマンド群

依存関係はファイルまたはMakefile内のディレクティブ(allなど)から評価され、ファイルの場合はターゲット(ファイル)と依存関係(ファイル)の日付が比較され、依存関係(ファイル)の方が新しい場合はコマンドが実行されます。(要するに対象ソースファイルが更新された場合のみビルドを実行したいケースで有用です)

クリーンな環境で実行されるコマンド群(シェル相当の形式)に展開すると次のような流れでコマンドが実行されます。

### 最初にツールチェインがビルドされる ### 
cd ../../tools && make

### ツールチェインの bmp2chr で font.bmp から font.chr を生成 ###
${BMP2CHR} font.bmp font.chr

### z80asm で program.asm をアセンブルして program.bin を出力 ###
z80asm -b program.asm

### ツールチェインの makerom で バンク0 = program.bin, バンク1 = font.chr の ROM を生成 ###
${MAKEROM} ${PROJECT}.rom program.bin font.chr

### ツールチェインの makepkg で ROM から game.pkg を生成 ###
${MAKEPKG} -o game.pkg -r ${PROJECT}.rom

### SDL2 版 VGS-Zero エミュレータをビルド ###
cd ../../src/sdl2 && make

### SDL2 版 VGS-Zero エミュレータで game.pkg を実行 ###
../../src/sdl2/vgs0 game.pkg

上記のシェル(バッチファイル)でも目的とするビルドを実施することができ、差分ビルドが不要(高速)ならシェルで良いのではないか?と思われるかもしれませんが、Makefileはコマンドの実行が失敗した時(戻り値が0以外だった時)、自動的に処理を中断してエラーになってくれるので便利です。

より実用的なゲーム実装例

以前開発していたローグライクRPGはマシン語で開発しているので、これを解析すれば Hello, World! より実用的なVGS-Zeroによるマシン語プログラミングのテクニックを見つけることができると思われます。

Z80プログラミングの学習方法

敢えて、Z80の細かい解説は省いてみましたが、アセンブリ言語の実装経験が無くても内容を理解できるように解説してみたつもりです。

Z80は、かつてのパソコンやゲーム機などで幅広い採用実績があり、多数の入門書が存在するので、Z80プログラミングの具体的なノウハウはそれらを読んで学習することを推奨します。

若干高度ですが、コードの具体例を交えた高速化の手法などは以下の書籍が界隈で人気があるようです。(電子版は無さそうなので少しお値段が高いですが…)

学習のコツはインプットしたことをアウトプットすることだと思われますが、これらの参考書でインプットしたことをアウトプットする場としてVGS-Zeroはとても手軽な選択肢だと思っています。

もちろん、MSXやゲームギアでも良いと思いますが、VGS-Zeroなら開発に必要な全ての機能がペライチのマークダウン・ドキュメント(README.md)にすべて記述されている点がプログラミングする人間にとって便利かもしれません。(ペライチのマークダウン・ドキュメントに仕様が全部記述されていることでブラウザでそれを開いておけば必要な情報をブラウザ内検索で簡単に見つけることができ、これが意外とプログラムの生産性に寄与しています)

私は参考書は一切読まずウェブに転がっている情報のみでZ80の学習をしてエミュレータ(以下)が開発できる程度の知識を習得しましたが、エミュレータを開発するというアウトプット機会があったからこそ頭に入り難いウェブ情報のみで学習できたのではないかと思っています。

結果的にゲームギア(CPUはZ80A)の実機で動作するゲーム(以下)を開発することもできました。

上記のゲームが私にとっての「Z80マシン語プログラム」の処女作ですが、ゲームギアのスペックの高さ()を考慮しても初心者にしては中々良く出来た方ではないかと思っています。

なお、私はマシン語プログラミング自体が初心者という訳ではなく、中学生の頃に青山学さんと日高徹さん共著の「PC-9801マシン語ゲームプログラミング」を読んで趣味でPC-9801で80186マシン語(アセンブラはTurbo Assemblerを使用)で書いた、空から降ってくるうんこをよけながらリンゴを拾う謎コンセプトの固定画面アクションゲームが本物の処女作(未公開)です。

86系(Z80を含む)以外にはRP2A03(6502のリコー製カスタムCPU)もウェブ情報のみで学習してプログラミングした経験があり、「とっつき易さ」としてはZ80よりも6502に分があります。

Z80は6502よりも「高度な処理をより簡単に記述することができる」というメリットがありますが、その分レジスタや命令が多くあるので複雑です。

そのため、「マシン語プログラミングの入門用」としてはどちらかといえば6502が良い感じかもしれません。

ただ、結局のところ思い入れのある機種でプログラミングをするのが一番ですね。

ファミコンに思い入れがあればRP2A03、セガマークIIIやゲームギアに思い入れがあればZ80、メガドライブに思い入れがあればMC68000が良いかなと。

ノーコードという選択肢

ゲームボーイ(GB, GBC)に思い入れがあればGBStudioでノーコードでゲームを開発できたりするようです。

レトロゲーム機でノーコードとか中々発想がイカれていて良いですね。

ノーコードというと最近っぽいようにも聞こえますが、古くはKlick&Play(1998年発売Windows3.1)、チャイムズクエスト(1991年発売PC-9801)、RPGコンストラクションツール・Dante(1990年発売MSX2)など30年以上前から存在します。(※RPGツクールはRPGコンストラクションツールの続編なのでMSX2版がオリジナルという認識)

デザインが得意だけどプログラミングが苦手な方々も一定数居るので、ノーコード開発環境は重要だと思いつつ、プログラミングが得意だと逆にそういったツールの使い方を覚える方が難しい「コード脳」になってしまっている(だからUnityとかも微妙に馴染みにくい)こともあり、ノーコード開発環境に一切手を出してこなかった私にはノーコード対応は難しい…(チャイムズクエストは持っていたけどコード脳が邪魔した所為かゲームを完成させることができなかった)


この記事が気に入ったらサポートをしてみませんか?