﻿# Создаём эмулятор Sega Mega Drive на C\+\+

В этой статье описано создание эмулятора 16\-битной приставки Sega Mega Drive на C\+\+\. Будет много интересного: эмуляция процессора Motorola 68000, реверсинг игр, графика на OpenGL, шейдеры и многое другое\. И всё это на современном C\+\+\. В статье много картинок, можно хоть на них посмотреть\.

![1252_perevod_sega_ru/image1.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image1.png)


> Мы опубликовали и перевели эту статью с разрешения правообладателя\\\. Автор статьи — \[Евгений Шульгин\]\(https://izaron\.github\.io/\)\\\. Почта: \[izaronplatz@gmail\\\.com\]\(mailto:izaronplatz@gmail\.com\)\\\.

## Устройство Sega Mega Drive

![1252_perevod_sega_ru/image2.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image2.png)

Архитектура Sega Mega Drive \([source](https://www.retrosix.wiki/hardware-overview-sega-mega-drive)\)

Описание каждого компонента из схемы в рандомном порядке:

* ROM — данные картриджа, имеет размер максимум 4MB;
* VDP — [Video Display Processor](https://rasterscroll.com/mdgraphics/vdp-overview/), асик разработки самой Sega, чип видеоконтроллера\. Имеет 64KB RAM \(называется VRAM — Video RAM\);
* FM Sound — асик от Yamaha \([YM2612](https://en.wikipedia.org/wiki/Yamaha_YM2612)\), имеет 6 FM\-каналов, синтезирует звук;
* PSG Sound — асик от Texas Instruments \([SN76489](https://en.wikipedia.org/wiki/Texas_Instruments_SN76489)\), имеет 3 меандровых канала, тоже синтезирует звук\. Нужен для совместимости с 8\-битной Sega Master System;
* CPU — процессор [Motorola 68000](https://en.wikipedia.org/wiki/Motorola_68000_series), делающий основную массу работы\. Имеет 64KB RAM;
* Co\-Processor — процессор [Zilog Z80](https://en.wikipedia.org/wiki/Zilog_Z80), используется "для звука", а точнее, его задача в том, чтобы вовремя просыпаться и писать команды в регистры YM2612\. Имеет 8KB RAM\.
* Input/Output — контроллеры\. Сначала это был "трёхкнопочный геймпад", потом добавился "шестикнопочный", а затем ещё с десяток более редких девайсов\.

Центральным компонентом является Motorola 68000 \(сокращённо m68k\)\. У него 24\-битовая адресация по адресам `0x000000` ̶ `0xFFFFFF`\. Любое обращение к памяти из этого процессора выполняется шиной \(на схеме обозначено как 68000 BUS\), которая преобразует адрес в разные места \([тут можно увидеть маппинг адресов](https://segaretro.org/Sega_Mega_Drive/Memory_map)\)\.

В этой статье будет эмуляция всех описанных компонентов, кроме Z80 и звука\.

## Эмуляция Motorola 68000

### Факты про m68k

В своё время m68k был популярным процессором\. Он использовался на протяжении десятилетий в компьютерах Macintosh, Amiga, Atari, приставке Sega Mega Drive и прочих устройствах\.

В [архитектуре](https://en.wikipedia.org/wiki/Motorola_68000#Architecture) процессора уже есть элементы 32\-битовости, но с ограничениями\.

Всего есть 16 регистров 32\-битных \(и один регистр 16\-битный\)\. Несмотря на то, что "адресные" регистры \(`A0`\-`A7`\) 32\-битные, по факту для адреса берутся младшие 24 бита\. То есть адресуется пространство в 16 мегабайт памяти\.

Процессор поддерживает зачаток виртуализации для многозадачных систем: обращение к регистру `A7` по факту будет обращением либо к `USP` \(user stack pointer\) либо к `SSP` \(supervisor stack pointer\) в зависимости от флага в статусном регистре\.

В отличие от \(почти всех\) современных архитектур, m68k придерживается порядка байт big\-endian\. Адрес и размер инструкции всегда делится на 2, читать память тоже можно только по адресу, делящемуся на 2 \(за небольшим исключением\)\. Не поддерживается floating\-point arithmetic\.

![1252_perevod_sega_ru/image3.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image3.png)

Таблица инструкций m68k \([source](http://goldencrystal.free.fr/M68kOpcodes-v2.3.pdf)\)

### Регистры m68k

Сделаем в общем месте базовые типы:

```cpp
using Byte = uint8_t;
using Word = uint16_t;
using Long = uint32_t;
using LongLong = uint64_t;

using AddressType = Long;
```

Класс для работы с big\-endian:

<details>
   <summary>Класс BigEndian<T\\\></summary>

Так как m68k придерживается порядка big\-endian, нередко будет требоваться поменять порядок следования байтов \(предполагая, что на нашем компьютере используется какой\-нибудь x86\_64/ARM, где по умолчанию little\-endian\)\. Для этого заведём тип:

```cpp
template<typename T>
class BigEndian {
public:
  T get() const {
    return std::byteswap(value_);
  }

private:
  T value_;
};
```

Потом, например, если где\-то надо вытащить значение `Word` из массива, можно делать так:

```cpp
const auto* array_ptr =
  reinterpret_cast<const BigEndian<Word>*>(data_ptr);
// ...
x -= array_ptr[index].get();
```


</details>
Так как процессор постоянно что\-то записывает в память или читает оттуда, нужны соответствующие сущности \(что именно записать или куда записать\):

<details>
   <summary>Классы DataView и MutableDataView</summary>

Лучше всего для этого подходит `std::span` — это указатель на данные плюс размер этих самых данных\. Для иммутабельной версии ещё удобно сделать хелпер, чтобы вызывать `.as<Word>()` и так далее:

```cpp
using MutableDataView = std::span<Byte>;

class DataView : public std::span<const Byte> {
public:
  using Base = std::span<const Byte>;
  using Base::Base;

  template <std::integral T>
  T as() const {
    return std::byteswap(*reinterpret_cast<const T*>(data()));
  }
};
```


</details>
Создадим тип для регистров m68k\. Объект этого типа будет полностью описывать состояние CPU в отрыве от памяти:

<details>
   <summary>Структура Registers</summary>

```cpp
struct Registers {
  /**
   * Data registers D0 - D7
   */
  std::array<Long, 8> d;

  /**
   * Address registers A0 - A6
   */
  std::array<Long, 7> a;

  /**
   * User stack pointer
   */
  Long usp;

  /**
   * Supervisor stack pointer
   */
  Long ssp;

  /**
   * Program counter
   */
  Long pc;

  /**
   * Status register
   */
  struct {
    // lower byte
    bool carry : 1;
    bool overflow : 1;
    bool zero : 1;
    bool negative : 1;
    bool extend : 1;
    bool : 3;

    // upper byte
    uint8_t interrupt_mask : 3;
    bool : 1;
    bool master_switch : 1;
    bool supervisor : 1;
    uint8_t trace : 2;

    decltype(auto) operator=(const Word& word) {
      *reinterpret_cast<Word*>(this) = word;
      return *this;
    }

    operator Word() const {
      return *reinterpret_cast<const Word*>(this);
    }
  } sr;
  static_assert(sizeof(sr) == sizeof(Word));

  /**
   * The stack pointer register depend on the supervisor flag
   */
  Long& stack_ptr() {
    return sr.supervisor ? ssp : usp;
  }
};
static_assert(sizeof(Registers) == 76);
```

Эта структура размером в 76 байт полностью описывает состояние CPU\.


</details>
### Обработка ошибок

Ошибочных ситуаций может произойти множество: unaligned \(не делящийся на 2\) адрес program counter / адрес чтения / адрес записи, неизвестная инструкция, попытка записи в защищённое адресное пространство\.

Я решил делать обработку ошибок без исключений \(которые `try`/`throw`/`catch`\)\. В целом ничего против стандартных исключений не имею, просто этот подход делает дебаг немного удобнее\.

Поэтому для ошибок заведём класс:

<details>
   <summary>Класс Error</summary>

```cpp
class Error {
public:
  enum Kind {
    // no error
    Ok,

    UnalignedMemoryRead,
    UnalignedMemoryWrite,
    UnalignedProgramCounter,
    UnknownAddressingMode,
    UnknownOpcode,

    // permission error
    ProtectedRead,
    ProtectedWrite,

    // bus error
    UnmappedRead,
    UnmappedWrite,

    // invalid action
    InvalidRead,
    InvalidWrite,
  };

  Error() = default;
  Error(Kind kind, std::string what)
    : kind_{kind}
    , what_{std::move(what)}
  {}

  Kind kind() const {
    return kind_;
  }
  const std::string& what() const {
    return what_;
  }

private:
  Kind kind_{Ok};
  std::string what_;
};
```


</details>
Теперь метод, который может завершиться с ошибкой, должен иметь возвращаемый тип `std::optional<Error>`\.

Если метод может либо завершиться с ошибкой, либо вернуть объект типа `T`, он должен иметь возвращаемый тип `std::expected<T, Error>`\. Этот шаблон [заехал в C\+\+23](https://en.cppreference.com/w/cpp/utility/expected), он удобен для такого подхода\.

### Интерфейс для чтения/записи памяти

Как упоминалось в разделе про архитектуру Sega Mega Drive, чтение или запись по адресам могут иметь разную семантику в зависимости от адреса\. Чтобы абстрагировать поведение с точки зрения m68k, можно завести класс `Device`:

```cpp
class Device {
public:
  // reads `data.size()` bytes from address `addr`
  [[nodiscard]] virtual std::optional<Error> read(AddressType addr,
                                                  MutableDataView data) = 0;

  // writes `data.size()` bytes to address `addr`
  [[nodiscard]] virtual std::optional<Error> write(AddressType addr,
                                                   DataView data) = 0;

  // ....
};
```

Ожидаемое поведение понятно из комментариев\. Добавим в этот класс хелперы для чтения/записи `Byte`/`Word`/`Long`:

```cpp
  template<std::integral T>
  std::expected<T, Error> read(AddressType addr) {
    T data;
    auto err = read(addr,
                    MutableDataView { reinterpret_cast<Byte*>(&data),
                                      sizeof(T) });
    if (err)
    {
      return std::unexpected{std::move(*err)};
    }
    // swap bytes after reading to make it little-endian
    return std::byteswap(data);
  }

  template<std::integral T>
  [[nodiscard]] std::optional<Error> write(AddressType addr, T value) {
    // swap bytes before writing to make it big-endian
    const auto swapped = std::byteswap(value);
    return write(addr,
                 DataView { reinterpret_cast<const Byte*>(&swapped),
                            sizeof(T) });
  }
```

### Контекст исполнения m68k

Контекст исполнения m68k — это регистры плюс память, таким образом это:

```cpp
struct Context {
  Registers& registers;
  Device& device;
};
```

### Представление операндов m68k

У каждой инструкции есть от 0 до 2 операндов, они же — цели\. Они могут указывать на адрес в памяти/регистр большим количеством способов\. У класса операнда есть примерно такие переменные:

```cpp
Kind kind_;      // один из 12 типов адресации (addressing mode)
uint8_t index_;  // значение "индекса" для индексных типов адресации
Word ext_word0_; // первый extension word
Word ext_word1_; // второй extension word
Long address_;   // значение "адреса" для адресных типов адресации
```

И ещё 2\-3 переменные\. Всего я уложился в 24 байта\.

Этот класс имеет методы для чтения/записи:

```cpp
[[nodiscard]] std::optional<Error> read(Context ctx, MutableDataView data);
[[nodiscard]] std::optional<Error> write(Context ctx, DataView data);
```

Реализацию можно посмотреть в [lib/m68k/target/target\.h](https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/target/target.h)\.

Самыми сложными типами адресации оказались `Address with Index` и `Program Counter with Index`\. Вот так для них вычисляется адрес:

<details>
   <summary>Target::indexed\\\_address</summary>

```cpp
Long Target::indexed_address(Context ctx, Long baseAddress) const {
  const uint8_t xregNum = bits_range(ext_word0_, 12, 3);
  const Long xreg = bit_at(ext_word0_, 15) ? a_reg(ctx.registers, xregNum)
                                           : ctx.registers.d[xregNum];
  const Long size = bit_at(ext_word0_, 11) ? /*Long*/ 4 : /*Word*/ 2;
  const Long scale = scale_value(bits_range(ext_word0_, 9, 2));
  const SignedByte disp = static_cast<SignedByte>(
                            bits_range(ext_word0_, 0, 8)
                          );

  SignedLong clarifiedXreg = static_cast<SignedLong>(xreg);
  if (size == 2) {
    clarifiedXreg = static_cast<SignedWord>(clarifiedXreg);
  }

  return baseAddress + disp + clarifiedXreg * scale;
}
```


</details>
### Представление инструкций m68k

У класса инструкций есть примерно такие переменные:

```cpp
Kind kind_;      // один из 82 опкодов
Size size_;      // Byte, Word или Long
Condition cond_; // одно из 16 условий для "бранчевых" инструкций
Target src_;     // source-операнд
Target dst_;     // destination-операнд
```

И ещё 2\-3 переменные\. Всего я уложился в 64 байта\.

### Парсинг инструкций m68k

У класса инструкций есть статический метод для парсинга текущей инструкции:

```cpp
static std::expected<Instruction, Error> decode(Context ctx);
```

Его реализацию можно посмотреть в [lib/m68k/instruction/decode\.cpp](https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/instruction/decode.cpp)

Чтобы не копипастить кучу проверок на "ошибку", я прибегаю к макросам наподобие таких:

```cpp
#define READ_WORD_SAFE                    \
  const auto word = read_word();          \
  if (!word) {                            \
    return std::unexpected{word.error()}; \
  }
```

Также я в удобном формате проверяю опкод на паттерн:

<details>
   <summary>Макрос HAS\\\_PATTERN</summary>

Функции для расчёта "маски":

```cpp
consteval Word calculate_mask(std::string_view pattern) {
  Word mask{};
  for (const char c : pattern) {
    if (c != ' ') {
      mask = (mask << 1) | ((c == '0' || c == '1') ? 1 : 0);
    }
  }
  return mask;
}

consteval Word calculate_value(std::string_view pattern) {
  Word mask{};
  for (const char c : pattern) {
    if (c != ' ') {
      mask = (mask << 1) | ((c == '1') ? 1 : 0);
    }
  }
  return mask;
}
```

Макрос `HAS_PATTERN`:

```cpp
#define HAS_PATTERN(pattern) \
  ((*word & calculate_mask(pattern)) == calculate_value(pattern))
```



И затем, например:

```cpp
if (HAS_PATTERN("0000 ...1 ..00 1...")) {
  // это MOVEP
  // ...
}
```

Код выше проверяет, что биты в опкоде удовлетворяют паттерну, то есть соответствующие биты \(где не точка\) равны 0 или 1, в нашем случае это паттерн для опкода MOVEP\.



Это работает так же быстро, как если писать руками: `consteval` гарантирует, что вызов исполнится в compile time\.


</details>
### Исполнение инструкций m68k

У класса инструкций есть метод для исполнения — во время исполнения меняются регистры, и опционально есть обращение в память:

```cpp
[[nodiscard]] std::optional<Error> execute(Context ctx);
```

Его реализацию можно посмотреть в [lib/m68k/instruction/execute\.cpp](https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/instruction/execute.cpp) — это самый сложный код в симуляторе\.

Описание того, что должна делать инструкция, можно прочитать [в этой markdown\-документации](https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/eor.md)\. Иногда этого недостаточно, тогда можно прочитать [длинное описание в этой книге](http://www.easy68k.com/paulrsm/doc/68kprm.pdf)\.

Написание эмуляции инструкций — это итеративный процесс\. Сначала каждую инструкцию сложно делать, но потом накапливается больше паттернов и общего кода, и становится проще\.

Есть муторные инструкции, например [MOVEP](https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/movep.md)\. И ещё все инструкции про BCD\-арифметику \(как [ABCD](https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/abcd.md)\)\. BCD\-арифметика — это когда с hex\-числами проводят операции как над decimal\-числами\. Например, BCD\-сложение — это 0x678 \+ 0x535 \= 0x1213\. Над этими BCD\-инструкциями я просидел больше четырёх часов, потому что у них [супер сложная логика](https://github.com/Izaron/SegaCxx/blob/b3d7fe5b239d3e3a58cb8e90bc3ea7f87d44825f/src/lib/m68k/instruction/execute.cpp#L235-L273), которая нигде нормально не объясняется\.

### Тестирование эмулятора m68k

Самая важная часть — тестирование\. Небольшая ошибка в каком\-нибудь статусном флаге может привести к катастрофе во время эмуляции\. Когда программа большая, её легко сломать в неожиданном месте, поэтому нужны тесты на все инструкции\.

Мне очень помогли [тесты из этого репозитория](https://github.com/TomHarte/ProcessorTests/blob/main/680x0/68000/v1/README.md)\. На каждую инструкцию есть 8000\+ тестов, которые покрывают все возможные случаи\. Суммарно тестов чуть больше миллиона\.

Они могут находить даже самые мелкие ошибки: нередко бывает ситуация, что не проходятся \~20 тестов из 8000\.

Например, инструкция `MOVE (A6)+ (A6)+` \(обращение к регистру A6 делается с пост\-инкрементом\) должна работать не так, как я реализовал, поэтому я сделал костыль, чтобы работало корректно\.

Сейчас эмулятор работает правильно почти везде, ломается лишь на единичных кейсах не больше \~10 штук \(где то ли ошибка в самих тестах, то ли ещё что\-то\)\.

### Эмуляция C\+\+ программ

Можно эмулировать свои программы\. Напишем простую программу, которая читает два числа, а потом записывает в цикле все значения в промежутке:

```cpp
    void work() {
        int begin = *(int*)0xFF0000;
        int end = *(int*)0xFF0004;

        for (int i = begin; i <= end; ++i) {
            // если не писать "volatile",
            // компилятор соптимизирует в одну запись!
            *(volatile int*)0xFF0008 = i; 
        }
    }
```

Компиляторы GCC и Clang поддерживают m68k как цель\. Скомпилируем в Clang \(из файла `a.cpp` сделается `a.o`\):

```cpp
clang++ a.cpp -c --target=m68k -O3
```

Ассемблер объектного файла можно посмотреть командой \(скорее всего, сначала потребуется установить пакет `binutils-m68k-linux-gnu`\):

```cpp
m68k-linux-gnu-objdump -d a.o
```

[Выведет такой ассемблер](https://gist.github.com/Izaron/b241bfd9fffab3a1731c74f5ba37d07c)\.

Этот объектный файл упакован в формат [ELF](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format)\. Нужно распаковать\. Вытащим ассемблерный код \(секция `.text`\) в файл `a.bin`:

```cpp
m68k-linux-gnu-objcopy -O binary --only-section=.text a.o a.bin
```

Командой `hd a.bin` можно удостовериться, что вытащились правильные файлы\.

Теперь можно проэмулировать работу на этом ассемблере\. [Код эмулятора тут](https://github.com/Izaron/SegaCxx/blob/main/src/bin/m68k_emulator/main.cpp), а [тут логи эмуляции](https://gist.github.com/Izaron/3d163abd63bc7e29a657c38a35e951dc)\. В этом примере по адресу `0xFF0008` записываются все числа от 1307 до 1320\.

<details>
   <summary>Ещё эмуляция\\\. Решето Эратосфена</summary>

В следующей программе мне пришлось помучаться с компиляторами\. Я сделал вычисление простых чисел до 1000 через [решето Эратосфена](https://ru.wikipedia.org/wiki/%D0%A0%D0%B5%D1%88%D0%B5%D1%82%D0%BE_%D0%AD%D1%80%D0%B0%D1%82%D0%BE%D1%81%D1%84%D0%B5%D0%BD%D0%B0)\.



Для этого понадобился массив, который нужно заполнить нулями\. Компиляторы всё норовили использовать метод `memset` из стандартной библиотеки при обычном объявлении `bool notPrime[N + 1] = {0}`, что нужно избегать, так как никакие библиотеки не прилинкованы\. В итоге код выглядел так:

```cpp
    void work() {
        constexpr int N = 1000;

        // avoiding calling "memset" -_-
        volatile bool notPrime[N + 1];
        for (int i = 0; i <= N; ++i) {
            notPrime[i] = 0;
        }

        for (int i = 2; i <= N; ++i) {
            if (notPrime[i]) {
                continue;
            }
            *(volatile int*)0xFF0008 = i;
            for (int j = 2 * i; j <= N; j += i) {
                notPrime[j] = true;
            }
        }
    }
```

И сбилжен через GCC \(с пакетом g\+\+\-m68k\-linux\-gnu\):

```cpp
m68k-linux-gnu-g++ a.cpp -c -O3
```

Ассемблер [выглядит так](https://gist.github.com/Izaron/e9b3e9dcfc15d4f6fdd601662c74cc68), вывод эмулятора [выглядит так](https://gist.githubusercontent.com/Izaron/449493d6faab5db23ac822269bd174f8/raw/cb38c6c4726ed789e7ee7098ea87f2f5e54cfe08/output)\.

Более нетривиальные программы эмулировать сложно — получается слишком синтетическое окружение\. Например, в такой программе с записью строки есть целых две проблемы:

```cpp
    void work() {
        strcpy((char*)0xFF0008, "Der beste Seemann war doch ich");
    }
```

Первая проблема — это вызов метода, который ещё не прилинкован к объектному файлу\. Вторая проблема — это сама строка, у которой ещё неизвестно место в памяти\.


</details>
При желании и усидчивости можно эмулировать, например, работу Linux для m68k\. [QEMU умеет так делать](https://wiki.qemu.org/Documentation/Platforms/m68k)\!

### Формат ROM\-файлов

Для анализа всяких неизвестных форматов/протоколов я использую [ImHex](https://github.com/WerWolv/ImHex), чтобы лучше видеть содержимое\.

Пусть ROM\-файл с любимой игрой детства скачан\. Погуглив формат ROM\-файлов, становится понятно, что первые 256 байт занимает [m68k vector table](https://wiki.megadrive.org/index.php?title=68k_vector_table), то есть куча адресов на всякие случаи наподобие деления на ноль\. Следующие 256 байт занимает [ROM header](https://plutiedev.com/rom-header) с информацией про игру\.

Набросаем hex pattern на внутреннем языке ImHex для парсинга бинарных файлов и посмотрим на содержимое:

<details>
   <summary>Паттерн sega\\\.hexpat</summary>

`be` перед типом означает big\-endian:

```cpp
struct AddressRange {
    be u32 begin;
    be u32 end;
};

struct VectorTable {
    be u32 initial_sp;
    be u32 initial_pc;
    be u32 bus_error;
    be u32 address_error;
    be u32 illegal_instruction;
    be u32 zero_divide;
    be u32 chk;
    be u32 trapv;
    be u32 privilege_violation;
    be u32 trace;
    be u32 line_1010_emulator;
    be u32 line_1111_emulator;
    be u32 hardware_breakpoint;
    be u32 coprocessor_violation;
    be u32 format_error;
    be u32 uninitialized_interrupt;
    be u32 reserved_16_23[8];
    be u32 spurious_interrupt;
    be u32 autovector_level_1;
    be u32 autovector_level_2;
    be u32 autovector_level_3;
    be u32 hblank;
    be u32 autovector_level_5;
    be u32 vblank;
    be u32 autovector_level_7;
    be u32 trap[16];
    be u32 reserved_48_63[16];
};

struct RomHeader {
    char system_type[16];
    char copyright[16];
    char title_domestic[48];
    char title_overseas[48];
    char serial_number[14];
    be u16 checksum;
    char device_support[16];
    AddressRange rom_address_range;
    AddressRange ram_address_range;
    char extra_memory[12];
    char modem_support[12];
    char reserved1[40];
    char region[3];
    char reserved2[13];
};

struct Rom {
    VectorTable vector_table;
    RomHeader rom_header;
};

Rom rom @ 0x00;
```


</details>


![1252_perevod_sega_ru/image4.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image4.png)

Рисунок N3 – ImHex "разобрал" начало файла

Там же можно дизассемблировать какое\-то количество инструкций, начиная с `initial_pc` \(точка входа\), и посмотреть, что происходит в первых инструкциях:

![1252_perevod_sega_ru/image5.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image5.png)

Рисунок N4 – Дизассемблер в ImHex

Когда всё станет понятно, можно структуры из `hex pattern` завезти в C\+\+\. Пример в [lib/sega/rom\_loader/rom\_loader\.h](https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/rom_loader/rom_loader.h) \(ненужные поля повыбрасывал\)\.

В отличие от многих других форматов, где заголовки как бы не являются составной частью самого содержимого, в ROM\-файлах этот заголовок в 512 байт является неотъемлемой частью, то есть ROM\-файл просто надо целиком загрузить в память\. По маппингу адресов ему отведена область `0x000000 - 0x3FFFFF`\.

### Bus Device

Для более удобной работы с маппингом адресов можно реализовать `BusDevice` \(bus \= шина\) как класс\-наследник `Device`, и чтобы он команды на запись/чтение перенаправлял в более точный device:

```cpp
class BusDevice : public Device {
public:
  struct Range {
    AddressType begin;
    AddressType end;
  };
  void add_device(Range range, Device* device);

  /* ... еще override методы `read` и `write` */

private:
  struct MappedDevice {
    const Range range;
    Device* device;
  };
  std::vector<MappedDevice> mapped_devices_;
};
```

И эмулятору m68k подсовывается объект этого класса\. Полная реализация — [lib/sega/memory/bus\_device\.h](https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/memory/bus_device.h)\.

### GUI

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

Для GUI я использовал мегакрутую либу [ImGui](https://github.com/ocornut/imgui)\. В ней очень много всего, и можно сделать какой угодно интерфейс\.

![1252_perevod_sega_ru/image6.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image6.png)

Рисунок N5 – Пример окна \- статус эмулятора m68k

Так можно отрисовать всё состояние эмулятора в разных окнах — без этого дебажить очень трудно\.

### Работа в Docker

Чтобы не страдать от старых версий операционки на своём компе \(когда все пакеты старые, даже современный C\+\+ не компилируется\) и не загрязнять его всякими левыми пакетами, разработку лучше вести из\-под Docker\.

Сначала заведите [Dockerfile](https://github.com/Izaron/SegaCxx/blob/main/Dockerfile), потом при его изменении пересоздавайте образ:

```cpp
sudo docker build -t segacxx .
```

И потом заходите в контейнер с монтированием директорий \(`-v`\) и другими нужными параметрами:

```cpp
sudo docker run --privileged \
                -v /home/eshulgin:/usr/src \
                -v /home/eshulgin/.config/nvim:/root/.config/nvim \
                -v /home/eshulgin/.local/share/nvim:/root/.local/share/nvim \
                -v /tmp/.X11-unix:/tmp/.X11-unix \
                -e DISPLAY=unix${DISPLAY} \
                -it \
                segacxx
```

Подводные камни:

1. Может возникнуть проблема с GUI, у которого не будет доступа по умолчанию, но после спортивного гуглежа в команду добавились `-v` для X11 и `-e DISPLAY`;
1. Также, чтобы GUI работал, нужно с компа запустить `xhost +`, чтобы выключить `access control`;
1. Чтобы был доступ к контроллерам \(про них раздел ниже\), в команду добавился `--privileged`\.

![1252_perevod_sega_ru/image7.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image7.png)

Рисунок N6 – NeoVim запущенный из\-под docker\-контейнера

### Реверсинг игр в Ghidra

Пусть мы настроили эмуляцию m68k по ROM'у, почитали какую\-нибудь документацию, накидали несколько базовых девайсов в шину \(ROM, RAM, [trademark\-регистр](https://segaretro.org/TradeMark_Security_System), etc\.\) и эмулируем по одной инструкции, глядя в дизассемблер\.

Это муторное занятие, хочется получить более высокоуровневую картину\. Для этого можно отреверсить игру\. Я для этого использую [Ghidra](https://github.com/NationalSecurityAgency/ghidra):

![1252_perevod_sega_ru/image8.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image8.png)

Рисунок N7 – Реверсинг игры для Sega Mega Drive

Очень хороший старт даёт [плагин](https://github.com/lab313ru/ghidra_sega_ldr) от [@DrMefistO](https://habr.com/users/drmefisto): он сам промаркирует общеизвестные адреса и создаст сегменты\.

Можно будет увидеть, что, поскольку игры писались на ассемблере изначально, у них специфический вид:

* вперемешку код и данные: есть кусок кода, потом идут куски байтов, например для цвета, потом снова код и так далее\. Всё по архитектуре фон Неймана\.

Чтобы сделать фрейм, в ассемблере m68k надо использовать [LINK](https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/link.md) и [UNLK](https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/unlk.md)\. На деле такое почти не встречается: в большинстве функций аргументы передаются через полурандомные регистры\. Некоторые функции помещают результат во флаг статусного регистра \(например, в ZF\)\. К счастью, в Ghidra в таких случаях можно указать руками, что именно делает функция, чтобы декомпилятор показал более адекватный вывод\. Ещё встречается `switch` из функций, когда у них одинаковый контент, но первые несколько инструкций отличаются\. Пример на скрине:

![1252_perevod_sega_ru/image9.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image9.png)

Рисунок N8 – "switch" из функций

Чтобы примерно представлять, что происходит \(и сделать более точный эмулятор Sega\), не обязательно реверсить всю игру, достаточно каких\-то 5\-10%\. Лучше реверсить ту игру, которую вы хорошо помните из детства, чтобы она не была "черным ящиком"\.

Это умение понадобится в будущем, чтобы быстро отдебажить поломку эмуляции в других играх\.

### Эмуляция прерываний

Пусть какая\-то базовая рабочая эмуляция настроена\. Запускаем эмулятор, и он ожидаемо попадает в вечный цикл\. Отреверсив место, видим, что там обнуляется флаг в RAM, и затем цикл ждёт, пока флаг остаётся нулевым:

![1252_perevod_sega_ru/image10.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image10.png)

Рисунок N9 – Уже отреверсенная функция WaitVBLANK

Смотрим, где ещё есть обращение к этому месту, и видим, что это код по месту прерывания VBLANK\. Отреверсим VBLANK:

![1252_perevod_sega_ru/image11.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image11.png)

Рисунок N10 – Уже отреверсенная функция VBLANK

Кто такие легендарный VBLANK и его внук, популярный HBLANK?

Видеоконтроллер 60 или 50 раз в секунду \(в зависимости от [NTSC или PAL/SECAM](https://ru.wikipedia.org/wiki/NTSC)\) отрисовывает на старом телевизоре фрейм попиксельно\.

![1252_perevod_sega_ru/image12.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image12.png)

Отрисовка фрейма \([source](https://segaretro.org/Sega_Mega_Drive/Interrupts)\)

Когда текущая линия отрисована, и луч идёт на следующую строку \(зелёные отрезки на картинке выше\), в это время сработает прерывание HBLANK\. За это время на реальной приставке физически можно отправить в видеопамять всего 18 байт \(хотя в симуляторе я такого ограничения не ставлю\), и далеко не все игры используют это прерывание\.

Когда весь фрейм отрисован, и луч идёт в начало экрана \(синий отрезок\), в это время сработает прерывание VBLANK\. За это время можно отправить в видеопамять максимум 7 килобайт данных\.

Пусть мы захардкодили использование NTSC \(60 фреймов в секунду\)\. Чтобы вызвать прерывание, надо в цикле исполнения инструкций встроить проверку, которая смотрит, выполняются ли условия:

1. VBLANK\-прерывание включено видеопроцессором;
1. Значение Interrupt Mask в статусном регистре меньше, чем 6 \(это типа уровень важности текущего прерывания\);
1. Прошло 1s/60 времени с предыдущего прерывания\.

Если да, то прыгаем на функцию\. Выглядит это примерно так:

```cpp
std::optional<Error> InterruptHandler::call_vblank() {
  // push PC (4 bytes)
  auto& sp = registers_.stack_ptr();
  sp -= 4;
  if (auto err = bus_device_.write(sp, registers_.pc)) {
    return err;
  }

  // push SR (2 bytes)
  sp -= 2;
  if (auto err = bus_device_.write(sp, Word{registers_.sr})) {
    return err;
  }

  // make supervisor, set priority mask, jump to VBLANK
  registers_.sr.supervisor = 1;
  registers_.sr.interrupt_mask = VBLANK_INTERRUPT_LEVEL;
  registers_.pc = vblank_pc_;

  return std::nullopt;
}
```

Полный код в [lib/sega/executor/interrupt\_handler\.cpp](https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/executor/interrupt_handler.cpp)\.

Работа игр крутится вокруг этого прерывания, это двигатель игры\.

В GUI также надо настроить перерисовку экрана по получении прерывания VBLANK\.

### Video Display Processor

Video Display Processor \(он же VDP\) — второй по сложности компонент эмулятора после m68k\. Чтобы понять принцип его работы, рекомендую прочитать эти сайты:

* [Plutiedev](https://plutiedev.com/) — не только про VDP, а в целом про программирование под Sega Mega Drive\. Есть много инсайтов, как в играх реализованы псевдо\-float и прочая математика\.
* [Raster Scroll](https://rasterscroll.com/mdgraphics/) — суперкрутое описание VDP с тонной картинок\. Я бы посоветовал читать просто для интереса\.

_<Начало нудного текста\>_

Этот процессор работает так: у него 24 регистра, которые отвечают за всякую фигню, а также 64 килобайты собственного RAM \(называется VRAM — Video RAM\), куда нужно засовывать информацию о графике\.

Данные во VRAM засовывает m68k \(он же может менять регистры\), в основном на VBLANK, и VDP просто отрисовывает на телевизор картинку согласно присланным данным\. И всё, больше ничего он не делает\.

В VDP достаточно навороченная система с цветами\. В любой момент времени активны 4 палитры, в каждой находится 16 цветов\. Каждый цвет занимает 9 бит \(то есть по 3 бита на R/G/B, суммарно доступно 512 уникальных цветов\)\.

Первый цвет палитры всегда прозрачный, то есть по факту в палитре доступно 15 цветов плюс прозрачность\.

Базовая единица в VDP — это тайл, квадрат размером 8x8 пикселей\. Прикол в том, что в каждом пикселе указывается не цвет, а его номер в палитре\. То есть на пиксель уходит 4 бита \(значение от 0 до 15\), суммарно на один тайл уходит 32 байта\. Вы можете спросить: "А где указывается номер палитры?" А он указывается не в тайле, а в более высокоуровневой сущности `plane` \(или `sprite`\)\.

Высота экрана может составлять 28 или 30 тайлов, длина экрана может составлять 32 или 40 тайлов\.

В VDP захардкожены две сущности, которые называются `Plane A` и `Plane B` \(на деле есть ещё _Window Plane_\) — это условно передний и задний фоны размером не больше 64x32 тайлов\.

Они могут менять сдвиг относительно камеры с разной скоростью \(например, передний фон на \+2 пикселя за фрейм, задний — на \+1\), чтобы давать эффект объёма в игре\.

У plane можно отдельно задавать сдвиг для строки в 8 пикселей или вообще построчно, чтобы получать разные эффекты\.

Plane задаёт список тайлов и указывает палитру для каждого из них, а в целом данные для plane могут занимать нехилое место во VRAM\.

В VDP есть сущность `sprite` — это составной кусок из тайлов размером от 1x1 до 4x4 \(т\.е\. могут быть спрайты размером 2x4 тайла или 3x2 тайла\), у него есть позиция на экране и палитра, согласно которой отрисовываются тайлы\. Спрайт может быть отражён по вертикали и/или горизонтали, чтобы не дублировать тайлы\. Многие объекты отрисовываются в несколько спрайтов, если размера одного не хватает\.

В VDP влезает не больше 80 спрайтов одновременно\. У каждого спрайта есть поле `link` — это значение следующего спрайта для отрисовки, получается этакий linked list\. VDP отрисовывает сначала нулевой спрайт, потом спрайт, куда указывает `link` нулевого спрайта, и так пока очередной `link` не будет равен нулю\. Это нужно для корректной глубины спрайтов\.

В зависимости от разных обстоятельств в VRAM хватает памяти для 1400\-1700 тайлов, что вроде выглядит неплохо, но это не так много\. Например, если заполнять задний фон уникальными тайлами, то на это уйдёт \~1100 тайлов, и ни на что другое не хватит\. Так что левел\-дизайнеры жёстко дублировали тайлы для отрисовки\.

В VDP есть куча всяких правил, например два уровня приоритета слоёв:

![1252_perevod_sega_ru/image13.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image13.png)

Рисунок N12 – Приоритеты слоёв графики VDP

_<Конец нудного текста\>_

Отрисовывать VDP лучше итеративно\. Сначала можно отрисовать палитры и прикидывать, что они действительно правильно меняются со временем, то есть цвета примерно такие же, как содержимое заставки или главного меню:

![1252_perevod_sega_ru/image14.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image14.png)

Рисунок N13 – Окно в GUI \- палитры со цветами

Затем можно отрисовать все тайлы:

![1252_perevod_sega_ru/image15.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image15.png)

Рисунок N14 – Все тайлы в 0й палитре и полностью отрисованный фрейм

<details>
   <summary>Эти же тайлы в других палитрах</summary>

![1252_perevod_sega_ru/image16.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image16.png)

Рисунок N15 – 1я палитра

![1252_perevod_sega_ru/image17.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image17.png)

Рисунок N16 – 2я палитра

![1252_perevod_sega_ru/image18.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image18.png)

Рисунок N17 – 3я палитра


</details>
Затем можно отрисовать planes по отдельным окнам:

![1252_perevod_sega_ru/image19.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image19.png)

Рисунок N18 – Два plane по отдельности \(внизу\) и полностью отрисованный фрейм \(наверху\)

Ещё есть window plane, который отрисовывается немного по\-другому:

![1252_perevod_sega_ru/image20.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image20.png)

Рисунок N19 – Window Plane \(справа\) и полностью отрисованный фрейм \(слева\)

Потом наступит очередь спрайтов:

![1252_perevod_sega_ru/image21.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image21.png)

Рисунок N20 – Начало списка спрайтов \(справа\) и полностью отрисованный фрейм \(слева\)

Полная реализация отрисовщика — [lib/sega/video/video\.cpp](https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/video/video.cpp)\.

Вычислять фрейм надо попиксельно\. Чтобы пиксели показались в ImGui, надо создать 2D\-текстуру OpenGL и засовывать туда каждый фрейм:

```cpp
ImTextureID Video::draw() {
  glBindTexture(GL_TEXTURE_2D, texture_);
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA,
               width_ * kTileDimension, height_ * kTileDimension,
               0, GL_RGBA, GL_UNSIGNED_BYTE, canvas_.data());
  return texture_;
}
```

### Тестирование отрисовщика VDP

Хотя можно запускать игру и смотреть, что отрисовалось, это может быть неудобно\. Лучше доиграться до интересных случаев, собрать много дампов и сделать тест, который одной командой генерирует на дампах картинки\. По `git status` станет видно, какие картинки изменились\. Это удобно: можно фиксить баги VDP, не запуская эмулятор\.

Для этого я сделал в GUI кнопку `Save Dump`, которая сохраняем состояние видеопамяти \(регистры VDP \+ VRAM \+ CRAM \+ VSRAM\)\. Эти дампы сохранил в [bin/sega\_video\_test/dumps](https://github.com/Izaron/SegaCxx/tree/main/src/bin/sega_video_test/dumps) и написал [README](https://github.com/Izaron/SegaCxx/blob/main/src/bin/sega_video_test/README.md) как перегенерировать их одной командой\.

Конечно, это работает только если данные правильно передались в видеопамять \(на паре дампов по ссылке это не так\)\.

Для сохранения в PNG\-файлы пригодилась либа [std\_image](https://github.com/nothings/stb/blob/master/stb_image.h)\.

### Поддержка ретро контроллера

Так как мы не идём простым путём, можно поддержать ретро контроллеры, идентичные "сеговым"\.

Погуглил, что можно купить поблизости, и приобрёл контроллер за 25$: 

![1252_perevod_sega_ru/image22.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image22.png)

Рисунок N21 – Контроллер

Так как с одной стороны производитель заявлял поддержку Windows, а про Linux не было ни слова, а с другой стороны ImGui заявлял о поддержке контроллеров Xbox\+PlayStation\+Nintendo Switch, то я был морально готов реверсить ещё и контроллер\.

Но, к счастью, обошлось\. Поддержать 3\-кнопочный сега\-контроллер удалось малой кровью, понажимав кнопки и посмотрев, какому коду они соответствуют:

<details>
   <summary>Маппинг клавиатуры и ретро контроллера</summary>

```cpp
void Gui::update_controller() {
  static constexpr std::array kMap = {
      // keyboard keys
      std::make_pair(ImGuiKey_Enter, ControllerDevice::Button::Start),

      std::make_pair(ImGuiKey_LeftArrow, ControllerDevice::Button::Left),
      std::make_pair(ImGuiKey_RightArrow, ControllerDevice::Button::Right),
      std::make_pair(ImGuiKey_UpArrow, ControllerDevice::Button::Up),
      std::make_pair(ImGuiKey_DownArrow, ControllerDevice::Button::Down),

      std::make_pair(ImGuiKey_A, ControllerDevice::Button::A),
      std::make_pair(ImGuiKey_S, ControllerDevice::Button::B),
      std::make_pair(ImGuiKey_D, ControllerDevice::Button::C),

      // Retroflag joystick buttons
      std::make_pair(ImGuiKey_GamepadStart, ControllerDevice::Button::Start),

      std::make_pair(ImGuiKey_GamepadDpadLeft, ControllerDevice::Button::Left),
      std::make_pair(ImGuiKey_GamepadDpadRight,
                     ControllerDevice::Button::Right),
      std::make_pair(ImGuiKey_GamepadDpadUp, ControllerDevice::Button::Up),
      std::make_pair(ImGuiKey_GamepadDpadDown, ControllerDevice::Button::Down),

      std::make_pair(ImGuiKey_GamepadFaceDown, ControllerDevice::Button::A),
      std::make_pair(ImGuiKey_GamepadFaceRight, ControllerDevice::Button::B),
      std::make_pair(ImGuiKey_GamepadR2, ControllerDevice::Button::C),
  };

  auto& controller = executor_.controller_device();
  for (const auto& [key, button] : kMap) {
    if (ImGui::IsKeyPressed(key, /*repeat=*/false)) {
      controller.set_button(button, true);
    } else if (ImGui::IsKeyReleased(key)) {
      controller.set_button(button, false);
    }
  }
}
```


</details>


<details>
   <summary>Небольшая сайд\\\-стори про случай, когда не повезло</summary>

У меня есть клавиатура HyperX Alloy Origins Core \(тоже не реклама\)\. На ней можно настроить RGB\-подсветку со сложными паттернами \(анимация, реакция на нажатия\) и макросы\. Но программа для настройки есть только на Windows, а хотелось бы менять подсветку и на Linux по каким\-то событиям\.

Тогда пришлось поснимать дампы USB в Wireshark, пореверсить поведение\. 

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

Подсмотреть некуда \(если не реверсить \.exe\), протокол придумал дядя Ляо в подвале AliExpressTech, доки нет\. Хотя для этой клавы есть неполный реверс в [OpenRGB](https://gitlab.com/CalcProgrammer1/OpenRGB/-/blob/master/Controllers/HyperXKeyboardController/HyperXAlloyOriginsCoreController/HyperXAlloyOriginsCoreController.cpp) \(оказывается, есть такой проект для реверсов всякой разноцветной фигни\)\.


</details>
### Пиксельные шейдеры

Для крутости можно сделать всякие пиксельные шейдеры\.

Это было очень больно: в ImGui шейдеры поддержаны через одно место, и поменять его можно жутким костылём\. Кроме этого, пришлось намучиться с установкой либы `GLAD`, чтобы вызывать функцию для компиляции пиксельного шейдера\. Ещё код шейдера должен быть не любым, а на GLSL версии 130, и ещё там единственная переменная извне — это `uniform sampler2D Texture;`, остальное — это константы\.

Моей целью было написать CRT\-шейдер, который имитировал бы старый телевизор, и по возможности ещё какие\-нибудь шейдеры\.

Так как я абсолютный ноль в шейдерах, за меня их сделал ChatGPT с учётом ограничений, описанных выше\. Их исходники в [lib/sega/shader/shader\.cpp](https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/shader/shader.cpp)\. Я даже не вчитывался в код шейдеров, прочитал только комментарии\.

Фичи CRT\-шейдера от нейросетки:

* Barrel Distortion — эффект выпуклости;
* Scanline Darkness — каждая вторая строка темнее;
* Chromatic Aberration — как бы искажение RBG\-слоёв;
* Vignette — цвет по краям темнее \(когда\-таки посадил кинескоп\.\.\.\)

Результат шейдера:

![1252_perevod_sega_ru/image23.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image23.png)

Рисунок N22 – Кликните чтобы увидеть полный размер

Фред Флинстоун до шейдера и после шейдера \(увеличенный\):

![1252_perevod_sega_ru/image24.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image24.png)

Рисунок N23 – Фред Флинстоун

Попросил ChatGPT сделать другие шейдеры, но они не такие интересные:

<details>
   <summary>Шейдеры</summary>

![1252_perevod_sega_ru/image25.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image25.png)

Рисунок N24 – Без шейдеров

![1252_perevod_sega_ru/image26.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image26.png)

Рисунок N25 – Шейдер Desaturate



![1252_perevod_sega_ru/image27.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image27.png)

Рисунок N26 – Шейдер Glitch



![1252_perevod_sega_ru/image28.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image28.png)

Рисунок N27 – Шейдер Night Vision




</details>


Я играл в эмуляторе в основном без шейдеров, иногда — с CRT\.

### Оптимизации для Release\-сборки

Может показаться неочевидным, но отрисовка фрейма — это достаточно ресурсоёмкая задача, если сделать всё неоптимально\. Пусть размер экрана 320x240 пикселей\. Мы итерируемся попиксельно\. В любой момент на экране есть до 80 спрайтов плюс три `plane`, плюс у них есть приоритет, то есть их проходить надо по два раза\. У каждого спрайта или `plane` надо найти соответствующий пиксель и проверить, что он находится внутри `bounding box`, после чего вытащить тайл из тайлсета и проверить, что пиксель непрозрачный\. И всё это надо вычислять 60 раз в секунду достаточно быстро, чтобы ещё оставалось время на ImGui и эмулятор m68k\.

Поэтому вычисления не должны содержать лишнего кода, аллокаций памяти и так далее\.

На деле достаточно будет иметь Release\-сборку с выкрученными настройками оптимизации\.

```cpp
set(CMAKE_BUILD_TYPE Release)
```

Сначала выключим неиспользуемые фичи и лишние варнинги:

```cpp
add_compile_options(-Wno-format)
add_compile_options(-Wno-nan-infinity-disabled)
add_compile_options(-fno-exceptions)
add_compile_options(-fno-rtti)
```

Поставим режим сборки `Ofast`, соберём под нативную архитектуру \(в ущерб переносимости бинарника\) с link\-time optimization, раскруткой циклов и "быстрой" математикой:

```cpp
set(CMAKE_CXX_FLAGS_RELEASE
    "${CMAKE_CXX_FLAGS_RELEASE} \
     -Ofast \
     -march=native \
     -flto \
     -funroll-loops \
     -ffast-math"
)
```

Этого достаточно, чтобы получить стабильные 60 FPS и даже 120 FPS, если играть с x2 скоростью \(когда промежуток для прерывания VBLANK уменьшается в 2 раза\)\.

Единственное, что можно распараллелить — вычисление пикселей на _одной_ строке \(вычислять на разных строках одновременно нельзя, потому что между строками работает HBLANK и там могут, например, свопнуть цвета\), но я бы не рекомендовал это делать\. Если параллелить это, то для хорошей утилизации ресурсов придётся использовать lock\-free алгоритм, а мы точно не хотим туда лезть без крайней необходимости\.

### Тестирование эмулятора играми

Почти каждая игра приносила что\-то новое в эмулятор \- то используется редкая фича VDP \(которую неправильно реализовал\), то игра делает что\-то странное, и так далее\. Здесь я описал разные приколы, с которыми столкнулся, пока запускал несколько десятков игр\.

#### Заработали сразу

На игре **Cool Spot \(1993\)** я в принципе построил эмулятор: реверсил её, дебажил приколы VDP и так далее\. Персонаж Cool Spot — это маскот лимонада 7 Up \(он известен только в США, для прочих регионов маскот другой\)\. Это красивый платформер, много раз проходил его до конца в детстве\.

![1252_perevod_sega_ru/image29.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image29.png)

Рисунок N28 – Cool Spot \(1993\)



Игра **Earthworm Jim \(1994\)**\. Червяк шарится по помойкам, выглядит круто\.

![1252_perevod_sega_ru/image30.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image30.png)

Рисунок N29 – Earthworm Jim \(1994\)

Игра **Alladin \(1993\)**\. Не очень зашло, графика и геймплей без особых фокусов\.

![1252_perevod_sega_ru/image31.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image31.png)

Рисунок N30 – Alladin \(1993\)

#### Чтение статусного регистра VDP

Некоторые игры читают статусный регистр VDP: если положить неправильный бит, то игра зависнет или будет работать некорректно\.

Так было в **Battle Toads \(1992\)**\. Игра делала так:

```cpp
    do {
      wVar2 = VDP_CTRL;
    } while ((wVar2 & 2) != 0);
```

![1252_perevod_sega_ru/image32.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image32.png)

Рисунок N31 – Battle Toads \(1992\)

Window Plane по\-другому показывается при ширине 32 тайла\.

Одно из самых плохо документированных мест — поведение window plane\. Оказывается, если ширина окна 32 тайла, а ширина всех plane 64 тайла, то для window plane тайл должен искаться из расчёта, что его ширина всё\-таки 32 тайла\. Не смог найти, где это было бы задокументировано\. Оставил у себя костыль\.

Проявляется, например, в игре **Goofy's Hysterical History Tour \(1993\)**\. Эта игра так себе по геймплею, на любителя\.

![1252_perevod_sega_ru/image33.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image33.png)

Рисунок N32 – Goofy's Hysterical History Tour \(1993\), жёлтая полоска внизу пришла из "Window Plane"\)

#### Ошибки с auto increment в DMA

Самое проблемное место в VDP — это DMA \(Direct Memory Access\), придуманный, чтобы переносить куски памяти из RAM m68k в VRAM\. Там несколько режимов и настроек, и можно легко ошибиться\. Чаще всего ошибки происходят с `auto increment`\. Когда указатель на память увеличивается на это число, бывают неочевидные условия, в какой момент это должно произойти\.

В игре **Tom and Jerry \- Frantic Antics \(1993\)**, когда персонаж двигается по карте, новые слои в plane докидываются через редкий auto increment \(128 вместо обычного 1\)\. У меня был код, как будто там всегда 1, из\-за этого plane почти не менялся, кроме верхней строки\. Отдебажил методом пристального взгляда на окно plane, обнаружив, что слой добавляется как бы вертикально\.

![1252_perevod_sega_ru/image34.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image34.png)

Рисунок N33 – Tom and Jerry \- Frantic Antics \(1993\)

Сама эта игра, наверное, худшая из запущенных мной\. Авторы как будто вообще не старались и делали её для приставок более старого поколения\.

#### Оверсайз запись во VSRAM\-память

На верхнеуровневой схеме архитектуры Sega Mega Drive это не обозначено, но, кроме основной видеопамяти VRAM \(64Kb\), по какой\-то причине отдельно стоят CRAM \(128 байт, описание 4 цветовых палитр\) и VSRAM \(80 байт, вертикальный сдвиг\)\. Наличие этих независимых кусков памяти выглядит ещё смешнее, если учесть, что горизонтальный сдвиг полностью лежит в VRAM, но не суть\.

В игре **Tiny Toon Adventures \(1993\)** используется один и тот же алгоритм, чтобы обнулить CRAM и VSRAM\. И, соответственно, в VSRAM записывается 128 байт, когда его размер 80 байт\.\.\. И если никак не обработать это, то будет сегфолт\. Приставка позволяет много вольностей, и это только верхушка айсберга\.

![1252_perevod_sega_ru/image35.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image35.png)

Рисунок N34 – Tiny Toon Adventures \(1993\)

Сама игра имеет приятную графику, геймплей средний, в нем есть жёсткий закос под Соника\.

#### Вызов DMA когда он выключен

В игре **The Flinstones \(1993\)** было странное поведение: plane двигался наверх так же, как вправо\. То есть были странные записи в VSRAM\. Разгадывалось просто: чтобы DMA работал \(или, наоборот, не работал\), надо поставить определённый бит в одном регистре VDP\. Я это стал учитывать, и движение plane починилось\. Игра как раз пыталась сделать DMA\-записи, когда это было выключено\. Видимо, авторы как\-то криво написали логику\.

![1252_perevod_sega_ru/image36.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image36.png)

Рисунок N35 – The Flinstones \(1993\)

#### Однобайтовые чтения регистров

Обычно регистры читаются двумя байтами \(так видел всех гайдах\), но в игре **Jurassic Park \(1993\)** сделано чтение регистра VDP одним байтом\. Пришлось это поддержать\.

![1252_perevod_sega_ru/image37.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image37.png)

Рисунок N36 – Jurassic Park \(1993\)

#### Попытка записи в read\-only память

В игре **Spot goes to Hollywood \(1995\)**, если декомпилировать одно место, то там происходит такое:

```cpp
  if (psVar4 != (short *)0x0) {
    do {
      sVar1 = psVar4[1];
      *(short *)(sVar1 + 0x36) = *(short *)(sVar1 + 0x36) + -2;
      *psVar4 = sVar1;
      psVar4 = psVar4 + 1;
    } while (sVar1 != 0);
    DAT_fffff8a0._2_2_ = DAT_fffff8a0._2_2_ + -2;
  }
```

То есть тут off\-by\-one ошибка, и запись делается по адресу `0x000036`\. И Sega просто ничего с этим не делает, аналога сегфолта нет\. А что, так можно было? Оказывается, да\. И такие приколы возникают нередко — приходится вместо возврата `Error` просто писать лог и ничего не делать\.

![1252_perevod_sega_ru/image38.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image38.png)

Рисунок N37 – Spot goes to Hollywood \(1995\)

#### Смена endianness при DMA в режиме VRAM fill

В игре **Contra: Hard Corps \(1994\)** увидел раздолбанные сдвиги plane'ов\. Добавил логи, увидел, что в нём используется редкий режим VRAM fill для заполнения таблицы горизонтальных сдвигов\. После серии пристальных взглядов удостоверился, что каким\-то образом записанные байты меняют endianness\.\.\. Пришлось поставить кринжовый костыль:

```cpp
    // change endianness in this case (example game: "Contra Hard Corps")
    if (auto_increment_ > 1) {
      if (ram_address_ % 2 == 0) {
        ++ram_address_;
      } else {
        --ram_address_;
      }
    }
```

![1252_perevod_sega_ru/image39.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image39.png)

Рисунок N38 – Contra: Hard Corps \(1994\)

#### Зависимость от Z80 RAM и прочие зависимости

В эмуляторе пока не поддержан Z80, а некоторые игры от него зависят\. Например, игра **Mickey Mania \(1994\)** зависает после старта\. Открыв декомпилятор, видим, что оно вечно читает адрес `0xA01000`, пока там не окажется ненулевой байт\. Это зона Z80 RAM, то есть в игре создаётся неявная связь между m68k и z80\.

Поставим новый кринжовый костыль: возвращаем рандомный байт, если это чтение Z80 RAM\.

Но тут Остапа понесло — теперь игра читает VDP H/V Counter \(по адресу `0xC00008`\)\.

Закостылим и его\. Теперь игра показывает заставку и успешно падает, читая ещё один незамапленный адрес, и\.\.\. игра временно откладывается, пока не накопилось критическое количество костылей\.

![1252_perevod_sega_ru/image40.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image40.png)

Рисунок N39 – Заставка Mickey Mania \(1994\)

Ещё пример — игра **Sonic the Hedgehog \(1991\)**, где я попадаю в некий дебаг\-режим, потому что есть странные цифры в верхнем левом углу\.

![1252_perevod_sega_ru/image41.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image41.png)

Рисунок N40 – Sonic the Hedgehog \(1991\) с двумя plane

К счастью, первый Соник давно полностью отреверсен \([GitHub](https://github.com/sonicretro/s1disasm)\), поэтому если упороться, то есть возможность его полностью поддержать\.

### Поддержка звука Z80

#### Что делает Z80

Как писалось ранее, Zilog Z80 — сопроцессор для проигрывания музыки\. Имеет собственный RAM размером в 8Kb и подключён к синтезатору звука YM2612\.

Сам по себе Z80 совершенно обычный процессор \(не специально\-звуковой\), который использовался в приставках прошлых поколений\.

Как создавалась музыка для игр Mega Drive? Компания Sega распространяла среди разработчиков тулзу [GEMS](https://segaretro.org/GEMS) под MS\-DOS, где можно создать всякие звуки и проверить на разработческой плате, как звучало бы, если бы звук проигрывался на Mega Drive \(_what you hear is what you get_\)\.

Однако многие разработчики забивали на музло и использовали дефолтные сэмплы, из\-за чего во многих несвязанных играх есть повторяющиеся звуки\.

Созданный звук транслировался в программу на ассемблере Z80 \(эта программа называлась Sound Driver\) и упаковывался в ROM картриджа со всеми прочими данными\. Во время игры m68k читал Sound Driver из ROM картриджа и засовывал его в RAM Z80, после чего процессор Z80 начинал по программе производить звук, работая независимо от m68k\. Вот такая многопоточность\.\.\. Подробнее про музыку в Mega Drive можно узнать [в этом видео](https://www.youtube.com/watch?v=WEvnZRCW_qc)\.

#### Как поддержать Z80

Сначала надо выучить 332\-страничный [референс](https://www.zilog.com/docs/z80/um0080.pdf), создать эмулятор Z80, аналогично эмулятору m68k\. Покрыть его тестами, позапускать программки на Z80\. Потом заботать теорию звуков, [регистры YM2612](https://plutiedev.com/ym2612-registers), написать генератор звуков под Linux\.

По объёму звучит как примерно всё то же, что ранее было описано \(m68k \+ VDP\), или, по крайней мере, как половина описанного, то есть это немало чего надо сделать\.

### Что ещё можно сделать

Описанное в статье уже даёт возможность запускать много игр, но можно сделать и больше \(кроме звука\), например всякие мелочи\.

**Поддержать режим двух игроков**

Сейчас поддерживается только один игрок\. Можно поддержать режим с двух геймпадов\.

**Поддержать HBLANK**

Сейчас вызывается VBLANK, но после каждой строки надо вызывать HBLANK\. Его, на самом деле, используют мало игр\. Самый мейнстримный кейс — смена палитры посередине изображения\.

<details>
   <summary>Пример по игре Ristar \\\(1994\\\)</summary>

Например, в игре **Ristar \(1994\)** используется эта фича\. Обратите внимание на то, что на уровне воды есть волны, а под уровнем воды колонны размыты:



![1252_perevod_sega_ru/image42.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image42.png)

Рисунок N41 – Ristar \(1994\), без HBLANK, на моем эмуляторе



И вот что на самом деле должно быть \(по прохождению из YouTube\):





![1252_perevod_sega_ru/image43.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image43.png)

Рисунок N42 – Ristar \(1994\), это же место на корректном эмуляторе



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



![1252_perevod_sega_ru/image44.png](https://import.viva64.com/docx/blog/1252_perevod_sega_ru/image44.png)

Рисунок N43 – Слева \- почти под уровнем воды, справа \- полностью под уровнем воды


</details>
**Поддержать другие контроллеры**

Сейчас поддержан только 3\-кнопочный геймпад\. Можно поддержать [6\-кнопочный](https://plutiedev.com/controllers#6-button), а также более редкие периферийные устройства: [Sega Mouse](https://plutiedev.com/mouse), [Sega Multitap](https://plutiedev.com/sega-multitap), [Saturn Keyboard](https://plutiedev.com/saturn-keyboard), [Ten Key Pad](https://plutiedev.com/ten-key-pad), и, внезапно, [принтер](https://plutiedev.com/printer)\.

**Более крутой дебаггер**

Встроенный дебаггер можно сделать круче, чтобы можно было видеть память, ставить брейки на запись/чтение, разматывать стектрейс и в итоге намного быстрее дебажить проблемы\.