Чуть-чуть о компрессии
Рано или поздно переводчик напарывается на пожатые данные, которые нужно изменить (или часто просто не найдя, скажем, текста, принимает всё это за компрессию - это тема для отдельного разговора). Для переводчика наиболее актуально сжатие графики, текста и тайловых карт(последнее особенно распространено на NES). Пока CaH4e3 не разродился своей новой докой, которую мировая общественность ждёт уже несколько лет, я немного поведаю о наиболее распространённом случае сжатия в играх на Денди: сжатие тайловых карт методом RLE.
Для начала о принципах работы над сжатыми данными: при любом алгоритме сжатия, в первую очередь, необходимо найти кусок кода, отвечающий за распаковку данных в PPU (просто графика будет распаковываться в одно адресное пространство PPU, а данные тайловых карт - в другое. Текст же на этом этапе вообще можно считать тайловой картой) отладчиком. Далее, в любом случае, нужно будет написать распаковщик данных, который проделывает то же самое, что и игра. Цель - получить файл, который уже можно отредактировать по усмотрению переводчика. Например распаковав шрифт, можно будет открыть его тайловым редактором и изменить его. Для тайловых карт можно воспользоваться программой Djinn’a “Djinn Tile Mapper”, ну а текстовые файлы и вовсе редактируются блокнотом или хекс редактором - всё зависит от того как вы написали распаковщик.
Далее нужно вставить изменённые данные в РОМ. Тут два пути: первый - написать запаковщик и вставить данные на место старых. Этот путь сложен, так как запаковщик пишется намного сложнее распаковщика (так же как и в случае с написанием генератора паролей), а изменённые и запакованные данные не должны превышать по объёму старых. Второй метод - расширить РОМ, всунуть данные в разжатом виде в расширенный блок, а потом поменять процедуру загрузки данных, чтобы они читались из нового места. Метод практически не выполним вследствие особенностей железа NES. Для расширения РОМа потребуется ОЧЕНЬ много работы, в том числе и связанной с особенностями переключения банков мапперами в каждой отдельной игре и много много другой головной боли. В рунете лично я не встречал людей, которые расширяли РОМы NES. Поэтому, гораздо более реален первый вариант.
$D1F6:8D 07 20 STA $2007 = #$00
$D1DC:8D 07 20 STA $2007 = #$00
Посмотрите в окошко с надписью 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, где произойдёт чтение следующего контрольного байта.
Уф-ф-ф… С процедурой разобрались.
Что имеем?
Ну, что же. Я не случайно назвал предыдущую главу “простыми вещами”, т.к. мы имеем дело с самым-самым простым алгоритмом в мире =). Если подытожить всё что я написал, то мы имеем вначале контрольный байт 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. Т.е. в РОМе вообще ничего увидеть невозможно, кроме…
Обратите внимание на надпись OR и цифры 2,3,4,5,6. Они не пожались RLE, т.к. состоят из разных байтов. Буквы DO, составлявших слово DOOR, прекрасно зажались и теперь не отображаются, а вторая половина слова не пожалась. Так же как и единичка и семёрка состоящие из одинаковых байт также хорошо зажались, а всё остальное - нет. Это просто небольшая специфика RLE, по которой можно при случае узнать процедуру без отладки.
Распаковщик
Написание распаковщика - самое весёлое занятие во всём процессе перевода. Потому что он, как правило проще упаковщика в несколько раз, и его образец фактически уже дан самой игрой. Немного порывшись в закромах, я нашёл простецкий распаковщик на 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. Иногда кажется, что проще переписать сам алгоритм распаковки в игре на более эффективный, чем всунуть все необходимые данные. Ну что же, в этом деле может помочь только практика, практика и ещё раз практика.
Заключение
Может возникнуть вопрос: зачем же столько мороки ради простейшего алгоритма?
Во-первых, не всегда можно рассчитывать, что процедура распаковки будет такой же лёгкой (вспомнить хотя бы Kirby’s Adventure, пакер к которой не смог написать сам CaH4e3!). Поэтому я так подробно расписал весь процесс.
А во-вторых, мне попутно удалось немного поведать о принципах работы PPU, а это дорогого стоит ;). В прочем, если кто захо чет узнать о железе NES, сможет найти много полезных документов как на romhacking.net так и на tv-games.ru