[Перевод] Эмулятор Game Boy Color на MicroPython: Pokémon Crystal на конференц-бейдже с RP2350

Закончив эмулятор NES, я перепробовал на нём кучу игр, и все шли нормально. На бейдже я даже прошёл первые несколько подземелий Zelda. И тут меня зацепило: в Zelda поиграть хотелось, но по форме бейдж куда больше похож на Game Boy, чем на NES. А на Game Boy тоже есть и Zelda, и Pokémon. Интересно, насколько это сложно, если про железо Game Boy я толком ничего не знаю? Оказалось, довольно сложно.

Так что я снова взял в напарники Gemini и решил проверить, потянет ли та же плата Badger на RP2350 Game Boy и Game Boy Color. Генератор кода Viper в MicroPython и 8 МБ QSPI PSRAM остались прежними, а вот головоломка со стороны процессора и картриджа оказалась совсем другой.
Опыт написания эмуляторов для чтения не нужен: каждый термин я объясню по ходу дела.

Я думал, что SM83 будет проще. Не тут-то было
В NES стоит Ricoh 2A03, урезанный MOS 6502: три 8-битных регистра (A, X, Y), указатель стека и 56 инструкций. Всё ядро помещалось в локальные переменные одного цикла @micropython.viper над фиксированной картой памяти без переключения банков.
В Game Boy стоит Sharp SM83 (LR35902), гибрид 8080 и Z80: восемь 8-битных регистров (A, F, B, C, D, E, H, L, которые объединяются в пары AF, BC, DE, HL), указатель стека, счётчик команд и 512 инструкций. Это 256 базовых опкодов плюс ещё 256 за префиксом 0xCB, в девять раз больше, чем у 6502.
MOS 6502 (NES) Sharp SM83 (Game Boy Color)
┌───────────────────────────┐ ┌───────────────────────────────────┐
│ Registers: A, X, Y │ │ Registers: A, F, B, C, D, E, H, L │
│ Opcodes: 56 base │ vs │ Opcodes: 512 (256 base + 256 CB)│
│ Banking: Cartridge discrete │ Banking: ROM, VRAM, WRAM, SRAM │
│ Speed: 1.79 MHz flat │ │ Speed: 4.19 MHz / 8.38 MHz GBC│
└───────────────────────────┘ └───────────────────────────────────┘
Процессор сложнее, зато с экраном всё проще:
Показатель | GBC ( | NES ( |
|---|---|---|
Пикселей в кадре | 23 040 | 61 440 |
Строк развёртки на кадр | 154 | 262 |
Тактов процессора на кадр | 17 556 | 29 606 |
Максимум спрайтов | 40 | 64 |
Копирование при переключении банка CHR (байт) | 0 | 8 192 |
Пикселей в 2,67 раза меньше, строк развёртки в 1,7 раза меньше.
Чтобы уложиться в ограничения MicroPython, понадобилось пять приёмов Viper, которых в сборке для NES не было.
ViperTypeError: viper:8004
На NES прежний подход работал. У 6502 три регистра, A, X и Y, поэтому PC, SP, P и пара указателей ещё влезали в четыре быстрые локальные переменные Viper (r0–r3). 56 опкодов сначала разбирались цепочкой if/elif глубиной 55, потом она превратилась в пятиуровневый двоичный поиск, и стоимость диспетчеризации упала вдвое. Один цикл Viper, фиксированная карта памяти, всё компилировалось без ошибок.
SM83 туда не влезает. Восемь регистров (A, F, B, C, D, E, H, L) плюс PC, SP и счётчики тактов в четыре локальные переменные не помещаются: остальные регистры Cortex-M33 заняты под стек, кадр исключений и рантайм. Это и есть viper:8004: регистровый файл не помещается в локальные переменные.
Дешифратор тоже не масштабируется. Дерево, которое хорошо работало на 56 опкодах, для 512 (256 базовых и 256 с префиксом CB) потребовало бы около 500 ветвлений. То, что делало эмулятор NES быстрым, для Game Boy даже не собирается.
Остаются две задачи: где держать восемь регистров, если не в локальных переменных, и как декодировать 512 инструкций без 512 ветвлений.
Восемь регистров не влезают в четыре переменные
Если читать опкод не как одно число, а как три маленьких, 512 инструкций схлопываются в несколько блоков. Но чтобы обращаться к регистрам по индексу, 8-битный регистровый файл пришлось вынести из локальных переменных Viper в плоскую память: восемь байт по адресу 0x14500 внутри одного bytearray (self.mem = bytearray(0x14A00)):
# CPU 8-bit Register File in flat arena (0x14500..0x14507)
# Index: 0=B, 1=C, 2=D, 3=E, 4=H, 5=L, 6=(HL) indirect pointer, 7=A
Индекс 6 особый: в этом битовом поле SM83 он означает косвенное обращение к памяти через (HL), поэтому настоящие регистры лежат по индексам 0, 1, 2, 3, 4, 5, 7.
Разработчики Z80 и SM83 работали на бумаге в восьмеричной системе. Если записать любой байт опкода по основанию 8, он распадается на три восьмеричные цифры (x, y, z), и это как раз битовые поля набора инструкций:
x: int = op >> 6 # Bits 7-6: Opcode category (0..3)
y: int = (op >> 3) & 7 # Bits 5-3: Destination register or ALU operation (0..7)
z: int = op & 7 # Bits 2-0: Source register or condition code (0..7)
У всех 64 инструкций LD r, r' (0x40..0x7F) x == 1, поэтому они укладываются в пять строк Viper:
if x == 1:
# 0x40..0x7F: LD r[y], r[z] or HALT (0x76)
if op == 0x76:
halted = 1
else:
vr1: int = 0
if z == 6:
hl1: int = (int(mem[0x14504]) << 8) | int(mem[0x14505])
vr1 = read_memory(hl1)
else:
vr1 = int(mem[0x14500 + z])
if y == 6:
write_memory((int(mem[0x14504]) << 8) | int(mem[0x14505]), vr1)
else:
mem[0x14500 + y] = vr1
Одна проверка покрывает шестьдесят четыре опкода, а y и z выбирают регистр-приёмник и регистр-источник.
При x == 2 те же битовые поля задают все восемь операций АЛУ над A и r[z] (ADD, ADC, SUB, SBC, AND, XOR, OR, CP): ещё шестьдесят четыре арифметические инструкции в одном блоке.
256 битовых операций с префиксом 0xCB делятся так же: cb_x == 1 — это BIT, 2 — RES, 3 — SET, а 0 — сдвиг, циклический сдвиг или обмен тетрад y над r[z]. Пятьдесят строк битовой арифметики заменили сотни ветвлений.
Четыре строки арифметики указателей вместо ветвлений по адресу
На NES можно было схитрить. ОЗУ, PPU, APU, PRG-ROM: всё лежит там, куда его припаяли, поэтому чтение сводится к проверке адреса, и готово.
На Game Boy так не выйдет. Процессор по-прежнему видит 64 КБ, но картридж постоянно меняет то, что находится за этим окном:
$0000..$3FFF: 16 КБ фиксированного банка ROM 0;$4000..$7FFF: 16 КБ переключаемого банка ROM (до 125/128/512 банков уMBC1/MBC3/MBC5);$A000..$BFFF: 8 КБ внешней SRAM (4 банка);$C000..$CFFF: 4 КБ фиксированного банка рабочей памяти WRAM 0;$D000..$DFFF: 4 КБ переключаемой WRAM (банки 1–7 черезSVBKпо адресу$FF70);$E000..$FDFF: эхо-область, зеркало$C000..$DDFF.
Первая версия проверяла адрес при каждом обращении: если он в этом диапазоне, делаем одно, если в том — другое:
if addr < 0x8000: ...
elif addr < 0xA000: ...
elif addr < 0xC000: ...
elif addr < 0xD000: ...
elif addr < 0xE000: ...
Читается легко и совпадает с тем, как карту памяти рисуют во всех документах. Но Game Boy работает на 4,19 МГц, а GBC в режиме двойной скорости вдвое быстрее, так что эта проверка выполняется миллионы раз в секунду. Мы снова упираемся в ограничения скромного железа. Каждый if — это ветвление, которое Cortex-M33 должен предсказать, а промах останавливает конвейер.
Хитрость в том, что любой адрес Game Boy 16-битный, то есть четыре шестнадцатеричные цифры вроде $D123. В двоичном виде это 1101 0001 0010 0011: левые четыре бита (15–12) — старшая тетрада, правые двенадцать (11–0) — смещение внутри страницы размером 4 КБ.
Страница в 4 КБ — это 4096 = 2¹² байт, поэтому сдвиг вправо на 12 (addr >> 12) отбрасывает младшие двенадцать бит и оставляет только старшую тетраду: от 0 до 15. Шестнадцать значений, шестнадцать страниц, ровно 64 КБ. Маска в обратную сторону (addr & 0xFFF) оставляет младшие двенадцать бит, то есть смещение 0–4095 внутри страницы.
$D123 → 0xD (страница 13) + 0x123 (смещение 291). Старшая тетрада сама говорит, какой банк использовать, никакой if не нужен. Я держу таблицу страниц из 16 записей в self.st (ячейки 32–47 массива array('i', [0]*64)) с заранее вычисленными смещениями внутри self.mem и индексирую её прямо этой тетрадой:
Address Range Page Slot Mapped Arena Region
$8000..$8FFF st[40] ──► 0x0000 (VRAM Bank 0 or 1, low)
$9000..$9FFF st[41] ──► 0x1000 (VRAM Bank 0 or 1, high)
$A000..$AFFF st[42] ──► 0xC000 + (sram_bank << 13)
$B000..$BFFF st[43] ──► 0xD000 + (sram_bank << 13)
$C000..$CFFF st[44] ──► 0x4000 (WRAM Bank 0)
$D000..$DFFF st[45] ──► 0x4000 + (wram_bank << 12)
$E000..$EFFF st[46] ──► 0x4000 (Echo WRAM0)
Переключение банка — одна запись, без копирования:
# Switching GBC WRAM bank (SVBK at $FF70)
wb: int = vw & 7
if wb == 0: wb = 1
st[REG_WRAM_BANK] = wb
st[45] = 0x4000 + (wb << 12) # Slot 45 points to the active 4KB WRAM bank
А чтение — четыре строки без единого ветвления:
# 4KB Page Table lookup in Viper
page_offset: int = st[32 + (addr >> 12)]
val: int = int(mem[page_offset + (addr & 0xFFF)])
Найти в таблице, наложить маску, сложить, загрузить. После этого переключение банков пропало из профилей.
Ленивая загрузка двухмегабайтных картриджей по шине QSPI на 115 МГц
Картриджи NES из нашей коллекции весили от 40 до 256 КБ, их можно было целиком прочитать в ОЗУ при загрузке. Картриджи Game Boy Color намного больше: Link’s Awakening DX занимает 1 МБ (64 банка по 16 КБ), а Pokémon Crystal — 2 МБ (128 банков).
Чтение 2 МБ с QSPI Flash при запуске подвешивает бейдж на несколько секунд. Когда мы разогнали RP2350 через machine.freq(300_000_000), чтобы выиграть запас на кадр, бейдж намертво зависал при переключении банков. Вместе с ним отваливался последовательный порт по USB, и вернуть MonaOS удавалось только передёргиванием питания.
Узким местом оказалась общая шина QMI:
RP2350 Microcontroller
┌──────────────────────┐
│ Cortex-M33 Cores │
│ 520 KB SRAM │
└──────────┬───────────┘
│ QMI Bus (clk_sys / 2)
┌───────────────┴───────────────┐
│ │
XIP_CS0 (Flash) XIP_CS1 (PSRAM)
┌──────────────────────┐ ┌──────────────────────┐
│ 16 MB QSPI Flash │ │ 8 MB QSPI PSRAM │
│ (Firmware & ROMs) │ │ (Heap & Buffers) │
└──────────────────────┘ └──────────────────────┘
Штатная частота RP2350 — 150 МГц, а прошивка бейджа гоняет его на 230 МГц (разгон на 53%). Судя по схеме бейджа и конфигурации платы tufty2350 от Pimoroni, 16 МБ Flash Winbond W25Q128JVPIQ и 8 МБ PSRAM AP Memory APS6404L-3SQR-ZR (PSRAM_CS на GPIO8) делят линии QSPI_SD0..3 и QSPI_SCLK при PICO_FLASH_SPI_CLKDIV = 2 (clk_sys / 2). На 230 МГц QSPI работает на частоте:
Обе микросхемы памяти рассчитаны максимум на 133 МГц (в pimoroni_tufty2350.h указано 266 MHz как «абсолютный максимум, после которого придётся снижать частоту PSRAM»), так что у 115 МГц хороший запас по таймингам. На 300 МГц QSPI выходил на 150 МГц, за пределы паспортных 133 МГц обеих микросхем. Ядро Cortex-M33 это переживало, но пакетное копирование банков по 16 КБ из Flash (XIP_CS0) в PSRAM (XIP_CS1) по общей шине QMI вешало железо.
На бейдже 8 МБ PSRAM отданы под кучу MicroPython (gc.mem_free() показывает 8,189,632 свободных байт), поэтому ни вытеснять банки, ни разгонять шину не пришлось:
оставить штатные 230 МГц, чтобы шина QMI работала на 115 МГц;
один раз выделить в PSRAM область под весь картридж (
self.rom = bytearray(self.num_banks * 16384)) сdirect_rom = Trueи откатываться на кольцевой буфер из 20 ячеек, только если вылетитMemoryError;при запуске загружать только банки 0 и 1, чтобы игра стартовала сразу, а каждый банк по 16 КБ подтягивать при первом обращении (
self.mem[0x14300 + bank] = slot) и держать в PSRAM до конца сеанса.
Это убрало и многосекундную паузу при загрузке, и дёрганье Flash посреди игры.
Масштаб 1,5×
Экран Game Boy — 160×144, экран бейджа — 320×240. Для удвоения места мало, но 1,5× (240×216, с повтором каждой второй строки по схеме 2:1:2:1) влезает, и это казалось неплохим компромиссом.
На экране выглядит терпимо. Часть линий становится вдвое толще, часть остаётся прежней. Зато картинка занимает почти всю высоту экрана (216 из 240 пикселей. — Прим. пер.), хотя выглядит не так, как задумано в игре.
Масштабирование я оставил, но по умолчанию включил родной 1:1: 160×144 по центру экрана 320×240, один пиксель ЖК-экрана на один пиксель бейджа, и поле вокруг пошире.
320 px (Badge Screen Width)
┌──────────────────────────────────────────────┐
│ GUTTER (80 px) 160x144 LCD GUTTER (80px)│
│ ┌──────────────┬──────────────┬────────────┐ │ 48 px Top
│ │ spf13 labs │ │ │ │
│ │ 60 fps │ 1:1 NATIVE │ [SAV] │ │
│ │ │ PIXEL │ │ │ 144 px LCD
│ │ [D-PAD HUD] │ │ │ │
│ │ (Active) │ │ │ │
│ └──────────────┴──────────────┴────────────┘ │ 48 px Bottom
│ GAME BOY COLOR │
└──────────────────────────────────────────────┘
В режиме 1:1 пиксель-арт выглядит так, как задумали художники, но, возможно, разглядеть его получится только в очках. Вспоминается старая лупа для Game Boy, обычное увеличительное стекло, которое крепилось над экраном. Может, кто-нибудь сделает такую для бейджа :)

Joyplus Handy Boy с лупой и стереодинамиками. Фото: Thomas Bresson, CC BY 3.0
Если отцентровать окно 160×144, по бокам остаются поля по 80 пикселей, а сверху и снизу по 48. Пустое место рядом с запущенной игрой выглядит как ошибка, да и у настоящего Game Boy вокруг экрана пластик, поэтому я нарисовал рамку: сумеречно-серый фон, тонкие золотые полосы и кремовая надпись GAME BOY COLOR внизу (для монохромных картриджей .gb она меняется на GAME BOY POCKET). На левом поле — небольшая панель с зарядом батареи, частотой кадров и живой подсветкой сенсорной крестовины и кнопок, на правом — индикаторы сохранения состояния SAV и RES.
Link’s Awakening DX

Pokémon Crystal

Super Mario Land

Tetris DX

С ROM-ами для GBC всё ещё хитрее
Железо прожило очень долго. Монохромный Game Boy вышел в Японии 21 апреля 1989 года, а версия Color, в которой частоту SM83 удвоили до 8,4 МГц, появилась только 21 октября 1998 года, через девять лет, и с тем же видеочипом на 160×144. Nintendo выпускала семейство GB/GBC до марта 2003 года. К концу этого срока неиспробованных регистров не осталось, и поздние игры для GBC выжимают из железа всё, что оно позволяет: от включения слоя окна посреди кадра до анимированных шрифтов, которые подгружаются прямо из ROM.
Окно, которое делит экран, надо фиксировать построчно
В Oracle of Ages, когда подходишь к персонажу, внизу появляется окно диалога, и спрайты мира рисовались прямо поверх текста.
Чтобы нарисовать окно диалога внизу экрана (LY = 80..143), игра включает слой Window (бит 5 регистра LCDC) по совпадению LYC == LY на строке 80 и выключает его снова во время VBlank. Мой первый рендерер проверял один WY на весь кадр уже после строки 143 и судил обо всех 144 строках по тому состоянию регистров, которое было в конце кадра. В итоге передвигаемые камни на строках 0..79 вели себя так, будто их закрывает окно, а спрайты на строках 80..143 рисовались поверх текста, потому что VBlank уже сбросил бит 5 LCDC.
Если на каждой строке сохранять LCDC, SCY, SCX, WX и WY в 0x14600 и 0x14840, а во время отрисовки отслеживать текущую строку окна в 0x14900, рендерер получает ровно то, что видел видеочип на этой строке:
# Snapshot per-scanline LCDC, SCY, SCX, WX, WY for visible lines 0..143
if sl < 144:
sl_off: int = 0x14600 + (sl << 2)
mem[sl_off] = mem[0x14140] # LCDC
mem[sl_off + 1] = mem[0x14142] # SCY
mem[sl_off + 2] = mem[0x14143] # SCX
mem[sl_off + 3] = mem[0x1414B] # WX
mem[0x14840 + sl] = mem[0x1414A] # WY
Двойная скорость и бюджет строки
В Link’s Awakening DX глюк был страннее: двухтайловая анимация ходьбы Линка на каждом шаге на один кадр выворачивалась наизнанку, но только при ходьбе вниз, а влево и вправо — никогда.
При ходьбе влево или вправо два спрайта Линка размером 8×16 остаются на месте. При ходьбе вниз каждые 8 кадров игра меняет их тайлы в VRAM ($8000/$8020) и переключает hflip (0x20) у обеих записей OAM ($FE0B/$FE0F).
Link’s Awakening DX работает в режиме двойной скорости GBC (бит 7 регистра KEY1), поэтому на одну строку развёртки процессору положено 228 M-циклов вместо обычных 114. С бюджетом в 114 строка обрывалась ровно между записью тайла в VRAM и отражением в OAM, и Линк на кадр оставался развёрнутым наполовину:
target_cyc: int = 228 if st[REG_DOUBLE_SPD] else 114
Когда режим двойной скорости получил свои полные 228 циклов на строку, разрыв исчез.
Указатель стека может смотреть в ROM
Когда я запустил Pokémon Crystal, вступление профессора Оука и все рамки меню показывались пустыми прямоугольниками.
Я и не знал, что железо позволяет то, что здесь делает игра. Чтобы распаковать однобитные тайлы шрифта и рамок в двухбитный формат в WRAM, Pokémon Crystal вызывает FarCopyBytesDouble_DoubleBankSwitch ($0DEF). Эта процедура направляет SP в переключаемый банк ROM ($4000..$7FFF), в цикле делает POP DE, вытаскивая по два байта каждые 3 M-цикла, передаёт результат через HDMA во время HBlank ($4209) и останавливает передачу командой RES 7, (HL) по адресу $FF55 (424A: cb be).
Я исходил из того, что SP всегда указывает в ОЗУ, а RES 7 — обычная битовая операция, и оба шага ломались. Когда POP rr при SP < 0x8000 стал читать через окно ROM, а сброс бита 7 в $FF55 стал трактоваться как отмена HDMA (а не как GP-DMA на 128 тайлов 0x7F), Оук и рамки вернулись.
@micropython.viper
def update_palettes_cgb_16(st: ptr32, mem: ptr8, fpal: ptr16):
st[REG_PAL_DIRTY] = 0
for i in range(32):
i2: int = i << 1
bgr: int = int(mem[0x14200 + i2]) | (int(mem[0x14201 + i2]) << 8)
r5: int = bgr & 0x1F
g5: int = (bgr >> 5) & 0x1F
b5: int = (bgr >> 10) & 0x1F
col_bg: int = (r5 << 11) | (g5 << 6) | b5
if i & 3:
col_bg = col_bg | 0x0020
fpal[i] = col_bg
Вместе с рендерером фона, который пропускает пустые тайлы, отрисовка кадра по нашим замерам через последовательный порт занимает примерно от 1,5 до 2,8 мс.
РПГ на шнурке для конференц-коридоров
По нашим замерам на бейдже, выполнение кода процессора укладывается в 12 мс, отрисовка — в 3 мс, и цикл помещается в бюджет 16,6 мс, нужный для 60 Гц.
Сохранения тоже должны работать: это всё-таки бейдж на конференции.
Если удерживать
A + BилиSTART + SELECT650 мс (now - self.menu_hold_start >= 650вapps/gbc/__init__.py; есть ещё таймерAUTO_SAVE_IDLE_MSна 60 секунд, если кнопки не нажимают), состояние сохраняется и открывается меню паузы с пропуском кадров, фильтрами строк развёртки, масштабом и выходом.Касание сенсорной площадки HOME на обороте вызывает
machine.Pin.board.BUTTON_HOME.irq, а тот —save_state(sync_sram=True): область памяти на 84 КБ и состояние регистров записываются во Flash перед возвратом в MonaOS.
Код открыт: github.com/spf13/universe-2026-badge-hacks. Чтобы поставить его на бейдж:
подключите бейдж по USB, откройте в MonaOS USB Disk и смонтируйте диск;
склонируйте репозиторий и скопируйте приложение:
git clone https://github.com/spf13/universe-2026-badge-hacks.git
cd universe-2026-badge-hacks
Copy-Item -Recurse -Force .\apps\gbc F:\apps\
положите в
F:\apps\gbc\roms\свои законно снятые дампы.gb/.gbc(защищённые авторским правом ROM в репозитории не распространяются):zelda_ladx.gbc(Link’s Awakening DX)pokemon_crystal.gbc(Pokémon Crystal)dw_1_2.gbc(Dragon Warrior I & II)tetris_dx.gbc(Tetris DX)zelda_ages.gbc(Oracle of Ages)mario_land.gb(Super Mario Land)ff_legend.gb(The Final Fantasy Legend)
безопасно извлеките диск, откройте лаунчер GBC, переверните бейдж вверх ногами и играйте.
Работа над эмулятором Game Boy Color заодно ускорила и эмулятор NES. Когда пять приёмов Viper заработали на SM83, я перенёс их обратно в apps/nes/emu.py: переключение банков CHR через указатели (REG_CHR_B0_OFF / REG_CHR_B1_OFF внутри self.mem вместо копирования 8192 байт в Python при каждом переключении); все 262 строки развёртки внутри одного вызова Viper вместо выхода по REG_CHR_DIRTY; обновление палитры в Viper по флагу изменений (REG_PAL_DIRTY); заранее вычисленные указатели на строки таблицы имён с развёрнутой записью по 8 пикселей в render_bg_32; более короткие быстрые пути для операндов и циклов ожидания.
Теперь у меня на шее висит Game Boy Color, на котором в режиме 1:1 идут Link’s Awakening DX и Pokémon Crystal. Если будете на Universe в Сан-Франциско, подходите поздороваться.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.