AIドパガキ GBC 版 技術設計書(アーキテクチャ)
本書は GBC 版のエンジン内部構造・メモリマップ・実行予算・サウンドドライバの正典である。ゲームの数値(判定・スコア・観客・レーン)は ../spec/aidopagaki-gb-design.md を正とし、本書はそれを実現する手段だけを定める。
前提: RGBDS 1.0.3(rgbasm / 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(倍速、KEY1) | 1 M-cycle = 2 dots |
OAM DMA($FF46) | 160 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. 決定事項サマリ
| # | 決定 | 数値根拠 |
|---|---|---|
| D1 | CGB 倍速モードを起動時から常時 ON | VBlank 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 |
| D4 | apu_shadow 23 B + wave_shadow 16 B を WRAM の $xx10 / $xx30 境界に常駐 | GB の APU レジスタは読み戻せない($FF13 は常に $FF、$FF14 は bit6 のみ)。シャドウなしでは ref sim とのバイト一致検証が原理的に不可能。境界整列により snd_write_reg が ld l, c 1 命令でアドレスを作れる。追加コスト 39 B + 約 30 M-cycle/frame(全体の 0.09%)。§7.8 |
| D5 | VRAM キュー 256 B + hQOverflow / hQHiwater / hQOpCount を HRAM に | NES 版で 3 回起きた ppu_buf 溢れ事故(#66/#68/#35)を、視覚的グリッチではなくテスト失敗として観測可能にする。最悪 67 B / 256 B(26%)を、バイト数だけでなく op 数でも検査する。§4 |
| D6 | BG マップは $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 |
| D7 | sound_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 |
| D8 | HDMA を明示的に禁止し GDMA のみ使う | 画面表示中の VRAM 更新要求が一切ないため、HDMA は LY 同期・転送中断・二重起動という 3 種のタイミングバグを抱え込むだけ。§5 |
2. カートリッジヘッダと ROM 構成
2.1 ヘッダ
| アドレス | 値 | 意味 |
|---|---|---|
$0134-$013E | AIDOPAGAKI | タイトル(11 バイト。CGB カートでは $013F 以降がメーカーコードなのでここまで) |
$013F-$0142 | ADPJ | メーカーコード |
$0143 | $C0 | CGB 専用(DMG では起動しない) |
$0144-$0145 | AE | 新ライセンシー |
$0146 | $00 | SGB 非対応 |
$0147 | $19 | MBC5(RAM なし・バッテリなし) |
$0148 | $02 | ROM 128 KB(8 バンク) |
$0149 | $00 | SRAM なし |
$014A | $00 | 日本 |
$014D / $014E-$014F | rgbfix -v が計算 | ヘッダ / グローバルチェックサム |
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 |
| 1 | ROMX SONGS | BGM ストリーム 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 |
| 2 | ROMX GFX_AI | アイ 26F(3,328 B) + GO 2F(256 B) + ノーツ/マーカー/紙吹雪(288 B) | 3,872 B |
| 3 | ROMX GFX_COACH | 講師 32F | 4,096 B |
| 4 | ROMX GFX_BG | ステージ BG タイル(2,048 B) + 各シーンの tilemap/attrmap | ~8 KB |
| 5 | ROMX TEXT | かなフォント 3 種(864+960+1,216 = 3,040 B) + テキストスクリプト全文 | ~5 KB |
| 6 | ROMX ART_A | 一枚絵 3 枚(タイトル 3,920 + プロローグ p1 3,872 + p2 4,272) | 12,064 B(issue #13 実測。残余 4,320 B) |
| 7 | ROMX 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 回) |
| tilemap | 20 × 12 = 240 B | rgbgfx -t の出力そのまま。32 列へのパディングはしない |
| attrmap | 20 × 12 = 240 B | rgbgfx -a の出力そのまま。VRAM bank1 側 |
| パレット | 8 × 4 × 2 = 64 B | BGPD へ順次書込み(うち 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.py の test_the_prologue_pictures_fit_bank6_and_bank7 が両バンクの実測を毎回検査するので、超えた時点でテストが落ちる。
3. VRAM レイアウト
VRAM = 16 KB = 2 バンク × 8 KB(VBK $FF4F で切替)。
3.1 アドレッシング方式
| 構成 | LCDC.4 | BG が索引できる領域 | OBJ |
|---|---|---|---|
| GAME | 0($8800 符号付き) | index $00-$7F → $9000-$97FF(128 タイル/バンク) | $8000-$8FFF(256 タイル/バンク) |
| ART | 1($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-$8CFF | OBJ $00-$CF | アイ ダンス 26 フレーム × 8 | 208 | 3,328 |
$8D00-$8DFF | OBJ $D0-$DF | アイ ゲームオーバー 2 フレーム × 8 | 16 | 256 |
$8E00-$8EBF | OBJ $E0-$EB | ノーツ振りアイコン 6 種(8×16 = 2 タイル) | 12 | 192 |
$8EC0-$8EDF | OBJ $EC-$ED | 判定マーカー ◎(8×16) | 2 | 32 |
$8EE0-$8F1F | OBJ $EE-$F1 | 紙吹雪 2 形状(8×16) | 4 | 64 |
$8F20-$8FFF | OBJ $F2-$FF | 予備 | 14 | 224 |
$9000-$97FF | BG $00-$7F | UI 英数フォント 47 + ステージ BG 65(内訳は assets.md §2) | 128 | 2,048 |
VRAM bank 1
| アドレス | index | 内容 | タイル | バイト |
|---|---|---|---|---|
$8000-$8FFF | OBJ $00-$FF | 講師 32 フレーム × 8(常駐、ストリーミング全廃) | 256 | 4,096 |
$9000-$972F | BG $00-$72 | かなフォント窓(シーン入場時に GDMA で入替) | ≤115 | 1,840 |
$9730-$97FF | BG $73-$7F | 判定グリフ(常駐) 空白 1 + P E R F C T G O D M I S 12 | 13 | 208 |
⚠ 判定グリフはパレット 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 マップと属性
| 領域 | bank0 | bank1 |
|---|---|---|
$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-2 | BG パレット 0-7 |
| 3 | タイルの VRAM バンク(0 = 英数/ステージ, 1 = かな) |
| 5 | X フリップ |
| 6 | Y フリップ |
| 7 | BG-to-OBJ 優先 = 0(OBJ を前面に) |
⚠ BG が bit3 で per-tile にバンクを選べるため、1 行の中に英数とかなを混在できる(例: アイ「なかみは いくらかなー?)。NES の「CHR 窓の奪い合い」は完全に消える。
3.3 かなフォント窓の入替
| シーン | 内容 | タイル | GDMA バイト | dots |
|---|---|---|---|---|
| 会話 1-3 | 会話かな 54 | 54 | 864 | 1,728 |
| プロローグ(ART) | プロローグかな 60 | 60 | 960 | 1,920 |
| エンディング | エンディングかな 76 | 76 | 1,216 | 2,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-$2E | UI 英数フォント 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 英数フォント 47 | 752 | 47 |
| 区切り線 1 | 16 | 1 |
| かなフォント(プロローグ 60) | 960 | 60 |
| 合計 | 1,728 | 108 |
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 | レーンノーツ 最大 6 | 6 |
| (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 + ノーツ 6 | 7 ≤ 10 ✓ |
| キャラ上半(y 72-87) | アイ 2 + コーチ 2 | 4 ✓ |
| キャラ下半(y 88-103) | アイ 2 + コーチ 2 | 4 ✓ |
| CLEAR キャラ帯(y 72-103、紙吹雪が最悪に重なった場合) | アイ 2 + コーチ 2 + 紙吹雪 2 | 6 ≤ 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 に置く。
; 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_push は T+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 の定義: hQOpCount は q_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 の内側ループ(ナイーブ版、初期実装はこれで足りる):
.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 時点の記録として残す。
| シーン / フレーム | hQHiwater | hQOpCount | 対 256 B | 出どころ |
|---|---|---|---|---|
| チュートリアル入場フレーム(製品 ROM の全シーン最大) | 90 B | 6 | 35% | S5(tests/test_flow.py) |
デバッグ ROM 10 VRAM QUEUE STRESS | 73 B | 6 | 29% | tests/test_debug_menu.py |
| ゲームオーバーフレーム | 67 B | 5 | 26% | tests/test_flow.py |
| ポーズ解除 / 会話ページ / エンディングページ切替 / SONG SELECT ← → | 57 B | 4 | 22% | tests/test_rank.py / test_text.py / test_flow.py |
本番(曲 3 を FLOW_CLEAR まで 4,153 フレーム完走。S28) | 44 B | 4 | 17% | S28(tests/test_budget.py) |
| HUD だけのフレーム(入場 / CLEAR / ART→GAME 復帰) | 34 B | 3 | 13% | tests/test_rank.py ほか |
| プロローグ 1 行 / SONG SELECT 入場 | 24 B | 1 | 9% | tests/test_art.py / test_flow.py |
タイトル(MODE 行) | 15 B | 2 | 6% | tests/test_art.py |
| エンディング入場フレーム | 0 B | 0 | 0% | 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 フレーム遅らせるだけで予算上の必要が無い。
| op | NES | GBC | 内訳 |
|---|---|---|---|
| 判定文字 7 タイル | 10 | 10 | 3 + 7 |
| スコア 6 桁 | 9 | 9 | 3 + 6 |
| 観客カウンタ 2 桁 | 5 | 5 | 3 + 2 |
| 観客スタンド 1 行 | 31(28 タイル) | 19(16 タイル) | 3 + 16 |
| バナー 20 タイル | 13(10 タイル) | 23 | 3 + 20 |
| 終端 | 1 | 1 | |
| ゲームプレイ最悪 合計 | 69 / 96(72%) | 67 / 256(26%) | |
| 固定費由来のレコード数上限 | — | 16 op / frame | Q_MAX_OPS_PER_FRAME。詳細な導出は下記 |
| その他のケース | 内訳 | GBC バイト |
|---|---|---|
| ポーズ解除フレーム | バナー消去 23 + 判定文字 10 + スコア 9 + 観客カウンタ 5 + 観客スタンド 1 行 19 + 終端 1 | 67 |
| CLEAR フレーム | RANK 7 タイル 10 + バナー 20 タイル 23 + 観客スタンド 1 行 19 + スコア 9 + 観客カウンタ 5 + 終端 1 | 67 |
| 会話ページ更新(4 行を 1 行/フレームに分割) | 20 タイル 1 行 23 | 23 |
| Issue #3 Hello GBC 基準フレーム(PyBoy 2.7.0、300 フレーム) | スコア 3 + 6 + 終端 1 | 10(hQHiwater 実測) |
| Issue #4 AI スプライト E2E フレーム(PyBoy 2.7.0、6 振り) | スコア 3 + 6 + 終端 1 | 10(hQHiwater 実測) |
| Issue #5 ステージ BG 通常フレーム(PyBoy 2.7.0、600 フレーム) | スコア 3 + 6 + 観客カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 1 | 34(hQHiwater 実測) |
| Issue #6 リズムコア 判定フレーム(PyBoy 2.7.0) | 判定文字 3 + 7 + スコア 3 + 6 + 観客カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 1 | 44(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 + 終端 1 | 57(hQHiwater 実測) |
| Issue #11 ゲームオーバーフレーム(PyBoy 2.7.0) | 判定文字 3 + 7 + バナー 3 + 20 + スコア 3 + 6 + AUD カウンタ 3 + 2 + 観客スタンド 1 行 3 + 16 + 終端 1 | 67(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 B、hQOverflow = 0だった。これは Hello GBC の基準フレームであり、後続 issue のゲームプレイ最悪値 67 B を置き換える測定ではない。
Issue #4 実測(2026-09-02): AI の 6 振り(各 20 フレーム)を含む E2E を PyBoy 2.7.0 の CGB/headless モードで実行した。
hQHiwater = 10 B、hQOverflow = 0、hQOpCount最大値 = 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 B、hQOverflow = 0、hQOpCount最大値 = 3(スコア 6 桁 /AUD:2 桁 / 観客スタンド 1 行)。gdma_rowは 1 フレームあたり最大 1 回で、観客値が変化したフレームにだけ発火した(600 フレーム中 4 回 = 起動時 1 + 注入 3)。視聴率ゲージは GDMA なので VRAM キューを 1 バイトも消費しない。BG タイルは bank0$9000-$97FFの 128 / 128 タイル(stage.2bpp2,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 B、hQOverflow = 0、hQOpCount最大値 = 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_chr208 B は起動時 1 回のgdma_bulk)。
Issue #7 実測(2026-09-03): サウンドドライバ(
src/sound.asm)を載せたbuild/aidopagaki.gbc(DRUM_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_drumsCH1 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 B(hQOpCount最大 3)、ポーズ解除フレーム 57 B(同 4)、ゲームオーバーフレーム 67 B(同 5、設計値と一致)。3 ケースともhQOverflow == 0で、上限 128 B に対して最悪でも 52%。⚠ CLEAR フレームが設計値 67 B に届かないのは仕様どおりである:main.asm .clear_performanceはtrigger_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_BLANKでq_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_BLANKをqueue_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 = 0、hQOpCount = 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.py の vblank_dots)。負荷は設計最悪キュー 67 B / 5 op に gdma_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 dots | 60.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-cycle | dots(倍速 ×2) | dots(通常速 ×4) |
|---|---|---|---|
割込みディスパッチ + push af/bc/de/hl + バンク退避 | 27 | 54 | 108 |
| OAM DMA(HRAM スタブ + 160 M 待ち) | 168 | 336 | 672 |
| VRAM キュー flush 90 B(payload 71 B × 9 M/byte + 6 op × 51 M/op、ヘッダ 18 B と終端 1 B は op 側に含める) | 945 | 1,890 | 3,780 |
gdma_row × 2(本体 66 M + call 6 M = 72 M/回) | 144 | 288 | 576 |
gdma_row × 2(転送 64 B × 2 dots/B、実時間固定) | — | 128 | 128 |
BG パレット 1 本書換(判定色、BGPI + BGPD × 8) | 40 | 80 | 160 |
BG パレット 8 本 64 B 一括反映(ART のフェード、wBgPalDirty。Issue #12) | 597 | 1,194 | 2,388 |
SCX / SCY / VBK / ROM バンク復帰 | 20 | 40 | 80 |
pop × 4 + reti | 20 | 40 | 80 |
| 通常フレーム 合計 | 1,364 | 2,856 dots(62.6%) | 5,584 dots(122.5%)✗ 超過 |
Lv4 ハイプ フレーム(+ BGP1 / BGP3 16 B) | 1,444 | 3,016(66.1%) | 5,904(129.5%)✗ |
VBlank GDMA 上限フレーム(gdma_row なし + GDMA 1,024 B + gdma_bulk 有効呼出し 44 M) | 1,264 | 4,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 ハイプのフラッシュは BGP1 と BGP3 を別々の BGPI オートインクリメント区間で塗る(隣接パレットではないので 1 回のループにまとめられない。src/judge.asm の hype_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 点である。
- 通常速では 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% に収まる。 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_1X、src/defines.inc)に下げ、2 フレームに分割する。現行の VBlank が流す GDMA は gdma_row 2 本 = 4 ブロック = 64 B だけなので、この上限の内側にそのまま収まる(src/vblank.asm の ASSERT 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 TOGGLE で rP1 = $30 → rIE = 0 → rKEY1 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 入口 LY | 144(1,500 / 1,500) | 144(1,500 / 1,500) |
VBlankHandler.isr_done LY | 149(1,500 / 1,500) | 1(1,500 / 1,500。VBlank を越えて次フレームの走査線 1) |
gdma_row 入口 LY(2 本 / フレーム) | 148 と 149(各 1,500) | 153 と 0(各 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-cycle | 1,320 M-cycle(同一) |
hQHiwater / hQOpCount | 67 B / 5 op | 67 B / 5 op |
hQOverflow | 0 | 0 |
| 完走 | ✓ | ✓ |
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_row2 本のうち 2 本目は LY 0、つまり表示中に走っている(1,500 / 1,500)。導出との突き合わせ: 上表の設計値(通常フレーム 5,584 dots = 122.5%)が想定する q_flush は 90 B / 6 op(945 M-cycle)だが、この測定の実負荷は 67 B / 5 op(
51 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-cycle(gdma_rowの引数 ASSERT 12 M × 2 本 + APU リングログの巻き戻し 5 M)だけで、残る約 158 M-cycle は上表が項目化していない製品 ROM にもある ISR 経路である(q_flushのレコード境界検査、issue #12 が足したwBgPalDirty/wSceneIsArtのゲート 2 本、視聴率ゲージの帯域色rBGPD2 バイト、属性行の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_row2 本は呼出しオーバーヘッドも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 入口 / .done | LY = 145 / 145(300 回中 300 回) |
gdma_row の転送量 | HDMA5=1 → 2 ブロック = 32 B / VBlank(gdma_row 1回分) |
| VRAM キュー | hQHiwater = 10 B、hQOverflow = 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,364 | 3.9% |
sound_tick(Issue #7 実測最悪。設計値 900 は過小) | 6,784 | 19.3% |
| 観客行のランタイム合成 | 150 | 0.4% |
| ゲームロジック(判定 / 集計 / フロー) | 1,200 | 3.4% |
| OAM 構築(16 OBJ) | 500 | 1.4% |
| ノーツレーン描画 | 400 | 1.1% |
| HUD / キュー積み | 600 | 1.7% |
| 合計(Issue #7 実測 + §4.5 再計算反映) | 10,998 | 31.3% |
| 余裕 | 24,114 | 68.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 時点の記録として残す(同じ項目の現在値は本表が正典)。
| 予算項目 | 上限 | 最終実測 | 使用率 |
|---|---|---|---|
| ROM0 | 16,384 B | 11,705 B(空き 4,679 B) | 71.4% |
| ROMX(7 バンク) | 114,688 B | 49,211 B(空き 65,477 B) | 42.9% |
| ROM 合計 | 131,072 B(128 KB / 8 バンク) | 60,916 B | 46.5% |
| WRAM0 | 4,096 B | 1,536 B(製品)/ 2,059 B(DEBUG) | 37.5% / 50.3% |
| HRAM | 127 B | 36 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 op | 6 op(同フレーム) | 38% |
hQOverflow(全シーン・全フレーム) | 0 | 0 | — |
| OAM per-line(全シーン最大) | 10 OBJ | 6 OBJ(本番レーン。マーカー 1 + ノーツ 5。S28 の 4,153 フレーム完走で確認) | 60% |
| OAM 総数(最大) | 40 OBJ | 16 OBJ(CLEAR: アイ 4 + 講師 4 + 紙吹雪 8) | 40% |
VBlank gdma_row | 2 行 / VBlank | 2 行(視聴率ゲージ 1 + 本文属性行 1。§4.5) | 100% |
| VBlank ISR 占有(倍速、実測最大) | 4,560 dots | 2,652 dots(§4.5) | 58.2% |
| OBJ タイル bank0 | 256 | 242(ノーツ + マーカー + 紙吹雪 + アイ + game-over) | 95% |
| OBJ タイル bank1 | 256 | 256(講師 32F) | 100% |
| BG タイル bank0(GAME) | 128 | 128(stage.2bpp) | 100% |
| BG タイル bank1(かな窓、GAME) | 115(判定グリフ 13 を除く) | 76(エンディング) | 66% |
| 一枚絵タイル(ART bank0) | 240 | 233(プロローグ p2。5 枚中の最大) | 97% |
| ART テキストタイル(ART bank1) | 256 | 108(英数 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 dots | 2,856 dots | 63% |
| VBlank(倍速、GDMA 上限ケース) | 4,560 dots | 4,576 dots | 100% ✗ |
| VBlank(通常速、通常フレーム) | 4,560 dots | 5,584 dots | 123% ✗ |
| フレーム全体(倍速) | 35,112 M-cycle | 4,883 M-cycle | 14% |
| VRAM キュー | 256 B | 67 B | 26% |
| VRAM キュー(Issue #3 Hello 基準、実測) | 256 B | 10 B(hQHiwater) | 4% |
VBlank gdma_row(Issue #3、実測) | LY 144-153 | 入口 145 / 完了 145 | 32 B / VBlank |
| VRAM キュー(Issue #4 AI スプライト、実測) | 256 B | 10 B(hQHiwater) | 4% |
| OAM(Issue #4 AI スプライト、実測) | 40 OBJ | 4 OBJ | 10% |
| OAM(per-line、Issue #4 AI スプライト、実測) | 10 OBJ | 2 OBJ(同一走査線の実測最大) | 20% |
| OBJ タイル bank0(Issue #4 AI + game-over、実測) | 256 | 224 | 88% |
| ROM 使用量(Issue #4 build map、実測) | 128 KB | 5,516 B | 4.2% |
| VRAM キュー(Issue #5 ステージ BG、実測) | 256 B | 34 B(hQHiwater) | 13% |
| VRAM キュー op(Issue #5、実測) | 16 op | 3 op | 19% |
VBlank gdma_row(Issue #5、実測) | 2 行 / VBlank | 1 行(観客値が変化したフレームだけ) | 50% |
| BG タイル bank0(Issue #5 ステージ BG、実測) | 128 | 128 | 100% |
| ROM 使用量(Issue #5 build map、実測) | 128 KB | 12,452 B | 9.5% |
| VRAM キュー(Issue #6 リズムコア、実測) | 256 B | 44 B(hQHiwater) | 17% |
| VRAM キュー op(Issue #6、実測) | 16 op | 4 op | 25% |
| OAM(per-line、Issue #6 レーン、実測) | 10 OBJ | 6 OBJ(マーカー 1 + ノーツ 5) | 60% |
| OBJ タイル bank0(Issue #6 ノーツ + マーカー + 紙吹雪、実測) | 256 | 242 | 95% |
| ROM 使用量(Issue #6 build map、実測) | 128 KB | 14,300 B | 10.9% |
| BG タイル bank1(Issue #6 判定グリフ、実測) | 128 | 13($73-$7F 常駐) | 10% |
| VRAM キュー(Issue #7 サウンドドライバ、実測) | 256 B | 34 B(hQHiwater)(BGM 再生中も不変。サウンドは VRAM キューを消費しない) | 13% |
sound_tick(Issue #7、実測最悪) | 35,112 M-cycle | 6,784 M-cycle | 19.3% |
| ROM 使用量(Issue #7 build map、実測) | 128 KB | ROM0 6,962 B / ROMX 18,440 B = 合計 25,402 B | 19.4% |
| VRAM キュー(Issue #8 譜面 4 曲統合、実測) | 256 B | 44 B(hQHiwater)(本番曲 3「クリムゾン・オーバードライブ」を $FF 終端まで通した 4,210 フレームの最悪値。シーンが張り替わる直前のラッチ値) | 17% |
| VRAM キュー op(Issue #8、実測) | 16 op | 4 op | 25% |
| VRAM キュー(Issue #8 SELECT やり直し、実測) | 256 B | 34 B(hQHiwater)(再カウントインを挟むフレーム) | 13% |
| ROMX bank1(Issue #8 曲 + 譜面、実測) | 16,384 B | 9,591 B(BGM ストリーム 7,704 B + 譜面/拍ターゲット表 1,887 B) | 59% |
| ROM 使用量(Issue #8 build map、実測) | 128 KB | ROM0 7,334 B / ROMX 20,327 B = 合計 27,661 B | 21.1% |
| VRAM キュー(Issue #9 講師スプライト、実測) | 256 B | 34 B(hQHiwater)(講師は VRAM キューを 1 バイトも使わない) | 13% |
| OAM(per-line、Issue #9 キャラ帯、実測) | 10 OBJ | 4 OBJ(アイ 2 + コーチ 2) | 40% |
| OBJ タイル bank1(Issue #9 講師 CHR、実測) | 256 | 256 | 100% |
| VRAM キュー(Issue #15 10 VRAM QUEUE STRESS、実測) | 256 B | 73 B(hQHiwater)(設計最悪 67 + 実測表示 6) | 29% |
| VRAM キュー op(Issue #15、実測) | 16 op | 6 op | 38% |
VBlank ISR(Issue #15 倍速・設計最悪キュー + gdma_row 2 本、実測) | 4,560 dots | 2,640 dots(入口 LY 144 / 終端 LY 149) | 57.9% |
VBlank ISR(Issue #15 通常速・設計最悪キュー + gdma_row 2 本、実測) | 4,560 dots | 5,280 dots(入口 LY 144 / 終端 LY 1 = 次フレーム) | 115.8%(✗ 超過。§4.5 S30) |
| ROMX bank7(Issue #15 デバッグメニュー、実測) | 16,384 B | 1,219 B(-D DEBUG_MENU=1 のみ。ROM0 トランポリンは別に 165 B。製品はどちらも 0 B) | 7% |
| WRAM0(Issue #15 デバッグメニュー、実測) | 4,096 B | 製品 1,536 B / DEBUG 2,059 B(wApuLog 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 KB | ROM0 7,757 B / ROMX 24,423 B = 合計 32,180 B | 24.6% |
| VRAM キュー(Issue #10 会話ページ更新、実測) | 256 B | 57 B(hQHiwater)(HUD 34 + 会話行 23) | 22% |
| VRAM キュー op(Issue #10 会話ページ、実測) | 16 op | 4 op(会話行 / スコア / AUD カウンタ / 観客スタンド 1 行) | 25% |
| VRAM キュー(Issue #10 Lv4 ハイプ発火フレーム、実測) | 256 B | 44 B(hQHiwater)(フラッシュはパレット RAM の直書きでキューを 1 バイトも使わない) | 17% |
VBlank gdma_row(Issue #10 テキスト属性行、実測) | 2 行 / VBlank | 2 行(視聴率ゲージ 1 + 本文属性行 1) | 100% |
| BG タイル bank1(Issue #10 かな窓、実測) | 115(判定グリフ 13 を除く) | 76(エンディング。会話 54 / プロローグ 60) | 66% |
| ROMX bank5(Issue #10 かな + テキスト、実測) | 16,384 B | 5,220 B(かなフォント 3 種 3,040 B + 行データ 2,180 B) | 32% |
| ROM 使用量(Issue #10 build map、実測) | 128 KB | ROM0 8,794 B / ROMX 29,643 B = 合計 38,437 B | 29.3% |
| ROM 使用量(Issue #9 + #11 マージ + #11 修正ラウンド 2 後 build map、実測) | 128 KB | ROM0 8,773 B / ROMX 24,423 B = 合計 33,196 B | 25.3% |
| VRAM キュー(Issue #12 タイトル、実測) | 256 B | 15 B(hQHiwater)(MODE:NORMAL ⇄ MODE:EASY の 11 B + ヘッダ 3 B + 終端 1 B。カーソル移動は 1 B × 2 本で 9 B) | 6% |
| VRAM キュー op(Issue #12 タイトル、実測) | 16 op | 2 op(カーソルの消去 + 描画) | 13% |
| VRAM キュー(Issue #12 ART→GAME 復帰フレーム、実測) | 256 B | 34 B(hQHiwater)(Issue #5 / #7 / #9 の基準値と同値) | 13% |
| 一枚絵タイル(Issue #12 タイトル、実測) | 240 | 211(.2bpp 3,376 B。-u -m の反転統合後) | 88% |
| ART テキストタイル bank1(Issue #12 タイトル、実測) | 256 | 48(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 B | 3,920 B(タイル 3,376 + tilemap 240 + attrmap 240 + パレット 64) | 24% |
| ROM 使用量(Issue #12 単独 build map、実測) | 128 KB | ROM0 9,793 B / ROMX 28,343 B = 合計 38,136 B | 29.1% |
| ROM 使用量(Issue #10 + #11 マージ後 build map、実測) | 128 KB | ROM0 9,810 B / ROMX 29,643 B = 合計 39,453 B | 30.1% |
| VRAM キュー(Issue #10 + #11 マージ後、チュートリアル入場フレーム、実測) | 256 B | 90 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 KB | ROM0 9,822 B(+12 B) / ROMX 29,643 B(不変) = 合計 39,465 B | 30.1% |
| VRAM キュー(Issue #14 エンディング入場フレーム、実測) | 256 B | 0 B(hQHiwater)(かな窓 76 ブロック = 1,216 B は GDMA、本文の消去は次フレームから。入場フレームは VRAM キューを 1 バイトも使わない) | 0% |
| VRAM キュー(Issue #14 エンディングページ切替フレーム、実測) | 256 B | 57 B(hQHiwater)(HUD 34 + 本文行 23。会話ページ更新と同値) | 22% |
| ROM 使用量(Issue #14 単独 build map、実測) | 128 KB | ROM0 9,998 B(#10 修正ラウンド 1 から +176 B) / ROMX 29,643 B(不変) = 合計 39,641 B | 30.2% |
| ROM 使用量(Issue #10 + #11 + #12 マージ後 build map、実測) | 128 KB | ROM0 10,926 B / ROMX 33,563 B = 合計 44,489 B | 33.9% |
| ROM 使用量(Issue #14 + #12 + #15 マージ後 build map、実測) | 128 KB | ROM0 11,094 B / ROMX 33,563 B = 合計 44,657 B | 34.1% |
| 一枚絵タイル(Issue #13 プロローグ 4 枚、実測) | 240 | 233(p2 が最大。p1 208 / p3 206 / p4 195) | 97% |
| ART テキストタイル bank1(Issue #13 プロローグ、実測) | 256 | 108(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 行 / VBlank | 0 行(ART は視聴率ゲージも本文属性行も持たない) | 0% |
| VRAM キュー(Issue #13 プロローグ、実測) | 256 B | 24 B(hQHiwater)(かな 1 行 20 B + ヘッダ 3 B + 終端 1 B。1 フレームに積む行は 1 行) | 9% |
| VRAM キュー op(Issue #13 プロローグ、実測) | 16 op | 1 op(8 フレームに 1 行) | 6% |
ROMX bank6(Issue #13 ART_A、実測) | 16,384 B | 12,064 B(タイトル 3,920 + p1 3,872 + p2 4,272) | 74% |
ROMX bank7(Issue #13 ART_B、実測) | 16,384 B | 7,504 B(p3 3,840 + p4 3,664) | 46% |
| ROM 使用量(Issue #13 マージ後 build map、実測) | 128 KB | ROM0 11,357 B / ROMX 49,211 B = 合計 60,568 B | 46.2% |
| VRAM キュー(Issue #14 SONG SELECT 入場フレーム、実測) | 256 B | 24 B(hQHiwater)(選曲行 3 + 20 + 終端 1。game_scene_restore が q_reset / q_hiwater_reset を通ったあとの最初の 1 フレームなので HUD はまだ積まれていない) | 9% |
| VRAM キュー(Issue #14 SONG SELECT ← → 移動フレーム、実測) | 256 B | 57 B(hQHiwater)(HUD 34 + 選曲行 23。会話ページ更新と同値。無操作のフレームは HUD だけの 34 B) | 22% |
| ROM 使用量(Issue #14 + #13 マージ + SONG SELECT 実装後 build map、実測) | 128 KB | ROM0 11,705 B / ROMX 49,211 B(#13 から不変) = 合計 60,916 B | 46.5% |
| OAM(総数) | 40 OBJ | 16 OBJ(CLEAR 時) | 40% |
| OAM(per-line、レーン) | 10 OBJ | 7 OBJ | 70% |
| OBJ タイル bank0 | 256 | 242 | 95% |
| OBJ タイル bank1 | 256 | 256 | 100% |
| BG タイル bank0(GAME) | 128 | 112 | 88% |
| BG タイル bank1(かな窓、GAME) | 115(判定グリフ 13 を除く) | 76(エンディング) | 66% |
| 一枚絵タイル(ART bank0) | 240 | 233(プロローグ p2、実測。5 枚中の最大) | 97% |
| ART テキストタイル(ART bank1) | 256 | 108(英数 47 + 区切り 1 + かな 60) | 42% |
| ROM | 128 KB(8 バンク) | 60,916 B(全 issue マージ後の実測。上の最終実測表と同値) | 46.5% |
| WRAM bank0 | 4,096 B | 1,536 B(製品)/ 2,059 B(DEBUG) | 38% / 50% |
| HRAM | 127 B | 36 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.asm の kana_window_load を text_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}.png160×96 →rgbgfx -c gbc: -u -m -N 240 -b 0)のユニークタイルは 208 / 233 / 206 / 195、.2bppは 3,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.mdI3 の解消)。プロローグ入場(
flow_enter_prologue→art_scene_begin+art_load_kana_prologue)のgdma_bulkは 5 回 / 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.png160×96 →rgbgfx -c gbc: -u -m -N 240 -b 0)は ユニークタイル 211 / 240、.2bpp3,376 B、.tilemap/.attrmap各 240 B、.gbcpal64 B。ART_A(ROMX bank6)の実占有は 3,920 B / 16,384 B。ART 入場(
art_scene_begin)のgdma_bulkは 4 回で、bank0 が$8000へ 128 ブロック +$8800へ 83 ブロック(絵 211 タイル)、bank1 が$8000へ 47 ブロック(UI 英数)+$82F0へ 1 ブロック(区切り線)である。どの転送も 128 ブロック以下で、入口と完了のVBKが同一(バンクを跨がない)ことをtests/test_art.pyがフックで固定している。ART→GAME 復帰(game_scene_restore→game_chr_reload)は 9 回 / 711 ブロック = 11,376 B = 22,752 dots。Initも同じgame_chr_reloadを呼ぶので、起動経路と復帰経路で手順が分岐しない。⚠ CHR 往復の所要時間は導出値である(Issue #9 と同じ理由。PyBoy は GDMA の CPU 停止をモデル化しない)。実測なのは転送の回数・ブロック数・バンク・LCD 状態だけである。
hQHiwaterは タイトルで 15 B(MODE行の 11 B が最大のレコード)、ART→GAME 復帰フレームで 34 B(#5 / #7 / #9 の基準値と同値)。ART のフレームは HUD も観客も持たないので、キューはメニュー操作のあるフレームしか使わない。⚠ シーン切替のフレームは VBlank ISR が 1 回も走らない。 LCD OFF 中は
LYが 0 で停止して VBlank 割込みが来ないためで、tests/harness.pyのtick()はこのフレームに限って「VBlank フック 0 回」を許す。緩和が発火するのは 1 遷移あたり 2 フレーム以下であることをtests/test_art.pyが固定している。
Issue #9 実測(2026-09-03): 講師 32 フレーム(
assets/coach-32f.2bpp4,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.chrはnes_aiDopagaki@218089021b64af9026dcaf8f0416d8e4cb394ecaから vendored した 4,096 B で、bank1 の OBJ 窓を 256 / 256 タイルちょうど埋める(フレーム追加の余地は無い)。⚠ CHR 再ロード時間は導出値であって実測値ではない。
gdma_bulkは汎用 GDMA(HDMA5bit7 = 0)で CPU を停止させるが、PyBoy はこの停止時間をモデル化しない(DIV差分が 0 になる)ため、エミュレータからは測れない。上表の 20,032 dots は §5.4 の 2 dots/byte から求めた値で、転送の回数とブロック数だけが実測である: 起動時のgdma_bulkフックが bank1 へ$8000128 ブロック +$8800128 ブロックのちょうど 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_done(tests/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 モードで実行した。タイトルから本番までの実チェーン(wFlowState9 → 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_flushはrVBK = 0固定で走る(§4.3 のフォーマットにバンク欄は無く、追加は正典の変更にあたる)。そこでタイル行だけをq_pushで積み、属性行はwRowScratchスロット 0 に合成して VBlank ISR がrVBK = 1でgdma_rowする。VBlank のgdma_rowは視聴率ゲージ 1 本と合わせて 2 行 / VBlank ちょうど(GDMA_MAX_ROWS_PER_VBLANKを使い切る)で、src/vblank.asmのVBLANK_GDMA_ROWS == 2のASSERTがこれを固定する。キューのフォーマット(§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_off→gdma_bulk→scene_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 = 0、hQHiwaterの最大は 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_endingはwEndingPhase(消去相 ⇄ ページ相)で既存のtext_begin_clear(1 フレーム 1 行、ステージの休止状態へ戻す)を挟み、相の切替では必ずフレームをまたぐ。消去の最終行とページ 1 行目を同じフレームに積むとhQHiwaterが 34 + 23 + 23 = 80 B に跳ねる(§4.4 の「1 フレームに積む行は 1 行」。flow_update_clear_to_performanceが警告している罠と同じもの)。曲 4(トワイライト・スターズ)は
flow_enter_endingのsound_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 B(wTextScene/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.asm、FLOW_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 = 0、hQHiwaterは入場フレーム 24 B(選曲行 3 + 20 + 終端 1。game_scene_restoreのq_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.asmのperformance_song_select→song_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_highwayがBANNER_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 回)
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_bulk は HDMA5 へ書く直前にも 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,872 | 242 | 2 回(128 + 114) | 7,744 |
| 講師 CHR(bank1 OBJ) | 4,096 | 256 | 2 回(128 + 128) | 8,192 |
| ステージ BG タイル | 2,048 | 128 | 1 回 | 4,096 |
| かな窓入替(エンディング 76 タイル) | 1,216 | 76 | 1 回 | 2,432 |
| 一枚絵タイル | 3,840 | 240 | 2 回(128 + 112) | 7,680 |
| ART 用 UI 英数フォント 47(bank1) | 752 | 47 | 1 回 | 1,504 |
| ART 用 区切り線 1 + かな 60(bank1) | 976 | 61 | 1 回 | 1,952 |
| BG マップ 1 行 | 32 | 2 | 1 回 | 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_title → art_scene_end)が行い、art_scene_end はその直後に ei する。起動経路ではこの時点で rIE == $00 なので、この ei は何も有効化しない。 Init は flow_goto から戻った直後に明示的に di へ戻し、そのうえで rIF クリア → rIE = IE_VBLANK → ei を行う。割込みを実際に有効化する 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.mdS4 /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 するだけで進行を完全に追える。grep で ld [wFlowState], a が 1 箇所しかないことを CI 検査する。
⚠ pause_flag は状態にしない(NES 版と同じ)。ポーズ中は sound_tick をスキップして BGM を行位置ごと凍結する。
⚠ ENTRY_OPENING / ENTRY_COUNTIN は状態ではなく enter の中のサブルーチンである(../spec/aidopagaki-gb-design.md §1.1)。flow_enter_entrance が entrance_opening_init を、flow_enter_performance が performance_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 つある:
- 必要がない。 シーン切替は CHR の入替を伴うので、どのみち LCD OFF 窓(§5.4)に入る。LCD OFF 中に
$9800を直接組み替えれば、構築途中の画面は物理的に表示されない。「裏で組み立ててアトミックに見せる」という二面の唯一の利点は、LCD OFF 窓がすでに提供している。 - 表示面を指すベースを全経路が解決しなければならない。 二面にすると
q_pushの宛先もgdma_rowのdstも 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 TITLE | ART | 一枚絵(タイトル)3,840 B + bank1 に UI 英数 47 + 区切り線 1(かなは使わない)768 B |
| 10 PROLOGUE | ART | 一枚絵(ページごと)3,840 B + bank1 に UI 英数 47 + 区切り線 1 + プロローグかな 60 = 1,728 B |
| 8 ENTRANCE | GAME | OBJ CHR 再ロード 7,968 B + ステージ BG 2,048 B + かな窓 ← 会話 54(864 B) |
| 0,1,2,3,4,5,6,7,11 | GAME | 再ロードなし(BG マップの差分のみ) |
| 12 ENDING | GAME | かな窓 ← エンディング 76(1,216 B) |
⚠ すべての enter は flow_goto から呼ばれ、その直前に q_hiwater_reset が走る(§6.2)。したがって hQHiwater は「そのシーンに入ってから現在までの累積最大」を意味する。E2E はシーンごとの実測値としてこれを読む。
7. サウンドドライバ
7.1 実行位置
⚠ sound_tick は VBlank ISR ではなく、メインループの先頭(vsync 待ちの直後)で回す。 理由は 2 つ:
sound_tickは VBlank 予算の最大変動要因であり、ISR に入れると破綻する。設計値では 900 M-cycle(倍速 VBlank 予算 2,280 M-cycle の 39%)と見積もっていたが、実測最悪は 6,784 M-cycle(§4.6 Issue #7 実測)で、ISR に入れれば VBlank 予算そのものを超過していた- メインループ先頭なら実行タイミングが毎フレーム同一位置に固定され、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 チャンネル対応
| NES | GBC | 移植方針 |
|---|---|---|
p1 pulse1 | CH1(duty + sweep) | 周期式のみ変更、他は 1:1 |
p2 pulse2 | CH2(duty) | 同上 |
tr triangle | CH3 wave(32 × 4bit) | wave RAM に三角波を 1 回だけ書く。音量は NR32 の 4 段 |
nz noise | CH4 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 / CH2 | 2047 | X = 2048 - div ≥ 1。f ≥ 64 Hz(MIDI 36 = 65.41 Hz) |
| CH3 | 4095(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) | div | CH1/2 X | CH3 X |
|---|---|---|---|---|---|
| 24 | C1 | 32.70 | 4008 | (不可) | 44 |
| 26 | D1 | 36.71 | 3571 | (不可) | 263 |
| 36 | C2 | 65.41 | 2004 | 44 | 1046 |
| 60 | C4 | 261.63 | 501 | 1547 | 1798 |
| 69 | A4 | 440.00 | 298 | 1750 | 1899 |
| 84 | C6 | 1046.50 | 125 | 1923 | 1986 |
| 107 | B7 | 3951.07 | 33 | 2015 | 2032 |
エクスポータの検査(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 段をそのまま適用できないので量子化する。
| 楽器音量 vol | NR32 | 出力 |
|---|---|---|
| 0 | $00 | ミュート |
| 1-4 | $60 | 25% |
| 5-10 | $40 | 50% |
| 11-15 | $20 | 100% |
⚠ 実効段数を増やすなら「振幅を 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 Hz(262144 / 0.5)である。262,144 Hz ではない。この係数を 2 倍間違えると表全体が 1 オクターブずれ、「NES の高域ノイズは GB で頭打ちになる」という誤った結論に至る。実際には NES idx 0 の 447 kHz も GB で表現できる。
| NES idx | NES 周期 | NES f (Hz) | GB NR43 | shift / r | GB f (Hz) | 誤差 |
|---|---|---|---|---|---|---|
| 0 | 4 | 447,443 | $00 | 0 / 0.5 | 524,288 | +17.2% |
| 1 | 8 | 223,722 | $01 | 0 / 1 | 262,144 | +17.2% |
| 2 | 16 | 111,861 | $02 | 0 / 2 | 131,072 | +17.2% |
| 3 | 32 | 55,930 | $05 | 0 / 5 | 52,429 | −6.3% |
| 4 | 64 | 27,965 | $15 | 1 / 5 | 26,214 | −6.3% |
| 5 | 96 | 18,643 | $17 | 1 / 7 | 18,725 | +0.4% |
| 6 | 128 | 13,983 | $25 | 2 / 5 | 13,107 | −6.3% |
| 7 | 160 | 11,186 | $26 | 2 / 6 | 10,923 | −2.4% |
| 8 | 202 | 8,860 | $27 | 2 / 7 | 9,362 | +5.7% |
| 9 | 254 | 7,046 | $35 | 3 / 5 | 6,554 | −7.0% |
| 10 | 380 | 4,710 | $37 | 3 / 7 | 4,681 | −0.6% |
| 11 | 508 | 3,523 | $45 | 4 / 5 | 3,277 | −7.0% |
| 12 | 762 | 2,349 | $47 | 4 / 7 | 2,341 | −0.3% |
| 13 | 1,016 | 1,762 | $55 | 5 / 5 | 1,638 | −7.0% |
| 14 | 2,034 | 880 | $65 | 6 / 5 | 819 | −6.9% |
| 15 | 4,068 | 440 | $75 | 7 / 5 | 410 | −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 | 目標 Hz | ch3_div | CH3 X | NR32 | CH4 NR42 | CH4 NR43 | ノイズ Hz |
|---|---|---|---|---|---|---|---|
| 0 | 203.0 | 323 | $06BD (1725) | $20 100% | $F0 | $54 | 2,048 |
| 1 | 185.0 | 354 | $069E (1694) | $20 | $80 | $64 | 1,024 |
| 2 | 169.0 | 388 | $067C (1660) | $20 | $00 DAC off | — | — |
| 3 | 154.8 | 423 | $0659 (1625) | $20 | |||
| 4 | 142.1 | 461 | $0633 (1587) | $20 | |||
| 5 | 130.7 | 501 | $060B (1547) | $20 | |||
| 6 | 120.6 | 543 | $05E1 (1505) | $40 50% | |||
| 7 | 111.7 | 587 | $05B5 (1461) | $40 | |||
| 8 | 103.6 | 632 | $0588 (1416) | $40 | |||
| 9 | 96.5 | 679 | $0559 (1369) | $60 25% | |||
| 10 | 90.1 | 727 | $0529 (1321) | $60 | |||
| 11 | 84.5 | 776 | $04F8 (1272) | $60 | |||
| (12) | — | — | 解放後の状態。⚠ 注記行でありエントリではない。CH3_RELEASE は f11 に立つ |
SNARE(9 フレーム / 151 ms)
CH4 のノイズ明度をフレーム単位でスイープする。NR43 はリトリガなしで書換え可能。
| f | CH4 NR42 | CH4 NR43 | ノイズ Hz | CH3 X(190 Hz ボディ) | NR32 |
|---|---|---|---|---|---|
| 0 | $F0 | $14 | 32,768 | $06A7 (1703) | $40 50% |
| 1 | $E0 | $02 | 131,072 | $06A7 | $60 25% |
| 2 | $C0 | $24 | 16,384 | CH3 解放(CH3_RELEASE = 1、CH3_WRITE = 0) | — |
| 3 | $A0 | $24 | 16,384 | ||
| 4 | $80 | $34 | 8,192 | ||
| 5 | $60 | $36 | 5,461 | ||
| 6 | $40 | $44 | 4,096 | ||
| 7 | $20 | $54 | 2,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 | 目標 Hz | ch3_div | CH3 X | NR32 | CH4 NR42 | CH4 NR43 | ノイズ Hz |
|---|---|---|---|---|---|---|---|
| 0 | 150.0 | 437 | $064B (1611) | $20 | $B0 | $55 | 1,638 |
| 1 | 144.1 | 455 | $0639 (1593) | $20 | $50 | $65 | 819 |
| 2 | 138.5 | 473 | $0627 (1575) | $20 | $00 DAC off | — | — |
| 3 | 133.3 | 492 | $0614 (1556) | $20 | |||
| 4 | 128.4 | 510 | $0602 (1538) | $40 | |||
| 5 | 123.8 | 529 | $05EF (1519) | $40 | |||
| 6 | 119.5 | 548 | $05DC (1500) | $40 | |||
| 7 | 115.5 | 567 | $05C9 (1481) | $40 | |||
| 8 | 111.7 | 587 | $05B5 (1461) | $60 | |||
| 9 | 108.2 | 606 | $05A2 (1442) | $60 | |||
| 10 | 104.9 | 625 | $058F (1423) | $60 | |||
| (11) | — | — | 解放後の状態。⚠ 注記行でありエントリではない。CH3_RELEASE は f10 に立つ |
ドラムテーブルのバイナリ形式(1 フレーム = 4 B)
サイズ = (12 + 9 + 11) × 4 B = 128 B。NES の DPCM サンプル 2,307 B の 1/18。
| バイト | ビット | 内容 |
|---|---|---|
| B0 | 7-0 | CH3 X の下位 8 bit(X = 2048 - ch3_div) |
| B1 | 2-0 | CH3 X の上位 3 bit(X は 11 bit) |
| 4-3 | NR32 コード: 0 → $00(ミュート) / 1 → $20(100%) / 2 → $40(50%) / 3 → $60(25%) | |
| 5 | CH3_WRITE: 1 なら X と NR32 を書く。0 なら CH3 に触れない | |
| 6 | CH4_WRITE: 1 なら B2 / B3 を NR42 / NR43 に書く。0 なら CH4 に触れない | |
| 7 | CH3_RELEASE: 1 ならこのフレームで CH3 の借用を終え、tr_restore を呼ぶ。⚠ **最終フレームとは限らない。立つのは「CH3 の発音が終わるフレーム」**で、CH3_WRITE = 1 のフレーム(KICK / TOM の最終フレーム)ではフレーム末に、CH3_WRITE = 0 のフレーム(SNARE の f=2)ではフレーム先頭に解放する。位置は KICK = f11 / SNARE = f2 / TOM = f10 のちょうど 1 フレームずつ | |
| B2 | 7-0 | NR42($00 は DAC オフ = 停止。CH4_WRITE = 1 のまま書く) |
| B3 | 7-0 | NR43 |
⚠ 表の —(横線)は「書かない」であって「値 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 の下位バイトが $10、wWaveShadow の下位バイトが $30、かつ両者が同じ 256 B ページにあることが正しさの前提である。§8.1 は wApuShadow = $C310 / wWaveShadow = $C330 と定める。
; 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-$C326 | 23 B | $FF10-$FF26(NR10-NR52)の書込み鏡 |
wave_shadow | $C330-$C33F | 16 B | $FF30-$FF3F の書込み鏡 |
⚠ シャドウなしでは APU の検証が原理的に成立しない。 GB の APU レジスタは読み戻せない($FF13 は常に $FF、$FF14 は bit6 のみ)。PyBoy にも write callback はない。
ログの二重化(../design/e2e.md §5):
- 第一手段: PyBoy の
hook_registerをsnd_write_regの入口に張り、(frame, C, A)を記録 - 第二手段(
-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 Vxx | 100% そのまま(§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 = 1でsound_tickをスキップし、NR51 = 0(ミキサ遮断)。行位置は保持される。解除でNR51復帰 + 全チャネル再トリガ。NES 版の「BGM の行位置ごと凍結」と同一挙動 snd_silence:NR52 = 0(APU 全体 OFF)→NR52 = $80、NR50 = $77、NR51 = $FFで再初期化
⚠ ポーズで snd_silence を呼んではならない。 NR52 = 0 は APU 全体をリセットし、チャンネルの周期・音量・duty 位相をすべて失う。「行位置ごと凍結して復帰する」という要件と両立しない。ポーズの消音は NR51 = 0(ミキサ遮断)だけである。snd_silence はシーン遷移専用(BGM を完全に打ち切って次のシーンへ移るとき)であり、flow_goto の exit からのみ呼ぶ。仕様 §11.1 もこの定義に揃えてある。
8. WRAM / HRAM マップ
⚠ シンボル名の接頭辞規約(本書と E2E とテストで共有する唯一の命名規則):
| 領域 | 接頭辞 | 例 |
|---|---|---|
HRAM($FF80-$FFFE) | h | hFrameCount / hCurSpeed / hQHiwater / hQOpCount |
WRAM($C000-$CFFF) | w | wFlowState / 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-$CFFF。SVBK は 1 固定で bank1 は未使用)
| アドレス | サイズ | シンボル | 内容 |
|---|---|---|---|
$C000-$C09F | 160 | wOamShadow | OAM シャドウ($FF46 の DMA 元。256 B 境界整列が必須 → $C000 ✓) |
$C0A0-$C0FF | 96 | wRowScratch | BG マップ行合成スクラッチ(32 B × 3:観客 2 段 + ゲージ) |
$C100-$C1FF | 256 | wVramQueue | VRAM キュー |
$C200-$C2FF | 256 | wFlowState ほか | ゲーム変数(wFlowState, wSongFrame, wAudience, wScoreDigits[6], 判定カーソル、ポーズ状態…) |
$C300-$C30F | 16 | — | 予備(wApuShadow を $xx10 に整列させるためのパディング) |
$C310-$C326 | 23 | wApuShadow | $FF10-$FF26(NR10-NR52)の書込み鏡。LOW == $10 が snd_write_reg の前提 |
$C327-$C32F | 9 | — | 予備 |
$C330-$C33F | 16 | wWaveShadow | $FF30-$FF3F の書込み鏡。LOW == $30 が前提。wApuShadow と同一ページ |
$C340-$C3B7 | 120 | wChanState | サウンド: チャンネル状態 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-$C3BF | 8 | — | 予備 |
$C3C0-$C3FF | 64 | wDrumState | ドラムエンジン状態(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-$C43F | 64 | wBgPalShadow | BG パレットシャドウ(8 本 × 4 色 × 2 B) |
$C440-$C47F | 64 | wObjPalShadow | OBJ パレットシャドウ |
$C480-$C57F | 256 | wTextScratch | テキストページ合成スクラッチ |
$C580-$C5FF | 128 | — | 予備 |
$C600-$C7FF | 512 | wApuLog(DEBUG のみ) | APU リングログ(-D DEBUG=1 のみ。[reg_lo, value] × 256) |
$C800 | 1 | wApuLogLen(DEBUG のみ) | wApuLog の使用エントリ数。VBlank ISR が毎フレーム 0 に戻す |
$C801-$C80A | 10 | wDebugMenuVars(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%、
wApuLog512 B +wApuLogLen1 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_reg が ld l, c の 1 命令でアドレスを作れる(§7.8)。旧稿はチャンネル状態を $C300-$C33F に置き、シャドウを $C340 に置いていたため、snd_write_reg の書込先がチャンネル状態の内側に落ちていた。ビルド時 ASSERT で境界を強制する。
⚠ パレットシャドウを持つ理由: BCPD/OCPD も読み戻しに制約があり、フェード演出(前の値から 1/16 ずつ寄せる)には元の値が要る。
8.2 HRAM($FF80-$FFFE、127 B)
| アドレス | シンボル | 内容 |
|---|---|---|
$FF80-$FF89 | hOamDmaRoutine | OAM DMA ルーチン(10 B、init で HRAM へコピー。§4.2) |
$FF8A | hVBlankFlag | vsync 待ちのフラグ |
$FF8B-$FF8C | hFrameCount | 16bit。起動検出にも使う(../design/e2e.md §2) |
$FF8D-$FF8F | hPadCur / hPadPrev / hPadNew | |
$FF90-$FF91 | hQTail | キュー書込みポインタ |
$FF92 | hQOverflow | 0 以外はテスト失敗 |
$FF93 | hQHiwater | シーン内の累積最大使用バイト。flush ではリセットしない。q_hiwater_reset を呼んだときだけ 0 になる(§4.3 / §6.2) |
$FF94 | hQOpCount | q_reset からの受理レコード数。Q_MAX_OPS_PER_FRAME の判定に使う |
$FF95 | hCurRomBank | MBC5 のバンクレジスタは書込専用 → 必ず鏡を持つ |
$FF96 | hCurVramBank | |
$FF97 | hCurSpeed | 0 = 通常 / 1 = 倍速 |
$FF98 | hSoundGate | 1 = ポーズ中 |
$FF99-$FFA0 | hIsrScratch | ISR 専用スクラッチ。メインループからは使わない |
$FFA1-$FFA3 | hMainScratch | メインループ専用スクラッチ(q_push の長さ・tail 保存。コピー残数はレジスタ D で保持。3 B) |
$FFA4 | hDebugAssert | DEBUG の 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 cycle | VRAM bank1 に 32 フレーム常駐(§3.2) |
| CHR 窓の共有(プロローグ/エンディング/会話) | リセット時に progress_entry_request で分岐 | かな窓を GDMA でシーン入替(§3.3) |
| リセットによる画面遷移 | マジックバイト $C3/$3C + 範囲チェック + 6 B 保存ブロック | 状態機械 + LCD OFF 窓での画面再構築(§6) |
| 観客テーブル 17 状態 × 4 コマ × 168 B | 11,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 の「実際の失敗確率順に並べた鑑別診断ラダー」形式を踏襲する。
| 順 | リスク | 兆候 | 緩和策 | 残存 |
|---|---|---|---|---|
| 0 | rgblink -n の .sym を出し忘れる/文書と実装でシンボル名が揺れる | E2E が KeyError で全滅する | .sym を毎ビルド再生成し、ハーネスは名前解決を原則とする。.sym に載らないハードウェアレジスタ等は tests/harness.py の HW 表に集約し、それ以外のアドレス直書きを禁止する(0x[0-9A-Fa-f]{4} と 10 進 \b65[0-9]{3}\b を CI で検査)。§8 冒頭の接頭辞規約(HRAM = h、WRAM = w)を全文書で守る | 低 |
| 1 | CH1/CH2 の毎フレーム音量更新でリトリガが 60 Hz 発生し、duty 位相リセットでブザーになる | 定常音が「ジー」と鳴る | §7.4 の R2(値が変わったフレームだけリトリガ)+ R3(エクスポータが vol 系列の定常部を平坦化)。子 issue 6 の早期に実機で試聴する | 中 |
| 2 | エフェクトのピッチ方向が逆になる | ビブラート・スライドが逆向き、ポルタメントが発散 | §7.3 の「内部は div 保持、書込み時のみ X = 2048 - div」を設計ルール化。ref sim が最初に検出する | 低 |
| 3 | 8×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)が足りなくなる | rgblink が Section ... 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)の目視レビューを必須ゲートにする | 中 |
| 7 | GDMA を表示中に撃って画面が化ける | 帯状のノイズ | gdma_row を VBlank 専用、gdma_bulk を LCD OFF 専用に API で分離し ASSERT。VBlank 用の一括 API は作らない(§5.2)。E2E S4 がフック回数で両者の呼出し文脈を検査する | 低 |
| 8 | OBJ タイルが bank0 242/256・bank1 256/256 でほぼ満杯 | フレーム追加の余地がない | X フリップ属性による idle コマの共有、追加演出は BG 側で作る | 低 |
| 9 | 倍速の STOP が実機でハングする | 起動しない | §5.1 の手順(rP1=$30 → rIE=0 → KEY1 → STOP)を厳守し、SameBoy / BGB / 実機の 3 環境での起動確認を子 issue 2 の受入条件にする | 低 |
| 10 | PyBoy のスクリーンショットが CGB 色で環境依存に揺れる | golden 比較が落ちる | 子 issue 2 の受入条件で「同一入力 2 回でバイト一致」を最初に証明する。不安定なら比較を BG マップのタイル列中心に切り替え、画像は目視レビュー用に降格 | 中 |
| 11 | Codex が予算表を更新せずに最適化を入れる | 予算コメントと実装が乖離し、次の事故で原因を追えない | 各 issue の受入条件に「§4.5 / §4.4 の予算表を実測値で更新」を明記。hQHiwater の実測を PR 本文に貼る | 低 |
11. 未決事項
| # | 論点 | 現状 | 判断時期 |
|---|---|---|---|
| A1 | NR32 4 段量子化で BGM の表現力が足りるか | 4 段で実装し A/B 試聴で判断。足りなければ振幅スケール wave 4 種を追加 | 子 issue 6 |
| A2 | hardware.inc v5.3.0 の定数名(LCDC_BLOCK01 等)が本書の記述と一致するか | 解消済み(v5.3.0 実測)。vendored ファイルの定数名を grep で突き合わせ、RGBDS 1.0.3 でビルド確認済み。旧 LCDCF_* 系とは共存しない | 2026-09-02 |
| A3 | ART シーンのパレットフェードを何段にするか | 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 |