Всем привет! Была у меня лет в 13-14 игра. Называется она Castle Excellent - превосходный замок. Превосходство этого замка заключалось в 100 комнатах-головоломках, пройдя которые нужно было найти принцессу. Как же я хотел посмотреть на все комнаты сразу! В Денди не было ни возможности сохранений, ни возможности что-то там достать и посмотреть. Но вот теперь, проведя реверс инжиниринг этой игры, и переводя ассемблер 6502 в человекочитаемый код у меня это наконец-то получилось!
Игру эту написала японская ASCII Corporation. Которая к американскому институту стандартизации вообще никакого отношения не имеет. Я в те годы как раз только учился программировать про ASCII кодировку, и почему в DOS нет русских символов прекрасно знал. Однако, японская контора была до определенного времени представительством самой Microsoft в Японии.
Замок этот действительно превосходный, и не только потому, что это одна большая головоломка, но и потому, как организован код и данные в ней. Вот об этом и будет статья. Это и дань уважения тем программистам, которые делали такие шедевры, фактически имея всего лишь 64 килобайта памяти и простой 8-ми битный процессор, который имел всего-то 3 регистра, даже не умел умножать и делить и мог только такой объем памяти адресовать.
Игры я исследую уже не первый раз. Обычно дело заканчивалось распаковкой заднего фона. Самую первую попытку я сделал в далеком 2005 году. Тогда еще регистрация на хабр была по приглашению. Написать какую-то статью мне очень хотелось. Но я не мог ничего придумать. Была у меня идея рассказать про распаковку уровней в игре Adventure Island. Но пока я дейст��ительно разобрался, как там всё устроено - прошло еще 2 года. Я столкнулся с тем, с чем никогда не сталкивался в обычной жизни. Там в стек в зависимости от байта помещался указатель возврата и когда происходил возврат из функции, тебя выкидывало в произвольное место кода... Это очень сильно дурило голову.... Позже я понял, что в nes это тот самый вызов функции по указателю! Вот так оформленный. Но эта тема другой статьи.
Проблема была в том, что материалов о том, как распаковывать уровни и как они устроены не было. Попалось мне пара статей, где автор рассказывал про распаковку Contra Force от Konami и на этом всё. Да, я тогда попробовал распаковать контру - получилось. Супер контру - тоже получилось. Они действительно запакованы одним алгоритмом и даже адреса функций одинаковые. Интересно, так работал компилятор или они их привязывали к адресам какими-то директивами?
От полного понимания ситуации меня останавливал тот факт, что NES имел жесткое ограничение - не более 8 спрайтов на строку. Иначе все последующие спрайты он просто не отображал. Чтобы это преодолеть, программисты постоянно перемешивали спрайтовый буфер! Поэтому, если ставить точку останова на один и тот же адрес, каждый раз там будут разные данные. Так и с этой игрой. Но здесь мне повезло и я смог понять, откуда и чего берётся.
Я не буду вдаваться в технические детали организации памяти в nes, мануалов действительно много. Буду касаться их только по мере необходимости.
Весь реверс инжиниринг nes игр сводится к простым вещам: найти нужные байты, найти код который записывает их в это место памяти и понять, откуда он их берет.
Распаковку уровня сильно облегчает тот факт, что все номера тайлов (я буду называть их так - блоки 8 на 8 пикселей; под спрайтами я буду понимать тайлы из первой половины знакогенератора) хранятся в 2 таблицах имен (name table), которые расположены с адреса PPU $2000. Размеры этих таблиц фиксированы: 32 в ширину и 30 в высоту. Адреса, где хранятся сами тайлы тоже фиксированы:$0000-$0FFF - знакогенератор спрайтов$1000-$1FFF - знакогенератор заднего фона
Давайте сначала посмотрим на саму игру.

Мы видим, что сам уровень смещен, относительно начала экрана. Значит, запись нам надо отслеживать не с адреса $2000, а с какого-то другого.
Откроем Name Table Viewer и найдем это место.

Значит, нам нужно искать запись в адрес PPU с номером $20E4. Ставим точку остановка на этот адрес и попадаем вот сюда:
01:C8BC: A9 20 LDA #$20 ;старшая половинка адреса
01:C8BE: 8D 06 20 STA PPU_ADDRESS ;сохранение в $2006
01:C8C1: A9 E4 LDA #$E4 ;младшая половинка адреса
01:C8C3: 8D 06 20 STA PPU_ADDRESS ;сохранение в $2006
01:C8C6: AE 00 04 LDX $0400 ;загрузка в регистр X адреса, откуда читаются данные
01:C8C9: BD 30 FF LDA $FF30,X ;загрузка номера тайла по адресу $FF30+X
>01:C8CC: 8D 07 20 STA PPU_DATA ;сохранение в $2007
01:C8CF: BD 94 FF LDA $FF94,X ;загрузка номера тайла по адресу $FF94+X
01:C8D2: 8D 07 20 STA PPU_DATA ;сохранение в $2007
Как данные пишутся в PPU? Сначала 2 половинки адреса в порт $2006, а потом сами данные в $2007. При этом, в зависимости от 2 бита регистра статуса PPU по адресу $2000 в адресном пространстве CPU, видеопроцессор делает инкремент на 1 или 32 байта.
В данном случае инкремент происходит на 1.
Что видно из кода? Оказывается, байт с адресом 0x400 читается дважды! Дальше байты из адресов $401, $402... пишутся последовательно. Причем без смены адреса так читается 14 байт.
Следующий адрес для записи в PPU будет $24E0. Байты для записи по этому адресу читаются из ячеек памяти $40E-$413. После запись начинается с новой строки.
Значит исходный буфер имеет ширину по X = 20.
Если прокрутить весь этот код (примерно 2300 строк), то можно увидеть, что источник адреса для записи в таблицу имён задается каждый раз! Начиная от $400 и заканчивая $058F.
01:C8C6: AE 00 04 LDX $0400
...
01:C8D5: AE 01 04 LDX $0401
01:C8D8: BD 30 FF LDA $FF30,X ; опять тот же начальный адрес для первой половинки
01:C8DB: 8D 07 20 STA PPU_DATA
01:C8DE: BD 94 FF LDA $FF94,X ; а теперь для второй
01:C8E1: 8D 07 20 STA PPU_DATA
01:C8E4: AE 02 04 LDX $0402
...
...
01:E1E6: AE 8F 05 LDX $058F
То есть они 400 раз (0x58F-0x400=399) захардкодили исходный байт для чтения. Зачем? Почему бы это не сделать в цикле, используя регистр Y? Я подозреваю, что дело в оптимизации. Была такая техника - развертка циклов. Вот её здесь и применили. Хотя, копипаст, конечно.
Значит, уровень имеет размер 20 на 20 клеток. Причем по X клетки пишутся по 2 раза начиная с адресов 0xFF30+x и 0xFF94+x. А по Y - построчно.
Теперь следующий этап. Нам нужно понять, а какой код пишет в диапазон адресов $400-$58F. Ставим точку останова
> 01:E8C6: 91 29 STA ($29),Y @ $0400 = #$53 ;запись байта в выходной поток
И мы находимся в самом сердце процедуры распаковки. Вот она вся:
01:E830: 20 58 81 JSR $8158 ;установка адресов таблицы имен для фона в $2000 для спрайтов $0000
01:E833: 20 64 81 JSR $8164 ;отключение рендеринга
01:E836: A0 02 LDY #$02
01:E838: 20 75 81 JSR $8175 ; переключение на банк 2
01:E83B: E0 43 CPX #$43 ; x здесь - номер загружаемой комнаты от 0..99
01:E83D: 90 05 BCC $E844 ; если x < $#43 переходим на E844
01:E83F: A0 03 LDY #$03
01:E841: 20 75 81 JSR $8175 ; переключение на банк 3
01:E844: 8A TXA
01:E845: 0A ASL ; (номер комнаты - 1) * 2
01:E846: AA TAX
01:E847: BD 1C E7 LDA $E71C,X ; загрузка адреса PPU - $21-$22
01:E84A: 85 21 STA $21
01:E84C: BD 1D E7 LDA $E71D,X
01:E84F: 85 22 STA $22
01:E851: A9 00 LDA #$00
01:E853: 85 16 STA $16
01:E855: 85 25 STA $25 ; обнуление счетчика
01:E857: 85 26 STA $26 ; распакованых байт
01:E859: A2 00 LDX #$00
01:E85B: 20 18 E8 JSR $E818 ; подготовка буфера [$0702..$0711]
*01:E85E: A9 E4 LDA #$E4 ; начало пожатых данных $E45E ; сюда переходит после записи байта в выходной поток
01:E860: 85 24 STA $24
01:E862: A9 5D LDA #$5D
01:E864: 85 23 STA $23
*01:E866: A0 00 LDY #$00
01:E868: B1 23 LDA ($23),Y
01:E86A: 85 19 STA $19
01:E86C: E6 23 INC $23
01:E86E: D0 02 BNE $E872 ; если нет переполнения, то переходим через строку
01:E870: E6 24 INC $24 ; если есть, увеличиваем старшую часть адреса
01:E872: A0 00 LDY #$00
01:E874: A2 00 LDX #$00
*01:E876: B1 23 LDA ($23),Y
01:E878: F0 2A BEQ $E8A4 ;переход, если в исходном буфере 0,
;сдвиг [$0702..$0711] на x и
;догрузка данных из PPU
01:E87A: DD 02 07 CMP $0702,X
01:E87D: D0 09 BNE $E888 ; переход произойдет, когда $0702+X != ($23),Y
01:E87F: E8 INX ; Y++
01:E880: C8 INY ; X++
01:E881: C0 10 CPY #$10 ;сравнить Y с 16
01:E883: 90 F1 BCC $E876 ;если Y < 16 переход на E876 ;
;С устанавливается если Y >= 16 (0x10)
01:E885: 4C 85 E8 JMP $E885 ;бесконечный цикл, страховка?
*01:E888: C8 INY ;сюда происходит переход, если
;в исходном потоке байт не равен
;байту в буфере [$0702..$0711]
01:E889: B1 23 LDA ($23),Y
01:E88B: D0 FB BNE $E888 ;крутиться будет до тех пор,
;пока не встретит 0 в исходном потоке
01:E88D: C8 INY
01:E88E: B1 23 LDA ($23),Y ;загрузка следующего байта
01:E890: C9 FF CMP #$FF ;сравнение с концом сжатых данных
01:E892: D0 03 BNE $E897 ;переход если байт не равен 0xFF
01:E894: 4C 94 E8 JMP $E894 ;бесконечный цикл, страховка?
*01:E897: 98 TYA ;накопленное смещение -
;сколько байт прочитано
;вот здесь E888
01:E898: 18 CLC
01:E899: 65 23 ADC $23 ;добавление его к адресу в
;потоке распаковки
01:E89B: 85 23 STA $23
01:E89D: 90 02 BCC $E8A1
01:E89F: E6 24 INC $24
*01:E8A1: 4C 66 E8 JMP $E866 ;переход на чтение следующего байта
;из потока распаковки
;сюда идет переход, если в исходном буфере 0
*01:E8A4: A0 00 LDY #$00 ;идет копирование из ячейки 702+х в ячейку 702 + x - 1
*01:E8A6: BD 02 07 LDA $0702,X ;сдвигает буфер [$0702..$0711] на 1 влево
01:E8A9: 99 02 07 STA $0702,Y
01:E8AC: C8 INY
01:E8AD: E8 INX
01:E8AE: E0 10 CPX #$10 ; сравнивается x c 16
01:E8B0: 90 F4 BCC $E8A6 ;переход, если x != 16
01:E8B2: 98 TYA
01:E8B3: AA TAX ;X = Y
01:E8B4: 20 18 E8 JSR $E818 ; дописывает байты,
; исходя из положения бита 22-23
; в конец буфера [$0702..$0711]
01:E8B7: A5 25 LDA $25
01:E8B9: 85 29 STA $29 ; (запись младшей части адреса!)
01:E8BB: A5 26 LDA $26
01:E8BD: 18 CLC
01:E8BE: 69 04 ADC #$04
01:E8C0: 85 2A STA $2A ; (запись старшей части адреса!)
01:E8C2: A0 00 LDY #$00
01:E8C4: A5 19 LDA $19 ; (запись значения для адреса 2A-29)
> 01:E8C6: 91 29 STA ($29),Y @ $0400 = #$53 ;запись байта в выходной поток
01:E8C8: E6 25 INC $25
01:E8CA: D0 02 BNE $E8CE
01:E8CC: E6 26 INC $26 ; в $25-$26 хранится количество
; распакованных байт
01:E8CE: A5 25 LDA $25
01:E8D0: C9 90 CMP #$90
01:E8D2: D0 8A BNE $E85E
01:E8D4: A5 26 LDA $26
01:E8D6: C9 01 CMP #$01
01:E8D8: D0 84 BNE $E85E ; и как только оно превышает 0x190
;или 400 происходит
;выход из цикла распаковки
01:E8DA: A0 00 LDY #$00
01:E8DC: 4C 75 81 JMP $8175 ; переключение на банк 0
Я постарался прокомментировать исходный код, чтобы всё в нем было понятно.
Что же здесь интересного? В игре используется маппер, который переключает банки памяти в PPU. (в те времена 2 килобайта памяти была большая роскошь!) И если вот сейчас посмотреть на PPU Viewer, то мы увидим вот такую кашу:
Я по началу подумал, может это какой-то баг эмулятора? Может он что-то там не нарисовал? Ведь здесь должна быть графика! Но код показал себя с другой стороны.
Давайте разбираться.
01:E85B: 20 18 E8 JSR $E818; подготовка буфера [$0702..$0711]
Что ты такое? И о каком буфере речь?
*01:E818: 86 17 STX $17 = #$0F ; счетчик в буфере в $17
01:E81A: 20 EC E7 JSR $E7EC ; магия!
01:E81D: 18 CLC ; сбрасываем флаг переноса C=0
01:E81E: F0 01 BEQ $E821 ; если бит равен 0,
01:E820: 38 SEC ; устанавливаем флаг переноса +1
*01:E821: A9 30 LDA #$30 ; Загрузить '0' (ASCII 48)
01:E823: 69 00 ADC #$00 ; добавление 1 если бит равен 1
01:E825: A6 17 LDX $17 = #$0F ; Восстановить X
01:E827: 9D 02 07 STA $0702,X @ $0711 = #$30 ; Записать символ в буфер [$0702..$0711]
01:E82A: E8 INX ; X++
01:E82B: E0 10 CPX #$10 ; X < 16?
01:E82D: 90 E9 BCC $E818 ; Да → следующий символ
01:E82F: 60 RTS -----------------------------------------
О! Как интересно. В зависимости от Z флага в выходной буфер 16 раз записывается 30, если флаг сброшен, или 31, если он взведен.
Получается из последовательности1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0
мы получаем последовательность31 31 31 31 31 31 31 31 30 30 30 30 30 30 30 30
Но что же это за хитрый флаг переноса и откуда он берется? Смотрим
01:E7EC: AD 02 20 LDA PPU_STATUS ; данные читаются из PPU!
01:E7EF: A5 22 LDA $22
01:E7F1: 8D 06 20 STA PPU_ADDRESS ; сначала старший байт адреса
01:E7F4: A5 21 LDA $21
01:E7F6: 8D 06 20 STA PPU_ADDRESS ; потом младший байт
01:E7F9: AD 07 20 LDA PPU_DATA ; грязное чтение
01:E7FC: AD 07 20 LDA PPU_DATA ; читает байт по адресу $22-$21
01:E7FF: A6 16 LDX $16 ; загружает счетчик битов
01:E801: 3D E4 E7 AND $E7E4,X ; битовая проверка от 7 до 0 бита
; если бит сброшен, то флаг Z=1
01:E804: 08 PHP ; сохраняет флаги в стек
01:E805: E8 INX ; увеличивает X
01:E806: 86 16 STX $16 ; сохраняет счетчик битов
01:E808: E0 08 CPX #$08 ; Сравнивает X с 8 (все биты проверены?)
01:E80A: 90 0A BCC $E816 ; переход, если X != 8
01:E80C: A2 00 LDX #$00
01:E80E: 86 16 STX $16 ; $16 = 0
01:E810: E6 21 INC $21 ; увеличивает $21
01:E812: D0 02 BNE $E816 ; если нет переполнения, то переход
01:E814: E6 22 INC $22 ; иначе увеличение старшей части адреса - $22
*01:E816: 28 PLP
01:E817: 60 RTS -----------------------------------------
Подождите, что? Данные для распаковки читаются из PPU? То есть видеопамять используется как обычная память, только доступ к ней средствами видео процессора?
Именно так! Именно поэтому на картинке выше мы видим мусор. Потому что непосредственно к тайлам он отношения не имеет никакого. Он имеет отношение к сжатым данным.
Объясню немного по поводу грязного чтения. Видео процессор содержит буфер. И первое чтение по указанному адресу обычно содержит мусор. А второе указанный байт. Поэтому чтений по адресу $2007 (PPU_DATA) два.
Если посмотреть на код раньше, то банк переключается перед самой распаковкой:
00:8175: B9 7C 81 LDA $817C,Y @ $817C = #$00 ; запись выше адреса $8000
00:8178: 99 7C 81 STA $817C,Y @ $817C = #$00 ; приводит к переключению банка
; номер банка 0-3 в Y
00:817B: 60 RTS -----------------------------------------
Кто там про видео карты и нейронные сети говорил? Сколько там гигабайт надо для работы? Вот откуда ноги растут - дорогущую видео память использовали для распаковки!
По адресу $E7E4 хранится массив масок для битового сравнения80 40 20 10 08 04 02 01
Друже! Я уже устал от ассемблерного кода, как это выглядит нормальным, человекочитаемым языком? Код в студию!
public byte[] Unpack(int roomNumber)
{
pack_offset = data_start;
ppu_stream = (roomNumber - 1) < 0x43 ? reader.ChrBanks[2] : reader.ChrBanks[3];
var ppu_offs = (roomNumber - 1) *2;
ppu_offset = pack_buf[0xE71C+ppu_offs] + (pack_buf[0xE71D+ppu_offs] << 8);
append_missing_bytes(16);
while (count < 400)
{
// Читаем data_byte и сбрасываем координаты
int y = 0;
byte data_byte = pack_buf[pack_offset + y];
pack_offset++;
int x = 0;
// Основной цикл поиска совпадения
while (true)
{
byte a = pack_buf[pack_offset + y];
if (a == 0)
{
// Найдено совпадение до нуля - выводим байт
shift_left_ppu_buf(x); // Сдвигаем ppu_buf на x байт
append_missing_bytes(x); // Дописываем недостающие байты
output_buffer[count] = data_byte;
count++;
pack_offset = data_start;
break;
}
else if (a == ppu_buf[x])
{
// Продолжаем совпадение
x++;
y++;
}
else
{
// Несовпадение - ищем следующий ноль
do
{
y++;
}
while (pack_buf[pack_offset + y] != 0);
y++; // Пропускаем сам ноль
if (pack_buf[pack_offset + y] != 0xFF)
{
pack_offset += y;
}
// Вместо goto возвращаемся к началу внешнего цикла
break;
}
}
}
return output_buffer;
}
//[0 1 2 3 4 5 6 7 8 9 A B C D E F]
private void shift_left_ppu_buf(int x)
{
var start = 15 - x;
if (x == 0) return;
for (int i = x; i<16; i++)
{
ppu_buf[i - x] = ppu_buf[i];
}
}
// если мы говорим 3, мы дописываем 3 байта в конец
private void append_missing_bytes(int start_byte)
{
for (int i = 16 - start_byte; i < 16; i++)
{
byte bit = read_next_bit();
ppu_buf[i] = (byte)(0x30 + bit);
}
}
private byte read_next_bit()
{
var data = ppu_stream[ppu_offset];
var bit = (data & (1 << 7-ppu_bit)) >> (7-ppu_bit);
ppu_bit++;
if (ppu_bit == 8)
{
ppu_offset++;
ppu_bit = 0;
}
return (byte)bit;
}
Получилось 95 строк против 154.
Может кто-то подскажет, как называется этот алгоритм? Я нашел здесь скользящее окно (тот самый буфер [$0702..$0711]), а ИИ упорно настаивал на вариации LZ77 с фиксированным буфером.
Первая часть распаковки завершена. А что там с раскраской?
Когда читаешь мануалы, ты думаешь, что всё понятно.
Первая таблица атрибутов находится по адресу $23C0-$23FF и имеет длину 64 байта.
Вторая - $27C0-$27FF и каждый байт закрашивает 4 каких-то блока...
Давайте рассмотрим эту ситуацию на практике. Но чтобы это сделать мы сначала модифицируем байт знакогенератора с индексом FF, который отвечает за фон. Он окрашивает свободную часть экрана в черный цвет.
Запишем 16 байт нашей шахматки по адресу $1FF0F0F0F0F0 0F0F0F0F F0F0F0F0 0F0F0F0F

Каждый блок такой шахматки - 4 пикселя. Тайл имеет размер 2 на 2 блока. Я долго искал удобный байт для демонстрации. Но все же нашел. Находится он во второй комнате по адресу в PPU $23D3 и имеет значение 0x00. Если записать по этому адресу значение 0x55, то можно увидеть, что 1 байт влияет на блок 4 на 4 тайла.
Должен отметить, эмулятор, благодаря модификации памяти, очень здорово помогает разобраться, что и как устроено прямо на практике. Я выбрал цвет по ярче и поставил 4 значения:
0x02 = 00000010
0x08 = 00001000
0x20 = 00100000
0x40 = 01000000Этот самый вопрос с окраской выбил меня из колеи. Я думал, что байт отвечает за блок 2 на 2 тайла, но на самом деле оказалось, что за этот блок отвечают 2 бита. Таким образом, байт в таблице атрибутов отвечает за блок 4 на 4 тайла вот как:00000011 - верхний левый угол (0x03)00001100 - верхний правый угол (0x0с)00110000 - нижний левый угол (0x30)11000000 - нижний правый угол (0xC0)
Теперь с этими знаниями идём искать, откуда же грузятся атрибуты для комнаты.
Мы знаем, что таблица атрибутов начинается с адреса 23С0. Наш исследуемый байт имеет адрес внутри PPU $23D3. Ставим точку останова на него и попадаем вот сюда
01:E247: AD 13 01 LDA $0113
>01:E24A: 8D 07 20 STA PPU_DATA
Если чуть прокрутить вверх, мы увидим начальную загрузку
01:E1FB: A9 23 LDA #$23
01:E1FD: 8D 06 20 STA PPU_ADDRESS
01:E200: A9 C8 LDA #$C8
01:E202: 8D 06 20 STA PPU_ADDRESS
01:E205: AD 08 01 LDA $0108
01:E208: 8D 07 20 STA PPU_DATA
Здесь загрузка адреса опять идет половинками: сначала старшая часть $23, потом младшая $08. Что еще есть интересного в этом коде? А то, что байты атрибутов читаются начиная с адреса $108. Как же так? В мануале написано, что с адреса 0x100 располагается стек. А здесь буфер, в который записываются атрибуты цвета. Теория не совпадает с практикой?
Если промотать вниз, то можно заметить, что последняя загрузка атрибута идёт с адреса $017B. Значит длинна буфера атрибутов 123 ба��та.
01:E44F: AD 7B 01 LDA $017B
01:E452: 8D 07 20 STA PPU_DATA
Что удобно в этой игре, так это то, что загрузки всех данных хоть и выглядят, как длинные портянки, но эти портянки повторяются и хорошо читаются. Просто и линейно.
Идем дальше и ставим точку останова на запись по адресу $0113 остановится на очистке этого буфера:
01:E917: A2 00 LDX #$00
01:E919: 8A TXA
01:E91A: 9D 00 01 STA $0100,X
01:E91D: E8 INX
01:E91E: 10 FA BPL $E91A ;выход, как только X станет равным 0x80
То есть, очищается только 128 байт. И только потом в самой процедуре записи атрибутов:
01:E920: A9 00 LDA #$00
01:E922: 85 16 STA $16
01:E924: A2 00 LDX #$00
01:E926: BD AD B7 LDA $B7AD,X
01:E929: 9D 00 01 STA $0100,X
01:E92C: E8 INX
01:E92D: E0 10 CPX #$10
01:E92F: 90 F5 BCC $E926 ;Выход, как только X станет равным 0x10
01:E931: A9 00 LDA #$00 ;Только что увеличили Y
01:E933: 85 17 STA $17 ;Позиция по X
01:E935: A5 16 LDA $16 ;Позиция по Y
01:E937: 0A ASL ;x2
01:E938: 0A ASL ;x2
01:E939: 18 CLC
01:E93A: 65 16 ADC $16 ;+1 = x5
01:E93C: 85 23 STA $23 ;=$16x5
01:E93E: A9 00 LDA #$00
01:E940: 85 24 STA $24
01:E942: 06 23 ASL $23 ;x2
01:E944: 26 24 ROL $24 ;Перенос старшего бита в $24
01:E946: 06 23 ASL $23 ;x2
01:E948: 26 24 ROL $24 ;Перенос старшего бита в $24
01:E94A: A5 23 LDA $23 ;$23 = ($16x5)x4 = $16x20
01:E94C: 18 CLC
01:E94D: 69 00 ADC #$00
01:E94F: 85 23 STA $23
01:E951: A5 24 LDA $24
01:E953: 69 04 ADC #$04 ;Добавляем 4 к старшей части адреса, так как буфер уровня начинается с $400
01:E955: 85 24 STA $24 ;$(23) теперь содержит указатель на начало ряда в буфере, который начинается с $400 для ячейки $16=0 и $17=0
01:E957: A5 17 LDA $17 ;Только что увеличили X
01:E959: 0A ASL ;Умножение на 2, т.к. в исходной таблице 1 байт по горизонтали содержит 2 в таблице имён
01:E95A: 18 CLC
01:E95B: 69 04 ADC #$04 ;Смещение по X внутри таблицы имён
01:E95D: AA TAX ;X = $17*2 + 4
01:E95E: A5 16 LDA $16
01:E960: 18 CLC
01:E961: 69 07 ADC #$07 ;Смещение по Y внутри таблицы имён
01:E963: A8 TAY ;Y = $16 + 7
01:E964: 20 DF E8 JSR $E8DF ;А = маска, X - смещение внутри буфера таблицы атрибутов
01:E967: 48 PHA ;Сохранение маски палитры в стек
01:E968: 49 FF EOR #$FF ;Инвертировать. Если было %00110000, станет %11001111
01:E96A: 3D 00 01 AND $0100,X ;Обнуление нужных бит в буфере
01:E96D: 85 25 STA $25 ;Сохранение буфера в $25
01:E96F: A4 17 LDY $17
01:E971: B1 23 LDA ($23),Y ;Адрес распакованного уровня - $0400... Загружается номер тайла
01:E973: C9 53 CMP #$53 ;Палитра не меняется, если это #$53
01:E975: F0 16 BEQ $E98D
01:E977: C9 09 CMP #$09 ;Или #$09
01:E979: F0 12 BEQ $E98D
01:E97B: 20 A7 E9 JSR $E9A7 ;Или #$18; #$1B; #$1E; #$21; #$24; #$27; #$28; #$2B; #$2C; #$2D; #$2E
01:E97E: F0 0D BEQ $E98D
01:E980: A8 TAY ;Y -> номер тайла
01:E981: 68 PLA ;Восстановить чистую битовую маску из стека
01:E982: 39 D2 E9 AND $E9D2,Y ;Загрузка атрибута для клетки?
01:E985: 05 25 ORA $25 ;OR с чистой палитрой
01:E987: 9D 00 01 STA $0100,X ;Сохраняет атрибут в буфере
01:E98A: 4C 8E E9 JMP $E98E
01:*E98D: 68 PLA ;Восстановить А из стека
01:*E98E: E6 17 INC $17 ;Увеличиваем позицию по X
01:E990: A5 17 LDA $17
01:E992: C9 14 CMP #$14
01:E994: 90 C1 BCC $E957
01:E996: E6 16 INC $16 ;Увеличиваем позицию по Y
01:E998: A5 16 LDA $16
01:E99A: C9 14 CMP #$14
01:E99C: 90 93 BCC $E931
01:E99E: A9 FF LDA #$FF
01:E9A0: 8D 9A 07 STA $079A
01:E9A3: 8D 9B 07 STA $079B
01:E9A6: 60 RTS
Что здесь происходит простыми словами?
Сначала загружается 16 байт из адреса $B7AD
Потом в цикле 20 на 20 клеток считается реальное положение атрибута в таблице имён.
В коде видно, что по X добавляется 4, по Y - 7. Наше поле смещено именно на это количество единиц. Также X умножается на 2, потому что одной клетке в исходном поле соответствует 2 значения в таблице имён. И потом считаются атрибуты для каждой клетки, но уже внутри таблиц имён.
Смотрим, что делает процедура E8DF
01:E8DF: A9 00 LDA #$00
01:E8E1: 85 25 STA $25 ;Смещение внутри начала адреса палитры
01:E8E3: E0 20 CPX #$20 ;Поскольку таблицы имен 2
01:E8E5: 90 09 BCC $E8F0
01:E8E7: A9 40 LDA #$40
01:E8E9: 85 25 STA $25 ;+$40 смещение в выходном буфере
01:E8EB: 8A TXA
01:E8EC: 38 SEC
01:E8ED: E9 20 SBC #$20 ;Отнимаю 32 байта - длину первой таблицы
01:E8EF: AA TAX ;Сохраняю в X
01:E8F0: 98 TYA
01:E8F1: 0A ASL ;Y*2
01:E8F2: 29 F8 AND #$F8 ;Очистка 3 младших бит F8=11111000
01:E8F4: 18 CLC
01:E8F5: 65 25 ADC $25 ;+25$
01:E8F7: 85 25 STA $25 ;A=$25
01:E8F9: 8A TXA ;X=A
01:E8FA: 4A LSR ;X / 4
01:E8FB: 4A LSR ;
01:E8FC: 18 CLC
01:E8FD: 65 25 ADC $25 ;
01:E8FF: 85 25 STA $25 ;
01:E901: 98 TYA ;Y координата
01:E902: 29 02 AND #$02 ;Y&2 = вертикальный бит (бит 1)
01:E904: 85 26 STA $26 ;Сохраняем в $26
01:E906: 8A TXA ;X координата (0-31)
01:E907: 4A LSR ;X&1 = горизонтальный бит (бит 0)
01:E908: 29 01 AND #$01 ;
01:E90A: 05 26 ORA $26 ;Объединяем биты: [Y%2][X%2]
01:E90C: AA TAX ;X = итоговый индекс (0-3)
01:E90D: BD 13 E9 LDA $E913,X ;Загружаем маску палитры по индексу
;03=00000011 0C=00001100
;30=00110000 C0=11000000
01:E910: A6 25 LDX $25
01:E912: 60 RTS
И вот итоговый код для загрузки палитры:
public byte[] load_attr()
{
var forbidden_bytes = new[] { 0x53, 0x09, 0x18, 0x1B, 0x1E, 0x21, 0x24, 0x27, 0x28, 0x2B, 0x2C, 0x2D, 0x2E };
for (int i = 0; i<128; i++)
attr[i] = 0;
for (int i = 0; i<16; i++)
attr[i] = pack_buf[0xB7AD+i];
for (int y = 0; y< 20; y++)
{
for (int x = 0; x < 20; x++)
{
var row_offset = y*(2*2+1)*2*2;//y*20
var real_x = x*2 + 4;
var real_y = y + 7;
(var attr_offs, var attr_byte) = get_attr(real_x, real_y);
byte temp_attr = (byte)((attr_byte ^ 0xFF) & attr[attr_offs]);
var data = output_buffer[row_offset+x];
if (forbidden_bytes.Contains(data)) continue;
attr_byte &= pack_buf[0xE9D2+data]; // чистый байт атрибутов смешиваем с палитрой
attr_byte |= temp_attr;
attr[attr_offs] |= attr_byte; // добавляем к результату
}
}
return attr;
}
private (byte attr_offs, byte attr_byte) get_attr(int x, int y)
{
byte offset = 0x00;
// Проверяем, какая таблица имен (первая или вторая)
if (x >= 0x20)
{
offset = 0x40; // Смещение для второй таблицы
x -= 0x20; // Вычитаем длину первой таблицы (32 байта)
}
// Вычисляем базовый адрес палитры
byte baseAddr = (byte)((y * 2) & 0xF8); // Y*2, очистка 3 младших бит
baseAddr += offset;
// Добавляем X / 4
baseAddr += (byte)(x >> 2);
// Извлекаем биты для индекса
byte verticalBit = (byte)(y & 0x02); // Y & 2 (вертикальный бит)
byte horizontalBit = (byte)(x>>1 & 0x01); // X & 1 (горизонтальный бит)
byte index = (byte)(verticalBit | horizontalBit); // [Y%2][X%2] (0-3)
// Таблица масок палитры (аналог $E913)
byte[] paletteMasks = { 0x03, 0x0C, 0x30, 0xC0 };
// Возвращаем маску палитры по индексу + смещение буфера атрибутов
byte attr_byte = paletteMasks[index];
byte attr_offs = baseAddr;
return (attr_offs, attr_byte); // Возврат tuple
}
Как видно, он очень простой.
Осталась последняя и самая интересная часть - спрайты. Давайте разбираться, где и как хранятся все предметы в каждой комнате, враги и ключи.
Буфер спрайтов находится начиная с адреса 0x200. И одной командой загрузки в порт $4014 числа 0x02 весь целиком отправляется в PPU. Если открыть hex редактор и посмотреть, что там делается, то можно увидеть постоянно бегущие цифры,
Давайте немного разберем саму структуру спрайтового буфера.
В нем находятся записи по 4 байта длинной.
Y координата
Номер спрайта
Атрибуты - цвет, поворот по оси X, поворот по оси Y
X координата
Хорошо было бы прицепиться к номеру спрайта, чтобы понять, откуда он читается. Но в данном случае по адресу 0x201 будет каждый раз новое число...
Давайте попробуем понаблюдать за игрой. Идеально было бы найти какой-то предмет, который мы можем двигать. Судя по всему где-то есть исходный буфер из которого берутся данные для перемешивания и он статичный.
Я нашел такой предмет в комнате справа - это ящик.
Открыв Hex Editor при перемещении ящика можно заметить, что меняется адрес 0x69. Значит, это x координата ящика. Проверим.
Там будет строка 8B A0 00 60. Давайте заменим A0 и 60 на 00. Что мы получим?
Мы видим, что наш ящик переместился в левый верхний угол! Значит по адресу 0x69 находится X координата объекта, а по адресу 0x6B - Y координата. Получается, координаты объектов хранятся не относительно экрана, а относительно краев карты. (а мы помним, что карта это отдельный массив 20 на 20 клеток, который хранится по адресу 0x400).
А что же за число 8B по адресу 0x68? Я попробовал его поменять и оказалось, что это тип объекта. Например, если поставить число 0x82, то мы увидим принцессу!
Если теперь подойти к принцессе,
то мы узнаем, кто сделал игру. Что же, снимем шляпу в дань уважения. Это было самое желанное и интересное место во всех играх - титры о её создателях.
Но, это не наш путь. Поигравшись с числами можно понять, за что отвечает каждое число. Как оказалось, объектов в игре, которые нам требуется отображать, не так уж и много.
Нам остается поставить точку останова на адрес 0x68 и посмотреть код. Первую точку останова можно пропустить. А на второй остановимся подробнее.
01:EBE9: A9 00 LDA #$00
01:EBEB: AA TAX
>01:EBEC: 95 4A STA $4A,X ; запись 0 по адресу $4A + X
01:EBEE: E8 INX
01:EBEF: E0 90 CPX #$90 ; сравнить с 0x90 (144)
01:EBF1: 90 F9 BCC $EBEC
Значит, наш буфер имеет начало по адресу 0x4A и длину 144 байта.
Идём дальше.
01:EBF3: A6 37 LDX $37 = #$48
01:EBF5: CA DEX
01:EBF6: 20 D2 ED JSR $EDD2 ; подготовка указателя 21-22 на начало данных для комнаты, указанной в X
01:EBF9: A2 00 LDX #$00
*01:EBFB: A0 00 LDY #$00
01:EBFD: B1 21 LDA ($21),Y
01:EBFF: C9 FF CMP #$FF
01:EC01: F0 5F BEQ $EC62 ; как только встретил FF - выход
01:EC03: A8 TAY
01:EC04: B9 8F EB LDA $EB8F,Y
01:EC07: 95 4A STA $4A,X ; загрузка типа спрайта
01:EC09: A9 00 LDA #$00
01:EC0B: C0 12 CPY #$12
01:EC0D: 90 07 BCC $EC16 ; переход если Y < 0x12
01:EC0F: C0 2A CPY #$2A
01:EC11: B0 03 BCS $EC16 ; переход если Y >= 0x2A
01:EC13: B9 C0 EB LDA $EBC0,Y @ $EBC1 = #$92 ; сюда попадём, только если 0x12 <= Y < 0x2A
*01:EC16: 95 4E STA $4E,X
01:EC18: A9 00 LDA #$00
01:EC1A: 85 1A STA $1A
01:EC1C: A0 01 LDY #$01
01:EC1E: B1 21 LDA ($21),Y ; здесь происходит загрузка X координаты
01:EC20: 0A ASL
01:EC21: 26 1A ROL $1A
01:EC23: 0A ASL
01:EC24: 26 1A ROL $1A
01:EC26: 0A ASL
01:EC27: 26 1A ROL $1A
01:EC29: 0A ASL
01:EC2A: 26 1A ROL $1A
>01:EC2C: 95 4B STA $4B,X ;4B = A * 16
01:EC2E: A5 1A LDA $1A
01:EC30: 95 4C STA $4C,X
01:EC32: C8 INY
01:EC33: B1 21 LDA ($21),Y ;загрузка Y координаты
01:EC35: 0A ASL
01:EC36: 0A ASL
01:EC37: 0A ASL ;умножение на 8
01:EC38: 95 4D STA $4D,X ;сохранение по адресу 4D
01:EC3A: B5 4A LDA $4A,X
01:EC3C: C9 90 CMP #$90
01:EC3E: 90 0F BCC $EC4F ;переход если A < 0x90
01:EC40: C9 96 CMP #$96
01:EC42: B0 0B BCS $EC4F ;переход если A >= 0x96
01:EC44: B5 4B LDA $4B,X @ $0069 = #$00
01:EC46: 18 CLC
01:EC47: 69 04 ADC #$04
01:EC49: 95 4B STA $4B,X @ $0069 = #$00 ;
01:EC4B: 90 02 BCC $EC4F ;переход, если был перенос в резулате сложения
01:EC4D: F6 4C INC $4C,X @ $006A = #$00 ;увеличение старшего разряда
*01:EC4F: A9 03 LDA #$03
01:EC51: 18 CLC
01:EC52: 65 21 ADC $21 = #$1E
01:EC54: 85 21 STA $21 = #$1E
01:EC56: 90 02 BCC $EC5A
01:EC58: E6 22 INC $22 = #$F6 ;добавление 3 к указателю $22-$23
01:EC5A: 8A TXA
01:EC5B: 18 CLC
01:EC5C: 69 06 ADC #$06 ;добавление 6 к Х
01:EC5E: AA TAX
01:EC5F: 4C FB EB JMP $EBFB ;переход в начало цикла
01:EC62: 60 RTS -----------------------------------------
Что видно из этого кода? По адресу $(21), 0 находится находится тип объекта, по адресу $(21), 1 - x координата, по адресу $(21), 2 - y координата. Значит, они хранятся тройками байт, а записываются в выходной буфер по адресу $4A, который имеет длину 6 байт. X координата умножается на 16, Y координата умножается на 8. Данные по комнатам разделены терминатором 0xFF.
Осталось только выяснить, что делает процедура $EDD2.
01:EDD2: A9 6B LDA #$6B
01:EDD4: 85 21 STA $21 = #$1E
01:EDD6: A9 EF LDA #$EF
01:EDD8: 85 22 STA $22 = #$F6
01:EDDA: A0 00 LDY #$00
01:EDDC: E8 INX
*01:EDDD: CA DEX
01:EDDE: F0 0E BEQ $EDEE ;выход, как только X = 0
*01:EDE0: B1 21 LDA ($21),Y
01:EDE2: E6 21 INC $21
01:EDE4: D0 02 BNE $EDE8
01:EDE6: E6 22 INC $22
*01:EDE8: C9 FF CMP #$FF ;конец данных
01:EDEA: D0 F4 BNE $EDE0 ;если это не FF, читаем следующий байт
01:EDEC: F0 EF BEQ $EDDD ;если встретили терминатор, уменьшаем номер комнаты
*01:EDEE: 60 RTS -----------------------------------------
Это простой цикл, который считает терминатор FF до тех пор, пока не будет достигнут 0. При каждом цикле отнимается 1 от номера комнаты. Данные по объектам всех комнат хранятся с адреса 0xEF6B.
Теперь мы знаем типы и координаты наших игровых объектов в каждой комнате. Но как же нам это раскрасить? И более того, здесь мы видим 1 номер, а выводим аж 4 спрайта.
Придется всё же залезть в буфер спрайтов, чтобы понять, что же там происходит. Скорее всего данные о атрибутах хранятся где-то еще. Что же. Ставим остановку на адрес 0x200.
01:C6CF: B9 00 03 LDA $0300,Y
>01:C6D2: 9D 00 02 STA $0200,X
01:C6D5: B9 01 03 LDA $0301,Y
01:C6D8: 9D 01 02 STA $0201,X
01:C6DB: B9 02 03 LDA $0302,Y
01:C6DE: 9D 02 02 STA $0202,X
01:C6E1: B9 03 03 LDA $0303,Y
01:C6E4: 9D 03 02 STA $0203,X
Мы видим, что данные для первого спрайта (тот который 8 на 8) берутся из адресов 0x300..0x303. То есть, действительно есть некое заранее подготовленное место. Номер спрайта - это 0x301 байт. Ставим точку останова на запись по этому адресу.
Первый раз идет обнуление буфера
00:B6AC: A2 00 LDX #$00
00:B6AE: A9 F0 LDA #$F0
00:B6B0: 9D 00 02 STA $0200,X
>00:B6B3: 9D 00 03 STA $0300,X
00:B6B6: E8 INX
00:B6B7: D0 F7 BNE $B6B0
мы его пропускаем. Дальше срабатывает точка останова вот здесь
00:B47C: A0 00 LDY #$00
00:B47E: B1 21 LDA ($21),Y
>00:B480: 8D 01 03 STA $0301
Видим, что в ячейку 0x301 записывается спрайт игрока. Это не совсем то, что нам нужно.
Посмотрим в PPU Viewer и увидим, что по адресу 0x325 хранится значение $D8, что соответствует иконке ключа. Ставим точку останова на этот адрес и попадаем в процедуру очистки
00:AC17: A2 00 LDX #$00
00:AC19: A9 F0 LDA #$F0
>00:AC1B: 9D 00 03 STA $0300,X
00:AC1E: E8 INX
00:AC1F: D0 FA BNE $AC1B
А дальше мы попадаем в процедуру загрузки нужного нам значения:
00:B265: 86 22 STX $22
00:B267: 84 21 STY $21
00:B269: A6 41 LDX $41
00:B26B: B5 4B LDA $4B,X
00:B26D: 18 CLC
00:B26E: 69 20 ADC #$20
00:B270: 85 2B STA $2B
00:B272: B5 4C LDA $4C,X
00:B274: 69 00 ADC #$00
00:B276: 85 2C STA $2C
00:B278: A5 2B LDA $2B
00:B27A: 38 SEC
00:B27B: E5 34 SBC $34
00:B27D: 85 1B STA $1B
00:B27F: A5 2C LDA $2C
00:B281: E9 00 SBC #$00
00:B283: 90 29 BCC $B2AE
00:B285: D0 27 BNE $B2AE
00:B287: A6 16 LDX $16
00:B289: A0 00 LDY #$00
00:B28B: B1 21 LDA ($21),Y
00:B28D: 9D 01 03 STA $0301,X
00:B290: A0 01 LDY #$01
00:B292: B1 21 LDA ($21),Y
00:B294: 9D 02 03 STA $0302,X
00:B297: A6 41 LDX $41 = #$12
00:B299: B5 4D LDA $4D,X
00:B29B: 18 CLC
00:B29C: 69 37 ADC #$37
00:B29E: A6 16 LDX $16
00:B2A0: 9D 00 03 STA $0300,X
00:B2A3: A5 1B LDA $1B
00:B2A5: 9D 03 03 STA $0303,X
00:B2A8: E8 INX
00:B2A9: E8 INX
00:B2AA: E8 INX
00:B2AB: E8 INX
00:B2AC: 86 16 STX $16
00:B2AE: 60 RTS -----------------------------------------
Я не стал её описывать, потому что она не особо интересна. Интересны в ней ровно 2 факта.
Номер спрайта и его атрибуты берутся из значений LDA ($21), 0 и LDA ($21), 1. А само значение этого указателя берется из значений X и Y.
Выйдем из этой процедуры и попадем вот сюда:
00:B02B: A2 B5 LDX #$B5
00:B02D: A0 B0 LDY #$B0
00:B02F: 20 65 B2 JSR $B265
>00:B032: 4C 50 AC JMP $AC50
Значит, указатель равен $B5B0. Но как мы сюда попали? Переходим по JMP.
*00:AC2F: A4 41 LDY $41
00:AC31: B9 4D 00 LDA $004D,Y
00:AC34: C9 A0 CMP #$A0
00:AC36: B0 18 BCS $AC50 ;если Y > 160 переход к следующему предмету
00:AC38: B9 4A 00 LDA $004A,Y
00:AC3B: F0 13 BEQ $AC50 ;если тип блока == 0 переходим к следующему предмету
00:AC3D: 29 7F AND #$7F
00:AC3F: 0A ASL
00:AC40: A8 TAY
00:AC41: B9 93 AD LDA $AD93,Y
00:AC44: 85 31 STA $31
00:AC46: B9 94 AD LDA $AD94,Y
00:AC49: 85 32 STA $32
00:AC4B: A6 41 LDX $41
00:AC4D: 6C 31 00 JMP ($0031)
*00:AC50: A5 41 LDA $41 ;переход сюда после JMP
00:AC52: 18 CLC
00:AC53: 69 06 ADC #$06 ;добавление смещения к следующему предмету; размер информации о предмете 6 байт
00:AC55: 85 41 STA $41
00:AC57: C9 90 CMP #$90 ;18 предметов - 0x90 / 6 байт размер инфы о предмете
00:AC59: 90 D4 BCC $AC2F ;переход если количество предметов меньше #$90
00:AC5B: A9 00 LDA #$00
00:AC5D: 85 FB STA $FB = #$FF
00:AC5F: 60 RTS -----------------------------------------
У нас есть тип предмета. Он хранится в $4A. Далее, AND $7F означает остаток от деления (лайфхак - часто такое в играх используют) на 128 и потом умножение на 2.
А дальше идет получение указателя по адресам $AD93 и $AD94. Потом переход по этому адресу. Вот это засада. Это что же? Анализировать все 128 адресов? Нет. Ведь блоков у нас всего 34. Значит, нам нужно 34 адреса. Я составил подпрограмму и она рассчитала мне все эти адреса. Вот они:
81: ADE7
82: AE08
83: AE12
84: AE19
85: AE75
86: AEBB
87: AF1E
88: AF6A
89: AFB0
8A: AFF4
8B: AFFB
8C: B002
8D: B009
8E: B010
8F: B017
90: B02B
91: B035
92: B03C
93: B043
94: B04A
95: B051
96: B058
97: B05F
98: B066
99: B06D
9A: B074
9B: B07B
9C: B082
9D: B089
9E: B089
9F: B090
A0: B0AC
A1: B0C9
A2: B0D0
A3: B0DA
A4: B0E1
A5: B0E8
Дальше я проанализировал весь код по этим адресам. Он однотипный. Почти везде идёт вызов процедуры подобной $B265. В чём, кстати, прелесть этой игры - много копипаста и код легко читается. Единственное, на что сейчас обращу внимание. Таких процедур там еще 2.$B265 вызывается, когда нужно отрисовать объект шириной 1 спрайт. $B1D6 вызывается, когда нужно отрисовать объект шириной 2 спрайта, а $B21F - когда 4. Причем последняя вызывается для одного единственного объекта - трубы с номером A2.
Что важно еще ответить. PPU имеет режим спрайтов 8 на 8 или 8 на 16. Это значит, что при записи байта в спрайтовый буфер будет читаться 1 или 2 байта. В этой игре используется 2-й режим. Поэтому идет только одна загрузка, а спрайтов рисуется сразу два.
Я составил словарь чтобы не заморачиваться со всеми этими переходами, в котором ключ - это номер объекта, а значение это пара (смещение, количество байт)
private static Dictionary<int, (ushort pointer, int count)> _types = new()
{
[0x81] = (0xB598, 2),
[0x82] = (0xB5AC, 2),
[0x83] = (0xB5BC, 2),
[0x84] = (0xB4FE, 2),
[0x85] = (0xB516, 2),
[0x86] = (0xB52E, 1),
[0x87] = (0xB554, 2),
[0x88] = (0xB560, 2),
[0x89] = (0xB57C, 2),
[0x8A] = (0xB5C0, 2),
[0x8B] = (0xB5C4, 2),
[0x8C] = (0xB5C8, 2),
[0x8D] = (0xB5CC, 2),
[0x8E] = (0xB5D0, 2),
[0x8F] = (0xB5D8, 2),
[0x90] = (0xB5B0, 1),
[0x91] = (0xB5B2, 1),
[0x92] = (0xB5B4, 1),
[0x93] = (0xB5B6, 1),
[0x94] = (0xB5B8, 1),
[0x95] = (0xB5BA, 1),
[0x96] = (0xB5DC, 2),
[0x97] = (0xB5E0, 2),
[0x98] = (0xB5E4, 2),
[0x99] = (0xB5E8, 2),
[0x9A] = (0xB5EC, 2),
[0x9B] = (0xB5F0, 2),
[0x9C] = (0xB5F4, 2),
[0x9F] = (0xB60C, 2),
[0xA1] = (0xB618, 2),
[0xA2] = (0xB610, 4),
[0xA3] = (0xB620, 2),
[0xA4] = (0xB624, 2),
[0xA5] = (0xB628, 2),
};
В процессе отладки выяснилось, что в игре есть лучик света, подойдя под который игрок становится неубиваем. Его номер - 0x9F.
Мы узнали как упакованы уровни, как хранятся палитры и спрайты, как хранятся атрибуты и осталось только проверить, что всё на месте. Что же? Запустим рисование второго уровня
А где же труба? Вот незадача. Ставим точку останова на запись по адресу 0x0074 (именно там хранится значение 0xA2) и попадаем вот сюда
>01:EDC0: 95 4A STA $4A,X @ $0074 = #$00
01:EDC2: C9 A3 CMP #$A3
01:EDC4: D0 04 BNE $EDCA
01:EDC6: A9 FF LDA #$FF
01:EDC8: 95 4E STA $4E,X
01:EDCA: A5 16 LDA $16
01:EDCC: 18 CLC
01:EDCD: 69 06 ADC #$06
01:EDCF: 85 16 STA $16
01:EDD1: 60 RTS -----------------------------------------
Выйдем из этой процедуры. Она не очень интересна. Мы попадём вот сюда:
01:ED24: A9 A2 LDA #$A2
01:ED26: 20 6D ED JSR $ED6D
>01:ED29: 4C 5C ED JMP $ED5C
То есть значение этой самой трубы у нас захардкожено.... Посмотрим шире
01:ECF5: A6 37 LDX $37 = #$48
01:ECF7: BD 49 AB LDA $AB49,X
01:ECFA: AA TAX
01:ECFB: BD 10 8F LDA $8F10,X
01:ECFE: 85 16 STA $16 = #$2A
01:ED00: A9 04 LDA #$04
01:ED02: 85 22 STA $22
01:ED04: A9 00 LDA #$00
01:ED06: 85 21 STA $21 ;($21) = 0x400 начало распакованного уровня
*01:ED08: A0 00 LDY #$00
01:ED0A: B1 21 LDA ($21),Y
01:ED0C: C9 4B CMP #$4B
01:ED0E: D0 0C BNE $ED1C
01:ED10: A9 53 LDA #$53
01:ED12: 91 21 STA ($21),Y
01:ED14: A9 A1 LDA #$A1
01:ED16: 20 6D ED JSR $ED6D
01:ED19: 4C 5C ED JMP $ED5C
*01:ED1C: C9 4D CMP #$4D
01:ED1E: D0 0C BNE $ED2C
01:ED20: A9 53 LDA #$53
01:ED22: 91 21 STA ($21),Y
01:ED24: A9 A2 LDA #$A2
01:ED26: 20 6D ED JSR $ED6D
01:ED29: 4C 5C ED JMP $ED5C
*01:ED2C: C9 51 CMP #$51
01:ED2E: D0 0C BNE $ED3C
01:ED30: A9 53 LDA #$53
01:ED32: 91 21 STA ($21),Y
01:ED34: A9 A3 LDA #$A3
01:ED36: 20 6D ED JSR $ED6D
01:ED39: 4C 5C ED JMP $ED5C
*01:ED3C: C9 49 CMP #$49
01:ED3E: D0 0C BNE $ED4C
01:ED40: A9 53 LDA #$53
01:ED42: 91 21 STA ($21),Y
01:ED44: A9 A4 LDA #$A4
01:ED46: 20 6D ED JSR $ED6D
01:ED49: 4C 5C ED JMP $ED5C
*01:ED4C: C9 4A CMP #$4A
01:ED4E: D0 0C BNE $ED5C
01:ED50: A9 53 LDA #$53
01:ED52: 91 21 STA ($21),Y
01:ED54: A9 A5 LDA #$A5
01:ED56: 20 6D ED JSR $ED6D
01:ED59: 4C 5C ED JMP $ED5C
*01:ED5C: E6 21 INC $21 = #$B5
01:ED5E: D0 02 BNE $ED62
01:ED60: E6 22 INC $22 = #$04
01:ED62: A5 21 LDA $21
01:ED64: C9 90 CMP #$90
01:ED66: A5 22 LDA $22
01:ED68: E9 05 SBC #$05
01:ED6A: 90 9C BCC $ED08
01:ED6C: 60 RTS -----------------------------------------
Эта процедура очень простая. Она в цикле перебирает все значения нашего уровня по адресу 0x400 и смотрит, что за значение там хранится. (и дополнительно анимирует фон - #$53 это пустота). То есть, спрайты появляются дополнительно в зависимости от значений клеток уровня. Я создал еще один словарь, в котором прописал эти дополнительные значения:
Dictionary<int, int> addidtionalType = new()
{
[0x4B] = 0xA1,
[0x4D] = 0xA2,
[0x51] = 0xA3,
[0x49] = 0xA4,
[0x4A] = 0xA5,
};
С учетом всего вышесказанного вот процедура загрузки данных о спрайтах
public void ReadAllSprites(int roomNumber)
{
Sprites.Clear();
var ptr = GetRoomPointer(roomNumber-1);
Sprite sprite;
do
{
sprite = new Sprite();
var ofs = pack_buf[ptr];
if (ofs == 0xFF) break;
sprite.n = pack_buf[0xEB8F+ofs];
//Y >= 0x12 && Y < 0x2A
//похоже на направление 00 или FF
if (ofs >= 0x12 && ofs < 0x2A)
sprite.dir = pack_buf[0xEBC0+ofs];
sprite.x = pack_buf[ptr+1]*16;
sprite.y += pack_buf[ptr+2]*8;
// для ключа добавляем 4 пикселя
if (sprite.n >= 0x90 && sprite.n <= 0x95)
sprite.x += 4;
if (!_types.TryGetValue(sprite.n, out var info))
{
Console.WriteLine($"Unknown sprite type: [{sprite.n:X2}] room [{roomNumber:X2}]");
ptr += 3;
continue;
}
// номер спрайта
// его атрибуты
sprite.data = new byte[info.count*2];
for (int i = 0; i < info.count * 2; i++)
{
sprite.data[i] = pack_buf[info.pointer+i];
}
Sprites.Add(sprite);
ptr += 3;
} while (true);
Dictionary<int, int> addidtionalType = new()
{
[0x4B] = 0xA1,
[0x4D] = 0xA2,
[0x51] = 0xA3,
[0x49] = 0xA4,
[0x4A] = 0xA5,
};
for (int x = 0; x<20; x++)
{
for (int y = 0; y<20; y++)
{
var b = output_buffer[y*20+x];
if (addidtionalType.ContainsKey(b))
{
sprite = new Sprite();
sprite.n = addidtionalType[b];
sprite.x = x*16;
sprite.y = y*8;
if (!_types.TryGetValue(sprite.n, out var info))
{
Console.WriteLine($"Unknown sprite type: [{sprite.n:X2}] room [{roomNumber:X2}]");
ptr += 3;
continue;
}
// номер спрайта
// его атрибуты
sprite.data = new byte[info.count*2];
for (int i = 0; i < info.count * 2; i++)
{
sprite.data[i] = pack_buf[info.pointer+i];
}
Sprites.Add(sprite);
}
}
}
}
Посмотрим, что у нас получилось.
Теперь осталось самое главное. Узнать, насколько этот замок vast, как было написано в финальных титрах. Сшиваем все полученные комнаты.
Чего я не описал? Загрузка самой палитры происходит с адреса в памяти 0xC53D. Как это отследить? Поставить точку останова на запись по адресу начала палитр 0x3F00. Сразу код:
public void load_pal()
{
var offset = 0xC53D;
for (var i = 0;i<16;i++)
{
backgorund[i] = pack_buf[offset++];
}
for (var i = 0; i<16; i++)
{
foreground[i] = pack_buf[offset++];
}
}
Вот код на github.
https://github.com/wizzard2010/cex
Надеюсь, вам, как и мне было интересно узнать, как мыслили разработчики 80-х.
Что еще не описал? Как считается атрибут для фона. Идея там такая.
Пусть у нас за окраску отвечает 4 и 5 бит (счет с 0). Это число %00110000.
К этому числу применяется XOR FF. Если было %00110000, станет %11001111.
Это маска AND-ится с числом, которое есть в буфере палитре, чтобы не затереть уже раскрашенные клетки. Таким образом отчищаются нужные биты.
Если, например, значение было 0xA5, что в двоичной системе 10100101, то после операции AND c маской %11001111 оно станет равным 10000101. Дальше загружается атрибут для выбранной клетки и значения OR-ится с очищенной палитрой.
На этом, пожалуй, всё.
Автор: wizzard_2
