NES изнутри
                          [версия 23:19 22.09.2004]

     

Введение

Данный опус предназначается в первую очередь для тех, кто в совершенстве освоил азы перевода приставочных игрушек (в частности NES). Под азами я понимаю умение составлять таблицы символов, искать в ручную и Relative поиском тексты, править тайловые карты и прочая, прочая. То есть, это не столько мануал по переводу игрушек, сколько по продвинутому ромхакингу для NES. Подразумевается также, что человек, читающий этот документ, разбирается в командах ассемблера процессора М6502 и немного в программировании и алгоритмизации. Документ разбит на несколько частей, в соответствии с характером и направлением поиска и взлома. Из всех программ, которые нам понадобятся для работы, можно выделить лишь эмулятор FCEUltra, имеющий простой отладчик с минимальным, но, тем не менее, самодостаточным набором функций. Существует модифицированная версия FCEUltra от группы Dragon Eye Studios, занимающейся продвинутым ромхакингом, в ней переделан заново отладчик и добавлена функция просмотра таблицы тайлов, причем можно увидеть ее состояние во время прохождения луча через любую из строк растра. Недостатком является то, что модифицировалась очень старая версия, а отладчик потерял самое главное - просмотр памяти (в документации авторы постоянно пользуются дополнительными утилитами для сравнения сохраненных дампов, чтобы обнаружить какие-либо нужные им переменные, тогда как при наличии простого просмотрщика памяти в реальном времени, это можно сделать гораздо быстрее визуально). В настоящем документе в примерах я буду пользоваться только официальной версией FCEUltra. Все остальные утилиты: шестнадцатиричные редакторы с Relative поиском, графические редакторы, просмотрщики тайловых карт - в общем выполняют лишь вспомогательные функции и выбираются исключительно из личных предпочтений. Шестнадцатеричные числа в тексте даны в нотации языка Си, то есть с префиксом "0x". В HEX дампах все числа шестнадцатеричные по умолчанию, потому даются без каких-либо префиксов. Шестнадцатеричные числа в параметрах ассемблерных команд даются в нотации ассемблера M6502, то есть с префиксом "$".

1. Указатели

Для начала хочу напомнить, что NES умеет адресовать всего 64Кб памяти, а для хранения непосредственно игровых данных и кода используется всего лишь 32Кб (не считая расширений и прочих дополнений на картридже), хотя объем игровых программ может существенно превосходить оба этих значения. Данная проблема решается путем "банкования" ПЗУ, иначе говоря, большой объем памяти разбивается на блоки размером 32К или меньше (в зависимости от маппера) и при помощи специальных команд они сменяют друг друга в доступном адресном пространстве процессора. В результате, вся программа и ее данные оказываются разбиты на независимые блоки, которые при работе программы располагаются в одних и тех же адресах памяти, причем эти адреса совершенно не соответствуют реальному расположению кода или данных в ПЗУ. Далее. Биты в памяти расположены в соответствии с соглашением Intel, то есть, по меньшим адресам располагаются младшие биты чисел. То есть, двоичное число 0111011100110101b (0x7735) (в записи числа младший бит справа), будет располагаться в памяти так: Адрес: 0 1 2 3 4 5 6 7 8 9 A B C D E F Бит: 1 0 1 0 1 1 0 0 1 1 1 0 1 1 1 0 А так как память читается не битами, а байтами, и так же отображается в HEX редакторе, мы имеем дело не с расположением битов, а с расположением байтов в памяти, так что младший байт (т.е. младшие 8 бит 16-битного числа) лежит в памяти ПЕРЕД старшим байтом. Отсюда, число 0x7735 видно в HEX редакторе в таком виде: 00000ВВАА ?? 35 77 ?? ?? ?? Это и есть причина того, почему надо "переворачивать" байты HEX дампа для получения 16-битного числа. Имеется достаточно много описаний нахождения и пересчета указателей для различных систем и для NES в частности, но на мой взгляд, все они описывают процессы нахождения и пересчета, опираясь лишь на анализ голых данных, практически в полном отрыве от принципа работы приставки, с эмпирической стороны. Я хочу показать лишь, как все это в достаточно простой форме описывается с точки зрения программы, а не данных. Подразумевается, что смещение собственно самого ресурса в памяти известно, а нужно лишь найти указатель на него, с целью его изменения при пересчете или переносе ресурса в другое место памяти. Несколько предпосылок: - Практически никогда указатель на какой-либо ресурс (строка, спрайт, тайловая карта) не совпадает со смещением этого ресурса в ПЗУ файле (случай ПЗУ файлов размером 32Кб не рассматривается). - Практически невозможно по смещению ресурса в ПЗУ определить реальный его адрес в памяти в процессе выполнения программы (у смещения в файле и указателя могут совпадать только младшие биты адреса с учетом размера заголовка ПЗУ файла). - Банки, соседствующие в ПЗУ, могут находится в несмежных областях адресного пространства, и наоборот: банки, соседствующие в адресном пространстве процессора, могут находяться в совершенно разных местах ПЗУ. Из всего вышеизложенного вытекает несколько следствий: - Искать указатель для одиночного ресурса путем анализа данных ПЗУ файла практически нереальное и неблагодарное занятие. - Искать указатель по младшим битам адреса среди данных ПЗУ также неблагодарное занятие. - Таблицы указателей на определенные виды ресурсов могут находиться очень далеко от данных в адресном пространстве приставки, и не обязаны быть непосредственно возле данных в ПЗУ, даже в пределах одного сегмента данных размером в один банк. Обычно, основные процедуры работы с ресурсам и прочие служебные функции программ расположены в последних банках и в дальних областях адресного пространства, так как обычно они аппаратно закрепляются за последними банками ПЗУ и содержат обработчики прерываний и прочие системные функции. Как правило, таблицы указателей на ресурсы расположены непосредственно вблизи функций обработки ресурсов, или же они ссылаются на другие таблицы, расположенные непосредственно вместе с данными. Но данные и код обычно разнесены не только в ПЗУ, но и расположены совсем в разных банках. Именно потому указатели совершенно не обязательно должны находиться где-то рядом. Это правило в большинстве своем неверно только для строковых данных. Так как они, как правило, занимают сравнительно немного места и обычно располагаются в пределах всего одного банка данных. Именно указатели на строки чаще всего располагаются сразу перед блоками строк и образуют таблицы указателей. Их достаточно легко обнаружить визуально по упорядоченной однотипной структуре: в HEX редакторе это видно в виде чередующихся столбцов больших, медленно растущих чисел, и столбцов, хаотически заполненных числами, тем не менее, так же увеличивающихся в последовательности, но гораздо быстрее. 00012548 08 05 00 00 00 00 01 07 05 00 00 00 00 08 09 05 00012558 00[f7 a5 0a a6 12 a6 24 a6 32 a6 54 a6 5e a6 68 00012568 a6 72 a6 7d a6 8a a6 94 a6 9f a6 c0 a6 e8 a6 0e 00012578 a7 22 a7 38 a7 57 a7 6c a7 81 a7 98 a7 9e a7 aa 00012588 a7 b8 a7 c8 a7 d3 a7 da a7 f3 a7 3f a8 4e a8 5b 00012598 a8 67 a8 79 a8 93 a8 9f a8 ba a8 c1 a8 da a8 e8 000125A8 a8 f3 a8 00 a9 2d a9 4f a9 77 a9 91 a9 b1 a9 c2 000125B8 a9 e8 a9 f6 a9 19 aa 31 aa 40 aa ba aa ed aa fb 000125C8 aa 07 ab 11 ab 1d ab 2a ab 39 ab 47 ab 55 ab 63 000125D8 ab ad ab c4 ab cc ab e5 ab 1d ac 37 ac 51 ac 6a Большие, медленно растущие числа, это старшие байты указателей, они не могут быть меньше 0x80, так как любые данные ПЗУ файла располагаются в адресном пространстве 0x8000-0xFFFF (исключая случаи, когда картридж имеет дополнительное ОЗУ в нижней области адресов, которое может также быть окном для данных из ПЗУ), а следовательно, их старшие байты лежат в диапазоне 0x80-0xFF. Остальные байты соответственно - младшие. На примере четко видно, что старший байт адреса лежит в диапазоне приблизительно 0xA6-0xAA. Проследив, где данная последовательность обрывается, можно найти начало и конец таблицы указателей. Последовательность обрывается числом 0xA5 по смещению 0x0001255A. В соответствии с соглашением о расположении битов, можно точно сказать, что предыдущее число 0xF7 по смещению 0x00012559 является младшим байтом указателя. Таким образом, мы получили, что таблица указателей начинается указателем 0xA5F7 по смещению в ПЗУ файле 0x00012559. Таблица найдена. Найдя таблицу, гораздо проще проверить, является ли она таблицей указателей для искомого ресурса или блока строк по все тем же младшим битам адреса, 8 из которых (а вообще и все 12) у смещения в ПЗУ файле и указателя совпадают. Для упрощения задачи, рекомендую работать с чистым ПЗУ файлом, без заголовка, чтобы не надо было учитывать 16-байтовое смещение. Указатель на одиночный ресурс (при отсутствии видимых признаков таблиц указателей или при каких-либо затруднениях в их поиске) проще найти путем отладки кода, вместо поиска возможных указателей в ПЗУ файле по высчитанному значению младших битов адреса, так как существует большая вероятность того, что найденное вами число совершенно не относится к данному ресурсу, а сам указатель может содержаться лишь в операнде какой-либо ассемблерной команды, и просто напросто быть ненаходим визуально. Для того, чтобы найти какой-либо указатель, нужно найти команду, которая читает данные нашего ресурса. Среди кода, читающего этот ресурс, должен быть и код, который указывает, откуда ресурс должен быть считан, а имено - должен читать искомую нами таблицу указателей или просто должен его содержать в операндах. Младшие биты указателя наиболее эффективно можно использовать именно во время отладки. Зная их, можно поставить максимум 8 точек останова на чтение из адресов с различными старшими битами и отследить, какая из них сработает при выводе на экран искомого текста. Как правило, точка останова срабатывает на команде чтения данных из памяти, которая является командой с косвенной адресацией по содержимому ячейки памяти. Это команды с укорочеными операндами исключительно для работы с так называемой Zero Memory Area: LDA ($??),Y или LDA ($??),X. Где 0x?? - адрес первой из двух ячеек в Zero Memory Area, где храниnся 16-битное число, являющееся адресом читаемых данных, а X или Y - индексный регистр, значение которого складывается с числом в указанной ячейке памяти, и позволяет индексировать целый массив чисел максимальной длины 256 байт. Данная команда интересна нам тем, что мы узнаем адрес 0x??. Это сразу нам дает исходный указатель, но остается вероятность, что таких чисел в ПЗУ файле достаточно много, чтобы однозначно определить местоположение нужного. В таком случае необходимо поставить новую точку останова - на запись в наш адрес 0x??. В окрестностях кода, где произойдет останов можно найти другую команду чтения из памяти, которая однозначно укажет нам место в ПЗУ, откуда читается данный указатель - там же обычно расположена и таблица указателей, зачастую слишком маленькая, чтобы определить ее визуально. Теперь все, что остается сделать, это записать некоторое количество байт, начиная с найденного адреса таблицы указателей и далее, чтобы потом найти их непосредственно в ПЗУ для получения смещения этой таблицы в файле. Хочется заметить, что не всегда таблицы указателей состоят из готовых к употреблению адресов. Иногда, старший и младший байты адресов могут быть поменяны местами, они могут быть разнесены в разные массивы данных и прочее. В общем, имеется достаточно примеров, когда обнаружить указатели анализом данных в HEX редакторе не удается или достаточно сложно. Для примера я взял игрушку "Kirby's Adventure". Вы увидите, как можно найти нужный указатель, даже если он находится в нестандартной таблице. ИГРА: Kirby's Adventues ЗАДАЧА: Найти таблицу указателей на строки сообщений с подсказками, вызываемых нажатием кнопки START. ИСХОДНЫЕ ДАННЫЕ: Известно смещение первой такой строки ("Hit the Down Button...") от начала ПЗУ файла: 0x714B0. Для начала определим младшие 12 битов искомого указателя. Берем младшие 12 бит смещения в файле: 0x4B0, и отнимаем длину заголовка: 0x10. Получаем 0x4A0. Необходимо дождаться возможности в игре нажать кнопку START, чтобы получить подсказку (Сразу же после падения Kyrby на звезде к первой двери). Теперь нужно поставить 8 точек останова на чтение из всех возможных адресов с соответствующими младшими битами: 0x84A0, 0x94A0, 0xA4A0, 0xB4A0, 0xC4A0, 0xD4A0, 0xE4A0, 0xF4A0. По мере установки точек останова, некоторые из них могут сработать еще до нажатия кнопки START. Это ложные срабатывания при случайном попадании на исполняющийся кусок кода. Эти точки останова можно смело удалять. В нашем случае, срабатывают ложно 0x84A0 и 0xE4A0. Выбрасываем, остается всего 6 точек останова. Переключаемся в игру и нажимаем START. Первое срабатывание было на точке 0xC4A0: >C49F: C6 00 DEC $00 @ $0000 = $15 C4A1: D0 F9 BNE $C49C Нет команды чтения данных по сколь-нибудь похожему адресу, а сработала точка при исполнении участка кода с заданным адресом. Эта точка также не представляет для нас интереса, так что можно смело ее удалить. Сразу же после возобновления работы сработала точка останова 0xB4A0: >C54D: B1 16 LDA ($16),Y @ $B4A0 = $61 C54F: C8 INY Вот она наша заветная команда. Значит, наша искомая строка лежит в памяти по адресу 0xB4A0. Но регистр Y не равен нулю, следовательно, найденный адрес не является указателем на нашу строку, а лишь адресом одного из ее символов. По адресу 0x16 в памяти лежит младший байт искомого указателя, а его старший байт обязан быть в ячейке с адресом 0x17. Открываем окно просмотра памяти и находим два числа по заданным адресам: 0x92,0xB4. Наша строка начинается гораздо раньше, чем мы предполагали, и ее указатель имеет значение 0xB492. Но тем не менее, в ПЗУ файле нет таблицы, содержащей такой указатель. Существует вероятность, что найденный указатель также не является исходным, потому что зачастую, вместо оперирования одним лишь индексным регистром в команде (который позволяет адресовать лишь 256 байт данных), изменению подвергается и само значение адреса в памяти, а индексный регистр при этом чаще просто оставляют нулевым. Тогда, даже найдя этот адрес, мы не можем быть уверены, что он не изменен. Необходимо найти участок кода, который записывает в ячейки 0x16,0x17 нужные нам указатели и узнать, откуда он их считывает. Приступим. Первым делом нужно вернуться в исходное состояние (до нажатия START) в игре и установить точку останова на запись в одну из этих ячеек, например в 0x16. Но вот незадача, эта ячейка очень активно используется в фоновом режиме, и поймать момент, когда туда будет записан нужный нам адрес, довольно сложно, а вообще - практически нереально. Необходима небольшая трассировка кода. Для этого нужно снова задать точку останова на чтение нашей строки, то есть 0xB4A0 (или же 0xB492). Часть кода по адресу 0xC54D, где происходит останов, наиболее вероятно принадлежит функции вывода строки на печать (хотя в нашем случае, строки в Kirby, как часть тайловых карт, зажаты определенным алгоритмом, и найденная нами процедура является процедурой распаковки тайловой карты). Тем не менее, остановившись на адресе 0xC54D, нужно протрассировать несколько следующих команд: >C54D: B1 16 LDA ($16),Y @ $B4A0 = $61 C54F: C8 INY C550: D0 02 BNE $C554 C552: E5 17 INC $17 @ $0017 = $B4 C554: 60 RTS Судя по команде RTS, это некая подпрограмма считывания байта данных и установки указателя на следующий за ним байт. Трассируем до выхода из подпрограммы: С488: 20 4В C5 JSR $C54D >C48B: 20 62 C5 JSR $C562 C48E: C6 00 DEC $00 @ $0000 = $12 C490: D0 F6 BNE $C488 C492: C6 01 DEC $01 @ $0001 = $00 C494: 10 F2 BPL $C488 C496: 4C 46 C4 JMP C446 Видно, что этот участок кода является неким циклом, который выбирает поочередно байты данных, в частности, нашей строки. Оканчивается цикл безусловным переходом по адресу 0xC446. Нас будет интересовать именно этот адрес. Можно протрассировать цикл до его окончания, а можно установить точку останова на 0xC446, удалив предварительно все предыдущие. >C446: B1 16 LDA ($16),Y @ $B4B2 = $35 C448: C9 FF CMP #$FF C44A: D0 01 BNE $C44D C44C: 60 RTS C44D: 29 E0 AND #$E0 Какая удача. Опять чтение данных строки, но код далее является проверкой на некий символ, а именно: если считанный байт является 0xFF, исполняется команда RTS - выход из подпрограммы вывода (обработки) строки на экран! Символ 0xFF - является признаком конца строки! Поставим точку останова на адрес 0xC44C, и дадим программе до конца обработать текущую строку. А затем выйдем из этой подпрограммы туда, где она вызывается. Видим занимательный код: B1AA: B9 5C B4 LDA $B45C,Y @ $B4CD = $58 B1AD: BE 77 B4 LDX $B477,Y @ $B4E8 = $84 B1B0: 20 12 BF JSR $BF12 >B1B3: 20 BE C0 JSR $C0BE B1B6: EA NOP Подпрограмма по адресу $BF12 является функцией обработки строки, из которой мы только что вышли, а вот две команды перед ней представляют очень большой интерес: они считывают два числа из адресов 0xB45C и 0xB477 по смещению в индексном регистре и сохраняют их в регистрах A и X соответственно. Запомним адрес 0xB1B0, вернемся в исходное состояние в игре (до нажатия START) и поставим точку останова на запомненный адрес. Нажмем старт, и что мы видим? В регистрах A и X хранятся два числа 0x92 и 0xB4! Это и есть наш указатель. Войдем в подпрограмму и видим, что значения этих регистров записываются в ячейки 0x16 и 0x17 соответственно! Сомнений больше нет. Массивы данных по адресам 0xB45C и 0xB477 являются таблицами указателей на строки подсказок! Оказывается, они расположены непосредственно перед первым байтом первой строки, и в первом массиве хранятся все младшие байты указателей, а во втором - все старшие их байты. Байты с одинаковыми индексами из двух таблиц составляют нужный указатель. Несложно видеть, что длина каждого массива - 27 байт, следовательно имеется всего 27 подсказок... Итак, результаты: со смещения 0x714A2 в файле начинается первая строка подсказки, а по смещениям 0x7146C и 0x71487 соответственно находятся массивы младших и старших байтов указателей на строки в последующем блоке данных. Возможно, данный способ покажется кому-то слишком сложным, но он является наиболее эффективным, и позволяет практически со стопроцентным результатом обнаруживать любые строки и указатели. Пересчет найденных указателей сам по себе еще проще. Для пересчета нам не требуется вообще знать значение указателя. Вернее, он нам пригодится, но просто как число само по себе, не надо ломать голову, в каком месте памяти и банке он будет находиться и прочее. Хотя, все это справедливо лишь до тех пор, пока мы не захотим пересемтить наши данные совершенно в другое место, но в NES, как правило, места практически не остается, и о переносе данных в большинстве случаев речи не идет. Так как данные обычно располагаются последовательно друг за другом, необходимо знать лишь адрес первого из них, все же остальные адреса можно получить путем прибавление размера всех предыдущих блоков данных. Указатели, получающиеся путем сложения базового указателя не первый ресурс и общего размера всех предыдущих ресурсов записывается в очередную ячейку таблицы. Хотя можно просто знать, на сколько сместился новый ресурс по сравнению со старым и уменьшить или увеличить на соответствующее число оригинальный указатель.

2. Спрайты.

Для начала немного теории. Спрайтом здесь и далее мы будем называть одиночный, обрабатываемый графическим процессором объект, состоящий из одного (8x8) или двух (8x16) тайлов - в зависимости от режима. Изображения каких-либо объектов на экране (персонажей, летающих объектов и т.д.), составленных из нескольких "физических" спрайтов, мы не будем называть никак, потому что в данном опусе они не рассматриваются. Спрайтовая память в NES расположена в третьем (!) независимом адресном пространстве, кроме адресных пространств центрального процессора и модуля обработки изображения. Занимает она всего 256 байт и может хранить информацию всего лишь о 64 спрайтах экрана. Нетрудно посчитать, что на один спрайт приходится 4 байта информации. Эта информация включает в себя координаты спрайта на экране (2 байта), индекс тайла в тайловой таблице (1 байт) и дополнительные атрибуты цвета и параметры отображения (1 байт). Доступ к спрайтовой памяти может осуществляться двумя путями: через регистр ввода/вывода, аналогично записи/считыванию из видеопамяти, либо при помощи прямого доступа к памяти, иначе - DMA канала. Первый способ практически не применяется на практике, так как требует достаточно большого времени на выполнение. Практически всегда используется второй способ записи данных в спрайтовую память, а именно - через DMA. Особенность этого режима такова, что за одну операцию передачи обновляется сразу вся спрайтовая память, а именно - 256 байт. Вторая особенность в том, что данные для записи в спрайтовую память должны быть расположены в основном адресном пространстве, причем адрес их расположения должен быть обязательно кратен 256-ти. Ну и третья особенность в том, что передача данных в спрайтовую память может осуществляться только во время обратного вертикального хода луча, возвращающегося в изначальное положение на первую строку растра для начала формирования следующего кадра изображения. Все эти особенности практически раз и навсегда определили принцип работы со спрайтовой памятью. Так как спрайтовый буфер всегда должен быстро и часто изменяться, он располагается в основном ОЗУ приставки, то есть в первых двух килобайтах адресного пространства. Так как адресное пространство от 0x0000 до 0x00FF зарезервировано для использования в специальных версиях команд с укороченным операндом (операнд адреса имеет размер 8 бит, вместо 16-ти) и называется Zero Memory Area, оно не используется для спрайтового буфера. Также исторически сложилось, что последние несколько 256-килобайтных блоков ОЗУ тоже редко используются под буфер спрайтовых данных (но это скорее для удобства и красоты). Так что, наиболее вероятное расположение этого буфера нужно искать по адресам 0x0100, 0x0200, 0x0300. Спрайтовый буфер устанавливается в программе один раз и практически никогда не меняется (хотя мог бы). Далее, говоря о работе со спрайтовым буфером, мы будем подразумевать работу именно с буфером в ОЗУ, предназначенным для отправки в спрайтовое ОЗУ. Основной код программы постоянно в течение исполнения изменяет спрайтовую информацию в буфере, и только при наступлении обратного хода луча, эти данные отсылаются в спрайтовую память, а изменения отображаются на экране уже на следующем кадре. Также есть еще одна особенность отображения спрайтов: одну строку растра не может пересекать более чем 8 различных спрайтов. Приоритет спрайтов определяется их положением в спрайтовом ОЗУ. То есть, первыми всегда отображаются спрайты с меньшим индексом в спрайтовой памяти. Как только одну строку растра пересечет 8 спрайтов, все остальные спрайты, пересекающие эту строку, перестают отображаться. Это влечет просто к исчезновению чсти спрайтов, находящихся на экране, что приводит к сильной порче изображения в целом. Разные программисты решают эту проблему по-разному. Одни выверяют количество отображаемых на экране и на одной строке растра спрайтов с большой точностью, так что оно никогда не превысит допустимого значения. Если это практически невозможно, используется метод перемешивания. На каждом кадре вся спрайтовая память подвергается обработке, меняющей местами определенные спрайтовые записи, так что на каждом кадре меняется их приоритет и на каждом кадре будут исчезать разные спрайты. Хотя сами по себе спрайты исчезать не перестанут, исчезать они будут совершенно хаотично и с большой частотой, так что при частоте обновления не ниже 50 герц, это мерцание не так бросается в глаза. Теперь можно перейти непосредственно к проблеме. Некоторые надписи на экране зачастую выводятся при помощи спрайтов и найти такие надписи в ПЗУ бывает достаточно затруднительно. Есть вероятность, что индексы тайлов для таких надписей хранятся подряд, как обычные строки, и компилируются в спрайты в ходе выполнения программы. Но чаще бывает так, что эти строки хранятся в памяти в виде уже готовых спрайтов, которые непосредственно копируются в спрайтовый буфер, а значит, между двумя буквами обязательно будет промежуток, который не позволит найти данный текст обычным Relative поиском. Без каких-либо дополнительных утилит, которые могут выстраивать шестнадцитиричные данные в столбцы заданной ширины, а затем отображать символы в соответствии с таблицей кодировки, найти визуально такие спрайты достаточно трудно. Так как запись спрайта имеет размер 4 байта, то и индексы тайлов тоже располагаются на расстоянии 4 байтов друг от друга. Если выводить шестнадцатиричный дамп в столбце шириной 4 символа, то все строки будут расположены в дампе вертикально. Но даже расположение индексов тайлов в памяти в виде строк текста, компилируемых при выполнении программы в спрайты, не гарантирует их находимость Relative поиском. Дело в том, что очень часто используется режим тайлов размера 8x16. Фактически, количество тайлов, выводимых на экран в виде спрайтов, удваивается. Для тайлов используются обе тайловых таблицы, но каждый четный индекс тайла в спрайтовой памяти определяет один из 128-ми тайлов размера 8x16 в первой тайловой таблице, а каждый нечетный - один из 128-ми тайлов во второй тайловой таблице. Пусть вас не вводит в заблуждение нечентость индекса для второй таблицы. Нечетный индекс достаточно уменьшить на единицу, и мы получим индекс первого из пары тайлов во второй тайловой таблице. Итак, индексы одной и той же надписи бывают либо четными, либо нечтными, тогда как для Relative поиска необходимо, чтобы индексы в искомой таблице символов шли подряд. Ситуация, когда каждый четный символ алфавита был бы записан в первой таблице тайлов, а каждый нечетный - во второй, чтобы индексы тайлов следовали друг за другом, практически невозможна. Существует третий, самый сложный, а вообще - практически неразрешимый для ручного поиска среди данных случай. Это формирование спрайтов непосредственно из операндов ассемблерных команд. Как правило это такие места в программе, которые используются только один раз и только в каком-то конкретном месте. Для таких строк нет нужды отводить память и знать указатель на эту строку. Нужный индекс тайла или другие параметры заносятся в спрайтовую память всего лишь одной командой (как правило, это команда загрузки числа в регистр и последующая за ним команда записи значения регистра в ячейку памяти). Если для простых строковых данных можно совершенно спокойно обойтись без вникания в код программы и дизассемблирования, то для спрайтов - это, пожалуй, самый верный и единственный способ однозначного их нахождения. Прежде чем рассмотреть несколько примеров, несколько общих рекомендаций. Признаки для обнаружения спрайтового буфера в оперативной памяти были описаны ранее. Сделать это не составляет практически никакого труда, но, если возникают какие-то затруднения, их можно разрешить одним простым способом. Как уже говорилось, спрайтовый буфер переносится целиком в спрайтовую память путем DMA канала. Организуется эта операция предельно просто: в порт 0x4014 записывается восьмибитное число, являющееся старшими восьми битами шестнадцатибитного абсолютного адреса в адресном пространстве центрального процессора, где располагается спрайтовый буфер. Достаточно поставить точку останова на запись в ячейку 0x4014 в любое время во время исполнение программы, и определить, что за число туда пишется. Допустим, это будет 2, значит спрайтовый буфер располагается по адресу 0x200 в оперативной памяти. Дальнейший порядок действия следующий: дождаться необходимого места в программе, где выводится искомый спрайт, и попытаться найти индекс спрайта среди всех записей о спрайтах, находящихся в буфере. Из каждых четырех байт записи, первый и последний содержат кординаты на экране по горизонтали и вертикали соответственно. Второй байт является непосредственно индексом тайла, а третий - атрибутом цвета и параметрами спрайта. Далее необходимо поставить точку останова на адрес в спрайтовом буфере, где расположен искомый спрайт, принадлежащий надписи, и найти код, который в эту ячейку записывает данные. Там должен находится и код, который считывает эти данные из памяти, или же операнд, который присваивает им значение перед записью. Во многих случаях это оказывается совсем не сложно, но иногда возникают довольно сильные затруднения. Например, когда спрайтовая память непрерывно перемешивается. Приведу несколько примеров, чтобы охватит основные возможные варианты вывода текста спрайтами. Для иллюстрации простых шрифтов размера 8x8, содержащихся в операндах ассемблерных команд я возьму игру "Eliminator Boat Duel", наглядным примером конвертирования строк текста в спрайты для последующего вывода будет игра "Robocop 3", а для иллюстрации методов нахождения спрайтов при постоянном перемешивании спрайтового буфера и хранения строк в виде готовых спрайтов я возьму игру "Teenage Mutant Ninja Turtles - Tournament Fighters". Итак: ИГРА: Eliminator Boat Duel ЗАДАЧА: Найти смещения тайлов $ и К для записи сумм выигрышей, выводимых над головами соперников на экране награждения. ИСХОДНЫЕ ДАННЫЕ: Режим тайлов 8x8, для спрайтов используется первая таблица, индексы тайлов символов $ и К соответственно: 0x0A, 0x0B. Индексы тайлов чисел от 0 до 9. Загружаем игру в FCEUltra и первым делом ищем спрайтовый буфер. В заполненном состоянии этот буфер в данной игре можно спутать с просто данными, так как заполнен он достаточно нерегулярными числами, потому поставим первую точку останова на 0x4014 и посмотрим, какой адрес это нам даст. Останов происходит по адресу 0xc013 на команде STA $4014, значит в регистре A у нас хранится искомое число, а именно 2. Следовательно, буфер расположен по адресу 0x200. Проезжаем первую трассу, и выходим в отладчик на экране награждения. Включаем просмотр памяти по адресу 0x200. Нам необходимо найти два искомых индекса: 0x0A и 0x0B. По адресам 0x281 и 0x291 находятся два тайла с индексами 0x0A, далее, через два спрайта от каждого из адресов находятся индексы 0x0B. Индексы между ними являются значениями выигрыша. Отлично. Спрайты никуда не сдвигаются и не перемешиваются, так что ставим точку останова на запись в ячейки 0x281 и 0x291. Оба останова срабатывают по одному и тому же адресу: С58C: 86 1E STX $1E C58E: A6 13 LDX $13 @ $0013 = $80 >C590: 9D 01 02 STA $201,X @ $281 C593: A5 1E LDA $1E По всей видимости за запись разных спрайтов в память отвечает одна и таже подпрограмма. Но в этой подпрограмме нет самого главного: команды загрузки регистра A, который и содержит наш искомый тайловый индекс. Необходимо найти место, откуда эта подпрограмма вызывается. Трассируем до первого адреса после команды RTS: D058: A0 0A LDY #$0A D05A: 98 TYA D05B: A4 0E LDY $0E D05D: A6 0F LDX $0F D05F: 20 71 D0 JSR $D071 D062: A6 23 LDA $23 @ $0023 = $05 D064: 20 71 D0 JSR $D071 D067: A5 22 LDA $22 @ $0022 = $01 D069: 20 71 D0 JSR $D071 D06C: A6 0B LDA #$0B D06E: 4C 71 D0 JMP $D071 D071: 20 88 C5 JSR $C588 >D074: 8A TXA Этот кусок кода очень интересен, подпрограмма по адресу 0xD071 вызывается 4 раза, причем перед каждым вызовом в регистр А заносится число. А самое интересное, что первое число это 0x0A, последнее же - 0x0B. Два промежуточных числа совпадают с первой и второй цифрами в количестве выигрыша. Вот он, кусок программы, отвечающий за вывод на экран размера выигрышей. Теперь необходимо лишь найти смещения последовательностей кодов 0A-98-A4-0E и 0B-4C-71-D0 в нашем ПЗУ файле, и мы получим смещения этих тайловых индексов. Эти смещения будут соответственно 0x1D069 и 0x1D07D. ИГРА: Robocop 3 ЗАДАЧА: Найти смещения в ПЗУ надписей титульного меню. ИСХОДНЫЕ ДАННЫЕ: Режим тайлов 8x16, тайлы с алфавитом расположены во второй тайловой таблице. Как всегда начинаем с поиска спрайтового буфера. Без особых ухищрений он обнаруживается все по тому же знакомому адресу 0x200. Ждем появления титульного экрана. Наш поиск очень сильно облегчился из-за того, что надпись START то гаснет, то появляется, следовательно, с такой же частотой изменяются данные в спрайтовой памяти. Видно, что у пяти спрайтов, начиная с адреса 0x254 изменяется координата X. Тем самым, спрайт перемещается то за пределы экрана, то опять на нем появляется, чем и обеспечивается мерцание. Теперь посмотрим на тайловые индексы. Первые два индекса в надписи START будут 0x59 и 0x5B, а реальные индексы в таблице тайлов соответственно 0x58 и 0x5A и т.д. Поиск строки в ПЗУ не дает никаких результатов, как не давал и Relative поиск. Будм продолжать. Ставим точку останова на запись в ячейку памяти 0x255, где располагается индекс первого символа строки START. Оказывается, спрайтовая память обновляется только лишь при первом показе титульного экрана, значит нам необходимо дождаться его исчезновения и появления вновь. Ждем. Двойное срабатывание точки останова происходит тогда, когда экран переключается на таблицу рекордов - первый раз для очистки, второй раз для записи спрайтов в таблице рекордов. Пропускаем эти два срабатывания и ждем переключения обратно на титульный экран. Первое срабатывание перед его появлением также является просто очищением спрайтового буфера, но на втором уже можно заострить внимание: 999D: A5 40 LDA $40 999F: 29 3F AND #$3F 99A1: 18 CLC $0F 99A2: 7D D6 97 ADC $97D6,X @ $97D8 = $00 99A5: 2C CF 03 BIT $03CF @ $03CF = $80 99A8: 10 02 BPL $99AC 99AA: 69 34 ADC #$34 >99AC: 99 01 02 STA $201,Y @ $255 99AF: 68 PLA Как видно, индекс тайла спрайта вычисляется путем каких-то арифметических операций. Можно предположить, что по адресу 0x97D6 находится искомая нам строка, но число по этому адресу равно нулю, а индекс X=2 не меняется. Итак, судя по этому куску кода, число, с которым производятся арифметические операции, и которое, по видимому, является основой получающегося тайлового индекса, хранится по адресу 0x40. Не будем ставить точку останова на эту ячейку, а просто лишь пролистаем код немного назад. Вот, что там есть интересного: 9979: A2 00 LDX #$00 997B: A0 00 LDY #$00 997D: B1 52 LDA ($52),Y @ $A5FD = $A5 997F: F0 53 BEQ $99D4 9981: 85 40 STA $40 Вот оно. В ячейках с адресами 0x52, 0x53 хранится адрес в памяти нужной нам строки, а судя по виду операнда, все символы спрайтов находятся в памяти в виде последовательных строк текста. Итак, Адрес 0xA5FD - смещение первого символа последовательности байтов, которая в последствии станет надписью START. Проанализировав код, узнаем, что индексы тайлов получаются путем сложения значения младших 6 битов исходных байтов с числом 0x34. Старшие два бита используются для хранения атрибутов спрайта для каждой буквы. В конкретном случае, два старших бита всегда одинаковы, так что можно упростить вычисления, отняв от исходного байта число 0x4C. Теперь мы знаем, что в ПЗУ нужно искать индексы, увеличенные на 0x4C. И такие строки очень скоро находятся по смещениям: 0x165FD, 0x1663D, 0x1667D, 0x166BD. ИГРА: Teenage Mutant Ninja Turtles - Tournament Fighters ЗАДАЧА: Найти смещения в ПЗУ двух последних строк меню options. ИСХОДНЫЕ ДАННЫЕ: Режим тайлов 8x16, тайлы с алфавитом расположены во второй тайловой таблице. Запустим игру и зайдем в меню options. Спрайтовый буфер расположен по адресу 0x200 и постоянно изменяется. Следовательно, у спрайта надписи нет фиксированного адреса, и код потребует чуть большей трассировки, чем обычно. Но, одна вещи, будучи замеченной вовремя, может сильно облегчить задачу. Не все спрайты используются, неиспользуемые спрайты выводятся на пределы экрана, где они отсекаются. Для этого, в первый байт спрайта записывается число 0xF4, и только в те спрайты, что будут отображаться на экране, записываются другие значения. Поставив точку останова на любой из первых байтов любого спрайта, можно увидеть, что онасрабатывает в двух разных точках: D794: A9 F4 LDA #$F4 >D796: 9D 00 02 STA $0200,X @ $0200 и в: D810: 65 01 ADC $01 @ $0001 = $60 D812: 90 02 BCC $D816 D814: A9 F4 LDA #$F4 >D816: 9D 00 02 STA $0200,X @ $0200 Первая не представляет интереса, так как всегда пишет значение 0xF4, нам нужна именно вторая точка. Посмотрим дальнейший код: D819: C8 INY D81A: B1 08 LDA ($08),Y @ $8937 = $31 D81C: 05 12 ORA $12 @ $0012 = $00 D81E: 9D 01 02 STA $0201,X @ $0201 Похоже, что поиски УЖЕ практически закончились. Данный кусок кода читает какие-то данные и записывает их во второй байт спрайта, который содержит индекс тайла. Следовательно, в ячейках 0x08, 0x09 расположен адрес области памяти, где расположены индексы видимых на экране тайлов. Если мы посмотрим кусок памяти до и после этого адреса, мы обранужим, что индексы располагаются на расстоянии 4 байта друг от друга и являются нечетными, значит используется вторая тайловая таблица. Если уменьшить нечетный индекс на 1, то мы получим индекс первого из тайлов в паре 8x16. Судя по во второй тайловой таблице, надпись TIMER должна состоять из кодов 0x21, 0x23, 0x25, 0x27 (0x20, 0x22, 0x24, 0x26 индексы тайлов 8x8 соответственно). Запомним несколько байтов по адресу 0x8937 из памяти приставки, и найдем их смещение в ПЗУ файле. Теперь, если отобразить шестнадцатиричный дамп в области этого адреса столбцом в 4 байта шириной, надпись TIMER (в указанной последовательности байт) будет видна в горизонтальном столбце, начиная со смещения: 0x10929. Остальные надписи находятся там же и теперь легкообнаружимы. Пожалуй, это все основные методы и варианты поиска спрайтовых надписей, само собой, каждая игра имеет свои особенности, с которыми не всегда удается так же легко справляться. Поможет только знание принципов работы процессора и терпение при трассировке.

4. Сжатие.

В целях экономии места в ПЗУ многие игры используют сжатие различных данных. Наиболее распространено сжатие тайловых карт, на втором месте по частоте использования стоит сжатие тайблиц тайлов, и реже всего упаковывают тексты (все это применительно исключительно к NES). В зависимости от потребностей, игра может упаковывать и другие свои данные, но, как правило, алгоритмы бывают аналогичные алгоритмам упаковки в вышеизложенных случаях.

 

Designed with LAYOUT TECHNIQUES by www.glish.com/css