Всем привет! Была у меня лет в 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-ится с очищенной палитрой.
На этом, пожалуй, всё.
