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

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

0. Ориентир


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

Мир 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 файл, идем по смещению 0×63D0 смотрим, что там хранится:

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 Strudio Max. Он содержит язык макросов, и можно написать программу, которая по данным из файла генерирует макрос построение геометрии. Так я и сделал. С макросами в 3D Studio я не работал, но посмотрел при помощи инструмента их записи, как и что, устроено. Я накидал простую программу. Запустил, получил скрипт для макса и… И получилось совсем не то, что я ожидал.

Для тех кому интересен код
#include 
#include 
#include 

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

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

o7-pry5ufhrdmjzw8rulyieofx4.png


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

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


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

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

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


Загрузил ROM, запустил игру и вышел в первый уровень. После чего открыл Debug→Hex Editor, сделал Edit → Find в качестве шаблона поиска выбил последовательность из смещения 0×63D0, а именно »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 блока из адреса 0×63D0 во что то ещё.

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

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

Итого по смещению 0×13 мы видим 5, по смещению 0×04 мы видим 2, и по смещению 0×01 мы видим 1.

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

Тот же код с доработками
#include 
#include 
#include 

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 единиц. Благо он как бы в квадрате находится, а не раскинут по всей карте, значит просто можно приподнять его над остальными уровнями.

wu6hzdh9o72ffd7hwyeai696bno.png

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

4smiphzv3aspf1u9vy3crohmgoo.png

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

0yrhu3ovw7z1e-1mqdnk44d06nk.png


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

Но блока кроме высоты есть ещё тип блока: земля, вода, люк, судя по картинке по полученной геометрии весы и плевательницы ниблов тоже имеют высоту из этого массива. Ну чтож значит надо как то поискать тип блока. А значит оставляем брейкпоинт на чтение 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 с содержимым ячейки памяти 0×00FA
  4. Теперь уже A переносим в X
  5. Считываем A из ячейки 0xCF6A со смещением X
  6. Проверяем взведен ли флаг С, если нет то сразу идем на адрес 0xA63A, если же он установлен то делаем четыре сдвига вправо
  7. Выполняем AND c над регистром A с числом 0×0F (отрезаем верхний полубайт)


Дальше пошли всякие проверки, не очень хочется в них вникать. Посмотрел что было в ячейке 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 
#include 
#include 

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);
}


4c9azevkbvcizgh67argfkwxjpo.png

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


Предположение оказалось верным, все блоки подписаны соответственно с их графическим представлением, но на последних уровнях, что-то не так, если смотреть на большую картинку, или прямо в игре, то видно, что некоторые одинаковые по функционалу блоки имеют разные 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, сравниваем с 0×08, делаем сдвиг вправо, но не обычный, а когда в старший разряд берется из флага С, а он в свою очередь будет установлен командой CMP если в ячейке 0xAA было значение больше или равно 0×08. После при помощи AND очищаем все биты кроме старшего. И его пишем уже в 0xFA. Осталось узнать, что хранится в ячейке 0xAA, но тут нам на помощь приходит сайт на котором, мы нашли расположения уровня
http://datacrystal.romhacking.net/wiki/Snake_Rattle_N_Roll: RAM_map

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

Исправляем код
#include 
#include 
#include 

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. Внешний вид блоков


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

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


bbp864qiazwxl4cdprwfx7idgv4.pngВ первых пяти уровня, на карте расставлены люки в которых могут быть: ловушки, бонусные вещи, переходы в бонус уровень или варп на другой уровень. Можно конечно пройтись по уровням и всё переписать вручную, но это быстро и скучно. А можно попытаться поискать, где и как оно хранится в игре. На этот раз я выбрал вариант с загрузкой/сохранением состояния игры, и инструментом Tool→Ram search… И при помощи вариантов Equal / Not Equal начал смотреть что изменяется. Через несколько попыток стало заметно, что меняется ячейка 0×1B1, до открывания люка там 0×41 после открывания 0×4E. Попробовал открыть люк рядом поменялась ячейка 0×1B3, было 0×29 стало 0×2E. Уже сейчас можно заметить, что меняются в обоих случаях младшие четыре бита. Но мы всё таки поставим брэйкпоинт, и посмотрим легко ли разобраться как игра оперирует с этими байтами.

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

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 описаны адреса
0×4D7и 0×4C3, а именно это координаты игрока по X на карте мира в «пикселах», причем ширина клетки составляет шестнадцать пикселов, это установлено ручным замером. Получается, что старшая часть байта 0×4D7 и младшая 0×4C3 образую координату по X в блоках. Только здесь эти части байта поменяны местами, и сравниваются с ячейкой 0×1B0, а там в данный момент как раз хранится 0×80 (а первый люк который я проверял как раз и находится по координатам 8:4 если считать от нуля). В ячейке 0×4FF хранится координата игрока по Y и от ней используется только старшая часть для сравнения. Ну и наконец после всего этого берется младшие четыре бита и дальше идет куча сравнений. Получается этот кусок кода ищет координаты открытого люка начиная с адреса 0×1B0 и дальше смещаясь каждый шаг на два байта, и так до места пока не найдет нужный люк. Выхода по невозможности найти не предусмотрено. Поэтому если изменить координаты на несуществующие, то при попытке открыть люк игра повиснет.

Срываем покровы

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


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

Ладно сделаем допущение, что запись в ячейку происходит инструкцией STA и значит в A в момент входа на первый уровень должно равняться 0×80 поможем 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 читаем число по смещению 0×700+Номер_уровня, переносим A в Y. Потом читаем байт по смещению 0×700+Y и копируем его в 0×1B0+X, инкрементируем X и Y, проверяем что X неравен 0×30 и если это так повторяем цикл копирования.

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

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

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

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

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

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


_s2fw0bfuh0p6tksqewyslceubm.pngВ первых четырех уровнях есть переходы на бонусные уровни, где можно спокойно ничего не боясь покушать ниблов. Хранятся они, где-то отдельно. Первый бонус уровень очень простой в плане геометрии, возвышенность, и порядка 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. Посмотрим что там лежит:

qdff3c8u2qnqmadgey1pbrdlprk.png

достаточно отчетливо видно, что где-то между 0×200 и 0×300 лежит бонус уровень. Только правый верхний угол, тут стал левым нижним. Надо теперь понять, как он там оказывается. Ну чтож берем какую ни будь ячейку из блока (я взял 0×221 там лежало хорошо узнаваемое 0×3F), и ставим на неё брейпоинт по записи. Во время игры туда постоянно что-то пишется, поэтому добавим условие 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 это 0×2006, PPU_DATA = 0×2007.

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

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

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

0x01DA
0x022A
0x029E
0x030E

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

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


Доработанная программа
#include 
#include 
#include 

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. Подводные уровни


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

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

То есть, кто уровень грузят изначально по адресу 0×200, а потом копируют на 0×700, переставляем брэкйпоинт на 0×200, и попадаем в тот же кусок, что и для бонусных уровней. Но бонус выбирался в зависимости от номера уровня, а тут пять разных комнат, и номер уровня не меняется. Значит есть шанс, что сюда попадаем из уже при правильно установленном 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 
#include 
#include 

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[%
    
            

© Habrahabr.ru