Тетрис на Nintendo — одна из моих любимых версий тетриса. Моя единственная жалоба заключается в том, что ему не хватает возможности «Hard Drop» — мгновенного падения текущей фигуры и её фиксации на месте. Давайте её добавим
В этом посте описывается модификация, которую я внёс в тетрис, — нажатие кнопки «вверх» приводит к мгновенному падению текущей фигуры и отображению «призрачной фигуры» — точечный контур текущей фигуры, показывающий, где она приземлится.
Ускоренное и мгновенное падение
Текущая фигура перемещается на одну клетку вниз за каждый тик игры. Реализации тетриса обычно предоставляют два способа ускорить падение — ускоренное и мгновенное падение.
В случае ускоренного падения нажатие кнопки мгновенно переместит текущую фигуру вниз на одну позицию, а удерживание кнопки заставит её упасть быстрее.
Hard drop мгновенно опускает текущую фигуру и фиксирует её на месте. Поскольку игроку может быть сложно визуально определить, выровнена ли фигура с тем местом, куда она должна приземлиться, реализации тетриса с мгновенным падением обычно отображают контур этой фигуры, показывающий, где она в конечном счёте окажется.
До моих изменений NES Тетрис поддерживал только ускоренное падение.
Артефакт
Я сделал программу на rust, которая считывает файл NES ROM в формате INES. Если на входе был NES Tetris (обычно файл назван что-то вроде «Tetris(U)[!].nes»), на выходе она создаст новый файл ROM NES, который представляет собой NES Tetris, с добавлением быстрого падения фигуры.
Входной файл должен иметь хэш sha1 a99f922e9da20b2a27e4398348505d2e9d15271b.
$ cargo install nes-tetris-hard-drop-patcher # install my tool
$ nes-tetris-hard-drop-patcher < 'Tetris (U) [!].nes' > tetris-hd.nes # patch a NES Tetris ROM
$ fceux tetris-hd.nes # run the result in an emulator
Этот инструмент полагается на то, что пользователь получит ROM-файл NES Tetris. В нём нет встроенного тетриса. Полученный файл ROM совместим со всеми эмуляторами NES — он неспецифичен для fceux.
Патч
После публикации этого поста некоторые отметили, что существует стандартный формат для патча ROM (IPS), который широко поддерживается эмуляторами. Вы можете скачать мой патч здесь.
Инструменты
Пару лет назад я сделал эмулятор NES. Оказывается, это полезный инструмент для реверс-инжиниринга, поскольку эмулятор легко настроить для проведения экспериментов с запущенной им программой. В частности, очень пригодилась возможность логировать каждую инструкцию, перемежающуюся интересными событиями, такими как обновления видеопамяти. Также он может отображать гифки, и я использовал его для создания всех анимаций в этом посте.
Чтобы протестировать свой эмулятор, я создал библиотеку для написания ассемблерных программ на языке Rust. Вот пример, в котором значение в регистре «аккумулятора» умножается на 12:
b.inst(Clc, ()); // clear carry flag
b.inst(Rol(Accumulator), ()); // rotate accumulator 1 bit to the left (x2)
b.inst(Rol(Accumulator), ()); // rotate accumulator 1 bit to the left (x4)
b.inst(Sta(ZeroPage), 0x20); // store current accumulator value at address 0x0020
b.inst(Rol(Accumulator), ()); // rotate accumulator 1 bit to the left (x8)
b.inst(Adc(ZeroPage), 0x20); // add the accumulator with the value at 0x0020 (x12)
Это позволяет мне использовать rust как язык высокого уровня для ассемблерных программ NES. Гибкость Rust важна при добавлении пользовательского кода к существующей программе, написанной в 1980-х годах.
Во время отладки эмулятора я написал простой дизассемблер, который может отображать сборку программ NES для каждой функции.
Наконец, я использовал сторонний эмулятор NES под названием Mesen, который может похвастаться богатым набором инструментов отладки. Он был полезен для понимания содержимого памяти и состояния графического чипа в конкретный момент.
Визуализация конечного положения фигуры
В NES есть два разных типа графики:
-
фон представляет собой сетку из плиток 8×8 пикселов;
-
спрайты — это плитки, которые можно рисовать в произвольных местах на экране.
В большинстве игр используется комбинация фонов и спрайтов, и Тетрис — не исключение.
Спрайты в тетрисе используются, чтобы отрисовать текущую и следующую фигуры, а также фоновую графику для всего остального. На изображениях ниже демонстрируются два типа графики, с фоном вверху и спрайтами внизу.
В игре явно уже есть логика для рисования текущей фигуры с использованием спрайтов, поэтому самый простой способ визуализации конечного положения фигуры, похоже, заключается в повторном использовании этой логики, но с использованием контурной плитки, а не обычной.
Говоря о контурных плитках, я добавил в игру новую плитку, чтобы использовать её для призрачной фигуры:
Моя цель здесь — выследить ту часть кода, которая рендерит текущую фигуры, чтобы повторно использовать этот код для рендеринга конечного положения фигуры.
Для рендеринга спрайтов на NES вы заполняете область основной памяти метаданными спрайта (положение, плитка и т. д.), а затем записываете адрес начала этой области памяти в регистр OAMDMA. (Прямой доступ к памяти атрибутов объекта — OAM — это специальная память для хранения метаданных спрайта, а DMA — это общий термин для устройств, непосредственно считывающих и записывающих основную память.) Запись адреса в OAMDMA заставляет графическое оборудование NES копировать метаданные спрайта указанной области основной памяти и в специализированную память атрибутов объекта, которая будет использоваться во время рендеринга для рисования спрайтов.
Регистр OAMDMA отображается в адресное пространство ЦП по адресу 0x4014. Поиск в дизассемблированной программе по этому адресу показывает:
0xAB63 Lda(Immediate) 0x02 # load accumulator with 2
0xAB65 Sta(Absolute) 0x4014 # write accumulator to 0x4014
При этом значение 2 записывается в OAMDMA, в результате чего память от 0x0200 до 0x02FF копируется в OAM. И одна функция определённо передаёт управление подпрограмме как ответственная за заполнение буфера. Она находится в 0x8A0A и может многое рассказать о том, как работает Тетрис.
Она начинается с чтения значений из адресов 0x0040 и 0x0041, умножения каждого на 8 и добавления их к некоторым смещениям. На NES каждая плитка имеет размер 8×8 пикселей, так что это, по-видимому, переводится из координаты плитки в координату пиксела, где смещения являются компонентами координаты пиксела верхнего левого угла доски. Несколько минут копания в мезене подтверждают это: 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:
b.label("oam-dma-buffer-update");
// Call original function
b.inst(Jsr(Absolute), 0x8A0A);
// Return
b.inst(Rts, ());
Мой инструмент для патча заменяет все вызовы исходной функции (0x8A0A) вызовами новой функции.
Затем я взял дизассемблированный код из исходной функции обновления буфера DMA OAM и вручную перевел его на язык rust для сборки NES.
Этот код:
0x8A0A Lda(ZeroPage) 0x40
0x8A0C Asl(Accumulator)
0x8A0D Asl(Accumulator)
0x8A0E Asl(Accumulator)
0x8A0F Adc(Immediate) 0x60
0x8A11 Sta(ZeroPage) 0xAA
...
превратился в:
b.label("render-ghost-piece"); // function label so it can be called by name later
b.inst(Lda(ZeroPage), 0x40);
b.inst(Asl(Accumulator), ());
b.inst(Asl(Accumulator), ());
b.inst(Asl(Accumulator), ());
b.inst(Adc(Immediate), 0x60);
b.inst(Sta(ZeroPage), 0xAA);
...
Я изменил свою копию обновления буфера DMA OAM, чтобы использовать контурную плитку вместо плитки, считанной из буфера формы. Чтобы проверить это изменение, я обновил oam-dma-buffer-update, чтобы вызвать мою функцию вместо оригинала:
b.label("oam-dma-buffer-update");
// Call new function
b.inst(Jsr(Absolute), "render-ghost-piece");
// Return
b.inst(Rts, ());
Затем я заставил мою функцию рендеринга призрачной фигуры принимать аргумент, определяющий вертикальное расстояние, на котором должна оказаться призрачная фигура. В конце концов, это будет вычисляться на основе того, сколько раз фигура может сдвинуться вниз до столкновения, но сначала я попытался вызвать функцию с константой, равной 6.
b.label("oam-dma-buffer-update"); // Call original function first b.inst(Jsr(Absolute), 0x8A0A); // Render the ghost piece, passing the vertical offset argument in address 0x0028. b.inst(Lda(Immediate), 6); b.inst(Sta(ZeroPage), 0x28); b.inst(Jsr(Absolute), "render-ghost-piece"); // Return b.inst(Rts, ());b.label("oam-dma-buffer-update");
// Call original function first
b.inst(Jsr(Absolute), 0x8A0A);
// Render the ghost piece, passing the vertical offset argument in address 0x0028.
b.inst(Lda(Immediate), 6);
b.inst(Sta(ZeroPage), 0x28);
b.inst(Jsr(Absolute), "render-ghost-piece");
// Return
b.inst(Rts, ());
Теперь вычислим истинное вертикальное смещение от текущей фигуры до места, где она приземлится после падения. Наблюдая за памятью с помощью mesen, я заметил, что ничего, похоже, не работает с памятью от 0x0020 до 0x0028. Первые 256 байтов памяти называются «нулевой страницей» и обеспечивают более быстрый доступ, чем остальная часть памяти. Мне нужно 8 байтов нулевой страницы для хранения координат X, Y каждой плитки текущей фигуры и обнаружения столкновений, а также один дополнительный байт для хранения временных значений во время вычислений.
Начните с инициализации значений от 0x20 до 0x27 координатами X, Y каждой плитки текущей фигуры:
b.label("compute-hard-drop-distance"); // function label so it can be called by name later
const SHAPE_TABLE: Address = 0x8A9C;
const ZP_PIECE_COORD_X: u8 = 0x40;
const ZP_PIECE_COORD_Y: u8 = 0x41;
const ZP_PIECE_SHAPE: u8 = 0x42;
// Multiply the shape by 12 to make an offset into the shape table,
// storing the result in IndexRegisterX.
b.inst(Lda(ZeroPage), ZP_PIECE_SHAPE); // read shape index into accumulator
b.inst(Clc, ()); // clear carry flag to prepare for arithmetic
b.inst(Rol(Accumulator), ()); // rotate left: index * 2
b.inst(Rol(Accumulator), ()); // rotate left: index * 4
b.inst(Sta(ZeroPage), 0x20); // store index * 4 at 0x0020
b.inst(Rol(Accumulator), ()); // rotate left: index * 8
b.inst(Adc(ZeroPage), 0x20); // add to 0x0020: index * 12
b.inst(Tax, ()); // transfer accumulator to IndexRegisterX
// Store absolute X,Y coords of each tile by reading relative coordinates from shape table
// and adding the piece offset, storing the result in zero page 0x20..=0x27.
for i in 0..4 { // this is a rust loop - the assembly generated inside will be generated 4 times
b.inst(Lda(AbsoluteXIndexed), Addr(SHAPE_TABLE)); // read Y offset from shape table
b.inst(Clc, ()); // clear carry flag to prepare for addition
b.inst(Adc(ZeroPage), ZP_PIECE_COORD_Y); // add to Y coordinate of piece
b.inst(Sta(ZeroPage), 0x21 + (i 2)); // store the result in zero page
b.inst(Inx, ()); // increment IndexRegisterX to sprite index
b.inst(Inx, ()); // increment IndexRegisterX to X offset
b.inst(Lda(AbsoluteXIndexed), Addr(SHAPE_TABLE)); // read X offset from shape table
b.inst(Clc, ()); // clear carry flag to prepare for addition
b.inst(Adc(ZeroPage), ZP_PIECE_COORD_X); // add to X coordinate of piece
b.inst(Sta(ZeroPage), 0x20 + (i 2)); // store the result in zero page
b.inst(Inx, ()); // increment IndexRegisterX to next tile
}
Теперь о фактическом обнаружении столкновений! Неоднократно увеличивайте компонент Y каждой координаты плитки в адресах от 0x20 до 0x27, пока одна из плиток не столкнётся с зафиксированной плиткой или не выйдет за нижнюю часть поля. Изучая память с помощью mesen, я узнал, что состояние поля хранится в виде строкового массива индексов спрайтов, начинающихся с 0x0400, и что 0xEF — это индекс плитки «пустого пространства». Стратегия будет заключаться в использовании координаты каждой плитки для построения индекса в этом массиве и остановке, если будет найдено что-либо кроме 0xEF.
Возможная путаница в приведённом ниже коде заключается в том, что он реализует цикл в сборке, но есть также цикл for в rust, который генерирует сборку. Эти два цикла не связаны между собой. Сборка в цикле rust проходит 4 раза, и результат составляет тело цикла сборки.
const BOARD_TILES: Address = 0x0400;
const EMPTY_TILE: u8 = 0xEF;
const BOARD_HEIGHT: u8 = 20;
b.inst(Ldx(Immediate), 0); // Load 0 into IndexRegisterX - this will be our loop counter
b.label("start-ghost-depth-loop"); // This is a label - a target for branch instructions
for i in 0..4 { // the assembly in this rust loop will be emitted 4 times
// Increment the Y component of the coordinate
b.inst(Inc(ZeroPage), 0x21 + (i * 2));
// Break out of the loop if the tile is off the bottom of the board
b.inst(Lda(ZeroPage), 0x21 + (i * 2));
b.inst(Cmp(Immediate), BOARD_HEIGHT);
b.inst(Bpl, LabelRelativeOffset("end-ghost-depth-loop"));
// Multiply the Y component of the coordinate by 10 (the number of columns)
b.inst(Asl(Accumulator), ());
b.inst(Sta(ZeroPage), 0x28); // store Y * 2
b.inst(Asl(Accumulator), ());
b.inst(Asl(Accumulator), ()); // accumulator now contains Y * 8
b.inst(Clc, ());
b.inst(Adc(ZeroPage), 0x28); // accumulator now contains Y * 10
// Now add the X component to get the row-major index of the cell
b.inst(Adc(ZeroPage), 0x20 + (i * 2));
// Load the tile at that coordinate
b.inst(Tay, ());
b.inst(Lda(AbsoluteYIndexed), BOARD_TILES);
// Test whether the tile is empty, breaking out of the loop if it is not
b.inst(Cmp(Immediate), EMPTY_TILE);
b.inst(Bne, LabelRelativeOffset("end-ghost-depth-loop"));
}
// Increment counter and loop
b.inst(Inx, ());
b.inst(Jmp(Absolute), "start-ghost-depth-loop");
b.label("end-ghost-depth-loop");
Это приводит к тому, что IndexRegisterX содержит количество повторений цикла, которое также является вертикальным расстоянием от текущей фигуры до того места, где она окажется после падения. Для удобства эта функция вернёт результат через регистр аккумулятора:
// Return depth via accumulator
b.inst(Txa, ()); // transfer IndexRegisterX to accumulator
b.inst(Rts, ()); // return
Вот полный код замещающей функции обновления буфера DMA OAM:
b.label("oam-dma-buffer-update");
// Call original function first
b.inst(Jsr(Absolute), 0x8A0A);
// Compute distance from current piece to drop destination, placing result in accumulator
b.inst(Jsr(Absolute), "compute-hard-drop-distance");
// Check if the distance is 0, and skip rendering the ghost piece in this case
b.inst(Beq, LabelRelativeOffset("after-render-ghost-piece"));
// Render the ghost piece, passing the vertical offset argument in address 0x0028.
b.inst(Sta(ZeroPage), 0x28);
b.inst(Jsr(Absolute), "render-ghost-piece");
b.label("after-render-ghost-piece");
// Return
b.inst(Rts, ());
Результат:
Добавление контроллера для Hard Drop
Теперь, когда выполняется рендеринг призрачной фигуры, следующий шаг — сделать так, чтобы при нажатии кнопки «вверх» на контроллере происходило мгновенное падение. Кнопка «вверх» не используется в Тетрисе, поэтому нам не нужно беспокоиться о потере некоторых функций, чтобы получить hard drop.
Как и в случае с добавлением контуров фигуры, я решил найти функцию, которую я мог бы заменить новой функцией, которая вызывает оригинал перед выполнением дополнительных действий, — в этом случае проверяется, была ли нажата кнопка «вверх», и выполняется мгновенное падение фигуры, если да.
Моя первая попытка состояла в том, чтобы найти код, который считывает регистр состояния контроллера 0x4016, но, похоже, существует довольно много косвенных связей между чтением этого регистра и обновлением состояния игры на основе того, какие кнопки нажимаются.
Моя вторая идея заключалась в том, чтобы настроить мой эмулятор для логирования каждой выполненной инструкции. Я загрузил Тетрис и прошёлся по меню, чтобы начать новую игру, а затем сохранил файл состояния. У моего эмулятора есть возможность запускать определённое количество кадров. Я настроил его на работу в 20 кадров, загрузил файл состояния и записал каждую инструкцию, не нажимая никаких элементов управления. Затем я повторил этот процесс, но на этот раз я нажимал левую кнопку на протяжении 20 кадров. Теперь у меня было два лога потока инструкций — один без нажатых элементов управления, а второй — с их нажатием. Само собой разумеется, что в первую очередь эти потоки различаются, когда программа в первый раз разветвляется по состоянию левой кнопки.
Конечно же:
@@ -116912,9 +116912,175 @@
0x89B8 Lda(ZeroPage) 0xB5
0x89BA And(Immediate) 0x03
0x89BC Bne(Relative) 0x15
-0x89BE Lda(ZeroPage) 0xB6
-0x89C0 And(Immediate) 0x03
-0x89C2 Beq(Relative) 0x45
+0x89D3 Lda(Immediate) 0x00
+0x89D5 Sta(ZeroPage) 0x46
+0x89D7 Lda(ZeroPage) 0xB6
+0x89D9 And(Immediate) 0x01
+0x89DB Beq(Relative) 0x0F
...
Перекрёстная ссылка с дизассемблированным ПЗУ, эта функция начинается с:
0x89AE Lda(ZeroPage) 0x40
0x89B0 Sta(ZeroPage) 0xAE
0x89B2 Lda(ZeroPage) 0xB6
0x89B4 And(Immediate) 0x04
0x89B6 Bne(Relative) 0x51 (relative: 0x51, absolute: 0x8A09)
0x89B8 Lda(ZeroPage) 0xB5
0x89BA And(Immediate) 0x03
0x89BC Bne(Relative) 0x15 (relative: 0x15, absolute: 0x89D3)
0x89BE Lda(ZeroPage) 0xB6
0x89C0 And(Immediate) 0x03
0x89C2 Beq(Relative) 0x45 (relative: 0x45, absolute: 0x8A09)
...
Это ветвление на основе содержимого адресов 0x00B5 и 0x00B6. Во время наблюдения за этими адресами в mesen во время затирания элементов управления у меня создаётся впечатление, что 0xB5 хранит различия между кадрами в состоянии контроллера, а 0xB6 хранит текущее состояние контроллера. Несмотря на то что тетрис не использует её, состояние кнопки «вверх» отражается в этих значениях.
Я запустил эту функцию так же, как и мою замену для обновления буфера DMA OAM. Всё, что он сделал, — это вызвал исходную функцию и вернул:
b.label("handle-controls");
// Call the original function
b.inst(Jsr(Absolute), 0x89AE);
// Return
b.inst(Rts, ());
Теперь добавим проверку, нажата ли кнопка «вверх». А пока просто телепортируем текущую фигуры на фиксированную высоту при нажатии кнопки:
b.label("handle-controls");
const CONTROLLER_STATE: u8 = 0xB6;
const CONTROLLER_BIT_UP: u8 = 0x08;
// Call the original function
b.inst(Jsr(Absolute), 0x89AE);
// Skip to the end if the UP bit of the controller state is not set
b.inst(Lda(ZeroPage), CONTROLLER_STATE);
b.inst(And(Immediate), CONTROLLER_BIT_UP);
b.inst(Beq, LabelRelativeOffset("controller-end"));
// Set the current piece's Y coordinate to 7
b.inst(Lda(Immediate), 7);
b.inst(Sta(ZeroPage), ZP_PIECE_COORD_Y);
b.label("controller-end");
// Return
b.inst(Rts, ());
Вот код в действии, когда я несколько раз нажимаю «вверх»:
Затем замените тестовую константу 7 на фактическое положение, в котором деталь окажется после резкого падения. Используйте функцию compute-hard-drop-distance, которую мы написали для рендеринга призрачной части, а затем просто добавьте текущую позицию фигуры, чтобы получить абсолютную координату Y, в которой он окажется после падения:
b.label("handle-controls");
const CONTROLLER_STATE: u8 = 0xB6;
const CONTROLLER_BIT_UP: u8 = 0x08;
// Call the original function
b.inst(Jsr(Absolute), 0x89AE);
// Skip to the end if the UP bit of the controller state is not set
b.inst(Lda(ZeroPage), CONTROLLER_STATE);
b.inst(And(Immediate), CONTROLLER_BIT_UP);
b.inst(Beq, LabelRelativeOffset("controller-end"));
// Compute distance from current piece to drop destination, placing result in accumulator
b.inst(Jsr(Absolute), "compute-hard-drop-distance");
// Add the current piece's Y coordinate
b.inst(Clc, ());
b.inst(Adc(ZeroPage), ZP_PIECE_COORD_Y);
// Update the current piece's Y coordinate with the result
b.inst(Sta(ZeroPage), ZP_PIECE_COORD_Y);
b.label("controller-end");
// Return
b.inst(Rts, ());
Выглядит неплохо!
Однако имеется небольшая проблема со скоростью. Похоже, что игра ожидает окончания текущего «тика», прежде чем создать следующую фигуру. После hard drop’a текущий тик должен немедленно закончиться и следующая фигура должна появиться без задержки.
Глядя на память с помощью mesen, можно увидеть, что есть счётчик по адресу 0x0045, который ведёт отсчёт до некоторого числа, а затем сбрасывается на следующем тике (когда текущая фигура перемещается вниз сама по себе). Чтобы узнать больше, я заставил свой эмулятор записывать все инструкции и запускать игру в течение 13 тиков. Я выбрал 13, потому что казалось маловероятным, что они генерируются случайно.
Во время этого прогона таймер истёк бы 13 раз. Где-то в логах инструкций есть связанная инструкция, которая была выполнена ровно 13 раз. Давайте найдём!
Логи инструкций находится в файле с именем /tmp/log.txt:
cat /tmp/log.txt | sort | uniq --count | sort --numeric-sort
Мы сортируем инструкции по частоте. Просматривая те, которые были выполнены 13 раз, я заметил:
13 0x8958 Lda(Immediate) 0x00
13 0x895A Sta(ZeroPage) 0x45
Это кажется актуальным, потому что он взаимодействует с таймером по адресу 0x0045!
Обращение к дизассемблированному коду этой инструкции:
0x8980 Lda(ZeroPage) 0x45 # load the timer value
0x8982 Cmp(ZeroPage) 0xAF # compare with the value at 0x00AF
0x8984 Bpl(Relative) 0xD2 (relative: D2, absolute: 8958) # branch if it was higher
0x8986 Jmp(Absolute) 0x8972
0x8972 Rts(Implied)
0x8958 Lda(Immediate) 0x00 # load 0 into the accumulator
0x895A Sta(ZeroPage) 0x45 # store the accumulator (0) in the timer
Две последние инструкции устанавливают значение таймера в 0, и они выполняются ровно 13 раз. Единственный способ получить эти инструкции — через ветвь (0x8984), что означает, что условие ветвления выполняется только 13 раз — вероятно, один раз за такт. Таким образом, вероятное повествование состоит в том, что таймер увеличивается на единицу каждый кадр, а кадр, в котором он становится больше значения в 0xAF, отмечает конец текущего тика, в этот момент таймер сбрасывается, и текущая фигура перемещается вниз.
Наблюдаем за 0x00AF в mesen, и это, кажется, максимальное значение, которого достигает таймер в 0x0045. Кроме того, когда вы завершаете уровень, значение 0x00AF уменьшается, что ускоряет игру! Поэтому после hard drop просто установите значение таймера на значение 0x00AF:
b.label("handle-controls");
const CONTROLLER_STATE: u8 = 0xB6;
const CONTROLLER_BIT_UP: u8 = 0x08;
const TIMER: u8 = 0x45;
const TIMER_MAX: u8 = 0xAF;
// Call the original function
b.inst(Jsr(Absolute), 0x89AE);
// Skip to the end if the UP bit of the controller state is not set
b.inst(Lda(ZeroPage), CONTROLLER_STATE);
b.inst(And(Immediate), CONTROLLER_BIT_UP);
b.inst(Beq, LabelRelativeOffset("controller-end"));
// Compute distance from current piece to drop destination, placing result in accumulator
b.inst(Jsr(Absolute), "compute-hard-drop-distance");
// Add the current piece's Y coordinate
b.inst(Clc, ());
b.inst(Adc(ZeroPage), ZP_PIECE_COORD_Y);
// Update the current piece's Y coordinate with the result
b.inst(Sta(ZeroPage), ZP_PIECE_COORD_Y);
// Set the timer to its maximum value
b.inst(Lda(ZeroPage), TIMER);
b.inst(Sta(ZeroPage), TIMER_MAX);
b.label("controller-end");
// Return
b.inst(Rts, ());
Выглядит лучше, но всё равно есть большая задержка, если вы очень быстро опустите первую фигуру во время первого тика. Оказывается, первый тик занимает больше времени, чем все остальные тики. Глядя на память в mesen, я заметил, что значение 0x004E увеличивается во время первого тика. Для всех остальных тиков он установлен на 0. Установка его на 0 после появления hard drop’a решает проблему с синхронизацией.
b.label("handle-controls");
const CONTROLLER_STATE: u8 = 0xB6;
const CONTROLLER_BIT_UP: u8 = 0x08;
const TIMER: u8 = 0x45;
const TIMER_MAX: u8 = 0xAF;
const TIMER_FIRST_TICK: u8 = 0x4E;
// Call the original function
b.inst(Jsr(Absolute), 0x89AE);
// Skip to the end if the UP bit of the controller state is not set
b.inst(Lda(ZeroPage), CONTROLLER_STATE);
b.inst(And(Immediate), CONTROLLER_BIT_UP);
b.inst(Beq, LabelRelativeOffset("controller-end"));
// Compute distance from current piece to drop destination, placing result in accumulator
b.inst(Jsr(Absolute), "compute-hard-drop-distance");
// Add the current piece's Y coordinate
b.inst(Clc, ());
b.inst(Adc(ZeroPage), ZP_PIECE_COORD_Y);
// Update the current piece's Y coordinate with the result
b.inst(Sta(ZeroPage), ZP_PIECE_COORD_Y);
// Set the timer to its maximum value
b.inst(Lda(ZeroPage), TIMER);
b.inst(Sta(ZeroPage), TIMER_MAX);
// Clear the first tick timer
b.inst(Lda(Immediate), 0x00);
b.inst(Sta(ZeroPage), TIMER_FIRST_TICK);
b.label("controller-end");
// Return
b.inst(Rts, ());
Кажется, это работает!
Исходный код инструмента исправления доступен на github. Загрузите патч IPS, который применяет изменения, описанные в этом посте, здесь. Второй патч, который добавляет hard drop, но не визуализирует конечное положение фигуры, доступен здесь.
А если хотите создать свой игровой бестселлер, который, как и детище Алексея Пажитнова войдёт в историю — приходите к нам на курс «Разработчик игр на Unity», на котором мы рассказываем про все тонкости разработки игр.
Узнайте, как прокачаться в других специальностях или освоить их с нуля:
Другие профессии и курсы
ПРОФЕССИИ
КУРСЫ