Тетрис на Nintendo — одна из моих любимых версий тетриса. Моя единственная жалоба заключается в том, что ему не хватает возможности «Hard Drop» — мгновенного падения текущей фигуры и её фиксации на месте. Давайте её добавим В этом посте описывается модификация, которую я внёс в тетрис, — нажатие кнопки «вверх» приводит к мгновенному падению текущей фигуры и отображению «призрачной фигуры» — точечный контур текущей фигуры, показывающий, где она приземлится. Текущая фигура перемещается на одну клетку вниз за каждый тик игры. Реализации тетриса обычно предоставляют два способа ускорить падение — ускоренное и мгновенное падение. В случае ускоренного падения нажатие кнопки мгновенно переместит текущую фигуру вниз на одну позицию, а удерживание кнопки заставит её упасть быстрее. Hard drop мгновенно опускает текущую фигуру и фиксирует её на месте. Поскольку игроку может быть сложно визуально определить, выровнена ли фигура с тем местом, куда она должна приземлиться, реализации тетриса с мгновенным падением обычно отображают контур этой фигуры, показывающий, где она в конечном счёте окажется. До моих изменений NES Тетрис поддерживал только ускоренное падение. Я сделал программу на rust, которая считывает файл NES ROM в формате INES. Если на входе был NES Tetris (обычно файл назван что-то вроде «Tetris(U)[!].nes»), на выходе она создаст новый файл ROM NES, который представляет собой NES Tetris, с добавлением быстрого падения фигуры. Входной файл должен иметь хэш sha1 a99f922e9da20b2a27e4398348505d2e9d15271b. Этот инструмент полагается на то, что пользователь получит ROM-файл NES Tetris. В нём нет встроенного тетриса. Полученный файл ROM совместим со всеми эмуляторами NES — он неспецифичен для fceux. После публикации этого поста некоторые отметили, что существует стандартный формат для патча ROM (IPS), который широко поддерживается эмуляторами. Вы можете скачать мой патч здесь. Пару лет назад я сделал эмулятор NES. Оказывается, это полезный инструмент для реверс-инжиниринга, поскольку эмулятор легко настроить для проведения экспериментов с запущенной им программой. В частности, очень пригодилась возможность логировать каждую инструкцию, перемежающуюся интересными событиями, такими как обновления видеопамяти. Также он может отображать гифки, и я использовал его для создания всех анимаций в этом посте. Чтобы протестировать свой эмулятор, я создал библиотеку для написания ассемблерных программ на языке Rust. Вот пример, в котором значение в регистре «аккумулятора» умножается на 12: Это позволяет мне использовать rust как язык высокого уровня для ассемблерных программ NES. Гибкость Rust важна при добавлении пользовательского кода к существующей программе, написанной в 1980-х годах. Во время отладки эмулятора я написал простой дизассемблер, который может отображать сборку программ NES для каждой функции. Наконец, я использовал сторонний эмулятор NES под названием Mesen, который может похвастаться богатым набором инструментов отладки. Он был полезен для понимания содержимого памяти и состояния графического чипа в конкретный момент. В NES есть два разных типа графики: фон представляет собой сетку из плиток 8x8 пикселов; спрайты — это плитки, которые можно рисовать в произвольных местах на экране. В большинстве игр используется комбинация фонов и спрайтов, и Тетрис — не исключение. Спрайты в тетрисе используются, чтобы отрисовать текущую и следующую фигуры, а также фоновую графику для всего остального. На изображениях ниже демонстрируются два типа графики, с фоном вверху и спрайтами внизу. В игре явно уже есть логика для рисования текущей фигуры с использованием спрайтов, поэтому самый простой способ визуализации конечного положения фигуры, похоже, заключается в повторном использовании этой логики, но с использованием контурной плитки, а не обычной. Говоря о контурных плитках, я добавил в игру новую плитку, чтобы использовать её для призрачной фигуры: Моя цель здесь — выследить ту часть кода, которая рендерит текущую фигуры, чтобы повторно использовать этот код для рендеринга конечного положения фигуры. Для рендеринга спрайтов на NES вы заполняете область основной памяти метаданными спрайта (положение, плитка и т. д.), а затем записываете адрес начала этой области памяти в регистр OAMDMA. (Прямой доступ к памяти атрибутов объекта — OAM — это специальная память для хранения метаданных спрайта, а DMA — это общий термин для устройств, непосредственно считывающих и записывающих основную память.) Запись адреса в OAMDMA заставляет графическое оборудование NES копировать метаданные спрайта указанной области основной памяти и в специализированную память атрибутов объекта, которая будет использоваться во время рендеринга для рисования спрайтов. Регистр OAMDMA отображается в адресное пространство ЦП по адресу 0x4014. Поиск в дизассемблированной программе по этому адресу показывает: При этом значение 2 записывается в OAMDMA, в результате чего память от 0x0200 до 0x02FF копируется в OAM. И одна функция определённо передаёт управление подпрограмме как ответственная за заполнение буфера. Она находится в 0x8A0A и может многое рассказать о том, как работает Тетрис. Она начинается с чтения значений из адресов 0x0040 и 0x0041, умножения каждого на 8 и добавления их к некоторым смещениям. На NES каждая плитка имеет размер 8x8 пикселей, так что это, по-видимому, переводится из координаты плитки в координату пиксела, где смещения являются компонентами координаты пиксела верхнего левого угла доски. Несколько минут копания в мезене подтверждают это: 0x40 — это координата x, а 0x41 — координата Y текущего фрагмента. Затем функция считывает данные из 0x42. Это место всегда содержит значение от 0 до 12, которое, по-видимому, кодирует форму текущей фигуры, а также её вращение. Для фигур с вращательной симметрией (например, фигура “S”) несколько одинаковых вращений получают одно значение в 0x42. Я буду называть это значение “индексом формы”. Каждая фигура в Тетрисе состоит из 4 плиток, и для каждой плитки рендерится один спрайт. Координаты в 0x40 и 0x41 — это позиция фигуры, но для рендеринга спрайтов мы должны узнать положение каждой плитки. С этой целью эта функция обращается к таблице в ПЗУ по адресу 0x8A9C, которую я буду называть «таблицей форм». Каждая из 13 частей (включая уникальные вращения) имеет 12-байтовую запись в таблице форм. Запись таблицы форм для фрагмента хранит по 3 байта для каждой из 4 плиток: смещение плитки по оси y (относительно 0x41); индекс спрайта, используемый при рендеринге плитки; смещение плитки по оси x (относительно 0x40). Эта функция вычисляет местоположение и индекс спрайта каждой плитки текущей фигуры и заполняет буфер OAM DMA этой информацией. Чтобы визуализировать призрачную фигуру, мне нужна аналогичная функция, за исключением того, что она отображает каждую плитку с контуром, а не плиткой из таблицы форм, и визуализирует фигуру с вертикальным смещением, так что фигура появляется в том месте, где она должна приземлиться после hard drop. Было бы нетривиально изменить эту функцию на месте, чтобы она была общей для призрачной и обычной фигуры, поэтому вместо этого я скопировал/вставил код и изменил его, чтобы сделать то, что мне нужно. Сначала я стал использовать программу для просмотра памяти — mesen, чтобы найти, казалось бы, неиспользуемую область ПЗУ. Я не знаю, что здесь делают строки с 0x00 и 0xFF! Также я не знаю, как изменить шрифт в mesen на Monospace! Я выделил 512 байт памяти, начиная с адреса 0xD6D0. Первым кодом, который я добавил в эту область, была функция, которая просто вызывает существующую функцию обновления буфера DMA OAM: Мой инструмент для патча заменяет все вызовы исходной функции (0x8A0A) вызовами новой функции. Затем я взял дизассемблированный код из исходной функции обновления буфера DMA OAM и вручную перевел его на язык rust для сборки NES. Этот код: превратился в: Я изменил свою копию обновления буфера DMA OAM, чтобы использовать контурную плитку вместо плитки, считанной из буфера формы. Чтобы проверить это изменение, я обновил oam-dma-buffer-update, чтобы вызвать мою функцию вместо оригинала: Затем я заставил мою функцию рендеринга призрачной фигуры принимать аргумент, определяющий вертикальное расстояние, на котором должна оказаться призрачная фигура. В конце концов, это будет вычисляться на основе того, сколько раз фигура может сдвинуться вниз до столкновения, но сначала я попытался вызвать функцию с константой, равной 6. Теперь вычислим истинное вертикальное смещение от текущей фигуры до места, где она приземлится после падения. Наблюдая за памятью с помощью mesen, я заметил, что ничего, похоже, не работает с памятью от 0x0020 до 0x0028. Первые 256 байтов памяти называются «нулевой страницей» и обеспечивают более быстрый доступ, чем остальная часть памяти. Мне нужно 8 байтов нулевой страницы для хранения координат X, Y каждой плитки текущей фигуры и обнаружения столкновений, а также один дополнительный байт для хранения временных значений во время вычислений. Начните с инициализации значений от 0x20 до 0x27 координатами X, Y каждой плитки текущей фигуры: Теперь о фактическом обнаружении столкновений! Неоднократно увеличивайте компонент Y каждой координаты плитки в адресах от 0x20 до 0x27, пока одна из плиток не столкнётся с зафиксированной плиткой или не выйдет за нижнюю часть поля. Изучая память с помощью mesen, я узнал, что состояние поля хранится в виде строкового массива индексов спрайтов, начинающихся с 0x0400, и что 0xEF — это индекс плитки «пустого пространства». Стратегия будет заключаться в использовании координаты каждой плитки для построения индекса в этом массиве и остановке, если будет найдено что-либо кроме 0xEF. Возможная путаница в приведённом ниже коде заключается в том, что он реализует цикл в сборке, но есть также цикл for в rust, который генерирует сборку. Эти два цикла не связаны между собой. Сборка в цикле rust проходит 4 раза, и результат составляет тело цикла сборки. Это приводит к тому, что IndexRegisterX содержит количество повторений цикла, которое также является вертикальным расстоянием от текущей фигуры до того места, где она окажется после падения. Для удобства эта функция вернёт результат через регистр аккумулятора: Вот полный код замещающей функции обновления буфера DMA OAM: Результат: Теперь, когда выполняется рендеринг призрачной фигуры, следующий шаг — сделать так, чтобы при нажатии кнопки «вверх» на контроллере происходило мгновенное падение. Кнопка «вверх» не используется в Тетрисе, поэтому нам не нужно беспокоиться о потере некоторых функций, чтобы получить hard drop. Как и в случае с добавлением контуров фигуры, я решил найти функцию, которую я мог бы заменить новой функцией, которая вызывает оригинал перед выполнением дополнительных действий, — в этом случае проверяется, была ли нажата кнопка «вверх», и выполняется мгновенное падение фигуры, если да. Моя первая попытка состояла в том, чтобы найти код, который считывает регистр состояния контроллера 0x4016, но, похоже, существует довольно много косвенных связей между чтением этого регистра и обновлением состояния игры на основе того, какие кнопки нажимаются. Моя вторая идея заключалась в том, чтобы настроить мой эмулятор для логирования каждой выполненной инструкции. Я загрузил Тетрис и прошёлся по меню, чтобы начать новую игру, а затем сохранил файл состояния. У моего эмулятора есть возможность запускать определённое количество кадров. Я настроил его на работу в 20 кадров, загрузил файл состояния и записал каждую инструкцию, не нажимая никаких элементов управления. Затем я повторил этот процесс, но на этот раз я нажимал левую кнопку на протяжении 20 кадров. Теперь у меня было два лога потока инструкций — один без нажатых элементов управления, а второй — с их нажатием. Само собой разумеется, что в первую очередь эти потоки различаются, когда программа в первый раз разветвляется по состоянию левой кнопки. Конечно же: Перекрёстная ссылка с дизассемблированным ПЗУ, эта функция начинается с: Это ветвление на основе содержимого адресов 0x00B5 и 0x00B6. Во время наблюдения за этими адресами в mesen во время затирания элементов управления у меня создаётся впечатление, что 0xB5 хранит различия между кадрами в состоянии контроллера, а 0xB6 хранит текущее состояние контроллера. Несмотря на то что тетрис не использует её, состояние кнопки «вверх» отражается в этих значениях. Я запустил эту функцию так же, как и мою замену для обновления буфера DMA OAM. Всё, что он сделал, — это вызвал исходную функцию и вернул: Теперь добавим проверку, нажата ли кнопка «вверх». А пока просто телепортируем текущую фигуры на фиксированную высоту при нажатии кнопки: Вот код в действии, когда я несколько раз нажимаю «вверх»: Затем замените тестовую константу 7 на фактическое положение, в котором деталь окажется после резкого падения. Используйте функцию compute-hard-drop-distance, которую мы написали для рендеринга призрачной части, а затем просто добавьте текущую позицию фигуры, чтобы получить абсолютную координату Y, в которой он окажется после падения: Выглядит неплохо! Однако имеется небольшая проблема со скоростью. Похоже, что игра ожидает окончания текущего «тика», прежде чем создать следующую фигуру. После hard drop’a текущий тик должен немедленно закончиться и следующая фигура должна появиться без задержки. Глядя на память с помощью mesen, можно увидеть, что есть счётчик по адресу 0x0045, который ведёт отсчёт до некоторого числа, а затем сбрасывается на следующем тике (когда текущая фигура перемещается вниз сама по себе). Чтобы узнать больше, я заставил свой эмулятор записывать все инструкции и запускать игру в течение 13 тиков. Я выбрал 13, потому что казалось маловероятным, что они генерируются случайно. Во время этого прогона таймер истёк бы 13 раз. Где-то в логах инструкций есть связанная инструкция, которая была выполнена ровно 13 раз. Давайте найдём! Логи инструкций находится в файле с именем /tmp/log.txt: Мы сортируем инструкции по частоте. Просматривая те, которые были выполнены 13 раз, я заметил: Это кажется актуальным, потому что он взаимодействует с таймером по адресу 0x0045! Обращение к дизассемблированному коду этой инструкции: Две последние инструкции устанавливают значение таймера в 0, и они выполняются ровно 13 раз. Единственный способ получить эти инструкции — через ветвь (0x8984), что означает, что условие ветвления выполняется только 13 раз — вероятно, один раз за такт. Таким образом, вероятное повествование состоит в том, что таймер увеличивается на единицу каждый кадр, а кадр, в котором он становится больше значения в 0xAF, отмечает конец текущего тика, в этот момент таймер сбрасывается, и текущая фигура перемещается вниз. Наблюдаем за 0x00AF в mesen, и это, кажется, максимальное значение, которого достигает таймер в 0x0045. Кроме того, когда вы завершаете уровень, значение 0x00AF уменьшается, что ускоряет игру! Поэтому после hard drop просто установите значение таймера на значение 0x00AF: Выглядит лучше, но всё равно есть большая задержка, если вы очень быстро опустите первую фигуру во время первого тика. Оказывается, первый тик занимает больше времени, чем все остальные тики. Глядя на память в mesen, я заметил, что значение 0x004E увеличивается во время первого тика. Для всех остальных тиков он установлен на 0. Установка его на 0 после появления hard drop’a решает проблему с синхронизацией. Кажется, это работает! Исходный код инструмента исправления доступен на github. Загрузите патч IPS, который применяет изменения, описанные в этом посте, здесь. Второй патч, который добавляет hard drop, но не визуализирует конечное положение фигуры, доступен здесь. А если хотите создать свой игровой бестселлер, который, как и детище Алексея Пажитнова войдёт в историю — приходите к нам на курс «Разработчик игр на Unity», на котором мы рассказываем про все тонкости разработки игр. Узнайте, как прокачаться в других специальностях или освоить их с нуля: Профессия Data Scientist Профессия Data Analyst Курс по Data Engineering ПРОФЕССИИ Профессия Fullstack-разработчик на Python Профессия Java-разработчик Профессия QA-инженер на JAVA Профессия Frontend-разработчик Профессия Этичный хакер Профессия C++ разработчик Профессия Разработчик игр на Unity Профессия Веб-разработчик Профессия iOS-разработчик с нуля Профессия Android-разработчик с нуля КУРСЫ Курс по Machine Learning Курс "Machine Learning и Deep Learning" Курс "Математика для Data Science" Курс "Математика и Machine Learning для Data Science"  Курс "Python для веб-разработки" Курс "Алгоритмы и структуры данных" Курс по аналитике данных Курс по DevOps