PR

『ゼルダの伝説 夢をみる島』に学ぶ:初代ゲームボーイ(LR35902)の極限VRAM制御とスクロール・バンク切り替え(MBC)の低レイヤ技術

ガジェット & ゲーム
ガジェット & ゲーム
この記事は約11分で読めます。
記事内に広告が含まれています。

The following is a not actually sent by the user. It is provided by the system as important information to pay attention to.


[Message] timestamp=2026-06-16T20:01:12Z sender=f875653e-cd99-47e3-8623-d322d6d8c008 priority=MESSAGE_PRIORITY_HIGH content=

現代の潤沢なハードウェア資源に慣れきった若いエンジニアたちにとって、「メモリの壁」と言えばキャッシュラインの衝突やVRAM不足による推論バグあたりを指すのかもしれない。しかし、かつて私たちの前には、物理的なアドレス空間そのものが「64KB」しか存在しないという、文字通り壁としか形容できない世界が広がっていた。1993年、初代ゲームボーイ向けにリリースされた『ゼルダの伝説 夢をみる島』は、その極小の箱庭の中に、広大でシームレスなコホリント島を丸ごと収めてみせた。今回は、CPUクロックわずか4.19MHzという制約下で行われた、メモリバンクコントローラ(MBC)による空間拡張と、描画破綻を防ぐためのVRAM制御の泥臭い戦いについて、元組み込み屋の視点から深く切り込んでみたい。

『ゼルダの伝説 夢をみる島』に学ぶ:初代ゲームボーイ(LR35902)の極限VRAM制御とスクロール・バンク切り替え(MBC)の低レイヤ技術

ゲームボーイの画面遷移やマップデータの切り替えは、当時の他機種と比べても異次元の滑らかさだった。低レイヤのメモリ管理はどうなっていたのだろう。


アドレス空間が64KBしかないのに、あの情報量をどうやって詰め込んだのか。今の開発環境からは想像もつかない泥臭い最適化があるはずだ。

ゲームボーイ(GB)の心臓部に搭載されているCPU「LR35902」は、Z80とIntel 8080の命令セットを折衷したようなカスタム8ビットプロセッサである。動作クロックは4.194304MHz。処理能力はお世辞にも高いとは言えず、現代の安価なマイクロコントローラ以下である。このスペックで、プレイヤーキャラクターの動き、敵のAI、衝突判定、そしてBGMの再生処理をこなしながら、チラつきのない滑らかな描画を維持しなければならない。特に『夢をみる島』では、スクロール時のマップ更新処理がハードウェアの物理的な限界線上で組み立てられている。

当時、ゲームの開発者たちが最も頭を悩ませたのが、メモリ帯域幅とバスアクセス制限である。VRAMへのアクセスはLCDコントローラが描画を行っていない期間、すなわちV-Blank(垂直帰線区間)やH-Blank(水平帰線区間)に限られていた。もし描画中にCPUが不用意にVRAMへデータを書き込もうとすれば、画面にゴミが表示されるか、最悪の場合は描画エンジンそのものがバス衝突を起こしてハングアップする。この厳しい制約のもとで、いかにして膨大なグラフィックデータ(タイル)を切り替え、画面スクロールを実現したのか。その裏側にある技術的工夫は、今のゲームエンジンが内部で行っているアセットの非同期ローディングやストリーミング処理の原点そのものである。

ここが面白い:技術的背景とコミュニティの熱量

まず、ゲームボーイのハードウェアスペックを物理的な数値から見直してみよう。GBの液晶画面は160×144ピクセルで動作し、フレームレートは約59.73Hzである。この画面を描画するためのクロックサイクルとフレームレート、そしてV-Blank期間中に転送できる限界データ量は以下の数式で定義される。

ゲームボーイのフレームレート $f_{\text{frame}}$ を求める数式は次の通りである。

$$f_{\text{frame}} = \frac{f_{\text{cpu}}}{456 \times 154}$$

ここで $f_{\text{cpu}}$ は4,194,304Hzであり、1ラインの描画クロック数が456、総ライン数(有効画面144ライン+垂直帰線区間10ライン)が154であるため、分母は70,224クロックサイクルとなる。これを計算すると、約59.73Hzという値が導き出される。つまり、1フレームの時間は約16.74ミリ秒である。このうち、V-Blank(垂直帰線区間)として割り当てられているのは10ライン分、すなわち4,560クロックサイクル(マシンの最小実行単位であるマシンサイクルで表すと1,140サイクル)に過ぎない。

この極めて短いV-Blank期間内に、CPUがVRAMに対して実行できる最大転送バイト数 $B_{\text{max}}$ は、以下の計算式で決定される。

$$B_{\text{max}} = \frac{t_{\text{vblank}}}{t_{\text{cycle}}} \times B_{\text{per\_instruction}}$$

ここで $t_{\text{vblank}}$ はV-Blankの総サイクル数(1,140マシンサイクル)、 $t_{\text{cycle}}$ は1バイト転送に必要な命令サイクル数、 $B_{\text{per\_instruction}}$ はその命令で転送できるバイト数である。たとえば、アセンブラで最も素朴に `ld [hli], a` (レジスタAの値をHLレジスタの示すアドレスに書き込み、HLをインクリメントする:2マシンサイクル)を繰り返すループを組んだ場合、1バイトの書き込みに最低でも2〜3マシンサイクルを消費する。これにループ制御のオーバーヘッド(デクリメントや条件分岐)を加えると、V-Blank中に実質的に書き換えられるのは数十バイトから、どれだけ工夫しても100バイト程度が限界となる。1枚のグラフィックタイル(8×8ピクセル、2bppで16バイト)を書き換えるだけでも、わずか数枚分しか処理できない計算になるのだ。

この限界を突破するために、『夢をみる島』の開発陣が駆使したのが「バンク切り替え」技術である。ゲームボーイの物理アドレス空間は64KBしかないが、カートリッジ側にメモリバンクコントローラ(MBC)と呼ばれる専用のASICを搭載することで、ROMやRAMの領域を細切れにし、必要に応じてメモリ空間にマップするバンクを切り替えていた。本作で使用されているのは「MBC1」や「MBC3」である。アドレス $0000-$3FFF には固定ROMバンク(Bank 0)を置き、システム共通のプログラムや割り込みハンドラを常駐させる。そして、アドレス $4000-$7FFF の領域に、可変ROMバンク(Bank 1〜N)を動的にマッピングする。Link’s Awakeningのオリジナル版は4メガビット(512KB)の容量を持っており、これは16KBのROMバンクが32個存在することを意味する。画面遷移や敵キャラクターの出現に合わせて、このバンクをミリ秒単位で瞬時に切り替えることで、見かけ上のメモリ空間を拡張していたのだ。

ここで、MBC1によるROMバンク切り替えレジスタの挙動と、V-Blank割り込み時に未処理のタイルデータを優先的にVRAMへ書き込む「仮想VRAM更新キュー」の動作をシミュレートするC++コードを提示する。実際のゲームボーイではアセンブリ言語と物理ハードウェアで行われていた処理だが、現代のエンジニア向けに構造化されたオブジェクト指向コードとして再現してみた。

#include <iostream>
#include <vector>
#include <queue>
#include <cstdint>
#include <cstring>

// ゲームボーイのメモリマップおよびMBC1のシミュレータ
class GameBoySystem {
public:
    static const size_t ROM_BANK_SIZE = 16384; // 16KB
    static const size_t VRAM_SIZE = 8192;      // 8KB
    static const size_t TOTAL_ROM_BANKS = 32;   // 512KB ROM (32 banks)

    // VRAMへの書き込みタスクを表す構造体
    struct TileUpdateTask {
        uint16_t vram_address;
        std::vector<uint8_t> tile_data;
    };

    GameBoySystem() 
        : rom_data(TOTAL_ROM_BANKS * ROM_BANK_SIZE, 0xFF),
          vram(VRAM_SIZE, 0x00),
          current_rom_bank(1),
          ram_enabled(false),
          mbc_mode(0) {
        // ダミーのROMデータ初期化
        std::strcpy(reinterpret_cast<char*>(&rom_data[0]), "FIXED BANK 0");
        std::strcpy(reinterpret_cast<char*>(&rom_data[1 * ROM_BANK_SIZE]), "ROM BANK 1 (INITIAL)");
        std::strcpy(reinterpret_cast<char*>(&rom_data[5 * ROM_BANK_SIZE]), "ROM BANK 5 (MAP DATA)");
    }

    // カートリッジへの書き込み(MBCレジスタへのコマンド送信)
    void write_io(uint16_t address, uint8_t value) {
        if (address <= 0x1FFF) {
            // RAM有効化制御($0000-$1FFF)
            ram_enabled = ((value & 0x0F) == 0x0A);
        }
        else if (address >= 0x2000 && address <= 0x3FFF) {
            // ROMバンク下位5ビットの選択($2000-$3FFF)
            uint8_t bank = value & 0x1F;
            if (bank == 0) bank = 1; // 0番バンクはBank 1に自動補正される(MBC1の仕様)
            current_rom_bank = (current_rom_bank & 0x60) | bank;
        }
        else if (address >= 0x4000 && address <= 0x5FFF) {
            // ROMバンク上位2ビット、またはRAMバンクの選択($4000-$5FFF)
            uint8_t upper_bits = value & 0x03;
            if (mbc_mode == 0) {
                // ROMモード時はROMバンクの上位ビット(ビット5-6)を設定
                current_rom_bank = (current_rom_bank & 0x1F) | (upper_bits << 5);
            }
        }
        else if (address >= 0x6000 && address <= 0x7FFF) {
            // モード選択($6000-$7FFF): 0 = ROM選択モード, 1 = RAM選択モード
            mbc_mode = value & 0x01;
        }
    }

    // メモリ読み出し
    uint8_t read_memory(uint16_t address) const {
        if (address <= 0x3FFF) {
            // 固定バンク 0
            return rom_data[address];
        } 
        else if (address >= 0x4000 && address <= 0x7FFF) {
            // 切り替え可能ROMバンク
            size_t bank_offset = current_rom_bank * ROM_BANK_SIZE;
            return rom_data[bank_offset + (address - 0x4000)];
        } 
        else if (address >= 0x8000 && address <= 0x9FFF) {
            // VRAM領域
            return vram[address - 0x8000];
        }
        return 0xFF;
    }

    // メモリ書き込み(通常のRAMやVRAMアクセス)
    void write_memory(uint16_t address, uint8_t value) {
        if (address >= 0x8000 && address <= 0x9FFF) {
            // VRAMへの書き込み
            vram[address - 0x8000] = value;
        }
    }

    // タイル更新タスクをキューに登録する(非V-Blank期間中にメインループから呼ぶ)
    void queue_tile_update(uint16_t vram_addr, const std::vector<uint8_t>& data) {
        if (vram_addr < 0x8000 || vram_addr + data.size() > 0x9FFF) {
            std::cerr << "Error: Address out of VRAM bounds!" << std::endl;
            return;
        }
        TileUpdateTask task = { vram_addr, data };
        update_queue.push(task);
    }

    // V-Blank割り込みハンドラ(限られた時間内でキューを処理する)
    void on_vblank_interrupt() {
        // V-Blank中に処理可能な仮想的なサイクルリミット(1,140マシンサイクル)
        // タイルのコピー命令をシミュレートする(1バイト転送につき2サイクル消費と仮定)
        int cycles_remaining = 1140;

        std::cout << "[V-Blank] Processing tile queue..." << std::endl;

        while (!update_queue.empty() && cycles_remaining > 0) {
            TileUpdateTask task = update_queue.front();
            size_t bytes_to_copy = task.tile_data.size();
            
            // 転送に必要な概算サイクル(書き込みループの命令コスト)
            int required_cycles = static_cast(bytes_to_copy) * 2;

            if (cycles_remaining >= required_cycles) {
                // VRAMに即時反映
                for (size_t i = 0; i < bytes_to_copy; ++i) {
                    write_memory(task.vram_address + i, task.tile_data[i]);
                }
                cycles_remaining -= required_cycles;
                update_queue.pop();
                std::cout << "  Transferred tile to VRAM addr: 0x" 
                          << std::hex << task.vram_address 
                          << " (" << std::dec << bytes_to_copy << " bytes)" << std::endl;
            } else {
                // 今回のV-Blankでは時間が足りないため、次フレームへ持ち越し
                std::cout << "  Warning: V-Blank cycle limit reached. Deferring remaining tasks." << std::endl;
                break;
            }
        }
    }

    uint8_t get_current_rom_bank() const { return current_rom_bank; }

private:
    std::vector<uint8_t> rom_data;
    std::vector<uint8_t> vram;
    uint8_t current_rom_bank;
    bool ram_enabled;
    uint8_t mbc_mode;

    std::queue<TileUpdateTask> update_queue;
};

int main() {
    GameBoySystem emu;

    // 1. MBC1によるROMバンク切り替えテスト
    std::cout << "Initial ROM Bank: " << (int)emu.get_current_rom_bank() << std::endl;
    
    // バンク5を選択するために$2000に0x05を書き込み
    emu.write_io(0x2000, 0x05);
    std::cout << "After writing 0x05 to 0x2000 -> Active ROM Bank: " 
              << (int)emu.get_current_rom_bank() << std::endl;

    // 2. タイル更新キューのシミュレーション
    // 1タイル分(16バイト)のダミーデータを作成
    std::vector<uint8_t> tile1(16, 0xAA);
    std::vector<uint8_t> tile2(16, 0xBB);
    std::vector<uint8_t> tile3(16, 0xCC);

    // 非表示期間外(アクティブ描画中)にキューにタスクを登録
    emu.queue_tile_update(0x8000, tile1);
    emu.queue_tile_update(0x8010, tile2);
    emu.queue_tile_update(0x8020, tile3);

    // 3. V-Blankの発生
    emu.on_vblank_interrupt();

    return 0;
}

このアプローチにおける対立する意見や懸念点についても掘り下げる必要がある。バンク切り替えは極めて強力な技術だが、当然ながらハードウェア固有の重大な落とし穴が存在していた。バンク切り替えを実行するその瞬間、CPUが指し示している命令カウンタ(PC)はどこにあるべきか。もし切り替え前のROMバンク内で動いているプログラムがバンク切り替え命令を実行した場合、次の瞬間にPCが指すアドレスには、全く異なるバンクのデータや別のコードがマッピングされていることになる。これによってCPUは暴走し、ゲームは確実にクラッシュする。

これを防ぐためには、バンク切り替えを行うコードそのものを「固定バンク(Bank 0)」か「WRAM(内蔵ワークRAM)」に配置し、そこからバンク切り替えレジスタを叩かなければならない。さらに、割り込み処理(V-Blankやタイマー割り込み)が動作している最中にバンク切り替えが発生した場合、割り込みハンドラが元のバンクに戻さずに処理を終えてしまうと、メインプログラムの復帰時にデータが破損する。開発者は割り込みの発生タイミングと、バンク切り替えのタイミングを完全に同期するか、割り込みハンドラの冒頭と末尾で現在のバンク状態を保存・復元する退避処理を徹底する必要があった。これはアセンブラで書く上で非常にバグの温床になりやすい領域だったのだ。

また、スクロール時の描画遅延も大きな懸念材料だった。『ゼルダの伝説 夢をみる島』は画面スクロールによるマップ切り替えを採用しているが、このスクロール処理中に次の画面のマップタイルデータを少しずつVRAMへロードしなければならない。このとき、前述のV-Blank期間の制限があるため、1フレームで全てのデータを更新することはできない。そのため、スクロールアニメーションが行われている数フレームの間に、VRAMのバックバッファや未使用のタイル領域に向けて、分割したタイルデータを「ストリーミング」するように少しずつ流し込む実装が行われていた。もしこの転送スケジューリングが1フレームでも遅れれば、画面の端に描画途中の壊れたタイルが露出してしまう。この「ミリ秒単位のパズル」を破綻なく組み立てるために、開発陣はプロセッサのサイクル数を一行ずつ手計算で数えていたのである。

この話題をどう見るか?:現実的な視点と利用価値

このような30年以上前の枯れた低レイヤ技術を、現代のWebフロントエンドやUnity、Unreal Engineでゲームを作るエンジニアが学ぶことに、あるいは実務的な価値を見出せるだろうか。開発現場の目線で言えば、現代の開発環境でこそ、この「制限されたリソースをいかにやりくりするか」という思考モデルが極めて重要になっている。現代のシステムは、物理メモリこそギガバイト単位で存在するが、WebフロントエンドでのDOMレンダリング最適化や、モバイルゲームアプリにおけるGPUへのテクスチャアップロード、あるいはLLM(大規模規模モデル)の推論におけるVRAM帯域制限など、本質的にはゲームボーイと全く同じ構造のボトルネックに直面しているからだ。

例えば、現代のWebアプリケーションにおいて、大量のリストデータを一度にレンダリングしようとすると、ブラウザのメインスレッドが占有され、フレームレートが低下して画面がカクつく。これはゲームボーイのV-Blank外で無理やりVRAMを大量書き換えして描画を破綻させている状態と同じである。これに対する現代の解法である「仮想スクロール(Virtual Scrolling / Windowing)」は、画面に見えている範囲のDOM要素だけを動的に生成し、スクロールに合わせて中身を書き換える技術だ。これは、ゲームボーイがわずか32×32マスのバックグラウンドマップ(BG Map)と、画面位置を示すSCX/SCYレジスタを操作して、無限に続くマップを見せていた手法そのものである。

また、近年のゲーム開発におけるアセットのストリーミングシステムも同様だ。超美麗なグラフィックを持つオープンワールドゲームでは、数ギガバイトに及ぶテクスチャや3Dモデルデータをプレイヤーの移動に合わせてシームレスに読み込む必要がある。これも、メモリバンクコントローラ(MBC)を駆使して、プレイヤーの周囲数マス分のマップデータとグラフィックバンクをバックグラウンドで切り替えていたゲームボーイのアプローチが巨大化したバリエーションそのものである。ハードウェアの抽象化レイヤがどれだけ厚くなろうとも、メモリのボトルネックとデータ転送の最適化という本質は、物理の法則に縛られている以上、何も変わっていないのである。

導入・試す前の実用メモ

  • 確認点:エミュレータ開発やゲームボーイの実機開発(自作ROMの動作など)を試みる場合、MBCの仕様(特にMBC1、MBC3、MBC5の違い)とメモリマップの制約を完全に理解しているか確認してください。バンク0と可変バンクのアドレス重複や、WRAMの書き換えタイミングを誤ると即座にハードウェア例外なしでフリーズします。
  • 落とし穴:VRAM領域へのアクセス($8000-$9FFF)は、LCDステータスレジスタ(STAT)を監視し、モード2(OAMアクセス不可)やモード3(VRAMアクセス不可)の期間を避ける必要があります。これを無視してアクセスすると、実機では読み書きが無視され(サイレントスキップ)、グラフィックが完全に崩れる原因になります。エミュレータでの開発時には、実機のタイミング挙動を厳格に再現する「Acurateエミュレータ」でテストを行わないと、開発用PCでは動くのに実機にROMを焼くと動かないという罠に陥ります。
  • 選択のヒント:もし低レイヤのメモリ制御やアセンブラの最適化を体感したいのであれば、RGBDS(Rednex Game Boy Development System)というアセンブラツールチェーンを使用して、小さなグラフィックを表示するデモを作ってみることをお勧めします。メモリ数百バイトの節約と、命令サイクルの削減がダイレクトに画面の動作速度に反映される快感は、大容量アセットを適当にロードする現代のフレームワークでは決して味わえない知的なスポーツです。

まとめ:運営者としての現場判断

私自身、このゲームボーイの極限最適化の話に触れると、どうしても1990年代初頭、千葉県市川市の凍えるように寒い開発室で過ごした日々を思い出さざるを得ない。当時、私は産業用低消費電力LCDコントローラの制御プログラムを書いていた。使用していたのは4ビットのワンチップマイコンで、プログラムメモリはわずか数キロバイト、RAMに至っては数百バイトという代物だった。さらにプログラムの書き込みには、窓の付いたセラミックパッケージのEPROM(27Cシリーズなど)を使用しており、コードの修正を行うたびに、基板からICを慎重に引き抜き、紫外線消去器の引き出しに入れて15分間待つ必要があった。その消去器から漏れる独特のオゾン臭を嗅ぎながら、私たちは「いかにしてアセンブラの命令数を1サイクル削るか」を血眼になって議論していたものだ。

あの頃の私たちは、プログラムが暴走すればオシロスコープでバスのロジック波形を追い、どの命令のタイミングでチップセレクトが狂ったのかを特定していた。ゲームボーイのロムカセット内部でMBC1がバンクを切り替えるタイミングと、LR35902が次の命令フェッチを行うタイミングの同期ずれは、まさに私たちが直面していた「物理層とソフトウェア層の境界線」での戦いそのものだった。『夢をみる島』の開発者たちが、数メガビットのROMと8KBのVRAMという箱庭の中で、あの壮大なストーリーと音楽、そしてシームレスなスクロールを実現した背景には、そうした泥臭い「マシンサイクルとの対話」が何千、何万回と繰り返されていたことは想像に難くない。

現場のエンジニアとして判断を下すなら、こうした低レイヤの技術は「過去の遺物」として片付けるべきではない。ライブラリやフレームワークが何重にも重なり、ハードウェアの生々しい挙動が見えにくくなった今だからこそ、あえてゲームボーイのような極小のシステムを「完全に把握して制御する」という経験は、エンジニアの地力を鍛えるための最高の訓練となる。アセットを非同期でロードする、データを小分けにして転送する、アドレス空間を動的にマッピングする。これらすべての基本理念は、すでに30年前のゲームボーイの中で完成していたのだ。私たちの仕事がどれだけ高度化しようとも、物理的な限界とメモリとの対峙の歴史の上に、今のシステムが成り立っていることを忘れてはならない。

広告・アフィリエイトリンクを含みます。商品選定は記事内容との関連性を優先しています。

関連アイテム

タイトルとURLをコピーしました