1. 1. はじめに — NES アセンブリの読みにくさから始まった
NES のプログラムを書くとき、最初にぶつかる壁はアセンブリの読みにくさです。
ca65 や NESASM を直接書いていると、少し複雑なことをしようとするだけでラベルと分岐が増え始めます1。たとえばコントローラ入力を読んで、左右に動かして、画面端で止めるだけで、こういうコードになります。
lda gamepad
and #PAD_LEFT
beq :+
lda cursor_x
beq :+
dec cursor_x
:
lda gamepad
and #PAD_RIGHT
beq :+
lda cursor_x
cmp #31
beq :+
inc cursor_x
:Code language: CSS (css)
何をしているかは読めますが、パターンが繰り返されて目が滑ります。敵の当たり判定、スプライトの更新、スコアの加算が加わると、どのラベルがどこへ飛ぶのかを追うだけで消耗します。
「もう少し読みやすく書けないか」と考えていたとき、nbasic というものを知りました。Bob Rost が NES 向けに作った高級言語で、BASIC 風の制御構造を持ちながら、静的メモリ配置と 1 バイト中心の設計で 6502 に素直に対応しています2。アイデアとして面白いのですが、BASIC 的な goto と gosub 中心の構造は、アセンブリとあまり変わらない読み心地でした。
2. 2. Common Lisp との出会いと気づき
しばらくして Common Lisp を勉強し始めました。
最初に驚いたのは、S 式そのものの性質です。Common Lisp では (+ 1 2) を評価すると 3 になりますが、'(+ 1 2) のようにクォートすると評価を止めてリストとして扱えます。S 式は「評価するかしないかを選べる木構造」なのです3。
そこで気づきました。6502 アセンブリの命令列も、前置記法で書くと S 式に近いかたちになります。
LDA #10 → (lda 10)
STA $0200 → (setf (@ #x0200) a)
BEQ label → (branch-if zero label)Code language: PHP (php)
評価しなければ、S 式はただのデータです。これをアセンブラへの入力として扱えば、Lisp 風に書いて 6502 アセンブリを出力するコンパイラが作れます。nbasic が示した「静的メモリ配置とラベルベースの制御」を借りて、表面構文を Common Lisp に寄せればよい。これが nessexp のアイデアの出発点です。
目指すのは実行時に動く Lisp ではなく、S 式で書く 6502 マクロアセンブラです。
3. 3. nessexp とはなにか — 仕様と構文一覧
nessexp は Common Lisp で実装する NES 向けのマクロアセンブラです。ユーザーが書くのは Common Lisp 風の S 式で、コンパイラがそれを 6502 アセンブリへ変換します。
処理は Read、Expand、Lower、Emit の4段階を順に通ります。S 式を読み込み、マクロを展開し、6502 命令列へ変換して、アセンブリテキストを出力します。
構文を分野別に示します。
3.1. 宣言
;; ROM バンクとアドレス
(bank 0)
(org #x8000)
;; 定数
(defconst ppu-ctrl #x2000)
(defconst pad-a #x80)
;; 通常 RAM 変数(アドレス指定)
(defvar player-x #x0002)
;; ゼロページ変数(自動配置)
(defzp nmi-ready 1)
(defzp frame-counter 1)
;; 配列(長さ指定)
(defarray oam #x0200 256)
(defarray enemy-x #x0010 6)
;; ROM 上のバイト列
(data palette
#x0f #x21 #x11 #x01
#x0f #x16 #x27 #x38)
;; 文字コード表(後述)
(defcharmap default-font
(#\A #x41)
(#\B #x42)
(#\space #x00))
;; ラベル(コードの区切りと飛び先)
(label reset
...)Code language: Lisp (lisp)
defzp はゼロページへの配置を指示します4。data で定義したバイト列は ROM 上に配置されます5。branch-if が飛べる距離には制限があります6。
3.2. 代入と算術
;; 代入
(setf player-x 10)
(setf (sprite-y 0) player-y)
(setf (aref enemy-x i) 0)
(setf (offset oam 4) #x20)
(setf (@ #x0200 x) a)
;; インクリメント・デクリメント
(incf player-x)
(decf player-x)
(incf player-x 2)
;; ビット演算
(logand pad1-current pad-left)
(logior pad-left pad-right)
(logxor value #xff)
(ash value -1) ; 右シフト(定数のみ)Code language: Lisp (lisp)
3.3. 制御構文
;; 条件分岐
(if (= player-x 0)
(setf player-x 0)
(decf player-x))
(when (> player-x 0)
(decf player-x))
(unless (= lives 0)
(decf lives))
(cond
((= scene 0) (jsr title-scene))
((= scene 1) (jsr game-scene))
(t (jsr game-over-scene)))
;; ループ
(loop forever
(jsr main-loop))
(loop for i below 64 do
(setf (sprite-y i) #xfe))
(loop for i from 0 below 64 by 4 do
(setf (aref oam i) #xfe))
(loop repeat 256 do
(setf (@ #x0200 x) 0))
(loop while (> count 0) do
(decf count))
;; ジャンプ
(goto label)
(jsr label)
(rts)
(rti)Code language: Lisp (lisp)
3.4. 条件式(condition 型)
if、when、unless、cond、loop while の引数に渡せる型です。
(= x y)
(/= x y)
(< x y)
(> x y)
(<= x y)
(>= x y)
(not condition)
(and condition1 condition2)
(or condition1 condition2)Code language: Lisp (lisp)
3.5. フラグ分岐(flag-condition 型)
branch-if 専用の型です。直前の命令が更新した CPU フラグを見ます7。if や when には渡せません。
(branch-if zero label)
(branch-if not-zero label)
(branch-if carry-set label)
(branch-if carry-clear label)
(branch-if plus label)
(branch-if minus label)Code language: Lisp (lisp)
3.6. place の種類
setf の第1引数に渡せる「書き込み可能な場所」です。
player-x ; シンボル(変数)
(offset enemy-x 2) ; 定数オフセット
(aref enemy-x i) ; 可変添字
(@ #x0200 x) ; 生インデックス参照
(sprite-y 0) ; OAM 専用 place
(sprite-tile 0)
(sprite-attr 0)
(sprite-x 0)Code language: Lisp (lisp)
3.7. PPU 操作
;; PPU アドレスを設定して書く
(set-ppu-addr (nt-addr nametable0 cursor-x cursor-y))
(setf ppu-data #x24)
;; 1タイルまとめて書く
(ppu-write-tile nametable0 cursor-x cursor-y #x24)Code language: Lisp (lisp)
PPU への VRAM 書き込みは $2006 への2回書きが必要です8。nt-addr はネームテーブルのタイル座標から PPU アドレスを計算します9。
3.8. OAM 操作
;; フレーム先頭でリセット
(begin-frame-sprites)
;; 1枚出力(next-sprite を自動消費)
(emit-sprite player-y #x20 #x00 player-x)
;; 複数枚まとめて設定(スロット番号指定)
(set-sprite 0 player-y #x20 #x00 player-x)
;; 未使用スロットを隠す
(hide-rest-sprites)Code language: Lisp (lisp)
OAM は PPU 内部の 256 バイトのメモリで、最大 64 枚のスプライトを管理します10。OAM の DMA 転送は 513〜514 サイクルを消費します11。
3.9. レジスタ操作
;; 割り込みハンドラ内でのレジスタ退避
(push a)
(push x)
(push y)
(pop y)
(pop x)
(pop a)Code language: Lisp (lisp)
NMI ハンドラでは A、X、Y レジスタを必ず退避・復帰させます12。6502 のスタックは $0100〜$01FF の固定領域を使います13。
3.10. マクロ定義
(defmacro when-held (bits mask &body body)
(let ((skip (gensym 'skip)))
`(progn
(setf a ,bits)
(and ,mask)
(branch-if zero ,skip)
,@body
(label ,skip))))Code language: Lisp (lisp)
3.11. 文字コードと文字列
;; 文字コード表の定義
(defcharmap default-font
(#\A #x41)
(#\B #x42)
(#\space #x00))
;; data 内で使う
(data title-text
(string default-font "HELLO")
#x00)Code language: Lisp (lisp)
3.12. 生アセンブリ
;; 複数行
(asm
"lda #$20"
"sta $2006"
"sta $2006")
;; 1行
(asmline "nop")Code language: Lisp (lisp)
4. 4. Common Lisp の名前を借りる、ただし意味は限定する
nessexp は Common Lisp の名前を積極的に借りていますが、すべてを借りるわけではありません。どこまで借りるかを最初に決めることが、設計の中で最も議論した部分でした。
4.1. 採用した名前
defvar、defconstant、setf、incf、decf、if、when、unless、cond、progn、let、loop、logand、logior、logxor、ash、defmacro は Common Lisp の名前をそのまま使います。見た目と直感が一致するからです。
ただし意味は限定しています。たとえば setf は Common Lisp では place への汎用代入ですが14、nessexp では place の種類を symbol、offset、aref、@、sprite-* に絞ります。
4.2. 採用しなかった名前
defun、lambda、funcall、apply、eval は使いません。
なかでも defun の扱いは慎重に決めました。Common Lisp でこの名前を見ると、引数を受け取って値を返す関数を期待するのが自然です。しかし nessexp には本物の関数呼び出しがありません。サブルーチンは label で定義して jsr で呼ぶだけなので、defun という名前はユーザーに誤った期待を与えます15。
4.3. 変数名の慣習も不採用
Common Lisp では *player-x* のように前後を * で囲む特別変数の慣習と、+ppu-ctrl+ のような定数慣習があります。nessexp ではどちらも採用しませんでした。
nessexp には実行時の動的束縛がなく、変数は全部グローバルです。* で区別する意味がない上に、NES アセンブリに慣れたユーザーには player-x の方が素直に読めます。種別は名前の見た目ではなく定義フォームで表すので、defvar で宣言されていれば変数、defconst なら定数、それで十分です。
5. 5. 型で守る設計 — condition と flag-condition の分離
nessexp の設計で特徴的な判断のひとつが、式の型を区別することです。
6502 プログラムでは、条件分岐に2種類の書き方があります。ひとつは高級な比較で、player-x が 0 より大きいかを調べてから decf する書き方です。もうひとつは CPU フラグ直接参照で、lsr 命令を実行した直後に Carry フラグが立っているかを見て分岐する書き方です16。
これを同じ構文で表すと、コンパイラが何をすればよいか曖昧になります。nessexp ではこの2つを別の型として分けています。
condition は if、when、unless、cond、loop while が受け取る型です。値を比較して真偽を返す式で、=、<、>、and、or、not が該当します。コンパイラはこれを受け取ると、比較命令を生成してからフラグ分岐へ落とします。
flag-condition は branch-if だけが受け取る型です。直前の命令が更新した CPU フラグをそのまま見るもので、zero、carry-clear、plus などが該当します。コンパイラはこれを受け取ると、単一の分岐命令だけを出力します。
この分離により、(branch-if (> x 0) label) のような型違いをコンパイル時に弾けます。既存の NES アセンブリを移植するときは、フラグ依存のコードを branch-if でほぼそのまま書けます。
全体の型は次のように整理しています。
| 型 | 意味 | 使う場所 |
|---|---|---|
value | 8ビット値 | 算術、代入の右辺 |
place | 書き込み可能な場所 | setf の第1引数 |
condition | 比較結果 | if や when の条件 |
flag-condition | CPUフラグ | branch-if の第1引数 |
ppu-address | PPUアドレス | set-ppu-addr の引数 |
statement | 文 | トップレベルや progn の中 |
ppu-address を独立させているのは、$2006 への書き込みが 16 ビット値を上位から下位の順に2回書く特殊手順だからです。普通の setf と混ぜると仕様が濁ります。
6. 6. place の4種類
setf の第1引数に渡せる place は4種類あります。それぞれ 6502 のアドレッシングモードに対応しています17。
6.1. シンボル
(setf player-x 10)Code language: Lisp (lisp)
defvar や defzp で宣言されたシンボルが対象です。コンパイラはそのシンボルのアドレスへの直接書き込み命令を生成します。
6.2. offset — 定数オフセット
(setf (offset enemy-x 2) 100)Code language: Lisp (lisp)
配列の先頭から固定オフセットの場所です。第2引数はコンパイル時定数である必要があります。展開時に enemy-x + 2 のアドレスへ解決されるので、実行時の計算が不要です。パレットや固定インデックスのテーブル参照に向いています。
6.3. aref — 可変添字
(setf (aref enemy-x i) 0)Code language: Lisp (lisp)
実行時の値を添字として使う形です。6502 の indexed アドレッシングに落ちます18。i は変数でよく、ループ内で毎回変わる場合に使います。offset と aref を分けているのは、定数か可変かで生成コードが大きく違うからです。
6.4. @ — 生インデックス参照
(setf (@ #x0200 x) a)Code language: Lisp (lisp)
絶対アドレスに X または Y レジスタをそのまま加算する形です。6502 の STA $0200,X に直接対応します。RAM クリアや OAM 操作の内部ルーチンなど、既存のアセンブリコードをほぼそのまま移植したい場面で便利です。
6.5. OAM 専用 place
(setf (sprite-y 0) player-y)
(setf (sprite-tile 0) #x20)
(setf (sprite-attr 0) #x00)
(setf (sprite-x 0) player-x)Code language: Lisp (lisp)
OAM は 1 スプライトあたり 4 バイトで、Y、tile、attr、X の順に並んでいます19。sprite-y などはスプライト番号から OAM バッファ上の正しいオフセットを計算する place です。引数は定数でも変数でも構いません。
7. 7. マクロ層と gensym
nessexp のマクロは、頻出パターンをまとめる薄い変換層です。評価器は存在しないので、マクロは S 式を別の S 式へ書き換えるだけです。
7.1. gensym が必要な理由
when や cond を展開すると、分岐の飛び先ラベルが必要になります。このラベル名がユーザーの書いたラベルと衝突しないように gensym を使います。
たとえば when-held はこう展開されます。
(defmacro when-held (bits mask &body body)
(let ((skip (gensym 'skip)))
`(progn
(setf a ,bits)
(and ,mask)
(branch-if zero ,skip)
,@body
(label ,skip))))Code language: Lisp (lisp)
展開後の skip は毎回一意な名前になります。たとえば skip-0042 のような内部ラベルに変換され、出力アセンブリでは __skip_0042: として現れます。ユーザーが skip というラベルを書いていても衝突しません。
7.2. gensym の仕様
nessexp の gensym は Common Lisp のそれとほぼ同じですが、用途をマクロ展開専用の内部ラベル生成に限定しています。返り値はユーザーのシンボル表には登録されず、展開後の中間表現の中だけで使われます20。
(gensym) ; → #:g-0001 のような内部シンボル
(gensym 'skip) ; → #:skip-0002Code language: Lisp (lisp)
7.3. 標準マクロの例
組み込みマクロとして、よく使うパターンをあらかじめ用意します。
;; ボタン押下中の判定
(defmacro when-held (bits mask &body body) ...)
;; ボタンを押した瞬間の判定
(defmacro when-pressed (bits mask &body body) ...)
;; コントローラストローブ
(defmacro strobe-controller (port) ...)
;; スプライト4バイトまとめ設定
(defmacro set-sprite (i y tile attr x) ...)
;; next-sprite を消費して1枚出力
(defmacro emit-sprite (y tile attr x) ...)Code language: Lisp (lisp)
strobe-controller は NES コントローラの読み取り開始手順を抽象化します21。
8. 8. NES の文字コード問題と defcharmap
NES のゲームで文字を表示するとき、ASCII コードとタイル番号が一致する保証はありません。パターンテーブル上のタイル配置はゲームごとに自由なので、’A’ が ASCII の #x41 ではなく #x00 から始まる独自フォントになっているのが普通です22。
文字列リテラルをそのまま data に書けない理由がここにあります。文字からタイル番号への変換表が必要です。
8.1. defcharmap
defcharmap はその変換表を定義する構文です。
(defcharmap default-font
(#\A #x00)
(#\B #x01)
(#\C #x02)
...
(#\space #x24)
(#\. #x25))Code language: Lisp (lisp)
各エントリは文字とタイル番号の対です。Common Lisp の文字リテラル記法 #\A をそのまま使えます。
8.2. data 内での使い方
変換表を定義したあと、data の中で string フォームを使って文字列をバイト列に変換します。
(data title-text
(string default-font "PRESS START")
#x00)Code language: Lisp (lisp)
string フォームは展開時に defcharmap の表を引いて、文字列を対応するバイト列へ変換します。変換できない文字が含まれていればコンパイルエラーになります。
8.3. リーダーマクロではなく展開時変換にした理由
変換をリーダーマクロで行うことも考えましたが、採用しませんでした。defcharmap はコンパイル時に定義されるもので、S 式を読み込む時点ではまだ確定していない可能性があります23。読み込み順序の問題が出るため、string フォームは通常のマクロと同じく展開フェーズで処理します。
9. 9. 実装の構造
nessexp は Common Lisp で実装します。処理は4段階に分かれます。
Read では、Common Lisp の標準リーダーで S 式を読みます。#x や #b はそのまま Common Lisp のリテラルとして読めるので、パーサを別途用意する必要がありません。数値リテラルは #x2000、#b10101010、255 の3種類です24。
Expand では、マクロを展開します。defmacro で定義されたユーザーマクロと、when、unless、cond、loop などの組み込みマクロを、より低級な S 式へ書き換えます。gensym によるラベル生成もここで走ります。型チェックもこの段階で行い、branch-if の第1引数が flag-condition でなければエラー、setf の第1引数が place でなければエラー、というように引数型を検査します。
Lower では、展開済みの S 式を 6502 の抽象命令列へ変換します。たとえば (when (> x 0) (decf x)) は、比較命令、フラグ分岐、デクリメント命令の列へ落ちます。ラベル解決、分岐距離の計算、ゼロページ利用の判定もここで行います。近距離なら BEQ や BNE、遠距離なら逆条件に JMP を組み合わせる最適化もここに入ります25。
Emit では、抽象命令列を ca65 向けのアセンブリテキストへ出力します。シンボル名のハイフンをアンダースコアへ変換するなど、アセンブラの記法に合わせた整形を行います。
Common Lisp で実装する利点は、S 式のパーサが不要な点です。マクロ展開系も macroexpand を参考にして実装しやすく、シンボル表の管理にはハッシュテーブルをそのまま使えます。
10. 10. 今後の展望
nessexp の設計はまだ途中です。実際のゲームコードを少しずつ移植しながら、必要な構文を確認しています。
次に必要な構文として見えているのは、当たり判定用の bbox-hit-p マクロ、アニメーションフレーム管理の構文、ROM バンク切り替え対応、defcharmap を使った文字列描画ルーチンの整備あたりです26。
書き味は Common Lisp、実体はアセンブリという立ち位置を保ちながら、実際のゲームが書けるだけの構文を少しずつ固めていきます。
- ca65 は cc65 プロジェクトが開発するオープンソースの 6502 アセンブラで、NES 開発でも広く使われています。NESASM は NES/PC-Engine 向けのアセンブラとして長く使われてきたツールです。 – GitHub – ClusterM/nesasm
- nbasic は Bob Rost がカーネギーメロン大学の NES ゲーム開発講座のために作成した言語で、当初は自身のホームブルーゲーム「Sack of Flour, Heart of Gold」の開発に使われました。ソースコードは GitHub でも公開されています。 – GitHub – Fordi/nbasic
- Common Lisp のクォートはシングルクォート
'で書くほか、(quote ...)と書いても同じ意味になります。クォートされた S 式はコンパイル時にも実行時にも評価されず、そのままのリスト構造として扱われます。これは Lisp がコードとデータを同じ構造で表現するという「コード・イズ・データ」の思想の核心です。 - 6502 のゼロページは $0000〜$00FF の 256 バイトの領域で、通常の絶対アドレスより 1 バイト短い命令で参照できます。ゼロページへのロードは 2 バイト・3 サイクルなのに対し、絶対アドレスへのロードは 3 バイト・4 サイクルかかります。NES では限られたゼロページ領域を、最も頻繁にアクセスするポインタや作業変数に割り当てるのが定石です。 – Zero page – Wikipedia
- nbasic のマニュアルでは、
dataで定義したバイト列はラベルの直後に置くのが一般的とされています。ラベル名を配列名として参照でき、パレットデータやテキストテーブルの格納に使われます。 – nbasic Reference Manual - 6502 の条件分岐命令はすべて相対アドレッシングを使い、分岐先はプログラムカウンタから -128〜+127 バイトの範囲に限られます。この範囲を超える場合は、逆条件の分岐命令と
JMPを組み合わせて対応するのが一般的です。 – Instruction reference – NESdev Wiki - 6502 のプロセッサステータスレジスタには N(Negative)、V(Overflow)、Z(Zero)、C(Carry)などのフラグが含まれます。条件分岐命令はこれらのフラグを直接参照します。たとえば
BEQは Z フラグが立っていれば分岐し、BCCは C フラグがクリアなら分岐します。 – CPU addressing modes – NESdev Wiki - CPU と PPU は別バスで動作しており、CPU は PPU のメモリに直接アクセスできません。VRAM へ書き込むには、まず
$2006(PPUADDR)に上位バイト・下位バイトの順で 16 ビットアドレスを2回書き込み、その後$2007(PPUDATA)にデータを書きます。$2007への書き込みのたびにアドレスが自動でインクリメントされるため、連続した領域の書き込みはアドレス設定を1回で済ませられます。 – PPU registers – NESdev Wiki - NES のネームテーブルは通常
$2000から始まり、1画面ぶんは 32×30 タイルです。タイル座標 (x, y) に対応するネームテーブルアドレスは$2000 + y * 32 + xで求められます。NES 内部には 2 KiB の VRAM があり、通常2枚のネームテーブルを保持します。 – PPU nametables – NESdev Wiki - OAM(Object Attribute Memory)は PPU 内部にある動的 RAM で、64 スプライト分のデータを 4 バイトずつ格納します。各エントリは Y 座標、タイル番号、属性、X 座標の順です。なお Y 座標は実際の表示位置より 1 ライン引いた値を書く必要があります。毎フレーム vblank 中に
$4014へページ番号を書くことで、CPU RAM 上のバッファから OAM へ 256 バイトを DMA 転送します。 – PPU OAM – NESdev Wiki $4014への書き込みでトリガーされる OAM DMA は、CPU を一時停止させて 256 バイトを PPU OAM へ転送します。転送にかかるサイクル数は 513 または 514 で、CPU の位相によって変わります。vblank 中に行うのが原則で、vblank 外での転送は OAM データの decay を引き起こすリスクがあります。 – DMA – NESdev Wiki- NMI(Non-Maskable Interrupt)は vblank 開始時に発生する割り込みです。この時点で A、X、Y レジスタはメインループの処理途中の値を持っているため、NMI ハンドラの先頭で
PHA/TXA/PHA/TYA/PHAとしてスタックに退避し、終了前に逆順で復帰するのが必須です。nbasic のマニュアルでもpush/popの典型的な使い方として割り込みハンドラでの例が示されています。 – nbasic Reference Manual - 6502 のスタックポインタは 8 ビットで、$0100〜$01FF のページ 1 に固定されています。
JSR/RTS/PHA/PLAなどがこの領域を使うため、スタックオーバーフローを起こすとgosubの戻りアドレスが壊れてプログラムがクラッシュします。nbasic では nbasic_stack としてこの領域を予約しており、ユーザーが誤って上書きしないよう注意を促しています。 – CPU memory map – NESdev Wiki - Common Lisp の
setfはジェネリックな代入マクロで、(setf (car list) val)や(setf (aref array i) val)のように、任意の「場所(place)」への代入を統一した構文で書けます。place の概念は Common Lisp の仕様(ANSI CL)で厳密に定義されており、ユーザー定義の place もdefine-setf-expanderで追加できます。 - 6502 には本物のスタックフレームや引数渡しの仕組みがありません。
JSRはリターンアドレスをスタックに積むだけで、引数は通常ゼロページの作業変数やレジスタ経由で渡します。nbasic のマニュアルでも「関数パラメータの受け渡しはなく、グローバル変数と配列がその代わりになる」と明示されています。 – nbasic Reference Manual - 6502 の
LSR(Logical Shift Right)命令はビットを右に 1 つシフトし、はみ出したビットを Carry フラグに格納します。コントローラの読み取りルーチンではLSR Aで D0 ビットを Carry に出し、ROLで結果バイトへ積み込むパターンがよく使われます。 – Controller reading code – NESdev Wiki - 6502 は絶対アドレッシング、ゼロページアドレッシング、インデックス付き絶対アドレッシング、インデックス付きゼロページアドレッシングなど複数のアドレッシングモードを持ちます。どのモードが使われるかによって命令サイズとサイクル数が変わるため、コンパイラがモードを自動選択できることには実用上の意義があります。 – 6502 Addressing Modes
- 6502 の indexed アドレッシングでは、ベースアドレスに X または Y レジスタの値を加算してアクセス先を決めます。ページ境界(256 バイト境界)をまたぐ場合は追加サイクルが発生します(store 命令では常に追加サイクル)。nbasic のマニュアルでは X または Y レジスタを添字として直接使うとこのペナルティを回避しやすいと説明されています。 – CPU addressing modes – NESdev Wiki
- OAM の各スプライトエントリは Y 座標・タイル番号・属性・X 座標の 4 バイト構成です。属性バイトにはパレット番号(下位2ビット)、優先度(ビット5)、水平反転(ビット6)、垂直反転(ビット7)が含まれます。また Y 座標は実際の表示 Y 位置より 1 引いた値を格納する必要があります。 – PPU OAM – NESdev Wiki
- Common Lisp の
gensymは呼び出されるたびに#:G123のようなユニークなシンボルを返します。このシンボルはどのパッケージにも属さない(uninterned)ため、既存のシンボルとの衝突が起きません。Hygienic macro(衛生的マクロ)とも呼ばれますが、Common Lisp ではgensymを手動で使うのが一般的です。Scheme のsyntax-rulesのような自動的な衛生性はありません。 - NES の標準コントローラは 4021 という 8 ビットのパラレル・シリアル変換シフトレジスタを内蔵しています。$4016 に 1 を書くとシフトレジスタにボタン状態がラッチされ、0 を書くとシリアル読み出しモードになります。その後 $4016 から 8 回読み出すと、A・B・Select・Start・Up・Down・Left・Right の順でボタン状態が 1 ビットずつ得られます。 – Standard controller – NESdev Wiki
- NES のパターンテーブルは $0000〜$0FFF(テーブル0)と $1000〜$1FFF(テーブル1)の 2 つがあり、それぞれ 256 枚の 8×8 ピクセルタイルを格納できます。どのタイルに何のグラフィックを入れるかは開発者が自由に決めるため、文字フォントのレイアウトも各ゲームで独自に決まります。ASCII との互換性を保つ仕組みはハードウェアレベルでは存在しません。 – PPU memory map – NESdev Wiki
- Common Lisp のリーダーマクロは
set-macro-characterやset-dispatch-macro-characterで定義できます。文字を読み込んだ瞬間に変換を実行するため、変換表がその時点で確定していないと機能しません。nbasic でもdataに文字列を渡すとアセンブラが ASCII 値に変換する仕様ですが、NES のような独自フォント環境では不十分です。 – nbasic Reference Manual - nbasic では
$始まりで 16 進数、%始まりで 2 進数、通常の数字で 10 進数を表します。nessexp では Common Lisp の標準リテラル記法(#x、#b)をそのまま採用するため、パーサの実装が不要になります。これは Common Lisp で実装する大きな利点のひとつです。 – nbasic Reference Manual - 6502 の条件分岐命令(BEQ、BNE、BCC、BCS 等)はすべて -128〜+127 バイトの範囲にしか飛べません。これを超える場合は逆条件の分岐で JMP を読み飛ばすパターンが一般的です。たとえば
BEQ too_farが範囲外になる場合はBNE skip / JMP too_far / skip:に書き換えます。 – 6502 assembly optimisations – NESdev Wiki - NES のコントローラ入力処理は、ゲームの全機能の起点になります。NESdev Wiki のコントローラ読み取りのページには、リングカウンタ方式を含む複数の実装例が掲載されており、nessexp の
strobe-controllerマクロ設計の参考になります。 – Controller reading code – NESdev Wiki