Skip to content

システム構成・エンジン設計 ​

実装の正典は src/engine.s(ゲームロジック)+ src/sound.s(BGM ドライバ)。本書はその構造の解説です(main 2026-09-02 時点、build/engine.nes = 131,088 バイト)。

ROM / ハードウェア構成 ​

項目内容
マッパーmapper 2(UNROM)。バンク 0〜6 = データ、固定バンク($C000-$FFFF)= コード・UI フォント・サウンドドライバ・DPCM
PRG-ROM128KB(8 × 16KB、全バンク出力・$FF パディング)
CHRCHR-ROM なし。CHR-RAM 8KB にリセット時転送(起動エントリで内容を差し替え)
ミラーリング垂直(固定 1 画面のため実質未使用)
画面固定 1 画面(スクロールなし)
リンカ設定cfg/nes.cfg(NROM テスト ROM は cfg/nrom.cfg)
iNES ヘッダ4E 45 53 1A 08 00 21 00

PRG バンク割り当て ​

バンク内容
BANK0全 5 曲の BGM データ(bgm_data.inc)+ 譜面・拍ターゲット表・ポインタ表(charts_data.inc)+ タイトル画面一式(#86: シーンコード update_title 系・メニュー行・一枚絵 CHR/NAM/ATR/PAL・呼吸パレット表 = title_picture.inc、約 5.5KB)。タイトル BGM(曲 0)のバンクと同居させることで、BGM 再生中も NMI の snd_bank 復帰でこのバンクが mapped のまま保たれる(.assert BGM_SONG0_BANK = TITLE_CODE_BANK)
BANK1観客タイル列テーブル(17 状態 × 4 フレーム × 168B = 11,424B)
BANK2講師 CHR 4,096B + コーチストリーム雛形 + AI CHR 3,328B + ノーツ/マーカー/紙吹雪 CHR + AI ゲームオーバー CHR
BANK3production は空。DEBUG_MENU ビルドではデバッグ状態機械
BANK4CLEAR 結果描画・ランク計算・進行更新・紙吹雪・S ランク演出 + シーンコード(PROLOGUE / GAME OVER メニュー / CLEAR の START 処理。PROLOGUE_CODE_BANK、DEBUG ビルドでは BANK3)
BANK5プロローグフォント CHR
BANK6エンディングフォント CHR
PRGFIXエンジン本体(CODE 約 6.7KB)+ RODATA + DPCM サンプル(0x940)+ ベクタ。空きは約 630B(DEBUG 約 570B、#86 でタイトルを BANK0 へ移設して確保)

UNROM バス競合

バンク切替は ROM 上に 0〜7 が並ぶ snd_banktable への同値書き込み(lda table,x / sta table,x)で行う。固定バンクのコードが読むデータを切替バンクに置かないこと(song_bit_masks を BANK4 に置いて曲選択が無限ループした実績あり)。

エンジン全体構造 ​

リセット
  ├─ RAM クリア($0C-$11 の進行レコードは保護)→ 進行レコード検証(magic $C3/$3C + 範囲チェック)
  ├─ progress_entry_request に応じて CHR-RAM 転送
  │    (UI フォント / ステージ BG / 会話かなフォント or プロローグ・エンディングフォント / スプライト。
  │      TITLE・PROLOGUE は $1000 に一枚絵、スプライトシートは載せない)
  ├─ パレット設定(BG 4本 + スプライト 4本)
  ├─ sound_init → エントリ別の初期 BGM(タイトル=曲0 / COUNTIN=本番開始 / ENDING=曲4)
  └─ メインループ(フロー状態機械)
       ├─ nmi_flag 待ち(1フレーム同期)・パッド読み取り(pad_cur / pad_prev / pad_new)
       ├─ START+A+B → タイトルへソフトリセット(タイトル以外)
       ├─ flow_state ごとの更新(下表)
       ├─ ノーツレーン描画・キャラアニメ・コーチ CHR ストリーミングのパッチ
       ├─ 4 拍ごとの集計 → 観客増減 → CLEAR / ゲームオーバー判定
       └─ 描画要求を ppu_buf に積む(高負荷処理は *_pending で次フレームへ分離)

NMI(vblank)
  ├─ OAM DMA
  ├─ ppu_buf フラッシュ
  ├─ コーチ CHR 半フレーム転送(RAM 上の展開コードを実行、64B/vblank)
  ├─ sound_tick(ポーズ中は停止)
  └─ frame_count++ / nmi_flag セット / バンク復帰

ゲームフロー状態機械(flow_state) ​

値状態画面進行遷移先
9FLOW_TITLEタイトル一枚絵(256×160、タイル行 0〜19、#86)+ 行 20 黒 + メニュー 4 行(行 22 START / 24 CONTINUE / 26 PROLOGUE / 28 難易度行、col 0 の - がカーソル)。全曲クリア済み(mask=%11111)なら CONTINUE 行は STAGE SELECT 表記(#87)。ロゴとポータルのパレットが 12f × 8 ステップで「呼吸」(下記)。アイ・コーチ・観客は出ない上下・A、←→/SELECT で NORMAL⇄EASYSTART→8 / CONTINUE→6 / STAGE SELECT→ENTRY_SONG_SELECT で reset → 11 / 10 秒無入力→10(アトラクト)
10FLOW_PROLOGUEプロローグ: 全幅の絵(256×160、タイル行 0〜19)+ 3 行テキスト(行 22/24/26)× 4 ページ240f で自動送り、A で次ページ、START でタイトル各ページは progress_prologue_page を永続化して jmp reset で切替。4 ページ後にタイトルへ
8FLOW_ENTRANCEコーチが左端から歩いて入場自動x=88 到達で 0
0 / 1FLOW_DIALOG_1/2会話ページ 0 / 1(24 タイル × 4 行、8f/行)A1 → 4
4FLOW_CLEAR_TO_TUTORIAL会話行を HUD へ戻す(2 行/frame)自動2
2FLOW_TUTORIAL6 ノーツ練習(BGM 停止・メトロノーム)方向/A/B、SELECT でスキップ譜面 $FF or SELECT → 3
3FLOW_DIALOG_3会話ページ 2A5
5FLOW_CLEAR_TO_PERFORMANCE同上自動start_performance → 6
6FLOW_PERFORMANCE本番(LETS DANCE バナー 180f → カウントイン 96f → BGM 開始)方向/A/B、START=ポーズ、SELECT=曲送り譜面 $FF → 7 / 観客 0 → ゲームオーバー(下記)
7FLOW_CLEARRANK: X + プロンプト行STARTソフトリセット経由で 6(次曲 or リトライ)/ 初回 ALL CLEAR は 12 / 選曲プレイのクリア(RESULT_SELECT)は 11
11FLOW_SONG_SELECT曲選択(全曲クリア後のタイトル STAGE SELECT、または選曲プレイのクリア後に ENTRY_SONG_SELECT で直接)上下(プレビュー再生)・A、B でタイトルへ(#93)6 / B→9(reset 経由)
12FLOW_ENDING曲 4 + エピローグ 12 ページ × 3 行A / 300f 自動最終ページで A or START → 9

起動エントリ progress_entry_request: 0=TITLE / 1=OPENING(入場) / 2=COUNTIN(本番直行) / 3=PROLOGUE_MANUAL / 4=PROLOGUE_AUTO / 5=ENDING / 6=SONG_SELECT(#87)。進行レコードの検証は ENTRY_REQUEST_COUNT(7) 未満を正とする。画面をまたぐ遷移は jmp reset で行い、reset がこの値を消費して CHR とサウンドを初期化する。

ゲームオーバー(game_state=1、#87): flow_state は 6 のまま。発火フレームは文字を積まず、空席再描画(6 フレーム、1 行/フレーム)が終わってから切替バンクの update_gameover_menu が row 7 GAME OVER / row 9 CONTINUE / row 11 TITLE を 1 フレーム 1 行で描く(24 タイル全幅行で JUDGE ラベル等を上書き、col 0 の - がカーソル)。上下で選択、A または START(いずれも pad_new)で決定: CONTINUE=ENTRY_COUNTIN(同じ progress_current_song の本番)/ TITLE=ENTRY_TITLE(進行レコードは保持されるのでタイトルの CONTINUE も同じ曲)。main loop は BGM 停止中の snd_bank にシーンコードバンクを入れて呼び出す(PROLOGUE と同方式)。カーソルと行ステージングはタイトル用の title_selected / scene_draw_row を流用(ZP 追加なし)。

進行システム(曲送り) ​

  • 進行レコード(ZP $0C-$11): magic 2B / progress_current_song / progress_cleared_mask / progress_entry_request / progress_easy_mode。reset の RAM クリアから除外され、ソフトリセットをまたいで保持(電源断で消える)
  • CLEAR 時 ランク A 以上で現在曲のビットを立て、次の未クリア曲へ前進。A 未満は同じ曲でリトライ
  • 初期 mask は %10000(譜面なしの曲 4 をクリア済み扱い)。%11111 で ALL CLEAR → エンディング
  • エンディングは初回だけ(#87): update_clear_progress は OR する前の mask が既に %11111 なら RESULT_SELECT を返し、CLEAR 画面(STAGE SELECT - START)の START は ENTRY_SONG_SELECT で選曲画面へ戻る。CLEAR の START 分岐は切替バンクの clear_result_entries(RETRY/NEXT→COUNTIN、ALL_CLEAR→ENDING、SELECT→SONG_SELECT)の表引き
  • EASY モード: 偶数 index の拍(各小節 1・3 拍目)を強制 REST 化($FF 終端は対象外、チュートリアルには効かない)

隠しコマンド ​

場所入力効果
タイトル← → ← → ↑ ↓ B B全曲クリア扱い(CONTINUE 行が STAGE SELECT に変わり曲選択に入れる)。白フラッシュ + 歓声(update_cheer / update_audience_flash はタイトル経路から明示的に呼ぶ #86)
本番ポーズ中A+B+SELECT 同時押しGOOD/MISS を 0 にして即 CLEAR = RANK S
タイトル以外どこでもSTART+A+B 同時押しタイトルへソフトリセット(直後の 1 フレームはパッドを捨てて押しっぱなし誤検出を防止 #69)

PPU アクセス規約(重要) ​

初期化後、メインスレッドは $2007 に直接触らない。 すべての PPU 書き込みは ppu_buf($0300、96 バイト、[len, addr_hi, addr_lo, data...]* 0)に積み、NMI がフラッシュする。

  • 最悪ケース試算は engine.s 冒頭コメントに記載(ゲームプレイ 69B / ポーズ解除 82B / CLEAR 67B / ゲームオーバー 32B / タイトル 51B = 行 28 + 呼吸パレット 18 + 隠しコマンドの backdrop 4 + 終端 1)。ppu_buf を増やす変更は試算コメントも更新する
  • 1 フレームに収まらない処理は clear_pending / restart_pending / audience_queue_pending で次フレームへ分離(#68 の崩壊対策)。queue_audience 自身にも空き容量ガードあり
  • テキスト行は 1 フレーム 1〜2 行に分割して積む

コアループ: ノーツレーン ​

お手本ガール(コーチ)とプレイヤーが同じ目標拍で一緒に踊る。

定数値意味
NOTE_APPROACH_FRAMES96ノーツは目標拍の 96f 前に出現 = カウントイン長。song_frame が 96 に達した瞬間に BGM 開始
NOTE_SPEED2 px/f右から左へ(96f × 2px = レーン幅 192px)
MAX_LANE_NOTES6レーン上の未判定ノーツ最大数(+ 判定マーカー 1 枚 = 同一 Y に 7 枚、8 枚/スキャンライン制限内)
FRAMES_PER_BEAT32チュートリアルとアイドルアニメ位相にのみ使用。本番は曲別テーブル
ANNOUNCE_FRAMES180LETS DANCE バナー
  • 譜面は 1 拍 1 バイト(0=REST / 1=L / 2=R / 3=U / 4=D / 5=A / 6=B)、$FF 終端。拍数は len(order)*16 固定で、未作成部分は休符パディング
  • 拍ターゲット target[N] = 96 + ceil(N × 4 × speed8.8 / 256) を tools/export_charts_inc.py がサウンドドライバと同じ丸めで事前計算(16bit)。行カウンタは共有しないが結果として拍とノーツが一致する
  • 曲頭・曲末の休符パディングは scan_silent を立てて集計・描画なしで一気にスキャン(これがないと ppu_buf が溢れる #35/#68)
  • OAM: アイ $0200-$021F / コーチ $0220-$023F / レーン $0240-$025B(CLEAR 時は紙吹雪 8 枚が同じ枠を使用)

楽曲一覧 ​

#曲BPMf/拍拍数ノーツ長さ役割
0サニーステップ12030.00644332.0s新規スタート曲 / タイトル BGM
1ネオン・ミッドナイト12828.1214410267.5s
2グルーヴ・サーキット11631.051289166.2s
3クリムゾン・オーバードライブ15223.6917612869.5s
4トワイライト・スターズ9637.52112070.0sエンディング専用(譜面なし。曲選択・本番曲送りではスキップ)

CHARTED_SONGS_MASK = %00001111。譜面は assets/charts/charts.json(譜面メーカー #45 で編集)→ charts_data.inc。

判定システム ​

チューニングは engine.s 冒頭の定数で完結する。

定数値内容
JUDGE_PERFECT4目標拍 ±4f → PERFECT +100
JUDGE_GOOD8目標拍 ±8f → GOOD +50
AUD_INIT / AUD_MAX8 / 16観客の初期値・上限(0 でゲームオーバー)
AUD_LV4_MIN13Lv4 ハイプ(A/B の PERFECT で歓声 12f + 白フラッシュ 2f)
  • 押した振りは判定に関係なく即座に再生(タイミング外・誤ボタンでも踊る)
  • 誤ボタンは MISS でノーツ消化、目標 +9f 経過で無入力 MISS
  • 窓外の空振り・譜面終了後の入力は MISS 表示のみでノーツを消化せず、集計・ランクにも入らない(#62)
  • フレーズ集計は判定カーソルが 4 進むごと(休符も数える): 全 PERFECT → 観客 +2 / MISS ≤1 → +1 / MISS ≥2 → −2。チュートリアル中は増減・スコア加算なし
  • 観客表示は 17 状態 × 4 アニメフレーム・3 段 84 席。テンション Lv1=1-4 / Lv2=5-8 / Lv3=9-12 / Lv4=13-16。1 フレーム 1 行(28 タイル)ずつ描画

ランク(実判定数比) ​

ランク条件
SMISS 0 かつ GOOD 0(全 PERFECT)
AMISS 0(GOOD あり)→ 次曲へ進行
Bhits ≥ 60%
Chits ≥ 40%
Dそれ以外

hits = perfect + good、total = perfect + good + miss(判定済みノーツのみ)。S ランクは紙吹雪 8 枚(8bit LFSR で揺らぎ)+ pulse1 の笛スライド + 両キャラのジャンプループ。

HUD レイアウト ​

項目ネームテーブル位置
SCORE: + 6 桁$2064 / $206Arow 3 col 4 / 10
AUD: + 2 桁$2075 / $2079row 3 col 21 / 25
MODE: TUTORIAL / DANCE$20A4 / $20B4row 5
RANK: X$20ECrow 7 col 12
JUDGE: + 7 文字$2124 / $212Brow 9
CLEAR プロンプト$2165row 11 col 5
GAME OVER メニュー(GAME OVER / CONTINUE / TITLE)$20E4 / $2124 / $2164row 7 / 9 / 11 col 4(24 タイル全幅)
タイトルメニュー(START / CONTINUE→STAGE SELECT / PROLOGUE / MODE: 行)$22C4 / $2304 / $2344 / $2384row 22 / 24 / 26 / 28 col 4(絵の下、行 29 はオーバースキャン回避)
LETS DANCE / PAUSE バナー$216Brow 11 col 11

キャラアニメーション ​

  • 全振り共通: 4 コマ × ANIM_STEP_LEN(5) = 20 フレーム固定・接地固定(ジャンプのみ pose_dy で Y 浮上、ユーザー確定要件)。入力優先順位は L → R → U → D → A → B
  • アイ(26 フレーム + ゲームオーバー 2 フレーム)とコーチ(32 フレーム)は独立したアニメ状態を持ち、汎用メタスプライト描画で共通描画
  • コーチ CHR ストリーミング: 講師 CHR(4,096B、32 フレーム × 128B)を BANK2 から CHR-RAM の $1E00(タイル $E0-$E7)/ $1E80($E8-$EF)へダブルバッファ転送。RAM 上に展開した lda abs / sta PPUDATA × 64 組(385B)を NMI が実行し、1 vblank 64B × 2 回で 1 フレーム分。位相 0〜3 で管理し、完了時に coach_tile_base と coach_visible_pose を同時に publish して絵と座標のズレを防ぐ
  • ゲームオーバー中は 32 フレームごとに専用ポーズを交互(アイ 26/27、コーチ 30/31)

サウンド(src/sound.s) ​

Web トラッカー(tools/nes_tracker_template.html)の再生セマンティクスを 1:1 移植した 5ch ドライバ。

項目内容
チャンネルpulse1 / pulse2 / triangle / noise / DPCM(kick / snare / tom の 3 サンプル、固定バンク 64B アライン)
テンポ8.8 固定小数タイマ。1 パターン 64 行、4 行/拍 → 16 拍/パターン。オーダー末尾でループ
曲データtools/export_bgm_inc.py が assets/bgm/*.json から生成 → BANK0。ROM 収録は DPCM 版 5 曲(JSON は 15 曲)
APIsound_init / sound_play_song(A=曲番号) / sound_stop / snd_silence / sound_tick(NMI、PPU 処理後)
同期拍ターゲット表と同一の丸め。カウントイン 96f ちょうどで sound_play_song(tests/chart_sync_test.lua で検証)
ポーズpause_flag で sound_tick を止め、行位置を凍結

効果音 ​

SEch条件
メトロノーム(880Hz)pulse1譜面の各拍。BGM 再生中は無効(= チュートリアル専用)
セリフ声(周期 170/200 交互)pulse1会話 1 行につき 2f × 2 バースト
歓声(ノイズ周期 3-6 揺らし)noiseLv4 の A/B PERFECT、12f。sound_tick 後に上書き
S ランク笛(timer $040→$12C スライド)pulse1CLEAR 画面、60f 周期
曲選択プレビューBGMカーソル移動ごとに sound_play_song

判定音(PERFECT/GOOD/MISS)と決定音は未実装。

一枚絵の表示(プロローグ #79 / タイトル #86) ​

  • 絵 = パターンテーブル 1($1000)、文字 = テーブル 0。行 19 に置いた sprite 0(絵と同じタイル・BG 優先)の hit(scanline 152)から約 11 走査線待って PPUCTRL bit4 を 0 に切替(固定バンクの picture_split_wait、両シーン共用)。絵のタイル 0 は黒固定で行 20 はどちらのテーブルでも黒になるため、切替位置の許容幅が広い。NMI は ppu_ctrl_value(絵の表示中 %10011000)を書き戻す
  • プロローグのデータは tools/export_prologue_pictures.py が assets/prologue/nes/pN.* から生成する src/prologue_pictures.inc(P1/P2 = BANK5、P3/P4 = BANK6、記述子表は固定バンク)。文字色はページのパレットで最も明るい色を選び、フォント転送時にその色番号のプレーンへ複製
  • どちらのシーンも @actors(アイ・コーチ描画、コーチ CHR ストリーミング)を通らない。プロローグのシーン処理は BANK4(DEBUG は BANK3)に常駐

タイトルの絵と呼吸アニメ(#86) ​

  • データは tools/export_title_picture.py が assets/title/nes/title.*(make title-nes の出力)から生成する src/title_picture.inc(BANK0)。reset の ENTRY_TITLE(production のみ)で title_picture フラグを立て、BANK0 の title_load_picture が CHR $1000 / BG パレット / NAM 行 0〜19 + 黒行 / ATR / sprite 0 を構築する。DEBUG ビルドはタイトル画面を持たない(debug_dispatch が全フレームを奪う)ので、この経路を通らずスプライトシートを載せたまま
  • パレット: P0 ロゴ 0f,27,15,38 / P1 顔・手 0f,0c,36,21 / P2 服 0f,0c,30,21 / P3 ポータル 0f,21,25,35。メニュー文字は color 1 が最も明るい P0(属性 $00)
  • 呼吸 = パレット書換のみ(CHR/NAM 不変)。title_breathe_palettes(16B × 8 ステップ、エクスポータが生成)を 12f ごとに 1 ステップ進め、[15, $3F, $01, 15B] を ppu_buf に積む($3F00 は書かないので隠しコマンドの白フラッシュと衝突しない)。ステップの輝度行オフセットは −1/0/0/+1/+1/0/0/−1、行 3 を超える色は白 $30。呼吸するのは P0 の 3 色・P3 の 3 色・P1/P2 が共有する $21 で、肌 $36 と服 $30 は固定
  • STAGE SELECT へは reset(ENTRY_SONG_SELECT)で入る: 絵が $1000 を占有しておりステージ BG・スプライトが無いため、その場で flow_state を切り替えられない

主要 RAM(zeropage・抜粋) ​

addr変数役割
$0Bflow_stateフロー状態機械
$0C-$11progress_*進行レコード(上記)
$27/$28song_frame_lo/hi16bit ノーツレーンクロック
$29-$2Cnext_note_index / next_note_target_* / chart_finished判定カーソル
$2D-$2Fcoach_chart_pos / coach_target_*コーチ専用譜面カーソル(96f 先行)
$30-$35perfect_cnt / miss_cnt / target_cnt / total_*フレーズ集計・累計
$36 / $39clear_rank / audienceランク / 観客 0〜16
$41-$47cptr / scan_silent / restart_pending / tptrl / tptrh現在曲の譜面・ターゲット表ポインタ
$49-$4Bpose / coach_pose / pose_timerアニメ状態
$65-$67pause_flag / clear_pending / audience_queue_pendingフレーム分離フラグ
$78-$7Atitle_picture / title_breathe_step / title_breathe_timerタイトルの絵フラグ・呼吸ステップ(サウンド ZP の後ろに追加 #86。DEBUG は $82-$84)
$05E7-snd_*サウンドドライバ状態(snd_playing $05E7 / snd_song $05E8 / snd_ordpos $05EF / snd_row $05F0)

ZP 変数を追加・削除したら Lua テストのアドレス定数を build/engine.dbg から引き直す(実アドレスは dbg が正)。

デバッグ ROM(make debug) ​

ca65 -D DEBUG_MENU=1。BANK3 に 9 項目のメニュー: DANCE 6 MOVES LOOP / JUDGE P G MISS / AUDIENCE LV1-4 / LV4 FLASH AND CHEER / DIALOG 3 PAGES / OPENING ENTRANCE / BGM ALL SONGS SELECT / CLEAR SCREEN(常に RANK S)/ GAME OVER SCREEN。B または START でメニューへ戻る(warm-reset 署名 $A5/$5A で選択を保持)。

ビルド・実行 ​

bash
make            # build/engine.nes(131,088B)
make debug      # build/engine-debug.nes(DEBUG_MENU)
make nromtest   # build/nrom_test.nes(NROM 32KB+8KB、ドナー基板のスモークテスト)
make flashbin   # build/flash/prg_512k.bin / chr_512k.bin(SST39SF040 書き込み像)
make run        # Mesen2 で起動(要 MESEN 環境変数)
python3 -m unittest discover -s tools -p 'test_*.py'   # 73 tests

詳細はビルド手順。実機化は実機 ROM 化ガイド→フラッシュライター→カート改造。