AIドパガキ GBC 版 E2E ハーネス設計
本書は PyBoy による E2E 検証の正典である。ハーネス API、フレーム送りの規約、不変条件、シナリオ一覧、ゴールデン画像の運用が他文書と食い違った場合、本書を正とする。
前提: PyBoy 2.7.0(headless、window="null")。下記の API 記述は実際に動かして help() で確認した実測(スパイク結果 spike/RESULT.md)であり、推測を含まない。 参照: ../spec/aidopagaki-gb-design.md(期待される数値)/ architecture.md(シンボルと予算)
0. この設計が守ろうとしている失敗
NES 版で実際に起きた事故を、GBC 版ではテスト失敗として観測可能にするのが本書の目的である。
| NES で起きた事故 | GBC 版での対策 |
|---|---|
ppu_buf(96 B) 溢れ 3 回(issue #66 / #68 / #35)。症状は画面のグリッチだけで、原因追跡に時間がかかった | hQOverflow / hQHiwater / hQOpCount を HRAM に置き、tick() が毎フレーム検査する。バイト数または op 数の上限を超えた瞬間にどのテストでも落ちる |
| lua テストが ZP アドレスを直書きしており、変数を 1 個増やすたびに全滅した(鉄則 #8) | ハーネスは .sym の名前解決のみ。アドレス直書きを grep で CI 失敗させる |
| Mesen2 が非対話起動できず、lua スイートが「文書化されたハーネス」に留まった | PyBoy は完全に非対話。make e2e が exit code を返す |
APU の正しさを担保できたのは ref_sim.py のビット一致だけだった | apu_shadow + ref_sim_gb.py で同じ体制を再現(§5) |
1. PyBoy 2.7.0 の API(実測、逐語)
from pyboy import PyBoy
pyboy = PyBoy(
"build/aidopagaki.gbc",
window="null", # headless。ウィンドウバックエンド不要
cgb=True, # CGB モードを強制
symbols="build/aidopagaki.sym", # rgblink -n の .sym をそのまま読む
)
# --- フレーム送り ---
pyboy.tick(count=1, render=True, sound=True) -> bool
# count フレーム進める。render=True は「最後のフレームのピクセルを pyboy.screen へ
# 押し出すか」だけを制御する。render=False でも全フレームは完全にエミュレートされる
# ので、フレーム精度のカウンタは render の値に影響されない
# --- シンボル解決(.sym から) ---
pyboy.symbol_lookup(symbol: str) -> (bank: int, addr: int)
# 例: pyboy.symbol_lookup("wFlowState") -> (0, 0xC200)
# サブラベルも解決する: "VBlankHandler.doneJoypad" -> (0, 0x0245)
# --- メモリアクセス(RAM もハードウェアレジスタも同じ dict 風 API) ---
pyboy.memory[addr] -> int # 例: pyboy.memory[0xFF10]
pyboy.memory[addr] = value # 書込みはハードウェアのマスクを受ける
# (NR11 の下位ビットは書いても $FF で読み戻る等、
# 実機の挙動と一致する)
pyboy.memory[bank, addr] -> int # バンク指定の読み
pyboy.memory[bank, addr:addr+10] -> list # バンク指定のスライス読み
# --- ジョイパッド ---
pyboy.button_press("a") # button_release まで押しっぱなし
pyboy.button_release("a")
pyboy.button("a", delay=1) # 押して delay tick 後に自動で離す
# --- 画面キャプチャ ---
pyboy.screen.image -> PIL.Image.Image # RGBA 160x144、.save("x.png") 可
# --- コードフック(指定 PC を CPU が実行したら Python コールバックが発火) ---
pyboy.hook_register(bank, addr, callback, context)
# callback シグネチャ: def cb(context): ...
# bank=None + addr=<シンボル文字列> なら .sym から自動解決する:
pyboy.hook_register(None, "VBlankHandler", cb, None)
# 手で解決して int を渡す形も動作確認済み:
b, a = pyboy.symbol_lookup("VBlankHandler")
pyboy.hook_register(b, a, cb, None)
pyboy.stop(save=False)⚠ pyboy.__version__ は 2.7.0 に存在しない(Cython ビルド)。版の確認は pip show pyboy で行う。
⚠ APU レジスタ $FF10-$FF26 は読み書きできるが、実機どおりのビットマスクがかかる。 $FF13 は常に $FF、$FF14 は bit6 しか読めない。だから apu_shadow(WRAM)が要る(architecture.md §7.8)。
2. 起動待ち(最重要の落とし穴)
⚠ PyBoy は CGB ブート ROM を実際に実行する。skip_bootrom フラグは存在しない。 起動から約 66-67 フレーム(任天堂ロゴとチャイム、約 1.1 秒)は PC が $0000-$0900 付近にあり、カートリッジの $0100 に到達しない。Init クリアループ込みの本リポジトリの実測は 83 フレーム(2026-09-02、PyBoy 2.7.0)である。ROM 側のコードに張ったフックはその間 1 度も発火しない。「N フレーム進めてフックの回数を数える」だけのテストは、N がブート長より小さいと「何も起きていない」と読めてしまう。
対策: WRAM ポーリングで「ゲームコードが始まった」ことを検出する。
def wait_for_boot(pyboy, sym, limit=300):
"""hFrameCount が 0 以外になった時点をゲーム開始とみなす。
limit を超えても動かなければ ROM が壊れている。"""
_, addr = sym["hFrameCount"]
for i in range(limit):
pyboy.tick(1, render=False)
if pyboy.memory[addr] != 0 or pyboy.memory[addr + 1] != 0:
return i
raise RuntimeError("ROM never started: hFrameCount stayed 0 for %d frames" % limit)⚠ 「67 フレーム飛ばす」とハードコードしない。 ブート長は DMG モードや PyBoy のビルドで変わりうる。
⚠ フックを張るデバッグループの初回実行は必ず timeout N python ... で包む。 実機の ei/halt タイミングバグ(ei の直後に halt を置くと IME=0 のまま halt が素通りし、IF がクリアされないまま全速でスピンする)は、Python 側のハングとして現れる。スパイクではこれで数百 MB の出力を吐き、10 分のタイムアウトで殺す羽目になった。
⚠ ROM 側の対策: ei と最初の halt の間に nop を 1 個置く。
ei
nop ; IME=1 を最初の halt より前に保証する
MainLoop:
halt
nop
jp MainLoop3. ハーネス API(tests/harness.py)
class GBHarness:
def __init__(self, rom="build/aidopagaki.gbc",
sym="build/aidopagaki.sym",
shots="tests/shots"):
self.py = PyBoy(rom, window="null", cgb=True, symbols=sym)
self.sym = self._load_sym(sym) # {name: (bank, addr)}
self._register_hooks() # ROM 内部ラベルを .sym から解決
self.boot_frames = wait_for_boot(self.py, self.sym)
self.boot_event_counts = self._event_lengths()
# --- シンボル解決 ---
def addr(self, name) -> int # WRAM/HRAM はバンク無視で addr
def peek(self, name, n=1) -> bytes
def peek8(self, name) -> int
def peek16(self, name) -> int # リトルエンディアン
def poke(self, name, *vals) # 状態注入(ランク閾値テスト等)
def read_hw(self, name) -> int # harness.HW のレジスタを読む
def row_scratch(self, slot, n=32) -> bytes # wRowScratch の slot (0-2)
# --- 時間とボタン ---
def frame(self, n=1) # 素の tick(不変条件を見ない。内部用)
def tick(self, n=1) # ★ 標準の時間送り。§4 の不変条件を毎フレーム検査
def events_since_boot(self, name) -> list # フック履歴からブート中の分を除いた値
def press(self, *btns, hold=2, release=2) # "a","b","start","select","up","down","left","right"
def hold(self, *btns) / def release(self, *btns)
def wait_state(self, value, timeout=900) # wFlowState == value まで tick。超過で TimeoutError
def wait_until(self, pred, timeout=900) # 任意条件
def enter_performance(self) / enter_song_select(self) / enter_entrance(self)
# 製品の導線(タイトル → 選択 → A)で入る
# --- 検証補助 ---
def screenshot(self, seq, state, tag) # shots/{seq:02d}_{state}_{tag}.png
def apu_shadow(self) -> bytes # 23 B、WRAM の apu_shadow から
def wave_shadow(self) -> bytes # 16 B
def oam_shadow(self) -> bytes # 160 B、WRAM $C000 から
def bg_row(self, row, col, n) -> bytes # VRAM bank0 $9800 マップの一部
def bg_attr(self, row, col, n) -> bytes # VRAM bank1 の同位置
def header(self, off, n) -> bytes # ROM bank0 のヘッダ
def oam_per_line_worst(self) -> int # 不変条件と同じ per-line OBJ 数を値で返す
# --- 不変条件(tick が毎フレーム呼ぶ) ---
def assert_queue_ok(self) # hQOverflow == 0 かつ hQHiwater <= 128
# op 上限違反も hQOverflow で検出
def assert_oam_per_line(self, limit=10) # oam_shadow から各スキャンラインの OBJ 数を計算
def assert_no_page_cross(self) # キュー内の全 op が $xx00 をまたがない
def close(self) # pyboy.stop(save=False)⚠ call_routine()(ROM0 のサブルーチンを 1 本だけ呼ぶ入口)は結局 tests/harness.py に実装しなかった。 唯一の想定利用者だった issue #14 の progress_continue_target(CONTINUE の行き先)が、製品の導線からそのまま検証できたためである: enter_song_select() が全曲クリア済みのタイトルで CONTINUE を押し、wait_state(FLOW_SONG_SELECT) が行き先を読む(tests/test_flow.py::test_s3_song_select_opens_on_the_first_charted_song)。ROM の途中に無理やり call を挿す仕組みを持たないほうが、E2E が「実機で起こりうる状態」だけを測るという原則(§1)に忠実である。同じ理由で、新しい純関数を検証したくなった場合もまず製品の導線を探すこと。導線が本当に無い場合に限り、この節に実装ごと追記する。
⚠ bg_row() / bg_attr() は harness.HW["BG_MAP_BASE"] で固定の $9800 面を見る。 BG マップは 1 面だけであり(architecture.md D6 / §6.3)、$9C00 は 1 バイトも使われない。表示面を実行時に解決する必要はない。
⚠ シンボル名は architecture.md §8 の接頭辞規約に従う(HRAM = h、WRAM = w)。hFrameCount / hCurSpeed / hQOverflow / hQHiwater / hQOpCount / wFlowState / wApuShadow / wOamShadow。文書と実装で名前が揺れると、そのまま KeyError になる(architecture.md §10 リスク 0)。
⚠ 原則としてすべてのアドレスは symbol_lookup または .sym の名前解決で得る。 ただし .sym に載らないハードウェアレジスタ(LCDC / LY / KEY1 / VBK / HDMA1-5 / palette I/O など)は、tests/harness.py の名前付き HW 定数表に集約する場合に限って例外として許可する。同表には固定の ROM ヘッダ基底と単一 BG マップ基底も置き、テスト側へ生アドレスを漏らさない。
# CI の静的ゲート(HW 定数表ブロックの行範囲だけを許可する)
bash tools/check_conventions.sh⚠ 例外の実体は harness.py の HW 表だけである。 ROM ヘッダの $0143 / $0147 / $0148 も、ハードウェアレジスタと同じく HW["ROM_HEADER_BASE"] から header() が読む。テスト側に即値を置くこと、または表の行以外で .sym にないアドレスを使うことは禁止する。
4. tick() の不変条件(本設計の心臓)
⚠ すべてのテストの標準の時間送りを tick() にする。frame() を直接呼んでよいのは起動待ちだけ。
def tick(self, n=1):
for _ in range(n):
self._clear_frame_events()
self.frame()
self._assert_frame_hooks() # VBlank / GDMA / LCDC の当該フレーム検査
self.assert_queue_ok()
self.assert_oam_per_line()
self.assert_no_page_cross()| 不変条件 | 検査内容 | 由来 |
|---|---|---|
hQOverflow == 0 | VRAM キューが 1 度も溢れていない | NES で 3 回起きた ppu_buf 事故 |
hQHiwater <= 128 | キュー使用量が容量の半分を超えない(設計値 67 B) | 予算表(architecture.md §4.4)との突き合わせ |
hQOpCount <= Q_MAX_OPS_PER_FRAME | q_reset 以降の受理レコード数を固定費予算内に抑える。超過は hQOverflow でも失敗する | architecture.md §4.4 / §4.5 |
| per-line OBJ ≤ 10 | OAM シャドウ 160 B から各スキャンライン(画面 Y 0-143)の OBJ 数を数える。OAM X ≥ 168 の OBJ も数える(画面には出ないが OAM スキャンの枠を消費するため) | GB のハード制約 |
$xx00 非跨ぎ | キュー内の各 op で (dst & 0xFF) + len <= 0x100 | flush の inc e 最適化の前提(architecture.md §4.3) |
| VBlank / GDMA の LY | VBlankHandler 入口、ISR 終了直前、gdma_row 入口/完了の全フックが 144-153 | issue #3 受入条件 7、S4 |
gdma_bulk | 入口で HL/DE/C/VBK/LCDC、完了時に VBK を記録し、整列・範囲・LCD OFF・VBK 不変(入口/完了で同一、0 または 1)を毎フレーム検査 | issue #3 受入条件 7、S4 |
| LCD OFF 書込み | write_lcdc の A と LY を記録し、bit7=0 の書込みだけ LY≥144 を要求する。ON 側は検査しない | issue #3 受入条件 8、S4 |
| VBlank の回数 | 1 フレームにつき VBlankHandler の入口と ISR 終端がちょうど 1 回。⚠ 例外は「シーン入場の LCD OFF 窓を閉じたフレーム」だけ(下記) | issue #3 受入条件 7、issue #10 |
| LCD OFF 窓の長さ | LCD が消えたまま跨げるフレーム境界は 1 つまで。窓を閉じ忘れた退行(画面が黒いまま進行する)をここで捕まえる | issue #10 |
⚠ シーン入場の LCD OFF 窓(architecture.md §5.2 / §6.4)には VBlank が来ない。 かなフォント窓の入替は gdma_bulk を使うので LCD OFF 中でなければならず、LCD を切っている間は PPU が止まって LY が 144 に達しない。したがって窓を閉じた(rLCDC の bit7 を 1 に戻した)フレームでは VBlankHandler が 1 度も走らない。ハーネスは write_lcdc フックで LCD の有効/無効を追い、そのフレームに限って VBlank 0 回を許す。それ以外のフレームは従来どおり厳密に 1 回であり、加えて「LCD が消えたまま 2 つ以上のフレーム境界を跨がない」を新たに検査する。PyBoy は LCDC の bit7 を落とした瞬間にフレームを閉じる(pyboy/core/lcd.py の set_lcdc)ので、この 1 フレームは必ず現れる。
GBHarness.__init__ は wait_for_boot() より前に、.sym の VBlankHandler、VBlankHandler.isr_done、gdma_row、gdma_row.done、gdma_bulk、gdma_bulk.done、write_lcdc へ hook_register を登録する。履歴はブート中も累積し、boot_event_counts を境に events_since_boot() で分離する。当該フレームの一時バッファが空、または期待したフックが発火しない場合も tick() が assert で失敗するため、検査対象コードを一度も実行していないテストにはならない。
per-line 計数の実装:
⚠ OAM の Y は画面 Y + 16 である(../spec/aidopagaki-gb-design.md §2.2)。OAM Y をそのままスキャンライン番号として数えると 16 ライン分ずれ、画面上下端で偽陽性・偽陰性が出る。必ず 画面 Y = OAM Y − 16 に直し、0-143 にクリップしてから数える。
def assert_oam_per_line(self, limit=10):
oam = self.oam_shadow() # 40 エントリ × 4 B
h = 16 # 8×16 モード固定
counts = [0] * 144 # 画面 Y 0-143
for i in range(40):
oam_y = oam[i * 4]
if oam_y == 0 or oam_y >= 160: # 画面外(上下)は OAM スキャンに乗らない
continue
top = oam_y - 16 # ★ OAM Y → 画面 Y
for line in range(max(0, top), min(top + h, 144)):
counts[line] += 1 # X は見ない: X>=168 でも枠は消費する
worst = max(counts)
assert worst <= limit, f"per-line OBJ {worst} > {limit}"⚠ hQHiwater は flush でリセットされない。 シーンに入ってから現在までの累積最大であり、0 に戻るのは q_hiwater_reset(flow_goto がシーン enter の直前に呼ぶ)だけである(architecture.md §4.3 / §8.2)。したがってハーネスは tick() の中で毎フレームそのまま読めばよく、「1 フレームのずれ」を意識する必要はない。flush 後にリセットする設計では読める値が常に 0 になり、この不変条件は自明に通ってしまう。
⚠ ただし hQHiwater はシーンをまたいで引き継がれない。 flow_goto が enter の直前に q_hiwater_reset を呼ぶので、**遷移先の状態を観測した時点で読める値は「遷移先に入ってからの値」**である。FLOW_CLEAR を観測してから hQHiwater を読むと、演奏中の最悪値ではなく CLEAR 数フレーム分の値になり、<= 128 が自明に通ってしまう。シーンの最悪値は「wFlowState が変わる直前のフレームの値」をラッチして報告する。
⚠ hQHiwater は 1 バイトで満杯時に $FF へ飽和するため、使用量 255 B と 256 B は区別できない。
シーンごとの実測値の取り方(ラッチ方式):
h.wait_state(FLOW_PERFORMANCE) # enter で q_hiwater_reset が走る
latched = 0
while h.peek8("wFlowState") == FLOW_PERFORMANCE:
latched = h.peek8("hQHiwater") # ★ 遷移する前の最後の値を保持する
h.tick(1) # 固定フレーム数でカウントしない
# ここで wFlowState は FLOW_CLEAR。h.peek8("hQHiwater") は既に 0 に戻っている
print("q_hiwater(performance) =", latched) # ← PR 本文に貼る値⚠ h.tick(4150) のような固定フレーム数で待ってはならない(S28)。カウントイン 96 フレームが加わるので 4,150 では曲を完走しない。待つのは常に wFlowState の変化である。
5. APU 検証
5.1 二重化した取得経路
| 手段 | 方式 | 利点 |
|---|---|---|
| 第一 | hook_register を snd_write_reg の入口に張り、register_file.C / register_file.A から (sound_frame, reg_lo, value) を記録。sound_frame はハーネス側のカウンタで sound_tick 入口フックで進め、sound_play_song 呼び出しフックで 0 にリセットする | ROM を DEBUG ビルドにしなくてよい |
| 第二 | -D DEBUG=1 ビルドで snd_write_reg の末尾(snd_log_write)が WRAM $C600-$C7FF(wApuLog、[reg_lo, value] × 256)に積み、wApuLogLen($C800)を増やす。VBlank ISR が wApuLogLen を 0 に戻す。ハーネスは sound_tick.done(src/sound.asm 末尾のラベル)にフックを張り、そのフレームぶんの wApuLog[0..wApuLogLen) を読み出す | エミュレータ非依存。SameBoy / BGB / 実機でも同じデータが取れる |
⚠ 両方を実装する。 PyBoy の API 差やレジスタファイルへのアクセス方法が版で変わっても、第二手段が生き残る。tests/test_apu_refsim.py は両経路のイベント列が同一であることも検査する。
def on_apu_write(ctx):
ctx["log"].append((ctx["h"].sound_frame, ctx["h"].reg_c(), ctx["h"].reg_a()))
bank, addr = h.sym["snd_write_reg"]
h.py.hook_register(bank, addr, on_apu_write, {"log": log, "h": h})
events = h.apu_events(source="hook") # または source="ring"(DEBUG ビルド専用)⚠ ドライバ側の規約: APU レジスタへの書込みは必ず snd_write_reg(c = $10-$3F、a = 値)を経由する。直書き禁止。 grep -n 'ld \[\$FF[123][0-9A-F]\], a' src/ はレジスタアドレスの直書きにしかマッチせず、snd_write_reg 自体は正規の綴り ldh [c], a(レジスタ間接)で書くのでマッチしない。正しい実装での期待値は 0 件であることを CI(tools/check_conventions.sh)が検査する(issue #7 受入条件 1 の確定解釈)。
5.2 ダンプ形式 = 書込みイベント列
tests/apu/{song}.txt 形式: frame reg value
0 FF24 77
0 FF25 FF
0 FF26 80
0 FF30 01
...
96 FF10 00
96 FF11 80
96 FF12 F0
96 FF13 6B
96 FF14 87生成: snd_write_reg が呼ばれたすべての書込みを発生順にそのまま記録する(フックまたは WRAM リングログ)。シャドウの毎フレーム差分ではない。
⚠ シャドウの差分をダンプにすると「同じ値の NRx4 再書込み」が消える。 シャドウは値しか持たないので、NR14 = $87 を 2 フレーム連続で書いても差分は 1 回しか出ない。しかし実機では 2 回目の書込みが余計なリトリガになり、duty 位相がリセットされてブザーになる。これは §7.4 の R2(値が変わったフレームだけリトリガ)の違反であり、R2 は architecture.md §10 リスク 1(本設計の最大リスク)の唯一の緩和策である。イベント列にすれば、余計な書込みは行が 1 本増える形で必ず現れる。
⚠ シャドウの値スナップショットは、実 APU レジスタとの読戻し照合にだけ使う。 その際はレジスタごとに読み戻せるビットが違う($FF13 は全ビット不可、$FF14 は bit6 のみ)ため、レジスタ別の読戻しマスク表を ref_sim_gb.py と共有し、マスクした上で比較する。
5.3 リファレンスシミュレータ tools/ref_sim_gb.py
NES 版 tests/ref_sim.py(297 行、5 曲でビット一致達成)と同じ差分オラクル方式。
| 項目 | 設計 |
|---|---|
| 入力 | assets/bgm/new_*_dpcm.json(NES と同一の JSON)、曲番号、フレーム数 |
| 出力 | (frame, reg_lo, value) の書込みイベント列(+ 参考として各フレーム末の apu_shadow 23 B / wave_shadow 16 B) |
| 再現対象 | note_div[]、vol_mul[]、seq_step、env_frame、fx 0/1/2/3/4/V、8.8 タイマ、ドラム 3 テーブル、CH3/CH4 の優先度調停、リトリガ規約(値が変わったフレームだけ NRx4 を書く) |
| 共有 | 周期表・ノイズ表・ドラム表・vol_mul・レジスタ別読戻しマスク表はジェネレータ(tools/gen_*.py)から import して二重管理を避ける |
| 比較 | イベント列を長さも順序も完全一致で突き合わせる。 1 イベントでも違えば frame N #k: expected (reg $FFxx, $YY) got (reg $FFzz, $WW) で失敗。書込み回数の差も失敗(余計なリトリガの検出) |
| 目標 | NES 版と同じ PERFECT MATCH: 1500 frames, no divergence を 5 曲すべてで |
| ドラム両モード | -D DRUM_USE_WAVE=0 と =1 の両方で一致を要求 |
⚠ NES 版の罠を避ける: ROOT に旧リポジトリ名の絶対パスを直書きしていた。 GB 版は pathlib.Path(__file__).resolve().parents[1] のみを使い、絶対パスを 1 つも書かない(tools/ref_sim_gb.py はリポジトリルート相対で parents[1]。誤記だった parents[2] を訂正、issue #7 U7)。これは unittest で検査する。
$ python3 tests/test_apu_refsim.py
song 0 sunny_step : PERFECT MATCH: 1500 frames, no divergence
song 1 neon_midnight : PERFECT MATCH: 1500 frames, no divergence
song 2 groove_circuit : PERFECT MATCH: 1500 frames, no divergence
song 3 crimson_overdrive : PERFECT MATCH: 1500 frames, no divergence
song 4 twilight_stars : PERFECT MATCH: 1500 frames, no divergence6. シナリオ一覧
6.1 基盤(機能実装より前に通す)
| # | シナリオ | アサート |
|---|---|---|
| S0 | 決定性の証明 | 同一入力列を 2 回実行し、300 フレーム目のスクリーンショットがバイト一致。加えて hFrameCount / wFlowState / apu_shadow の全履歴が一致。⚠ これが通るまで他のテストを書かない |
| S1 | 起動 → タイトル | ROM ヘッダ $0143=$C0 $0147=$19 $0148=$02。wait_for_boot が 300 フレーム以内に成功。#4 スキャフォルド段階は wFlowState == 6(FLOW_PERFORMANCE / GAME・LCDC_GAME 構成)で起動し、タイトル画面 issue (#12) で wFlowState == 9(FLOW_TITLE / ART 構成)に戻す |
| S2 | 倍速モード | hCurSpeed == 1。KEY1 bit7 が 1。SameBoy / BGB での起動も手動で確認し PR 本文に記録 |
| S3 | VRAM キュー | 意図的に溢れさせて hQOverflow が増えることを確認し、通常プレイでは 0 のままであること。hQHiwater はシーン累積最大なので、q_hiwater_reset を経た直後から読み始めた値を予算表(67 B)と突き合わせる。さらに同一フレームに 2 本の q_push を発行し、後続 payload の VRAM 到達、hQTail が終端を指すこと、hQHiwater のフォーマット値を検査する |
| S4 | GDMA / LCD | ① gdma_row の呼出し時と転送完了時の両方で LY が 144-153 の範囲内(フックの入口と出口の 2 点で読む。「VBlank 内で完了する」= issues.md #2 受入条件 7 を文字どおり検査する。現設計の 32 B = 64 dots では余裕があるが、VBlank の GDMA 量が増えたときに「VBlank 終端で撃って表示期間へはみ出す」退行を捕まえるため)。② gdma_bulk の入口で HL=src / DE=dst / C=blocks を記録し、1 ≤ C ≤ 128、src/dst とも 16 B 整列、dst + C*16 <= $A000 を検査する。③ gdma_bulk の呼出し時は必ず LCD OFF(rLCDC bit7 = 0)。④ 実行開始から wait_for_boot() 後の全フレームで、rLCDC 書込み値の bit7=0 の書込みが LY ≥ 144 に限られる。入口ラベルではなく、全経路が通る rLCDC 書込み命令をフックする。⑤ gdma_bulk の入口と完了時の VBK は入口と完了で同一(0 または 1)。⑥ LCD の ON 側は検査しない(LCD OFF 中は LY が 0 で停止し VBlank が来ないため、ON を VBlank 中に行うことは原理的に不可能) |
S3 回帰証跡(修正前 ROM、2026-09-02): q_multi_record_probe を追加した状態で、q_push の終端直後の inc bc をまだ削除していない ROM に対して次を実行した。
$ ./.venv/bin/python -m pytest tests/test_budget.py::test_s3_multiple_queue_records_reach_vram_and_keep_tail_format -q
F ... AssertionError: first/second q_push regression: queue[9:11]=[0, 3], VRAM $9800[0:3]=[10, 10, 10]
assert b'\n\n\n' == b'\xa1\xa2\xa3'
1 failed, 1 warning in 0.05sこれは 2 本目以降のレコードが旧終端を飛び越し、最初の payload さえ VRAM に届かないことを示す。修正後は同じテストで hQTail = wVramQueue + 22、[hQTail] = $00、hQHiwater = 23 を含めて PASS する。
6.2 フロー
| # | シナリオ | アサート |
|---|---|---|
| S5 | フル導線 | タイトルで A → wFlowState が 9 → 8 → 0 → 1 → 4 → 2 → 3 → 5 → 6 の順に遷移。各遷移でスクショ |
| S6 | カウントイン同期 | snd_playing が 0→1 になるフレームで song_frame == 96 ちょうど(NES lua は ±2 だった。GB は sound_tick がメインループの固定位置にあるので ±0 を要求する) |
| S7 | 拍ターゲット整合 | 拍 index N から target[N] = 96 + ceil(N * 4 * speed88 / 256) を Python 側で再計算し、ROM の target[] と全曲・全拍で一致すること。coach_target と next_note_target はどちらもこの target[] の要素であることを確認する(32f 固定への退行検出)。⚠ 「差分が 4*speed88/256 の整数倍」という検査は成立しない: ceil を通した整数フレームなので拍間隔は揺れる(曲 3 は 24, 24, 23, 24, 23 …)。5 曲中 4 曲でこの検査は必ず落ちる |
| S8 | アトラクト | タイトルで 600 フレーム無操作 → wFlowState == 10、240 フレームごとに wProloguePage が 0 → 1 → 2 → 3 と進み、4 ページ送り切って wFlowState == 9 へ戻る。⚠ 実装は tests/test_art.py に置く(前半のタイトル 600 フレームを #12 がそこに書いており、後半も同じ ART シーンを観測するため) |
| S9 | 曲進行 | RANK A 以上で cleared_mask のビットが立ち次曲へ。B 以下で同一曲リトライ |
| S10 | 全曲クリア → エンディング | cleared_mask == %00001111 で CONTINUE が SONG SELECT に。CLEAR で START → wFlowState == 12 → 12 ページ → 9 |
6.3 判定・スコア・観客
| # | シナリオ | アサート |
|---|---|---|
| S11 | 判定窓 | song_frame を target-9,-8,-5,-4,0,+4,+5,+8,+9 に注入して正ボタンを押す → PERFECT/GOOD/MISS とスコア差分 100/50/0 が 9 ケースすべてで正しい |
| S12 | 誤ボタン | 窓内で違う振り → MISS かつノーツ消化、total_miss += 1 |
| S13 | 窓外の空振り | 窓外でボタン → MISS 表示のみ、next_note_index 不変、total_* 不変(NES issue #62) |
| S14 | モーション優先 | 窓外・誤ボタンでも pose が押した振りの先頭コマになる(判定より先にモーション開始) |
| S15 | スコア | 999999 でクランプ。チュートリアル中は加算なし |
| S16 | 観客増減 | 4 拍集計を注入 → +2 / +1 / +1 / −2 の 4 パターン。0 でクランプ + game_state == 1、16 でクランプ |
| S17 | 視聴率ゲージ | bg_row(1, 0, 16) の点灯タイル数が audience と一致。帯域境界(4/5、9/10、12/13)で BGP7 の c2 が変わる |
| S18 | Lv4 ハイプ | audience >= 13 で A ノーツを PERFECT → BGP1/BGP3 が 2 フレーム白、CH4 に 12 フレームの歓声 |
| S19 | ランク閾値 | (perfect, good, miss) = (6,0,10)→D / (7,0,9)→C / (10,0,6)→B / (15,1,0)→A / (16,0,0)→S(NES clear_rank_thresholds.lua と同一 5 ケース) |
| S20 | EASY モード | タイトルで ← 押下 → easy_mode == 1。本番で偶数 index の拍がすべて REST。$FF 終端は残る |
6.4 演出・操作
| # | シナリオ | アサート |
|---|---|---|
| S21 | ポーズ | 本番中 START → pause_flag == 1、snd_row が凍結、バナーが PAUSE。再 START で復帰し snd_row が進む |
| S22 | 裏技 | ポーズ中 A+B+SELECT → CLEAR かつ clear_rank == S。どこでも START+A+B → wFlowState == 9。タイトルで ← → ← → ↑ ↓ B B → cleared_mask 全立ち |
| S23 | S ランク祝賀 | 紙吹雪 8 枚が OAM idx 8-15 に存在し Y が単調増加、confetti_lfsr が周期 255 で回る。CH1 に 60 フレーム周期のピッチスライド。OAM 使用が 16 枠(アイ 4 + コーチ 4 + 紙吹雪 8)。紙吹雪の Y 分散: 同一の 16 px 帯に紙吹雪が 3 枚以上入らないことを全フレームで検査(architecture.md §4.1 の不変条件) |
| S24 | ゲームオーバー演出 | アイ pose 26/27、講師 30/31 の交互。OBJ タイル $D0/$D8(アイ、bank0)と bank1 の講師タイル |
| S25 | チュートリアル | 譜面が 4 拍に 1 ノーツ × 6 グループで L→R→U→D→A→B。SELECT で最終セリフへスキップ。メトロノーム(880 Hz / 8f)が apu_shadow に出る |
| S26 | テキストページ | 全ページで bg_row のタイル列が reflow_text.py の期待列と完全一致(OCR 不要)。8 フレームに 1 行ずつ現れる |
| S27 | 一枚絵 | ART シーンで LCDC == $95(OBJ OFF)。BG マップ row 0-11 が絵、row 13-15 がかな、row 17 が PUSH A。ART → GAME 復帰後に OBJ CHR が正しく再ロードされている(アイのスクショが golden 一致) |
| S27b | プロローグ 4 ページ | 4 ページとも row 0-11 が build/art/prologueN.tilemap / .attrmap と完全一致し、row 13-15 が tools/reflow_text.py の期待列と完全一致する。テキストは 8 フレームに 1 行(タイプライタではない)。A で次ページ、アトラクトでは 240 フレーム自動送り。ページ間はパレットフェード 16 段 → LCD OFF → 絵の差し替え → フェードイン。row 12-17 の BG 属性は ART_TEXT_ATTR のまま変わらない |
6.5 長時間・予算
| # | シナリオ | アサート |
|---|---|---|
| S28 | 完走 | 曲 3(譜面のある曲で最長・最速)を FLOW_PERFORMANCE 開始から wFlowState == FLOW_CLEAR に到達するまで tick() し、不変条件が一度も破れない。固定フレーム数でカウントしない(BGM 尺 4,150 フレームにカウントイン 96 フレームが加わるため)。wFlowState が FLOW_CLEAR に変わる直前のフレームの hQHiwater をラッチして記録し、128 以下(§4 のラッチ方式。FLOW_CLEAR 到達後に読むと q_hiwater_reset 済みの値になり自明に通る)。⚠ 「最長」は譜面のある 4 曲の中での話である。曲 4(4,181 フレーム)は曲 3 より長いが譜面を持たない。実測(2026-09-03): 4,153 フレームで FLOW_CLEAR に到達し、ラッチした hQHiwater は 44 B / hQOpCount 4 / per-line OBJ 6 / hQOverflow 0(tests/test_budget.py::test_s28_the_densest_song_runs_to_clear_without_breaking_an_invariant) |
| S29 | 5 曲 APU 一致 | 5 曲すべてで ref_sim_gb.py と 1,500 フレーム分の書込みイベント列が完全一致(DRUM_USE_WAVE の両モード) |
| S30 | 通常速フォールバック | デバッグ ROM の SPEED TOGGLE で通常速に落とし、VBlank に gdma_row × 2(64 B)を毎フレーム流す通常プレイ区間を完走する。hQOverflow == 0 と gdma_row が 2 本 / フレームであることは自動、画面が崩れないことは目視。実測では完走するが VBlank ISR は 115.8%(5,280 dots)で収まらない(§9 の実測。architecture.md §4.5)。⚠ 通常速で「VBlank GDMA 上限 1,024 B」を撃つと VBlank を 135.5% 超過して破綻する(architecture.md §4.5)。通常速で一括転送を VBlank に流す必要が生じたら、上限を 32 ブロックに下げて 2 フレームに分割する。キュー/GDMA 量を無制限に流せるという意味ではない |
7. スクリーンショットとゴールデン運用
7.1 命名
tests/shots/{seq:02d}_{state}_{tag}.png
tests/shots/golden/{同名}.png ← 承認済み基準画像
tests/shots/diff/{同名}.png ← 差分(失敗時のみ出力)
例:
01_title_default.png
02_title_easy.png
03_prologue_p1.png
04_prologue_p4.png
10_entrance_coach_walk.png
11_dialog_p2.png
12_tutorial_note.png
20_performance_note_approach.png
21_performance_judge_perfect.png
22_performance_judge_miss.png
23_performance_lv4_flash.png
24_performance_gauge_full.png
30_clear_rank_s.png
31_clear_confetti.png
34_clear_all_songs.png
40_gameover.png
50_ending_p1.png
50_ending_p11.png
50_ending_p12.png
51_ending_to_title.png
60_song_select_song1.png
61_song_select_song4.png
62_song_select_to_performance.png
70_full_run_start.png
70_full_run_mid.png
70_full_run_clear.png⚠ seq はシーンのおおまかな順序であって一意な ID ではない。 デバッグメニュー(60_debug_menu.png 以降)と SONG SELECT(60_song_select_*.png 以降)が同じ 60 番台を使っているように、{state} が違えばファイル名は衝突しない。衝突しない限り番号を詰め直さない: 番号を振り直すと tools/sync_docs_art.py の ART_SOURCES と tests/shots/golden/ の対応が同時に切れる。70 番台は S28(曲 3 の完走)が使う。
7.2 運用ルール
| ルール | 内容 |
|---|---|
| 比較 | ピクセル完全一致。PyBoy は決定的で CGB 色も固定(S0 で証明する) |
| 失敗時 | shots/diff/ に差分画像を出力し、テストは失敗にする |
| 更新 | ゴールデンの更新は専用 PR でのみ許可する。コミット type は art:(NES 版で一級だった type を継承) |
| 承認 | 帯パレット再マップ(assets.md §4.2)と一枚絵の減色は人間の目でしか判定できない。該当のゴールデンはレビュー担当(Fable)がプレビューを目視承認するまでマージしない |
⚠ 万一 CGB 色が環境依存で揺れる場合の退避: 比較を bg_row() / bg_attr() のタイル列中心(S26 方式)に切り替え、画像は目視レビュー用に降格する。この判断は S0 の結果で決める。
8. Python unittest(tools/test_*.py)
生成物側の検査は assets.md §8 に一覧がある(約 75 件)。E2E 側(tests/)は pytest で以下を持つ:
| ファイル | 内容 |
|---|---|
test_boot.py | S0(決定性)、S1、S2 |
test_budget.py | S3、S4、S28 |
test_flow.py | S5-S10(S8 を除く。上記参照) |
test_judge.py | S11-S15 |
test_audience.py | S16-S18 |
test_rank.py | S19、S23 |
test_easy.py | S20 |
test_cheats.py | S21、S22 |
test_text.py | S25、S26 |
test_art.py | S8、S27、S27b |
test_apu_refsim.py | S29 |
test_debug_menu.py | S30、デバッグメニュー ROM(§9) |
make test # python3 -m unittest discover -s tools (生成物)
make e2e # all + debug、timeout 300、python3 -m pytest tests -q (ROM)⚠ gb サブフォルダは作らない。 本リポジトリは GB 専用なので、tools / tests / build の下に gb ディレクトリを掘るのは誤りである(issues.md §4 共通受入条件 7 の GB_SUBDIR_GREP)。
⚠ 両方を exit code で確認する。 NES 版「鉄則 #6」(make が通っても make debug が壊れる)を継承し、make debug も CI で叩く。
9. デバッグ ROM
make debug(-D DEBUG=1 -D DEBUG_MENU=1、bank7)。NES 版 9 項目を継承 + GBC 固有 3 項目:
row 2 1 DANCE 6 MOVES LOOP row 8 7 BGM SONG SELECT
row 3 2 JUDGE P G MISS row 9 8 CLEAR SCREEN
row 4 3 AUDIENCE LV1-LV4 row 10 9 GAME OVER SCREEN
row 5 4 LV4 FLASH CHEER row 11 10 VRAM QUEUE STRESS
row 6 5 DIALOG 3 PAGES row 12 11 PALETTE VIEWER
row 7 6 OPENING ENTRANCE row 13 12 SPEED TOGGLE⚠ 項目は BG row 2-13 のちょうど 12 行に並ぶ。 HUD が row 0-1 を、観客スタンドが row 14-17 を毎フレーム描き直すので、メニューが使えるのはこの 12 行だけである。 タイトル行は置けない(issue #15 実装時の実測。src/debug_menu.asm)。
⚠ 3 / 4 / 7 の項目名は原文が GB の 20 桁に収まらないので短縮した(issue #15 の 設計逸脱。PR 本文で報告済み)。原文 → 実装は次のとおり:
| # | e2e.md 原文(桁数) | 実装(桁数) |
|---|---|---|
| 3 | 3 AUDIENCE LV1 TO LV4(21) | 3 AUDIENCE LV1-LV4(18) |
| 4 | 4 LV4 FLASH AND CHEER(21) | 4 LV4 FLASH CHEER(17) |
| 7 | 7 BGM ALL SONGS SELECT(22) | 7 BGM SONG SELECT(17) |
- 10 VRAM QUEUE STRESS: 設計最悪ケースの全 op を毎フレーム積んで
hQHiwaterを 3 桁 10 進で BG row 3 col 0 に実測表示する。issue #15 実測 = 73 B(設計最悪 67 + 実測表示 6、hQOpCount = 6、hQOverflow = 0) - 11 PALETTE VIEWER: BG 8 本 / OBJ 8 本を一覧表示。減色後の一枚絵パレットの確認にも使う。BG は「色 index 0-3 が一様な 4 枚」を VRAM bank1 の BG 窓へ置き、BG 属性 bit3 = 1 + パレット 0-7 で row 2-9 × col 0-3 に並べる。OBJ は bank0 の空きタイル(
$F2-$F3)の縦分割スワッチを OAM 16-23 に 8 体並べる(画面 Y 112-127。アイ / 講師の帯と重ならないので per-line OBJ は 8) - 12 SPEED TOGGLE: 通常速フォールバック。A で 1X ⇔ 2X を往復する。1X / 2X のどちらの区間でも、設計最悪ケースのキュー負荷(5 op / 67 B)を積み、VBlank の
gdma_row2 本(視聴率ゲージ行 + 会話本文の属性行 = 64 B)を毎フレーム発火させたまま走らせるので、倍速と通常速で同じフレーム構成を読み比べられる。ゲージは観客値を毎フレーム動かして帯域色のrBGPD書込み経路も通し、属性行はテキストエンジンの行スロットを借りて素のステージと同じ値を流す(画面は変わらない)。VBlank に流す GDMA はgdma_row2 本 = 4 ブロック(64 B)で、1X 上限の 32 ブロック(GDMA_MAX_BLOCKS_PER_VBLANK_1X)の内側にいる
issue #15 実測(2026-09-03。issue #12 マージ後に再測定): 12 SPEED TOGGLE で曲 0 を 1X / 2X それぞれ 1,500 フレーム完走した。どちらも
hQOverflow = 0、hQHiwater = 67、hQOpCount = 5、gdma_rowは 1 フレームあたり 2 本(各区間 3,000 回)。VBlank ISR の入口 LY は 144、終端 LY は 通常速 1(VBlank を越えて次フレームの走査線 1)/ 倍速 149(どちらも 1,500 フレーム全部が同じ値)。占有は 通常速 5,280 dots = VBlank 4,560 dots の 115.8%(超過)/ 倍速 2,640 dots = 57.9% で、CPU 仕事量はどちらも 1,320 M-cycle である。通常速は完走する(受入条件 4 は充足)が VBlank には収まらず、2 本目のgdma_rowは LY 0(表示中)で走る(architecture.md§4.5 の「Issue #15 通常速フォールバック実測」に導出と実機での注意を書いた)。
⚠ デバッグメニューからの復帰はリセットではない。 NES 版は debug_boot_request を書いて jmp reset していたが、GBC 版は flow_goto() を呼ぶだけである。
⚠ DEBUG_MENU ビルドでは B と START がグローバル予約になる。 main.asm の call read_pad 直後に debug_menu_return_check が入り、演出の実行中(wDebugRun != 0) に B / START が押されたフレームは flow_goto FLOW_DEBUG_MENU してそこで打ち切る。 ポーズ(START)やゲームオーバー復帰(START)はデバッグ ROM では踏めない。 メニュー待機中は何も食わないので、tests/test_harness.py の wQueueTest プローブと tests/test_apu_refsim.py のメインループ経路はそのまま通る。
⚠ デバッグ ROM の起動シーンは FLOW_DEBUG_MENU(13)である。 製品 ROM の FLOW_TITLE(9。issue #12)とは違う。src/init.asm は IF DEF(DEBUG_MENU) で 起動シーンだけを差し替えるので、製品 ROM には 1 バイトも残らない。メニューの enter は APU に触れない(sound_stop を呼ばない)ので、tests/test_apu_refsim.py の 「BGM ドライバが APU 書込みを独占する」前提は保たれる。
スクリーンショット(tests/test_debug_menu.py):
| ファイル | 内容 |
|---|---|
60_debug_menu.png | 12 項目メニュー |
61_debug_queue_stress.png | 10 VRAM QUEUE STRESS(hQHiwater の 3 桁表示) |
62_debug_palette_viewer.png | 11 PALETTE VIEWER(BG 8 本 / OBJ 8 本) |
63_debug_speed_1x.png | 12 SPEED TOGGLE の 1X 区間 |
64_debug_item01.png 〜 64_debug_item09.png | 項目 1-9 の演出 |
10. 未決事項
| # | 論点 | 現状 | 判断時期 |
|---|---|---|---|
| E1 | ゴールデン画像を PNG 完全一致で運用できるか | S0 の結果で決める。不安定ならタイル列比較へ降格 | 子 issue 2 |
| E2 | hook_register からレジスタ A / C を読む正確な API | スパイクではフックの発火回数のみ検証済み。値の取得方法は子 issue 6 で確定させ、駄目なら WRAM リングログ 1 本に絞る | 子 issue 6 |
| E3 | ref sim のフレーム数(NES は 1,450) | 1,500 に切り上げる。曲 3 の 4,150 フレーム全域を見るかは実行時間次第 | 子 issue 6 |
| E4 | カスタムブート ROM で起動アニメを飛ばすか | 当面は WRAM ポーリングで足りる。フレーム 0 からのバイト一致が要るようになったら LD A,1 / LDH [$50],A / JP $0100 の小さなブート ROM を用意する | 必要になったら |