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

от автора

В этой статье описано создание эмулятора 16-битной приставки Sega Mega Drive на C++.

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

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

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

Архитектура 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 — контроллеры, сначала это был «3-кнопочный геймпад», потом добавился «6-кнопочный», а затем еще с десяток более редких девайсов.

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

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

Эмуляция Motorola 68000

Факты про m68k

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

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

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

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

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

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

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

Регистры m68k

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

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

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

Класс BigEndian<T>

Так как 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();

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

Классы DataView и MutableDataView

Лучше всего для этого подходит 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 в отрыве от памяти:

Структура Registers
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). В целом ничего против стандартных исключений не имею, просто этот подход делает дебаг немного удобнее.

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

Класс Error
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;     if (auto err = read(addr, MutableDataView{reinterpret_cast<Byte*>(&data), sizeof(T)})) {       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» — вот так для них вычисляется адрес:

Target::indexed_address
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()}; \   }

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

Макрос HAS_PATTERN

Функции для расчета «маски»:

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 int*)0xFF0008 = i; // если не писать "volatile",                                           // компилятор соптимизирует в одну запись!         }     } 

Компиляторы 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 для парсинга бинарных файлов, и посмотрим на содержимое:

Паттерн sega.hexpat

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

ImHex "разобрал" начало файла

ImHex «разобрал» начало файла

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

Дизассемблер в ImHex

Дизассемблер в 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. В ней очень много всего, и можно сделать какой угодно интерфейс.

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

Пример окна — статус эмулятора 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

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

  1. Может возникнуть проблема с GUI, у которого не будет доступа по умолчанию, но после спортивного гуглежа в команду добавились -v для X11 и -e DISPLAY

  2. Также чтобы GUI работал, нужно с компа запустить xhost +, чтобы выключить «access control».

  3. Чтобы был доступ к контроллерам (про них раздел ниже) в команду добавился --privileged.

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

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

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

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

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

Реверсинг игры для Sega Mega Drive

Реверсинг игры для Sega Mega Drive

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

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

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

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

"switch" из функций

«switch» из функций

Еще встречается «switch» из функций, когда у функций одинаковый контент, но первые несколько инструкций отличаются, пример на скрине

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. VBLANK-прерывание включено видеопроцессором;

  2. Значение Interrupt Mask в статусном регистре меньше чем 6 (это типа уровень важности текущего прерывания);

  3. Прошло 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 регистра, которые отвечают за всякую хреновню, а также 64Kb собственного RAM (называется VRAM — Video RAM), куда нужно совать информацию о графике.
Данные во VRAM засовывает m68k (он же может менять регистры), в основном на VBLANK, и VDP просто отрисовывает на телевизор картинку согласно присланным данным и всё, больше ничего не делает.

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

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

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

В VDP захардкожены две сущности, которые называются «Plane A» и «Plane B» (на деле есть еще «Window Plane») — это условно передний и задний фон, размером не больше 64×32 тайлов.

Они могут менять сдвиг относительно камеры с разной скоростью (например передний фон на +2 пикселя за фрейм, задний на +1), чтобы давать эффект объема в игре.
У plane можно отдельно задавать сдвиг для «строки» в 8 пикселей, или вообще построчно, чтобы получать разные эффекты.
Plane задает список тайлов и указывает палитру для каждого тайла, а в целом данные для plane могут занимать нехилое место во VRAM.

В VDP есть сущность «sprite» — это составной кусок из тайлов размера от 1×1 до 4×4 (т.е. могут быть спрайты размером 2×4 тайла или 3×2 тайла), у него есть позиция на экране и палитра, согласно которой отрисовываются тайлы. Спрайт может быть отражен по вертикали и/или горизонтали, чтобы не дублировать тайлы. Многие объекты отрисовываются в несколько спрайтов, если размера одного не хватает.
В VDP влезает не больше 80 спрайтов одновременно. У каждого спрайта есть поле «link», это значение следующего спрайта для отрисовки, получается этакий linked list. VDP отрисовывает сначала нулевой спрайт, потом спрайт куда указывает «link» нулевого спрайта, и так пока очередной «link» не будет равен нулю. Это нужно для корректной глубины спрайтов.

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

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

Приоритеты слоев графики VDP

Приоритеты слоев графики VDP

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

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

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

Окно в GUI — палитры со цветами

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

Все тайлы в 0й палитре и полностью отрисованный фрейм

Все тайлы в 0й палитре и полностью отрисованный фрейм
Эти же тайлы в других палитрах
1я палитра

1я палитра
2я палитра

2я палитра
3я палитра

3я палитра

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

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

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

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

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

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

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

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

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

Полная реализация отрисовщика — 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.

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

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

Погуглил, что можно купить поблизости, и за деньги по курсу 29$ приобрел контроллер от «Retroflag» (не реклама):

Так как с одной стороны производитель заявлял поддержку 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 — цвет по краям темнее (когда таки посадил кинескоп…)

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

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

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

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

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

Шейдеры
Без шейдеров

Без шейдеров
Шейдер Desaturate

Шейдер Desaturate
Шейдер Glitch

Шейдер Glitch
Шейдер Night Vision

Шейдер Night Vision

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

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

Может показаться неочевидным, но отрисовка фрейма это достаточно ресурсоемкая задача, если сделать неоптимально. Пусть размер экрана 320×240 пикселей, мы итерируемся попиксельно. В любой момент на экране есть до 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» — он известен только в США, для прочих регионов маскот другой. Это красивый платформер, много раз проходил его до конца в детстве.

Cool Spot (1993)

Cool Spot (1993)

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

Earthworm Jim (1994)

Earthworm Jim (1994)

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

Alladin (1993)

Alladin (1993)

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

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

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

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

Battle Toads (1992)

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

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

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

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

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

Tom and Jerry - Frantic Antics (1993)

Tom and Jerry — Frantic Antics (1993)

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

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

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

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

Tiny Toon Adventures (1993)

Tiny Toon Adventures (1993)

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

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

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

The Flinstones (1993)

The Flinstones (1993)

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

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

Jurassic Park (1993)

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

Spot goes to Hollywood (1995)

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_;       }     }
Contra: Hard Corps (1994)

Contra: Hard Corps (1994)

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

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

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

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

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

Заставка Mickey Mania (1994)

Заставка Mickey Mania (1994)

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

Sonic the Hedgehog (1991) с двумя plane

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)

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

Ristar (1994), без HBLANK, на моем эмуляторе

Ristar (1994), без HBLANK, на моем эмуляторе

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

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

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

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

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

Слева — почти под уровнем воды, справа — полностью под уровнем воды

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

Сейчас поддержан только 3-кнопочный геймпад. Можно поддержать 6-кнопочный, а также более редкие периферийные устройства: Sega Mouse, Sega Multitap, Saturn Keyboard, Ten Key Pad, и (внезапно) принтер.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/871284/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *