[Перевод] Перепрограммирование GameBoy за счёт бага в Pokemon Yellow

Pokemon Yellow – это карманная вселенная со своими правилами. В ней можно покупать и продавать предметы, тренировать покемонов, побеждать других тренеров — но нельзя менять правила самой игры. Нельзя построить себе дом, поменять музыку или даже переодеться. Точнее, так было задумано. На самом деле есть последовательность валидных команд (типа перемещения из одного места в другое и манипуляций с предметами), которая позволяет превратить игру в Pacman, тетрис, Pong, MIDI-проигрыватель и что угодно ещё.

Существует спидран Felipe Lopes de Freitas (p4wn3r), в котором Pokemon Yellow проходится за 1 минуту 36 секунд. Этот спидран основан на следующем хаке: в норме инвентарь игрока ограничен 20 предметами. Но есть баг, который позволяет игнорировать это ограничение и обращаться с памятью, расположенной сразу после инвентаря, как будто бы это был список предметов. Соответственно, стандартные манипуляции с предметами позволяют эту память переписывать. Спидраннер использует эту возможность, чтобы заставить дверь из стартовой комнаты переносить его на финальную локацию, в которой ему остаётся только выслушать поздравления.

Когда я впервые увидел этот спидран и понял, что памятью Gameboy можно манипулировать с помощью одного только списка предметов, безо всяких внешних инструментов, я решил посмотреть, получится ли у меня усовершенствовать приёмы p4wn3r. Вот что получилось:

Gameboy — это восьмибитный компьютер. Соответственно, всё происходящее в игре — результат того, что исполняется некая последовательность однобайтовых команд. Например, последовательность[62 16 37 224 47 240 37 230 15 55] в местных машинных кодах означает, что надо выяснить, какие кнопки нажаты в данный момент, и записать результат в регистр A. Можно создать программу, которая будет читать ввод с кнопок и записывать куда-нибудь в память исполняемый код, а потом передавать ему управление. Если удастся каким-то образом засунуть такую программу (назовём её программой ввода) в память и заставить Gameboy её исполнить — то я выиграл, потому что теперь я могу исполнять произвольный код (скажем, тетрис) вместо Pokemon Yellow. 

Во-первых, как написать такую программу? Восьмибитными числами представлены не только правила, но и состояние игры (инвентарь, список покемонов, имя главного героя и т.п.) Инвентарь хранится вот так:

item-one-id         (0-255)
item-one-quantity   (0-255)
item-two-id         (0-255)
item-two-quantity   (0-255)
.
.
.

Упомянутая выше подпрограмма для чтения кнопок хранится в той же последовательности битов, что и следующий набор предметов:

lemonade     x16
guard spec.  x224
leaf stone   x240
guard spec.  x230
parlyz heal  x55

Так что если нам удастся собрать нужные предметы в нужном количестве и порядке — то получится код программы ввода. Правда, его с самого начала нужно писать так, чтобы он состоял только из валидных ID и количеств предметов. Это не так-то просто, потому что предметов в игре немного, а многие машинные инструкции состоят из 2-3 битов. Та версия программы, на которой я остановился, состоит из 92 бит; примерно половина из них не делает ничего полезного и вставлена только для того, чтоб код был ещё и корректным списком предметов.

Записать код в память — это только полдела, потому что надо ещё заставить игру его исполнить. К счастью для нас, недалеко от инвентаря есть указатель на функцию, которую игра регулярно вызывает, чтобы обсчитывать эффект отравления и прочие штуки, срабатывающие по таймеру. Без него ничего бы не получилось, а с ним мы можем исполнить код по произвольному адресу, положив нужные предметы в соответствующую ячейку инвентаря.

Поехали!

Я начинаю с того, что называю своего противника Lp/k. Эти символы в своё время превратятся в предметы и будут перемещены на указатель функции, чтобы я мог исполнить программу ввода. Но сперва её надо записать, так что я начинаю забег так же, как и p4wn3r: перезапускаю игру при сохранении, чтобы сломать список покемонов. После этого я меняю местами 8 и 10 покемона, что ломает список предметов и позволяет мне манипулировать предметами после 20-го (то есть соответствующей областью памяти). С помощью этих манипуляций я выставляю скорость текста на максимум и переключаю свою дверь на магазин Celadon Dept. store. p4wn3r в этот момент переключил её на “Зал славы” и выиграл, но мне неинтересен спидран как таковой.

Поэтому я не останавливаюсь, а выкладываю из своего поломанного инвентаря в сундук множество глитчевых предметов 0x00, которые мне ещё пригодятся. Потом я забираю из сундука зелье, что вызывает переполнение счётчика предметов с 0xFF на 0x00 и восстанавливает мой инвентарь. Зелье при этом, правда, исчезает. После этого я забираю 255 0x00-предметов и отправляюсь в магазин. Там я продаю 2 0x00 за 414925 монет каждый, что позволяет мне купить следующие предметы:

+-------------------+----------+
|##| Item           | Quantity |
+--+----------------+----------+
|1 | TM02           |  98      |
|2 | TM37           |  71      |
|3 | TM05           |   1      |
|4 | TM09           |   1      |
|5 | burn-heal      |  12      |
|6 | ice-heal       |  55      |
|7 | parlyz-heal    |  99      |
|8 | parlyz-heal    |  55      |
|9 | TM18           |   1      |
|10| fire-stone     |  23      |
|11| water-stone    |  29      |
|12| x-accuracy     |  58      |
|13| guard-spec     |  99      |
|14| guard-spec     |  24      |
|15| lemonade       |  16      |
|16| TM13           |   1      |
+--+----------------+----------+

Эти предметы я раскладываю в инвентаре в таком порядке, чтобы получилась моя первая программа ввода. Первая — потому что написать окончательную версию программы ввода внутри игры у меня не получилось. Та, которую я собираю из предметов, умеет читать только кнопки A, B, start и select. Каждый фрейм игры она пишет 4 бита в определённую область памяти; собрав 200 с лишним байт, она исполняет то, что написала. С помощью этой программы я пишу следующую, которая читает уже все 8 кнопок и пишет произвольное число байтов в произвольную область памяти, а потом исполняет код по произвольному адресу. Эта программа, в свою очередь, используется для загрузки третьей, которая делает всё то же самое, но ещё и выводит записываемый код на экран.

Закончив с кодом первой программы, я отправляюсь в Celadon mansion (это не критично, мне просто нравится локация). Там я снова меняю местами сломанных покемонов и ломаю инвентарь. Проскроллив до имени своего противника, я выкидываю предметы до тех пор, пока имя не окажется на указателе функции. Как только я закрываю меню, программа ввода исполняется, и я могу заливать следующую программу.

Инфраструктура

Всё видео было записано ботами, а сам я к Gameboy (точнее, к эмулятору) даже не притрагивался. Ниже — краткое описание инфраструктуры, которую я создал для этого проекта. Исходники доступны в http://hg.bortreb.com/vba-clojure

Прежде всего, мне нужен был программный доступ к эмулятору, так что я скачал vba-rerecording. Поверх него я написал низкоуровневый интерфейс на C, к которому я смогу обращаться из Java через JNI. Этот интерфейс позволяет обращаться к базовым функциям эмулятора: прогнать один фрейм игры или один тик часов Gameboy, обратиться к любому адресу памяти или регистру. Кроме того, он позволяет выгрузить состояние эмулятора в объект Java или загрузить его обратно.

Поверх JNI я написал обёртку на clojure, так что в итоге у меня получился функциональный интерфейс к vba-rerecording. Этот интерфейс работает с состоянием эмулятора как с иммутабельным объектом и позволяет делать в функциональном стиле всё то, что интерфейс на C позволял делать в императивном. С помощью этого интерфейса я написал функции, которые принимают на вход состояние эмулятора, находят и исполняют последовательность команд, которая приведёт к желаемому эффекту. Из этих функций я собрал высокоуровневые функции, которые выполняют в игре задачи типа перемещения и покупки предметов. Вот, например, код для перемещения кратчайшим путём из магазина покемонов в Viridian City в лабораторию Оака:

(defn-memo viridian-store->oaks-lab
  ([] (viridian-store->oaks-lab
       (get-oaks-parcel)))
  ([script]
     (->> script
          (walk [↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
                 ← ← ← ← ← ← ← ← ← 
                 ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
                 ← ←
                 ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
                 ↓ ↓ ↓ ↓ ↓ ↓ ↓
                 → → → → → → → →
                 ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
                 ← ← ← ← ←
                 ↓ ↓ ↓ ↓
                 ])
          (walk-thru-grass
           [↓ ↓ ↓ ↓ ↓ ↓ ↓])
          (walk [↓ ↓ ← ↓ ↓ ↓ ←
                 ↓ ↓ ↓ ↓ ↓ ↓
                 → → → ↑])
                
          (do-nothing 1))))

А вот функция, которая находит кратчайшую последовательность команд для переноса нужных предметов в сундук:

(defn-memo hacking-10
  ([] (hacking-10 (hacking-9)))
  ([script]
     (->> script
          begin-deposit
          (deposit-held-item 17 230)
          (deposit-held-item-named :parlyz-heal 55)
          (deposit-held-item 14 178)
          (deposit-held-item-named :water-stone 29)
          (deposit-held-item 14 32)
          (deposit-held-item-named :TM18 1)
          (deposit-held-item 13 1)
          (deposit-held-item 13 191)
          (deposit-held-item-named :TM02 98)
          (deposit-held-item-named :TM09 1)
          close-menu)))

Вооружившись этими инструментами, я составил программу ввода, которая может быть представлена в инвентаре. Это было непросто, потому что многие полезные коды не соответствуют никакому предмету, а количество предметов не может быть больше 99. Итак, вот что у меня получилось:

Программа ввода

(defn pc-item-writer-program

  []

  (let [;;limit 75

        limit 201 ;; (item-hack 201 is the smallest I could make this.) 

        [target-high target-low] (disect-bytes-2 pokemon-list-start)]

    (flatten

     [[0x00  ;; (item-hack) no-op (can't buy repel (1E) at celadon)

       0x1E  ;; load limit into E

       limit

       0x3F  ;; (item-hack) set carry flag no-op

       ;; load 2 into C.

       0x0E   ;; C == 1 means input-first nybble

       0x04   ;; C == 0 means input-second nybble

       0x21 ;; load target into HL

       target-low

       target-high

       0x37 ;; (item-hack) set carry flag no-op

       0x00 ;; (item-hack) no-op

       0x37 ;; (item-hack) set carry flag no-op

       0x00 ;; (item-hack) no-op

       0xF3 ;; disable interrupts

       ;; Input Section

       0x3E ;; load 0x20 into A, to measure buttons

       0x10 

       0x00 ;; (item-hack) no-op

       0xE0 ;; load A into [FF00]

       0x00

       0xF0 ;; load 0xFF00 into A to get

       0x00 ;; button presses

       

       0xE6

       0x0F ;; select bottom four bits of A

       0x37 ;; (item-hack) set carry flag no-op

       0x00 ;; (item-hack) no-op

       0xB8 ;; see if input is different (CP A B)

       0x00 ;; (item-hack) (INC SP)

       0x28 ;; repeat above steps if input is not different

       ;; (jump relative backwards if B != A)

       0xED ;; (literal -19) (item-hack) -19 == egg bomb (TM37)

       0x47 ;; load A into B

              0x0D ;; dec C

       0x37 ;; (item-hack) set-carry flag

       ;; branch based on C:

       0x20 ;; JR NZ

       23 ;; skip "input second nybble" and "jump to target" below

       ;; input second nybble

       0x0C ;; inc C

       0x0C ;; inc C

       0x00 ;; (item-hack) no-op

       0xE6 ;; select bottom bits

       0x0F

       0x37 ;; (item-hack) set-carry flag no-op

       0x00 ;; (item-hack) no-op

       0xB2 ;; (OR A D) -> A

       0x22 ;; (do (A -> (HL)) (INC HL))

       0x1D ;; (DEC E)

       0x00 ;; (item-hack) 

       0x20 ;; jump back to input section if not done

       0xDA ;; literal -36 == TM 18 (counter)

       0x01 ;; (item-hack) set BC to literal (no-op)

       ;; jump to target

       0x00  ;; (item-hack) these two bytes can be anything.

       0x01 

       0x00   ;; (item-hack) no-op

       0xBF   ;; (CP A A) ensures Z

       0xCA   ;; (item-hack) jump if Z

       target-low

       target-high

       0x01   ;; (item-hack) will never be reached.

       ;; input first nybble

       0x00

       0xCB

       0x37  ;; swap nybbles on A

       0x57  ;; A -> D

       0x37  ;; (item-hack) set carry flag no-op

       0x18  ;; relative jump backwards

       0xCD  ;; literal -51 == TM05; go back to input section

       0x01  ;; (item-hack) will never reach this instruction

       ]

      (repeat 8 [0x00 0x01]);; these can be anything

      [;; jump to actual program

       0x00

       0x37  ;; (item-hack) set carry flag no-op

       0x2E  ;; 0x3A -> L

       0x3A

       0x00  ;; (item-hack) no-op

       0x26  ;; 0xD5 -> L

       0xD5  

       0x01  ;; (item-hack) set-carry BC

       0x00  ;; (item-hack) these can be anything

       0x01  

       0x00

       0xE9 ;; jump to (HL)

Мне особенно пригодились глитч-предметы 0x00 и 0xFF. 0x00 стоит почти половину максимально возможного числа денег, и всего двух хватило на то, чтоб купить все необходимые предметы. К тому же 0x00 — это NO-OP, который я могу вставлять куда угодно, чтобы подогнать программу под требования инвентаря. 0xFF имеет другое полезное свойство. В норме игра объединяет стеки предметов: если купить, например, покеболл, а потом ещё один покеболл, то инвентарь будет выглядеть вот так:

Мне особенно пригодились глитч-предметы 0x00 и 0xFF. 0x00 стоит почти половину максимально возможного числа денег, и всего двух хватило на то, чтоб купить все необходимые предметы. К тому же 0x00 — это NO-OP, который я могу вставлять куда угодно, чтобы подогнать программу под требования инвентаря. 0xFF имеет другое полезное свойство. В норме игра объединяет стеки предметов: если купить, например, покеболл, а потом ещё один покеболл, то инвентарь будет выглядеть вот так:

pokeball x2

Но если где-то перед тем в списке предметов есть 0xFF, то эта функция отключается, что позволяет мне получить вот такой инвентарь:

pokeball x1
pokeball x1
pokeball x1

Так что я вставляю в самое начало инвентаря 0xFF и больше об этом не беспокоюсь. 

Полезная нагрузка, которую я залил в Gameboy, — это тоже последовательность программ. Я создал упрощённый формат MIDI, имплементировал его в машинных кодах GameBoy, и перевёл в свой формат мелодию с http://www.everyponysings.com/. В полезной нагрузке последней программы ввода содержится как сам MIDI-файл, так и интерпретатор. Картинка устроена так же — я перевёл PNG-файл в Gameboy-совместимый формат и добавил код, который её показывает. И картинку, и мелодию загружает последняя программа ввода, так что исходники можно увидеть на экране эмулятора (или скачать с http://hg.bortreb.com/vba-clojure).

 

Источник

gameboy, Speedrun, покемон, хаки

Читайте также