Как стать автором
Обновить

Хранение мира в Snake Rattle'n'Roll

Время на прочтение 34 мин
Количество просмотров 7.3K
Много лет назад мне довелось поиграть на Dendy в игру Snake Rattle'n'Roll. Пройти её мне тогда так и не удалось, из-за широко известного в узких кругах бага с фонтанчиком на 7 уровне. Да, и на данный момент игра так и не пройдена. Прогресс пока остановился на последнем уровне из-за его сложности. Игра сама по себе для NES была достаточно нестандартна. Это был изометрический мир, в котором надо было карабкаться верх, по пути собирая бонусы, поедая ниблов (местная живность) и сражаясь с ногами, шашками и прочими грибами. Вроде бы ничего необычного, но продвигаясь дальше по уровням я замечал, что мир хоть и был разбит на уровни, но был единым целым, просто каждый из уровней происходил в другой ограниченной части этого мира. И вот однажды мне захотелось получить 3D модель данного мира, с целью распечатать себе сувенир на 3D принтере. Учитывая характеристики железа NES я представлял, что это будет не очень просто, как оно оказалось на самом деле судить вам. Итак, если вас заинтересовало исследование этого мира — добро пожаловать под кат.

0. Ориентир


В качестве ориентира возьмем такую картинку, разрешение у неё 2000x4000 поэтому спрячу пад спойлер.

Мир Snake Rattle'n'Roll
image

Автора к сожалению не знаю, но сделано супер!..

1. Поиск чужих наработок


Snake Rattle'n'Roll Level 1Учитывая, что у меня нет опыта в разборе ассемблера процессора MOS6502, который использовался в NES, я решил поискать, не выложил ли уже кто адреса по которым хранится уровень и его формат. Всё что я смог найти (два года назад, надо заметить, может сейчас что-то изменилось) был сайт http://datacrystal.romhacking.net/wiki/Snake_Rattle_N_Roll:ROM_map,
откуда мы можем предположить, что уровень у нас имеет размеры 64х64 и каждый блок закодирован одним байтом. Всего получается четыре килобайта на уровень. Один байт это вроде мало, но если там кодировать только высоту блока, может можно будет ещё пару бит выделить на какие нибудь флаги. Так я думал…

Итак открываем ROM файл, идем по смещению 0x63D0 смотрим, что там хранится:

000063D0 13 04 04 04 04 00 00 00 00 00 00 00 00 01 00 00
000063E0 00 00 00 01 01 01 00 00 00 00 01 01 01 01 01 00
000063F0 00 00 00 00 00 00 00 00 01 01 01 01 01 01 01 01
00006400 00 00 00 00 00 00 00 00 01 00 00 00 01 00 00 2A
00006410 13 04 04 04 04 01 01 01 01 01 00 00 00 00 00 00
00006420 00 00 00 00 01 01 01 01 01 01 01 01 01 01 01 01
00006430 00 00 2A 00 00 00 01 01 01 01 01 01 01 01 01 01
00006440 01 01 01 00 00 00 01 01 01 00 00 00 00 00 00 00
00006450 13 04 04 04 04 01 01 01 2A 01 01 00 00 00 00 00
00006460 00 00 00 00 00 01 01 2A 01 01 01 01 01 01 01 01
00006470 00 00 00 00 00 01 01 01 01 00 00 00 01 01 01 01
00006480 01 01 01 01 01 01 01 01 01 01 00 00 00 00 00 00
00006490 13 04 04 04 04 01 01 01 01 01 01 01 01 01 00 00
000064A0 00 00 00 00 00 00 01 01 01 01 01 01 01 01 01 01
000064B0 00 00 00 01 01 01 01 01 00 00 00 00 00 00 01 01
000064C0 01 01 01 01 01 01 2A 01 01 01 01 01 01 00 00 00
000064D0 13 04 04 04 04 01 01 01 2A 01 01 01 01 01 01 00
000064E0 00 00 00 00 00 00 01 01 01 01 01 01 01 01 01 01
000064F0 01 01 01 01 01 01 2A 01 00 00 00 00 00 00 04 01
00006500 01 01 01 01 01 01 01 01 01 01 01 01 01 01 00 00
00006510 13 04 04 04 04 01 01 01 01 01 01 01 01 01 01 00
00006520 00 00 00 00 00 01 01 01 01 01 01 2A 01 01 01 13
00006530 13 05 05 05 05 01 01 01 00 00 00 00 00 02 1E 04
00006540 01 01 01 01 01 01 01 01 01 01 01 01 01 00 00 00
00006550 13 13 0C 0C 0C 05 05 05 05 05 05 05 05 05 04 22
00006560 22 04 01 01 01 01 01 01 01 01 01 01 01 01 13 13
00006570 4A 0F 0F 0F 0F 05 05 05 1A 1A 1A 1A 13 2F 13 1B
00006580 05 05 05 01 01 01 01 01 01 01 01 00 00 00 00 00


Если предположить что уровень строится от левого нижнего угла то вроде бы всё сходится. Чтобы было наглядней, приведу начало каждой строки в обратном порядке по вертикали:

13 13 0C 0C 0C 05 05 05
13 04 04 04 04 01 01 01
13 04 04 04 04 01 01 01
13 04 04 04 04 01 01 01
13 04 04 04 04 01 01 01
13 04 04 04 04 01 01 01
13 04 04 04 04 00 00 00


Если сравнить со скриншотом в начале этой части, то можно увидеть закономерность. Отлично, подумал я, и решил это дело визуализировать.

2. Первая попытка визуализации


Встал вопрос, чем визуализировать. Связываться с каким либо форматом файла мне не особо хотелось, хотелось побыстрей проверить, что всё правильно. Посмотрев список установленных программ, я нашёл только две, которые могли бы помочь. Как ни странно это Excel, в котором можно построить диаграмму из столбов по каждому ряду, и 3D Studio Max. Он содержит язык макросов, и можно написать программу, которая по данным из файла генерирует макрос построение геометрии. Так я и сделал. С макросами в 3D Studio я не работал, но посмотрел при помощи инструмента их записи, как и что, устроено. Я накидал простую программу. Запустил, получил скрипт для макса и… И получилось совсем не то, что я ожидал.

Для тех кому интересен код
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];


void read_world();
void genBox(uint8_t x, uint8_t y, uint16_t high);
uint8_t getHigh(uint8_t x, uint8_t y);

FILE * max_out;

int main(){
    read_world();

    max_out = fopen("level.ms", "w");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
            genBox(x, y, getHigh(x,y));
        }
    }
    fclose(max_out);
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return map[y*64 + x];
}

void genBox(uint8_t x, uint8_t y, uint16_t high){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d]\" ", high*4, x*4, y*4, x ,y);
    color = color_map[(x % 2)][(y % 2)];

    if(high > 0){
            if(color == 1){
                fprintf(max_out, "wirecolor:(color 00 200 00)");
            } else {
                fprintf(max_out, "wirecolor:(color 00 150 00)");
            }
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fclose(file);
}


image

С других ракурсов





В общем местами геометрия угадывается, но не то, что бы сильно. Я пробовал поискать закономерности появления некоторых блоков, но так их и не нашел. Пришлось браться за дебагер.

3. Поиск принципов обозначения блоков.


Пришло осознание, что жизнь это боль. Придется посмотреть на ассемблер 6502 и попробовать понять, как оно там внутри работает. Итак, берем FCEUX-2.2.3, ну просто потому, что она уже у меня есть, и других инструментов я особо не знаю.

Что мы имеем на данный момент: есть блок в ROM 4 килобайта, игра как-то по нему строит сцену. Блок может находиться как в PPU, так и CPU пространстве, но есть надежда, что он активен в основное время.

Пояснение
Картриджи на денди часто имели памяти больше чем приставка могла адресовать, и чтобы она таки могла добираться до этой памяти, придумали маперы, в данном случае MMC1, игра делает «магические» записи по определенным адресам, после чего мапер меняет кусок памяти ROM который доступен для чтения приставкой. Подробнее тут.

Загрузил ROM, запустил игру и вышел в первый уровень. После чего открыл Debug->Hex Editor, сделал Edit → Find в качестве шаблона поиска выбил последовательность из смещения 0x63D0, а именно «13 04 04 04 04 00 00 00», и на этот раз мне повезло. Нашлось то что нужно по адресу 0xE3C0 (я догадываюсь что есть правильный способ поиска этого адреса, но мне было лень его искать).

Ставим брэйкпоинт на чтение этого байта, и немного пройдем вперед, чтобы игре нужно было перерисовать уровень, и видим вот какой код:

>00:A5EA:B1 08 LDA ($08),Y @ $E3C0 = #$13
00:A5EC:F0 14 BEQ $A602
00:A5EE:AA TAX
00:A5EF:BD 69 D0 LDA $D069,X @ $D069 = #$00


Что мы тут видим: первой командой читаем в регистр загружается A значением находящееся по адресу 0xE3C0, а именно там находится нижний левый угол уровня. Потом идёт проверка, что прочитали мы не ноль, дальше то, что прочитали, копируем в регистр X и используя его как смещение относительно адреса, 0xD069.

То есть по адресу 0xD069 хранится, что то преобразующее ID блока из адреса 0x63D0 во что то ещё.

Вспоминаем первую строку из карты

13 04 04 04 04 01 01 01

Посмотрим что хранится в памяти по таким смещениям

00 01 04 33 02 03 0C 0E 12 58 1F 40 04 3C 60 06 60 62 2C 05

Итого по смещению 0x13 мы видим 5, по смещению 0х04 мы видим 2, и по смещению 0x01 мы видим 1.

Если посмотреть на картинку начала первого уровня, то выглядит достаточно похоже. Ну что же, пришло время проверить. Щелкаем правой кнопкой по адресу 0xD069 и выбираем Go here in ROM File, после чего попадаем на адрес 0x5079. Модифицируем код генерирующий макрос.

Тот же код с доработками
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];
uint8_t high_map[256];


void read_world();
void genBox(uint8_t x, uint8_t y, uint16_t high);
uint8_t getHigh(uint8_t x, uint8_t y);

FILE * max_out;

int main(){
    read_world();

    max_out = fopen("level.ms", "w");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
            genBox(x, y, getHigh(x,y));
        }
    }
    fclose(max_out);
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return high_map[map[y*64 + x]];
}

void genBox(uint8_t x, uint8_t y, uint16_t high){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d]\" ", high*4, x*4, y*4, x ,y);
    color = color_map[(x % 2)][(y % 2)];

    if(high > 0){
            if(color == 1){
                fprintf(max_out, "wirecolor:(color 00 200 00)");
            } else {
                fprintf(max_out, "wirecolor:(color 00 150 00)");
            }
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fseek(file, 0x5079, SEEK_SET);
    readed = fread(high_map, 256, 1, file);
    printf("HighMap Readed: %d\r\n", readed);

    fclose(file);
}


Вот теперь то, что нужно геометрия уровня отлично угадывается, даже последние уровни есть, только они по высоте начинаются от нуля, видимо не хватило высоты уровня в 255 единиц. Благо он как бы в квадрате находится, а не раскинут по всей карте, значит просто можно приподнять его над остальными уровнями.



Другие ракурсы
Начало второго уровня:



Спрятавшиеся 9-10-11 уровни:



Кратенький итог по адресам 0x63D0 хранятся ID блоков, которые при помощи массива по адресу 0x5079 преобразуются в высоту блока.

Но блока кроме высоты есть ещё тип блока: земля, вода, люк, судя по картинке по полученной геометрии весы и плевательницы ниблов тоже имеют высоту из этого массива. Ну чтож значит надо как то поискать тип блока. А значит оставляем брейкпоинт на чтение ID первого блока и ждём сработает ли он где нибудь ещё. А он не срабатывает. Ну остается понадеяться, что оно находится где то по рядом с кодом который вычислял высоту. Посмотрим, что там есть похожего на выборку по индексу.

00:A5EA:B1 08 LDA ($08),Y
00:A5EC:F0 14 BEQ $A602
00:A5EE:AA TAX
00:A5EF:BD 69 D0 LDA $D069,X
00:A5F2:0A ASL
00:A5F3:69 02 ADC #$02
00:A5F5:85 04 STA $0004
00:A5F7:A5 72 LDA $0072
00:A5F9:38 SEC
..................................................
00:A62C:8A TXA
00:A62D:4A LSR
00:A62E:05 FA ORA $00FA
00:A630:AA TAX
00:A631:BD 6A CF LDA $CF6A,X
00:A634:90 04 BCC $A63A
00:A636:4A LSR
00:A637:4A LSR
00:A638:4A LSR
00:A639:4A LSR
00:A63A:29 0F AND #$0F


По адресу 0xA631, что то похожее, если понадеяться на то, что X у нас выше не меняется. Что же происходит в этом куске, нас интересует код начиная с 0xA62C

Итак:

  1. X переносим в A
  2. Для A делаем сдвиг вправо (при этом младший бит попадает в флаг процессора С)
  3. Делаем операцию OR с содержимым ячейки памяти 0x00FA
  4. Теперь уже A переносим в X
  5. Считываем A из ячейки 0xCF6A со смещением X
  6. Проверяем взведен ли флаг С, если нет то сразу идем на адрес 0xA63A, если же он установлен то делаем четыре сдвига вправо
  7. Выполняем AND c над регистром A с числом 0x0F (отрезаем верхний полубайт)

Дальше пошли всякие проверки, не очень хочется в них вникать. Посмотрел что было в ячейке 0xFA на момент выполнения, там лежал 0. Непонятно пока меняет оно или нет, а искать лень. Смотрим в памяти, что у нас находится по адресу 0xCF6A

00 70 70 00 67 56 57 6A 75 06

Вспомним первую строку уровня

13 04 04 04 04 01 01 01

Значит нам надо преобразовать эти числа по вышеописанному алгоритму в итоге получаем для всех 3 значений 0. Похоже на правду. Можно попробовать над каждым блоком написать его ID.
Дописываем программу, а за одно приподнимаем уровни 9-10-11.

Дописываем код
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];
uint8_t high_map[256];
uint8_t block_type[256];

void read_world();
void genBox(uint8_t x, uint8_t y, uint16_t high, uint8_t type);
void genText(uint8_t x, uint8_t y, uint16_t high, uint8_t type);
uint8_t getHigh(uint8_t x, uint8_t y);
uint8_t getBlockType(uint8_t x, uint8_t y);

#define LEVEL9_UP (114)

FILE * max_out;

int main(){
    uint32_t i;
    read_world();

    max_out = fopen("level.ms", "w+");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
                genBox(x, y, getHigh(x,y), getBlockType(x, y));
                genText(x,y,getHigh(x,y), getBlockType(x, y));
        }
    }
    fclose(max_out);
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return high_map[map[y*64 + x]];
}


uint8_t getBlockType(uint8_t x, uint8_t y){
    uint8_t block_id;
    uint8_t ret;

    block_id = map[y*64 + x];
    ret = block_type[block_id >> 1];
    if((block_id & 0x01) == 1) {
        ret = ret >> 4;
    }
    ret &= 0x0F;

    return ret;
}
void genText(uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    float fy;
    fy = y*4 - 1.5;
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "text size:5 font:\"Courier New\" text:\"%X\" pos:[%d,%03.01f,%d.1] wirecolor:(color 108 8 136) name:\"TX[%02d:%02d]\" \r\n", type, x*4, fy, high*4, x,y);
}

void genBox(uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d][BT%02X]\" ", high*4, x*4, y*4, x ,y, type);
    color = color_map[(x % 2)][(y % 2)];

    if((high > 0) && (high < 114)){
            if(color == 1){
                fprintf(max_out, "wirecolor:(color 00 200 00)");
            } else {
                fprintf(max_out, "wirecolor:(color 00 150 00)");
            }
    } else if (high >= 114){
        fprintf(max_out, "wirecolor:(color 200 200 250)");
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fseek(file, 0x5079, SEEK_SET);
    readed = fread(high_map, 256, 1, file);
    printf("HighMap Readed: %d\r\n", readed);


    fseek(file, 0x4F7A, SEEK_SET);
    readed = fread(block_type, 256, 1, file);
    printf("block_type Readed: %d\r\n", readed);

    fclose(file);
}



Немного картинок жуткого качества




Предположение оказалось верным, все блоки подписаны соответственно с их графическим представлением, но на последних уровнях, что-то не так, если смотреть на большую картинку, или прямо в игре, то видно, что некоторые одинаковые по функционалу блоки имеют разные ID. Вспоминаем про заигноренную выше ячейку памяти 0xFA. Ставим на запись в неё брэйкпоинт, и смотрим когда она меняется.

Пришлось пройти уровень, и только при переходе на следующий имеем срабатывание и вот такой кусок кода:

00:82D2:A5 AA LDA $00AA
00:82D4:C9 08 CMP #$08
00:82D6:6A ROR
00:82D7:29 80 AND #$80
>00:82D9:85 FA STA $00FA


Тут всё просто читаем, что было в ячейке 0xAA, сравниваем с 0x08, делаем сдвиг вправо, но не обычный, а когда в старший разряд берется из флага С, а он в свою очередь будет установлен командой CMP если в ячейке 0xAA было значение больше или равно 0x08. После при помощи AND очищаем все биты кроме старшего. И его пишем уже в 0xFA. Осталось узнать, что хранится в ячейке 0xAA, но тут нам на помощь приходит сайт на котором, мы нашли расположения уровня
http://datacrystal.romhacking.net/wiki/Snake_Rattle_N_Roll:RAM_map

И хранится там номер текущего уровня, причем уровни считаются от нуля. Из чего получаем для уровней с первого по восьмой там записано 0x00, для уровней больше восьмого 0x80. Правим код учитывая эту особенность, и получаем правильные значения по всем уровням.

Исправляем код
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];
uint8_t high_map[256];
uint8_t block_type[256];

void read_world();
void genBox(uint8_t x, uint8_t y, uint16_t high, uint8_t type);
void genText(uint8_t x, uint8_t y, uint16_t high, uint8_t type);
uint8_t getHigh(uint8_t x, uint8_t y);
uint8_t getBlockType(uint8_t x, uint8_t y);

#define LEVEL9_UP (114)

FILE * max_out;

int main(){
    uint32_t i;
    read_world();

    max_out = fopen("level.ms", "w+");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
                genBox(x, y, getHigh(x,y), getBlockType(x, y));
                genText(x,y,getHigh(x,y), getBlockType(x, y));
        }
    }
    fclose(max_out);
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return high_map[map[y*64 + x]];
}

uint8_t getBlockType(uint8_t x, uint8_t y){
    uint8_t block_id;
    uint8_t ret;
    uint8_t level_id;
    level_id = 0;
    if((x<29) && (y>35)){
        level_id = 0x80;
    }

    block_id = map[y*64 + x];
    ret = block_type[(block_id >> 1) | level_id];

    if((block_id & 0x01) == 1) {
        ret = ret >> 4;
    }
    ret &= 0x0F;

    return ret;
}

void genText(uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    float fy;
    fy = y*4 - 1.5;
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "text size:5 font:\"Courier New\" text:\"%X\" pos:[%d,%03.01f,%d.1] wirecolor:(color 108 8 136) name:\"TX[%02d:%02d]\" \r\n", type, x*4, fy, high*4, x,y);
}

void genBox(uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d][BT%02X]\" ", high*4, x*4, y*4, x ,y, type);
    color = color_map[(x % 2)][(y % 2)];

    if((high > 0) && (high < 114)){
            if(color == 1){
                fprintf(max_out, "wirecolor:(color 00 200 00)");
            } else {
                fprintf(max_out, "wirecolor:(color 00 150 00)");
            }
    } else if (high >= 114){
        fprintf(max_out, "wirecolor:(color 200 200 250)");
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fseek(file, 0x5079, SEEK_SET);
    readed = fread(high_map, 256, 1, file);
    printf("HighMap Readed: %d\r\n", readed);


    fseek(file, 0x4F7A, SEEK_SET);
    readed = fread(block_type, 256, 1, file);
    printf("block_type Readed: %d\r\n", readed);

    fclose(file);
}


4. Внешний вид блоков


ID Внешний вид Level 1-8
0 Обычный блок по которому можно ходить, если расположен на нулевой высоте (1,2 уровни), то выглядит как вода, но позволяет прыгать от неё, в отличии от воды на других высотах, раскрашен в виде шахматной доски, левая нижняя клетка светлая, дальше чередуются. По вертикали имеет узор переплетенной растительности.
2 Люк с крышкой, содержит внутри бонусы или ловушки, после пятого уровня не встречается
4 Ниблострел, снизу до верху имеет замысловатый механический узор, верх венчается двумя раструбами.
5 Обычный блок, по которому можно ходить, сверху имеет вид ровной поверхности, сбоку левой плоскости текстуру кирпичной кладки, с правой не то трещины, не то растения
6 Пирамидальный блок, при попытке приземлится на него, змея падает в пропасть
7 Тоже что и блок шесть только другого цвета
8 Весы, выглядят как шкала с делениями, и большим звонком сверху
A Блок воды если смотреть сверху, и водопад если сбоку
ID Внешний вид Level 9-11
0   Блок обозначает яму, куда можно провалится не отрисовывается никак
4 Место битвы с финальным босом, имеет вид лунной поверхности (или сыра:)
5 Ровный блок из льда, сверху текстура льда, сбоку потресканя ледяная поверхность
6 Пирамидальный блок, при попытке приземлится на него, змея падает в пропасть
8 Весы, выглядят как шкала с делениями, и большим звонком сверху
9 Ледяной блок с наклоном влево-вниз (на наблюдателя)
B Ледяной блок с наклоном вправо-вниз (на наблюдателя)

Этой информации достаточно чтобы построить 3D модель и распечатать её. Но сейчас возникла проблема, а именно принтер на работе, а я на самоизоляции. И это только первая проблема. Если загрузить полную модель в слайсер, то при размере одной клетки в четыре миллиметра, и заполнением в десять процентов, полученная модель будет печататься пять суток. А учитывая что модель таких размеров в наш скромный рабочий принтер не помещается. Печатать придется частями, что ещё увеличит время печати. Поэтому отложим это на будущее. Но пока азарт исследования этой игры не угас, продолжим изучать, что ещё сможем вытащить из игры.

5. Бонусы в люках


В первых пяти уровня, на карте расставлены люки в которых могут быть: ловушки, бонусные вещи, переходы в бонус уровень или варп на другой уровень. Можно конечно пройтись по уровням и всё переписать вручную, но это быстро и скучно. А можно попытаться поискать, где и как оно хранится в игре. На этот раз я выбрал вариант с загрузкой/сохранением состояния игры, и инструментом Tool->Ram search… И при помощи вариантов Equal / Not Equal начал смотреть что изменяется. Через несколько попыток стало заметно, что меняется ячейка 0x1B1, до открывания люка там 0x41 после открывания 0x4E. Попробовал открыть люк рядом поменялась ячейка 0x1B3, было 0x29 стало 0x2E. Уже сейчас можно заметить, что меняются в обоих случаях младшие четыре бита. Но мы всё таки поставим брэйкпоинт, и посмотрим легко ли разобраться как игра оперирует с этими байтами.

Пытаемся открыть люк и попадаем вот в такой кусок кода:

01:CCD7:A0 FE LDY #$FE
01:CCD9:C8 INY
01:CCDA:C8 INY
01:CCDB:BD D7 04 LDA $04D7,X @ $04D7 = #$85
01:CCDE:29 F0 AND #$F0
01:CCE0:1D C3 04 ORA $04C3,X @ $04C3 = #$00
01:CCE3:59 B0 01 EOR $01B0,Y @ $01B0 = #$80
01:CCE6:D0 F1 BNE $CCD9
01:CCE8:B9 B1 01 LDA $01B1,Y @ $01B1 = #$41
01:CCEB:5D FF 04 EOR $04FF,X @ $04FF = #$49
01:CCEE:29 F0 AND #$F0
01:CCF0:D0 E7 BNE $CCD9
01:CCF2:B9 B1 01 LDA $01B1,Y @ $01B1 = #$41
01:CCF5:48 PHA
01:CCF6:29 0F AND #$0F
01:CCF8:C9 06 CMP #$06
01:CCFA:F0 04 BEQ $CD00


Всё расписывать не буду, скажу лишь, что здесь http://datacrystal.romhacking.net/wiki/Snake_Rattle_N_Roll:RAM_map описаны адреса
0x4D7и 0x4C3, а именно это координаты игрока по X на карте мира в «пикселах», причем ширина клетки составляет шестнадцать пикселов, это установлено ручным замером. Получается, что старшая часть байта 0x4D7 и младшая 0x4C3 образую координату по X в блоках. Только здесь эти части байта поменяны местами, и сравниваются с ячейкой 0x1B0 а там в данный момент как раз хранится 0x80 (а первый люк который я проверял как раз и находится по координатам 8:4 если считать от нуля). В ячейке 0x4FF хранится координата игрока по Y и от ней используется только старшая часть для сравнения. Ну и наконец после всего этого берется младшие четыре бита и дальше идет куча сравнений. Получается этот кусок кода ищет координаты открытого люка начиная с адреса 0x1B0 и дальше смещаясь каждый шаг на два байта, и так до места пока не найдет нужный люк. Выхода по невозможности найти не предусмотрено. Поэтому если изменить координаты на несуществующие, то при попытке открыть люк игра повиснет.

Срываем покровы
На самом деле автор сейчас вводит в заблуждение читателей, так как разбор данных идет достаточно давно, и в итоге первый раз автор расшифровывал данные аналитическим путем, не заглядывая в код. Именно во время поиска и всяческих экспериментов выяснилось что игра виснет. Но сейчас он осознал, что так проще, правильней и быстрей.

В итоге тип бонуса должен хранится в последних четырех битах, 0x1B1, что при помощи HEX редактора можно легко проверить. Поэкспериментировав, получаем вот такой список значений последнего байта:
ID Описание
0x0 Красная бомба
0x1 Красный шар
0x2 Инверсия движения
0x3 Ускорение
0x4 Зубастик
0x5 Будильник
0x6 Переход в бонус
0x7 Жизнь
0x8 Нога
0x9 Синий/фиолетовый шар
0xA Желтый шар
0xB Неуязвимость
0xC Переход в варп
0xD Бомба жизнь (бонус который в начале выглядит как жизнь но потом превращается в бобму)
0xE Пусто (возможно первая буква от Empty а может и просто совпадение)
0xF Переход в варп

Так теперь надо разобраться, где это хранится в ROMe. И как грузится в память. Ставим брейкпоинт на запись в ячейку 1B0, и по неизвестной мне причине FCEUX начинает реагировать на код LDA $001B хотя здесь вроде бы чтение, а не запись и не из той ячейки. Если вдруг кто знает, почему так происходит напишите в комментариях.

Ладно сделаем допущение, что запись в ячейку происходит инструкцией STA и значит в A в момент входа на первый уровень должно равняться 0x80 поможем FCEUX добавив условие в брейкпоинт A==#80

И получаем нужное место в коде:

00:8381:A4 AA LDY $00AA = #$00
00:8383:B9 00 07 LDA $0700,Y
00:8386:A8 TAY
00:8387:B9 00 07 LDA $0700,Y
00:838A:9D B0 01 STA $01B0,X
00:838D:E8 INX
00:838E:C8 INY
00:838F:E0 30 CPX #$30
00:8391:D0 F4 BNE $8387


С начало сохраняем в Y номер уровня, потом в A читаем число по смещению 0x700+Номер_уровня, переносим A в Y. Потом читаем байт по смещению 0x700+Y и копируем его в 0x1B0+X, инкрементируем X и Y, проверяем что X неравен 0x30 и если это так повторяем цикл копирования.

Посмотрим, что лежит по смещению 0x700 в памяти в момент копирования:

06 1C 32 50 6A 7E 80 41 80 29 71

Следуя алгоритму для первого уровня, мы берем значение по адресу 0x700 в данном случае это 0x06 и потом копируем 48 байт из адреса 0x700+06 в область 0x1B0. После проверки удалось убедиться, что данные именно те, что нам, и были нужны. Дальше получается интересная вещь, бонусы всегда копируются по 48 байт. Но если глянуть на первые шесть смещений (напомню после шестого уровня люки больше не встречается), то становится, очевидно, что данные в памяти между уровнями пересекаются, хотя зная как проверяются бонусы можно сказать, что это не проблема. Теперь осталось найти где эти данные хранятся в ROMе. Так как эти данные хранятся по адресу 0x700, а это RAM, значит они были подгружены туда из вне.

Можно поискать место, где они подгружаются, а можно попытать удачу и поискать вышеуказанною последовательность в ROM. И единственное вхождение такой записи, по адресу 0xF4D0 теперь посчитаем длину блока, смещение для шестого уровня 0x7E длинна блока 0x30 итого 0xAE.

Загрузив и распарсив все бонусы разом, получилось три пересечения. Про одно я знаю, что оно верное это (клетка 14:11), хитрое место в которое можно добраться из двух уровней разом, и в пятом там будет будильник, а вот в шестом это будет варп на восьмой уровень. Ещё два видимо совпали из-за того, что лежат на одной прямой [54:51] и [54:03], 51 в шестнадцатеричной системе это 0x33, а по Y у нас проверяются только первые 4 бита, вот они и совпадают в итоге. В случае необходимости можно отсечь харкодом. Отрисовывать в графике мне это было лень я просто вывел в консоль. Убедился, что данные совпадают с ожидаемыми. И так и оставил, всё равно пока не ясно, что с этим делать дальше. А у нас ещё есть несколько мест, которые было бы интересно прояснить.

6. Бонусные уровни


В первых четырех уровнях есть переходы на бонусные уровни, где можно спокойно ничего не боясь покушать ниблов. Хранятся они, где-то отдельно. Первый бонус уровень очень простой в плане геометрии, возвышенность, и порядка 12 клеток единичной высоты. Но попытка поискать это по шаблону в ROM потерпела неудачу. Это уже хуже, значит дальше может быть куча веселья, а может и не быть. Если предположить, что бонусы строятся также как и остальной уровень, то они должны использовать таблицу по адресу 0xD069. Подходим к переходу в бонус, ставим брейпоинт на чтение 0xD069-0xD168 и пробуем перейти в бонус, игра постоянно читает по этим адресам. Поэтому перейти в бонус при работающем брейкпоинте, было затруднительно. Но в если включить брейк поинт, в момент перехода в бонус уровень, то брейпоинт сработает уже в нужный момент.

В том же месте где и в обычных уровнях. 00:8A37:B1 08 LDA ($08),Y @ $0288 = #$01
00:8A39:85 81 STA $0081 = #$01
00:8A3B:A8 TAY
>00:8A3C:B9 69 D0 LDA $D069,Y @ $D06A = #$01


Но адрес по которому читается уровень изменен, теперь чтение идет из памяти приставки, а не ROM. Посмотрим что там лежит:



достаточно отчетливо видно, что где-то между 0x200 и 0x300 лежит бонус уровень. Только правый верхний угол, тут стал левым нижним. Надо теперь понять, как он там оказывается. Ну чтож берем какую ни будь ячейку из блока (я взял 0x221 там лежало хорошо узнаваемое 0x3F), и ставим на неё брейпоинт по записи. Во время игры туда постоянно что-то пишется, поэтому добавим условие A==#3F

И вот оно:

:0713:A5 AA LDA $00AA = #$00
:0715:0A ASL
:0716:85 8F STA $008F = #$00
:0718:A8 TAY
:0719:B9 5B 07 LDA $075B,Y @ $075B = #$01
:071C:8D 06 20 STA PPU_ADDRESS = #$DE
:071F:B9 5C 07 LDA $075C,Y @ $075C = #$DA
:0722:8D 06 20 STA PPU_ADDRESS = #$DE
:0725:A9 02 LDA #$02
:0727:85 C7 STA $00C7 = #$02
:0729:AD 07 20 LDA PPU_DATA = #$01
:072C:A2 00 LDX #$00
:072E:AD 07 20 LDA PPU_DATA = #$01
> :0731:9D 00 02 STA $0200,X @ $0221 = #$00
:0734:AD 07 20 LDA PPU_DATA = #$01
:0737:85 04 STA $0004 = #$00
:0739:BD 00 02 LDA $0200,X @ $0221 = #$00
:073C:E8 INX
:073D:F0 09 BEQ $0748
:073F:9D 00 02 STA $0200,X @ $0221 = #$00
:0742:C6 04 DEC $0004 = #$00
:0744:D0 F6 BNE $073C
:0746:F0 E6 BEQ $072E


Вот тут уже пришлось покопаться, поискать как и что работает, хотя опереленные знания о работе NES у меня были. Небольшое отступление, картридж содержит два вида памяти, память программы и память спрайтов для видеопроцессора (это упрощенно). К памяти программы процессор имеет непосредственный доступ, а вот к памяти видеопроцессора доступа есть только через регистры видео процессора. Ну и та и другая могут быть разбиты на переключаемые страницы при помощи маппера. Так вот в этом куске данные вытаскиваются как раз из памяти видеопроцессора.

Подробней по работе PPU можно почитать тут. Да небольшое дополнение FCEUX может заменять адреса регистров на понятные имена, в данном листинге PPU_ADDRESS это 0x2006, PPU_DATA = 0x2007.

Ну а теперь разберем, что и как. В начале читается номер уровня, потом ему делается сдвиг влево, что аналогично умножению на два, и переносится в Y. Затем по делается чтения по адресу 0x75B+Y и отправляется регистр адреса PPU, далее тоже самое повторяется для адреса 0X75C+Y. Этим мы указали адрес, с которого хотим читать данные из PPU. После делается пустое чтение из регистра данных PPU, это особенность работы PPU, после записи первое чтение будет содержать устаревшие данные. А теперь начинается самое интересное. Регистр X обнуляется, и происходит первое чтение из PPU, которое пишется по адресу 0x200+X, читается следующее значение из PPU сохраняется по адресу 0x04, потом вычитывается то, что мы с сохранили в 0x200+X в регистр A, инкрементится X и если он стал равен нулю идёт прыжок на выход из этой подпрограммы, если нет, то опять сохраняем полученное значение, а ячейке 0x200+X, уменьшаем значение в ячейке 0x04, и если оно не равно нулю прыгаем на инкремент X, если же равно, то прыгаем снова на чтение данных из дата регистра PPU.

Если описывать проще то это вариация на тему RLE кодирование, первый байт описывает, что именно мы пишем в память, второй сколько раз мы это делаем. Размер бонуса 256 байт, что дает размер комнаты 16x16.

А по адресу 0x75B хранится восемь байт описывающие смещение уровней в PPU по два байта на смещение, итого четыре бонус уровня. Смещения уровней таковы:

0x01DA
0x022A
0x029E
0x030E


Переключаем HEX редактор FCEUX в отображение PPU (View->PPU Memory) и идем по указанному смещению там видим 00 21 3F 01 01 0C, (важно это делать в момент загрузки уровня, иначе игра может переключить банк памяти и по указанному смещению уже будет непонятно что). Если расшифровать по указанному алгоритму, то вполне похоже на первый бонус. Поищем эту последовательность в ROM файле, и она находится по адресу 0xE1EA, попробуем отрисовать. И вся геометрия получается как в и игре, то, что и хотелось:

Бонусные уровни





Доработанная программа
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];
uint8_t high_map[256];
uint8_t block_type[256];

uint8_t bonus_offset[6];
uint8_t bonuses[84][2];

uint8_t bonus_levels[1024];
uint16_t bl_offsets[4] = {
    0x0000,
    0x022A - 0x01DA,
    0x029E - 0x01DA,
    0x030E - 0x01DA,
};

void read_world();
void genBox(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type);
void genText(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type);
uint8_t getHigh(uint8_t x, uint8_t y);
uint8_t getBlockType(uint8_t x, uint8_t y);
void bonuses_dec();
#define LEVEL9_UP (114)

FILE * max_out;

int main(){
    uint32_t i;
    read_world();

    max_out = fopen("level.ms", "w+");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
                genBox(max_out, x, y, getHigh(x,y), getBlockType(x, y));
                genText(max_out,x, y, getHigh(x,y), getBlockType(x, y));
        }
    }
    fclose(max_out);

    bonuses_dec();


    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
            if(getBlockType(x, y) == 2){
                printf("BN[%02u:%02u] = [", x, y);
                for(uint8_t n=0; n<84; n++){
                    uint8_t bx, by, bt;
                    bx = ((bonuses[n][0] >> 4)& 0x0F) | ((bonuses[n][0] << 4) & 0xF0);
                    by = ((bonuses[n][1] >> 4)& 0x0F);
                    bt = bonuses[n][1] & 0x0F;
                    if((bx == x) && ((y & 0x0F) == by)){
                        printf("[%02u]%X ", n, bt);
                    }
                }
                printf("]\r\n");
            }
        }
    }
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return high_map[map[y*64 + x]];
}


uint8_t getBlockType(uint8_t x, uint8_t y){
    uint8_t block_id;
    uint8_t ret;
    uint8_t level_id;
    level_id = 0;
    if((x<29) && (y>35)){
        level_id = 0x80;
    }

    block_id = map[y*64 + x];
    ret = block_type[(block_id >> 1) | level_id];

    if((block_id & 0x01) == 1) {
        ret = ret >> 4;
    }
    ret &= 0x0F;

    return ret;
}
void genText(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    float fy;
    fy = y*4 - 1.5;
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "text size:5 font:\"Courier New\" text:\"%X\" pos:[%d,%03.01f,%d.1] wirecolor:(color 108 8 136) name:\"TX[%02d:%02d]\" \r\n", type, x*4, fy, high*4, x,y);
}

void genBox(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d][BT%02X]\" ", high*4, x*4, y*4, x ,y, type);
    color = color_map[(x % 2)][(y % 2)];

    if((high > 0) && (high < 114)){
            if(type != 0xA){
                if(color == 1){
                    fprintf(max_out, "wirecolor:(color 00 200 00)");
                } else {
                    fprintf(max_out, "wirecolor:(color 00 150 00)");
                }
            } else {
                fprintf(max_out, "wirecolor:(color 00 00 230)");
            }
    } else if (high >= 114){
        fprintf(max_out, "wirecolor:(color 200 200 250)");
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void bonuses_dec(){
    uint32_t cnt, offset, i, j;
    uint8_t item, count, bnn;
    uint8_t x, y, block_id;
    FILE * file;
    char fname[32];
    for(i=0; i<4; i++){
        printf("Decode bonus level %d\r\n", i+1);
        sprintf(fname, "bonus_level_%d.ms", i+1);
        file = fopen(fname, "w+");
        offset = bl_offsets[i];
        cnt = 0;
        x = 0; y = 0;
        do {
            item = bonus_levels[offset++];
            count = bonus_levels[offset++];
            for(j = 0; j < count; j++){
                cnt++;

                block_id = block_type[(item >> 1)];
                if((item & 0x01) == 1) {
                    block_id = block_id >> 4;
                }
                block_id &= 0x0F;

                genBox(file, x, y, high_map[item], block_id);
                genText(file,x, y, high_map[item], block_id);

                x++;
                if(x > 15){ y++; x = 0;}
            }
        } while (cnt<256);
        fclose(file);
    }
}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fseek(file, 0x5079, SEEK_SET);
    readed = fread(high_map, 256, 1, file);
    printf("HighMap Readed: %d\r\n", readed);


    fseek(file, 0x4F7A, SEEK_SET);
    readed = fread(block_type, 256, 1, file);
    printf("Block_type Readed: %d\r\n", readed);

    fseek(file, 0xF4D0, SEEK_SET);
    readed = fread(bonus_offset, 6, 1, file);
    printf("Bonuses offsets: %d\r\n", readed);

    fseek(file, 0xF4D0+6, SEEK_SET);
    readed = fread(bonuses, 168, 1, file);
    printf("Bonuses Readed: %d\r\n", readed);

    fseek(file, 0xE1EA, SEEK_SET);
    readed = fread(bonus_levels, 1024, 1, file);
    printf("Bonus levels Readed: %d\r\n", readed);


    fclose(file);
}

7. Подводные уровни


А без чего не может обойтись любая хорошая игра, правильно без подводного уровня. Сразу вспоминается подводный уровень в первых Черепашках Ниндзя, батискаф из Червяка Джима, Crash Bandicoot 3, и даже части уровней в Unreal. В общем даже если у них и была нормальная сложность, получать удовольствие от них у меня никогда не получалось. Есть такие уровни в этой замечательной игре, и о боже они здесь просто чудесны, после достаточно сложного седьмого уровня, и перед очень сложными девятым и десятым, нам дают чисто расслабиться и перевести дух, спасибо разработчикам Rare за это. Но хватит лирики. Восьмой уровень по большей части состоит из пяти (пятую можно пропустить) подводных комнат, по виду они похожи на комнаты бонус уровней, и геометрия просто так в роме не ищется. Поискав, там же где были, бонус уровни тоже ничего не нашлось. Повторяем всё то, что делали для бонусных уровней и находим, что теперь уровень лежит по адресу 0x700, попробуем отследить, кто его туда выкладывает. Получаем такой кусок:

00:842B:A2 00 LDX #$00
00:842D:BD 00 02 LDA $0200,X @ $0200 = #$81
>00:8430:9D 00 07 STA $0700,X @ $0700 = #$55
00:8433:E8 INX
00:8434:D0 F7 BNE $842D


То есть, кто уровень грузят изначально по адресу 0x200, а потом копируют на 0x700, переставляем брэкйпоинт на 0x200, и попадаем в тот же кусок, что и для бонусных уровней. Но бонус выбирался в зависимости от номера уровня, а тут пять разных комнат, и номер уровня не меняется. Значит есть шанс, что сюда попадаем из уже при правильно установленном Y.

Пришло время попробовать трэйсер кода, запускаем Debug → Trace Logger… ставим 100 строк должно хватить, и в момент срабатывания брэйкпоинта видим следующее:

A:00 X:00 Y:00 S:FA P:nvUBdIZc $070A:A5 C5 LDA $00C5 = #$01
A:01 X:00 Y:00 S:FA P:nvUBdIzc $070C:F0 05 BEQ $0713
A:01 X:00 Y:00 S:FA P:nvUBdIzc $070E:18 CLC
A:01 X:00 Y:00 S:FA P:nvUBdIzc $070F:69 03 ADC #$03
A:04 X:00 Y:00 S:FA P:nvUBdIzc $0711:D0 02 BNE $0715
A:04 X:00 Y:00 S:FA P:nvUBdIzc $0715:0A ASL
A:08 X:00 Y:00 S:FA P:nvUBdIzc $0716:85 8F STA $008F = #$00
A:08 X:00 Y:00 S:FA P:nvUBdIzc $0718:A8 TAY
A:08 X:00 Y:08 S:FA P:nvUBdIzc $0719:B9 5B 07 LDA $075B,Y @ $0763 = #$06
A:06 X:00 Y:08 S:FA P:nvUBdIzc $071C:8D 06 20 STA PPU_ADDRESS = #$54
A:06 X:00 Y:08 S:FA P:nvUBdIzc $071F:B9 5C 07 LDA $075C,Y @ $0764 = #$78
A:78 X:00 Y:08 S:FA P:nvUBdIzc $0722:8D 06 20 STA PPU_ADDRESS = #$54

И так оно и есть, номер комнаты берется из ячейки 0xС5 и считается от одного, потом к нему прибавляется три, и дальше, так же как и с бонус уровнями. Получаем смещения уровней:

0x0678
0x06FE
0x0774
0x07DC
0x07DC


И мы видим, что два последних уровня совпадают, а так оно и есть в игре. Смотрим где эти уровни расположены в ROM, эти значения и находим адрес 0xE688. Правим код и делам расшифровку. И вот они подводные уровни, которые целиком в игре и не видны, только так можно их рассмотреть целиком.

Подводные уровни







Код генератор, который писать к этому моменту мне уже поднадоело
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t map[4096];
uint8_t high_map[256];
uint8_t block_type[256];

uint8_t bonus_offset[6];
uint8_t bonuses[84][2];

uint8_t bonus_levels[1024];
uint16_t bl_offsets[4] = {
    0x0000,
    0x022A - 0x01DA,
    0x029E - 0x01DA,
    0x030E - 0x01DA,
};
uint8_t uw_levels[1024];
uint16_t uw_offsets[5] = {
    0x0000,
    0x06FE - 0x0678,
    0x0774 - 0x0678,
    0x07DC - 0x0678,
    0x07DC - 0x0678,
};

void read_world();
void genBox(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type);
void genText(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type);
uint8_t getHigh(uint8_t x, uint8_t y);
uint8_t getBlockType(uint8_t x, uint8_t y);
void bonuses_dec();
void underwater_dec();
#define LEVEL9_UP (114)

FILE * max_out;

int main(){
    uint32_t i;
    read_world();

    max_out = fopen("level.ms", "w+");
    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
                genBox(max_out, x, y, getHigh(x,y), getBlockType(x, y));
                genText(max_out,x, y, getHigh(x,y), getBlockType(x, y));
        }
    }
    fclose(max_out);

    bonuses_dec();
    underwater_dec();


    for(uint8_t y=0; y<64; y++){
        for(uint8_t x=0; x<64; x++){
            if(getBlockType(x, y) == 2){
                printf("BN[%02u:%02u] = [", x, y);
                for(uint8_t n=0; n<84; n++){
                    uint8_t bx, by, bt;
                    bx = ((bonuses[n][0] >> 4)& 0x0F) | ((bonuses[n][0] << 4) & 0xF0);
                    by = ((bonuses[n][1] >> 4)& 0x0F);
                    bt = bonuses[n][1] & 0x0F;
                    if((bx == x) && ((y & 0x0F) == by)){
                        printf("[%02u]%X ", n, bt);
                    }
                }
                printf("]\r\n");
            }
        }
    }
    return 0;
}

uint8_t getHigh(uint8_t x, uint8_t y){
    return high_map[map[y*64 + x]];
}


uint8_t getBlockType(uint8_t x, uint8_t y){
    uint8_t block_id;
    uint8_t ret;
    uint8_t level_id;
    level_id = 0;
    if((x<29) && (y>35)){
        level_id = 0x80;
    }

    block_id = map[y*64 + x];
    ret = block_type[(block_id >> 1) | level_id];

    if((block_id & 0x01) == 1) {
        ret = ret >> 4;
    }
    ret &= 0x0F;

    return ret;
}
void genText(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    float fy;
    fy = y*4 - 1.5;
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "text size:5 font:\"Courier New\" text:\"%X\" pos:[%d,%03.01f,%d.1] wirecolor:(color 108 8 136) name:\"TX[%02d:%02d]\" \r\n", type, x*4, fy, high*4, x,y);
}

void genBox(FILE * max_out, uint8_t x, uint8_t y, uint16_t high, uint8_t type){
    uint8_t color;
    uint8_t color_map[2][2] = {{1,0}, {0,1}};
    if((x<29) && (y>35)){
        high += LEVEL9_UP;
    }

    fprintf(max_out, "Box lengthsegs:1 widthsegs:1 heightsegs:1 length:4 width:4 height:%d mapCoords:off pos:[%d,%d,0] name:\"Box[%02d:%02d][BT%02X]\" ", high*4, x*4, y*4, x ,y, type);
    color = color_map[(x % 2)][(y % 2)];

    if((high > 0) && (high < 114)){
            if(type != 0xA){
                if(color == 1){
                    fprintf(max_out, "wirecolor:(color 00 200 00)");
                } else {
                    fprintf(max_out, "wirecolor:(color 00 150 00)");
                }
            } else {
                fprintf(max_out, "wirecolor:(color 00 00 230)");
            }
    } else if (high >= 114){
        fprintf(max_out, "wirecolor:(color 200 200 250)");
    } else {
        fprintf(max_out, "wirecolor:(color 00 00 255)");
    }

    fprintf(max_out, "\r\n");

}

void bonuses_dec(){
    uint32_t cnt, offset, i, j;
    uint8_t item, count, bnn;
    uint8_t x, y, block_id;
    FILE * file;
    char fname[32];
    for(i=0; i<4; i++){
        printf("Decode bonus level %d\r\n", i+1);
        sprintf(fname, "bonus_level_%d.ms", i+1);
        file = fopen(fname, "w+");
        offset = bl_offsets[i];
        cnt = 0;
        x = 0; y = 0;
        do {
            item = bonus_levels[offset++];
            count = bonus_levels[offset++];
            for(j = 0; j < count; j++){
                cnt++;

                block_id = block_type[(item >> 1)];
                if((item & 0x01) == 1) {
                    block_id = block_id >> 4;
                }
                block_id &= 0x0F;

                genBox(file, x, y, high_map[item], block_id);
                genText(file,x, y, high_map[item], block_id);

                x++;
                if(x > 15){ y++; x = 0;}
            }
        } while (cnt<256);
        fclose(file);
    }
}

void underwater_dec(){
    uint32_t cnt, offset, i, j;
    uint8_t item, count, bnn;
    uint8_t x, y, block_id;
    FILE * file;
    char fname[32];
    for(i=0; i<5; i++){
        printf("Decode underwater level %d\r\n", i+1);
        sprintf(fname, "underwater_level_%d.ms", i+1);
        file = fopen(fname, "w+");
        offset = uw_offsets[i];
        cnt = 0;
        x = 0; y = 0;
        do {
            item = uw_levels[offset++];
            count = uw_levels[offset++];
            for(j = 0; j < count; j++){
                cnt++;

                block_id = block_type[(item >> 1)];
                if((item & 0x01) == 1) {
                    block_id = block_id >> 4;
                }
                block_id &= 0x0F;

                genBox(file, x, y, high_map[item], block_id);
                genText(file,x, y, high_map[item], block_id);

                x++;
                if(x > 15){ y++; x = 0;}
            }
        } while (cnt<256);
        fclose(file);
    }
}

void read_world(){
    uint32_t readed;
    FILE * file;

    file = fopen("Snake_Rattle'n_Roll_(U).nes", "rb");

    fseek(file, 0x63D0, SEEK_SET);
    readed = fread(map, 4096, 1, file);
    printf("Map Readed: %d\r\n", readed);

    fseek(file, 0x5079, SEEK_SET);
    readed = fread(high_map, 256, 1, file);
    printf("HighMap Readed: %d\r\n", readed);


    fseek(file, 0x4F7A, SEEK_SET);
    readed = fread(block_type, 256, 1, file);
    printf("Block_type Readed: %d\r\n", readed);

    fseek(file, 0xF4D0, SEEK_SET);
    readed = fread(bonus_offset, 6, 1, file);
    printf("Bonuses offsets: %d\r\n", readed);

    fseek(file, 0xF4D0+6, SEEK_SET);
    readed = fread(bonuses, 168, 1, file);
    printf("Bonuses Readed: %d\r\n", readed);

    fseek(file, 0xE1EA, SEEK_SET);
    readed = fread(bonus_levels, 1024, 1, file);
    printf("Bonus levels Readed: %d\r\n", readed);

    fseek(file, 0xE688, SEEK_SET);
    readed = fread(uw_levels, 1024, 1, file);
    printf("Underwater levels Readed: %d\r\n", readed);


    fclose(file);
}

8. Бонусы на карте


А как выяснилось в процессе не только бонусы

Кроме бонусов к люках, есть ещё просто бонусы, разбросанные по карте. Хорошо бы найти и то, как они хранятся. Так же как и с люках, поискав изменяющиеся ячейки, нашел, что когда берешь первый бонус то меняется значение в ячейке 0x692 c 0x34 на 0x00. Тоже самое происходит для ячейки 0x6A0 если взять жизнь возле водопада, и для ячейки 0x699 если взять ещё один бонус.Если вернуть в эти ячейки 0x34 то бонусы появляется вновь. Путем не хитрых поисков закономерности, можно заметить, что между этими элементами находится по семь байт. Дальнейшие эксперименты показали, что если принять 0x692 за первый байт, в последовательности то седьмой отвечает за тип бонуса. За что отвечает шестой непонятно, а вот со второго по пятый как-то отвечают за координаты. Разберемся попозже, сейчас надо найти откуда это все переносится в память, и какова длинна этого блока. Ставим брэйкпоинт на запись 0x692 и для удобства добавляем условие A==#34 Пробуем войти на первый уровень. Попадаем вот в такое место:

:0206:A5 C5 LDA $00C5
:0208:F0 02 BEQ $020C
:020A:69 03 ADC #$03
:020C:65 AA ADC $00AA
:020E:0A ASL
:020F:A8 TAY
:0210:AD 02 20 LDA PPU_STATUS
:0213:B9 55 02 LDA $0255,Y
:0216:8D 06 20 STA PPU_ADDRESS
:0219:B9 54 02 LDA $0254,Y
:021C:8D 06 20 STA PPU_ADDRESS
:021F:B9 56 02 LDA $0256,Y
:0222:38 SEC
:0223:F9 54 02 SBC $0254,Y
:0226:85 AC STA $00AC
:0228:AA TAX
:0229:A0 00 LDY #$00
:022B:84 AB STY $00AB
:022D:AD 07 20 LDA PPU_DATA
:0230:AD 07 20 LDA PPU_DATA
:0233:99 53 06 STA $0653,Y
:0236:AD 07 20 LDA PPU_DATA
:0239:99 54 06 STA $0654,Y
:023C:AD 07 20 LDA PPU_DATA
:023F:99 55 06 STA $0655,Y
:0242:AD 07 20 LDA PPU_DATA
:0245:99 56 06 STA $0656,Y
:0248:98 TYA
:0249:18 CLC
:024A:69 04 ADC #$04
:024C:A8 TAY
:024D:8A TXA
:024E:E9 03 SBC #$03
:0250:AA TAX
:0251:B0 DD BCS $0230
:0253:60 RTS


Статья разрослась, поэтому краткое изложение такое. Берется значение из ячейки 0x00C5 (номер комнаты подводного уровня) если он не ноль к нему прибавляют три, потом к полученному числу прибавляют текущий номер уровня, и умножают полученное на два. По полученному индексу идут в таблицу лежащую в момент загрузки уровня по адресу 0x254. Берут оттуда смещение, после чего рассчитывается, размер блока, и блок из PPU копируется по адресу 0x653. Смотрим, что в этот момент находится в PPU, а потом ищем такое же совпадение в ROM файле, и получаем адрес 0xE906.

C этим разобрались, теперь посмотрим, как оно закодировано, возьмем для примера блок первого уровня:

0A 00 0E 38 20 41 00
3F 00 B8 38 1C 00 00
0A 60 6E 98 10 40 00
0B 60 59 82 23 00 23
35 60 78 98 10 21 40
35 60 18 58 10 51 80
35 40 58 48 10 60 A0
35 00 E8 58 10 21 80
35 00 68 58 10 40 60
34 00 48 38 20 00 70
34 20 38 08 10 00 70
34 20 18 88 60 00 72
34 20 B8 88 50 00 6D
21 20 68 78 29 00 00
21 40 E8 58 29 00 00
27 40 68 48 10 00 00
26 00 28 D8 29 00 05

В первой колонке видим число, 0x34 которое как мы видели выше, меняется на 0x00, если бонус взять. И повторяется оно четыре раза. Если пробежаться по уровню то там есть четыре бонуса, два удлинителя языка, жизнь и будильник. Также мы видим, что в седьмой колонке два совпадение 0x70 и два разных числа. Можно предположить, что седьмая колонка отвечает за тип бонуса. После экспериментов это частично подтвердилось.

Но что будет, если 0x34 поменять на что ни будь другое, тут например 0x35 тоже повторяется. Меняем и «жизнь» превращается в «шашку». Значит, первое число отвечает за тип элемента. За что отвечают остальные колонки, путем экспериментов удалось частично разобраться. За координаты отвечают байты со второго по шестой, шестой и седьмой байты отвечают за параметры, и для каждого типа элемента расшифровываются по своему. Координаты тоже достаточно странно закодированы, графически можно представить так:



Для тех кому понятней код, запись такая:

uint16_t x = data[2] + (256 * ((data[1] & 0xF0) >> 5)) ;
uint16_t y = data[3] + (256 * ((data[1] & 0x0F) >> 2));
uint16_t z = data[4] + (256 * (((data[1] << 1) | ((data[5]) >> 7))  & 0x7));

Элементы массива считаются от нуля, как и положено. Надо учитывать, что последние уровни мы приподнимали при рендеринге, то же самое надо делать и с этими «вещами». Как оказалось, в этом массиве закодированы не только бонусы, но и разные элементы карты, враги, места где расположены генераторы нибблов (как оказалось на карте только графическое представление, а сам генератор берется из этого списка), а также двери и многое другое. У многих элементов в последних двух байтах закодированы их параметры, все я не разбирал, но что узнал, опишу. Хотя многое требует уточнения. Список получился достаточно внушительный:
ID Описание
0x0A Дверь, через которую змея входит или выходит с уровня, в параметрах закодировано вход это или выход, и ориентация двери
0x0B Весы, а точней язычок весов, в параметрах, кажется, указана начальная высота язычка весов
0x0D «Спихивалка» змей, есть в пятом и шестом уровнях, в параметрах скорей всего направление в котором она работает
0x0E Лезвия, атакующие змей, в параметрах направление движения лезвия
0x10 Скорей всего флаг, расположенный на вершине мира, во время битвы с финальной ногой
0x13 Зубастик в уровнях до девятого, на девятом-десятом, глыба льда. В параметрах указано направление движения, и количество клеток на которое происходит движение и цвет зубастика
0x14 Непонятная субстанция, стреляющая иглами во все стороны
0x21 Места? откуда выстреливаются нибблы, параметры если и есть, то не ясны
0x25 Ковер самолет, в параметрах скорей всего направление движение, количество клеток на которое движется, и возможно включение невидимости
0x26 Так и не понял что это, но явно связано с бонус уровнями, на первых четырех уровнях игры
0x27 Похоже на начальные координаты ноги на уровне, но если нога в люке, то помещение этого элемента над люком вызывает автооткрывание люка при приближение к нему, даже если в люке не нога
0x2С Ещё один зубастик, движется по кругу в отличии от других которые двигаются по прямой
0x2D Дверь, из которой в шестом уровне вылетают колокола, и акваланг для подъема по водопаду, в параметрах ориентация двери
0x2F Икра из которой вылупляются рыбки в подводных уровнях, в параметрах, похоже, число икринок
0x34 Бонусы расставленные по уровням: в седьмом байте указан тип бонуса, 0x6D — будильник, 0x6F — «континиус», 0x70 — удлинитель языка, 0x71 — неуязвимость, 0x72 — жизнь, 0x73 — инверсия движения, 0x74 — «ключ» ускоритель движения
0x35 Враги, в первом уровне шашки, во втором грибы, в девятом и десятом, ледяные прозрачные шары, в параметрах направление движение, и число клеток на которое оно происходит
0x36 Места откуда падают наковальни, в параметрах тип наковальни (возможно только цвет)
0x37 Фонтанчик, те самые фонтанчики, которые должны подбрасывать змей вверх, и на которых все висло у большинства игроков в России, параметров вроде бы нет
0x38 Сверла, вылезающие из земли в седьмом уровне, параметры не янсы, но должны быть достаточно интересны
0x39 Тоже сверла, но другого цвета и возможно имеют другие параметры
0x3A Падающие шары, в параметрах скорей всего высота, с которой идет падение шара, и направление движения
0x3B Колокольчики из шестого уровня, параметры скорей всего как у типа 0x35
0x3D Возможно корпус НЛО из последнего уровня, или его кабина
0x3E Возможно корпус НЛО из последнего уровня, или его кабина
0x3F Совсем непонятно что это, возможно как-то связано с ракетой в восьмой уровень, а может и нет

Правда, осмотрев седьмой уровень, я обнаружил, что элементов явно маловато. И нет фонтанчиков, а в игре они появляются после взятия будильника, да и в статье про баг написано, что игра закачивает новые данные в момент взятия этого будильника (Надо заметить такое есть кажется только в седьмом уровне). Чтобы найти, откуда грузится остаток, достаточно было поставить брэкйпоинт на изменение первого элемента списка, и попадаем вот в такой кусок:

00:8C73:A9 0F LDA #$0F
00:8C75:8D 06 20 STA PPU_ADDRESS
00:8C78:A9 78 LDA #$78
00:8C7A:8D 06 20 STA PPU_ADDRESS
00:8C7D:A9 77 LDA #$77
00:8C7F:85 AC STA $00AC
00:8C81:84 AB STY $00AB
00:8C83:AD 07 20 LDA PPU_DATA
00:8C86:AD 07 20 LDA PPU_DATA
00:8C89:99 53 06 STA $0653,Y
00:8C8C:AD 07 20 LDA PPU_DATA
00:8C8F:99 54 06 STA $0654,Y
00:8C92:C8 INY
00:8C93:C8 INY
00:8C94:C0 77 CPY #$77
00:8C96:90 EE BCC $8C86

То есть, копируем семьдесят семь байт из PPU по адресу 0xF78, поиск совпадений в ROM дает адрес 0xEF88. Что как раз идет аккурат за основным блоком, с данными элементов. Ну и надо расставить все точки о восьмом уровне. Во первых это единственный уровень без главного протагониста Ноги, а во вторых это уровень элементы в котором подгружаются до шести раз. Первый раз, когда входим на уровень, и потом каждый раз, когда мы попадаем в очередную подводную комнату. Но тут тоже есть маленькая хитрость, подгруздка идёт не только для подводных комнат, но и для основного уровня, именно по этому заходя в третью комнату мы слышим звук фонтанчика, хотя его вроде бы нигде и нет.

9. Нога


— Этa нога — у того, у кого надо нога.
На протяжении всей игры, по всем уровням игры (кроме восьмого) нас преследует один враг, Нога. Она же является финальным боссом. Не хорошо будет, обойти её стороной, поэтому давайте разберемся, по какому алгоритму она движется. Я догадываюсь, что мотыляние по ассемблер коду всем поднадоело уже. Поэтому опишу, что удалось выяснить.

В ROMе лежат два массива индексами в которых являются номер уровня, напомню уровни внутри игры считаются от нуля.

Level : 00 01 02 03 04 05 06 07 08 09 0A
0x3E88: 48 40 38 30 20 18 10 08 04 04 28
0x3FC0: 00 10 19 22 33 3F 59 00 6F 6F 75


По адресам 0x3E88 расположены задержки. Задержки считаются в кадрах. После того как насчитаем нужное количество кадров, Нога делает один ход, по его окончании счетчик обнуляется. И процесс запускается внось. По адресам 0x3FC0 расположены смещения, с которых начинается «скрипт» работы Ноги. Смещения берутся относительно адреса 0x3FCB. Логика работы достаточно проста. Берем байт по указанному смещению, если он не равен 0xFF то выполняем команду, и инкрементируем значения смещения. Если же он равен 0xFF то читаем следующий байт, и устанавливаем его в качестве текущего смещения. Команда состоит из трех частей:


Два младших бита отвечают за высоту прыжка, который делает нога каждый «ход». Высота рассчитывается просто, к этим младшим двум битам прибавляется двойка. То есть минимальная высота прыжка у нас получается два, максимальная пять. Но высота не совсем линейна (наверное это можно даже назвать импульсом прыжка). Вот высоты в пикселях, на которые прыгает нога по координате Z в зависимости от этого коэффициента: 0 — 15, 1 — 35, 2 — 64, 3 — 100.

А вот дальше всё достаточно заморочено. Биты с шестого по второй отвечают за направление прыжка. И вот тут скажу честно, я не осилил алгоритм расчета, он достаточно замороченный и использует какие то трюки, и таблицы предрасчета.

Функция расчет движения разбита на несколько частей, одна находится по адресу 0xCD4C вторая часть возможно сдесь 0xC480.

В общем итоге, я просто взял и замерил, как оно работает, получил в итоге вот такую табличку:



Кодом можно записать так:

int8_t X[32] = {30,33,35,37,39,40,41,42,42,42,41,40,39,37,35,33,30,27,23,20,16,12,8,4,0,-4,-8,-12,-16,-20,-23,-27};
int8_t Y[32] = {30,27,23,20,16,12,8,4,0,-4,-8,-12,-16,-20,-23,-27,-30,-33,-35,-37,-39,-40,-41,-42,-42,-42,-41,-40,-39,-37,-35,-33};

Как это расшифровывать? Очень просто, берем число закодированное с шестого по второй биты. И по таблице узнаем сколько нужно прибавить к координатам X и Y по завершении хода с высотой прыжка равной ноль (два если брать внутри игры), чтобы узнать на сколько прыгнет нога. При длине прыжка один, делим оба числа на два, а потом умножаем на три (напомню внутри игры к длине прибавляется двойка).

И остался последний седьмой бит, это бит инверсии если он установлен, то нужно поменять знак числам в таблице на противоположный.

И важно учитывать, что в отличии от например шашек, нога обрабатывает коллизию с миров, и может ударяться о стены.

С этим разобрались, не идеально, но думаю вполне понятно. И остался ещё один вопрос, сколько «жизней» у Ноги, на каждом уровне. Ходили легенды, что на последнем уровне нога неубиваема. Хотя возможно это и, правда. Но приступим. «Жизни» ноги устроены так, каждый кадр нога на одну единицу восстанавливает свое здоровье. Урон, который змея наносит ноге, рассчитывается так:

00:BBE5:A5 AA LDA $00AA = #$00
00:BBE7:0A ASL
00:BBE8:49 1F EOR #$1F
00:BBEA:C9 0E CMP #$0E
00:BBEC:B0 02 BCS $BBF0
00:BBEE:A9 0E LDA #$0E
00:BBF0:7D 17 06 ADC $0617,X


То есть урон зависит только от уровня:


Получаем такую картину, для того чтобы убить ногу нужно, наносить ей урон быстрей, чем она успевает его восстанавливать. Для NTSC версии, в первом уровне для этого нужно наносить два и больше удар в секунду. В то время как на одиннадцатом уровне, это уже больше четырех раз в секунду. По этому в целом может показаться, что нога не убиваема. Однако да, вполне может быть, и были картриджи с неубиваемой ногой.

Заключение


Надеюсь, вам было интересно. А может даже в чем-то и полезно. Может быть даже кто то захочет, сделать редактор уровней для этой или игры, или упаси боже ремейк (только хороший, пожалуйста). По сложившейся тут традиции выкладываю все наработки хотя смысла в этом особого и не вижу, но традиция есть традиция. Написание этой статьи заняло гораздо больше времени, чем я ожидал. Однако, из за этого удалось более подробно изучить многие вещи до которых руки не дошли бы. Ну а заодно чуть приподнять уровень знаний MOS6502 ассемблера.

Всем спасибо за внимание!
Теги:
Хабы:
+47
Комментарии 17
Комментарии Комментарии 17

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн