- PVSM.RU - https://www.pvsm.ru -
В этой статье описано создание эмулятора 16-битной приставки Sega Mega Drive на C++.
Будет много интересного: эмуляция процессора Motorola 68000, реверсинг игр, графика на OpenGL, шейдеры, и многое другое. И все это на современном C++. В статье много картинок, можно хоть на них посмотреть.
Описание каждого компонента из схемы в рандомном порядке:
ROM - данные картриджа, имеет размер максимум 4MB.
VDP - "Video Display Processor [2]", асик разработки самой Sega, чип видеоконтроллера. Имеет 64KB RAM (называется VRAM - Video RAM).
FM Sound - асик от Yamaha (YM2612 [3]), имеет 6 FM-каналов, синтезирует звук.
PSG Sound - асик от Texas Instruments (SN76489 [4]), имеет 3 меандровых канала, тоже синтезирует звук, нужен для совместимости с 8-битной Sega Master System.
CPU - процессор Motorola 68000 [5], делающий основную массу работы. Имеет 64KB RAM.
Co-Processor - процессор Zilog Z80 [6], используется "для звука", а точнее его задача в том чтобы вовремя просыпаться и писать команды в регистры YM2612. Имеет 8KB RAM.
Input/Output - контроллеры, сначала это был "3-кнопочный геймпад", потом добавился "6-кнопочный", а затем еще с десяток более редких девайсов.
Центральным компонентом является "Motorola 68000" (сокращенно "m68k"). У него 24-битовая адресация, по адресам 0x000000 - 0xFFFFFF
. Любое обращение к памяти из этого процессора выполняется шиной (на схеме обозначено "68000 BUS"), которая преобразует адрес в разные места - тут можно увидеть маппинг адресов [7].
В этой статье будет эмуляция всех описанных компонентов кроме Z80 и звука.
m68k был в свое время популярным процессором. Он использовался на протяжении десятилетий в компьютерах Macintosh, Amiga, Atari, приставке Sega Mega Drive, и прочих устройствах.
В архитектуре [8] процессора уже есть элементы 32-битовости, но с ограничениями.
Всего есть 16 регистров 32-битных (и 1 регистр 16-битный). Несмотря на то, что "адресные" регистры (A0
-A7
) 32-битные, по факту для адреса берутся младшие 24 бита. То есть адресуется пространство в 16 мегабайт памяти.
Процессор поддерживает зачаток виртуализации для многозадачных систем - обращение к регистру A7
по факту будет обращением либо к USP
(user stack pointer) либо к SSP
(supervisor stack pointer) в зависимости от флага в статусном регистре.
В отличие от (почти всех) современных архитектур, m68k придерживается порядка байт big-endian. Адрес и размер инструкции всегда делится на 2, читать память тоже можно только по адресу делящемуся на 2 (за небольшим исключением). Не поддерживается floating-point arithmetic.
Сделаем в общем месте базовые типы:
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 [10], он удобен для такого подхода.
Как упоминалось в разделе про "архитектуру 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" это регистры + память, таким образом это:
struct Context {
Registers& registers;
Device& device;
};
У каждой инструкции есть от 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 [11].
Самыми сложными типами адресации оказались "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;
}
У класса инструкций есть примерно такие переменные:
Kind kind_; // один из 82 опкодов
Size size_; // Byte, Word или Long
Condition cond_; // одно из 16 условий, для "бранчевых" инструкций
Target src_; // source-операнд
Target dst_; // destination-операнд
И еще 2-3 переменные, всего я уложился в 64 байта.
У класса инструкций есть статический метод для парсинга текущей инструкции:
static std::expected<Instruction, Error> decode(Context ctx);
Его реализацию можно посмотреть в lib/m68k/instruction/decode.cpp [12]
Чтобы не копипастить кучу проверок на "ошибку", я прибегаю к макросам наподобии таких:
#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.
У класса инструкций есть метод для исполнения - во время исполнения меняются регистры и опционально есть обращение в память:
[[nodiscard]] std::optional<Error> execute(Context ctx);
Его реализацию можно посмотреть в lib/m68k/instruction/execute.cpp [13] - это самый сложный код в симуляторе.
Описание что должна делать инструкция можно читать в этой markdown-документации [14]. Иногда этого недостаточно, тогда можно читать длинное описание в этой книге [15].
Написание эмуляции инструкций это итеративный процесс. Сначала каждую инструкцию сложно делать, но потом накапливается больше паттернов и общего кода, и становится проще.
Есть муторные инструкции. Например MOVEP [16]. И еще все инструкции про BCD-арифметику (как ABCD [17]). BCD-арифметика это когда с hex-числами проводят операции как над decimal-числа, например BCD-сложение это 0x678 + 0x535 = 0x1213
. Над этими BCD-инструкциями просидел больше четырех часов, потому что у них супер сложная логика [18], которая нигде нормально не объясняется.
Самое важная часть - тестирование. Небольшая ошибка в каком-нибудь статусном флаге может привести к катастрофе во время эмуляции. Когда программа большая, ее становится легко сломать в неожиданном месте, поэтому нужны тесты на все инструкции.
Мне очень помогли тесты из этого репозитория [19]. На каждую инструкцию есть 8000+ тестов, которые покрывают все возможные случаи. Суммарно тестов чуть больше миллиона.
Они могут находить даже самые мелкие ошибки - нередко бывает ситуация, что не проходятся ~20 тестов из 8000.
Например, инструкция MOVE (A6)+ (A6)+
(обращение к регистру A6 делается с пост-инкрементом) должна работать не так, как я реализовал, поэтому я сделал костыль, чтобы работало корректно.
Сейчас эмулятор работает правильно почти везде, ломается на единичных кейсах не больше ~10 штук (где то ли ошибка в самих тестах, то ли еще что-то).
Можно эмулировать свои программы. Напишем простую программу, которая читает два числа, а потом записывает в цикле все значения в промежутке:
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
Выведет такой ассемблер [20].
Этот объектный файл упакован в формат ELF [21]. Нужно распаковать - вытащим чисто байты с ассемблером (секция .text
) в файл a.bin
:
m68k-linux-gnu-objcopy -O binary --only-section=.text a.o a.bin
(Командой hd a.bin
можно удостовериться что вытащились правильные файлы)
Теперь можно проэмулировать работу на этом ассемблере. Код эмулятора тут [22], а тут логи эмуляции [23]. В этом примере по адресу 0xFF0008
записываются все числа от 1307 до 1320.
В следующей программе мне пришлось помучаться с компиляторами. Я сделал вычисление простых чисел до 1000 через решето Эратосфена [24].
Для этого понадобился массив, который нужно заполнить нулями. Компиляторы все норовили заиспользовать метод 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
Ассемблер выглядит так [25], вывод эмулятора выглядит так [26].
Более нетривиальные программы эмулировать сложно, получается слишком синтетическое окружение. Например, в такой программе с записью строки есть целых две проблемы:
void work() {
strcpy((char*)0xFF0008, "Der beste Seemann war doch ich");
}
Первая проблема это вызов метода, который еще не прилинкован к объектному файлу, вторая проблема это сама строка, у которой еще не известно место в памяти.
При желании и усидчивости можно эмулировать, например, работу Linux для m68k. QEMU умеет так делать! [27]
Для анализа всяких неизвестных форматов/протоколов я использую ImHex [28], чтобы лучше видеть содержимое.
Пусть ROM-файл с любимой игрой детства скачан. Погуглив формат ROM-файлов, становится понятным что первые 256 байт занимает m68k vector table [29], то есть куча адресов на всякие случаи наподобии деления на ноль. Следующие 256 байт занимает ROM header [30] с информацией про игру.
Набросаем "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;
Там же можно дизассемблировать какое-то количество инструкций начиная с initial_pc
(точка входа) и посмотреть что происходит в первых инструкциях:
Когда все станет понятно, можно структуры из "hex pattern" завезти в C++ - пример в lib/sega/rom_loader/rom_loader.h [31] (ненужные поля повыбрасывал).
В отличие от многих других форматов где заголовки как бы не являются составной частью самого содержимого, в ROM-файлах этот заголовок в 512 байт являются неотъемленной частью, то есть ROM-файл просто надо целиком загрузить в память. По маппингу адресов ему отведена область 0x000000 - 0x3FFFFF
.
Для более удобной работы с маппингом адресов можно реализовать 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 [32]
Сначала вывод эмуляции показывался только в терминале и управление было тоже через терминал, но для эмулятора это неудобно, поэтому надо переносить все в графический интерфейс.
Для GUI я использовал мега крутую либу ImGui [33]. В ней очень много всего, и можно сделать какой угодно интерфейс.
Так можно отрисовать всё состояние эмулятора в разных окнах - без этого дебажить очень трудно.
Чтобы не страдать от старых версий операционки на своем компе (когда все пакеты старые, даже современный C++ не компилируется) и не загрязнять всякими левыми пакетами, разработку лучше вести из-под Docker.
Сначала заведите Dockerfile [34], потом при его изменении пересоздавайте образ:
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
.
Пусть мы настроили эмуляцию m68k по ROM'у, почитали какую-нибудь документацию, накидали несколько базовых девайсов в шину (ROM, RAM, trademark-регистр [35], etc.), и эмулируем по одной инструкции, глядя в дизассемблер.
Это муторное занятие, хочется получить более высокоуровневую картину. Для этого можно отреверсить игру. Я для этого использую Ghidra [36]:
Очень хороший старт дает плагин [37] от @DrMefistO [38] - он сам промаркирует общеизвестные адреса и создаст сегменты.
Можно будет увидеть, что поскольку игры писались на ассемблере изначально, то у них специфический вид:
Вперемешку код и данные - есть кусок кода, потом идут куски байтов например для цвета, потом снова код, и так далее. Все по архитектуре фон Неймана.
Чтобы сделать "фрейм", в ассемблере m68k надо использовать LINK [39] и UNLK [40]. На деле такое почти не встречается, в большинстве "функций" аргументы передаются через полу-рандомные регистры. Некоторые "функции" помещают результат в флаг статусного регистра (например в ZF). К счастью в Ghidra в таких случаях можно указать руками, что именно делает функция, чтобы декомпилятор показал более адекватный вывод.
Еще встречается "switch" из функций, когда у функций одинаковый контент, но первые несколько инструкций отличаются, пример на скрине
Чтобы примерно представлять что происходит (и сделать более точный эмулятор Sega), не обязательно реверсить всю игру - достаточно каких-то 5-10%. Лучше реверсить ту игру, которую вы хорошо помните из детства, чтобы она не была "черным ящиком".
Это умение понадобится в будущем, чтобы быстро отдебажить поломку эмуляции в других играх.
Пусть какая-то базовая рабочая эмуляция настроена, запускаем эмулятор и он ожидаемо попадает в вечный цикл. Отреверсив место, видим что там обнуляется флаг в RAM и затем цикл ждет пока он остается нулевым:
Смотрим, где еще есть обращение к этому месту, и видим что это код по месту прерывания VBLANK. Отреверсим VBLANK:
Кто такие легендарный VBLANK и его внук популярный HBLANK?
Видеоконтроллер 60 или 50 раз в секунду (в зависимости от NTSC или PAL/SECAM [41]) отрисовывает на старом телевизоре фрейм "попиксельно".
Когда текущая линия отрисована и луч идет на следующую строку (зеленые отрезки на картинке выше) в это время сработает прерывание HBLANK. За это время на реальной приставке физически можно отправить в видеопамять всего 18 байт (хотя в симуляторе я такого ограничения не ставлю), и далеко не все игры используют это прерывание.
Когда весь фрейм отрисован и луч идет в начало экрана (синий отрезок) в это время сработает прерывание VBLANK. За это время можно отправить в видеопамять максимум 7Kb данных.
Пусть мы захардкодили использование 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 [43].
Работа игр крутится вокруг этого прерывания, это "двигатель" игры.
В GUI также надо настроить перерисовку экрана по получении прерывания VBLANK.
Video Display Processor, он же VDP - второй по сложности компонент эмулятора после m68k. Чтобы понять принцип его работы, рекомендую прочитать эти сайты:
Plutiedev [44] - не только про VDP, а в целом про программирование под Sega Mega Drive, есть много инсайтов как в играх реализованы псевдо-float и прочая математика.
Raster Scroll [45] - супер крутое описание VDP с тонной картинок, я бы посоветовал читать просто для интереса.
<Начало нудного текста>
Этот процессор работает так - у него 24 регистра, которые отвечают за всякую хреновню, а также 64Kb собственного 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 есть куча всяких правил, например два уровня "приоритета" слоев:
<Конец нудного текста>
Отрисовывать VDP лучше итеративно. Сначала можно отрисовать "палитры", и прикидывать, что они действительно правильно меняются со временем, то есть цвета примерно такие же, как содержимое заставки или главного меню:
Затем можно отрисовать все тайлы:
Затем можно отрисовать planes по отдельным окнам:
Еще есть "window plane", который отрисовывается немного по-другому:
Потом наступит очередь спрайтов:
Полная реализация отрисовщика - lib/sega/video/video.cpp [46].
Вычислять фрейс надо попиксельно. Чтобы пиксели показались в 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_;
}
Хотя можно запускать игру и смотреть что отрисовалось, это может быть неудобно. Лучше доиграться до интересных случаев, собрать много дампов, и сделать тест, который одной командой генерирует на дампах картинки, и по git status
станет видно, какие картинки изменились. Это удобно, можно фиксить баги VDP не запуская эмулятор.
Для этого я сделал в GUI кнопку Save Dump
, которая сохраняем состояние видео-памяти (регистры VDP + VRAM + CRAM + VSRAM). Эти дампы сохранил в bin/sega_video_test/dumps [47] и написал README [48] как перегенерировать их одной командой.
Конечно, это работает только если данные правильно послались в видео-память (на паре дампов по ссылке это не так).
Для сохранения в png-файлы пригодилась либа std_image [49].
Так как мы не идем простым путем, можно поддержать ретро-контроллеры, идентичные сеговым.
Погуглил, что можно купить поблизости, и за деньги по курсу 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 [50], оказывается есть такой проект для реверсов всякой разноцветной хрени.
Для крутости можно сделать всякие пиксельные шейдеры.
Это было очень больно - в ImGui шейдеры поддержаны через задницу, и поменять его можно жутким костылем. Кроме этого, пришлось намучиться с установкой либы GLAD, чтобы вызывать функцию для компиляции пиксельного шейдера. Еще код шейдера должен быть не любым, а на GLSL версии 130, и еще там единственная переменная "извне" это uniform sampler2D Texture;
, остально это константы.
Моей целью было написать CRT-шейдер, который имитировал бы старый телевизор, и по возможности еще какие-нибудь шейдеры.
Так как я абсолютный ноль в шейдерах, за меня их сделал ChatGPT, учтя ограничения описанные выше. Их исходники в lib/sega/shader/shader.cpp [51]. Я даже не вчитывался в код шейдеров, прочитал только комментарии.
Фичи CRT-шейдера от нейросетки:
Barrel Distortion - эффект выпуклости;
Scanline Darkness - каждая вторая строка темнее;
Chromatic Aberration - как бы искажение RBG-слоев;
Vignette - цвет по краям темнее (когда таки посадил кинескоп...)
Результат шейдера:
Фред Флинстоун до шейдера и после шейдера (увеличенный):
Попросил ChatGPT сделать другие шейдеры, но они не такие интересные:
Я играл в эмуляторе в основном без шейдеров, иногда с CRT.
Может показаться неочевидным, но отрисовка фрейма это достаточно ресурсоемкая задача, если сделать неоптимально. Пусть размер экрана 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" - он известен только в США, для прочих регионов маскот другой. Это красивый платформер, много раз проходил его до конца в детстве.
Игра Earthworm Jim (1994) - червяк шарится по помойкам, выглядит круто.
Игра Alladin (1993) - не очень зашло, графика и геймплей без особых фокусов.
Некоторые игры читают статусный регистр VDP - если положить неправильный бит, то игра зависнет или поведет некорректно.
Так было в Battle Toads (1992), игра делала так:
do {
wVar2 = VDP_CTRL;
} while ((wVar2 & 2) != 0);
Одно из самых плохо документированных мест - поведение "window plane". Оказывается, если ширина окна 32 тайла, а ширина всех plane 64 тайла, то для "window plane" тайл должен искаться из расчета что его ширина все-таки 32 тайла. Не смог найти, где это было бы задокументировано, оставил у себя костыль.
Проявляется например в игре Goofy's Hysterical History Tour (1993). Эта игра так себе по геймплею, на любителя.
Самое проблемное место в VDP это DMA (Direct Memory Access), придуманный чтобы переносить куски памяти из RAM m68k во VRAM. Там несколько режимов и настроек, и можно легко ошибиться. Чаще всего ошибки происходят с "auto increment" - когда указатель на память увеличивается на это число, бывают неочевидные условия в какой момент это должно произойти.
В игре Tom and Jerry - Frantic Antics (1993) когда персонаж двигается по карте, новые слои в plane докидываются через редкий auto increment - 128 вместо обычного 1. У меня был код как будто там всегда 1, из-за этого plane почти не менялся кроме верхней "строки". Отдебажил методом пристального взгляда на окно plane, обнаружив что слой добавляется как бы "вертикально".
Сама эта игра - наверное худшая из запущенных мной, авторы как будто вообще не старались и делали для приставок более старого поколения.
На верхнеуровневой схеме архитектуры Sega Mega Drive это не обозначено, но кроме "основной" видео-памяти VRAM (64Kb) по какой-то причине отдельно стоят CRAM (128 байт, описание четырех цветовых палитр) и VSRAM (80 байт, вертикальный сдвиг). Наличие этих независимых кусков памяти выглядит еще смешнее если учесть что горизонтальный сдвиг полностью лежит во VRAM, но не суть.
В игре Tiny Toon Adventures (1993) используется один и тот же алгоритм чтобы обнулить CRAM и VSRAM. И соответственно во VSRAM записывается 128 байт, когда его размер 80 байт... И если никак не обработать это, то будет сегфолт. Приставка позволяет много вольностей, и это только верхушка айсберга.
Сама игра имеет приятную графику, геймплей средний, в нем есть жесткий закос под Соника.
В игре The Flinstones (1993) было странное поведение - plane двигался наверх так же, как вправо. То есть были странные записи во VSRAM. Разгадывалось просто - чтобы DMA работал (или наоборот не работал) надо поставить определенный бит в одном регистре VDP. Я это стал учитывать и движение plane починилось. Игра как раз пыталась сделать DMA-записи когда это было выключено, видимо авторы как-то криво написали логику.
Обычно регистры читаются двумя байтами (так видел всех гайдах), но в игре Jurassic Park (1993) сделано чтение регистра VDP одним байтом. Пришлось это поддержать.
В игре 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
просто писать лог и ничего не делать.
В игре 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_;
}
}
В эмуляторе пока не поддержан Z80, а некоторые игры от него зависят. Например, игра Mickey Mania (1994) зависает после старта. Открыв декомпилятор, видим что оно вечно читает адрес 0xA01000
, пока там не окажется ненулевой байт. Это зона Z80 RAM, то есть в игре создается неявная связь между m68k и z80.
Поставим новый кринжовый костыль - возвращаем рандомный байт если это чтение Z80 RAM.
Но тут Остапа понесло - теперь игра читает "VDP H/V Counter" (по адресу 0xC00008
).
Закостылим и его, теперь игра показывает заставку и успешно падает, читая еще один незамапленный адрес... И игра временно откладывается, пока не накопилось критическое количество костылей.
Еще пример - игра Sonic the Hedgehog (1991), где я попадаю в некий "дебаг-режим", потому что есть странные цифры в верхнем левом углу.
К счастью первый Соник давно полностью отреверсен (github [52]) поэтому если упороться то есть возможность его полностью поддержать.
Как писалось ранее, Zilog Z80 - со-процессор для проигрывания музыки. Имеет собственный RAM размером в 8Kb, и подключен к синтезатору звука YM2612.
Сам по себе Z80 совершенно обычный процессор (не специально-звуковой), который использовался в приставках прошлых поколений на все руки.
Как создавалась музыка для игр Mega Drive? Компания Sega распространяла среди разработчиков тулзу GEMS [53] под 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 можно узнать в этом видео [54].
Сначала надо выучить 332-страничный референс [55], создать эмулятор Z80, аналогично эмулятору m68k. Покрыть его тестами, позапускать программки на Z80. Потом заботать теорию звуков, регистры YM2612 [56], написать генератор звуков под Linux.
По объему звучит как примерно то же, что всё ранее описанное (m68k + VDP), или по крайней мере как половина описанного, то есть это немало чего надо сделать.
Описанное в статье уже дает возможность запускать много игр, но можно сделать и больше (кроме звука), всякие мелочи.
Сейчас поддерживается только один игрок - можно поддержать режим с двух геймпадов.
Сейчас вызывается VBLANK, но после каждой строки надо вызывать HBLANK. Его на самом деле используют мало игр. Самый мейнстримный кейс - смена палитры посередине изображения.
Например, в игре Ristar (1994) используется эта фича - обратите внимание на то как на "уровне воды" есть "волны", а под "уровнем воды" колонны размыты:
И вот что на самом деле должно быть, по прохождению из YouTube:
Особенно это заметно становится, когда звездун полностью погружается в воду и палитра всегда "водяная":
Сейчас поддержан только 3-кнопочный геймпад. Можно поддержать 6-кнопочный [57], а также более редкие периферийные устройства: Sega Mouse [58], Sega Multitap [59], Saturn Keyboard [60], Ten Key Pad [61], и (внезапно) принтер [62].
Встроенный "дебаггер" можно сделать круче - чтобы можно было видеть память, ставить брейки на запись/чтение, разматывать стектрейс, и в итоге намного более быстрее дебагать проблемы.
Автор: Izaron
Источник [63]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/opengl/406714
Ссылки в тексте:
[1] source: https://www.retrosix.wiki/hardware-overview-sega-mega-drive
[2] Video Display Processor: https://rasterscroll.com/mdgraphics/vdp-overview/
[3] YM2612: https://en.wikipedia.org/wiki/Yamaha_YM2612
[4] SN76489: https://en.wikipedia.org/wiki/Texas_Instruments_SN76489
[5] Motorola 68000: https://en.wikipedia.org/wiki/Motorola_68000_series
[6] Zilog Z80: https://en.wikipedia.org/wiki/Zilog_Z80
[7] тут можно увидеть маппинг адресов: https://segaretro.org/Sega_Mega_Drive/Memory_map
[8] архитектуре: https://en.wikipedia.org/wiki/Motorola_68000#Architecture
[9] source: http://goldencrystal.free.fr/M68kOpcodes-v2.3.pdf
[10] заехал в C++23: https://en.cppreference.com/w/cpp/utility/expected
[11] lib/m68k/target/target.h: https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/target/target.h
[12] lib/m68k/instruction/decode.cpp: https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/instruction/decode.cpp
[13] lib/m68k/instruction/execute.cpp: https://github.com/Izaron/SegaCxx/blob/main/src/lib/m68k/instruction/execute.cpp
[14] в этой markdown-документации: https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/eor.md
[15] длинное описание в этой книге: http://www.easy68k.com/paulrsm/doc/68kprm.pdf
[16] MOVEP: https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/movep.md
[17] ABCD: https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/abcd.md
[18] супер сложная логика: https://github.com/Izaron/SegaCxx/blob/b3d7fe5b239d3e3a58cb8e90bc3ea7f87d44825f/src/lib/m68k/instruction/execute.cpp#L235-L273
[19] тесты из этого репозитория: https://github.com/TomHarte/ProcessorTests/blob/main/680x0/68000/v1/README.md
[20] Выведет такой ассемблер: https://gist.github.com/Izaron/b241bfd9fffab3a1731c74f5ba37d07c
[21] ELF: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[22] Код эмулятора тут: https://github.com/Izaron/SegaCxx/blob/main/src/bin/m68k_emulator/main.cpp
[23] тут логи эмуляции: https://gist.github.com/Izaron/3d163abd63bc7e29a657c38a35e951dc
[24] решето Эратосфена: https://ru.wikipedia.org/wiki/%D0%A0%D0%B5%D1%88%D0%B5%D1%82%D0%BE_%D0%AD%D1%80%D0%B0%D1%82%D0%BE%D1%81%D1%84%D0%B5%D0%BD%D0%B0
[25] выглядит так: https://gist.github.com/Izaron/e9b3e9dcfc15d4f6fdd601662c74cc68
[26] выглядит так: https://gist.githubusercontent.com/Izaron/449493d6faab5db23ac822269bd174f8/raw/cb38c6c4726ed789e7ee7098ea87f2f5e54cfe08/output
[27] QEMU умеет так делать!: https://wiki.qemu.org/Documentation/Platforms/m68k
[28] ImHex: https://github.com/WerWolv/ImHex
[29] m68k vector table: https://wiki.megadrive.org/index.php?title=68k_vector_table
[30] ROM header: https://plutiedev.com/rom-header
[31] lib/sega/rom_loader/rom_loader.h: https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/rom_loader/rom_loader.h
[32] lib/sega/memory/bus_device.h: https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/memory/bus_device.h
[33] ImGui: https://github.com/ocornut/imgui
[34] Dockerfile: https://github.com/Izaron/SegaCxx/blob/main/Dockerfile
[35] trademark-регистр: https://segaretro.org/TradeMark_Security_System
[36] Ghidra: https://github.com/NationalSecurityAgency/ghidra
[37] плагин: https://github.com/lab313ru/ghidra_sega_ldr
[38] @DrMefistO: https://www.pvsm.ru/users/drmefisto
[39] LINK: https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/link.md
[40] UNLK: https://github.com/prb28/m68k-instructions-documentation/blob/master/instructions/unlk.md
[41] NTSC или PAL/SECAM: https://ru.wikipedia.org/wiki/NTSC
[42] source: https://segaretro.org/Sega_Mega_Drive/Interrupts
[43] lib/sega/executor/interrupt_handler.cpp: https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/executor/interrupt_handler.cpp
[44] Plutiedev: https://plutiedev.com/
[45] Raster Scroll: https://rasterscroll.com/mdgraphics/
[46] lib/sega/video/video.cpp: https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/video/video.cpp
[47] bin/sega_video_test/dumps: https://github.com/Izaron/SegaCxx/tree/main/src/bin/sega_video_test/dumps
[48] README: https://github.com/Izaron/SegaCxx/blob/main/src/bin/sega_video_test/README.md
[49] std_image: https://github.com/nothings/stb/blob/master/stb_image.h
[50] OpenRGB: https://gitlab.com/CalcProgrammer1/OpenRGB/-/blob/master/Controllers/HyperXKeyboardController/HyperXAlloyOriginsCoreController/HyperXAlloyOriginsCoreController.cpp
[51] lib/sega/shader/shader.cpp: https://github.com/Izaron/SegaCxx/blob/main/src/lib/sega/shader/shader.cpp
[52] github: https://github.com/sonicretro/s1disasm
[53] GEMS: https://segaretro.org/GEMS
[54] в этом видео: https://www.youtube.com/watch?v=WEvnZRCW_qc
[55] референс: https://www.zilog.com/docs/z80/um0080.pdf
[56] регистры YM2612: https://plutiedev.com/ym2612-registers
[57] 6-кнопочный: https://plutiedev.com/controllers#6-button
[58] Sega Mouse: https://plutiedev.com/mouse
[59] Sega Multitap: https://plutiedev.com/sega-multitap
[60] Saturn Keyboard: https://plutiedev.com/saturn-keyboard
[61] Ten Key Pad: https://plutiedev.com/ten-key-pad
[62] принтер: https://plutiedev.com/printer
[63] Источник: https://habr.com/ru/articles/871284/?utm_campaign=871284&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.