Всем привет! Была у меня лет в 13-14 игра. Называется она Castle Excellent - превосходный замок. Превосходство этого замка заключалось в 100 комнатах-головоломках, пройдя которые нужно было найти принцессу. Как же я хотел посмотреть на все комнаты сразу! В Денди не было ни возможности сохранений, ни возможности что-то там достать и посмотреть. Но вот теперь, проведя реверс инжиниринг этой игры, и переводя ассемблер 6502 в человекочитаемый код у меня это наконец-то получилось!

Castle Excellent
Castle Excellent

Игру эту написала японская 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 байт нашей шахматки по адресу $1FF0
F0F0F0F0 0F0F0F0F F0F0F0F0 0F0F0F0F

Каждый блок такой шахматки - 4 пикселя. Тайл имеет размер 2 на 2 блока. Я долго искал удобный байт для демонстрации. Но все же нашел. Находится он во второй комнате по адресу в PPU $23D3 и имеет значение 0x00. Если записать по этому адресу значение 0x55, то можно увидеть, что 1 байт влияет на блок 4 на 4 тайла.

Атрибут по адресу $23D3 со значением 0x55
Атрибут по адресу $23D3 со значением 0x55

Должен отметить, эмулятор, благодаря модификации памяти, очень здорово помогает разобраться, что и как устроено прямо на практике. Я выбрал цвет по ярче и поставил 4 значения:

0x02 = 00000010
0x02 = 00000010
0x08 = 00001000
0x08 = 00001000
0x20 = 00100000
0x20 = 00100000
0x40 = 01000000
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.

лучик света в тёмном царстве
лучик света в тёмном царстве

Мы узнали как упакованы уровни, как хранятся палитры и спрайты, как хранятся атрибуты и осталось только проверить, что всё на месте. Что же? Запустим рисование второго уровня

Уровень справа от старта. Его номер 0x48.
Уровень справа от старта. Его номер 0x48.

А где же труба? Вот незадача. Ставим точку останова на запись по адресу 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-ится с очищенной палитрой.

На этом, пожалуй, всё.