Раз уж прошлая моя статья, к моему большому удивлению, вас заинтересовала. Я решил дополнить её результат, хакнутую версию игры "Contra (J) [T+Rus_Chronix]", небольшим функционалом, заодно показав "code injection" на NES. В этот раз я сделаю так, чтоб игроки начинали игру с прокачанным Spreadgun, для его получения в игре нужно подобрать иконку "S", а за ней "R".
Все заинтересовавшиеся welcome под кат.
Традиционно:
И традиционно распланируем последовательность действий.
- Найти нужные адреса
- Выяснить значение прокачанного Spreadgun
- Выяснить что пишет в эти адреса в начале игры
- Переписать ROM
- Easyway — поменять значение базового оружия на прокачанный спредган
- Hardway — использовать полноценную code injection, если легкий путь не получится
- сохранить результат в новый файл
Для поиска адресов используем ранее описанный метод, но помня что жизни оказались в соседних адресах будем искать оружие только у первого игрока в надежде, что второй игрок будет рядом. Открыв окно "Ram watch" после начала первого уровня ищем неизвестное значение. Я полагаю что базовое оружие имеет значение 0, но не знаю этого наверняка.
Бегаем по уровню, не забегая вперёд, стреляем во все стороны и отсеиваем меняющиеся значения. Оружие пока не менялось.
Давайте воспользуемся ещё одной опцией окна, а точнее в поле "Compare To/By" выделив радиоточку "Number of Changes" поставьте в поле 0. Тип сравнения конечно "Equals to". Оружие всё ещё не менялось.
Так перескакивая с варианта "равно предыдущему значению", на вариант "количество изменений равно 0" можно дойти до примерно 10000 адресов. Когда дальнейшие отсеивания будут сокращать список адресов слишком незначительно или не сокращать вовсе, можно выйти достаточно далеко вперёд, чтоб выбить первое оружие.
Подобрав его мы сразу используем метод поиска "не равно предыдущему значению", и дополнительно сократим список поиском "количество изменений равно 1", оружие поменялось ровно один раз.
В месте где мы подбираем наше первое оружие появляется ещё и усилитель оружия. Выбив и подобрав его можно сократить количество искомых адресов до 1, но если не получилось просто убейте вашего персонажа и таким образом поменяйте оружие ещё раз. (Не забывайте про поиск после каждого изменения).
С усилителем оружия есть некоторый риск того, что он поменяет не значение самого оружия, а флаг в другом месте в памяти, но будем надеяться что авторы игры экономили память и инструкции. И, мне повезло бонус "R" поменял само значение оружия и адрес оказался в координате AA16. (Я могу ошибаться, но я неоднократно мониторил оружие героя в разных версиях игры Contra, вроде везде этот адрес был AA16).
Ещё я обратил внимание, что бонус "R" увеличил значение в адресе на 1610 или 1016, то есть увеличил на 1 первую цифру шестнадцатеричного числа.
После рестарта в окне "Ram watch" видно что базовое значение действительно 0016, бонус "M" увеличил вторую цифру на 1, а бонус "R" первую.
Можно идти за спредганом, он точно встретится в этом уровне, а можно меняя значение адреса смотреть какие числа, какое оружие создают. Эмпирическим путём я выяснил что, 0116 это "Machinegun", 0216 это "Fire", 0316 это "Spreadgun", а 0416 это "Laser". При вводе других значений во вторую цифру возникают различные глюки.
Сбросив игру и введя в адрес значение 1x16, (где "x" любой из допустимых вариантов) перед подбором "Rapid" бонуса можно выяснить, что повторный подбор бонуса ничего не меняет.
Теперь можно перезапустить игру, начать игру за двоих и попробовать менять соседние с AA16 адреса. (Их два, поиск не будет долгим) Постреляв вторым игроком я очень быстро выяснил, что оружие второго игрока и правда хранится рядом в адресе AB16. И вот мы знаем интересующие нас адреса и значение которое следует туда положить, пора выяснить, что пишет в эти адреса.
Бросив брейкпоинт на запись этого адреса, я выяснил, что запись туда происходит несколько раз и один из них после заставки. Делает эту запись следующий код:
Address | Opcode | Mnemonic | Arguments | A | X |
---|---|---|---|---|---|
C307 | A2 28 | LDX | #$28 | ?? | ?? |
C309 | A9 00 | LDA | #$00 | ?? | 28 or 29 or… or F0 |
C30B | 95 00 | STA | $00,X | 00 | 28 or 29 or… or F0 |
C30D | E8 | INX | 00 | 28 or 29 or… or F0 | |
C30E | E0 F0 | CPX | #$F0 | 00 | 29 or 30 or… or F0 |
C310 | D0 F9 | BNE | $C30B | 00 | 29 or 30 or… or F0 |
Если внимательно прочитать игра здесь зануляет диапазон адресов с 002816 по 00F016, очевидно оба интересующих нас адреса в диапазоне. А значит лёгкого пути не будет. Придется использовать "Code Injection" и простейшее решение что я вижу выяснить откуда мы попадаем сюда, перенаправить выполнение в какое нибудь свободное от кода и данных место в памяти, написать там свою версию цикла зануляющую весь диапазон кроме адресов 00AA16 и 00AB16 и возвращающую каретку исполнения обратно. Кстати это наиболее классическая версия инъекции. Так-же можно предположить, что попадаем мы сюда из инструкции JSR(Jump to SubRoutine), это легко проверить с помощью стека.
В процессорах 6502 стек всегда расположен в диапазоне адресов 010016 — 01FF16 для всех компьютеров на базе данного процессора, и растёт от большего адреса к меньшему. Есть отдельный регистр указывающий на вершину стека, изначально он равен FF16 поскольку более значимый байт никогда не меняется. Сам регистр всегда указывает на самый высокий незанятый полезными данными байт.
Отладчик эмулятора не показывает значение регистра "Stack Pointer", вместо этого он показывает адрес на который регистр ссылается и прямо сейчас это адрес 01F216, нехитрые вычисления показывают, что последние попавшие в стек данные это, C316 и C216, что могло бы навести на мысль об адресе C3C216 но 6502 процессор типа "Little Endian" и потому при выполнении инструкций и сохранении адреса в памяти или стеке первым записывается менее значимый байт. И если на вершине стека действительно лежит адрес это адрес C2C316. И это адрес последнего аргумента инструкции JSR, если опять же это вообще адрес. Проверить очень легко, достаточно посмотреть что записано на два байта выше адреса C2C316.
Address | Opcode | Mnemonic | Arguments |
---|---|---|---|
C2C1 | 20 07 C3 | JSR | $C307 |
Как видите это инструкция JSR в адрес C30716, а значит предположение о субрутине верно.
Теперь нужно правильно написать код инъекции, найти для него подходящее место, записать его на это место и перенаправить инструкцию JSR на эту инъекцию.
Опкоды, инструкции, много информации про 6502 assembly.
Для этого очень удобно воспользоваться блокнотом, у меня Visual Studio Code за него. Стиль написания у всех свой, лично я первой пишу инструкцию JSR с её адресом, чтоб знать где менять и полным опкодом, чтоб знать что менять.
Через пару отступов я дублирую код цикла его уже можно использовать без адресов, но крайне полезно видеть мнемоники с аргументами помимо опкодов, и полезно прихватить следующую за всем этим циклом инструкцию, вместе с адресом, чтоб знать куда вернуться из инъекции.
C2C1:20 07 C3 JSR $C307
A2 28 LDX #$28
A9 00 LDA #$00
95 00 STA $00,X
E8 INX
E0 F0 CPX #$F0
D0 F9 BNE $C30B
C312:A2 07 LDX #$07
Ещё парой отступов ниже можно писать код самой инъекции, по сути это дубликат самого цикла с некоторыми дополнениями.
C312:A2 07 LDX #$07
A2 28 LDX #$28
A9 00 LDA #$00
95 00 STA $00,X
E8 INX
E0 AA CPX #$AA
D0 F9 BNE -7
A9 13 LDA #$13
95 00 STA $00,X
E8 INX
E0 AC CPX #$AC
D0 F9 BNE -7
A9 00 LDA #$00
95 00 STA $00,X
E8 INX
E0 F0 CPX #$F0
D0 F9 BNE -7
Для большей понятности я вместо адреса места прыжка указал отступ.
Здесь тот-же цикл, но разбитый на три части, в первом цикле мы сравниваем регистр X со значением AA16, поскольку нам нужно занулить все адреса до адреса 00AA16, после в регистр A кладём 1316, значение прокачанного спредгана и вторым циклом пишем его до адреса 00AC16 начиная с которого остаток диапазона следует опять занулить. Возвращаем в регистр A ноль и зануляем остаток диапазона.
Крайне важно в конце дописать инструкцию возврата из инъекции.
E0 F0 CPX #$F0
D0 F9 BNE -7
4C 12 C3 JMP $C312
Теперь для удобства я предпочитаю выписывать опкоды ниже в файле.
4C 12 C3 JMP $C312
A2 28 A9 00 95 00 E8 E0 AA D0 F9 A9 13 95 00 E8 E0 AC D0 F9 A9 00 95 00 E8 E0 F0 D0 F9 4C 12 C3
И подсчитав опкоды легко выяснить что их 3210. Следовательно нужно найти 2016 ничем не занятых адресов на РОМе. Как правило ничем не занятые адреса это большие пространства одинаковых значений, чаще всего нулей или FF16, Как раз такой большой кусок и нужно найти, он должен быть не меньше 3510 адресов, чтоб был некоторый запас.
В "Hex Editor" у меня такой диапазон нашёлся в B29E16-BFFF16. Использовать для инъекций начало подобных свободных участков может быть опасно, потому советую писать код инъекций в его конце. Наиболее удобный адрес для начала инъекции BFE016, но это адрес в памяти консоли, чтоб выяснить где он находится в РОМ файле нужно кликнуть по нему правой кнопкой мыши и выбрать пункт "Go Here In ROM File".
Теперь можно скопипастить сюда весь опкод (32 значения). Последний штрих поменять инструкцию
C2C1:20 07 C3 JSR $C307
на прыжок в адрес инъекции у меня это BFE016.
C2C1:20 E0 BF JSR $BFE0
Разумеется найдя истинное место инструкции на РОМе.
В стек попадает адрес инструкции JSR потому независимо от места прыжка там будет тот же адрес возврата, а из инъекции мы возвращаемся в код инструкцией JMP никак не влияющей на стек. Таким образом инструкция RTS срабатывает в том же месте что и без инъекции, и возвращает в то же место что и без инъекции. В итоге эта инъекция никак не ломает работу стека.
P.S. По хорошему нужно ещё сделать так чтоб после смерти персонажи возрождались с прокачанным спредганом, но уверен вооружённые полученными знаниями вы справитесь с этим самостоятельно. Я же обращу свой взор на что нибудь другое. Движок Unity например.
Комментарии (7)
Romhack
17.04.2019 17:09+1Эффект Даннинга — Крюгера какой-то.
но 6502 процессор типа «Big Endian»
Но 6502 — little-endian процессор.
То, что вы описываете уже во второй статье, никто не делает уже лет двадцать. Зачем вы учите новичков выписывать вручную опкоды и вставлять в ROM, когда FCEUX, не помню с какой версии, имеет прекрасный inline ассемблер?
Даже ознакомить новичка с отличным asm6 в разы полезнее, даже для нескольких байт кода.
Зачем вы вставляете куски своего кода там, где можно обойтись обычным байт-хаком.
Хочется спредган на старте и после смерти? Game Genie код XGKLPGIS (в ROM 0x1B451).
Хочется, чтобы пули при этом тоже были из спредгана? Game Genie код YOXUGSTO (в ROM 0x1b5bc). Всего два байта вместо вашего, с позволения сказать, injection.nesqvic Автор
17.04.2019 17:13Виноват, вечно путаюсь в названиях. Исправил.
Я скорее учусь сам и делюсь результатами, чем учу других.
tendium
18.04.2019 12:57+1Помню мне очень хотелось пройти Jurassic Park, но для меня она была адски сложна. Когда появились первые nes-образы на PC, я пытался её снова пройти, но не получалось пройти дальше, кажется, третьего уровня. Я гуглил читы, но если что-то и было, не сильно помогало.
И тогда — не зная как устроена архитектура дампа картриджа — я решил пойти на отчаянный поступок. Я сделал нечто такое, что с нами делает эволюция :D А именно, я решил попортить случайные байты в коде. Делал я это в попытках добиться бессмертия в игре. Игра переставала работать, и я начинал сначала. В итоге мне удалось сделать так, что игра — хоть и глючила — но персонаж отображался вечно мигающим (так, как будто его только что кто-то ранил и он временно не уязвим). В итоге мне осталось удалить путём перебора все неудачные «мутации». Оставшаяся «мутация» была закреплена путём успешного прохождения игры до финала. Она, кстати, оказалась не такой уж и длинной. Кстати, даже с миганием есть трудности прохождения, потому что мигающего персонажа все равно может затоптать «босс-динозавр». Если кому-то сильно интересно, могу попробовать отыскать дамп.
pproger
а вот было бы круто сделать хакнутую версию контры, где тебе бы давалась всего одна жизнь и устроить чемпионат с друзьями, кто дальше сможет пройти.
vadimm0s
Можно запустить игру в эмуляторе убить персонажа 2 раза, сохраниться, и для соревнований с друзьями загружать этот сейв.
bestie
Такое уже делалось для Павла Гринёва (ник Kinamania). О чём даже видео было у него на канале.
nesqvic Автор
Я вам больше скажу у кластера есть видео где он рассказывает как он это делал.