この記事はRust その2 Advent Calendar 2016 4日目の記事です。
こんにちは、murachueです。
Rustについて知った時、「RustでOSを書くこともできるんだぜ~」的なことを耳にしたので、きっとCやC++でしか書けないようなモノもRustなら書けるんだろうな~と思いました。
というわけで、今回はRustでむりやり初代PlayStation(以下PS1)向けの簡単なプログラムを作ってみます。
Cという名の高級アセンブラやC++という名の超高級アセンブラに負けないぞ~。
(ちなみに、その昔「ネットやろうぜ!」という一般人でもPS1ゲームが作れるキットがあったらしいんですが、その開発言語はCだったそうです。)
いちおう、RustについてはThe Rust Programming Languageの5~6章ちょっとくらいまでに書いてあることくらいを知っている前提です…が、まあ知らなくてもなんとかなるでしょう。(ほんとに?)
当初は簡単なゲームを作ってみる予定でしたが、説明量が多すぎて書く気が続かないので、パッドを押したら殺風景な画面がちょっと反応する程度のプログラムに留めます。ごめん。これを読んでるあなたのやる気と芸術爆発力に期待する。
(画像の作成、音声の独自形式(VH/VBファイルのVBみたいな感じ)変換と読み込みと再生、テクスチャのロード、ダブルバッファリング…と、かなりつらい…。)
動作確認環境
- VirtualBox 5.0.10 r104061 on Windows 7 SP1 (今回選んだエミュレータがWindows向け…)
- VMで作業する場合はコア数を多く割り当てないとRustのビルドに時間がすごいかかる。
- Debian jessie 8.6 (x86_64、上述のVBox上で動作。)
- Rust 1.12改造版 (後述の方法でビルド)
- gcc 4.9.2 → あとで5.3.0を作る
- binutils 2.25 → あとで2.25.1を作る
- その他Rustビルドに必要なやつ
- curl 7.38.0
- git 2.1.4
- flex 2.5.39
- bison 3.0.2
- cmake 3.7.0
- Debian付属のやつ(3.0.2)は古すぎてrust付属のLLVMが拒否するので、 https://cmake.org/download/ からバイナリをゲットして適当な場所に配置、PATHを通しておく
- Python 2.7.9
- xorriso 1.3.2 (mkisofsでも可)
- PS1エミュレータ
- NO$PSX 1.9
- エミュってしまうま(XEBRA) 150831
微妙に使うRustやgccやbinutilsのバージョンが半端なのは、手元で用意できてたのを使ったのと、PS1向けに作ろうとしたのがちょっと昔だからです。
まずPS1についての資料
PS1に限らず、何かで動かすプログラムを作るには、仕様というか、どういうCPUを使ってて、メモリ空間のどこがRAMだったりMemory-mapped I/O(以下MMIO)だったりするのか、OSみたいなものを使うならそのAPIをどう使うのか、という情報が必要になります。
本来はPS1を作ったSony Computer Entertainment Inc.とNDA等契約して情報を得る…とかなるはずですが、それはつらい…。
一方で、巷のスーパーハカー達がゲーム機をhackしていろいろ情報を公開していることが多く、幸い、PS1もご多分に漏れずhackして得られた情報を以下のページで公開している方がいらっしゃいます。英語で。
http://problemkaputt.de/psx-spx.htm (以下「例のページ」)
今回はありがたくこのページにある情報をガンガン使わせていただきます。
このページは1ページに全部の情報が書いてあるので、Ctrl+Fでガンガン検索できます。
一応簡単にプログラマから見たPS1の仕様を書いておくと、
- CPUはMIPSというアーキテクチャのR3000系というのを使っていて、
- CPUが直接使えるRAMは2MiB(+4KiB)、
- MMIOで叩けるものとしては以下のようなものがある
- GPU(frame-bufferの内容を画面に出したり、三角形や四角形をいい感じに描けたりする)
- GTE(Geometry Transform Engine; PS1固有のやつで、3D座標をいい感じに2D座標にさくさく変換できるやつ)
- SPU(Sound Processing Unit; サウンドカードみたいなやつ)
- CD-ROMを制御するやつ
- タイマー
- パッド (○×△□ボタンとかついてるアレとメモリーカード) やシリアル (通信ケーブルでPS1同士を繋げて遊ぶゲームがあるのを知っていますか?)
- DMA (Direct Memory Access; デバイスとメモリ間でデータ転送するやつ、CPUでループして転送するより速い)
といった感じです。
CPUについて詳しくは例のページのCPU Specifications、MMIOはI/O Mapに書いてあります。I/O Mapは今後よく見ることになります。
あと、起動時のロゴを出したり、音楽CDを入れたら再生できたり、実はゲームの裏で仕事をしているプログラムがPS1には予め備わっていて、巷ではこれをBIOSと呼んだりします。(人によってはOS ROMと呼ぶこともあります。)
BIOSについては例のページのKernel (BIOS)に書いてあります。
今回は、GPUとパッドをちょこっとだけさわります。
エミュレータとBIOSについて
PS1に限らずゲーム機のエミュレータを使う際にわりと避けて通れないのがBIOSで、PS1も上述の通りBIOSがあるので、一見本体から吸い出せる人じゃないとエミュレータを扱えないように思うかもしれません。
しかし、今回確認したエミュレータ、つまり NO$PSX と エミュってしまうま の2つについては、うれしいことに互換BIOSを内蔵しており、PS1の実機を持っていなくても、すぐに使うことができます。
また、これら互換BIOSに関してはPS1の実機のBIOSと100%互換が取れているわけではないのですが、逆にCD(イメージ)の検査に関してかなり甘く作られているようで、本来必要であるCDイメージへのライセンス情報書き込みを省略することができます。
以降の説明においてもこの手順を省略しているため、実機のBIOSを使ってエミュレータを使用している人は作成したCDイメージを起動できなかったりするかもしれません。(SCEIロゴ画面で止まるなどの現象が起こる。) その場合は、上記2つのエミュレータを、それぞれに内蔵する互換BIOSを使用する設定にして試してみてください。
環境整備
さて、Rustを使ってPS1向けプログラムを作るので、Rustを用意します。
バイナリでさくっと用意…したいところですが、色々あってむりやり感のあるパッチを当てたRustでないといけないので、いろいろ含めてビルドしなければいけません。
以下のようにビルドします。かなり時間がかかります。(コンパイルだけでCore i5 2500で3並列makeして1~2時間、各種tarballやgitの取得時間を含むとそれ以上。)
"make"の部分は"make -j3"などコア数に応じた並列ビルドオプションを指定したほうが良いです。ちなみにRustのビルドはメモリを大量に消費するので、3並列なら3.5GBくらい空きメモリが無いときついです。
がんばって!
#必要なパッケージの準備
$ sudo apt-get install build-essential git curl libgmp-dev libmpfr-dev libmpc-dev bison flex
#作業ディレクトリの準備、各種ビルドの引数を簡単にするための変数の準備
$ mkdir ~/ps1
$ cd ~/ps1
$ TOP=$HOME/ps1/toolchain
$ ARCH=mips
$ TRIPLET=mipsel-linux-musl
$ CROSSTOP=$TOP/$TRIPLET
$ mkdir $TOP
$ cd $TOP
#muslと、それ用のtoolchainの準備(PS1に一部だけ使う。Rustの内部処理分岐をごまかすのが面倒というのもある。crosstool-NGはRustが求める名前で作ってくれなかった…。)
#正直このやりかたは微妙に不正解な気がするが、一応動いている…。
$ curl http://ftp.gnu.org/gnu/binutils/binutils-2.25.1.tar.bz2 -O
$ curl http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-5.3.0/gcc-5.3.0.tar.bz2 -O
$ curl https://www.musl-libc.org/releases/musl-1.1.12.tar.gz -O
$ curl https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-4.4.35.tar.xz -O
#↑Linuxのヘッダがないとgccの2回目のビルドでヘッダファイルが無いと言って止まるので必要。バージョンは4.4.35でなくてもいいはず。
$ tar xf binutils-2.25.1.tar.bz2
$ tar xf gcc-5.3.0.tar.bz2
$ tar xf musl-1.1.12.tar.gz
$ tar xf linux-4.4.35.tar.xz
$ cd binutils-2.25.1
$ ./configure --target=$TRIPLET --disable-nls --prefix=$TOP
$ make
$ make install
$ cd ..
$ cd linux-4.4.35
$ make ARCH=$ARCH headers_install INSTALL_HDR_PATH=$CROSSTOP
$ cd ..
$ export PATH=$TOP/bin:$PATH
$ mkdir gcc-stage1
$ cd gcc-stage1
$ ../gcc-5.3.0/configure --target=$TRIPLET --prefix=$TOP --enable-languages=c --disable-nls --disable-shared --disable-threads --disable-libquadmath --disable-libgomp --with-arch=3000 --with-abi=32 --with-float=soft --without-long-double-128 --with-gcc --with-gnu-ld --with-gnu-as --without-headers --disable-libssp --disable-libatomic
$ make
$ make install
$ cd ..
$ cd musl-1.1.12
$ ./configure --target=$TRIPLET --prefix=$CROSSTOP --disable-shared CROSS_COMPILE=${TRIPLET}-
$ make
$ make install
$ cd ..
$ mkdir gcc-stage2
$ cd gcc-stage2
$ ../gcc-5.3.0/configure --target=$TRIPLET --prefix=$TOP --enable-languages=c --disable-nls --disable-shared --disable-threads --disable-libquadmath --disable-libgomp --with-arch=3000 --with-abi=32 --with-float=soft --without-long-double-128 --with-gcc --with-gnu-ld --with-gnu-as --without-llsc
$ make
$ make install
$ cd ..
#ようやくrustのビルド
$ git clone --depth 1 https://github.com/murachue/rust-psx.git rust
$ cd rust
$ git submodule init
$ git submodule update
# ↑cloneでやったようにsubmodule updateでうっかり--depth 1をつけると、ことごとく"no such remote ref..."と悲しいことになるので付けてはいけない。時間はかかる。 https://github.com/rust-lang/rust/issues/34228 など、同じところで引っかかってる人はいるみたい…。
$ ./configure --target=mipsel-unknown-linux-musl --disable-manage-submodules --disable-jemalloc --disable-docs --prefix=$TOP
$ make
$ make install
$ cd ../..
……できたでしょうか? 本当に、おつかれさまです。
なお、shellを一度抜けたあと、再度上記手順で作ったtoolchainを使いたい場合は、以下のようにPATHを通せばOKです。
$ export PATH=$TOP/bin:$PATH
# つまり…
$ export PATH=~/ps1/toolchain/bin:$PATH
あと、作ったプログラムをPS1で動かすには、特定の形式の実行ファイルを生成し、その他実行時に必要なデータ(絵や音など)を含んだCDイメージを作る必要があります。
特定の形式の実行ファイルを作るプログラムは自分が作りました(Cで書いたけど…)ので、これを使ってください(PS-X EXEが作れるなら他のツールも可です)。
さらにPS1のCDはMode 2と呼ばれるちょっと特殊な形式になっていて、xorrisoなどではその形式を作るのができなさそうなので、それらが作ることができるMode 1からMode 2への変換プログラムが必要になります。それも今回の目標程度なら使える簡単なものを自分が作りましたので、これを使ってください。
簡単なプログラムを作ってみる (HelloWorldもどき)
それでは、まず画面を一色に塗るプログラムを作ってみましょう。
#![feature(core_intrinsics, libc)]
extern crate libc;
use libc::c_void;
use std::intrinsics::volatile_store;
// start.sで定義されている関数(BIOSが提供する機能を呼び出すための関数)
extern "C" {
pub fn calla0(nr: u32, ...) -> u32;
pub fn callb0(nr: u32, ...) -> u32;
pub fn syscall(nr: u32) -> u32;
}
// called from start.s
#[no_mangle]
pub extern "C" fn init_heap(addr: *mut c_void, size: u32) {
unsafe { calla0(0x39, addr, size); }
}
// called from liballoc_system; see "BIOS Memory Allocation"
#[no_mangle]
pub extern "C" fn malloc(size: size_t) -> *mut c_void {
unsafe { calla0(0x33, size) as *mut c_void }
}
// note: 少なくともSCPH-100xのreallocはバグもちなので本当は使いたくない…
#[no_mangle]
pub extern "C" fn realloc(addr: *mut c_void, size: size_t) -> *mut c_void {
unsafe { calla0(0x38, addr, size) as *mut c_void }
}
#[no_mangle]
pub extern "C" fn free(addr: *mut c_void) {
unsafe { calla0(0x34, addr); }
}
// called from start.s
#[no_mangle]
pub fn main() {
let gp0 = 0x1F801810 as *mut u32; // GPU's GP0 register
let gp1 = 0x1F801814 as *mut u32; // GPU's GP1 register
unsafe {
// Display Mode(解像度の設定): 320x240 60Hz
// see http://problemkaputt.de/psx-spx.htm#gpudisplaycontrolcommandsgp1
volatile_store(gp1, 0x08000001);
// VRAMにおける表示位置、描画クリッピング領域、描画コマンドの位置オフセットの設定
// 以下4コマンドはdouble-bufferingをするときのヒントになるはず…
// Start of Display Area: (0,0)
volatile_store(gp1, 0x05000000);
// Set Drawing Area top left, bottom right: (0,0)-(320,240)
// see http://problemkaputt.de/psx-spx.htm#gpurenderingattributes
volatile_store(gp0, (0xE3 << 24) | (0 << 10) | (0 << 0));
volatile_store(gp0, (0xE4 << 24) | (240 << 10) | (320 << 0));
// Set Drawing Offset: (0, 0) ...this is NOT offset of Set Drawing Area top left!
volatile_store(gp0, (0xE5 << 24) | (0 << 11) | (0 << 0));
// ようやく四角形!
// Render monochrome variable-size opaque rectangle
// see http://problemkaputt.de/psx-spx.htm#gpurenderrectanglecommands
volatile_store(gp0, 0x60224466); // color #664422
volatile_store(gp0, (0 << 16) | (0 << 0)); // from y=0, x=0
volatile_store(gp0, (240 << 16) | (320 << 0)); // size h=240, w=320
}
loop{} // 無限ループによりここで止まる
}
……いきなりunsafeですね。でもしかたない…。
あとextern "C"のブロックやinit_heap、malloc、realloc、freeに関しては、「おまじない」としておいてください。
今回は動的メモリ確保を使わない(書く気が続かない…)のでinit_heap等に関しては必要ではないのですが、後述のstart.sを使う場合は少なくともinit_heapの記述は必須になります。あなたの想像力を爆発させる時にとても便利だと思いますので書いておいています。
おまじないについては、この程度の内容ならアセンブラで書いても良いですが、Rustでも書けるんだぞ~ということでRustで書いています。unsafeだけど。
当然ここで動くことになるコードはmainの中のものですが、ポイントとしては、以下のような感じです。
- ふつうのRustと違い、むりやりなので、mainはpubにして、#[no_mangle]を付けないといけません。
- 後述のstart.sからmainを呼ぶためです。
- 互換BIOSによってプログラム起動時の解像度がまちまちなので、明示的に設定します。
- 設定した解像度に応じたVRAMにおける描画領域を設定します。
- PS1のVRAMは1024x512あり、今回はその左上320x240にいろいろ描き(逆に言えばそれの外側は書き換えないようにしている)、それをそのまま表示することにしています。
- ここにきてようやく画面を覆い尽すサイズの四角形を書いています。
- 四角形を書くコマンドは、例のページのGraphics Processing Unit (GPU)に書いてあります。
- GPU I/O Ports, DMA Channels, Commands, VRAMを見ると、GP0というGPUのレジスタにコマンドやそのパラメータを書けばよく、GP0にはメモリ番地0x1F801810に32bitの値を書いていけばよさそうです。
- GPU Render Rectangle Commandsを見ると、単色の四角形を描くには0x60というコマンドを使えばよく、GP0へはコマンドと色、左上の座標、四角形の大きさといったパラメータを、書いてある形式(3つの32bit)で書けばよさそうです。
- メモリへの書き込みのreorderや、「最適化」と称する「同じ番地に連続して書いてるけどメモリなら最後の値を1回だけ書けばいいよね~」的な動作はMMIOを叩くときには余計なため、それを抑止するために、全体的にvolatile_storeを使っています。
- PS1のプログラムには終了という概念が無い気がするので、mainからはreturnしないように最後にloop{}で無限ループするようにしています。
さて、書いたコードを動かしたいですよね? ではrustc...ちょっとまってください。むりやり動かそうとしているので、いくつか御膳立てをしなければなりません。
上記main.rs以外にも、以下のようなファイルを作ってください。これらは今後も使い回します。
.set at
.set reorder
.macro pubfn sym
.section .text.\sym, "ax"
.globl \sym
.type \sym, %function
\sym:
.endm
pubfn start
# initialize bss; bss start/end must be aligned by 4!
la $t0, __bss_start
la $t1, __bss_end
bss_clear:
beq $t0, $t1, bss_clear_done
sw $zero, 0($t0)
addiu $t0,4
j bss_clear
bss_clear_done:
# initialize heap with BIOS
la $a0, __bss_end
li $a1, 0x801Ff000 #stack = 801Ff000..801Ffff0
subu $a1, $a0
jal init_heap
# call main
jal main
# main must not return...
die:
j die
# PSX BIOS function calls
pubfn calla0
move $t1, $a0
move $a0, $a1
move $a1, $a2
move $a2, $a3
li $t2, 0xa0
jr $t2
pubfn callb0
move $t1, $a0
move $a0, $a1
move $a1, $a2
move $a2, $a3
lw $a3, 0x10($sp)
li $t2, 0xb0
jr $t2
# note: $a0 is passed as is.
pubfn syscall
syscall 0
jr $ra
# target dependent.
# just break.
pubfn abort
break 0
# not implemented: just break.
pubfn posix_memalign
break 0
# called from panic/lock etc... try to avoid crashing before abort()
#pubfn __tls_get_addr
# jr $ra
pubfn pthread_mutex_lock
jr $ra
pubfn pthread_mutex_unlock
jr $ra
pubfn __xpg_strerror_r
jr $ra
pubfn __errno_location
jr $ra
pubfn pthread_key_create
jr $ra
pubfn pthread_mutex_destroy
jr $ra
pubfn pthread_cond_destroy
jr $ra
pubfn pthread_rwlock_rdlock
li $v0, 0
jr $ra
pubfn pthread_rwlock_wrlock
li $v0, 0
jr $ra
pubfn pthread_rwlock_unlock
li $v0, 0
jr $ra
#pubfn pthread_getspecific
# jr $ra
#pubfn pthread_setspecific
# jr $ra
pubfn pthread_key_delete
jr $ra
pubfn __sync_synchronize
jr $ra
OUTPUT_FORMAT(elf32-tradlittlemips)
OUTPUT_ARCH(mips:3000)
ENTRY(start)
MEMORY {
/* BIOS takes first 64kB. */
RAM : ORIGIN = 0x80010000, LENGTH = 0x1F0000
}
SECTIONS {
.header : { *(.header) } > RAM
.text : { *(.text .text.*) } > RAM
.rodata : { *(.rodata .rodata.*) } > RAM
/* .tdata: thread local data? */
.data : { *(.data .data.* .tdata .tdata.*) } > RAM
_gp = . + 0x7FF0;
.sdata : { *(.sdata .sdata.*) *(.lit4 .lit4.* .lit8 .lit8.*) } > RAM
. = ALIGN(4);
__bss_start = .;
.sbss : { *(.sbss .sbss.*) } > RAM
.bss : { *(.bss .bss.*) } > RAM
. = ALIGN(4);
__bss_end = .;
/DISCARD/ : { *(.reginfo .MIPS.abiflags .pdr .gnu.attributes .comment) }
}
BOOT = cdrom:\SLPS_999.99;1
TCB = 4
EVENT = 16
STACK = 801FFFF0
最後のSYSTEM.CNFは行末CRLFで保存したほうが良いです。
最初のstart.sはちょっと長いし、実は後半のむりやり感あふれるpubfnたちは今回は無くても動くのですが、あなたの想像力を爆発させる時にとても便利(以下略。
ではビルドしましょう。
#むりやり感あふれるrustcのオプション。staticlibとしてビルドして、あとで自分でlinkする。
$ rustc --target=mipsel-unknown-linux-musl --crate-type=staticlib -C soft-float -C target-cpu=mips1 -C relocation-model=static -C panic=abort -C llvm-args="-mno-check-zero-division" -C llvm-args="-disable-mips-delay-filler=true" main.rs
#start.sのアセンブル(初回のみでOK)
$ mipsel-linux-musl-as -march=r3000 -msoft-float -o start.o start.s
#すごくむりやり感のあるリンク。libgccをリンクする関係でldではなくgccを使う。-nostdlibしたうえで-lcするとinit/finiなどが消えて純粋なライブラリとしてlibcを使えるっぽい…
$ mipsel-linux-musl-gcc -nostdlib -Wl,--gc-sections -T psx.lds -o game.elf start.o -L. -lmain -lc
#リンクしたプログラムをELFからbinary形式に変換(なおELFファイルはデバッグする時に便利)
$ mipsel-linux-musl-objcopy -O binary game.elf game.bin
#環境整備の章で紹介したPS-X EXEつくるツールをビルド(もちろん初回のみでOK)
$ cc -o mkpsxexe mkpsxexe.c
#そのツールでbinary形式にしたプログラムを特定の形式の実行ファイルに変換
$ ./mkpsxexe \
-l $(mipsel-linux-musl-objdump -p game.elf | awk '/LOAD/{print $5}') \
-e $(mipsel-linux-musl-objdump -f game.elf | awk '/start address/{print $3}') \
-s 0x801Ffff0 -o SLPS_999.99 game.bin
#つくったプログラムとPS1のBIOSがプログラムを起動するのに必要なファイル(SYSTEM.CNF)をCDイメージにまとめる
$ xorrisofs -sysid PLAYSTATION -appid PLAYSTATION -o gamedisc.iso SYSTEM.CNF SLPS_999.99
#これも環境整備の章で紹介した簡易Mode 1→Mode 2変換プログラムをビルド(もちろん初回のみでOK)
$ rustc mkpsximg.rs
#そのツールでMode 2形式に変換
$ ./mkpsximg gamedisc.iso gamedisc.bin
できましたか? では作成したgamedisc.binをエミュレータに読み込ませてみてください。
おっと、NO$PSX では.binファイルに対応する.cueファイルを作らなければいけません。以下のような内容の.cueファイルを作って、それを読み込ませればよいでしょう。
FILE "gamedisk.bin" BINARY
TRACK 01 MODE2/2352
INDEX 01 00:00:00
ちなみに、NO$PSXではどうも「FILE」の後の文字列は実は見ていなくて、読み込ませたcueファイルの拡張子をbinに変えたファイルを見にいくっぽいので、ファイル名を自由に変えたい時は気を付けてください。
きっと作ったプログラムを読み込ませるとエミュレータの画面が茶色になると思います。そうなれば成功です! 四角形描画コマンドの色指定が#664422になっていますので茶色になっています。適当に色を変えてみて遊んでみてください~。
明滅してみる
さて、画面を好きな色にできたわけですが、それ以上のことはしていないため、なにか物足りないものを感じると思います。
それでは今度は画面を明滅させてみましょう。先ほど書いたRustプログラムのmainあたりを以下のように書き換えましょう。
// ……先頭からfreeまでは同じ
// BIOSが提供するEvent系関数のラッパー
// see "BIOS Event Functions"
type Event = u32;
fn open_event(class: u32, spec: u32, func: Option<u32>) -> Event {
let (mode, func_i) = match func {
Some(funcp) => (0x1000, funcp),
None => (0x2000, 0)
};
return unsafe { callb0(0x08, class, spec, mode, func_i) };
}
fn enable_event(ev: Event) -> u32 {
unsafe { callb0(0x0C, ev) }
}
fn wait_event(ev: Event) -> u32 {
unsafe { callb0(0x0A, ev) }
}
// called from start.s
#[no_mangle]
pub fn main() {
let gp0 = 0x1F801810 as *mut u32; // GPU's GP0 register
let gp1 = 0x1F801814 as *mut u32; // GPU's GP1 register
// VSyncイベントを待てるようにする準備
// 第1、第2引数の値については例のページの"BIOS Event Functions"の章の"Event Classes" や "Event Specs" を参考。
// 0xF2000003と0x0002は、それぞれ "VSync" 「割り込み発生」を示している。
let ev_vsync = open_event(0xF2000003, 0x0002, Option::None);
enable_event(ev_vsync);
unsafe {
// Display Mode(解像度の設定): 320x240 60Hz
volatile_store(gp1, 0x08000001);
// VRAMにおける表示位置、描画クリッピング領域、描画コマンドの位置オフセットの設定
// Start of Display Area: (0,0)
volatile_store(gp1, 0x05000000);
// Set Drawing Area top left, bottom right: (0,0)-(320,240)
volatile_store(gp0, (0xE3 << 24) | (0 << 10) | (0 << 0));
volatile_store(gp0, (0xE4 << 24) | (240 << 10) | (320 << 0));
// Set Drawing Offset: (0, 0) ...this is NOT offset of Set Drawing Area top left!
volatile_store(gp0, (0xE5 << 24) | (0 << 11) | (0 << 0));
// 割り込み関連
callb0(0x18); // SetDefaultExitFromException or die on SCPH BIOS?
let i_mask = 0x1F801074 as *mut u32; // Interrupt controller mask register
volatile_store(i_mask, std::intrinsics::volatile_load(i_mask) | 1); // enable vsync interrupt; see http://problemkaputt.de/psx-spx.htm#interrupts
syscall(2); // ExitCriticalSection or "enable interrupt"
}
let mut counter = 0;
loop{
counter = (counter + 4) % (4*60); // (4*60) / 4 = 60 段階, 最大値 4*(60-1) = 240 (255以下でないと誤動作しますよ)
unsafe {
// Render monochrome variable-size opaque rectangle
volatile_store(gp0, (0x60 << 24) | (counter << 16) | (counter << 8) | (counter << 0));
volatile_store(gp0, (0 << 16) | (0 << 0)); // from y=0, x=0
volatile_store(gp0, (240 << 16) | (320 << 0)); // size h=240, w=320
}
// wait for next VSync
wait_event(ev_vsync);
}
}
……書き換えられましたか?
追加要素をさらっと紹介しておくと、
- open_event, enable_event, wait_event 関数が新設されました。これは割り込みとかのイベントをいい感じにゲームプログラマが処理できるように用意されたBIOSの関数で、今回はそれを使って画面更新のタイミングを計ります。
- mainの冒頭でopen_event/enable_eventしています。これはVSyncという、まあ簡単に言ってしまえば画面を1フレーム描くごとに発生する割り込みがあって、これをBIOSが提供するEventというなんかイマドキなしくみで取り扱うようにするものです。VSync割り込みを待ちながら動作することで、手軽に適切な間隔で動作できたり、画面がちらつかなくなったりします。
- 割り込み関連のコードを追加しました…がこれはちょっと説明しづらい…。
- callb0(0x18)を実行しないと、一部のBIOSで初回割り込み時にクラッシュするケースがあった気がします…(が、手元で再現できない…)
- 割り込みコントローラのマスク(I_MASK; 0x1F801074)のうち、VSync割り込みを有効にします。(これは次の「パッドに反応してみる」の初期化関数で行われるので、普通にゲームを作るなら明示的な操作はいらないです。)
- BIOSがPS1プログラムを起動する時全体の割り込みが無効になっているっぽいので、有効にします。これによりVSync割り込みのイベントが反応できるようになります。
- 解像度、描画領域の設定はそのままですが、四角形の描画処理がloop{}の中に入りました。
- 四角形のコマンドは、色の指定部分にcounterを使うようになりました。このcounterは1フレームごとに4足され、60回で0に戻るようになっています。解像度の設定でNTSC(60フレーム/秒)としているので、丁度1秒ごとに画面がピカっとなるようになります。
- loop{}の最後でwait_eventすることで、1フレーム経過するまで待ちます。これにより、いい感じの速度で画面がピカっとなるようになります。
では先程のように、
- rustc
- mipsel-linux-musl-gccでむりやりリンク
- mipsel-linux-musl-objcopyでbinary形式に変換
- mkpsxexeで特定の形式の実行ファイルに変換
- xorrisofsでCDイメージを作成
- mkpsximgでMode 2形式に変換
という手順を経てgamedisc.binを作成し、エミュレータに読み込ませてみましょう。
画面が明滅しましたか? なら成功です!
色の指定部分を変更して白黒ではなく色をつけてみたり、counter変更部分をいじって明滅速度を変えたりして遊んでみましょう~
パッドに反応してみる
画面に動きが得られましたが、見ているだけでは物足りないですよね。そこでパッド操作に反応するようにしてみましょう。
今回も先ほど書いたRustプログラムのmainあたりを以下のように書き換えましょう。
// ……先頭からwait_eventまでは同じ
// see "BIOS Joypad Functions"
fn init_pad(buf1: &mut [u8;0x22], buf2: &mut [u8;0x22]) {
unsafe { callb0(0x12, buf1.as_ptr(), buf1.len(), buf2.as_ptr(), buf2.len()); }
}
fn start_pad() {
unsafe { callb0(0x13); }
}
fn flip_frame() {
let gp0 = 0x1F801810 as *mut u32; // GPU's GP0 register
let gp1 = 0x1F801814 as *mut u32; // GPU's GP1 register
unsafe {
// VRAMにおける表示位置、描画クリッピング領域、描画コマンドの位置オフセットの設定
// 以下4コマンドはdouble-bufferingをするときのヒントになるはず…
// Start of Display Area: (0,0)
volatile_store(gp1, 0x05000000);
// Set Drawing Area top left, bottom right: (0,0)-(320,240)
volatile_store(gp0, (0xE3 << 24) | (0 << 10) | (0 << 0));
volatile_store(gp0, (0xE4 << 24) | (240 << 10) | (320 << 0));
// Set Drawing Offset: (0, 0) ...this is NOT offset of Set Drawing Area top left!
volatile_store(gp0, (0xE5 << 24) | (0 << 11) | (0 << 0));
}
}
fn draw_rect(color: (u8, u8, u8), topleft: (u32, u32), size: (u32, u32)) {
let gp0 = 0x1F801810 as *mut u32; // GPU.GP0
unsafe {
// Render monochrome variable-size opaque rectangle
volatile_store(gp0, (0x60 << 24) | ((color.2 as u32) << 16) | ((color.1 as u32) << 8) | ((color.0 as u32) << 0));
volatile_store(gp0, (topleft.1 << 16) | (topleft.0 << 0)); // from y=topleft.1, x=topleft.0
volatile_store(gp0, (size.1 << 16) | (size.0 << 0)); // size h=size.1, w=size.0
}
}
// called from start.s
#[no_mangle]
pub fn main() {
unsafe { callb0(0x18); } // SetDefaultExitFromException or die on enable interrupt with SCPH BIOS...?
// 解像度の設定
{
let gp1 = 0x1F801814 as *mut u32; // GPU's GP1 register
unsafe {
// Display Mode: 320x240 60Hz
volatile_store(gp1, 0x08000001);
}
}
// VSyncイベントを待てるようにする準備
// 第1、第2引数の値については例のページの"BIOS Event Functions"の章の"Event Classes" や "Event Specs" を参考。
// 0xF2000003と0x0002は、それぞれ "VSync" 「割り込み発生」を示している。
let ev_vsync = open_event(0xF2000003, 0x0002, Option::None);
enable_event(ev_vsync);
// パッドの準備、取得開始
let mut padbuf1 = [0u8; 0x22];
let mut padbuf2 = [0u8; 0x22];
// 以下のパッド関数でVSync割り込みと全体的な割り込みが有効になるっぽいので明示的に割り込み関係を変更する必要はない。
init_pad(&mut padbuf1, &mut padbuf2);
start_pad();
let mut counter = 0;
loop{
flip_frame();
counter = (counter + 4) % (4*60); // (4*60) / 4 = 60 段階, 最大値 4*(60-1) = 240 (255以下でないと誤動作しますよ)
// 画面をさっぱりに(明滅しながら)
draw_rect((counter as u8, counter as u8, counter as u8), (0, 0), (320, 240));
// digital_pad(ID1=0x41)が接続されているならパッドの状態を表示
if padbuf1[0] == 0x00 && padbuf1[1] == 0x41 {
// 各ビットに対応するボタンは "Controllers - Communication Sequence" -> "Standard Controllers" あたりを参照
let buttons = ((padbuf1[3] as u16) << 8) | (padbuf1[2] as u16);
for i in 0..16 {
let color;
if (buttons & (0x8000 >> i)) == 0 {
// 0 = 押されてる: 黄緑
color = (128, 255, 0);
} else {
// 1 = 押されてない: 青
color = (0, 0, 128);
}
draw_rect(color, (i * 10 + 5, 5), (8, 8));
}
}
// wait for next VSync
wait_event(ev_vsync);
}
}
今回も追加要素を紹介すると、
- BIOSのパッド系関数を追加しました。
- 「VRAMにおける表示位置、描画クリッピング領域、描画コマンドの位置オフセットの設定」と「四角形の描画」をfnにくくり出しました。
- 「VRAMにおける~」についてはくくり出す必要はないかもしれませんが、これをダブルバッファリング対応にするなど変更の余地を残す意味でくくり出しておきました。
- ちなみに NO$PSX ではメニューの Window → VRAM/GPU Viewer でVRAMの状態が見れます。この際、右に描画命令一覧があるのですが、どうも最後のGP1(0x05)から発行されたGP0系命令が出るらしいので、毎フレームの最初にこのflip_frameを呼ぶとデバッグしやすいです。
- callb0(0x18)をmainの先頭に移動し、それ以外の「割り込み関連」部分は削除しました。(パッドの初期化で同様のことが行われるため不必要になったから。)
- パッドの初期化と取得開始を追加しました。
- BIOSのパッド関連は、パッドの状態を置くためのバッファを指定(init_pad)し、パッド取得開始(start_pad)したら、あとはVSync割り込みごとにBIOSが自動的にパッドの状態を取得し、init_padで指定されたバッファを更新する、という仕組みになっています。
- padbuf1は1P側、padbuf2は2P側の状態をそれぞれ格納しています。
- バッファの内容はコントローラにより違うようです。詳しくは例のページのControllers and Memory CardsのControllersをご覧ください。
- loop{}内でflip_frameを呼び、画面をさっぱりするため全画面を覆う四角形を描いたあと(これは直前のものと同じ)、1P側コントローラにデジタルパッド(Dualshockより前のやつ)が刺さっていれば、そのボタンの状態を画面に表示しています。
- ちなみに今回の2つのエミュレータの互換BIOSでは大丈夫でしたが、BIOSによっては画面上部16pxくらいが欠けることがあります。(今回設定していない画面表示のパラメータが違うため。)
- そのため、画面上部に情報を表示する際はある程度余裕をみてちょっと下に表示など工夫が必要になるかもしれません。もしくは、例のページにあるGPU Display Control Commands (GP1)のGP1(0x06/0x07)をうまく実行すれば、BIOSに影響されずに同一の画面を得られるかもしれません。
さて、これまで通り、
- rustc
- mipsel-linux-musl-gccでむりやりリンク
- mipsel-linux-musl-objcopyでbinary形式に変換
- mkpsxexeで特定の形式の実行ファイルに変換
- xorrisofsでCDイメージを作成
- mkpsximgでMode 2形式に変換
という手順を経てgamedisc.binを作成し、エミュレータに読み込ませてみましょう。
画面の左上に四角が16個表示され、パッドのボタンを押してみると反応しましたか? なら成功です!
ここまでできたら、四角形を十字ボタンで移動できるようにしてみたり、簡単なアクションやシューティングくらいは作れることでしょう~
今後の展望
冒頭で書いた通り、この記事では本当に少しのことしか触れていません。
でも、これまでに作った内容をどんどん応用すれば、さらに以下のようないろいろなことができるはずです!
- ダブルバッファリング(ちらつき防止) (ヒント: flip_frameのGP1/GP0で、描画完了・表示する画面と、これから描く・描き途中の画面をわけて指定。毎フレームごとに切り替える。)
- CD上のファイルを読む (ヒント: 例のページのBIOS File Functionsで"cdrom:\XXX"のようなファイルを開いて読む)
- メモリーカードの読み書き (ヒント: 例のページのBIOS Memory Card FunctionsにあるInitCard/StartCard/_bu_initを呼んで初期化したあと、BIOS File Functionsで"bu00:XXX"のようなファイルを開いて読み書きする。いちおうinit_pad→init_card(true)→start_pad→start_card→_bu_initでパッドもメモリーカードも使えるっぽい。)
- テクスチャつき三角形・四角形の描画、半透明処理 (ヒント: Render Polygon/Rectangle……に加えて予めVRAMへのテクスチャの転送が必要)
- SPUを使って発声 (ヒント: SPU RAMへの音声データをDMA等で転送して、SPU Voiceレジスタを設定してKey ON)
- CD-ROM XAなBGMの再生 (ヒント: CD-ROMコントローラにBIOSを介せずコマンド発行……ただしCDイメージの作り方を改めないと難しいと思われる)
- GTEを使って3D座標変換 (ヒント: ??? おそらくコプロセッサ命令等の遅延を考慮したコプロセッサ対応アセンブラ必須と思われる)
PS1側だけでなく、Rust側もネタがまだありそうです。
- Rust側でmainより前に走るコードを含めPSX BIOSをひとつのtargetとしてきちんと対応 (つらい)
- デバッガ対応 (ステートセーブが手っ取り早く使えるが、エミュレータごとに形式が違う…)
- Panic時の動作 (panic_hookを悪用すれば画面に情報を出すことも可能)
つらい話
Rustについて
むりやり、というのは、なぜパッチが当たったRustを使ったのか、というところにあります。
なぜなら、Rust 1.12.0の時点で、付属のLLVMがMIPS-I (PS1のCPUの命令セット) のコード生成に非対応だったからです!!
- -C target-cpu=mips1 とすると、本来なら"Code generation for MIPS-I is not implemented"と言われてビルドできない
- 当初そんなにMIPS-Iとその後のMIPS-II(これはサポートされている)で違いはないだろう、既存コードで十分MIPS-Iのコードが生成できるだろうと、src/llvmのlib/Target/Mips/MipsSubtarget.cppにある「MIPS-Iが指定されたらエラー終了」のコードを削ってみた
- その状態で作ったrustcでビルドしたPS1プログラムをエミュレータで実行してみると、物によってはクラッシュ(エミュレータが落ちたり(!!)、PS1プログラムが暴走したり)した
- よくよく調べてみると、MIPS-IではLoad delayというのがあり、それはメモリからロードする命令(lw/lh/lhu/lb/lbu)で指定された格納先レジスタは、その直後では内容が変化しておらず、その次(つまりロード命令から2つ後)でようやく内容が変化している、というものだった。例のページのCPU Load/Store Opcodesにも"Caution - Load Delay"として載っている。
- これは困った……
- なんとなくlib/Target/Mips/MipsInstrInfo.tdのLoadMemoryクラスにlet hasDelaySlot=1;を書いたらロード命令のうしろにnopを入れてくれたりした
- ……が、本来はジャンプ系の命令に使う属性であり、微妙にバグるので、しかたなくPS1向けRustコードをビルドするときは -C llvm-args="-disable-mips-delay-filler=true" をつけて常にhasDelaySlotな命令の後ろにnopが来るようにした。
- ついでに除算を含む式を書いたことにより除算命令が生成されるとき、0除算を検出するコードが付加され、それにMIPS-Iには存在しないteq命令が含まれるので、上記のついでに -C llvm-args="-mno-check-zero-division" をつけることで0除算検出コードを付加しないようにした。(ひどい!)
また、本来こういうfreestanding系のRustコードを書く際はno_stdを指定するのが常だと思いますが、そうするとメモリ確保する系がことごとく使えなくなり、Vecすら使えないすごく悲しいRustになってしまうので、それらを使えるようにするために、no_stdは指定せずにstart.sでpthread系をだましていたり(これはpanicの対応だったかも?)、malloc/realloc/freeをBIOS経由で呼ぶようにしているのです。そこ、努力の方向間違ってるとか言わない。
エミュレータ
上にもちらっと書いたのですが、Load delayを考慮しない(ロード後すぐにそのレジスタを使用したりする)コードを NO$PSX と エミュってしまうま で実行したところ、 NO$PSX では普通に動いて、 エミュってしまうま ではクラッシュするようなことがありました。少し挙動を調べてみたところ、NO$PSXは(NO$PSXの作者が書いている例のページに書いてあるのに!) Load delayをエミュレートしておらず、 エミュってしまうま ではエミュレートしているようでした。
ほかにも互換BIOSで微妙に挙動が違ったり、エミュレーション部分でも微妙に挙動が違ったりして、エミュレータによって動いたり動かなかったりということがわりと普通にありそうです。
こういうのは実機の挙動が正義となりますが、そういえばまだ実機で自作コードを動かしたことがないんですよね。はたしてどうなることやら……。
こわい!
おわり
明日の担当は cactaceae さんで、何か書くそうです。楽しみですね!
おまけ
市販の据え置きゲーム機向けの自作のゲームなどのプログラムをhomebrewと言うんですが、homebrewタグがどう見てもmacOS向けパッケージマネージャしか指してないですね。本当にありがとうございました。(まぎらわしくなるのでタグをつけなかった。)