[Перевод] Внутренности ретро-игр: Punch-Out для NES

image


Часть 1. Пароли


В игре для NES «Mike Tyson«s Punch-Out» используется система паролей, позволяющая игрокам продолжить игру с определённой точки. Каждый пароль состоит из 10 цифр, которые могут иметь значение от 0 до 9. Игра может принимать два типа паролей, которые я называю «обычными» и «особыми» паролями. Особые пароли — это определённые комбинации из 10 цифр, на ввод которых игра реагирует уникальным образом. Полный список особых паролей выглядит так:

  • 075 541 6113 — телефонный сигнал «занято» 1
  • 800 422 2602 — телефонный сигнал «занято» 2
  • 206 882 2040 — телефонный сигнал «занято» 3
  • 135 792 4680 — игра в скрытый турнир: «Another World Circuit» (чтобы пароль был принят, необходимо удерживать кнопку Select и нажать A + B)
  • 106 113 0120 — показ титров (чтобы пароль был принят, необходимо удерживать кнопку Select и нажать A + B)
  • 007 373 5963 — переносит игрока к бою с Майком Тайсоном


Второй тип паролей, принимаемых игрой — обычные пароли. В обычных паролях закодирован прогресс, которого игрок достиг в игре. В обычном пароле кодируются следующие игровые данные:

  • Количество побед в карьере
  • Количество проигрышей в карьере
  • Количество побед нокаутом
  • Следующий противник


Кодирование паролей


В качестве примера для изучения генерации паролей мы используем игру с 24 победами, 1 проигрышем, 19 нокаутом и начинающуюся в мировом турнире с боя против Super Macho Man.
Процесс кодирования состояния игры в пароль начинается со сбора в буфер количества побед, проигрышей и нокаутов. Игра представляет каждое число в виде двоично-десятичного кода, образуемого из 8 бит на цифру и 2 цифр на каждое значение. То есть для 24 побед нужен один байт со значением 2 и второй байт со значением 4. То же самое происходит с парами байтов для проигрышей и нокаутов, то есть всего получается 6 байтов данных. В показанной ниже схеме эти 6 байтов указаны со значениями и в десятичной, и в двоичной системе.

punchout01.png


Следующий этап — генерация контрольной суммы для этих 6 байтов. Байт контрольной суммы вычисляется сложением 6 отдельных байтов и вычитанием результата из 255. В нашем случае 2 + 4 + 0 + 1 + 1 + 9 = 17, то есть 255 — 17 = 238.

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

punchout02.png


Заметьте, что не все биты из первоначального буфера переносятся в новый промежуточный буфер. Эти биты игнорируются, потому что известно, что они всегда равны 0. Благодаря правилам игры количество проигрышей достаточно передавать в пароле всего 2 битами информации,. Если общая сумма проигрышей достигает 3, то наступает game over и игрок не получает пароля. Поэтому достаточно количество проигрышей вполне достаточно описывать числами 0, 1 и 2, а для этого хватает всего 2 битов.

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

0 — DON FLAMENCO (первый бой турнира major)

1 — PISTON HONDA (первый бой мирового турнира)

2 — SUPER MACHO MAN (последний бой мирового турнира)

Так как мы хотели сгенерировать пароль, сталкивающий нас с Super Macho Man, то в качестве значения противника мы используем 2. Затем биты контрольной суммы и значения противника записываются в промежуточные биты следующим образом:

punchout03.png


Следующий этап — выполнение нескольких циклических перестановок промежуточных битов влево. Одна циклическая перестановка влево означает, что все биты сдвигаются на одну позицию влево, а бит, бывший самым левым, перемещается и становится самым правым. Чтобы вычислить количество перестановок влево мы берём сумму значения противника и количества проигрышей, прибавляем 1 и берём остаток от деления на 3 этого результата. В нашем случае получается 2 + 1 + 1 = 4. Тогда остаток от 4/3 равен 1, поэтому мы циклически сдвигаем промежуточные биты влево один раз.

punchout04.png


На этом этапе промежуточные биты уже тщательно перемешаны и пора начинать разбивать их, чтобы получить цифры, составляющие пароль. Пароли должны состоять из 10 цифр, поэтому мы разобьём 28 промежуточных битов на 10 отдельных чисел, которые назовём значениями пароля P0, P1, P2 и т.д. Каждое из первых девяти значений пароля получают по 3 бита данных, а последнему достаётся только один из промежуточных битов. Чтобы завершить готовое значение пароля, мы также включим биты, обозначающие количество выполненных на предыдущем этапе перестановок.

punchout05.png


Наконец, мы прибавляем к каждому значению пароля уникальное жёстко прописанное в коде смещение. Готовая цифра пароля будет остатком этой суммы от деления на 10. Например, в седьмой позиции мы используем смещение 1, то есть получаем 5 + 1 = 6, а окончательной цифрой будет остаток от 6/10, то есть 6. В четвёртой позиции мы используем смещение 7, то есть получаем 5 + 7 = 12, а окончательная цифра равна остатку 12/10, то есть 2.

punchout06.png


Итак, мы получили готовые цифры пароля, которые можно проверить в игре.

punchout07.png


Декодирование паролей


Процесс декодирования паролей обратно в количество побед/проигрышей/нокаутов и значение противника — это простое выполнение в обратном порядке всех описанных выше шагов. Его я оставлю в качестве задачи для читателей. Однако в игре есть две заметные ошибки, которые она совершает при декодировании и проверке введённых игроком паролей.

Первая ошибка возникает в самом первом шаге декодирования пароля, то есть при вычитании смещений для возврата к значениям пароля. Изначальные значения пароля содержали по 3 бит данных каждое, то есть их значения до применения смещений должны были находиться в интервале 0–7. Однако игрок может ввести пароль, который после вычитания смещения приводит значению пароля 8 или 9 (делению на 10 с остатком). Вместо того, чтобы сразу же отклонять такой пароль, игра ошибочно не проверяет этот случай и позволяет добавить в значение пароля дополнительный бит данных, способный загрязнить набор промежуточных битов таким образом, что пароли больше не будут уникальными. Поскольку определённые промежуточные биты можно задать или соответствующей им цифрой пароля, ИЛИ дополнительным битом соседнего значения пароля, существует множество паролей, которое преобразуется к одинаковому множеству промежуточных битов. Именно поэтому можно найти разные пароли, дающие одинаковый внутриигровой результат, хотя они должны были быть уникальными.

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

  • контрольная сумма, хранимая в пароле, соответствует контрольной сумме, которая должна получаться с учётом хранимых в пароле количеств побед/проигрышей/нокаутов
  • значение проигрышей равно 0, 1 или 2
  • значение противника равно 0, 1 или 2
  • количество циклических перестановок, хранящееся в пароле, является верным количеством с учётом значения проигрышей и значения противника, сохранённых в пароле
  • все хранящиеся в пароле цифры побед/проигрышей/нокаутов находятся в интервале 0–9
  • побед >= 3
  • побед >= нокаутов


Если какие-то из этих условий не удовлетворяются, то игра должна отклонить пароль. Однако в реализации финальной проверки есть баг (а именно при проверке чисел, закодированных BCD.) Вместо проверки победы >= нокауты, игра допускает случаи, когда верхняя цифра побед равна 0, нижняя цифра побед >= 3, а верхняя цифра нокаутов меньше нижней цифры побед. Например, запись с 3 победами, 0 проигрышами и 23 нокаутами будет игрой принята (что доказывает пароль 099 837 5823), хотя должна отклоняться (так как невозможно выиграть 23 боёв нокаутом, если ты победил всего в 3 боях.)

Заключение


Частные детали такой схемы кодирования уникальны для Punch-Out, но общая идея получения важных битов состояния игры, их преобразования с возможностью восстановления для обфускации исходного состояния с последующим использованием их для генерации некоторого количества символов для демонстрации игроку в качестве пароля является довольно универсальным подходом. Можно использовать контрольные суммы, чтобы случайные изменения пароля (например при ошибке игрока) чаще всего бы приводили к его отклонению, а не созданию другого пароля со случайным состоянием игры.

Часть 2. Общий обзор Punch-Out


Каждый боец в Mike Tyson«s Punch-Out!!! управляется одним или несколькими интерпретируемыми байт-кодовыми скриптами. Персонаж игрока, Little Mac, выполняет простой скрипт, содержащий логику каждого доступного игроку действия (уклонение, блоки, удары и т.д.) Персонажи противников управляются 3 уровнями независимых скриптов, которые совместно создают поведение персонажа.

title.png


Скрипт матча


Самый высокоуровневый скрипт противника выполняется на протяжении всех 3 раундов боя и управляет самыми масштабными изменениями поведения противника. Я буду называть этот скрипт «скриптом матча». Его основная задача — выбор поведений, которые будет выполнять противник в ответ на различные события во время боя. Например, определённое поведение будет мгновенно запускаться после того, как противник поднимется после нокдауна, или когда у игрока закончатся сердца и он станет уставшим. Эти поведения записаны в таблицу и вызываются движком игры в ответ на соответствующие события. Также скрипт матча задаёт изначальные значения для опций конфигурации, связанных со сложностью боя (например количество времени, на протяжении которого противник остаётся уязвимым после пропущенного удара.) Наконец, скрипт матча начинает ожидать во время боя определённых временных маркеров, чтобы внести изменения в ранее заданные значения.

Скрипт поведения


Скрипт противника более низкого уровня — это «скрипт поведения». Этот уровень отвечает за последовательность определённых ударов и атак, которые противник должен выполнить в рамках текущего поведения (заданного скриптом матча.) Скрипты поведения выполняют команды наподобие «нанести правый джеб, сделать паузу на 28 кадров, случайным образом нанести левый или правый апперкот, повторить всё это 5 раз». Также у скрипта есть команды на считывание и запись по любому адресу в памяти из движка игры, поэтому поведения могут быть очень динамическими.

Скрипт анимаций


Самый низкоуровневый скрипт противников — это «скрипт анимаций». Такие скрипты выполняют подробности каждого отдельного удара, блока или специальной атаки как части поведения (задаваемого скриптом поведения.) На этом уровне содержатся такие команды, как «присвоить текущему кадру анимации противника спрайт 23, перемещать его вниз и вправо на 1 пиксель каждый второй кадр в течение следующих 10 кадров, сменить кадр анимации на спрайт 24, воспроизвести звуковой эффект 7». В дополнение к командам анимаций скрипты анимаций также выполняют последовательности различных изменений состояний геймплея, которые тесно связаны с движениями противников. Например, в длинную анимацию специальной атаки скрипт анимации может вставлять команды, делающие противника уязвимым к нокдаунам одним ударом на протяжении очень короткого периода времени. Как и скрипты поведений, скрипты анимаций могут выполнять считывание и запись произвольных адресов памяти в движке игры, чтобы достигать более динамичных эффектов.

Скрипт Little Mac


Выполняемый персонажем игрока Little Mac скрипт наиболее похож на скрипты анимаций противников. Он изменяет текущий кадр отражаемой анимации и перемещает игрока по экрану. Как и скрипты анимаций, скрипт Little Mac выполняет последовательности определённых геймплейных событий, например, в какой конкретно момент времени Mac должен бить противника, или когда он должен выполнять блок или уклонение. Скриптом Little Mac управляет ввод игрока, аналогично тому, как скрипты поведений управляют скриптами анимаций противников.

Каждый из этих четырёх скриптов обрабатывается собственным интерпретатором. Хотя многие из них имеют одинаковый функционал, например, базовый контроль за управлением и прямой доступ к памяти, каждая из систем реализует свою собственную версию, а не делит общий код (или пространство опкодов) с другими системами. Это позволяет каждому типу скриптов быть очень специфичным и эффективно использовать небольшой набор целевых команд. Данные скриптов составляют примерно 22% неграфических данных картриджа игры (сам машинный код для движка игры занимает всего 17%), поэтому очень важно было, чтобы скрипты имели компактный вид.

Часть 3. Скрипт матча Punch-Out


Скрипт матча управляет поведением противника на самом высоком уровне. Основная операция, которую он выполняет снова и снова — это ожидание определённого времени раунда и внесение в этот момент изменений в данные конфигурации противника. В видео показан первый раунд первого боя против Bald Bull вместе со скриптом матча, управляющим его поведением в целом.


Существует три основные операции, которые может выполнять скрипт матча. Первая — ожидать, пока таймер раунда достигнет определённого значения. Вторая — запрашивать, изменилось ли текущее поведение противника. Поведения записываются в таблицу конфигурации боя в памяти, а затем вызываются в разное время и скриптами матча, и самим движком игры. В таблице есть два сегмента поведений, используемых скриптами матча. Я называю их «основным» поведением и «особым» поведением. Особые поведения — это, например, удар Bull Charge противника Bald Bull или Honda Rush противника Piston Honda, а основные поведения — это обычные удары, которые наносит противник в течение всего остального времени. Конкретные скрипты поведений, используемые для реализации этих типов поведений, могут изменяться скриптом матча прямо в течение боя, поэтому бойцы способны начать с одного основного поведения, а позже переключиться на другое (как заметно в видео, Bald Bull делает это, когда таймер достигает отметки 0:20.)

Особенность изменения поведений, выполняемого скриптами матчей, заключается в том, что их могут замещать изменения поведений, запрашиваемые движком игры. Движок игры использует четыре сегмента поведений для запроса новых поведений, когда Mac теряет все свои сердца и устаёт, а также когда противник поднимается после нокдауна. Если скрипт матча выполнил запрос на смену поведения, но одно из этих четырёх событий движка игры происходит до обработки запроса (запросы не могут обрабатываться, пока противник не перейдёт в состояние ожидания), то движок игры задаёт нужное ему поведение, а запрос скрипта матча отбрасывается. Некоторые бойцы, например Bald Bull, за короткий промежуток времени несколько раз запрашивают особое поведение. Похоже, это необходимо только для того, чтобы снизить вероятность того, что какой-то из этих запросов будет случайно отброшен.

Третья основная операция скрипта матча — это патчинг памяти. Большинство патчей памяти влияет на таблицу конфигурации боя, где регистрируются скрипты поведений. Кроме наборов поведений таблица содержит данные, связанные со сложностью боя. Например, когда таймер в видео достигает отметки 0:30, Bald Bull меняет свои параметры защиты. Это приводит к тому, что игрок больше не может обмануть его, нажав вверх, а затем выполнив удар в тело. Кроме того, скрипты матчей имеют возможность патчить произвольные адреса памяти, но эта функция используется только один раз — в начале второго раунда с Майком Тайсоном, чтобы игрок получил звезду в первый раз, когда ударит его, находящегося в режиме ожидания.

Часть 4. Скрипт поведений Punch-Out


Теперь мы рассмотрим скрипты поведений, которые непосредственно занимаются реализацией поведений.

В видео показана интерпретация того, как мог бы выглядеть скрипт поведений противника Piston Honda 1 в командах на английском языке.


Команды анимаций


Скрипты поведений отвечают за последовательный запуск анимаций аналогично тому, как скрипты матчей отвечали за запуск поведений. Команда anim воспроизводит конкретную анимацию, а команда anim_rnd выполняет анимацию, случайно выбранную из списка 8 вариантов. В показанном выше видео в момент случайного выбора из списка вариантов выбранный вариант на мгновение подсвечивается красным. Когда Piston Honda наносит свои первые два джеба, то для каждого из них используется anim. После этого он использует anim_rnd для случайного выбора из набора, содержащего 6 анимаций хуков и 2 пустых анимаций. В результате этого в 75% времени он совершает хук, а в 25% времени ничего не делает.

С точки зрения скрипта поведений анимации воспроизводятся синхронно, потому что когда система анимаций не находится в режиме простоя, интерпретатор скриптов ставится на паузу.

Команды управления выполнением


Есть несколько команд, изменяющих выполнение самого скрипта поведений. Команды pause могут приостанавливать выполнение скриптов на определённое число кадров, или на число кадров, случайно выбранное из списка из 2 вариантов.

Существуют различные команды ветвления, при выполнении определённых условий опционально переходящие к разным частям скрипта поведений. У команды branch_rnd есть заданная вероятность того, что при каждом её выполнении произойдёт ветвление. Особым случаем вероятностного ветвления является команда branch_always, имеющая стопроцентную вероятность ветвления.

В интерпретатор скриптов поведений встроен простой механизм циклов. Команда set_loop_count задаёт текущее значение счётчика циклов. При каждом выполнении команды branch_while_loop она уменьшает значение счётчика циклов на единицу и выполняет ветвление, только если значение счётчика больше нуля.

Последний тип ветвления проверяет содержимое памяти, чтобы принять решение о ветвлении. Piston Honda использует эту команду branch_mem_test, чтобы проверить, был ли его последний удар в особом поведении удачным. Если удар попал в цель, то он выполняет ветвление к следующему удару. Если удар не был удачным, то он использует команду branch_while_loop, чтобы продолжать бить, только когда накопится 5 неудачных ударов.

Команды поведений


Есть две команды, которыми скрипты поведений могут управлять самой системой поведений. Команда begin_behavior_main используется для завершения текущего выполняемого поведения и запуска основного поведения. Это отличается от ветвления в скрипте поведений, потому что часть скрипта, считающаяся текущим «основным» поведением, может в течение матча изменяться скриптом матча (см. предыдущую часть статьи про скрипты матчей).

Ещё одна команда, связанная с поведением — это enable_behavior_change. При запуске нового поведения оно стартует с состояния блокировки, когда все дальнейшие запросы на смену поведения блокируются. С помощью команды enable_behavior_change скрипт сигнализирует, что готов к разрешению выполнения других поведений. Например, в особом поведении Piston Honda команда enable_behavior_change никогда не выполняется, поэтому если Mac в это время устаёт, то особое поведение продолжает выполняться. Однако события нокдауна обходят эту систему, поэтому если во время особого поведения Piston Honda главного героя отправляют в нокдаун, то поведение в любом случае будет изменено.

© Habrahabr.ru