Расследование: The Tower of Druaga

Кейс: проверка поведения игры на реальном картридже и починка в Breaknes — issue #527

Задача: issue #527 — проверить особенности работы игры, описанные в чате nesdev Discord, и проверить работоспособность игры в Breaknes.

Итог:

  1. Игра не загружалась в Breaknes: тип платы NAMCOT-3305 отсутствовал в семейном индексе Nescartdb/boards/index.json, PcbLoader не мог разрешить его в определение платы. Добавлено отображение NAMCOT-3305 → nrom.json — картридж загружается.
  2. После фикса игра загружается и проходит свою загрузочную последовательность (сторожевая строка "YAMAMO", флаг первой загрузки $26), но не выводит титульный экран: обработчик NMI игры повторно входит в себя по 5 раз за кадр, переполняя стек. Причина — ошибка тайминга записи $2000 в PPUSim (см. §4): бит $2000[7] (разрешение NMI) кратковременно сбрасывается во время записи, из-за чего линия NMI дёргается и 6502 повторно принимает NMI в середине обработчика.
  3. Механизм глитча из nesdev Discord (порча RAM, обходящая проверку первой загрузки) подтверждён и воспроизводится принудительной записью сторожевой строки в RAM до запуска.

1. Идентификация картриджа

Дамп идентичен записи nescartdb (MD5 b63a55584796c106bcfd3e2c08ea1e3c; PRG CRC32 AF6E8571, CHR 6DC7E8EA, суммарный CRC EB764567):

ПараметрЗначение
ИграThe Tower of Druaga (Namco, Famicom, 1985-08-06)
ПлатаNAMCOT-3305 (PCB 3305), класс NAMCOT-3305
iNES Mapper0
PRG32 КБ (2×16K)
CHR8 КБ
Зеркалированиевертикальное (заголовок iNES бит 0 = 1; профиль nescartdb "Mirroring: Vertical"; паяные площадки платы h=1 = H Scroll, VRAM_A10 = PA10)
CICотсутствует

NAMCOT-3305 электрически эквивалентен NROM-256: на плате только два масочных ПЗУ (PRG Fujitsu MB83256, CHR Sharp LH2367), без дополнительной логики. Та же плата у Pac-Land. Зеркалирование согласуется по трём источникам; в Breaknes перемычка скролла следует за заголовком iNES (ApplyScrollFromHeader), что совпадает с реальной платой.

2. Механизм "первой загрузки" (сторожевая строка)

Загрузочный код по вектору $8000 (подтверждено дизассемблированием и эмуляцией):

$801E-$8031  очистка $0500-$07FF (косвенная запись STA ($00),Y; $0000-$04FF не трогается)
$8033-$8040  проверка: RAM $0002-$0007 == ROM $8044-$8049 ("YAMAMO"), 6 байт
$803B        BNE $804A  -> не совпало: первая загрузка
$8042        BEQ $8072  -> совпало:   "тёплый" сброс (продолжение состояния из RAM)
$8044-$8049  данные: "YAMAMO"
$804A-$8052  [первая загрузка] очистка нулевой страницы $0000-$00FF
$8054-$805A  [первая загрузка] JSR $91A2, JSR $938D, JSR $91B4 (инициализация)
$8061-$806C  [первая загрузка] копирование "YAMAMO" из $8044 в $0002-$0007
$806E-$8070  [первая загрузка] $26 = 1 (флаг первой загрузки)
$8072+       общий путь: JSR $A120, инициализация скролла/рекордов и т.д.

Если в RAM при старте уже лежит "YAMAMO" в $0002-$0007, игра считает это продолжением (сброс без выключения питания) и восстанавливает состояние из RAM — ровно то, что описал ykst в nesdev Discord.

В Breaknes: при питании SRAM обнуляется (BaseBoard::SRAM, "all memory cells read as 0") — детерминированный выбор эмулятора; у реальных SRAM состояние при включении не определено. Для этой игры нулевая RAM даёт корректную первую загрузку. При сбросе (Board::Reset) SRAM не очищается, поэтому повторный сброс находит сторожевую строку → "тёплый сброс", что соответствует реальному поведению.

3. Глитч (RAM corruption, nesdev Discord)

Быстрое выключение/включение питания частично портит RAM. Если сторожевая строка сохранилась, а остальное состояние испорчено, игра идёт по ветке "тёплого сброса" с повреждёнными данными: неверный рекорд, сигнал (возможен вход в отладочный режим), обилие экипировки на 1-м этаже, вплоть до мгновенной концовки.

Воспроизведение в Breaknes: записать в RAM $0002-$0007 строку "YAMAMO" и испортить остальную RAM (DebugHub memMap / отладчик), затем запустить — игра уходит в ветку $8072 без инициализации первой загрузки (флаг $26 остаётся мусорным, нулевая страница не очищается). Проверено headless-сборкой (режим --glitch тестового харнесса, см. Temp/boottest.cpp).

4. Проблема с выводом титульного экрана (NMI storm)

После загрузки игра входит в холостой цикл $80E1 (ожидание $0C) с включённым NMI. Обработчик NMI ($828E) в самом начале выполняет OAM DMA (STA $4014 = $05, 513 циклов), затем пишет $2000 = $B4 ($82A3) и только потом, в $82FD, читает $2002 (сброс флага VBlank). В Breaknes это приводит к следующему:

Доказательства (headless-сборка, плата Famicom/HVC):

  1. Трассировка Nintendulator: 5 вхождений в NMI за кадр (V=241, 245, 250, 255, 260), стек монотонно убывает (S: F8→F1→EA→E3→DC→...), обработчик прерывается на $82A8.
  2. $2002 показывает vb=1 почти постоянно; при чтении $2002 вне обработчика флаг чист.
  3. Патч ROM "убрать STA $2000 из обработчика NMI" — шторм исчезает (3 вхождения за 4 кадра вместо 10), игра работает.
  4. Патч ROM "читать $2002 сразу после DMA в обработчике" — шторм также исчезает.
  5. Для сравнения: SMB (обработчик NMI которого рано снимает линию NMI) работает корректно; DK (не использует NMI) — корректно.

Вывод и исправление: корневая причина — формирование /DBE (входа n_DBE PPU) на плате. /CS PPU (выход LS139, стробируемый M2) активен не только в фазе PHI2, но и в 3 тактах PHI1 перед ней (M2 опережает PHI2). Регистры PPU сэмплируют шину данных по уровню, пока активен строб записи — а в этих 3 тактах PHI1 на шине ещё лежит значение предыдущего чтения (например, старший байт операнда STA). Поэтому запись $2000 = $B4 сразу после чтения операнда кратковременно сбрасывает бит CTRL0[7] (NMI enable) в 0. Из-за задержки в один такт (PPU обновляет выход NMI после того, как ядро сэмплирует вход) детектор фронта 6502 видит ложное снятие/установку линии NMI и повторно принимает прерывание.

При этом для чтений /CS обязан оставаться активным всю фазу PHI1: ядро 6502 сэмплирует шину данных в начале PHI2, поэтому PPU должен выставить данные чтения заранее (в PHI1). Гейтирование всего /CS фазой PHI2 ломает чтения.

Исправление (только на плате, сигналы интерфейса PPU не меняются): /CS остаётся активным весь цикл для чтений, но для записей стробируется фазой PHI2:

// NESBoard.cpp / FamicomBoard.cpp
ppu_inputs[PPUSim::InputPad::n_DBE] = OR(PPU_nCE, AND(NOT(CPU_RnW), NOT(apu->GetPHI2())));

После исправления игра загружается, проходит загрузочную последовательность и выводит титульный экран (проверено headless-сборкой: 1 NMI за кадр, "YAMAMO" в $0002-$0007, $26 = 1, OAM DMA работает). SMB и Donkey Kong работают как раньше.

5. Изменения в репозитории

Временный headless-харнесс для проверки: Temp/boottest.cpp (не коммитится, папка Temp в .gitignore).