logo

Документация к FCEUd #3

  1. Разминка
    Перед тем как начать, лучше сразу понять, что нужно знать, чтобы полностью понять этот документ. Во-первых, нужно знать основы программирования на ассемблере. (Основы просты: нужно понимать каждую команду и режимы адресации команд). Так же нужно быть сведущим в двоичной системе счисления, включая и вычисления в ней. В общем, для того чтобы начать пользоваться FCEUd нужно не так много. Документы по ассемблеру процессоров семейства 6502 и разные доки можно найти на Zophar Domain и ROMHacking.net. Или просто используйте поиск со словами “6502 Assembly” в Гугле.
    То, что нам понадобится:
    • Лично я использую FCEUxdSP, однако можно также взять FCEUxd или FCEUd
    • РОМ игры Contra Force,
    • Разные доки, которые можно найти на: www.zophar.net/, www.obelisk.demon.co.uk/6502/reference.html, www.romhacking.net/
  2. Чуть-чуть о компрессии
    Рано или поздно переводчик напарывается на пожатые данные, которые нужно изменить (или часто просто не найдя, скажем, текста, принимает всё это за компрессию - это тема для отдельного разговора). Для переводчика наиболее актуально сжатие графики, текста и тайловых карт(последнее особенно распространено на NES). Пока CaH4e3 не разродился своей новой докой, которую мировая общественность ждёт уже несколько лет, я немного поведаю о наиболее распространённом случае сжатия в играх на Денди: сжатие тайловых карт методом RLE.
    Для начала о принципах работы над сжатыми данными: при любом алгоритме сжатия, в первую очередь, необходимо найти кусок кода, отвечающий за распаковку данных в PPU (просто графика будет распаковываться в одно адресное пространство PPU, а данные тайловых карт - в другое. Текст же на этом этапе вообще можно считать тайловой картой) отладчиком. Далее, в любом случае, нужно будет написать распаковщик данных, который проделывает то же самое, что и игра. Цель - получить файл, который уже можно отредактировать по усмотрению переводчика. Например распаковав шрифт, можно будет открыть его тайловым редактором и изменить его. Для тайловых карт можно воспользоваться программой Djinn’a “Djinn Tile Mapper”, ну а текстовые файлы и вовсе редактируются блокнотом или хекс редактором - всё зависит от того как вы написали распаковщик.
    Далее нужно вставить изменённые данные в РОМ. Тут два пути: первый - написать запаковщик и вставить данные на место старых. Этот путь сложен, так как запаковщик пишется намного сложнее распаковщика (так же как и в случае с написанием генератора паролей), а изменённые и запакованные данные не должны превышать по объёму старых. Второй метод - расширить РОМ, всунуть данные в разжатом виде в расширенный блок, а потом поменять процедуру загрузки данных, чтобы они читались из нового места. Метод практически не выполним вследствие особенностей железа NES. Для расширения РОМа потребуется ОЧЕНЬ много работы, в том числе и связанной с особенностями переключения банков мапперами в каждой отдельной игре и много много другой головной боли. В рунете лично я не встречал людей, которые расширяли РОМы NES. Поэтому, гораздо более реален первый вариант.

  3. Начинаем
    Итак, сегодня мы посмотрим как хранятся тайловые карты в играх серии Contra. Да-да, и в Contra, и в Super C, и даже в более продвинутой Contra Force тайловые карты не только хранятся одинаково, но и, что намного более удивительно, имеют абсолютно одинаковый и тот же код распаковки (с точностью до единой команды). Впрочем, похоже, это особенность всех игр от Konami.
    Если вы не знаете что такое тайловая карта, то стоит почитать документ gottaX’a на форуме Шедевра. Впрочем, без знания основ вы бы не стали читать документ по отладке, так? ;) Для начала, надо сказать, что тайловые карты именно в том виде как они должны быть(т.е. размером в $3C0 тайлов), вы действительно не сможете найти в РОМе.
    Вначале загрузим РОМ Contra Force в FCEUxdSP и найдём любой экран, например, титульник. В первую очередь нужно будет поставить останов на запись индекса тайла в область PPU, где расположена тайловая карта. Когда я был маленьким, таких замечательных отладчиков у нас не было. Нам тогда приходилось туго: надо было искать индекс нужного тайла, его расположение в карте, и, соответственно, адрес PPU сторонними средствами (например вездесущим Nesticle). Теперь же можно просто открыть просмотрщик тайловых карт (Tools-> Name Table Viewer…), навести курсор на интересующий нас тайл и сразу внизу увидеть адрес в PPU и индекс.
    Зрячие могут увидеть, что адреса начинаются с $2000. Почему не с начала? Откроем первоисточник - замечательную доку y0shi. Смотрим в главу, посвящённую PPU и видим, что адреса PPU с $0000-$0FF и с $1000-$1FFF отданы под два знакогенератора (почти наверняка, это CHR-ROM, причём уже распакованный, если в РОМе он запакован) Их можно увидеть просмотрщиком (Tools->PPU Viewer). Нас они не интересуют, так как мы не вскрываем графику. Далее $2000-$23BF идёт первая тайловая карта (экранная страница), потом её аттрибуты, затем $2400-$26BF - вторая тайловая карта(в нашем случае, правая) и её аттрибуты, затем ещё две карты. Нам нужна только первая (в любом случае она сверху-слева), т.к. именно сюда обычно и записываются данные. Третья- зеркало первой, а вторая и четвёртая и вовсе пустые.
    Если бы мы хотели узнать как упакован текст (если бы он был упакован), нужно было бы найти в карте текст, подвести курсор к любой букве, узнать её адрес в PPU, и поставить останов на запись в этот адрес. Поскольку мы интересуемся тайловой картой, то можно взять любой тайл. Ну, для определённости, возьмём нижнюю часть буквы ‘K’ в слове ‘KONAMI’(её индекс $FB и расположена она по адресу $20AA в PPU). Открываем отладчик клавишей F1 и жмём “ADD…”, чтобы добавить бряк (для тех, кто не знаком с отладчиком рекомендую прочитать первый документ из этого цикла). Вписываем в качестве адреса $20AA, ставим галочку на ‘write’, и не забываем, что мы интересуемся именно PPU, а не CPU или спрайтовой памятью (памяти PPU и спрайтов расположены отдельно от памяти CPU на двух разных чипах, и процессор не имеет к ним прямого доступа), поэтому тыкаем на ‘PPU mem’. Жмём ОК и перезагружаемся (F10). Важно перезагрузиться ещё до того, как игра перейдёт на вступительную сценку, т.к. в этом случае загрузится другая, не нужная нам тайловая карта. Итак мы нажали F10 вовремя и останов тут же сработална команде $D1F6:8D 07 20 STA $2007 = #$00
    В аккумуляторе ноль, поэтому это первоначальное заполнение тайловой карты. Нам нужно, чтобы записываемое число было равно $FB. Жмём ‘Run’ и натыкаемся на $D1DC:8D 07 20 STA $2007 = #$00
    На этот раз в Аккумуляторе наш заветный индекс! И сейчас мы как раз в середине кода распаковки тайловой карты. Немного поясню что это за команда STA $2007. Для тех, кто привык работать с памятью CPU (RAM), непривычно, что в адресе стоит адрес $2007. Ведь должно быть $20AA! Здесь всё дело в особенностях записи в PPU. Как было сказано ранее, память PPU расположена в отдельном адресном пространстве. Запись в это пространство производится так:
    • записывается верхний адрес в $2006
    • нижний - в $2006
    • записываются данные в $2007. Далее, адрес будет увеличиваться на 1 после каждой записи.

    Посмотрите в окошко с надписью PPU в отладчике: там адрес, в который сейчас записывается значение - $20AA. Это значит, что адрес увеличивался по единичке каждый раз после записи и, наконец, достиг значения $20AA - игра остановилась. Ясно, что нам-то интересно, как это значение получилось - тут всё гораздо проще и привычнее. Или оно было считано непосредственно из РОМа (тогда тайловая карта не пожата и о ней нечего и говорить), или оно было вначале как-то распаковано, а потом уже загружено в аккумулятор и командой STA $2007.
    Для новичка, такое объяснение, наверное, непонятно, но всё приходит с опытом. Пока что важно просто запомнить последовательность: нашли адрес в PPU, поставили останов, нашли место записи.

1.Сложными словами о простых вещах
Теперь осталось исследовать эту процедуру, что благодаря лёгкости алгоритма компрессии, довольно просто.

```
$D1AE:AD 02 20 LDA $2002
$D1B1:A0 01 LDY #$01
$D1B3:B1 00 LDA ($00),Y
$D1B5:8D 06 20 STA $2006
$D1B8:88 DEY
$D1B9:B1 00 LDA ($00),Y
$D1BB:8D 06 20 STA $2006
$D1BE:A2 00 LDX #$00
$D1C0:A9 02 LDA #$02
$D1C2:20 0B D2 JSR $D20B
$D1C5:A0 00 LDY #$00
$D1C7:B1 00 LDA ($00),Y
$D1C9:C9 FF CMP #$FF
$D1CB:F0 3B BEQ $D208
$D1CD:C9 7F CMP #$7F
$D1CF:F0 2F BEQ $D200
$D1D1:A8 TAY
$D1D2:10 1A BPL $D1EE
$D1D4:29 7F AND #$7F
$D1D6:85 02 STA $0002 = #$0D
$D1D8:A0 01 LDY #$01
$D1DA:B1 00 LDA ($00),Y @ $956A = #$FB
$D1DC:8D 07 20 STA $2007 = #$00 ; Тут произошёл останов.
$D1DF:C4 02 CPY $0002 = #$0D
$D1E1:F0 03 BEQ $D1E6 
$D1E3:C8 INY 
$D1E4:D0 F4 BNE $D1DA 
$D1E6:A9 01 LDA #$01 
$D1E8:18 CLC 
$D1E9:65 02 ADC $0002 
$D1EB:4C C2 D1 JMP $D1C2
$D1EE:A0 01 LDY #$01 
$D1F0:85 02 STA $0002 
$D1F2:B1 00 LDA ($00),Y 
$D1F4:A4 02 LDY $0002 
$D1F6:8D 07 20 STA $2007 
$D1F9:88 DEY 
$D1FA:D0 FA BNE $D1F6 
$D1FC:A9 02 LDA #$02 
$D1FE:D0 EB BNE $D1EB 
$D200:A9 01 LDA #$01 
$D202:20 0B D2 JSR $D20B 
$D205:4C AE D1 JMP $D1AE 
```

Я ограничил рассматриваемый кусок первыми попавшимися командами JMP сверху и снизу. Скорее всего, всё, что выше и ниже уже не представляет для нас никакого интереса. Обратите внимание, что я стёр знаки типа “= #$01” или “$956A = #$FB” выше и ниже точки останова, потому что они не соответствуют действительным и будут нас только путать (это связано с особенностями отладчика в FCEU, и отдельно об этом можно прочитать в первом документе). Итак, разберём всю процедуру более подробно:
$D1AE:AD 02 20 LDA $2002 $D1B1:A0 01 LDY #$01 $D1B3:B1 00 LDA ($00),Y Странно, не так-ли? Зачем загружать из $2002, если это значение всё равно нигде не сохраняется. Всё дело в том, что чтение/запись памяти PPU может производиться только в периоды VBlank. Для инициализации VBlank (ну и не только для этого) у PPU есть порт по адресу $2002 (регистр статуса PPU) Старший бит в котором есть ни что иное, как VBlank флаг. Если он поднят (1), то PPU генерирует вертикальный импульс (Vertical Blanking Impulse). Этот флаг скидывается в 0, когда VBlank заканчивается, или CPU читает из $2002. Соответственно только что программно был вызван VBlank чтением из $2002, очевидно, для записи нашей тайловой карты.
$D1B1:A0 01 LDY #$01 $D1B3:B1 00 LDA ($00),Y $D1B5:8D 06 20 STA $2006 $D1B8:88 DEY $D1B9:B1 00 LDA ($00),Y $D1BB:8D 06 20 STA $2006 Если вы внимательно читали до этого, то легко узнаете запись верхнего и нижнего адреса в PPU. А судя по режиму адресации,адреса будут загружаться из РОМа, причём указатели на них будут храниться в $00 и $01. При желании, можно легко найтии указатели, однако, думаю, это мало кому понадобится.
``` $D1BE:A2 00 LDX #$00 $D1C0:A9 02 LDA #$02 $D1C2:20 0B D2 JSR $D20B

... 
$D20B:18 CLC
$D20C:75 00 ADC $00,X @ $0000 = #$A2
$D20E:95 00 STA $00,X @ $0000 = #$A2
$D210:90 02 BCC $D214
$D212:F6 01 INC $01,X @ $0001 = #$A9
$D214:60 RTS 
```

Интересная подпрограмма, хотя и довольно стандартная. Итак, вначале мы загружаем в аккумулятор 2, Далее мы прибавляем к занчению нижнего адреса указателя это число и сохраняем. Двойка нужна для того, чтобы скомпенсировать два сохранения в $2006 и дальнейшую загрузку уже индексов в карту вести без учёта этих двух сохранений. Очевидно, команда по адресу $D1C0 будет выполняться только после очередной загрузки верхнего и нижнего адресов. Если вы не понимаете почему нижний адрес идёт раньше верхнего, то вам стоит прочитать документ CaH4e3a. Дальнейшая чехарда с Carry Flag нужна просто для того чтобы увеличить старший байт на единичку, если младший стал больше $FF.
$D1C5:A0 00 LDY #$00 $D1C7:B1 00 LDA ($00),Y $D1C9:C9 FF CMP #$FF $D1CB:F0 3B BEQ $D208 $D1CD:C9 7F CMP #$7F $D1CF:F0 2F BEQ $D200 Загружаем байт из РОМа (обратите внимание, что мы уже увеличили поинтер на два, поэтотому можем смело начинать от нуля (LDY #$00)). Идут два сравнения: с $FF и с $7F. Если байт равен $FF, то:
``` $D208:4C 70 FE JMP $FE70

<$FE70:> 
$FE70:A5 FF LDA $00FF
$FE72:8D 00 20 STA $2000 
```

В управляющий регистр PPU забисывается какое-то число. Нас это не волнует, потому как, очевидно, к нашей процедуре мы уже не вернёмся, а это означает, что байт $FF соответствует концу распаковки.
В случае же, если байт будет равен $7F, то
$D200:A9 01 LDA #$01 $D202:20 0B D2 JSR $D20B $D205:4C AE D1 JMP $D1AE Безусловный переход в самое начало нашей процедуры распаковки! То есть берутся новые адреса PPU и распаковка может продолжаться с нового места в PPU. В принципе, это могло бы оказаться полезным, если между надписями на тайловой карте большие пробелы из одинаковых знаков (если бы не происходило сброса старшего бита в $2002). Однако в Contra Force это нужно чтобы заполнять вторую (пока не нужную) тайловую карту нулями. Только перед этим мы добавляем к поинтеру уже не два, а единичку, т.к. мы загрузили один байт командой по адресу $D1C7. В принципе, нам наплевать на вторую тайловую карту, поэтому в нашей будущей программе можно тоже прекращать распаковку при встрече контрольного байта $7F. Итак, как мы видим, произошла загрузка контрольного байта и если он равен $7F, то нужно начинать распаковку в другое место экрана, а если он равен $FF, то распаковку надо заканчивать. Идём дальше:
$D1D1:A8 TAY $D1D2:10 1A BPL $D1EE $D1D4:29 7F AND #$7F $D1D6:85 02 STA $0002 $D1D8:A0 01 LDY #$01 $D1DA:B1 00 LDA ($00),Y @ $956A = #$FB $D1DC:8D 07 20 STA $2007 = #$00 $D1DF:C4 02 CPY $0002 = #$0D $D1E1:F0 03 BEQ $D1E6 $D1E3:C8 INY $D1E4:D0 F4 BNE $D1DA Вторая команда проверяет выставлен ли флаг знака (Negative) после предыдущей операции (TAY). Фактически, это означает, что если в аккумуляторе при выполнении TAY был байт с выставленным старшим битом, то ветвения не происходит. Если уж совсем просто, то если загруженный контрольный байт больше либо равен $80, то подпрограмма по адресу $D1EE не выполняется. Пока что предположим, что у нас старший байт больше $80. Далее, мы сбрасываем в ноль старший бит, очевидно, он больше нам не понадобится (если вы не знакомы с битовой логикой, то вам стоит почитать документ по FCEUd #2). И сохраняем получившееся значение в $0002. Далее, мы загружаем из РОМа следующий за контрольным байт. И (наконец-то!) выводим индекс в тайловую карту.Далее можно увидеть как используется остаток нашего контрольного байта. Очевидно, это простой счётчик, только в результате того, что его старший бит используется как некоторый флаг, этот счётчик по максимальному значению не может превышать $7F. Итак, если мы загрузили в тайловую карту меньше индексов, чем указано счётчиком, то мы переходим по адресу $D1DA, т.е. загружаем следующий байт из РОМа. Итак, байты считываются ПОСЛЕДОВАТЕЛЬНО.
А что же будет, если контрольный байт меньше $80?
$D1EE:A0 01 LDY #$01 $D1F0:85 02 STA $0002 $D1F2:B1 00 LDA ($00),Y $D1F4:A4 02 LDY $0002 $D1F6:8D 07 20 STA $2007 $D1F9:88 DEY $D1FA:D0 FA BNE $D1F6 Ну что же, примерно то же самое, за исключением того, что теперь наш счётчик показывает не сколько индексов нам прочитать из РОМа, а сколько раз нужно вписать в тайловую карту индекс, который идёт в РОМе сразу за контрольным байтом. Обратите внимание, что сбрасывать старший бит уже не нужно, так как у нас заведомо число меньше $80.
Ну и в итоге оба случая приходят к одному концу: если у нас контрольный байт был меньше $80:
$D1E6:A9 01 LDA #$01 $D1E8:18 CLC $D1E9:65 02 ADC $0002 $D1EB:4C C2 D1 JMP $D1C2 И далее прыгаем в $D20B. В результате поинтер у нас должен увеличиться ровно на количество считанных из РОМа байт и плюс единичка(нужно учесть контрольный байт, ведь в $0002 мы вписали только количество нужных индексов). А если был больше $80:
$D1FC:A9 02 LDA #$02 $D1FE:D0 EB BNE $D1EB Т.е. поинтер должен увеличиться ровно на два. Т.е. один контрольный байт плюс один байт РОМа, который всё время повторялся. После чего в любом случае мы придём в $D1C5, где произойдёт чтение следующего контрольного байта.
Уф-ф-ф… С процедурой разобрались.

  1. Что имеем?
    Ну, что же. Я не случайно назвал предыдущую главу “простыми вещами”, т.к. мы имеем дело с самым-самым простым алгоритмом в мире =). Если подытожить всё что я написал, то мы имеем вначале контрольный байт N. Если он больше \$80, то следующие за ним N-(\$80) байт будут уже распакованными и просто копируются. А если контрольный байт меньше \$80, то следующий за ним байт копируется N раз. Только и всего! Такой алгоритм называется RLE (Run-Length Encoding) и применяется в случаях, когда имеется много повторяющихся байтов. Если кому-нибудь придёт в голову пожать RLE файл с в основном не повторяющимися данными, то выйдет файл намного превышающий по размерам оригинал. Вообще говоря, у RLE может быть так много вариантов исполнения, что просто невозможно описать их все. Здесь описан один из возможных вариантов, однако в остальных играх алгоритм может быть (и даже, скорее, должен быть) несколько иным. RLE получило ОЧЕНЬ широкое распространение именно на NES. Фактически, если игра на NES имеет хоть какое-то сжатие, то первое на что вы должны подумать - RLE компрессия. У RLE есть свои особенности. Например, её очень легко обнаружить именно по непожатым байтам. Чаще всего, даже переведя, скажем, надпись в составе тайловой карты, пожатой RLE, начинающий переводчик даже не подозревает о том, что данные как-то вообще упакованы. Просто потому что надпись по определению не имеет больше двух повторяющихся букв. Зато когда хочется изменить длину надписи, наступают проблемы.
    Ну, на примере той же Contra Force: возьмём экран выбора героев. Имя IRON русским транслитом никак не влезет в четыре буквы. Поэтому очень часто встречаешь переводы, в которых мощный мужик с гранатомётом на плече обозван еврейским именем АРОН =). Чтобы это исправить нужно сделать не так уж и много.
    Для начала увеличим длину имени и перепишем байты, отвечающие за пожатые пробелы между именами. Однако для того, чтобы новая карта влезла в сам РОМ, нужно ещё хотя бы на столько же уменьшить какой-нибудь не пожатый кусок. А этим куском будет BEANS, который по-русски пишется как БИНС. Последнюю букву используем для того чтобы устранить появившееся смещение байт в карте.
    Для сжатия текста RLE не применяется. Другое дело - графика. Тут уже без отладчика никуда не денешься. В тайловом редакторе пожатую RLE графику увидеть практически невозможно. Да и просто в хекс редакторе графику не отредактируешь. Обязательно придётся писать распаковщик, редактировать её в редакторе и писать упаковщик.
    Для примера возьмём Dick Tracy: вся графика, кроме основного шрифта здесь пожата RLE. Т.е. в РОМе вообще ничего увидеть невозможно, кроме…
    RLEdt
    Обратите внимание на надпись OR и цифры 2,3,4,5,6. Они не пожались RLE, т.к. состоят из разных байтов. Буквы DO, составлявших слово DOOR, прекрасно зажались и теперь не отображаются, а вторая половина слова не пожалась. Так же как и единичка и семёрка состоящие из одинаковых байт также хорошо зажались, а всё остальное - нет. Это просто небольшая специфика RLE, по которой можно при случае узнать процедуру без отладки.

  2. Распаковщик
    Написание распаковщика - самое весёлое занятие во всём процессе перевода. Потому что он, как правило проще упаковщика в несколько раз, и его образец фактически уже дан самой игрой. Немного порывшись в закромах, я нашёл простецкий распаковщик на Pascal’e, который не использует память, зато использует Label’ы, что большой грех =). Но, думаю, для примера так будет понятнее.

    seek(inp,$169b4) ;// адрес начала тайловой карты в РОМе  
    
    i:=0;                                                    
    beg:                                                     
      if i<= msize then 
      begin // msize:=$3c0                   
        read(rom,x); 
        i:=i+1;                                     
        if x>=$80 then 
        begin                                     
          for y:= x-$80 downto 1 do 
          begin                          
            read(rom,buf);                                           
            i:=i+1;                                                  
            write(output,buf);                                       
          end;                                                     
        end                                                      
      else 
      begin                                               
        read(rom,buf);i:=i+1;                                    
        for z:=x downto 1 do                                     
        write(output,buf);                                       
      end;                                                     
    goto beg;                                                
    end;                                                     

    Обратите внимание, что я не использовал сравнение контрольных байтов с $7F и $FF, так как я учёл максимально возможный размер распакованной карты (razmer). В принципе, писать распаковщик не обязательно - вы всё равно получите те же данные, что игра распаковала в PPU (а содержимое памяти PPU можно в любой момент сдампить). Однако правильно написанный распаковщик говорит нам о том, что мы правильно поняли алгоритм распаковки со всеми его ньюансами. А значит есть шанс написать правильный алгоритм запаковки. Вот тут обычно начинаются самые большие проблемы. Как правило сообразить как будет выглядеть программа запаковщик не так сложно (хотя для RLE она не самая простая), намного сложнее - засунуть заново пережатые данные обратно в РОМ.
    Следует всегда помнить, что чем легче алгоритм распаковки, тем хуже он сжимает. Это особенно касается RLE. Иногда кажется, что проще переписать сам алгоритм распаковки в игре на более эффективный, чем всунуть все необходимые данные. Ну что же, в этом деле может помочь только практика, практика и ещё раз практика.

  3. Заключение
    Может возникнуть вопрос: зачем же столько мороки ради простейшего алгоритма?
    Во-первых, не всегда можно рассчитывать, что процедура распаковки будет такой же лёгкой (вспомнить хотя бы Kirby’s Adventure, пакер к которой не смог написать сам CaH4e3!). Поэтому я так подробно расписал весь процесс.
    А во-вторых, мне попутно удалось немного поведать о принципах работы PPU, а это дорогого стоит ;). В прочем, если кто захо чет узнать о железе NES, сможет найти много полезных документов как на romhacking.net так и на tv-games.ru