В этой статье описано создание эмулятора 16-битной приставки Sega Mega Drive на C++.
Будет много интересного: эмуляция процессора Motorola 68000, реверсинг игр, графика на OpenGL, шейдеры, и многое другое. И все это на современном C++. В статье много картинок, можно хоть на них посмотреть.
Устройство Sega Mega Drive
Описание каждого компонента из схемы в рандомном порядке:
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-битный). Несмотря на то, что "адресные" регистры (A0
-A7
) 32-битные, по факту для адреса берутся младшие 24 бита. То есть адресуется пространство в 16 мегабайт памяти.
Процессор поддерживает зачаток виртуализации для многозадачных систем - обращение к регистру A7
по факту будет обращением либо к USP
(user stack pointer) либо к SSP
(supervisor stack pointer) в зависимости от флага в статусном регистре.
В отличие от (почти всех) современных архитектур, m68k придерживается порядка байт big-endian. Адрес и размер инструкции всегда делится на 2, читать память тоже можно только по адресу делящемуся на 2 (за небольшим исключением). Не поддерживается floating-point arithmetic.
Регистры 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;
Там же можно дизассемблировать какое-то количество инструкций начиная с initial_pc
(точка входа) и посмотреть что происходит в первых инструкциях:
Когда все станет понятно, можно структуры из "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. В ней очень много всего, и можно сделать какой угодно интерфейс.
Так можно отрисовать всё состояние эмулятора в разных окнах - без этого дебажить очень трудно.
Работа в Docker
Чтобы не страдать от старых версий операционки на своем компе (когда все пакеты старые, даже современный C++ не компилируется) и не загрязнять всякими левыми пакетами, разработку лучше вести из-под Docker.
Сначала заведите Dockerfile, потом при его изменении пересоздавайте образ:
sudo docker build -t segacxx .
И потом заходите в контейнер с монтированием директорий (-v
) и другими нужными параметрами:
sudo docker run --privileged -v /home/eshulgin:/usr/src -v /home/eshulgin/.config/nvim:/root/.config/nvim -v /home/eshulgin/.local/share/nvim:/root/.local/share/nvim -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix${DISPLAY} -it segacxx
Подводные камни:
Может возникнуть проблема с GUI, у которого не будет доступа по умолчанию, но после спортивного гуглежа в команду добавились
-v
для X11 и-e DISPLAY
Также чтобы GUI работал, нужно с компа запустить
xhost +
, чтобы выключить "access control".Чтобы был доступ к контроллерам (про них раздел ниже) в команду добавился
--privileged
.
Реверсинг игр в Ghidra
Пусть мы настроили эмуляцию m68k по ROM'у, почитали какую-нибудь документацию, накидали несколько базовых девайсов в шину (ROM, RAM, trademark-регистр, etc.), и эмулируем по одной инструкции, глядя в дизассемблер.
Это муторное занятие, хочется получить более высокоуровневую картину. Для этого можно отреверсить игру. Я для этого использую Ghidra:
Очень хороший старт дает плагин от @DrMefistO - он сам промаркирует общеизвестные адреса и создаст сегменты.
Можно будет увидеть, что поскольку игры писались на ассемблере изначально, то у них специфический вид:
Вперемешку код и данные - есть кусок кода, потом идут куски байтов например для цвета, потом снова код, и так далее. Все по архитектуре фон Неймана.
Чтобы сделать "фрейм", в ассемблере m68k надо использовать LINK и UNLK. На деле такое почти не встречается, в большинстве "функций" аргументы передаются через полу-рандомные регистры. Некоторые "функции" помещают результат в флаг статусного регистра (например в ZF). К счастью в Ghidra в таких случаях можно указать руками, что именно делает функция, чтобы декомпилятор показал более адекватный вывод.
Еще встречается "switch" из функций, когда у функций одинаковый контент, но первые несколько инструкций отличаются, пример на скрине
Чтобы примерно представлять что происходит (и сделать более точный эмулятор Sega), не обязательно реверсить всю игру - достаточно каких-то 5-10%. Лучше реверсить ту игру, которую вы хорошо помните из детства, чтобы она не была "черным ящиком".
Это умение понадобится в будущем, чтобы быстро отдебажить поломку эмуляции в других играх.
Эмуляция прерываний
Пусть какая-то базовая рабочая эмуляция настроена, запускаем эмулятор и он ожидаемо попадает в вечный цикл. Отреверсив место, видим что там обнуляется флаг в RAM и затем цикл ждет пока он остается нулевым:
Смотрим, где еще есть обращение к этому месту, и видим что это код по месту прерывания VBLANK. Отреверсим VBLANK:
Кто такие легендарный VBLANK и его внук популярный HBLANK?
Видеоконтроллер 60 или 50 раз в секунду (в зависимости от NTSC или PAL/SECAM) отрисовывает на старом телевизоре фрейм "попиксельно".
Когда текущая линия отрисована и луч идет на следующую строку (зеленые отрезки на картинке выше) в это время сработает прерывание 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.
Работа игр крутится вокруг этого прерывания, это "двигатель" игры.
В 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 это "тайл" - это квадрат из 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.
Вычислять фрейс надо попиксельно. Чтобы пиксели показались в 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 сделать другие шейдеры, но они не такие интересные:
Шейдеры
Я играл в эмуляторе в основном без шейдеров, иногда с CRT.
Оптимизации для Release-сборки
Может показаться неочевидным, но отрисовка фрейма это достаточно ресурсоемкая задача, если сделать неоптимально. Пусть размер экрана 320x240 пикселей, мы итерируемся попиксельно. В любой момент на экране есть до 80 спрайтов плюс три plane, плюс у них есть "приоритет", то есть их проходить надо по два раза. У каждого спрайта или plane надо найти соответствующий пиксель и проверить что он находится внутри bounding box, после чего вытащить тайл из тайлсета и проверить что пиксель "непрозрачный". И всё это надо вычислять 60 раз в секунду достаточно быстро, чтобы еще оставалось время на ImGui и на эмулятор m68k.
Поэтому вычисления не должны содержать лишнего кода, аллокаций памяти и так далее.
На деле достаточно будет иметь Release-сборку с выкрученными настройками оптимизации.
set(CMAKE_BUILD_TYPE Release)
Сначала выключим неиспользуемые фичи и лишние варнинги:
add_compile_options(-Wno-format)
add_compile_options(-Wno-nan-infinity-disabled)
add_compile_options(-fno-exceptions)
add_compile_options(-fno-rtti)
Поставим режим сборки Ofast
, сбилдим под нативную архитектуру (в ущерб переносимости бинарника), с link-time optimization, раскруткой циклов и "быстрой" математикой:
set(CMAKE_CXX_FLAGS_RELEASE
"${CMAKE_CXX_FLAGS_RELEASE} \
-Ofast \
-march=native \
-flto \
-funroll-loops \
-ffast-math"
)
Этого достаточно, чтобы получить стабильные 60 FPS и даже 120 FPS если играть с x2 скоростью (когда промежуток для прерывания VBLANK уменьшается в 2 раза).
Единственное, что можно "распараллелить" - вычисление пикселей на одной строке (вычислять на разных строках одновременно нельзя, потому что между строками работает HBLANK и там могут например свопнуть цвета), но я бы не рекомендовал это делать. Если параллелить это, то для хорошей утилизации ресурсов придется использовать lock-free алгоритм, а мы точно не хотим туда лезть без крайней необходимости.
Тестирование эмулятора играми
Почти каждая игра приносила что-то новое в эмулятор - то используется редкая фича VDP (которую неправильно реализовал), то игра делает что-то странное, и так далее. Здесь я описал разные приколы, с которыми столкнулся, пока запускал несколько десятков игр.
Заработали сразу
На игре Cool Spot (1993) я в принципе построил эмулятор - реверсил ее, дебажил приколы VDP и так далее. Персонаж "Cool Spot" это маскот лимонада "7 Up" - он известен только в США, для прочих регионов маскот другой. Это красивый платформер, много раз проходил его до конца в детстве.
Игра Earthworm Jim (1994) - червяк шарится по помойкам, выглядит круто.
Игра Alladin (1993) - не очень зашло, графика и геймплей без особых фокусов.
Чтение статусного регистра VDP
Некоторые игры читают статусный регистр VDP - если положить неправильный бит, то игра зависнет или поведет некорректно.
Так было в Battle Toads (1992), игра делала так:
do {
wVar2 = VDP_CTRL;
} while ((wVar2 & 2) != 0);
"Window Plane" по-другому показывается при ширине 32 тайла
Одно из самых плохо документированных мест - поведение "window plane". Оказывается, если ширина окна 32 тайла, а ширина всех plane 64 тайла, то для "window plane" тайл должен искаться из расчета что его ширина все-таки 32 тайла. Не смог найти, где это было бы задокументировано, оставил у себя костыль.
Проявляется например в игре Goofy's Hysterical History Tour (1993). Эта игра так себе по геймплею, на любителя.
Ошибки с 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, обнаружив что слой добавляется как бы "вертикально".
Сама эта игра - наверное худшая из запущенных мной, авторы как будто вообще не старались и делали для приставок более старого поколения.
Оверсайз запись во VSRAM-память
На верхнеуровневой схеме архитектуры Sega Mega Drive это не обозначено, но кроме "основной" видео-памяти VRAM (64Kb) по какой-то причине отдельно стоят CRAM (128 байт, описание четырех цветовых палитр) и VSRAM (80 байт, вертикальный сдвиг). Наличие этих независимых кусков памяти выглядит еще смешнее если учесть что горизонтальный сдвиг полностью лежит во VRAM, но не суть.
В игре Tiny Toon Adventures (1993) используется один и тот же алгоритм чтобы обнулить CRAM и VSRAM. И соответственно во VSRAM записывается 128 байт, когда его размер 80 байт... И если никак не обработать это, то будет сегфолт. Приставка позволяет много вольностей, и это только верхушка айсберга.
Сама игра имеет приятную графику, геймплей средний, в нем есть жесткий закос под Соника.
Вызов DMA когда он выключен
В игре The Flinstones (1993) было странное поведение - plane двигался наверх так же, как вправо. То есть были странные записи во VSRAM. Разгадывалось просто - чтобы DMA работал (или наоборот не работал) надо поставить определенный бит в одном регистре VDP. Я это стал учитывать и движение plane починилось. Игра как раз пыталась сделать DMA-записи когда это было выключено, видимо авторы как-то криво написали логику.
Одно-байтовые чтения регистров
Обычно регистры читаются двумя байтами (так видел всех гайдах), но в игре Jurassic Park (1993) сделано чтение регистра VDP одним байтом. Пришлось это поддержать.
Попытка записи в 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
просто писать лог и ничего не делать.
Смена 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_;
}
}
Зависимость от Z80 RAM и прочие зависимости
В эмуляторе пока не поддержан Z80, а некоторые игры от него зависят. Например, игра Mickey Mania (1994) зависает после старта. Открыв декомпилятор, видим что оно вечно читает адрес 0xA01000
, пока там не окажется ненулевой байт. Это зона Z80 RAM, то есть в игре создается неявная связь между m68k и z80.
Поставим новый кринжовый костыль - возвращаем рандомный байт если это чтение Z80 RAM.
Но тут Остапа понесло - теперь игра читает "VDP H/V Counter" (по адресу 0xC00008
).
Закостылим и его, теперь игра показывает заставку и успешно падает, читая еще один незамапленный адрес... И игра временно откладывается, пока не накопилось критическое количество костылей.
Еще пример - игра Sonic the Hedgehog (1991), где я попадаю в некий "дебаг-режим", потому что есть странные цифры в верхнем левом углу.
К счастью первый Соник давно полностью отреверсен (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) используется эта фича - обратите внимание на то как на "уровне воды" есть "волны", а под "уровнем воды" колонны размыты:
И вот что на самом деле должно быть, по прохождению из YouTube:
Особенно это заметно становится, когда звездун полностью погружается в воду и палитра всегда "водяная":
Поддержать другие контроллеры
Сейчас поддержан только 3-кнопочный геймпад. Можно поддержать 6-кнопочный, а также более редкие периферийные устройства: Sega Mouse, Sega Multitap, Saturn Keyboard, Ten Key Pad, и (внезапно) принтер.
Более крутой дебаггер
Встроенный "дебаггер" можно сделать круче - чтобы можно было видеть память, ставить брейки на запись/чтение, разматывать стектрейс, и в итоге намного более быстрее дебагать проблемы.
Комментарии (29)
BenGunn
03.01.2025 19:02Хорошая статья. Пишу свой эмулятор C64 и так же на плюсах. Есть желание после заняться мегадрайвом. Пара вопросов. При эмуляции учитывается количество затрачиваемых циклов процессора на выполнение команд? Как синхронизируется переферия сидящая на общей шине с процессором? Есть какое то архитектурное решение или просто все заинтересованные лица мониторят сколько циклов прошло? Я новичок в вопросах эмулятостроения поэтому интересен опыт других.
Izaron Автор
03.01.2025 19:02При эмуляции учитывается количество затрачиваемых циклов процессора на выполнение команд?
Нет, команды выполняются всё время без ограничений, пока не наступит VBLANK-прерывание. По идее ничего плохого не произойдет, просто игра будет много времени проводить в функции наподобии
WaitVBLANK
.Как синхронизируется переферия сидящая на общей шине с процессором? Есть какое то архитектурное решение или просто все заинтересованные лица мониторят сколько циклов прошло?
Всё работает в один поток - есть цикл, который делает эти вещи:
poll_events
- проверить события GUI, если видим что окно закрыли (нажали на крестик) то цикл прерывается и программа завершается.update_controller
- насобирать информацию с периферии, то есть про нажатия кнопок с клавиатуры и геймпада, и поставить флаги про это в "контроллер". А он в свою очередь реализует интерфейсDevice
и потом будет отдавать информацию про нажатия когда BusDevice ему передаст команду на read/write.execute
- постоянно исполнять инструкции пока верно условие или пока не получим информацию что мы прыгнули на VBLANKvideo_.update()
- вычисляет пиксели на игровом экранеrender()
- рисует весь GUI, в том числе игровой экран
Я новичок в вопросах эмулятостроения поэтому интересен опыт других.
Для меня это тоже первый эмулятор и я в другие эмуляторы не смотрел особо, пробовал позапускать, но с Linux они все как-то плохо дружат
DrMefistO
03.01.2025 19:02Точность в соблюдении циклов может быть важна в некоторых играх, ну и, конечно, в демках, типа titan.
ru_vlad
03.01.2025 19:02Спасибо за интересную статью! В свое время занимался ремонтом данных приставок, всегда было интересно для чего был нужен z80, теперь узнал.
SadOcean
03.01.2025 19:02Вот это монументально, спасибо, отличная работа.
Несмотря на понимание, что тут происходит, не думаю, что когда нибудь найду время сделать нечто подобное.
По поводу BCD - Я так понимаю тяжелое наследие со славных времен микрокалькуляторов (для которых и делались первые процессоры).
Это когда вместо честного двоичного числа (шестнадцатеричного по 4 бита) записывается поразрядно десятичное. Из 16-ти значений 4-х бит используется только 10. Но зато ты точно знаешь, что каждые 4 бита можно прямо читать и отображать как число разряда.
Это сильно упрощает логику сегментных дисплеев. Соответственно хорошо, когда процессоры поддерживают операции над такими числами, можно делать более простые устройства на их основе.
Nemoumbra
03.01.2025 19:02Отличная работа! После работы над PPSSPP было интересно прикинуть, как там живут в параллельных мирах другие эмуляторы. Кстати, рад, что воспользовались ImHex, я дофига сил отдал, чтобы он стал более-менее стабильным.
iWind
03.01.2025 19:02Молодец, хорошая статья! Попробуй продолжить проект и материала наберёшь ещё не на одну статью (например узнаешь о важности точной эмуляции генерации прерываний, а уверен в некоторых играх ещё и скорости работы dma).
DrMefistO
03.01.2025 19:02Кроме плагина для гидры есть плагин для иды, который ещё и отладчик. Точность у него уровня gens, но тем не менее, как работают какие-нибудь игровые штуки, полотна, спрайты и т.п. понять можно будет в рантайме.
goldexer
03.01.2025 19:02Большое спасибо за очень интересную статью. Можно сказать подарок! Работа проделана колоссальная!
splasher
03.01.2025 19:02Спасибо! Z80 если что это сердце ZX Spectrum, который не приставка, а вполне себе персональный компьютер с интерпретатором бейсика, а также отдельно загружаемыми программами на ассемблере. Даже дизассемблер на нем грузил. Памяти правда на нем, насколько помню, было 32-48K...
AskePit
03.01.2025 19:02Помимо невероятной крутости статьи и проделанной работы не могу не подметить, что приятно смотреть на современный C++ с человеческим лицом
Seenkao
03.01.2025 19:02И хорошо и не очень одновременно...
Лично я жду от подобных статей если не более подробной информации, то как минимум информацию для возможности подготовится написать эмулятор самому.
Просьба не воспринимать ни на чей счёт! Каждый пишет так, как ему удобнее или как он может изложить. И это не плохо!
По статье было бы желательно добавить ссылки для ознакомления: где какая информация бралась, с чего лучше всего начинать изучать информацию, ссылки на эмуляторы готовые (желательно самые простые), возможно на литературу (если она есть, а она есть на русском языке даже для изучения, ниже напишу), ну и желательно документацию - где её можно найти.
По статье, ссылок не мало, но в основном на свои ресурсы. А вот на сторонние - можно сказать мало (ну и ссылки все разбросаны по статье, с одной стороны хорошо, а с другой не очень). Информации много и не очень...
Получается что посмотреть что сделано можно, но посмотреть как это делалось - нельзя...
Вот и получается довольно двоякое мнение о статье. )))
P.S. Про книгу-то я и забыл...
Королёв А.Г., Игровые приставки, изд. ДМК, Москва, 2002.
Izaron Автор
03.01.2025 19:02ссылки на эмуляторы готовые (желательно самые простые)
Готовые эмуляторы в основном сами те еще раритеты годов написания этак 1995-2005 (золотой век эмуляторов давно прошел). Год назад переписывался с Sega-энтузиастом, его отзыв на то как обстоят дела с ретрохакингом:
Самое обидное в SMD-ретрохакинге — что с FOSS там очень плохо.
Gens, включая форк с выводом кишок — несвободный.
Когда-то была Kega Fusion, она просто говнище.
GPGX открытый, но несвободный, там от предыдущего автора осталась non-commercial-лицензия, и код такой из 90-х, трудно куда-то это встраивать, чтобы через год отребейзить и нифига не развалилось. Но это лучшее, что есть.
BizHawk тоже позволяет смотреть на кишки, в качестве smd core там тоже форк GPGX, но его пишут .NETеры какие-то волшебные, он без Windows не работает.Если хочется сами игоры конструировать, то вообще все принятые сообществом ромхак-инструменты — это win-specific говнище без исходников.
ну и желательно документацию - где её можно найти.
Пробовал читать "официальную документацию Sega" (на segaretro есть), она жуткая, намного удобнее и понятнее читать неофициальные сайты - segaretro, plutiedev, rasterscroll.
Информацию про то как писать всякие штуки на C++ (в т.ч. то что использовалось для симулятора) я иногда публикую в своем канале https://t.me/cxx95 (в теле статьи о нем не упоминал, чтобы не навязывать рекламу).
playermet
03.01.2025 19:02Есть же BlastEm. Мультиформенный, опенсорсный, лицензия GPLv3.
Еще есть Exodus, который пусть и Windows-only, но тоже опенсорсный, MIT, и с потрясающими возможности для отладки игр.
Причем у обоих cycle accuracy из преимуществ.
TechExpert
03.01.2025 19:02Когда я слышу слово "сущность", у меня начинается когнитивный диссонанс, я не понимаю, что хотел сказать автор. Просто слово паразит, причём активный. Ну есть классы, структуры, есть объекты классов и структур. Какое определение у "сущности", в сущности?
HardWrMan
03.01.2025 19:02Достаточно вспомнить, как оно будет на ангельском и сразу всё встанет на свои места. И да, это не наш, не русский метод. Но и эти языки погроммирования не мы, не русские, изобрели. Увы.
PS: Entity.
TechExpert
03.01.2025 19:02Спасибо. Просто часто встречаю применение этого термина везде и всюду. И в вэбе, и в бд, а теперь в священном Си++. И от того, что им называют всё подряд, смысл подистерся.
HardWrMan
03.01.2025 19:02Ну, для себя я сделал вывод, что это новояз для обозначения "объект". Ну в парадигме ООП или даже xHDL.
firehacker
03.01.2025 19:02Классы, структуры и экземпляры классов — это инструменты языка программирования. В каких-то языках таких фич, например, и нет вовсе.
Сущность же — это из понятийного аппарата человека, в отрыве от фич ЯП и абстрагируясь от специфики ЯП.
Я не понимаю, какие проблемы могут быть с восприятием термина «сущность»? Проблемы с абстрактным мышлением? Определенно, это не слово паразит. Это гипероним для множества более узко-специализированных понятий.
HardWrMan
03.01.2025 19:02Сущность же — это из понятийного аппарата человека, в отрыве от фич ЯП и абстрагируясь от специфики ЯП.
Ммм, то-то я смотрю, что в англоязычных документах так любят плодить эти сущности... Особенно при описании протоколов. Многие вещи про USB и Ethernet можно описать проще и понятнее, но 100500 сущностей не просто так там появились, просто следует запомнить.
mr-andry
03.01.2025 19:02Прочитал статью как блокбастер о воскрешении 16-битной приставки! Особенно понравились подробности про VDP и подводные камни с DMA. Кстати, как насчёт поддержки 6-кнопочного геймпада? В некоторых играх он кардинально менял геймплей!
Izaron Автор
03.01.2025 19:026-кнопочный сейчас поддержал в коммите, там просто муторная система для поддержки обратной совместимости с 3-кнопочным - описание на plutiedev
Проверил на игре Ultimate Mortal Kombat 3 (1996) - на скриншоте нажатие кнопки Z которая делает high kick
fedorro
Спасибо за Торт!