これは何?
SwiftでGame Boy Advance(GBA)のソフトを開発をするために必要なアレコレについて解説します。
iOSDC Japan 2024でk-koheyさんによる「ゲームボーイアドバンスでSwiftを動かそう」というトークがありました。
このトークではSwiftでGBA開発するための仕組みや方法について解説されていましたが、実際にやってみると自分の知識不足で詰まるところが多かったです。この記事では自分なりにそのHowToをまとめます。
GBA開発するための課題
(ここは読み飛ばして環境構築から読んでもOK)
SwiftでGBA開発をするには以下2つの課題が生じる
- SwiftはGBA向けに基本コンパイルできない
- バイナリサイズを小さくする必要がある
SwiftはGBA向けに基本コンパイルできない
SwiftのコンパイラではバックエンドとしてLLVMを採用しており、LLVMではx86やARMなど様々なアーキテクチャに向けてコンパイルすることができる。
GBAでは「ARMv4t」アーキテクチャを採用しているので、これに向けてコンパイルできればいいが、SwiftのLLVMではこれができない。
そこで、gba-llvm-devkitを使用することで、SwiftをARMv4t向けにコンパイル可能となる。
バイナリサイズを小さくする必要がある
GBAのROMの容量は大きくないので、通常のSwiftを用いるとバイナリサイズが大きくなってしまう可能性がある。(一般的に市販されていたゲームで最大32MBらしい)
Embedded Swiftは組み込みシステム向けに最適化されたSwiftのサブセットであり、通常のSwiftに比べ、バイナリサイズを抑えることができる。これを利用することで解決できる。
環境構築
gba-llvm-devkitとEmbedded Swiftを用いることで、SwiftでGBA開発できることがわかった。
実際にここからSwiftをGBA(ARMv4t)向けにコンパイルし、エミュレータで動作させる。
必要なもの
- Xcode 16
- Embedded SwiftはSwift 6.0(Xcode 16)から利用可能なため
- Xcode 16を使えない場合はSwiftのtoolchainをinstallする(ここから最新のXcodeのUniversalをinstallする)
-
gba-llvm-devkit
- ARMv4t向けにカスタマイズされたLLVMを基盤とするGBAの開発キット
- releaseからgba-llvm-devkit-1-Darwin-arm64.dmgをinstallして任意の場所に配置
- mGBA
- GBAのエミュレータ
$ brew install mgba
- zstd
- 圧縮ツール、gba-llvm-devkitの処理llvm-objcopyで必要になる
$ brew install zstd
Packageと空のSwiftファイルの作成
適当なフォルダを作成し、Swift Packageを作成する。
$ mkdir gbaSample
$ cd gbaSample
$ swift package init
Sources/gbaSample/にGameEntry.swiftを作成
@main
struct GameEntry {
static func main() {
// 一旦コンパイルさせたいので中身は空
}
}
さらにエラー回避用に空のstdoutを作成する。
これはコンパイル時にlibcのputchar.cからstdoutが参照されるが「存在しない」とエラーになってしまうため。
@_cdecl("stdout")
func stdout() {}
SwiftをGBA向けにコンパイル
gba-llvm-devkitとEmbedded Swiftを用いてコンパイルする。
以下に注意
-
path/to/gba-llvm-devkit-1-Darwin-arm64/lib/clang-runtimes/arm-none-eabi/armv4t
としているので、install先にpathを変える - 異なるtoolchainを使用する場合はswiftcのpathも変える(通常はこのままでいいはず)
$ /Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin/swiftc \
-o SampleGame.elf Sources/gbaSample/*.swift \
-wmo -enable-experimental-feature Embedded \
-target armv4t-none-none-eabi \
-Xfrontend -internalize-at-link \
-lto=llvm-thin \
-Xcc -mthumb -Xcc -mfpu=none -Xcc -fno-exceptions -Xcc -fno-rtti \
-Xcc -D_LIBCPP_AVAILABILITY_HAS_NO_VERBOSE_ABORT -Xcc -fshort-enums \
-Xlinker -lcrt0-gba \
-Xclang-linker -T \
-Xclang-linker gba_cart.ld \
-Xclang-linker --sysroot \
-Xclang-linker path/to/gba-llvm-devkit-1-Darwin-arm64/lib/clang-runtimes/arm-none-eabi/armv4t
特にエラーがなければwarningが発生するが、コンパイルに成功する。
warning: overriding the module target triple with armv4t-none-none-eabi [-Woverride-module]
1 warning generated.
さらにSampleGame.elf
が生成される。
Makefileの作成
以下のようにmake fileを定義。
GBA_LLVMのpathは前述したコマンドと同じようにpathを変える。
# 外部ツールやパスの定義
GBA_LLVM ?= path/to/gba-llvm-devkit-1-Darwin-arm64
TOOLCHAIN ?= /Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin
LLVM_BIN = $(GBA_LLVM)/bin
SWIFT_COMPILER = $(TOOLCHAIN)/swiftc
# ゲーム名と出力ファイルの定義
GAME_NAME = SampleGame
ELF_FILE = $(GAME_NAME).elf
GBA_FILE = $(GAME_NAME).gba
# フラグや設定
SWIFT_FLAGS = -wmo -enable-experimental-feature Embedded -target armv4t-none-none-eabi -Xfrontend -internalize-at-link -lto=llvm-thin -Osize
CFLAGS = -mthumb -mfpu=none -fno-exceptions -fno-rtti -D_LIBCPP_AVAILABILITY_HAS_NO_VERBOSE_ABORT -fshort-enums
SYSROOT = $(GBA_LLVM)/lib/clang-runtimes/arm-none-eabi/armv4t
LFLAGS = -lcrt0-gba
LINKER_FLAGS = -T gba_cart.ld --sysroot $(SYSROOT) -fuse-ld=lld
# Swiftファイルのpath
SWIFT_FILES = $(wildcard Sources/gbaSample/*.swift)
# ターゲット定義
# makeでgbaファイルを生成
all: $(GBA_FILE)
# elfファイルを変換してgbaファイルを生成
$(GBA_FILE): $(ELF_FILE)
$(LLVM_BIN)/llvm-objcopy -O binary $^ $@
$(LLVM_BIN)/gbafix $@
# elfファイルを生成
$(ELF_FILE): $(SWIFT_FILES)
$(SWIFT_COMPILER) -o $@ $(SWIFT_FILES) \
$(SWIFT_FLAGS) $(addprefix -Xcc ,$(CFLAGS)) \
$(addprefix -Xlinker ,$(LFLAGS)) \
$(addprefix -Xclang-linker ,$(LINKER_FLAGS))
# make runでgbaファイルを実行
run: $(GBA_FILE)
mGBA $<
# make cleanで生成物をclean
.PHONY: all clean run
clean:
rm -f *.o *.elf *.gba *.bc
makeしてみる。
$ make
すると「開発元が確認できない」というエラーが度々出るので、設定アプリ→プライバシーとセキュリティーから許可をする
makeに成功すると.gbaファイルが生成される
$ make
...
ROM fixed!
エミュレータで実行
runする
$ make run
するとエミュレータが起動する。
何も描画処理を入れてないので真っ白な状態。
これで環境構築が完了!
GBAの描画について
実際に何か表示していく前に、GBAにおける描画の基礎知識をまとめる。
描画モード
描画モードは6種類(0~5)まであり、大きく分けると2つに分類される
Tile Mode
Mode: 0, 1, 2
特徴
- 画面全体を小さなタイル(8×8pxのブロック)に分割し、それぞれのタイルに画像を配置して背景を描画する方法
- スクロール設定などが可能で2Dゲームによく使用される
Bitmap Mode
Mode: 3, 4, 5
特徴
- 画面上の各ピクセルに直接色データを割り当てて描画する方法
- 複雑なグラフィックスを表示するのに有効
Mode3の詳細
VRAM(先頭アドレス:0x06000000)に16bitの色情報を書き込むことで描画される。
例えば0x06000000に赤色(0x7C00)を書き込むと、一番上の左端のピクセルが赤くなり、2byte進んだアドレスに青色(0x001F)を書き込むと、一つ右のピクセルが青くなるといった感じ。
一行目の右端0x060001DEから2byte進むと2行目の左端0x060001E0に移動するのが少しわかりにくい仕様
実際に描画
Bitmap ModeのMode3で実際に描画していく。
前述した通り、VRAMに16bitの色情報を直接書き込んでいる。
今回は画面左上から100px分を赤くする。
// Drawer.swift
final class Drawer {
// VRAMの開始アドレス
private let drawPointer = UnsafeMutablePointer<UInt16>(bitPattern: 0x6000000)!
init() {
// Bitmapモードを指定する
// IO Register領域の0x04000000はdisplay control register(DISPCNT)
let displayControl = UnsafeMutablePointer<UInt16>(bitPattern: 0x4000000)!
// Bimap Modeのモード3を指定
let displayMode3: UInt16 = 0x3
// 背景レイヤー(BG2)をしてい
let enableBackground2: UInt16 = 0x400
// pointeeで0x4000000に直接値(0x403)を書き込む
displayControl.pointee = displayMode3 | enableBackground2
}
/// 描画する
/// - Parameters:
/// - drawStartPosition: 描画開始位置
/// - px: 描画するpx数
func draw(drawStartPosition: Int, px: Int) {
// drawPointerをdrawStartPosition分だけ進めて、新たな描画開始位置を取得する
let targetPointer = drawPointer.advanced(by: drawStartPosition)
let red = rgb555(red: 31, green: 0, blue: 0)
// 赤色を指定したpx分描画する
targetPointer.update(repeating: red, count: px)
}
// RGBの各値(0-31の範囲)を入力としてRGB555形式の16ビット値を返す
func rgb555(red: UInt8, green: UInt8, blue: UInt8) -> UInt16 {
// RGB値が0から31の範囲内に収まるように調整
let clampedRed = min(red, 31)
let clampedGreen = min(green, 31)
let clampedBlue = min(blue, 31)
// RGB555形式に変換
let rgb555Value = UInt16(clampedRed) | (UInt16(clampedGreen) << 5) | (UInt16(clampedBlue) << 10)
return rgb555Value
}
}
上記クラスを定義したら、GameEntry側でも呼び出して、描画の開始地点として0を指定し、100px分描画させる。
// GameEntry.swift
@main
struct GameEntry {
static func main() {
let drawer = Drawer()
drawer.draw(drawStartPosition: 0, px: 100)
}
}
そして実行する。(make runでも差分があればmakeも同時にしてくれる)
$ make run
すると画面左上から100px分赤を表示させることが確認できる。
様々なドットを描画する
こちらのサイトのCで書かれたサンプルをSwiftで書き直してみたので、そちらも記載する。
final class DotDrawer {
let displayControl = UnsafeMutablePointer<UInt16>(bitPattern: 0x4000000)!
let drawPointer = UnsafeMutablePointer<UInt16>(bitPattern: 0x6000000)!
// Vカウントレジスタへのポインタ(垂直同期用)
let vcount = UnsafeMutablePointer<UInt16>(bitPattern: 0x4000006)!
init() {
let MODE_3: UInt16 = 0x3//0x0003
let BG2_ENABLE: UInt16 = 0x400//0x0400
displayControl.pointee = MODE_3 | BG2_ENABLE
}
// 垂直同期を待つ
func waitForVsync() {
while vcount.pointee >= 160 {}
while vcount.pointee < 160 {}
}
func mode3PutPixel(x: Int, y: Int, color: UInt16) {
drawPointer.advanced(by: y * 240 + x).pointee = color
}
func rgb555(red: Int, green: Int, blue: Int) -> UInt16 {
let red = min(max(red, 0), 31)
let green = min(max(green, 0), 31)
let blue = min(max(blue, 0), 31)
let rgb555Value = UInt16(red) | (UInt16(green) << 5) | (UInt16(blue) << 10)
return rgb555Value
}
}
@main
struct GameEntry {
static func main() {
let dotDrawer = DotDrawer()
while true {
// 垂直同期を待つ
dotDrawer.waitForVsync()
// 白いドットを斜めに描画
for i in 0..<20 {
dotDrawer.mode3PutPixel(x: 5 + i, y: 5 + i, color: dotDrawer.rgb555(red: 31, green: 31, blue: 31))
}
// 赤、緑、青、白のドットを横に描画
for i in 0..<32 {
dotDrawer.mode3PutPixel(x: 20 + i * 2, y: 50, color: dotDrawer.rgb555(red: i, green: 0, blue: 0)) // 赤のグラデーション
dotDrawer.mode3PutPixel(x: 20 + i * 2, y: 60, color: dotDrawer.rgb555(red: 0, green: i, blue: 0)) // 緑のグラデーション
dotDrawer.mode3PutPixel(x: 20 + i * 2, y: 70, color: dotDrawer.rgb555(red: 0, green: 0, blue: i)) // 青のグラデーション
dotDrawer.mode3PutPixel(x: 20 + i * 2, y: 80, color: dotDrawer.rgb555(red: i, green: i, blue: i)) // 白のグラデーション
}
}
}
}
描画のタイミング
最初のサンプルではループさせなかったが、通常、frameごとに場面に合った描画を行うためwhile true
を使用して処理をループさせる。そこで気をつけないといけないことが描画のタイミング。
適当なタイミングで描画してしまうと、描画のズレやチラつきが発生してしまう。
GBAではVBlankという期間があり、これはdisplayが現在のframeの描画を完了し、新しいframeの描画を開始するまでの期間のことを指す。つまりこのタイミングで新たな描画処理を行えば、ちらつきなどが発生しなくなる。
以下でそのVBlank期間を判定できる
// VCOUNTレジスタは現在の描画中の水平ラインの番号を保持している
// GBAは縦が160pxなので、0~159が表示領域。160~227がVBlankとなる
let vcount = UnsafeMutablePointer<UInt16>(bitPattern: 0x4000006)!
...
// VBlankを待機
func waitForVsync() {
// 現在がVBlank中(VCOUNT >=160)の場合、VBlankが終了して表示領域に戻るまで待機
while vcount.pointee >= 160 {}
// 表示領域(VCOUNT < 160)に入ってから、再びVBlankに入るまで待機
while vcount.pointee < 160 {}
}
このwaitForVsync
を抜けたタイミングがVBlankなので、このタイミングで描画処理を書けばOK。
static func main() {
...
while true {
waitForVsync()
// 描画処理
}
}
十字キーでキャラクターや背景を動かす
長くなるので解説は省きますが、サンプルとしてリポジトリにまとめています。
↓背景動かしたり(こちらのサイトを参考にさせていただきました。元はCなのでSwiftに書き直してます)
その他色々試してわかったこと
- toncを使ったり、Cに変換した画像を参照するなどしない場合はPackage.swiftに追加の記述をする必要はない
- コンパイル時に
__atomic_load_4
などのエラーが出る場合はこちらのコードを追加すると回避できる - 実機で動かすにはSUPER CARDというのを使うと良さそう(参考)
- makeしてコンパイルも通ってるのに起動すると画面が真っ白の場合は正常に.gbaファイルが上書きされてない?ので一度.gbaファイルを削除すると良い
gritを使用したい場合
こちらの記事を参考にsudo dkp-pacman -S gba-dev
をした際に17のgritを入力する。
一時的に環境変数を定義。
$ export PATH=$PATH:/opt/devkitpro/tools/bin
例えばdog.pngを使用する場合、↓でdog.hとdog.cが生成される。
$ grit dog.png -gB4 -Mw 4 -Mh 4 -ftc
optionについてはこちらにまとまっている
おわりに
今回SwiftでGBA開発に挑戦してみましたが、最初の環境構築のハードルが高いことやGBA自体の知識が必要となるので難易度が高いなと感じました。なので本気にSwiftでGBA開発をするなら、まずはC言語で純粋にGBA開発をして知識を蓄えてからの方が良さそうです。
今回解説したコード含め、試してみたことをいくつかリポジトリにまとめているので参考程度に見てください。
また、Swift関係なくmacOSでGBA開発するための方法についてもまとめたので、もし純粋に「CでGBA開発やるぞ」という時は参考にしてください。