Top.Mail.Ru
Unicorn with delicious cookie
Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
Создаём эмулятор Sega Mega Drive на C++

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

30 Май 2025
Автор:

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

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи — Евгений Шульгин. Почта: izaronplatz@gmail.com.

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

Архитектура Sega Mega Drive (source)

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

  • ROM — данные картриджа, имеет размер максимум 4MB;
  • VDP — Video Display Processor, асик разработки самой Sega, чип видеоконтроллера. Имеет 64KB RAM (называется VRAM — Video RAM);
  • FM Sound — асик от Yamaha (YM2612), имеет 6 FM-каналов, синтезирует звук;
  • PSG Sound — асик от Texas Instruments (SN76489), имеет 3 меандровых канала, тоже синтезирует звук. Нужен для совместимости с 8-битной Sega Master System;
  • CPU — процессор Motorola 68000, делающий основную массу работы. Имеет 64KB RAM;
  • Co-Processor — процессор Zilog Z80, используется "для звука", а точнее, его задача в том, чтобы вовремя просыпаться и писать команды в регистры YM2612. Имеет 8KB RAM.
  • Input/Output — контроллеры. Сначала это был "трёхкнопочный геймпад", потом добавился "шестикнопочный", а затем ещё с десяток более редких девайсов.

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

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

Эмуляция Motorola 68000

Факты про m68k

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

В архитектуре процессора уже есть элементы 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.

Таблица инструкций m68k (source)

Регистры m68k

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

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

using AddressType = Long;

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

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

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

private:
  T value_;
};

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

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

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

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

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

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

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.

Обработка ошибок

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

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

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

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

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

Если метод может либо завершиться с ошибкой, либо вернуть объект типа T, он должен иметь возвращаемый тип std::expected<T, Error>. Этот шаблон заехал в C++23, он удобен для такого подхода.

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

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

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:

  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 — это регистры плюс память, таким образом это:

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

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

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

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

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

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

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

Реализацию можно посмотреть в lib/m68k/target/target.h.

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

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

Представление инструкций m68k

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

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

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

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

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

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

Его реализацию можно посмотреть в lib/m68k/instruction/decode.cpp

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

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

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

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

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:

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

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

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

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

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

Исполнение инструкций m68k

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

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

Его реализацию можно посмотреть в lib/m68k/instruction/execute.cpp — это самый сложный код в симуляторе.

Описание того, что должна делать инструкция, можно прочитать в этой markdown-документации. Иногда этого недостаточно, тогда можно прочитать длинное описание в этой книге.

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

Есть муторные инструкции, например MOVEP. И ещё все инструкции про BCD-арифметику (как ABCD). BCD-арифметика — это когда с hex-числами проводят операции как над decimal-числами. Например, BCD-сложение — это 0x678 + 0x535 = 0x1213. Над этими BCD-инструкциями я просидел больше четырёх часов, потому что у них супер сложная логика, которая нигде нормально не объясняется.

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

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

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

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

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

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

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

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

    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):

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

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

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

Выведет такой ассемблер.

Этот объектный файл упакован в формат ELF. Нужно распаковать. Вытащим ассемблерный код (секция .text) в файл a.bin:

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

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

Теперь можно проэмулировать работу на этом ассемблере. Код эмулятора тут, а тут логи эмуляции. В этом примере по адресу 0xFF0008 записываются все числа от 1307 до 1320.

В следующей программе мне пришлось помучаться с компиляторами. Я сделал вычисление простых чисел до 1000 через решето Эратосфена.

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

    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):

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

Ассемблер выглядит так, вывод эмулятора выглядит так.

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

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

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

При желании и усидчивости можно эмулировать, например, работу Linux для m68k. QEMU умеет так делать!

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

Для анализа всяких неизвестных форматов/протоколов я использую ImHex, чтобы лучше видеть содержимое.

Пусть ROM-файл с любимой игрой детства скачан. Погуглив формат ROM-файлов, становится понятно, что первые 256 байт занимает m68k vector table, то есть куча адресов на всякие случаи наподобие деления на ноль. Следующие 256 байт занимает ROM header с информацией про игру.

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

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

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;

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

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

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

Когда всё станет понятно, можно структуры из hex pattern завезти в C++. Пример в lib/sega/rom_loader/rom_loader.h (ненужные поля повыбрасывал).

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

Bus Device

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

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.

GUI

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

Для GUI я использовал мегакрутую либу ImGui. В ней очень много всего, и можно сделать какой угодно интерфейс.

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

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

Работа в Docker

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

Сначала заведите Dockerfile, потом при его изменении пересоздавайте образ:

sudo docker build -t segacxx .

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

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

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

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

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

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

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

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

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

Очень хороший старт даёт плагин от @DrMefistO: он сам промаркирует общеизвестные адреса и создаст сегменты.

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

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

Чтобы сделать фрейм, в ассемблере m68k надо использовать LINK и UNLK. На деле такое почти не встречается: в большинстве функций аргументы передаются через полурандомные регистры. Некоторые функции помещают результат во флаг статусного регистра (например, в ZF). К счастью, в Ghidra в таких случаях можно указать руками, что именно делает функция, чтобы декомпилятор показал более адекватный вывод. Ещё встречается switch из функций, когда у них одинаковый контент, но первые несколько инструкций отличаются. Пример на скрине:

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

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

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

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

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

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

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

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

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

Видеоконтроллер 60 или 50 раз в секунду (в зависимости от NTSC или PAL/SECAM) отрисовывает на старом телевизоре фрейм попиксельно.

Отрисовка фрейма (source)

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

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

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

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

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

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.

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

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

Video Display Processor

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

  • Plutiedev — не только про VDP, а в целом про программирование под Sega Mega Drive. Есть много инсайтов, как в играх реализованы псевдо-float и прочая математика.
  • Raster Scroll — суперкрутое описание 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 есть куча всяких правил, например два уровня приоритета слоёв:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Полная реализация отрисовщика — lib/sega/video/video.cpp.

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

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 и написал README как перегенерировать их одной командой.

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

Для сохранения в PNG-файлы пригодилась либа std_image.

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

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

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

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

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

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

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

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

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

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

Подсмотреть некуда (если не реверсить .exe), протокол придумал дядя Ляо в подвале AliExpressTech, доки нет. Хотя для этой клавы есть неполный реверс в OpenRGB (оказывается, есть такой проект для реверсов всякой разноцветной фигни).

Пиксельные шейдеры

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

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

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

Так как я абсолютный ноль в шейдерах, за меня их сделал ChatGPT с учётом ограничений, описанных выше. Их исходники в lib/sega/shader/shader.cpp. Я даже не вчитывался в код шейдеров, прочитал только комментарии.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

set(CMAKE_BUILD_TYPE Release)

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

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, раскруткой циклов и "быстрой" математикой:

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 (он известен только в США, для прочих регионов маскот другой). Это красивый платформер, много раз проходил его до конца в детстве.

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

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

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

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

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

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

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

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

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

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

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

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

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

Рисунок 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, обнаружив, что слой добавляется как бы вертикально.

Рисунок 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 байт... И если никак не обработать это, то будет сегфолт. Приставка позволяет много вольностей, и это только верхушка айсберга.

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

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

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

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

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

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

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

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

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

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

  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 просто писать лог и ничего не делать.

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

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

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

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

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

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

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

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

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

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

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

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

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

К счастью, первый Соник давно полностью отреверсен (GitHub), поэтому если упороться, то есть возможность его полностью поддержать.

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

Что делает Z80

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

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

Как создавалась музыка для игр Mega Drive? Компания Sega распространяла среди разработчиков тулзу 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 можно узнать в этом видео.

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

Сначала надо выучить 332-страничный референс, создать эмулятор Z80, аналогично эмулятору m68k. Покрыть его тестами, позапускать программки на Z80. Потом заботать теорию звуков, регистры YM2612, написать генератор звуков под Linux.

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

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

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

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

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

Поддержать HBLANK

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

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

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

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

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

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

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

Поддержать другие контроллеры

Сейчас поддержан только 3-кнопочный геймпад. Можно поддержать 6-кнопочный, а также более редкие периферийные устройства: Sega MouseSega MultitapSaturn KeyboardTen Key Pad, и, внезапно, принтер.

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

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

Последние статьи:

Опрос:

Вы уже пользуетесь PVS-Studio?

book gost

Дарим
электронную книгу
за подписку!

Популярные статьи по теме

Подпишитесь на новые комментарии к этой статье.

Подписаться

Комментарии (0)

close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
close form
Я хочу принять участие в тестировании
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам