Skip to content

AIドパガキ GBC 版 技術設計書(アーキテクチャ)

本書は GBC 版のエンジン内部構造・メモリマップ・実行予算・サウンドドライバの正典である。ゲームの数値(判定・スコア・観客・レーン)は ../spec/aidopagaki-gb-design.md を正とし、本書はそれを実現する手段だけを定める。

前提: RGBDS 1.0.3rgbasm / rgblink / rgbfix / rgbgfx)+ hardware.inc v5.3.0(gbdev/hardware.inc、リポジトリに vendoring) 実装者: Codex(1 issue = 1 PR)/ E2E: PyBoy 2.7 headless(../design/e2e.md


0. 予算の単位系(本書の土台)

すべての実行予算は dots に一元化する。 M-cycle は「CPU がその処理に使う時間」であって実時間ではなく、倍速で意味が変わるため、単独では比較できない。

定義
1 フレーム70,224 dots
VBlank(LY 144-153)10 ライン × 456 = 4,560 dots
CPU(通常速)1 M-cycle = 4 dots
CPU(倍速、KEY11 M-cycle = 2 dots
OAM DMA($FF46160 M-cycle 固定(CPU 側のコストが固定。dots では倍速で半減する)
GDMA(HDMA5 bit7=0)実時間固定 2 dots/byte(= 32 dots / 16 B ブロック。M-cycle 換算は倍速で 2 倍になる)

OAM DMA と GDMA は倍速に対する挙動が逆である。 OAM DMA は CPU 時間が固定(倍速で実時間が半分)、GDMA は実時間が固定(倍速で CPU 時間が 2 倍)。両者を同じ列に並べる前に必ず dots に直すこと。


1. 決定事項サマリ

#決定数値根拠
D1CGB 倍速モードを起動時から常時 ONVBlank ISR 通常フレーム(q_flush 90 B / 6 op 再計算後): 倍速 2,856 / 4,560 dots(62.6%)に対し通常速 5,584 dots(122.5%、超過)。VBlank GDMA 上限 1,024 B(GDMA_MAX_BLOCKS_PER_VBLANK = 64)を使い切ると倍速でも 4,576 dots(100.4%、わずかに超過)/通常速 7,104 dots(155.8%、破綻)——現設計では gdma_bulk を VBlank から呼ばないためこのケースには到達しない(§5.2)。§4.5
D2講師 CHR 自己書換えストリーミングを全廃OBJ タイル = 256/バンク × 2 = 512。アイ側 242(bank0)+ 講師 256(bank1)が常駐で収まる。§3.2
D3リセット遷移を全廃しシーン状態機械へリセットを跨ぐと PyBoy から状態注入ができず、ランク閾値テストが原理的に成立しない。§6
D4apu_shadow 23 B + wave_shadow 16 B を WRAM の $xx10 / $xx30 境界に常駐GB の APU レジスタは読み戻せない($FF13 は常に $FF$FF14 は bit6 のみ)。シャドウなしでは ref sim とのバイト一致検証が原理的に不可能。境界整列により snd_write_regld l, c 1 命令でアドレスを作れる。追加コスト 39 B + 約 30 M-cycle/frame(全体の 0.09%)。§7.8
D5VRAM キュー 256 B + hQOverflow / hQHiwater / hQOpCount を HRAM にNES 版で 3 回起きた ppu_buf 溢れ事故(#66/#68/#35)を、視覚的グリッチではなくテスト失敗として観測可能にする。最悪 67 B / 256 B(26%)を、バイト数だけでなく op 数でも検査する。§4
D6BG マップは $9800 単一面。$9C00 は使わないシーン切替はもともと LCD OFF 窓(§5.4)で行うので、表示中に次の面を裏で組み立てる必要がない。二面にすると「表示面を指すベース」を q_push / gdma_row / E2E の bg_row() すべてが解決しなければならず、事故の芽になる。LCDC は GAME $87 / ART $95 / BLANK $05 の 3 値を取り、bit3 は常に 0。§6.3
D7sound_tick を VBlank ISR からメインループ先頭(vsync 直後)へsound_tick 実測最悪 6,784 M-cycle(設計時の見積り 900 は過小だった。§4.6 Issue #7 実測)。ISR に置いていたら倍速 VBlank 予算 2,280 M-cycle を超過して破綻していた。メインループなら 35,112 M-cycle 中の 19.3%。§7.1
D8HDMA を明示的に禁止し GDMA のみ使う画面表示中の VRAM 更新要求が一切ないため、HDMA は LY 同期・転送中断・二重起動という 3 種のタイミングバグを抱え込むだけ。§5

2. カートリッジヘッダと ROM 構成

2.1 ヘッダ

アドレス意味
$0134-$013EAIDOPAGAKIタイトル(11 バイト。CGB カートでは $013F 以降がメーカーコードなのでここまで)
$013F-$0142ADPJメーカーコード
$0143$C0CGB 専用(DMG では起動しない)
$0144-$0145AE新ライセンシー
$0146$00SGB 非対応
$0147$19MBC5(RAM なし・バッテリなし)
$0148$02ROM 128 KB(8 バンク)
$0149$00SRAM なし
$014A$00日本
$014D / $014E-$014Frgbfix -v が計算ヘッダ / グローバルチェックサム
bash
rgbfix -v -C -m 0x19 -p 0xFF -t "AIDOPAGAKI" -i "ADPJ" -k "AE" -l 0x33 build/aidopagaki.gbc

セーブは実装しない。 進行(cleared_mask / current_song / easy_mode)は電源が入っている間だけ保持する。これは NES 版の「バズは一夜の夢」という前提の継承であり、リセット遷移の廃止と引き換えに失うものではない(GBC 版はそもそもリセットを挟まない)。SRAM 対応は将来の別 issue とし、その場合の変更は $0147 = $1B$0149 = $02 のヘッダ 2 バイト + save_commit ルーチン 1 本のみ。

2.2 ROM バンク割当(128 KB = 8 × 16 KB)

bankセグメント内容見積
0 (HOME $0000-$3FFF)ROM0ベクタ、init、メインループ、VBlank ISR、フロー状態機械、判定、HUD、OAM 構築、サウンドドライバ本体note_div 表(168 B)、vol_mul(256 B)~12 KB
1ROMX SONGSBGM ストリーム 5 曲 7,570 B + 楽器定義 + 譜面 & 拍ターゲット表 1,887 B(issue #8 実測。曲 4 の拍ターゲット表 226 B を含む) + ドラム再合成表 128 B(issue #7 U1。kick/snare/tom の静的フレームテーブル。64 B 超のため HOME 例外に含めず bank1 の BGM と同居させる)~10 KB
2ROMX GFX_AIアイ 26F(3,328 B) + GO 2F(256 B) + ノーツ/マーカー/紙吹雪(288 B)3,872 B
3ROMX GFX_COACH講師 32F4,096 B
4ROMX GFX_BGステージ BG タイル(2,048 B) + 各シーンの tilemap/attrmap~8 KB
5ROMX TEXTかなフォント 3 種(864+960+1,216 = 3,040 B) + テキストスクリプト全文~5 KB
6ROMX ART_A一枚絵 3 枚(タイトル 3,920 + プロローグ p1 3,872 + p2 4,272)12,064 B(issue #13 実測。残余 4,320 B)
7ROMX ART_B + COLD一枚絵 2 枚(プロローグ p3 3,840 + p4 3,664) + エンディング進行(未配置)+ デバッグメニュー(issue #15 実測 1,219 B。-D DEBUG_MENU=1 のときだけ存在し、製品ビルドには 1 バイトも入らない。ROM0 側のトランポリン "DebugMenu Stubs" は 165 B で、これも DEBUG_MENU ビルドにしか無い7,504 B(issue #13 実測、製品ビルド。残余 8,880 B)

一枚絵 1 枚の内訳(4,384 B):

要素サイズ備考
タイル240 × 16 = 3,840 Bユニークタイル上限 240。GDMA 240 ブロック(128 + 112 の 2 回)
tilemap20 × 12 = 240 Brgbgfx -t の出力そのまま。32 列へのパディングはしない
attrmap20 × 12 = 240 Brgbgfx -a の出力そのまま。VRAM bank1 側
パレット8 × 4 × 2 = 64 BBGPD へ順次書込み(うち BGP7 はテキスト帯用の固定色)

tilemap / attrmap は 1 行 20 バイトであり、GDMA では運べない。 GDMA は 16 B ブロック単位なので、1 行 20 B も 12 行分 240 B の連続転送も BG マップの 32 列レイアウトに乗らない(240 B を $9800 へ連続転送すると row 0 の 20 列の直後に row 0 の col 20-31 が続いてしまう)。一枚絵の tilemap / attrmap は ART シーン enter の LCD OFF 窓で CPU が 12 行 × 20 B を $9800 + row*32 へコピーする(§5.4 手順 6)。LCD OFF 中なので VRAM アクセス制限はなく、コストは 240 B × 約 9 M-cycle = 約 2,200 M-cycle ≈ 倍速 4,400 dots。タイル面と属性面で 2 回、計約 8,800 dots。

32 列へパディングして 384 B にする案は採らない。 ROM が 1 枚あたり 288 B 増え、rgbgfx の出力をビルド後に加工する工程(-N 240 の後段に Python か dd)が要り、「ビルドは rgbgfx を読むだけ」という §5 の決定的ビルド方針が崩れるため。

設計ルール(NES 版「鉄則 #3」の GBC 版):

HOME バンクにはコードのみを置く。64 バイトを超える読み取り専用データはすべて ROMX に置き、bankcall 経由で読む。1 曲の BGM ストリームと、その曲の譜面/拍ターゲット表は必ず同一バンクに置く(NES 版の不変条件をそのまま継承)。

上記ルールの唯一の例外は note_div(168 B) と vol_mul(256 B) の 2 表である。 sound_tick は毎フレーム最悪 6,784 M-cycle(実測、§4.6)の中でこの 2 表を数十回引く。ROMX に置くとその都度 cur_rom_bank の退避 → ld [$2000], a → 復帰が挟まり、バンク切替の往復だけで表参照のコストを上回る。しかも sound_tick はメインループ先頭(§7.1)でシーンごとのバンク状態を跨いで走るため、切替漏れが「特定シーンでだけ音が壊れる」形で出る。この 2 表だけを HOME バンクの例外として明示し、他のデータを HOME に置く提案は却下する。 424 B は HOME 16 KB の 2.6% にすぎない。

一枚絵 5 枚は 128 KB ROM に収まった(issue #13 実測。未決 A4 は解消)。 実測は bank6 12,064 B / 16,384 B(73%、残余 4,320 B)、bank7 7,504 B / 16,384 B(45%、残余 8,880 B)。見積り(1 枚 4,384 B = 上限 240 タイル)より小さいのは、rgbgfx -u -m の反転統合でユニークタイルが 240 に届かないためである(タイトル 211 / p1 208 / p2 233 / p3 206 / p4 195)。$0148 = $03(256 KB)への引き上げは不要../design/issues.md I3 も解消)。ROM 全体の実使用は 60,568 B / 131,072 B(46%)

bank7 の残余 8,880 B は「エンディング進行・デバッグメニュー」の置き場である。 6 枚目の一枚絵(1 枚あたり最大 4,384 B)を足すとここを食う。tests/test_art.pytest_the_prologue_pictures_fit_bank6_and_bank7 が両バンクの実測を毎回検査するので、超えた時点でテストが落ちる。


3. VRAM レイアウト

VRAM = 16 KB = 2 バンク × 8 KBVBK $FF4F で切替)。

3.1 アドレッシング方式

構成LCDC.4BG が索引できる領域OBJ
GAME0($8800 符号付き)index $00-$7F$9000-$97FF(128 タイル/バンク)$8000-$8FFF(256 タイル/バンク)
ART1($8000 符号なし)index $00-$FF$8000-$8FFF(256 タイル/バンク)OFF

GAME 構成では BG マップ値を 0-127 に制限する。 index $80-$FF$8800-$8FFF = OBJ 領域と衝突するため。マップ生成ツールに assert max(map) < 128 を入れ、ビルド時に落とす。

これにより GAME 中は BG と OBJ が VRAM 上で 1 バイトも重ならない(BG = $9000-$97FF、OBJ = $8000-$8FFF)。ART 中だけ絵が OBJ 領域を占有し、GAME 復帰時に再ロードが要る(§5.4)。

3.2 GAME 構成の VRAM

VRAM bank 0

アドレスindex内容タイルバイト
$8000-$8CFFOBJ $00-$CFアイ ダンス 26 フレーム × 82083,328
$8D00-$8DFFOBJ $D0-$DFアイ ゲームオーバー 2 フレーム × 816256
$8E00-$8EBFOBJ $E0-$EBノーツ振りアイコン 6 種(8×16 = 2 タイル)12192
$8EC0-$8EDFOBJ $EC-$ED判定マーカー ◎(8×16)232
$8EE0-$8F1FOBJ $EE-$F1紙吹雪 2 形状(8×16)464
$8F20-$8FFFOBJ $F2-$FF予備14224
$9000-$97FFBG $00-$7FUI 英数フォント 47 + ステージ BG 65(内訳は assets.md §2)1282,048

VRAM bank 1

アドレスindex内容タイルバイト
$8000-$8FFFOBJ $00-$FF講師 32 フレーム × 8(常駐、ストリーミング全廃)2564,096
$9000-$972FBG $00-$72かなフォント窓(シーン入場時に GDMA で入替)≤1151,840
$9730-$97FFBG $73-$7F判定グリフ(常駐) 空白 1 + P E R F C T G O D M I S 1213208

判定グリフはパレット index 2 で描いてあるassets.md §2.2 / §1.2 の例外)。UI 英数フォント(bank0)は index 1 = C_WHITE 固定でしか描かれないので、BGP2 の c2 を差し替えても画面の色が変わらなかった。判定行 7 セル(row 5 col 6-12)は BG 属性 bit3 = 1 + BGP2 でこのバンクを索引する。⚠ かなフォント窓はこの 13 タイルぶん縮んで 115 タイルになるKANA_TILE_LIMIT)。最大のエンディングかな 76 タイルは収まる(§3.3)。

BG マップと属性

領域bank0bank1
$9800-$9BFF唯一の BG マップ(タイル索引)同 属性
$9C00-$9FFF未使用LCDC bit3 は常に 0)

BG マップは $9800 の 1 面だけを使う(D6)。したがって ../spec/aidopagaki-gb-design.md §2.3 のアドレス定数 21 本は実アドレスの即値でよく、q_push / gdma_row / E2E の bg_row() が「今どちらの面が表示中か」を解決する必要はない。$9C00-$9FFF は 1 バイトも書かない(将来ウィンドウ機能を使う場合の予備)。

BG 属性ビット:

bit用途
0-2BG パレット 0-7
3タイルの VRAM バンク(0 = 英数/ステージ, 1 = かな)
5X フリップ
6Y フリップ
7BG-to-OBJ 優先 = 0(OBJ を前面に)

BG が bit3 で per-tile にバンクを選べるため、1 行の中に英数とかなを混在できる(例: アイ「なかみは いくらかなー?)。NES の「CHR 窓の奪い合い」は完全に消える。

3.3 かなフォント窓の入替

シーン内容タイルGDMA バイトdots
会話 1-3会話かな 54548641,728
プロローグ(ART)プロローグかな 60609601,920
エンディングエンディングかな 76761,2162,432

エンディング 76 + 会話 54 = 130 > 115 で溢れるが、両者は同時に画面へ出ないため入替で解決する。⚠ 窓の上限は 128 ではなく KANA_TILE_LIMIT = 115 である(末尾 13 タイルが判定グリフの常駐領域、§3.2)。tools/gen_judge_font_gb.py がこの上限を出力し、test_judge_font_gb.py が「エンディング 76 ≤ 115」を検査する。入替はシーン入場の LCD OFF 窓で完結する(VBlank に押し込まない)。test_kana_font_gb.py がタイル数を検査し、flow_enter_ending が入替を呼ぶことを静的解析テストで保証する。

3.4 ART 構成の VRAM

ART 構成では BG が $9000 を索引できないLCDC.4 = 1 なので index $00-$FF$8000-$8FFF を指す)。GAME 用の UI 英数フォント(bank0 $9000 常駐)はアドレスできない。そこで ART のテキスト系タイル(英数 47・区切り線・かな)はすべて VRAM bank1 の $8000-$8FFF に置き、BG 属性 bit3 = 1 で索引する。bank0 $8000-$8FFF は一枚絵専用とし、絵が 240 タイルを 1 枚も超えないことだけを守ればよい。

バンクindex内容タイル
bank0$00-$EF一枚絵(row 0-11、20×12 = 240 セル)≤ 240
bank0$F0-$FF未使用(予備)16
bank1$00-$2EUI 英数フォント 47(空白 / A-Z / 0-9 / 記号 8 / )。GAME の bank0 $9000 と同一データ47
bank1$2F区切り線(row 12)1
bank1$30-$6Bかなフォント(そのシーンで使う集合。プロローグは 60)≤ 60
bank1$6C-$FF未使用(予備)148

bank1 の $8000-$8FFF は GAME 構成では講師 CHR 256 タイルが常駐している領域である。 ART 入場時に上書きし、GAME 復帰時に §5.4 の手順 4 で再ロードする。ART は OBJ OFF なので上書きしても表示に影響しない。

タイトルのメニュー(START / CONTINUE / PROLOGUE / MODE:NORMAL../spec/aidopagaki-gb-design.md §9.3)に必要な英数 16 グリフとカーソル は、この bank1 の 47 グリフに全部含まれている。 bank0 に 16 タイルの英数枠を切り出す旧案は、 と空白を加えた 18 種を収容できないので廃止した。⚠ H はメニュー 4 行のどこにも現れないH を含むのは PUSH A で、タイトル row 17 は MODE:NORMAL)。

ART 入場時の GDMA(bank1 側):

対象バイトブロック
UI 英数フォント 4775247
区切り線 1161
かなフォント(プロローグ 60)96060
合計1,728108

4. OAM・VRAM キュー・実行予算

4.1 OAM 割当(40 枠中 16 枠使用)

OAM index用途枚数
0-3アイ(16×32 = 8×16 OBJ 4 枚)4
4-7お手本ガール4
8判定マーカー ◎1
9-14レーンノーツ 最大 66
(CLEAR 時)8-15紙吹雪 8 枚(マーカー + ノーツ枠を再利用)8
15-39未使用(Y=0 で消す)

最大使用数はゲームプレイ中の 15 ではなく CLEAR 中の 16 である(アイ 4 + コーチ 4 + 紙吹雪 8)。CLEAR ではマーカーもノーツも出ないので枠 8-15 が丸ごと紙吹雪になり、0-7 のキャラと合わせて 16 枠が同時に生きる。

CGB モードの OBJ 優先度は OAM インデックス順である(DMG の X 座標優先ではない。OPRI は CGB のデフォルトで OAM 順)。したがって重なり順は OAM 割当で決まる: アイ(0-3)がコーチ(4-7)の前、マーカー(8)がノーツ(9-14)の前。この順序を変えると見た目が変わるので、OAM 割当は仕様である。

1 ライン 10 枚制限の検証:

Y 帯内訳枚数
レーン(y 24-39)マーカー 1 + ノーツ 67 ≤ 10
キャラ上半(y 72-87)アイ 2 + コーチ 24 ✓
キャラ下半(y 88-103)アイ 2 + コーチ 24 ✓
CLEAR キャラ帯(y 72-103、紙吹雪が最悪に重なった場合)アイ 2 + コーチ 2 + 紙吹雪 26 ≤ 10

OAM X ≥ 168 の OBJ は画面に出ないが、OAM スキャンの 10 枚枠は消費する。 出現直後のノーツ(x=160 → OAM X=168)がこれに当たり、上表の 7 枚に含まれている。

不変条件(CLEAR の紙吹雪): 8 枚の紙吹雪は同一の 16 px 帯(画面 Y を 16 で割った帯)に 2 枚を超えて置かない。 紙吹雪は LFSR で落下位置が決まるため、無制約だとキャラ帯(y 72-103)に 8 枚全部が重なり 4 + 8 = 12 > 10 になりうる。落下 Y を「枠 8-15 の各枚に 16 px ずつ位相をずらした初期 Y を与え、同じ速度で落とす」ことで構造的に保証し、E2E S23 が全フレームで帯ごとの枚数 ≤ 2 を検査する。

4.2 OAM DMA

$FF46 への書込みで $C000-$C09F の 160 B を OAM へ転送する。転送中 CPU は HRAM しか読めないため、待ちループを HRAM に置く

asm
; init で HRAM $FF80 へコピーする 10 バイト($FF80-$FF89)
OamDmaRoutine:
    ld  a, HIGH(wOamShadow)   ; 2 B   = $C0。呼び出し側に a の準備を要求しない
    ldh [rDMA], a             ; 2 B
    ld  a, 40                 ; 2 B
.wait:
    dec a                     ; 1 B
    jr  nz, .wait             ; 2 B
    ret                       ; 1 B   合計 10 B = HRAM 割当と一致

コスト = 160 M-cycle 固定(倍速で 320 dots、通常速で 640 dots)。

4.3 VRAM キュー仕様

NES 版 ppu_buf(96 B)の GBC 版。メインスレッドは VRAM に直接触らない。 すべての VRAM 書込みはキューに積み、VBlank ISR が flush する。

フォーマット(GB のリトルエンディアンに合わせ lo/hi を NES と逆にする):

[ len(1) | dst_lo(1) | dst_hi(1) | data(len) ] * n   +  終端 $00
容量 256 B(NES は 96 B)

API(4 本だけ):

q_reset          ; フレーム先頭で呼ぶ。tail = vram_queue、終端 $00 を書く
q_push           ; in: de = VRAM 宛先, c = len, hl = ソース。バイト/op上限を超えたら hQOverflow++ して破棄
q_push_digits    ; in: de = 宛先, hl = BCD 桁配列, c = 桁数。数字タイル index に変換して push
q_hiwater_reset  ; hQHiwater = 0。シーンの enter からのみ呼ぶ(§8.2 / §6.4)

hQHiwater は flush でリセットしない。 flush 後にゼロクリアすると E2E から読める値が常に 0 になり、「実測値を PR 本文に貼る」という受入条件が原理的に成立しない。hQHiwaterシーン内の累積最大であり、リセットするのは q_hiwater_reset を明示的に呼んだときだけである。E2E は tick() の中で毎フレーム読む。⚠ シーンをまたぐと値は引き継がれない。 flow_goto は exit → wFlowState 書換え → q_hiwater_reset → enter の順に走る(§6.2 / §6.4)ので、遷移のたびに 0 に戻る。したがって E2E はシーンごとに値を読み切るwFlowState が変わる直前のフレームの値をラッチして報告する。e2e.md §4 / S28)。

hQTail の定義: hQTail は常に現在の終端 $00 のアドレスを指し、次の空きアドレスではない。空キューでは hQTail = wVramQueue かつ [wVramQueue] = $00 である。旧終端を T、payload 長を L とすると、q_pushT+1 から dst(2 B) と payload を書き、T+L+3 に新しい終端を書いて hQTail = T+L+3 とする。終端の 1 バイトを含む占有量(および hQHiwater の候補)は T+L+4 - wVramQueue であるため、空き容量判定は T + L + 4 < wVramQueueEnd とする。2 本目以降は旧終端を次の len で上書きするので、レコードの増分は len+3 バイト、各時点の終端は 1 バイトである。

hQOpCount の定義: hQOpCountq_reset から次の q_reset までに受理したレコード数である。q_push はバイト容量とは別にこの値を加算し、Q_MAX_OPS_PER_FRAME を超えるレコードを hQOverflow と同じ overflow 扱いにして破棄する。q_reset が毎フレームこれを 0 に戻すため、固定費の大きい小レコードの大量発行も検出できる。hQHiwater は 1 バイトなので、占有量 255 B と 256 B はどちらも $FF になり得て値だけでは区別できない。境界判定と hQOverflow はこの区別を保持する。

不変条件: どの 1 op も $xx00 境界をまたがない。 ../spec/aidopagaki-gb-design.md §2.3 のアドレス定数がこれを満たすよう設計されている。ビルド時 ASSERT と PyBoy の assert_no_page_cross() の両方で検査する。これにより flush の内側ループが inc de(2 M)ではなく inc e(1 M)で書ける。

q_push は dst / data / 新しい終端 $00 を先に書き、最後に len を 1 バイトだけ公開する。 ISR は未完成レコードを旧終端として扱えるため、ロックや割込み禁止を追加せずに更新がアトミックになる。

flush の内側ループ(ナイーブ版、初期実装はこれで足りる):

asm
.byte:  ld  a, [hl+]   ; 2 M
        ld  [de], a    ; 2 M
        inc e          ; 1 M   ← $xx00 非跨ぎ不変条件が保証する
        dec c          ; 1 M
        jr  nz, .byte  ; 3 M
                       ; = 9 M/byte(4 段アンロールで 6 M/byte まで落ちる)

最適化は原則禁止。 予算に余裕があるうちは可読性を優先し、hQHiwater が 128 を超えた実測が出た時点で初めてアンロールを起票する。各 issue の受入条件に「予算表を実測値で更新せずに高速化しない」を明記する。

4.4 キュー最悪ケース予算

最終実測(2026-09-03、main ef5fb31 + issue #17)

全 issue(#2〜#16)をマージした build/aidopagaki.gbc / build/aidopagaki-debug.gbc を PyBoy 2.7.0 の CGB/headless モードで通した hQHiwaterシーン別集約である。値はどれも「wFlowState が変わる直前のフレームのラッチ値」で、flow_goto が遷移のたびに q_hiwater_reset を通る(§4.3)ため各シーンで読み切っている。全シーン・全フレームで hQOverflow = 0。以降の「Issue #N 実測」欄は各子 issue 時点の記録として残す。

シーン / フレームhQHiwaterhQOpCount対 256 B出どころ
チュートリアル入場フレーム(製品 ROM の全シーン最大)90 B635%S5(tests/test_flow.py
デバッグ ROM 10 VRAM QUEUE STRESS73 B629%tests/test_debug_menu.py
ゲームオーバーフレーム67 B526%tests/test_flow.py
ポーズ解除 / 会話ページ / エンディングページ切替 / SONG SELECT ← →57 B422%tests/test_rank.py / test_text.py / test_flow.py
本番(曲 3 を FLOW_CLEAR まで 4,153 フレーム完走。S28)44 B417%S28(tests/test_budget.py
HUD だけのフレーム(入場 / CLEAR / ART→GAME 復帰)34 B313%tests/test_rank.py ほか
プロローグ 1 行 / SONG SELECT 入場24 B19%tests/test_art.py / test_flow.py
タイトル(MODE 行)15 B26%tests/test_art.py
エンディング入場フレーム0 B00%tests/test_text.py

製品 ROM の最大は 90 B で、設計目安の 67 B を 23 B 上回る。 原因はチュートリアル入場が「#10 の本文消去の最終行 23 B」と「#11 の init_note_highway が積む空白バナー 23 B + 判定行の空白 10 B」を HUD 34 B と同じ 1 フレームに重ねることである。対処は Q_MAX_OPS_PER_FRAME = 16 と E2E の hQHiwater <= 128 を据え置くこととした: 90 B は E2E 上限 128 B の 70%、キュー容量 256 B の 35% であり、§4.5 の q_flush はすでにこの 90 B / 6 op を設計最悪として dots を再計算している。消去とバナーをフレームをまたいで積む分割は、入場の見た目を 1 フレーム遅らせるだけで予算上の必要が無い。

opNESGBC内訳
判定文字 7 タイル10103 + 7
スコア 6 桁993 + 6
観客カウンタ 2 桁553 + 2
観客スタンド 1 行31(28 タイル)19(16 タイル)3 + 16
バナー 20 タイル13(10 タイル)233 + 20
終端11
ゲームプレイ最悪 合計69 / 96(72%)67 / 256(26%)
固定費由来のレコード数上限16 op / frameQ_MAX_OPS_PER_FRAME。詳細な導出は下記
その他のケース内訳GBC バイト
ポーズ解除フレームバナー消去 23 + 判定文字 10 + スコア 9 + 観客カウンタ 5 + 観客スタンド 1 行 19 + 終端 167
CLEAR フレームRANK 7 タイル 10 + バナー 20 タイル 23 + 観客スタンド 1 行 19 + スコア 9 + 観客カウンタ 5 + 終端 167
会話ページ更新(4 行を 1 行/フレームに分割)20 タイル 1 行 2323
Issue #3 Hello GBC 基準フレーム(PyBoy 2.7.0、300 フレーム)スコア 3 + 6 + 終端 110(hQHiwater 実測)
Issue #4 AI スプライト E2E フレーム(PyBoy 2.7.0、6 振り)スコア 3 + 6 + 終端 110(hQHiwater 実測)
Issue #5 ステージ BG 通常フレーム(PyBoy 2.7.0、600 フレーム)スコア 3 + 6 + 観客カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 134(hQHiwater 実測)
Issue #6 リズムコア 判定フレーム(PyBoy 2.7.0)判定文字 3 + 7 + スコア 3 + 6 + 観客カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 144(hQHiwater 実測)
Issue #7 サウンドドライバ BGM 再生中(PyBoy 2.7.0、5 曲 × 1,500-2,000 フレーム)サウンドドライバは snd_write_reg / snd_write_if_changed で APU レジスタとシャドウだけを更新し、VRAM キューには一切触れない34(hQHiwater 実測、Issue #6 の判定なしフレームと同値)
Issue #11 CLEAR 入場フレーム(PyBoy 2.7.0)RANK 7 タイル 3 + 7 + バナー 20 タイル 3 + 20 + 終端 1。⚠ main.asm .clear_performance が遷移直後にフレームを打ち切るので、HUD と観客行は同居しない34(hQHiwater 実測)
Issue #11 ポーズ解除フレーム(PyBoy 2.7.0)バナー消去 3 + 20 + スコア 3 + 6 + AUD カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 157(hQHiwater 実測)
Issue #11 ゲームオーバーフレーム(PyBoy 2.7.0)判定文字 3 + 7 + バナー 3 + 20 + スコア 3 + 6 + AUD カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 167(hQHiwater 実測、設計値と一致)
Issue #15 10 VRAM QUEUE STRESS(PyBoy 2.7.0、デバッグ ROM)設計最悪 5 op / 67 B に、hQHiwater の 3 桁読み出し 3 + 3 が乗る73(hQHiwater 実測、6 op)
Issue #15 12 SPEED TOGGLE の 1X 区間(PyBoy 2.7.0、デバッグ ROM)設計最悪 5 op / 67 B を通常速で 1,500 フレーム積み続ける67(hQHiwater 実測、hQOverflow = 0

Issue #3 実測(2026-09-02): build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで同一入力列 300 フレーム実行した。hQHiwater = 10 BhQOverflow = 0 だった。これは Hello GBC の基準フレームであり、後続 issue のゲームプレイ最悪値 67 B を置き換える測定ではない。

Issue #4 実測(2026-09-02): AI の 6 振り(各 20 フレーム)を含む E2E を PyBoy 2.7.0 の CGB/headless モードで実行した。hQHiwater = 10 BhQOverflow = 0hQOpCount 最大値 = 1。AI は OAM 4 OBJ、per-line の実測最大は 2 OBJ、VRAM bank 0 への AI 26F + game-over 2F のロード量は 3,584 B = 224 タイルだった。ビルド map の実測は ROM0 1,740 B、ROMX 3,776 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)。

Issue #5 実測(2026-09-02): ステージ BG(20×18 レイアウト・観客 2 段 32 席・視聴率ゲージ)を載せた build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで 600 フレーム実行し、途中で wAudience に 0 / 16 / 8 を注入した。hQHiwater = 34 BhQOverflow = 0hQOpCount 最大値 = 3(スコア 6 桁 / AUD: 2 桁 / 観客スタンド 1 行)。gdma_row1 フレームあたり最大 1 回で、観客値が変化したフレームにだけ発火した(600 フレーム中 4 回 = 起動時 1 + 注入 3)。視聴率ゲージは GDMA なので VRAM キューを 1 バイトも消費しない。BG タイルは bank0 $9000-$97FF128 / 128 タイルstage.2bpp 2,048 B)を使い切っている。GFX_BG セクションは 6,656 B(CHR 2,048 + tilemap 4×576 + attrmap 4×576)で、観客テーブル 11,424 B は 1 バイトも含まれない。ビルド map の実測は ROM0 2,212 B、ROMX 10,240 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)で、ROM 合計 12,452 B

Issue #6 実測(2026-09-03): リズムコア(レーン・判定・スコア・4 拍集計・メトロノーム・flow_goto)を載せた build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで実行した。曲 3 相当(23.578 f/拍、16 拍すべてノーツ)の譜面を注入し、5 フレームおきに 6 種の振りを入力した 400 フレームで、hQHiwater = 44 BhQOverflow = 0hQOpCount 最大値 = 4。最悪フレームの内訳は 判定文字 10(3 + 7)+ スコア 9(3 + 6)+ AUD カウンタ 5(3 + 2)+ 観客スタンド 1 行 19(3 + 16)+ 終端 1 = 44 B で、判定文字レコードだけが Issue #5 の 34 B に上乗せされている。⚠ 判定文字の色(BGP2 の 8 バイト)は VRAM キューを 1 バイトも使わない: 入力の無いフレームの hQHiwater は 34 B のままで、判定が起きたフレームだけが +10 B になる(tests/test_judge.py::test_the_judgement_colour_costs_no_vram_queue_bytes が差分をちょうど 10 B に固定している)。チュートリアル(メトロノーム 800 フレーム)も hQHiwater = 44 B / hQOverflow = 0。OAM は マーカー 1 + レーンノーツ 最大 5 枚で、per-line OBJ の実測最大は 6(アイ 4 枚とは Y 帯が異なるので加算されない)。⚠ 曲 3 の拍グリッドでは「レーン上 5 枚 + 遅刻クランプ 1 枚」は同時に成立しない(遅刻ノーツがある間、5 枚目の接近ノーツは必ず NOTE_APPROACH_FRAMES より遠い)。§3.3 の予測 6 枚に対し実測は 5 枚で、MAX_LANE_NOTES = 6 はスロット 1 枚ぶんの余裕を持つ。OBJ タイル bank0 はノーツ 6 種 12 + マーカー 2 + 紙吹雪 4 = 18 タイル(288 B)が加わり 242 / 256。ビルド map の実測は ROM0 3,564 B、ROMX 10,736 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)で、ROM 合計 14,300 B。⚠ 判定文字は bank0 の UI 英数フォントでは色が変わらない。assets.md §1.2 が全 BG パレットの c1 を C_WHITE に固定しており、UI フォントはパレット index 1 でしか描かれないため、BGP2 の c2/c3 を差し替えても画面は白のままだった。修正ラウンド 1 で 判定グリフ 13 タイル(空白 1 + P E R F C T G O D M I S)を index 2 で描き、VRAM bank1 の BG 窓末尾 $9730-$97FF(index $73-$7F)に常駐させ、判定行 7 セルの BG 属性を bit3 = 1 + BGP2 にした(§3.2 / assets.md §2.2)。グリフは常駐で毎フレームの転送が無く、判定文字の描画コストは 44 B のままである(judge_chr 208 B は起動時 1 回の gdma_bulk)。

Issue #7 実測(2026-09-03): サウンドドライバ(src/sound.asm)を載せた build/aidopagaki.gbcDRUM_USE_WAVE=1)/ build/aidopagaki-nowave.gbc=0)を PyBoy 2.7.0 の CGB/headless モードで実行し、tools/ref_sim_gb.py と APU 書込みイベント列を突き合わせた(tests/test_apu_refsim.py、S29)。収録 5 曲すべて × 両モードで PERFECT MATCH(曲 0-3 は 1,500 フレーム、曲 4 は 2,000 フレーム)、加えて 3 種のドラムをすべて含む合成曲 test_drums でも両モード 1,500 フレーム一致した。受入条件 10 のリトリガ定量ゲート(定常部で CH1/CH2 とも 8 回/秒以下)は 5 曲 + 合成曲すべてで達成: 曲 0 CH1 4/s・CH2 4/s、曲 1 CH1 4/s・CH2 4/s、曲 2 CH1 4/s・CH2 4/s、曲 3 CH1 6/s・CH2 5/s、曲 4 CH1 2/s・CH2 0/s、test_drums CH1 1/s・CH2 0/s(すべて volLoop 以降の定常部、全曲通しの最大は 6/s)。hQHiwater は BGM 再生中も 5 曲とも 34 B で Issue #6 の判定なしフレームと同値(サウンドは VRAM キューを消費しない)。sound_tick の実測 M-cycle は最悪 6,784、最良 1,600、平均約 3,700-4,200(§4.6)。ドラム再合成表 128 B は U1 のとおり ROMX BANK[1]"BGM Songs" セクションに配置し、note_div(168 B) / vol_mul(256 B) だけを HOME の例外として残した(§2.2)。CH4 の調停は U4/U5 のとおり nz にも R2 を適用し、ドラム占有中(kick 12f / snare 9f / tom 11f)は nz の実書込みを抑止して解放後に snd_write_if_changed のシャドウ比較で自動再送させた(§7.4 / §7.7)。ビルド map の実測は ROM0 6,962 B、ROMX 18,440 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)で、ROM 合計 25,402 B

Issue #11 実測(2026-09-03): CLEAR / RANK / ゲームオーバー / S ランク祝賀 / ポーズ / 裏技 3 種を載せた build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで実行した(tests/test_rank.py / tests/test_cheats.py / tests/test_flow.py)。hQHiwater はシーンごとに読み切っている(flow_goto が遷移のたびに q_hiwater_reset を呼ぶため)。CLEAR フレーム 34 BhQOpCount 最大 3)、ポーズ解除フレーム 57 B(同 4)、ゲームオーバーフレーム 67 B(同 5、設計値と一致)。3 ケースとも hQOverflow == 0 で、上限 128 B に対して最悪でも 52%。⚠ CLEAR フレームが設計値 67 B に届かないのは仕様どおりである: main.asm .clear_performancetrigger_clear の直後に .frame_done へ抜けるので、RANK とバナーだけが同じフレームに積まれ、スコア・AUD・観客行は次フレームに回る(この分離は NES #68 の遅延フラグ方針そのものである)。BG 属性の張り替え(判定行 7 セル)は VRAM キューを 1 バイトも消費しない: 属性は VRAM bank1 にあり q_flush が届かないため、判定色(BGP2)と同じく VBlank ISR の専用経路で 7 バイトを塗る。CLEAR 中の OAM 使用は設計値どおり 16 枠(アイ 4 + 講師 4 + 紙吹雪 8)である(#9 マージ後の実測。マージ前は講師 4 枠が無く 12 枠だった)。S 祝賀ではアイと講師が同じフレームに MOVE_A を積み、clear_pose_update が 2 体を同じ分岐で進めるのでジャンプ 18-21 の位相は永久に揃う。ゲームオーバーでも update_gameover_actors が同じ hFrameCount のビットでアイ 26/27 と講師 30/31 を切り替えるので位相は揃う。per-line OBJ の実測最大は 5(アイ 2 + 講師 2 + 紙吹雪 1)、同一 16 px 帯の紙吹雪は最大 1 枚で、§4.1 の不変条件(2 枚以下)を構造的に満たす。紙吹雪 8 枚に 16 px 等間隔の初期 Y を与えて 8 枚とも 1 px/frame で落とす実装が、この不変条件の根拠である(NES の 1/2 px 交互速度は移植していない)。ホイッスル SE は CH1 を 60 フレーム周期でリトリガし、x = 1976 - phase * 5 の 1 次式で 1,820 Hz → 357 Hz を滑る。⚠ 判定行の空白復帰(修正ラウンド 1): rank_row_attr_judge は BG 属性を判定グリフ(bank1 + BGP2)へ戻すのと同時に、判定行 7 セルを TILE_JUDGE_BLANKq_push(キュー 3 + 7 = 10 B)し直す。CLEAR が残した「RANK: X」の bank0 タイル index を消さないと、#10 のかなフォント窓(bank1 BG 窓 $00-$72)投入後にカウントイン中の判定行へ無関係なかなが 7 文字浮くためである。ビルド map の実測は ROM0 8,768 B、ROMX 24,423 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)で、ROM 合計 33,191 B。⚠ 修正ラウンド 2: init_note_highway(CLEAR 復帰・ゲームオーバー復帰・SELECT やり直し・チュートリアル入場の共通合流点)が rank_row_attr_judge の直後に BANNER_BLANKqueue_banner するようになり、本番復帰後も行 6 に CLEAR / GAME OVER バナーが残る不具合を塞いだ。追加コードは ROM0 に 5 B(ld a, n + call)で、ビルド map の実測は ROM0 8,773 B、ROM 合計 33,196 B(他は不変)。

Issue #15 実測(2026-09-03): デバッグメニュー ROM(make debug = -D DEBUG=1 -D DEBUG_MENU=1)の 10 VRAM QUEUE STRESS を PyBoy 2.7.0 の CGB/headless モードで実行した。メインループが積む 3 op(スコア 6 桁 / AUD カウンタ 2 桁 / 観客スタンド 1 行)に、項目が判定文字 7 タイルとバナー 20 タイルを足して設計最悪ケース 5 op / 占有 67 B を毎フレーム再現し、そこへ hQHiwater 自身の 3 桁表示(+1 op / +6 B)が乗る。hQHiwater = 73 B(上限 128 B の 57%)、hQOverflow = 0hQOpCount = 6。画面の 3 桁(BG row 3 col 0-2)は hQHiwater と完全一致する(tests/test_debug_menu.py::test_queue_stress_reports_hiwater_on_screen が両者の一致を固定している)。⚠ 73 は設計最悪 67 に測定器自身のコストが乗った値である。 予算表の基準値は 67 のままで、73 は「実測表示を出しながらでも上限の 57% に留まる」ことの記録である。

レコード数上限の導出(Q_MAX_OPS_PER_FRAME = 16): VBlank の 4,560 dots、倍速の 2 dots/M-cycle、既存の q_flush 以外の ISR 予算 419 M-cycle(2,394 - 714)から、q_flush に使える上限は (4,560 - 128) / 2 - 419 = 1,797 M-cycle となる。§4.5 の式 payload × 9 M/byte + 5 op × 51 M/op は 5 op / 67 B の最悪ケースを表すため、一般化すると payload × 9 + op × 51 である。設計最悪の 5 op / 67 B は 51 × 9 + 5 × 51 = 714 M-cycle で、上限を確実に通過する。hQHiwater <= 128 の範囲では、op 数ごとの最大 payload は 127 - 3 × op B なので、16 op では 79 B、27 op では 46 B までが予算内(それぞれ 1,527 M-cycle、1,791 M-cycle)だが、28 op では最大 43 B でも 1,815 M-cycle となり超過する。したがって、将来の設計最悪 5 op を許容しつつ、16 op で 270 M-cycle(540 dots)の余裕を残す値を採用する。病的な 31 レコード × len=1 は使用量 125 B だけならバイト検査を通るが、31 × 1 × 9 + 31 × 51 = 1,860 M-cycle 相当であり、op 数上限が捕捉する。

最悪ケースは 3 つとも 67 B で一致する。 これは偶然ではなく、「1 フレームに BG 行を 1 本しか更新しない」という不変条件(§6.3 の観客更新ルール)が上限を決めているためである。予算表の 67 は hQHiwater <= 128 の検査値の半分であり、2 倍の余裕がある。

視聴率ゲージは GDMA で更新するのでキューを 1 バイトも消費しない(§5.2)。

溢れ防止の遅延フラグは NES 版の 4 つをそのまま移植する: restart_pending / clear_pending / audience_queue_pending / scan_silent。予算に 4 倍の余裕があっても残す理由は、「1 フレーム 1 行」という不変条件そのものがテストで検査可能な性質だからである(hQHiwater <= 128 を全フレームでアサートできる)。

4.5 VBlank ISR 予算(dots)

VBlank = 4,560 dots。

最終実測(2026-09-03、main ef5fb31 + issue #17)

ISR の占有は PyBoy の CPU サイクル差分(VBlankHandler 入口 → VBlankHandler.isr_done)を倍率で割った実時間で、走査線 1 本ぶんの丸めが入らない(tests/harness.pyvblank_dots)。負荷は設計最悪キュー 67 B / 5 opgdma_row 2 本(視聴率ゲージ + 本文属性行)を毎フレーム重ねた 1,500 フレームである。

測定項目実測対 4,560 dots
VBlank ISR 占有(倍速 2X、最大)2,652 dots(最小 2,640。入口 LY 144 / 終端 LY 149)58.2%
同・gdma_row の転送停止 128 dots を足した値(倍速)2,768 dots60.7%
VBlank ISR 占有(通常速 1X、最大)5,304 dots(最小 5,280。終端 LY 1 = 次フレーム)116.3% ✗ 超過
gdma_row(倍速、1 フレームあたり)2 行 = 4 ブロック = 64 B(入口 LY 148 / 149、完了も同一走査線)100%(GDMA_MAX_ROWS_PER_VBLANK を使い切る)
gdma_row(通常速、1 フレームあたり)2 行。ただし 2 本目の入口 LY = 0(表示中)100%(✗ VBlank 外)
ISR の CPU 仕事量(占有 ÷ 速度倍率)1,320 M-cycle(1X と 2X で一致 = 完全に CPU 律速)

測っているのはデバッグ ROM である。 製品 ROM にはデバッグメニューが 1 バイトも入らないので、同じ 67 B / 5 op のフレームの製品側見積りは (1,320 - 29) × 2 + 128 = 2,710 dots = 59.4%(倍速)になる(29 M-cycle は gdma_row の引数 ASSERT 2 本 + APU リングログの巻き戻し。導出は下の S30 欄)。設計値 2,856 dots(62.6%)は実測 2,652-2,768 dots を上回る側にあり、予算表は保守的なままである。

q_flush の設計最悪は実測に合わせて 90 B / 6 op のままとする(§4.4 の最終実測でチュートリアル入場が 90 B / 6 op を出したため)。上の測定負荷 67 B / 5 op はデバッグメニューが再現できる最大であって、製品の最悪フレームではない。

項目M-cycledots(倍速 ×2)dots(通常速 ×4)
割込みディスパッチ + push af/bc/de/hl + バンク退避2754108
OAM DMA(HRAM スタブ + 160 M 待ち)168336672
VRAM キュー flush 90 Bpayload 71 B × 9 M/byte + 6 op × 51 M/op、ヘッダ 18 B と終端 1 B は op 側に含める)9451,8903,780
gdma_row × 2(本体 66 M + call 6 M = 72 M/回144288576
gdma_row × 2(転送 64 B × 2 dots/B、実時間固定128128
BG パレット 1 本書換(判定色、BGPI + BGPD × 8)4080160
BG パレット 8 本 64 B 一括反映(ART のフェード、wBgPalDirty。Issue #12)5971,1942,388
SCX / SCY / VBK / ROM バンク復帰204080
pop × 4 + reti204080
通常フレーム 合計1,3642,856 dots(62.6%)5,584 dots(122.5%)✗ 超過
Lv4 ハイプ フレーム(+ BGP1 / BGP3 16 B)1,4443,016(66.1%)5,904(129.5%)✗
VBlank GDMA 上限フレームgdma_row なし + GDMA 1,024 B + gdma_bulk 有効呼出し 44 M)1,2644,576 dots(100.4%)✗ 超過7,104 dots(155.8%)✗ 破綻

BG パレット 64 B の一括反映は ART のフェード中のフレームだけで起きる(Issue #12)。BGPI を 1 回書いてから BGPD へ 64 B を流す ld a,[hl+](2 M) / ldh [rBGPD],a(3 M) / dec c(1 M) / jr nz(3 M。最終周だけ 2 M)の 4 命令ループで 9 M/byte、64 回で 575 M。wBgPalDirty の判定と消去・rBGPI の設定・hl / c の仕込みが前後 22 M なので 597 M-cycle = 1,194 dots(倍速)src/vblank.asm.bg_pal_byte ループ)。ART のフレームは判定色 / 判定属性 / 視聴率ゲージをすべて持たない(wSceneIsArt でゲートしている)ので、この 1,194 dots は上の 3 項目と同じフレームには決して重ならない。ART のフレームは本文の消去も HUD も持たないので、下の q_flush 最悪(90 B / 6 op)とも重ならない。

q_flush の再計算は payload 71 B × 9 M/byte + 6 op × 51 M/op = 945 M-cycle である(#10 + #11 マージ後のチュートリアル入場フレーム実測 hQHiwater = 90 B / hQOpCount = 6 を設計最悪として採用: HUD 3 op + 空白バナー 1 op + 判定行の空白 1 op + 本文消去の最終行 1 op = 6 op、§4.4 実測欄)。90 B 全体を byte 単価に掛けず、ヘッダ 18 B(6 op × 3 B)+ 終端 1 B は 6 op のレコード処理に含める。

Lv4 ハイプのフラッシュは BGP1BGP3 を別々の BGPI オートインクリメント区間で塗る(隣接パレットではないので 1 回のループにまとめられない。src/judge.asmhype_flash_write)。判定色と同じ「BGPI + BGPD × 8 = 40 M」を 2 回払うので、通常フレームに +80 M が乗る。

gdma_row の production 経路も命令列から積算した。ld c,a から ret までが 66 M(ldh 各 3 M、ld h,0 / ld a,n 各 2 M、add hl,hl 10 回分 20 M を含む)で、VBlank ISR の call gdma_row 6 M を足して 72 M/回、2 回で 144 M となる。比較のため、GDMA 上限ケースの gdma_bulk は有効な C=64 経路が本体 38 M + call 6 M = 44 M である。

D1(倍速常用)の根拠は次の 2 点である。

  1. 通常速では VBlank ISR 単体でも収まらない。 gdma_bulk を 1 バイトも動かさない通常フレームでも、gdma_row × 2 と payload 71 B の flush を含めると通常速で 5,584 dots = 122.5%(1,024 dots 超過)になる。倍速なら同じフレームが 2,856 dots = 62.6% に収まる。
  2. GDMA_MAX_BLOCKS_PER_VBLANK = 64(1,024 B)という API 上限を使い切る場合、倍速でもわずかに超過する。 上限まで使ったフレームは通常速で 7,104 dots = 155.8%(破綻)、倍速でも 4,576 dots = 100.4%(4,560 dots 上限をわずかに超える)。現設計では gdma_bulk を VBlank から呼ばない(§5.2)のでこのフレームは到達しない(q_flush 90 B の実測ワーストと同一フレームで重なることも無い)。倍速でもなお収まらないという結果は、この上限ケースへ到達しない設計判断(§5.2)を裏付けるものとして残す。

つまり倍速は「1 が示す通常フレームの超過の解消」のために必須であり、通常速は現行のどのケースでも設計上限を満たさない。「2 の API 上限ケース」は倍速でも収まらないため、そもそも到達させない設計(§5.2)で回避している。

最終行は API 上限のケースであって、現設計のどのフレームでもない(上記 D1 の根拠 2)。シーン切替は LCD OFF 窓で行う(D6 / §5.4)ので、現行の VBlank に流れる GDMA は gdma_row 1 回 = 2 ブロック = 32 B である。GDMA_MAX_ROWS_PER_VBLANK = 2 なら最大 64 B まで許容し、GDMA_MAX_BLOCKS_PER_VBLANK = 64(1,024 B、§5.2)は別の一括転送 API 上限として扱う。通常速に落とす場合(デバッグ ROM の SPEED TOGGLE)は上限を 32 ブロックGDMA_MAX_BLOCKS_PER_VBLANK_1Xsrc/defines.inc)に下げ、2 フレームに分割する。現行の VBlank が流す GDMA は gdma_row 2 本 = 4 ブロック = 64 B だけなので、この上限の内側にそのまま収まる(src/vblank.asmASSERT VBLANK_GDMA_BLOCKS <= GDMA_MAX_BLOCKS_PER_VBLANK_1X がアセンブル時に固定する)。

検算(VBlank GDMA 上限フレーム): gdma_row を除いた本体は 27 + 168 + 945 + 40 + 20 + 20 = 1,220 M、ここへ gdma_bulk 有効呼出し 44 M を加えて 1,264 M。 倍速 = 1,264×2 + 1,024×2 = 2,528 + 2,048 = 4,576 dots / 通常速 = 1,264×4 + 1,024×2 = 5,056 + 2,048 = 7,104 dots

Issue #15 通常速フォールバック実測(S30)

デバッグメニュー ROM の 12 SPEED TOGGLErP1 = $30rIE = 0rKEY1 bit0 = 1 → stop の順に通常速へ落とし、設計最悪ケースのキュー負荷(5 op / 占有 67 B)を積み、かつ VBlank の gdma_row 2 本(ゲージ行 + 会話本文の属性行 = VBLANK_GDMA_ROWS)を毎フレーム発火させたまま曲 0 を 1,500 フレーム再生した。同じ ROM・同じフレーム構成を倍速でも 1,500 フレーム測り、A/B で読み比べている(tests/test_debug_menu.py::test_speed_toggle_falls_back_to_normal_speed)。

測定項目倍速(2X)通常速(1X)
VBlankHandler 入口 LY144(1,500 / 1,500)144(1,500 / 1,500)
VBlankHandler.isr_done LY149(1,500 / 1,500)1(1,500 / 1,500。VBlank を越えて次フレームの走査線 1)
gdma_row 入口 LY(2 本 / フレーム)148149(各 1,500)1530(各 1,500。2 本目は表示中)
ISR の占有(PyBoy の CPU サイクル実測)2,640 dots(57.9%)、最大 2,652(58.2%)5,280 dots(115.8%)✗ 超過、最大 5,304(116.3%)
同・gdma_row の転送停止 128 dots を足した値2,768 dots(60.7%)5,432 dots(119.1%)✗ 超過
ISR の CPU 仕事量(占有 ÷ 速度倍率)1,320 M-cycle1,320 M-cycle(同一)
hQHiwater / hQOpCount67 B / 5 op67 B / 5 op
hQOverflow00
完走

Issue #15 実測(2026-09-03。issue #12 マージ後に再測定): 通常速 1,500 フレームを hQOverflow = 0 で完走した(受入条件 4 は充足)。ただし VBlank ISR は VBlank に収まっていない。終端 LY は 1(VBlank の最終走査線 153 を越えて次フレームの可視期間へ 2 本ぶん食い込む)で、占有は 5,280 dots = 4,560 dots の 115.8% である。gdma_row 2 本のうち 2 本目は LY 0、つまり表示中に走っている(1,500 / 1,500)。

導出との突き合わせ: 上表の設計値(通常フレーム 5,584 dots = 122.5%)が想定する q_flush は 90 B / 6 op(945 M-cycle)だが、この測定の実負荷は 67 B / 5 op51 B × 9 + 5 op × 51 = 714 M-cycle)である。表の項目をそのまま置き換えると 1,364 - 945 + 714 = 1,133 M-cycle → 通常速 1,133 × 4 + 128 = 4,660 dots ≈ 102% になる。実測の CPU 仕事量は 1,320 M-cycle(1X の 5,280 dots ÷ 4 = 2X の 2,640 dots ÷ 2。両者が一致するので ISR は完全に CPU 律速である)で、表の積算より 187 M-cycle 多い。うち DEBUG ビルドだけが持つのは 29 M-cyclegdma_row の引数 ASSERT 12 M × 2 本 + APU リングログの巻き戻し 5 M)だけで、残る約 158 M-cycle は上表が項目化していない製品 ROM にもある ISR 経路である(q_flush のレコード境界検査、issue #12 が足した wBgPalDirty / wSceneIsArt のゲート 2 本、視聴率ゲージの帯域色 rBGPD 2 バイト、属性行の rVBK 切替とダーティフラグ処理、hFrameCount / hVBlankFlag の更新)。したがって同じ 67 B / 5 op のフレームを製品 ROM で走らせた場合の見積りは (1,320 - 29) × 4 + 128 = 5,292 dots = 116.1% である。表の導出(4,660 dots = 102%)でも実測(5,292-5,432 dots = 116-119%)でも 4,560 dots を越えるので、§4.5 の「通常速は VBlank に収まらない」という結論は覆らない。D1(倍速常用)の判断はそのまま維持する。

PyBoy は GDMA の転送停止をモデル化しない(§4.7 の Issue #9 注記と同じ制約)。gdma_row 2 本は呼出しオーバーヘッドも rBGPD 書込みも測定の内側に入っているgdma_row フックが 1 フレームあたり 2 回、1,500 フレームで 3,000 回発火している)。未計上なのは転送そのものの 128 dots(64 B × 2 dots/B)だけで、上表の「128 dots を足した値」がその補正である。実機ではこのぶん終端 LY がさらに後ろへ動く。

1X では 2 本目の gdma_row が LY 0(表示中)で走る。 PyBoy は表示中の GDMA も素通しするので測定は完走したが、実機では PPU と VRAM を奪い合うため画面が乱れる。hQOverflow = 0 で走り続けるという受入条件 4 は満たすが、1X は「落としても止まらない」ことを確認するための退避モードであり、常用構成ではない。

Issue #3 VBlank 実測

測定項目PyBoy 2.7.0 実測
VBlankHandler 入口LY = 144
gdma_row 入口 / .doneLY = 145 / 145(300 回中 300 回)
gdma_row の転送量HDMA5=1 → 2 ブロック = 32 B / VBlank(gdma_row 1回分)
VRAM キューhQHiwater = 10 BhQOverflow = 0

gdma_row の入口と .done の両方が LY 144-153 内にあり、VBlank 専用の呼出し条件を満たすことをフックで確認した。上表の dots 予算は後続機能を含む設計上限であり、この基盤 ROM の実測は呼出し位置と基準キュー量の記録として追記している。

4.6 フレーム全体の予算(倍速)

1 フレーム = 70,224 dots = 35,112 M-cycle

区分M-cycle比率
VBlank ISR(最悪。§4.5 の q_flush 90 B / 6 op 再計算後)1,3643.9%
sound_tick(Issue #7 実測最悪。設計値 900 は過小)6,78419.3%
観客行のランタイム合成1500.4%
ゲームロジック(判定 / 集計 / フロー)1,2003.4%
OAM 構築(16 OBJ)5001.4%
ノーツレーン描画4001.1%
HUD / キュー積み6001.7%
合計(Issue #7 実測 + §4.5 再計算反映)10,99831.3%
余裕24,11468.7%

この 69.3% の余裕こそが本設計の主張である。 ナイーブで読みやすいコードを書いても破綻しない。

Issue #7 sound_tick 実測(2026-09-03): build/aidopagaki.gbc で 5 曲すべて(曲 4 のみ 2,000 フレーム、他は 1,500 フレーム)を PyBoy 2.7.0 の CGB/headless モードで再生し、sound_tick の DIV 差分から M-cycle を毎フレーム測定した(分解能 64 M-cycle、倍速)。5 曲通しての最悪値は 6,784 M-cycle(曲 0 / 2 / 3 で観測。ドラム発火フレームがバンク退避/復帰 + CH1-4 全チャンネル更新 + drum テーブル参照を同一フレームに重ねるため)、最良値は 1,600 M-cycle(曲 2、更新の少ない静音フレーム)、平均は約 3,700-4,200 M-cycle/曲。設計値 900 M-cycle は 5ch 分の snd_env_frame / snd_calc_div / fx 位相更新をすべて含めていなかった過小見積りであり、本表を実測値に置き換える。フレーム全体の合計 10,767 M-cycle でも上限 35,112 M-cycle の 30.7% に留まり、余裕は依然 69.3% あるので追加の最適化は不要と判断する。BGM 再生中の hQHiwater は 5 曲とも 34 B(Issue #6 の判定なしフレーム基準と同値)で、サウンドドライバは VRAM キューを 1 バイトも消費しない。

4.7 予算サマリ(1 枚表)

最終実測(2026-09-03、main ef5fb31 + issue #17)

全 issue(#2〜#16)マージ後のクリーンビルド(make clean && make && make debug && make nowave)の build/aidopagaki.map と、make e2e の 317 シナリオが読み切った実測を 1 枚に集約する。以降の「Issue #N 実測」行は各子 issue 時点の記録として残す(同じ項目の現在値は本表が正典)。

予算項目上限最終実測使用率
ROM016,384 B11,705 B(空き 4,679 B)71.4%
ROMX(7 バンク)114,688 B49,211 B(空き 65,477 B)42.9%
ROM 合計131,072 B(128 KB / 8 バンク)60,916 B46.5%
WRAM04,096 B1,536 B(製品)/ 2,059 B(DEBUG)37.5% / 50.3%
HRAM127 B36 B(製品)/ 37 B(DEBUG)28.3% / 29.1%
VRAM キュー hQHiwater(全シーン最大)256 B(E2E 上限 128 B)90 B(チュートリアル入場フレーム。§4.4)35%(E2E 上限比 70%)
VRAM キュー hQOpCount(全シーン最大)16 op6 op(同フレーム)38%
hQOverflow(全シーン・全フレーム)00
OAM per-line(全シーン最大)10 OBJ6 OBJ(本番レーン。マーカー 1 + ノーツ 5。S28 の 4,153 フレーム完走で確認)60%
OAM 総数(最大)40 OBJ16 OBJ(CLEAR: アイ 4 + 講師 4 + 紙吹雪 8)40%
VBlank gdma_row2 行 / VBlank2 行(視聴率ゲージ 1 + 本文属性行 1。§4.5)100%
VBlank ISR 占有(倍速、実測最大)4,560 dots2,652 dots(§4.5)58.2%
OBJ タイル bank0256242(ノーツ + マーカー + 紙吹雪 + アイ + game-over)95%
OBJ タイル bank1256256(講師 32F)100%
BG タイル bank0(GAME)128128stage.2bpp100%
BG タイル bank1(かな窓、GAME)115(判定グリフ 13 を除く)76(エンディング)66%
一枚絵タイル(ART bank0)240233(プロローグ p2。5 枚中の最大)97%
ART テキストタイル(ART bank1)256108(英数 47 + 区切り 1 + かな 60)42%

上限に張り付いている 3 項目(OBJ bank1 256/256、BG bank0 128/128、gdma_row 2/2)はいずれも「使い切って完成」であり、増やす予定が無い。 講師 CHR は 32 フレームで bank1 の OBJ 窓ちょうど、ステージ BG は 128 タイルちょうど、gdma_row は視聴率ゲージと本文属性行の 2 本で GDMA_MAX_ROWS_PER_VBLANK を使い切る。設計値を上回ったのは hQHiwater の 90 B(設計目安 67 B)1 項目だけで、原因と対処は §4.4 の最終実測欄に書いた。

予算項目上限最悪(設計値)使用率
VBlank(倍速、通常フレーム)4,560 dots2,856 dots63%
VBlank(倍速、GDMA 上限ケース)4,560 dots4,576 dots100% ✗
VBlank(通常速、通常フレーム)4,560 dots5,584 dots123% ✗
フレーム全体(倍速)35,112 M-cycle4,883 M-cycle14%
VRAM キュー256 B67 B26%
VRAM キュー(Issue #3 Hello 基準、実測)256 B10 B(hQHiwater4%
VBlank gdma_row(Issue #3、実測)LY 144-153入口 145 / 完了 14532 B / VBlank
VRAM キュー(Issue #4 AI スプライト、実測)256 B10 B(hQHiwater4%
OAM(Issue #4 AI スプライト、実測)40 OBJ4 OBJ10%
OAM(per-line、Issue #4 AI スプライト、実測)10 OBJ2 OBJ(同一走査線の実測最大)20%
OBJ タイル bank0(Issue #4 AI + game-over、実測)25622488%
ROM 使用量(Issue #4 build map、実測)128 KB5,516 B4.2%
VRAM キュー(Issue #5 ステージ BG、実測)256 B34 B(hQHiwater13%
VRAM キュー op(Issue #5、実測)16 op3 op19%
VBlank gdma_row(Issue #5、実測)2 行 / VBlank1 行(観客値が変化したフレームだけ)50%
BG タイル bank0(Issue #5 ステージ BG、実測)128128100%
ROM 使用量(Issue #5 build map、実測)128 KB12,452 B9.5%
VRAM キュー(Issue #6 リズムコア、実測)256 B44 B(hQHiwater17%
VRAM キュー op(Issue #6、実測)16 op4 op25%
OAM(per-line、Issue #6 レーン、実測)10 OBJ6 OBJ(マーカー 1 + ノーツ 5)60%
OBJ タイル bank0(Issue #6 ノーツ + マーカー + 紙吹雪、実測)25624295%
ROM 使用量(Issue #6 build map、実測)128 KB14,300 B10.9%
BG タイル bank1(Issue #6 判定グリフ、実測)12813$73-$7F 常駐)10%
VRAM キュー(Issue #7 サウンドドライバ、実測)256 B34 B(hQHiwater(BGM 再生中も不変。サウンドは VRAM キューを消費しない)13%
sound_tick(Issue #7、実測最悪)35,112 M-cycle6,784 M-cycle19.3%
ROM 使用量(Issue #7 build map、実測)128 KBROM0 6,962 B / ROMX 18,440 B = 合計 25,402 B19.4%
VRAM キュー(Issue #8 譜面 4 曲統合、実測)256 B44 B(hQHiwater(本番曲 3「クリムゾン・オーバードライブ」を $FF 終端まで通した 4,210 フレームの最悪値。シーンが張り替わる直前のラッチ値)17%
VRAM キュー op(Issue #8、実測)16 op4 op25%
VRAM キュー(Issue #8 SELECT やり直し、実測)256 B34 B(hQHiwater(再カウントインを挟むフレーム)13%
ROMX bank1(Issue #8 曲 + 譜面、実測)16,384 B9,591 B(BGM ストリーム 7,704 B + 譜面/拍ターゲット表 1,887 B)59%
ROM 使用量(Issue #8 build map、実測)128 KBROM0 7,334 B / ROMX 20,327 B = 合計 27,661 B21.1%
VRAM キュー(Issue #9 講師スプライト、実測)256 B34 B(hQHiwater(講師は VRAM キューを 1 バイトも使わない)13%
OAM(per-line、Issue #9 キャラ帯、実測)10 OBJ4 OBJ(アイ 2 + コーチ 2)40%
OBJ タイル bank1(Issue #9 講師 CHR、実測)256256100%
VRAM キュー(Issue #15 10 VRAM QUEUE STRESS、実測)256 B73 B(hQHiwater(設計最悪 67 + 実測表示 6)29%
VRAM キュー op(Issue #15、実測)16 op6 op38%
VBlank ISR(Issue #15 倍速・設計最悪キュー + gdma_row 2 本、実測)4,560 dots2,640 dots(入口 LY 144 / 終端 LY 149)57.9%
VBlank ISR(Issue #15 通常速・設計最悪キュー + gdma_row 2 本、実測)4,560 dots5,280 dots(入口 LY 144 / 終端 LY 1 = 次フレーム)115.8%(✗ 超過。§4.5 S30)
ROMX bank7(Issue #15 デバッグメニュー、実測)16,384 B1,219 B-D DEBUG_MENU=1 のみ。ROM0 トランポリンは別に 165 B。製品はどちらも 0 B)7%
WRAM0(Issue #15 デバッグメニュー、実測)4,096 B製品 1,536 B / DEBUG 2,059 BwApuLog 513 + デバッグメニュー 10)38% / 50%
ART→GAME の CHR 再ロード(Issue #9、分割 GDMA、導出値)70,224 dots / フレーム20,032 dots = bank0 3,872 B(128 + 96 + 18 ブロック)+ bank1 4,096 B(128 + 128 ブロックの 2 回)+ BG 2,048 B(128 ブロック)29%
ROM 使用量(Issue #8 + #9 マージ後 build map、実測)128 KBROM0 7,757 B / ROMX 24,423 B = 合計 32,180 B24.6%
VRAM キュー(Issue #10 会話ページ更新、実測)256 B57 B(hQHiwater(HUD 34 + 会話行 23)22%
VRAM キュー op(Issue #10 会話ページ、実測)16 op4 op(会話行 / スコア / AUD カウンタ / 観客スタンド 1 行)25%
VRAM キュー(Issue #10 Lv4 ハイプ発火フレーム、実測)256 B44 B(hQHiwater(フラッシュはパレット RAM の直書きでキューを 1 バイトも使わない)17%
VBlank gdma_row(Issue #10 テキスト属性行、実測)2 行 / VBlank2 行(視聴率ゲージ 1 + 本文属性行 1)100%
BG タイル bank1(Issue #10 かな窓、実測)115(判定グリフ 13 を除く)76(エンディング。会話 54 / プロローグ 60)66%
ROMX bank5(Issue #10 かな + テキスト、実測)16,384 B5,220 B(かなフォント 3 種 3,040 B + 行データ 2,180 B)32%
ROM 使用量(Issue #10 build map、実測)128 KBROM0 8,794 B / ROMX 29,643 B = 合計 38,437 B29.3%
ROM 使用量(Issue #9 + #11 マージ + #11 修正ラウンド 2 後 build map、実測)128 KBROM0 8,773 B / ROMX 24,423 B = 合計 33,196 B25.3%
VRAM キュー(Issue #12 タイトル、実測)256 B15 B(hQHiwaterMODE:NORMALMODE:EASY の 11 B + ヘッダ 3 B + 終端 1 B。カーソル移動は 1 B × 2 本で 9 B)6%
VRAM キュー op(Issue #12 タイトル、実測)16 op2 op(カーソルの消去 + 描画)13%
VRAM キュー(Issue #12 ART→GAME 復帰フレーム、実測)256 B34 B(hQHiwater(Issue #5 / #7 / #9 の基準値と同値)13%
一枚絵タイル(Issue #12 タイトル、実測)240211.2bpp 3,376 B。-u -m の反転統合後)88%
ART テキストタイル bank1(Issue #12 タイトル、実測)25648(UI 英数 47 + 区切り線 1。タイトルはかなを使わない)19%
ART 入場の gdma_bulk(Issue #12、実測)128 ブロック / 転送4 回 / 259 ブロック = 4,144 B(bank0 128 + 83、bank1 47 + 1。8,288 dots)最大 128 / 128
ART→GAME 復帰の gdma_bulk(Issue #12、実測)128 ブロック / 転送9 回 / 711 ブロック = 11,376 B(bank0 OBJ 128 + 96 + 18、bank1 OBJ 128 + 128、BG 128、play tilemap 36、attrmap 36、判定グリフ 13。22,752 dots ≈ 0.32 フレーム)最大 128 / 128
ROMX bank6(Issue #12 ART_A、実測)16,384 B3,920 B(タイル 3,376 + tilemap 240 + attrmap 240 + パレット 64)24%
ROM 使用量(Issue #12 単独 build map、実測)128 KBROM0 9,793 B / ROMX 28,343 B = 合計 38,136 B29.1%
ROM 使用量(Issue #10 + #11 マージ後 build map、実測)128 KBROM0 9,810 B / ROMX 29,643 B = 合計 39,453 B30.1%
VRAM キュー(Issue #10 + #11 マージ後、チュートリアル入場フレーム、実測)256 B90 B(hQHiwater(HUD 34 + init_note_highway の空白バナー 23 + 判定行の空白 10 + 本文消去の最終行 23)。⚠ 設計目安の 67 B は超えるが S3 の上限 128 B の内側。#10 の本文消去と #11 の入場時の後始末が同じフレームに重なる 1 フレームだけの値である35%
ROM 使用量(Issue #10 修正ラウンド 1 後 build map、実測)128 KBROM0 9,822 B(+12 B) / ROMX 29,643 B(不変) = 合計 39,465 B30.1%
VRAM キュー(Issue #14 エンディング入場フレーム、実測)256 B0 B(hQHiwater(かな窓 76 ブロック = 1,216 B は GDMA、本文の消去は次フレームから。入場フレームは VRAM キューを 1 バイトも使わない)0%
VRAM キュー(Issue #14 エンディングページ切替フレーム、実測)256 B57 B(hQHiwater(HUD 34 + 本文行 23。会話ページ更新と同値)22%
ROM 使用量(Issue #14 単独 build map、実測)128 KBROM0 9,998 B(#10 修正ラウンド 1 から +176 B) / ROMX 29,643 B(不変) = 合計 39,641 B30.2%
ROM 使用量(Issue #10 + #11 + #12 マージ後 build map、実測)128 KBROM0 10,926 B / ROMX 33,563 B = 合計 44,489 B33.9%
ROM 使用量(Issue #14 + #12 + #15 マージ後 build map、実測)128 KBROM0 11,094 B / ROMX 33,563 B = 合計 44,657 B34.1%
一枚絵タイル(Issue #13 プロローグ 4 枚、実測)240233(p2 が最大。p1 208 / p3 206 / p4 195)97%
ART テキストタイル bank1(Issue #13 プロローグ、実測)256108(UI 英数 47 + 区切り線 1 + かな 60)42%
ART 入場の gdma_bulk(Issue #13 プロローグ、実測)128 ブロック / 転送5 回 / 316 ブロック = 5,056 B(bank0 128 + 80、bank1 47 + 1 + かな 60。10,112 dots)最大 128 / 128
ページ差し替えの gdma_bulk(Issue #13、実測)128 ブロック / 転送2 回 / 233 ブロック = 3,728 B(p2 が最大。絵だけを運び、bank1 のテキストタイルは運び直さない。7,456 dots)最大 128 / 128
ページ差し替えの LCD OFF 窓(Issue #13、実測)1 窓 / 遷移1 窓tick() の VBlank 緩和は 2 フレーム。窓がフレーム境界を跨ぐため)100%
VBlank gdma_row(Issue #13 プロローグ、実測)2 行 / VBlank0 行(ART は視聴率ゲージも本文属性行も持たない)0%
VRAM キュー(Issue #13 プロローグ、実測)256 B24 B(hQHiwater(かな 1 行 20 B + ヘッダ 3 B + 終端 1 B。1 フレームに積む行は 1 行)9%
VRAM キュー op(Issue #13 プロローグ、実測)16 op1 op(8 フレームに 1 行)6%
ROMX bank6(Issue #13 ART_A、実測)16,384 B12,064 B(タイトル 3,920 + p1 3,872 + p2 4,272)74%
ROMX bank7(Issue #13 ART_B、実測)16,384 B7,504 B(p3 3,840 + p4 3,664)46%
ROM 使用量(Issue #13 マージ後 build map、実測)128 KBROM0 11,357 B / ROMX 49,211 B = 合計 60,568 B46.2%
VRAM キュー(Issue #14 SONG SELECT 入場フレーム、実測)256 B24 B(hQHiwater(選曲行 3 + 20 + 終端 1。game_scene_restoreq_reset / q_hiwater_reset を通ったあとの最初の 1 フレームなので HUD はまだ積まれていない)9%
VRAM キュー(Issue #14 SONG SELECT ← → 移動フレーム、実測)256 B57 B(hQHiwater(HUD 34 + 選曲行 23。会話ページ更新と同値。無操作のフレームは HUD だけの 34 B)22%
ROM 使用量(Issue #14 + #13 マージ + SONG SELECT 実装後 build map、実測)128 KBROM0 11,705 B / ROMX 49,211 B(#13 から不変) = 合計 60,916 B46.5%
OAM(総数)40 OBJ16 OBJ(CLEAR 時)40%
OAM(per-line、レーン)10 OBJ7 OBJ70%
OBJ タイル bank025624295%
OBJ タイル bank1256256100%
BG タイル bank0(GAME)12811288%
BG タイル bank1(かな窓、GAME)115(判定グリフ 13 を除く)76(エンディング)66%
一枚絵タイル(ART bank0)240233(プロローグ p2、実測。5 枚中の最大)97%
ART テキストタイル(ART bank1)256108(英数 47 + 区切り 1 + かな 60)42%
ROM128 KB(8 バンク)60,916 B(全 issue マージ後の実測。上の最終実測表と同値)46.5%
WRAM bank04,096 B1,536 B(製品)/ 2,059 B(DEBUG)38% / 50%
HRAM127 B36 B(製品)/ 37 B(DEBUG)28% / 29%

上表の「ROM 使用量」7 行の ROM0 は同じ系列の異なる時点の実測である。 9,793 B は #12 を main(#10 マージ前)に載せた単独の値、9,810 B は #10 + #11 マージ直後(#10 修正前)、9,822 B は #10 修正ラウンド 1 適用後、10,881 B が #12 を #10 + #11 の上へマージし結線(裏コマンドのハイプ、ART のかな窓経路、会話中 SELECT の BGM 切替)まで入れ、E2E 用の状態注入 wFlowProbe を削除した(-16 B)値、そして 10,926 B が #12 修正ラウンド 1(game_scene_restore の open / close 分割と flow_enter_entrance の 1 窓化 +30 B、kana_load_dialog_raw +11 B、title_confirm のフェードイン中ガード +3 B、Init の明示 di +1 B = +45 B)を適用した現在値である。#10 修正ラウンド 1 の +12 B の内訳: rhythm.asm.tutorial ブロックに call beep_off(3 B)、text.asmkana_window_loadtext_bank_enter / text_bank_leave と同じ退避・復帰方式に変更(push af + ロード/ストア差し替えで +9 B)。issue #15(デバッグメニュー)はコードもデータもすべて -D DEBUG_MENU=1 の内側にあるので、この行のどれも 1 バイトも動かさない(マージ後の製品 ROM の sha256 が origin/main のビルドと一致することを確認した)。11,094 B は #12 と #15 を #14(エンディング)のブランチへマージした値である(#14 単独の 9,998 B に #12 のタイトル / ART / メニューが乗り、progress_continue_target の呼出しが title.asm の CONTINUE 行に結線された)。11,357 B は #13(プロローグ 4 枚)を main へマージした時点の値、11,705 B が #13 を #14 のブランチへ取り込み SONG SELECT(src/song_select.asm の enter / update / 曲名 4 行 80 B)まで入れた現在値である。#13 との差 +348 B は SONG SELECT の 173 B と、テキストエンジンのシーン統合(#13 の GAME / ART 分岐と #14 の DIALOG / ENDING ディスパッチ表を 3 シーンの表に畳んだぶん。表 6 種が 2 → 3 エントリで +12 B、ページ表・行ポインタ表の間接が 1 段増える)である。ROMX は #13 の 49,211 B から 1 バイトも動いていない(選曲の曲名は ROM0 の "Song Select Tables" に置いた)。以降このブランチを参照する場合は ROM0 11,705 B / ROMX 49,211 B を現在値とする。

これらは設計値である。 各 issue の受入条件で実測値に置き換え、PR 本文に hQHiwater の実測を貼る(NES 版「コミット本文が検証レポート」の継承)。

Issue #13 実測(2026-09-03): プロローグ 4 枚(assets/art/prologue{1,2,3,4}.png 160×96 → rgbgfx -c gbc: -u -m -N 240 -b 0)のユニークタイルは 208 / 233 / 206 / 195.2bpp3,328 / 3,728 / 3,296 / 3,120 B.tilemap / .attrmap は 4 枚とも各 240 B.gbcpal は各 64 B。5 枚は ART_A(bank6、12,064 B)と ART_B(bank7、7,504 B)に収まり、256 KB 化($0148 = $03)は不要である(未決 A4 / ../design/issues.md I3 の解消)。

プロローグ入場(flow_enter_prologueart_scene_begin + art_load_kana_prologue)の gdma_bulk5 回 / 316 ブロック = 5,056 B(bank0 に絵 128 + 80、bank1 に UI 英数 47 + 区切り線 1 + かな 60)。ページ差し替え(art_page_begin)は 2 回 / 最大 233 ブロック = 3,728 B で、bank1 のテキストタイル 1,728 B は既に載っているので運び直さない。LCD OFF 窓はページ送り 1 回につき 1 回で、tick() の VBlank 緩和は窓がフレーム境界を跨ぐぶん 2 フレームまで(tests/test_art.py が固定)。

hQHiwaterプロローグ全体で 24 B(かな 1 行 20 B + キューのヘッダ 3 B + 終端 1 B)。ART では視聴率ゲージも本文属性行も無いので VBlank の gdma_row は 0 行であり、#10 が使っていた属性行の 1 本ぶんがまるごと空く。属性面はプロローグでは書き換えない: art_build_map が row 12-17 に ART_TEXT_ATTR(bit3 = 1 | BGP7)を塗り、プロローグ行の属性は全 20 セルが bit3 のみ(tools/test_text_pages.py が検査)なので、合成しても同じ値にしかならない。

自動送りは 240 フレームごと(実測 240 / 241 / 241 フレーム)。暗転 16 段を ATTRACT_PAGE_FRAMES - FADE_STEPS から始めるので、ページが実際に変わるのがちょうど 240 フレーム目になる(タイトルの 600 フレームアトラクトと同型)。1 ページは 3 行 × 8 フレーム = 24 フレームで出そろうので、送り切る前に次ページへ行くことは構造的に起きない。

Issue #12 実測(2026-09-03): タイトル一枚絵(assets/art/title.png 160×96 → rgbgfx -c gbc: -u -m -N 240 -b 0)は ユニークタイル 211 / 240.2bpp 3,376 B.tilemap / .attrmap240 B.gbcpal 64 BART_A(ROMX bank6)の実占有は 3,920 B / 16,384 B

ART 入場(art_scene_begin)の gdma_bulk4 回で、bank0 が $8000 へ 128 ブロック + $8800 へ 83 ブロック(絵 211 タイル)、bank1 が $8000 へ 47 ブロック(UI 英数)+ $82F0 へ 1 ブロック(区切り線)である。どの転送も 128 ブロック以下で、入口と完了の VBK が同一(バンクを跨がない)ことを tests/test_art.py がフックで固定している。ART→GAME 復帰(game_scene_restoregame_chr_reload)は 9 回 / 711 ブロック = 11,376 B = 22,752 dotsInit も同じ game_chr_reload を呼ぶので、起動経路と復帰経路で手順が分岐しない。

CHR 往復の所要時間は導出値である(Issue #9 と同じ理由。PyBoy は GDMA の CPU 停止をモデル化しない)。実測なのは転送の回数・ブロック数・バンク・LCD 状態だけである。

hQHiwaterタイトルで 15 BMODE 行の 11 B が最大のレコード)、ART→GAME 復帰フレームで 34 B(#5 / #7 / #9 の基準値と同値)。ART のフレームは HUD も観客も持たないので、キューはメニュー操作のあるフレームしか使わない。

シーン切替のフレームは VBlank ISR が 1 回も走らない。 LCD OFF 中は LY が 0 で停止して VBlank 割込みが来ないためで、tests/harness.pytick() はこのフレームに限って「VBlank フック 0 回」を許す。緩和が発火するのは 1 遷移あたり 2 フレーム以下であることを tests/test_art.py が固定している。

Issue #9 実測(2026-09-03): 講師 32 フレーム(assets/coach-32f.2bpp 4,096 B)を VRAM bank1 の OBJ 窓に常駐させ、draw_coach が OAM 4-7 に 8x16 OBJ を 4 枚積む状態で build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで実行した。FLOW_PERFORMANCE の 240 フレームと FLOW_ENTRANCE の入場 90 フレームのいずれも hQHiwater = 34 B / hQOverflow = 0 で、Issue #5 / #7 の基準値から 1 バイトも増えていない(講師は毎フレームの CHR 転送も HUD 更新も持たないため VRAM キューを消費しない。NES 版の CHR ストリーマを全廃した直接の効果である)。per-line OBJ の実測最大は画面全体でも キャラ帯(AI_Y から 32 px)でも 4(アイ 2 + コーチ 2)で、上限 10 の 40%。assets/nes/instructor-dance-sprite-nes-v1.chrnes_aiDopagaki@218089021b64af9026dcaf8f0416d8e4cb394eca から vendored した 4,096 B で、bank1 の OBJ 窓を 256 / 256 タイルちょうど埋める(フレーム追加の余地は無い)。

CHR 再ロード時間は導出値であって実測値ではない。 gdma_bulk は汎用 GDMA(HDMA5 bit7 = 0)で CPU を停止させるが、PyBoy はこの停止時間をモデル化しないDIV 差分が 0 になる)ため、エミュレータからは測れない。上表の 20,032 dots は §5.4 の 2 dots/byte から求めた値で、転送の回数とブロック数だけが実測である: 起動時の gdma_bulk フックが bank1 へ $8000 128 ブロック + $8800 128 ブロックのちょうど 2 回、いずれも LCD OFF・VBK = 1 で入って VBK = 1 のまま完了することを tests/test_coach.py::test_coach_chr_is_loaded_by_two_lcd_off_bank1_transfers が固定している。

メインループが 1 走査線ぶん伸びた。 update_coach_pose / draw_coach の追加で MainLoop.lane_composed の LY 実測が 150-152 → 151-153 になった。フレーム全体の予算(35,112 M-cycle)から見れば誤差だが、.lane_composed より後ろの処理は PyBoy のフレーム境界を越えうる。Issue #8 の譜面と BGM を重ねた実装ではこれが常態で、1 周ぶんの状態を読む E2E はフレーム境界ではなく MainLoop.frame_donetests/harness.MainLoopSampler)で採る。レーンの OAM だけは走査が完了した瞬間が要るので .lane_composed フックを使う。

build_row_scratch はメインループの後半(.lane_composed の直後)に残す。 #9 の作業ブランチでは入力応答をフレーム境界で観測するために read_pad の直後へ移していたが、#8 が同じ問題を frame_done サンプリングで解いたので移動は不要になった。前半へ動かしてはならない: E2E が譜面と wSongFrame を注入するフレーム境界の位置がずれ、draw_note_highway の走査の途中に注入が挟まって前半のスロットだけ古い譜面で描かれる(tests/test_lane.py の 7 件が落ちる)。

Issue #10 実測(2026-09-03): かなフォント 3 種(会話 54 / プロローグ 60 / エンディング 76 タイル = 864 / 960 / 1,216 B)、テキストエンジン(8 フレームに 1 行)、会話 3 ページ、チュートリアル 6 ノーツ、Lv4 ハイプを載せた build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで実行した。タイトルから本番までの実チェーン(wFlowState 9 → 8 → 0 → 1 → 4 → 2 → 3 → 5 → 6)を通し、状態が変わる直前のフレームの値をラッチした結果、会話・消去・チュートリアル・バナーのどの状態でも hQHiwater = 57 B / hQOpCount 最大 4 / hQOverflow = 0 だった。内訳は HUD の 34 B(スコア 9 + AUD カウンタ 5 + 観客スタンド 1 行 19 + 終端 1)に会話行 1 本 23 B(3 + 20)が乗った 57 B で、予算表の「会話ページ更新 = 23 B」と完全に一致する。行を積まないタイトルと入場(FLOW_ENTRANCE)は 34 B のままである。⚠ 消去の最終行と LETS DANCE バナーを同じフレームに積むと 80 B(34 + 23 + 23)に跳ねる。「1 フレームに積む行は 1 行だけ」は予算の前提そのものなので、flow_update_clear_to_performance はバナーを次のフレームへ送っている。Lv4 ハイプの発火フレームは 44 B(判定文字が乗った Issue #6 と同値。BGP1/BGP3 のフラッシュ 16 B はパレット RAM への直書き、CH4 の歓声は snd_write_reg 経由なので、どちらも VRAM キューを 1 バイトも消費しない)。

BG 属性行はキューでは運べない。 属性は VRAM bank1 の $9800 面にあり、q_flushrVBK = 0 固定で走る(§4.3 のフォーマットにバンク欄は無く、追加は正典の変更にあたる)。そこでタイル行だけを q_push で積み、属性行は wRowScratch スロット 0 に合成して VBlank ISR が rVBK = 1gdma_row する。VBlank の gdma_row は視聴率ゲージ 1 本と合わせて 2 行 / VBlank ちょうどGDMA_MAX_ROWS_PER_VBLANK を使い切る)で、src/vblank.asmVBLANK_GDMA_ROWS == 2ASSERT がこれを固定する。キューのフォーマット(§4.3)も GDMA ヘルパの本数(§5.2)も wRowScratch の 96 B(§8.1)も変えていない。

かな窓の入替は LCD OFF 窓で完結し、その 1 フレームには VBlank が来ない。 flow_enter_entrance(会話 54 タイル / 864 B)と flow_enter_ending(エンディング 76 タイル / 1,216 B)は scene_lcd_offgdma_bulkscene_lcd_on の順に走る。LCD を切っている間は PPU が止まって LY が 144 に達しないため、窓を閉じたフレームでは VBlank ISR が 1 度も走らない。E2E ハーネスはこの 1 フレームだけ VBlank 0 回を許し、代わりに「窓は次のフレーム境界までに必ず閉じる」を検査する(../design/e2e.md §4)。入場の歩行が始まるのはこの 1 フレームぶん遅れる。

ROM 配置は §2.2 の鉄則 #3 のとおり、行データ本体 2,180 B(50 行 × タイル 20 B + 属性 20 B + 本文 4 行の休止状態 160 B + バナー 20 B)とかなフォント 3,040 B を ROMX BANK[5]"Text Rows" / "GFX_KANA" に置き、HOME にはページ表と行ポインタ表だけを残した(譜面の Chart Song Table と同じ扱い)。ビルド map の実測は ROM0 8,794 B、ROMX 29,643 B、WRAM0 1,536 B、HRAM 36 B(製品 ROM)で、ROM 合計 38,437 B。テキストは tools/test_text_pages.py が毎回再計算し、全 50 行(プロローグ 12 + 会話 8 + エンディング 30)が 20 タイル以内、最長 18 タイルであることを確認している。

Issue #14 実測(2026-09-03): エンディング 12 ページ(spec §10.4)と曲 4 の同時再生を載せた build/aidopagaki.gbc を PyBoy 2.7.0 の CGB/headless モードで実行した(tests/test_flow.py::test_s10_all_clear_walks_the_ending_and_returns_to_the_title / tests/test_text.py / tests/test_apu_refsim.py)。全曲クリア → CLEAR の START → FLOW_ENDING(12) → 12 ページ → FLOW_TITLE(9) の通しで hQOverflow = 0hQHiwater の最大は 57 B(HUD 34 + 本文行 23。会話ページ更新と同値で、上限 128 B の 45%)。⚠ 入場フレームの hQHiwater は 0 B である。 §6.4 が定める ENDING の入場処理はかな窓 76 ブロック(1,216 B / 2,432 dots)の gdma_bulk だけで、これは VRAM キューを 1 バイトも通らない。LCD OFF 窓を閉じたフレームには VBlank が来ないので、本文の消去が積み始めるのは次のフレームからになる。BG マップの入替は行わない(本文は会話と同じ TEXT_LINE_0..2_ADDR = row 3-5 に出す)。

ページの前には必ず消去相を通す。 エンディングのページごとの行数は 3,3,3,2,2,2,3,2,3,3,3,1 と減るので、前ページの余った行を消さずに次を出すと 12 ページ目(THE END の 1 行)に 11 ページ目の 2 行が居残る。flow_update_endingwEndingPhase(消去相 ⇄ ページ相)で既存の text_begin_clear(1 フレーム 1 行、ステージの休止状態へ戻す)を挟み、相の切替では必ずフレームをまたぐ。消去の最終行とページ 1 行目を同じフレームに積むと hQHiwater が 34 + 23 + 23 = 80 B に跳ねる(§4.4 の「1 フレームに積む行は 1 行」。flow_update_clear_to_performance が警告している罠と同じもの)。

曲 4(トワイライト・スターズ)は flow_enter_endingsound_play_song だけが鳴らす。譜面を持たないので update_rhythm は 1 度も走らないflow_update_lo/hi[12]flow_update_ending)。本番の導線で入ったエンディングの APU 書込みイベント列は、DRUM_USE_WAVE の両モードで tools/ref_sim_gb.py の曲 4 と 1,500 フレーム完全一致した。退場(flow_exit_lo/hi[12])は snd_silence で、曲 4 はタイトルへ持ち越されない(§7.10。シーン遷移専用の消音であり sound_stop ではない)。

テキストエンジンはシーンディスパッチ表(text_scene_*_tbl_lo/hi、6 種 × lo/hi × TEXT_SCENE_COUNT)を wTextScene で引くようになった。表は flow_call と同じ「lo 表の直後に hi 表」流儀で、text_begin_page(会話固定)は text_begin_scene_page の薄いラッパである。ディスパッチ表はページ表・行ポインタ表と同じ ROM0 常駐なので、text_bank_enter(ROMX の "Text Rows")の内側から引いてもバンクを張り替えない。ビルド map の実測は ROM0 9,998 B(+176 B)、ROMX 29,643 B(不変)、WRAM0 1,536 BwTextScene / wEndingPage / wEndingPhase の 3 B は wApuShadow 手前のパディングに収まった)、HRAM 36 B(製品 ROM)で、ROM 合計 39,641 B

Issue #14 SONG SELECT 実測(2026-09-03。#12 / #15 マージ後): 全曲クリア後のタイトルで CONTINUE を選ぶと開く選曲画面(src/song_select.asmFLOW_SONG_SELECT(11))を、製品の導線(タイトル → CONTINUE → A)で PyBoy 2.7.0 の CGB/headless モードから通した(tests/test_flow.py::test_s3_*)。操作は ← → で譜面のある曲 0-3 を 1 つ動かし(端で止まる、曲 4 は選べない)、A で FLOW_PERFORMANCE(6)、B で FLOW_TITLE(9)hQOverflow = 0hQHiwater入場フレーム 24 B(選曲行 3 + 20 + 終端 1。game_scene_restoreq_hiwater_reset 直後なので HUD はまだ乗らない)、無操作フレーム 34 B(HUD のみ)、← → のフレーム 57 B(HUD 34 + 選曲行 23。会話ページ更新と同値で上限 128 B の 45%)。hQOpCount は最大 1(選曲行 1 本)で、gdma_row は 1 回も走らない(視聴率も会話本文の属性行も動かないため)。

選択のカーソルは専用の変数を持たず wCurrentSong そのものである(spec §13 S3「選曲中の曲は wCurrentSong が正典」)。別変数にすると A を押した瞬間の同期処理が要り、本番中の SELECT(main.asmperformance_song_selectsong_next_charted)と規則が二重化する。wSndSong はここでは触らず、曲の実反映は performance_countin_init の 1 箇所だけが行う。

選曲行は BG row 6(BANNER_ADDR)の 1 行だけである。 ビジョン枠の中で BG 属性が bank0 のままなのは row 3 / 4 / 6 の 3 行しかなく(row 5 は判定グリフ 7 セルが bank1 = stage-play.attrmap)、4 曲を縦に並べる場所が無い。さらに row 6 だけは本番入場で init_note_highwayBANNER_BLANK を積んで必ず消してくれる(#11 修正ラウンド 2)ので、退場の後始末を 1 バイトも書かずに済む。row 3 / 4 に書くと本番へ抜けたあと曲名が残り、消すために text_begin_clear 相当の複数フレーム消去が要る。

START+A+B(どこでも復帰)を握っているフレームは選曲画面が入力を受け付けない。 update_title_return_request は要求を立てるだけで消費は次フレームの専用枠なので、ここで A を先に食うと FLOW_PERFORMANCE へ 1 フレームだけ寄り道してカウントインの初期化が空振りする。ガードの条件は同ルーチンと同じ hPadCur の 3 ボタン同時押しである(tests/test_flow.py::test_s3_song_select_ignores_the_anywhere_title_return_combo が「経路に FLOW_PERFORMANCE が現れない」ことで固定する)。

ビルド map の実測は ROM0 11,705 B、ROMX 49,211 B(#13 から不変。選曲の曲名は ROM0 の "Song Select Tables" に置いた)、WRAM0 1,536 B不変。新しい変数を 1 バイトも足していない)、HRAM 36 B で、ROM 合計 60,916 B。SONG SELECT 単体の増分は +173 B(enter / update / 行の押し込み 93 B + 曲名 4 行 80 B)で、#13 マージ後の 11,357 B との残差 175 B はテキストエンジンのシーン統合ぶんである。


5. 倍速モードと DMA

5.1 倍速モードへの切替(起動時 1 回)

asm
SwitchToDoubleSpeed:
    ldh a, [rKEY1]
    bit  7, a
    jr   nz, .already      ; すでに倍速なら何もしない
    ld   a, $30
    ldh  [rP1], a          ; ① ジョイパッドを全解除(STOP 前の必須手順)
    xor  a
    ldh  [rIE], a          ; ② STOP 中に割込みが来ないようにする
    ld   a, 1
    ldh  [rKEY1], a        ; ③ 速度切替を予約(bit0 = prepare)
    stop                   ; ④ ここで速度が切り替わる
.already:
    ld   a, 1
    ldh  [hCurSpeed], a

手順の順序を外すと実機で STOP がハングする。 rP1 = $30(両行とも非選択 = 全ボタン解除)と rIE = 0 を必ず stop の前に置く。これは既知の罠であり、SameBoy / BGB / 実機の 3 環境で起動確認することを子 issue 2 の受入条件にする。

APU のフレームシーケンサは DIV のビットの立ち下がりで駆動される(通常速は bit4、倍速は bit5)。ハードウェアがビットを切り替えるため、倍速でもエンベロープ・長さカウンタの実時間は変わらない。本設計はハードウェアエンベロープを使わないので実害はないが、ドラムの NR42 設計時にこの事実を前提にしてよい。

⚠ 倍速では DIV が 2 倍速で進む。タイマ割込み(TAC)を使う場合は再計算が要るが、本設計はタイマを一切使わない(時間の基準は VBlank のみ)。

5.2 GDMA の規約

HDMA1-5$FF51-$FF55)。HDMA5 に bit7=0 で書くと汎用 DMA(GDMA)が起動し、CPU を止めて一括転送する。

制約
転送長(HDMA5 & $7F) + 1 ブロック、1 ブロック = 16 B → 最大 128 ブロック = 2,048 B
転送元・先のアドレス下位 4 ビットが無視される = 16 B 境界に整列必須
転送先 VRAM バンクVBK の現在値。1 転送でバンクを跨げない
実行タイミングVBlank 中または LCD OFF 中のみ(表示中に撃つと Mode 3 と重なって化ける)
コスト2 dots/byte(実時間固定)
VBlank 内の上限GDMA_MAX_BLOCKS_PER_VBLANK = 64(1,024 B = 2,048 dots)

ヘルパは 2 本だけ:

gdma_row(slot, row) ; a = wRowScratch の slot (0-2), b = BG row (0-17)
                     ; 1 行 = 32 B = 2 ブロック。dst = $9800 + row*32
                     ; VBlank 専用。1 フレーム 2 本まで(GDMA_MAX_ROWS_PER_VBLANK = 2)
gdma_bulk(src, dst, blocks)   ; blocks <= 128。超過入力は 128 に clamp
                              ; LCD OFF 専用。呼ぶ前に VBK を設定する

DEBUG ビルドでは gdma_row の slot / row と gdma_bulk の block 数を実行時 ASSERT で検査し、違反コードを hDebugAssert に記録する。gdma_bulkHDMA5 へ書く直前にも and $7F を通し、bit7 を決して転送長に混ぜない。

VBlank 中に一括転送する API は用意しない。 「表示中に次シーンの面を裏で組み立てる」用途は D6 の廃止で消えたので、VBlank に流す転送は gdma_row の 32 B を 1 回(API 上は最大 2 回)だけである。まとまったデータ(CHR / 一枚絵 / かな窓)はすべて LCD OFF 窓で gdma_bulk が運ぶ。gdma_bulk を VBlank から呼ぶコードが存在しないことを E2E S4 がフックで検査する。

gdma_row は常に BG マップ 1 行 32 列すべてを転送する。 画面に見えるのは 20 列だが、32 B 単位にすることで転送先が必ず 32 B(したがって 16 B)境界になり、整列の検算が不要になる。観客席を col 2-17 に中央寄せできるのはこのおかげである。

大きな転送はすべて分割する。 1 転送 2,048 B の上限とバンク跨ぎ禁止に該当する箇所:

用途バイトブロック分割dots
アイ + ノーツ CHR(bank0 OBJ)3,8722422 回(128 + 114)7,744
講師 CHR(bank1 OBJ)4,0962562 回(128 + 128)8,192
ステージ BG タイル2,0481281 回4,096
かな窓入替(エンディング 76 タイル)1,216761 回2,432
一枚絵タイル3,8402402 回(128 + 112)7,680
ART 用 UI 英数フォント 47(bank1)752471 回1,504
ART 用 区切り線 1 + かな 60(bank1)976611 回1,952
BG マップ 1 行3221 回64

一枚絵の tilemap / attrmap(各 240 B)は GDMA で運ばない。 20 列 = 20 B は 16 B の倍数ではなく、240 B の連続転送も BG マップの 32 列レイアウトに乗らないため(§2.1)。LCD OFF 窓で CPU が 12 行 × 20 B をコピーする。

5.3 HDMA の禁止

HDMA(HDMA5 bit7=1、HBlank DMA)は使わない。 画面表示中の VRAM 更新要求が一切存在しないため、HDMA を入れると LY 同期・転送中断・二重起動という 3 種のタイミングバグを抱え込むだけになる。HDMA5 へ bit7=1 を書くコードが存在しないことを grep で CI 検査する。

5.4 ART ↔ GAME の CHR 往復

ART 構成では BG が $8000-$8FFF を占有するため、OBJ CHR が消える。復帰手順:

1. hdma なし(そもそも使わない)
2. VBlank を待って LCD OFF(rLCDC = $05)        ← OFF は必ず VBlank 中
3. VBK = 0 → gdma_bulk(アイ+ノーツ CHR 前半, $8000, 128)
             gdma_bulk(アイ+ノーツ CHR 後半, $8800, 114)
4. VBK = 1 → gdma_bulk(講師 CHR 前半, $8000, 128)
             gdma_bulk(講師 CHR 後半, $8800, 128)
5. VBK = 0 → gdma_bulk(ステージ BG タイル, $9000, 128)
6. BG マップ / 属性 / パレットを構築(一枚絵の tilemap/attrmap は CPU が 12 行 × 20 B コピー)
7. LCD ON(rLCDC = $87)                          ← ON はタイミング制約なし

起動時は LCD ON → rIF クリア → rIE 設定 → ei の順にする。 LCD ON 直後、ei の前に ART 構成の LCD OFF 中に保留された VBlank 割込みを捨ててから VBlank 割込みを有効化し、初回 ISR が想定外のタイミングで再入しないようにする。

実装 (src/init.asm) の Init はこの規約を次の形で満たす。 LCD ON と rIF クリアはシーンの enter(flow_enter_titleart_scene_end)が行い、art_scene_end はその直後に ei する。起動経路ではこの時点で rIE == $00 なので、この ei は何も有効化しない。 Initflow_goto から戻った直後に明示的に di へ戻し、そのうえで rIF クリア → rIE = IE_VBLANKei を行う。割込みを実際に有効化する ei は必ず rIE 設定のあとにある。 di を省いて rIE == $00 という偶然に頼ってはならない。

合計 GDMA = 3,872 + 4,096 + 2,048 = 10,016 B = 20,032 dots ≈ 0.29 フレーム。LCD OFF 中なので VBlank 制約はかからない。黒画面は 1 フレーム弱で、タイトル → 本編は 1 回、プロローグ → タイトルは 1 回しか通らない。

LCD の OFF は必ず VBlank 中に行う。ON はいつでもよい。

  • OFF: 表示中に LCD を切ると実機の LCD にダメージを与えうるという既知の作法に従い、LY >= 144 を待ってから rLCDC の bit7 を落とす。
  • ON: LCD OFF 中は LY が 0 で停止し、VBlank も STAT 割込みも発生しない。したがって「VBlank を待ってから ON にする」ループは永久にハングする。ON はいつ書いてもよく、書いた時点で LY = 0 からレンダリングが再開する。
  • E2E(../design/e2e.md S4 / issues.md #11 受入条件 9)が検査するのは OFF 側だけである。ON 側を検査条件に入れてはならない。

6. シーン状態機械

6.1 リセット遷移の廃止

観点NES で必要だった理由GBC で不要になる理由
CHR 窓の入替プロローグ/エンディング/会話フォントが同一 CHR 窓を奪い合い、レンダリング停止中でないと転送できなかったVRAM 2 バンク + GDMA で、シーン入場の LCD OFF 窓に 1,216 B を転送できる
固定バンク容量8 KB の固定バンクが数百バイトしか残っておらず、遷移ごとの初期化コードを置けなかったHOME バンク 16 KB。かつ cold code を bank7 に逃がせる
画面の初期化漏れリセットで RAM 全消去すれば「常にきれいな画面から始まる」保証が得られたシーン切替を LCD OFF 窓で行い、画面が完成してから LCD を ON にする(§5.4)。構築途中は物理的に表示されない
テスト容易性リセットを挟むと PyBoy から状態注入ができず、ランク閾値テスト((perfect, good, miss) を注入して RANK を検査)が原理的に成立しない

代償として progress_magic_a/b$C3/$3C)、範囲チェック、progress_entry_request の 6 分岐がすべて消える。

6.2 実装形

flow_enter_lo[] / flow_enter_hi[]     ; 13 エントリ。シーン入場
flow_exit_lo[]  / flow_exit_hi[]      ; 13 エントリ。シーン退場(何もしない状態は ret のみのスタブを置く)
flow_update_lo[] / flow_update_hi[]   ; 13 エントリ。毎フレーム
                                      ; 合計 13 エントリ × 6 本のジャンプテーブル
flow_goto(a)   ; flow_state を書き換える唯一の関数。
               ;   1. flow_exit[旧 state] を呼ぶ
               ;   2. wFlowState = a
               ;   3. q_hiwater_reset
               ;   4. flow_enter[新 state] を呼ぶ

exit テーブルは 13 本すべて実体を持つ。 「何もしない状態」も ret だけのスタブを置き、テーブルに穴を作らない。穴があると「exit を呼ぶ」という規約が状態ごとに条件分岐になり、追加した状態で呼び忘れる。

flow_state を直接書くコードは flow_goto の中の 1 箇所だけにする。 これにより PyBoy は flow_state を peek するだけで進行を完全に追える。grepld [wFlowState], a が 1 箇所しかないことを CI 検査する。

pause_flag は状態にしない(NES 版と同じ)。ポーズ中は sound_tick をスキップして BGM を行位置ごと凍結する。

ENTRY_OPENING / ENTRY_COUNTIN は状態ではなく enter の中のサブルーチンである../spec/aidopagaki-gb-design.md §1.1)。flow_enter_entranceentrance_opening_init を、flow_enter_performanceperformance_countin_init を呼ぶ。タイトルの START は flow_goto(FLOW_ENTRANCE)、CONTINUE は flow_goto(FLOW_PERFORMANCE) を呼ぶだけで、flow_state の番号は NES 版と同じ 0-12 のまま増えない。

6.3 BG マップは 1 面($9800

マップ用途
$9800(+ bank1 属性)唯一の BG マップ。 GAME / ART の両構成がここを使う
$9C00(+ bank1 属性)未使用。 LCDC bit3 は常に 0

二面切替(旧 D6)は廃止した。 廃止の理由は 2 つある:

  1. 必要がない。 シーン切替は CHR の入替を伴うので、どのみち LCD OFF 窓(§5.4)に入る。LCD OFF 中に $9800 を直接組み替えれば、構築途中の画面は物理的に表示されない。「裏で組み立ててアトミックに見せる」という二面の唯一の利点は、LCD OFF 窓がすでに提供している。
  2. 表示面を指すベースを全経路が解決しなければならない。 二面にすると q_push の宛先も gdma_rowdst も E2E の bg_row() も「今どちらが表示中か」を読む必要があり、1 箇所でも直値のままだと見えない面を更新し続けるという無症状のバグになる。仕様 §2.3 のアドレス定数 21 本はすべて $98xx/$99xx/$9Axx の即値であり、これを実行時ベース + オフセットに書き換えるコストと事故率は、得られるものに見合わない。

シーン内の BG マップ更新(表示中)は従来どおり: 差分は q_push(VBlank flush)、行単位の一括更新は gdma_row(VBlank、1 フレーム 2 本まで)。

ゴールデン画像比較は LCD OFF 窓が安定させる。 LCD OFF 中の PyBoy のスクリーンショットは白(または直前のフレーム)で、構築途中の中間状態は出ない。E2E は flow_state の遷移完了を待ってから撮る。

6.4 シーンごとの入場処理

flow構成enter で行う GDMA(LCD OFF 窓)
9 TITLEART一枚絵(タイトル)3,840 B + bank1 に UI 英数 47 + 区切り線 1(かなは使わない)768 B
10 PROLOGUEART一枚絵(ページごと)3,840 B + bank1 に UI 英数 47 + 区切り線 1 + プロローグかな 60 = 1,728 B
8 ENTRANCEGAMEOBJ CHR 再ロード 7,968 B + ステージ BG 2,048 B + かな窓 ← 会話 54(864 B)
0,1,2,3,4,5,6,7,11GAME再ロードなし(BG マップの差分のみ)
12 ENDINGGAMEかな窓 ← エンディング 76(1,216 B)

すべての enter は flow_goto から呼ばれ、その直前に q_hiwater_reset が走る(§6.2)。したがって hQHiwater は「そのシーンに入ってから現在までの累積最大」を意味する。E2E はシーンごとの実測値としてこれを読む。


7. サウンドドライバ

7.1 実行位置

sound_tick は VBlank ISR ではなく、メインループの先頭(vsync 待ちの直後)で回す。 理由は 2 つ:

  1. sound_tick は VBlank 予算の最大変動要因であり、ISR に入れると破綻する。設計値では 900 M-cycle(倍速 VBlank 予算 2,280 M-cycle の 39%)と見積もっていたが、実測最悪は 6,784 M-cycle(§4.6 Issue #7 実測)で、ISR に入れれば VBlank 予算そのものを超過していた
  2. メインループ先頭なら実行タイミングが毎フレーム同一位置に固定され、APU 書込みのフレーム内順序が決定的になる(ref sim との一致検証の前提)
main_loop:
    vsync 待ち(hVBlankFlag が立つまで halt)
    hVBlankFlag = 0
    sound_tick              ← ★ ここ
    read_pad
    START+A+B → タイトル復帰
    flow_update[flow_state] を呼ぶ
    update_pose / update_coach_pose / update_audience_animation
    draw_ai / draw_coach / draw_note_highway   (OAM シャドウを構築)
    HUD 差分を q_push
    jp main_loop

vblank_isr:
    push af/bc/de/hl / ROM バンク退避
    OAM DMA($C000 → OAM)
    VBK = 0
    VRAM キュー flush
    gdma_row(最大 2 本)
    BG パレット更新(判定色 / Lv4 ハイプ)
    SCX / SCY / VBK / ROM バンク復帰
    hFrameCount++ / hVBlankFlag = 1
    pop / reti

NES 版の PPU アクセス規約をそのまま継承する: 初期化後、メインスレッドは VRAM に直接触らない。

7.2 チャンネル対応

NESGBC移植方針
p1 pulse1CH1(duty + sweep)周期式のみ変更、他は 1:1
p2 pulse2CH2(duty)同上
tr triangleCH3 wave(32 × 4bit)wave RAM に三角波を 1 回だけ書く。音量は NR32 の 4 段
nz noiseCH4 noise周期テーブル差替え(§7.6)
dm DPCM ドラムCH4 + CH3(借用)ドラムエンジンで再合成(§7.7)

チャンネル優先度(CH4): SE(歓声 / ホイッスル)> dm(ドラム)> nz(ハイハット) チャンネル優先度(CH3): dm ボディ > tr(ベース)

7.3 ピッチの内部表現(符号反転の封じ込め)

GB:   CH1 / CH2   f = 131072 / (2048 - X)   →   X = 2048 - 131072/f
      CH3         f =  65536 / (2048 - X)   →   X = 2048 -  65536/f
      CH4         周期レジスタを持たない。NR43 の shift / divisor のみ(§7.6)

NES の period レジスタは音程が上がると減る。GB の X は音程が上がると増える。 エフェクト(1xx スライドアップ / 2xx ダウン / 3xx ポルタメント / 4xy ビブラート)をそのまま移植すると全部逆向きになる。

封じ込め策(必須の設計ルール):

ドライバ内部のピッチ状態は常に div = 131072 / f(NES period と同じ向き = 音程が上がると減る) で保持し、 レジスタ書込みの瞬間にだけ X = 2048 - div(CH3 は X = 2048 - div/2)に変換する。 これにより snd_fx_* のコードは NES 版から符号を一切変えずに移植できる。

div の値域:

チャンネルdiv の上限理由
CH1 / CH22047X = 2048 - div ≥ 1。f ≥ 64 Hz(MIDI 36 = 65.41 Hz)
CH34095(12 bit)X = 2048 - div/2 ≥ 1 を満たす。f ≥ 32 Hz(MIDI 24 = 32.70 Hz)

div は 12 bit(最大 4095)を許すこと。 MIDI 24 で div = 4008、MIDI 26 で div = 3571 になる。収録 5 曲の tr(ベース)は MIDI 26 まで下がるので、assert 1 <= div <= 2047 は CH1/CH2 にのみ適用する。CH3 の下限を 2047 で切ると全曲のベースが落ちる。

ドラム表(§7.7)だけは例外で div を持たない。 ドラムは CH3 専用の静的テーブルなので、131072/f ではなく ch3_div = 65536/f(= div/2)を直接持つ。列名も ch3_div とし、書込み値は X = 2048 - ch3_div である(div/2 の除算をランタイムから消すため)。§7.3 の div と混同すると 1 オクターブずれる。

音階テーブル note_div[] = MIDI 24-107 の 84 エントリ × 2 B = 168 B(HOME バンク)。CH3 は同じテーブルを srl 1 回で共用する。

MIDI音名f (Hz)divCH1/2 XCH3 X
24C132.704008(不可)44
26D136.713571(不可)263
36C265.412004441046
60C4261.6350115471798
69A4440.0029817501899
84C61046.5012519231986
107B73951.073320152032

エクスポータの検査(export_bgm_gb.py):

CH1/CH2 で f < 64 Hz(MIDI ≤ 35)、CH3 で f < 32 Hz(MIDI ≤ 23)のノートを検出したら警告を出し、1 オクターブ上げる。 黙って壊れるより、警告つきで音が変わるほうが検出できる。

7.4 音量制御(GB 固有の最大の罠)

CH1 / CH2 / CH4 は NRx2 に音量を書いても即座には反映されない。NRx4 の bit7(リトリガ)を書いて初めて新しい音量がエンベロープ器にロードされる。そしてリトリガは duty 位相をリセットする。

毎フレームの音量エンベロープをそのまま移植すると、60 Hz で duty 位相リセットが起き、耳障りなブザーになる。規約:

規約内容
R1ハードウェアエンベロープは常に period = 0(OFF)。音量はソフトウェア(vol_mul[cvol*16+env] 256 B 表)で決める
R2新音量が現在の音量と同じフレームではリトリガしない。 書込みを「音量が変化したフレームだけ」に絞る
R3エクスポータが楽器の vol 系列の定常部を平坦化する。 volLoop 以降で値が振動していたら、最頻値に丸めて警告を出す。これにより定常部のリトリガが 0 回/秒になる
R4確定(issue #7 オーナー判断 U4): CH4 の nz(ハイハット等の通常ノイズトラック)にも R2 を適用する(音量が変化したフレームだけリトリガ)。ドラム再合成(dm、§7.7)はテーブル自体が「フレーム番号 → レジスタ値」の静的表なので、その期間は R2/R3 を適用せず毎フレームの表どおりに書く(ドラムはCH4_WRITEビットが立つフレームだけ書き、位相リセットは聴感上問題にならない)

NRx2 の上位 5 ビットが全部 0 になると DAC が切れる$00 = 完全ミュート)。$08 のように direction=1 のビットが立っていると DAC は生きたまま音量 0 になるだけで、微小な DC オフセットが残る。ミュートには必ず $00 を使う。

CH3 の音量:

CH3 は NR32 の 4 段(0 / 25% / 50% / 100%)しかなく、リトリガ不要で即反映される。 vol_mul の 16 段をそのまま適用できないので量子化する。

楽器音量 volNR32出力
0$00ミュート
1-4$6025%
5-10$4050%
11-15$20100%

⚠ 実効段数を増やすなら「振幅を 4 段スケールした wave を複数持ってノートオン時に選ぶ」方法があるが、本設計では採らない。wave RAM の書換えには NR30 = 0(DAC オフ)→ 16 B 書込み → 再トリガが必要で、クリックノイズと ref sim の複雑化を招くため。4 段で足りるかは子 issue 6 の A/B 試聴で判断する。

7.5 wave RAM

sound_init で 1 回だけ書き、演奏中は絶対に書き換えない。

三角波(NES triangle 相当、デフォルト):

$01,$23,$45,$67,$89,$AB,$CD,$EF,$FE,$DC,$BA,$98,$76,$54,$32,$10

サイン波(ドラムボディ用、-D WAVE_SINE=1 でビルド切替、A/B 用):

$89,$AC,$DE,$EF,$FF,$EE,$DC,$A9,$86,$53,$21,$10,$00,$11,$23,$56

⚠ ドラムの胴鳴りに三角波を使うか正弦波を使うかは A/B で決める。DRUM_USE_WAVE=0 ならドラムは CH3 を一切奪わないので、この選択自体が消える。

7.6 ノイズ周期テーブル(NES 16 段 → GB NR43

NR43 = (shift << 4) | (width << 3) | divisor_code
f    = 262144 / (r × 2^shift)        r = divisor code、ただし code 0 のみ r = 0.5

GB の LFSR クロック上限は 524,288 Hz262144 / 0.5)である。262,144 Hz ではない。この係数を 2 倍間違えると表全体が 1 オクターブずれ、「NES の高域ノイズは GB で頭打ちになる」という誤った結論に至る。実際には NES idx 0 の 447 kHz も GB で表現できる。

NES idxNES 周期NES f (Hz)GB NR43shift / rGB f (Hz)誤差
04447,443$000 / 0.5524,288+17.2%
18223,722$010 / 1262,144+17.2%
216111,861$020 / 2131,072+17.2%
33255,930$050 / 552,429−6.3%
46427,965$151 / 526,214−6.3%
59618,643$171 / 718,725+0.4%
612813,983$252 / 513,107−6.3%
716011,186$262 / 610,923−2.4%
82028,860$272 / 79,362+5.7%
92547,046$353 / 56,554−7.0%
103804,710$373 / 74,681−0.6%
115083,523$454 / 53,277−7.0%
127622,349$474 / 72,341−0.3%
131,0161,762$555 / 51,638−7.0%
142,034880$656 / 5819−6.9%
154,068440$757 / 5410−6.9%

上表は tools/gen_noise_table.py が生成する。手打ちしない。 生成器は各 NES 周波数に対し全 112 通り(shift 0-13 × code 0-7)から |log(f_gb / f_nes)| を最小化する組を選ぶ。

7.7 ドラム再合成(CH4 + CH3)

すべて「フレーム番号 → レジスタ値」の静的テーブルであり、分岐を持たない。 これにより ref sim との一致検証が自明になる。

KICK(12 フレーム / 201 ms、NES f = 165·0.22^(t/0.22) + 38 を 59.7275 Hz で離散化)

ch3_div = round(65536 / f)。§7.3 の div = 131072/f の半分である(列名の取り違えは 1 オクターブのずれになる)。書込み値は X = 2048 - ch3_div

f目標 Hzch3_divCH3 XNR32CH4 NR42CH4 NR43ノイズ Hz
0203.0323$06BD (1725)$20 100%$F0$542,048
1185.0354$069E (1694)$20$80$641,024
2169.0388$067C (1660)$20$00 DAC off
3154.8423$0659 (1625)$20
4142.1461$0633 (1587)$20
5130.7501$060B (1547)$20
6120.6543$05E1 (1505)$40 50%
7111.7587$05B5 (1461)$40
8103.6632$0588 (1416)$40
996.5679$0559 (1369)$60 25%
1090.1727$0529 (1321)$60
1184.5776$04F8 (1272)$60
(12)解放後の状態。⚠ 注記行でありエントリではない。CH3_RELEASE は f11 に立つ

SNARE(9 フレーム / 151 ms)

CH4 のノイズ明度をフレーム単位でスイープする。NR43リトリガなしで書換え可能

fCH4 NR42CH4 NR43ノイズ HzCH3 X(190 Hz ボディ)NR32
0$F0$1432,768$06A7 (1703)$40 50%
1$E0$02131,072$06A7$60 25%
2$C0$2416,384CH3 解放CH3_RELEASE = 1CH3_WRITE = 0
3$A0$2416,384
4$80$348,192
5$60$365,461
6$40$444,096
7$20$542,048
8$00 DAC off

音の停止は長さカウンタではなく NR42 = $00(DAC オフ)で行う。 長さカウンタを使うなら NR44 の bit6(length enable)を立てる必要があり、$80 だけでは長さは働かない。テーブル方式なら長さカウンタは不要なので、NR44 = $80(リトリガのみ)に統一する。

TOM(11 フレーム / 184 ms、NES f = 95·0.5^(t/0.18) + 55

f目標 Hzch3_divCH3 XNR32CH4 NR42CH4 NR43ノイズ Hz
0150.0437$064B (1611)$20$B0$551,638
1144.1455$0639 (1593)$20$50$65819
2138.5473$0627 (1575)$20$00 DAC off
3133.3492$0614 (1556)$20
4128.4510$0602 (1538)$40
5123.8529$05EF (1519)$40
6119.5548$05DC (1500)$40
7115.5567$05C9 (1481)$40
8111.7587$05B5 (1461)$60
9108.2606$05A2 (1442)$60
10104.9625$058F (1423)$60
(11)解放後の状態。⚠ 注記行でありエントリではない。CH3_RELEASE は f10 に立つ

ドラムテーブルのバイナリ形式(1 フレーム = 4 B)

サイズ = (12 + 9 + 11) × 4 B = 128 B。NES の DPCM サンプル 2,307 B の 1/18

バイトビット内容
B07-0CH3 X の下位 8 bitX = 2048 - ch3_div
B12-0CH3 X の上位 3 bit(X は 11 bit)
4-3NR32 コード: 0$00(ミュート) / 1$20(100%) / 2$40(50%) / 3$60(25%)
5CH3_WRITE: 1 なら X と NR32 を書く。0 なら CH3 に触れない
6CH4_WRITE: 1 なら B2 / B3 を NR42 / NR43 に書く。0 なら CH4 に触れない
7CH3_RELEASE: 1 ならこのフレームで CH3 の借用を終え、tr_restore を呼ぶ。⚠ **最終フレームとは限らない。立つのは「CH3 の発音が終わるフレーム」**で、CH3_WRITE = 1 のフレーム(KICK / TOM の最終フレーム)ではフレーム末に、CH3_WRITE = 0 のフレーム(SNARE の f=2)ではフレーム先頭に解放する。位置は KICK = f11 / SNARE = f2 / TOM = f10 のちょうど 1 フレームずつ
B27-0NR42$00 は DAC オフ = 停止。CH4_WRITE = 1 のまま書く)
B37-0NR43

表の (横線)は「書かない」であって「値 0 を書く」ではない。 NR42 = $00 の停止フレームは CH4_WRITE = 1 で明示的に $00 を書く(DAC オフ)。その次のフレーム以降が CH4_WRITE = 0 になる。この区別を落とすと停止が効かないか、逆に毎フレーム DAC を叩き直してクリックが出る。

NR44 = $80(リトリガのみ、length enable なし)は表に持たず、CH4_WRITE = 1 のフレームでドライバが定数として書く。 長さカウンタは使わない(§7.7 冒頭)。

KICK 表の f = 12 行と TOM 表の f = 11 行は「解放後の状態」を示す注記行であって、テーブルのエントリではない。 エントリ数は見出しどおり KICK 12(f0-f11)/ SNARE 9(f0-f8)/ TOM 11(f0-f10)で、CH3_RELEASE はそれぞれ f11 / f2 / f10 に立つ。SNARE だけ最終フレーム(f8)ではないのは、CH3 ボディが f0-f1 で終わり、そこから f8 までは CH4 のノイズだけが鳴るためである。SNARE で f8 まで CH3 を保持すると、ベース(tr)が 6 フレーム余分に途切れる(§10 リスク 4)。

検算: 11(X) + 2(NR32) + 3(フラグ) = 16 bit = B0/B1、NR42 8 + NR43 8 = 16 bit = B2/B3。**32 bit = 4 B にちょうど収まる。**エントリ数は (12 + 9 + 11) = 32、32 × 4 B = 128 B(注記行を数えないので 136 B にはならない)。

CH3 の調停:

drum_active   : 現在鳴っているドラム ID($FF = なし)
drum_frame    : 経過フレーム
tr_held_note  : ドラム発火時に退避した tr の MIDI 番号($FF = 休符)
tr_held_vol   : 同 音量
  • CH3 を奪うドラム(kick / tom)の発火時に tr_held_* を退避
  • ドラム終了フレームで tr_held_note != $FF なら CH3 の周期・NR32 を復元して再トリガ
  • ドラム中に tr へ新しいノートオンが来たら tr_held_* を更新するだけで CH3 には書かない(ドラム優先)

-D DRUM_USE_WAVE=0 で CH3 借用を完全に無効化できる。 ベース欠落を嫌う場合の逃げ道であり、ref sim も同じフラグを持って両モードで一致検証する。デフォルトは 1。この A/B は出荷判断まで先送りできる唯一の形である。

CH4 の調停(issue #7 オーナー判断 U5。確定):

ドラム(dm)が CH4 を占有している間(表の全フレーム数、KICK 12f / SNARE 9f / TOM 11f)、nz(ハイハット等のノイズトラック)の CH4 への書込みを抑止する。 ドラムの静的テーブルと nz のシーケンサが同じフレームで CH4 を取り合うと、どちらが最後に書いたかが不定になり ref sim との一致が壊れる。

  • ドラム発火中は nz トラックのシーケンス自体は止めず(seq_step / env_frame は継続して評価する)、snd_write_reg / snd_write_if_changed への実書込みだけを抑止する
  • ドラムの最終フレーム(CH3_RELEASE と同じ扱いではなく、CH4 が表の最終書込みを終えたフレーム)の直後で nz の現在値を再送する。これは §2 の snd_write_if_changed が「シャドウと同値なら書かない」ため、抑止期間中に nz の値が変わっていれば自動的に再送される(明示的な特別処理は不要。抑止フラグを外すだけで次フレームの通常経路が正しい値を書く)
  • ドラムと nz の優先度は §7.2 のとおり SE > dm > nz。ドラム中に nz が変化しても、その変化はシャドウにだけ反映され、CH4 への実書込みはドラム解放後まで遅延する

7.8 APU シャドウと書込み規約

APU レジスタへの書込みは必ず snd_write_reg を経由する。直書きは禁止grep で CI 検査)。

シャドウは $xx10 / $xx30 境界に置く。 snd_write_reg はレジスタ下位バイト c をそのまま L に使うので、wApuShadow の下位バイトが $10wWaveShadow の下位バイトが $30、かつ両者が同じ 256 B ページにあることが正しさの前提である。§8.1 は wApuShadow = $C310 / wWaveShadow = $C330 と定める。

asm
; in: c = レジスタ下位バイト($10-$26 または $30-$3F)、a = 値
; wApuShadow  = $C310   ($FF10-$FF26 → $C310-$C326)
; wWaveShadow = $C330   ($FF30-$FF3F → $C330-$C33F)
ASSERT LOW(wApuShadow)  == $10
ASSERT LOW(wWaveShadow) == $30
ASSERT HIGH(wApuShadow) == HIGH(wWaveShadow)

snd_write_reg:
    ld   [$FF00+c], a        ; 実 APU へ
    ld   h, HIGH(wApuShadow) ; = $C3
    ld   l, c                ; $10-$26 → apu_shadow / $30-$3F → wave_shadow
    ld   [hl], a             ; シャドウへ
    ret

$FF30-$FF3F(wave)への分岐コードは存在しない。 両シャドウを同じページの $xx10 / $xx30 に置いたので、ld l, c の 1 命令が自動的に振り分ける。旧稿の ld h, HIGH(wApuShadow - $10)LOW(wApuShadow) == $10 のときにしか成立しない idiom で、wApuShadow = $C340 のままでは HL = $C310(正しくは $C340)を指し、チャンネル状態領域を破壊していた

領域アドレスサイズ内容
apu_shadow$C310-$C32623 B$FF10-$FF26(NR10-NR52)の書込み鏡
wave_shadow$C330-$C33F16 B$FF30-$FF3F の書込み鏡

シャドウなしでは APU の検証が原理的に成立しない。 GB の APU レジスタは読み戻せない($FF13 は常に $FF$FF14 は bit6 のみ)。PyBoy にも write callback はない。

ログの二重化../design/e2e.md §5):

  1. 第一手段: PyBoy の hook_registersnd_write_reg の入口に張り、(frame, C, A) を記録
  2. 第二手段(-D DEBUG=1 ビルド): snd_write_reg が WRAM $C600 のリングに [reg_lo, value] を積み apu_log_len を更新。VBlank でクリア。エミュレータ非依存なので SameBoy / BGB / 実機でも同じデータが取れ、PyBoy の API 差を丸ごと無効化できる

ref sim との比較対象はシャドウの値スナップショットではなく (frame, reg_lo, value) の書込みイベント列である。 シャドウは値しか持たないので、同じ値の NRx4 を再書込みする「余計なリトリガ」を検出できない。R2(§7.4「値が変わったフレームだけリトリガ」)は §10 リスク 1(本設計の最大リスク)の唯一の緩和策であり、その違反がテストをすり抜けてはならない。第一手段のフックも第二手段のリングログも書込みイベントをそのまま列として出すので、両方でイベント列比較ができる。

シャドウの値比較は「実 APU レジスタとの読戻し照合」にだけ使う。 その際はレジスタごとに読み戻せるビットが違うため($FF13 は全ビット不可、$FF14 は bit6 のみ)、レジスタ別の読戻しマスク表ref_sim_gb.py と E2E で共有し、マスクした上で比較する。

7.9 ストリームフォーマットの流用範囲

要素流用
ストリーム圧縮($00 空行 / $01-$60 note-on / $61 off / $62 vol / $63 fx / $80|n RLE)100% そのまま
8.8 固定小数 行タイマ(timer += speed88 → 毎フレーム -= $0100 → ≤0 で行送り)100% そのまま
楽器 4 シーケンス(vol / duty / arp / pitch + ループ点)100% そのまま
エフェクト 0xy 1xx 2xx 3xx 4xy Vxx100% そのまま(§7.3 の div 内部保持が前提)
vol_mul[cvol*16+env] 256 B テーブル100% そのまま(§7.4 の R2/R3 規約つき)
patternLen = 64 / rowsPerBeat = 4 ハードコード100% そのまま
トラッカー JSON スキーマ(version 2)無変更。既存 5 曲のノート情報は 100% 移植可能
dm チャンネル意味を「ドラム ID」に再解釈(0=kick, 1=snare, 2=tom)
nz チャンネルGB の NR43 値表 16 エントリに読み替え(§7.6)
周期テーブル差替え(§7.3)
snd_update_pulse / _tri / _noise書換え(レジスタ配置のみ)
DPCM トリガ全体削除、ドラムエンジンに置換
バンク切替(UNROM → MBC5)書換え(ld [$3000], a / ld [$2000], a + cur_rom_bank 鏡)
sound_tick の呼び出し位置NMI → メインループ先頭(D7)

7.10 ポーズと消音

  • ポーズ: hSoundGate = 1sound_tick をスキップし、NR51 = 0(ミキサ遮断)。行位置は保持される。解除で NR51 復帰 + 全チャネル再トリガ。NES 版の「BGM の行位置ごと凍結」と同一挙動
  • snd_silence: NR52 = 0(APU 全体 OFF)→ NR52 = $80NR50 = $77NR51 = $FF で再初期化

ポーズで snd_silence を呼んではならない。 NR52 = 0 は APU 全体をリセットし、チャンネルの周期・音量・duty 位相をすべて失う。「行位置ごと凍結して復帰する」という要件と両立しない。ポーズの消音は NR51 = 0(ミキサ遮断)だけである。snd_silenceシーン遷移専用(BGM を完全に打ち切って次のシーンへ移るとき)であり、flow_goto の exit からのみ呼ぶ。仕様 §11.1 もこの定義に揃えてある。


8. WRAM / HRAM マップ

シンボル名の接頭辞規約(本書と E2E とテストで共有する唯一の命名規則):

領域接頭辞
HRAM($FF80-$FFFEhhFrameCount / hCurSpeed / hQHiwater / hQOpCount
WRAM($C000-$CFFFwwFlowState / wApuShadow / wOamShadow
ROM の読み取り専用データ接頭辞なしnote_div / vol_mul / drum_table
ルーチン接頭辞なし(snake_case)snd_write_reg / flow_goto / q_push

ハーネスは .sym の名前解決だけで動く(§10 リスク 0)。本書で frame_count と書き E2E が hFrameCount を引けば、そのずれはそのまま KeyError になる。以下の表と ../design/e2e.md / issues.md の記述は同じ名前を使う。

8.1 WRAM bank 0($C000-$CFFFSVBK は 1 固定で bank1 は未使用)

アドレスサイズシンボル内容
$C000-$C09F160wOamShadowOAM シャドウ$FF46 の DMA 元。256 B 境界整列が必須 → $C000 ✓)
$C0A0-$C0FF96wRowScratchBG マップ行合成スクラッチ(32 B × 3:観客 2 段 + ゲージ)
$C100-$C1FF256wVramQueueVRAM キュー
$C200-$C2FF256wFlowState ほかゲーム変数(wFlowState, wSongFrame, wAudience, wScoreDigits[6], 判定カーソル、ポーズ状態…)
$C300-$C30F16予備(wApuShadow$xx10 に整列させるためのパディング)
$C310-$C32623wApuShadow$FF10-$FF26(NR10-NR52)の書込み鏡。LOW == $10snd_write_reg の前提
$C327-$C32F9予備
$C330-$C33F16wWaveShadow$FF30-$FF3F の書込み鏡。LOW == $30 が前提。wApuShadow と同一ページ
$C340-$C3B7120wChanStateサウンド: チャンネル状態 5ch(p1/p2/tr/nz/dm)× 24 B、SoA(フィールドごとに 5 B 連続。§7.9)。issue #7 で確定。旧稿の「4ch × 16 B = 64 B」は dm チャンネルと 16bit フィールド(ptr/pit/sli/curp)を勘定しておらず不足していた
$C3B8-$C3BF8予備
$C3C0-$C3FF64wDrumStateドラムエンジン状態(wDrumActive / wDrumFrame / wCh3Drum / wDrumCh3Trig)+ 曲グローバル(wSndPlaying / wSndSong / wSndPaused / wSndTimer(2) / wSndSpeed88(2) / wSndRow / wSndOrdPos / wSndOrdLen / wSndInstCount / wSndOrderPtr(2) / wSndPatternPtr(2) / wSndInstPtr(2) / wBgmRequest)+ sound_tick 内スクラッチ(フレームを跨いで保持しない)
$C400-$C43F64wBgPalShadowBG パレットシャドウ(8 本 × 4 色 × 2 B)
$C440-$C47F64wObjPalShadowOBJ パレットシャドウ
$C480-$C57F256wTextScratchテキストページ合成スクラッチ
$C580-$C5FF128予備
$C600-$C7FF512wApuLog(DEBUG のみ)APU リングログ-D DEBUG=1 のみ。[reg_lo, value] × 256)
$C8001wApuLogLen(DEBUG のみ)wApuLog の使用エントリ数。VBlank ISR が毎フレーム 0 に戻す
$C801-$C80A10wDebugMenuVars(DEBUG_MENU のみ)デバッグメニューwDebugCursor / wDebugCursorDrawn / wDebugRun / wDebugItem / wDebugTimer / wDebugPhase / wDebugSong / wDebugDigits[3])。-D DEBUG_MENU=1 のときだけ確保する
$C80B-$CFFF(DEBUG_MENU)/ $C801-$CFFF(DEBUG)/ $C600-$CFFF(製品)2,037 / 2,047 / 2,560未使用

製品ビルドでは wApuLog / wApuLogLen を確保しない。WRAM / HRAM の末尾を埋める ds は置かず、上の区画の実使用量をリンカ map の SUMMARY で検査する。

Issue #7 実測(2026-09-03): ビルド map の WRAM0 実使用量は製品ビルド 1,536 B(38%)、DEBUG ビルド 2,049 B(50%、wApuLog 512 B + wApuLogLen 1 B の 513 B 増)。HRAM は製品 36 B、DEBUG 37 B で変化なし(issue #7 は HRAM を追加しない)。

Issue #15 実測(2026-09-03): make debug-D DEBUG_MENU=1 を伴うようになり、DEBUG ビルドの WRAM0 は 2,059 B(50%)になった(デバッグメニューの 10 B 増)。HRAM は 37 B のままで、デバッグメニューは HRAM を 1 バイトも追加しない。製品ビルドは WRAM0 1,536 B / HRAM 36 B / ROM0 9,822 B / ROMX 29,643 B のまま 1 バイトも動かない(tests/test_budget.py::test_production_rom_contains_no_debug_menu_code が製品 map に DebugMenu セクションと debug_ / wDebug シンボルが 0 件であることを検査する)。

$C300-$C30F の 16 B パディングは無駄ではなく仕様である。 wApuShadow$xx10 に、wWaveShadow$xx30 に置くことで snd_write_regld l, c の 1 命令でアドレスを作れる(§7.8)。旧稿はチャンネル状態を $C300-$C33F に置き、シャドウを $C340 に置いていたため、snd_write_reg の書込先がチャンネル状態の内側に落ちていた。ビルド時 ASSERT で境界を強制する。

パレットシャドウを持つ理由: BCPD/OCPD も読み戻しに制約があり、フェード演出(前の値から 1/16 ずつ寄せる)には元の値が要る。

8.2 HRAM($FF80-$FFFE、127 B)

アドレスシンボル内容
$FF80-$FF89hOamDmaRoutineOAM DMA ルーチン(10 B、init で HRAM へコピー。§4.2)
$FF8AhVBlankFlagvsync 待ちのフラグ
$FF8B-$FF8ChFrameCount16bit。起動検出にも使う(../design/e2e.md §2)
$FF8D-$FF8FhPadCur / hPadPrev / hPadNew
$FF90-$FF91hQTailキュー書込みポインタ
$FF92hQOverflow0 以外はテスト失敗
$FF93hQHiwaterシーン内の累積最大使用バイト。flush ではリセットしない。q_hiwater_reset を呼んだときだけ 0 になる(§4.3 / §6.2)
$FF94hQOpCountq_reset からの受理レコード数。Q_MAX_OPS_PER_FRAME の判定に使う
$FF95hCurRomBankMBC5 のバンクレジスタは書込専用 → 必ず鏡を持つ
$FF96hCurVramBank
$FF97hCurSpeed0 = 通常 / 1 = 倍速
$FF98hSoundGate1 = ポーズ中
$FF99-$FFA0hIsrScratchISR 専用スクラッチ。メインループからは使わない
$FFA1-$FFA3hMainScratchメインループ専用スクラッチq_push の長さ・tail 保存。コピー残数はレジスタ D で保持。3 B)
$FFA4hDebugAssertDEBUG の GDMA / queue 引数 ASSERT マーカー(製品ビルドでは確保しない)
$FFA4-$FFFE(製品) / $FFA5-$FFFE(DEBUG)未使用

hQOverflow / hQHiwater / hQOpCount を HRAM に置くのは、PyBoy が毎フレーム安く peek するため../design/e2e.md §4)。

hQHiwater を flush でリセットしない理由: リセットすると E2E が読める値は常に 0 になり、hQHiwater <= 128 は自明に通り、「実測値を PR 本文に貼る」(issues.md 共通受入条件 5)が成立しない。累積最大にすることで、E2E は tick() の中で毎フレーム読み、シーン全体の最悪値をそのまま得る。1 バイトのままで済み、HRAM も増えない。


9. NES 版から削除するもの

削除するものNES での規模削除できる理由
講師 CHR 自己書換えストリーミングcoach_stream_code 385 B + 4 状態 + NMI 内 599 cycleVRAM bank1 に 32 フレーム常駐(§3.2)
CHR 窓の共有(プロローグ/エンディング/会話)リセット時に progress_entry_request で分岐かな窓を GDMA でシーン入替(§3.3)
リセットによる画面遷移マジックバイト $C3/$3C + 範囲チェック + 6 B 保存ブロック状態機械 + LCD OFF 窓での画面再構築(§6)
観客テーブル 17 状態 × 4 コマ × 168 B11,424 B + バンク切替ランタイム合成 150 M-cycle
DPCM サンプル 3 種2,307 B + .align 64 + $4012/$4013ドラムテーブル 128 B(§7.7)
属性の 16×16 px 単位制約.atr 64 B と帯設計BG 属性が per-tile(§3.2)
合計削減約 14,200 B + 3 種の複雑な機構

10. 実装上のリスク(発生確率順)

NES 版 docs/writer.md の「実際の失敗確率順に並べた鑑別診断ラダー」形式を踏襲する。

リスク兆候緩和策残存
0rgblink -n.sym を出し忘れる/文書と実装でシンボル名が揺れるE2E が KeyError で全滅する.sym を毎ビルド再生成し、ハーネスは名前解決を原則とする。.sym に載らないハードウェアレジスタ等は tests/harness.pyHW 表に集約し、それ以外のアドレス直書きを禁止する(0x[0-9A-Fa-f]{4} と 10 進 \b65[0-9]{3}\b を CI で検査)。§8 冒頭の接頭辞規約(HRAM = h、WRAM = w)を全文書で守る
1CH1/CH2 の毎フレーム音量更新でリトリガが 60 Hz 発生し、duty 位相リセットでブザーになる定常音が「ジー」と鳴る§7.4 の R2(値が変わったフレームだけリトリガ)+ R3(エクスポータが vol 系列の定常部を平坦化)。子 issue 6 の早期に実機で試聴する
2エフェクトのピッチ方向が逆になるビブラート・スライドが逆向き、ポルタメントが発散§7.3 の「内部は div 保持、書込み時のみ X = 2048 - div」を設計ルール化。ref sim が最初に検出する
38×16 モードの帯パレット統合(3 帯 → 2 帯)で下半身の見た目が劣化する靴と腰の色が破綻assets.md §4 の REMAP 表で index を付け替える。子 issue 3 のスクショ golden をレビュー担当(Fable)がプレビューを目視承認するまで先へ進めない
4ドラムの CH3 借用でベースが最大 12 フレーム途切れるキックのたびにベースが欠ける。特に曲 3(152 BPM、キック密度高)-D DRUM_USE_WAVE=0/1 で切替可能にし両モードを ref sim で検証。曲別に KICK_SHORT/KICK_LONG を選べるようにする
5一枚絵を追加・差替えしたときに bank6/bank7 の残余(実測 4,320 B / 8,880 B)が足りなくなるrgblinkSection ... does not fit で落ちる1 枚 4,384 B なので bank6 には 6 枚目が入らない(bank7 の残余には 1 枚だけ入るが、そこはエンディング進行とデバッグメニューの置き場である)。$0148 = $03(256 KB)へ上げる。MBC5 なのでコード変更は不要で、ヘッダ 1 バイトとリンカスクリプトのバンク数だけが変わる。⚠ 「ユニークタイルが 240 を超える」リスクは存在しない: 一枚絵は 160×96 = 240 セルしかなく、-u -m を通したユニークタイルが 240 を超えることは原理的にありえない
6減色パイプラインのノイズ(NES 版で「自動画像変換は減色ノイズで一度失敗済み」の前例)絵がざらつく・色が転ぶ減色はオフラインで 1 回だけ。32 色インデックス PNG を正典としてコミットし、以後 rgbgfx -c gbc:<name>.gbcpal は読むだけ。4 倍プレビュー PNG のレビュー担当(Fable)の目視レビューを必須ゲートにする
7GDMA を表示中に撃って画面が化ける帯状のノイズgdma_row を VBlank 専用、gdma_bulk を LCD OFF 専用に API で分離し ASSERT。VBlank 用の一括 API は作らない(§5.2)。E2E S4 がフック回数で両者の呼出し文脈を検査する
8OBJ タイルが bank0 242/256・bank1 256/256 でほぼ満杯フレーム追加の余地がないX フリップ属性による idle コマの共有、追加演出は BG 側で作る
9倍速の STOP が実機でハングする起動しない§5.1 の手順(rP1=$30rIE=0KEY1STOP)を厳守し、SameBoy / BGB / 実機の 3 環境での起動確認を子 issue 2 の受入条件にする
10PyBoy のスクリーンショットが CGB 色で環境依存に揺れるgolden 比較が落ちる子 issue 2 の受入条件で「同一入力 2 回でバイト一致」を最初に証明する。不安定なら比較を BG マップのタイル列中心に切り替え、画像は目視レビュー用に降格
11Codex が予算表を更新せずに最適化を入れる予算コメントと実装が乖離し、次の事故で原因を追えない各 issue の受入条件に「§4.5 / §4.4 の予算表を実測値で更新」を明記。hQHiwater の実測を PR 本文に貼る

11. 未決事項

#論点現状判断時期
A1NR32 4 段量子化で BGM の表現力が足りるか4 段で実装し A/B 試聴で判断。足りなければ振幅スケール wave 4 種を追加子 issue 6
A2hardware.inc v5.3.0 の定数名(LCDC_BLOCK01 等)が本書の記述と一致するか解消済み(v5.3.0 実測)。vendored ファイルの定数名を grep で突き合わせ、RGBDS 1.0.3 でビルド確認済み。旧 LCDCF_* 系とは共存しない2026-09-02
A3ART シーンのパレットフェードを何段にするか16 段(1 フレーム 1 段)を仮採用。実機で速すぎ/遅すぎなら段数のみ調整子 issue 11
A4一枚絵 5 枚が bank6/7 に収まるか解消済み(子 issue 13 実測)。bank6 12,064 B / 16,384 B、bank7 7,504 B / 16,384 B で収まった(rgbgfx -u -m の反転統合でユニークタイルが 195-233 に落ちるため、見積りの 1 枚 4,384 B より小さい)。$0148 = $03(256 KB)への引き上げは不要2026-09-03