
У меня уже были статьи, посвящённые эмуляции «Ну, погоди!». Цель этой статьи — рассказать, с чем я столкнулся при переносе моего эмулятора на Linux, и почему вообще я не воспользовался готовым решением. Статья может послужить туториалом для тех, кто хочет начать разбираться в библиотеке SDL2.
Для желающих видеть цельную картину того, как я всё реализовал, исходный код эмулятора находится в репозитории на GitHub. Эмулятор можно собрать, изучать код, возможно проводить какие-то свои эксперименты.
Эмулятор самого микроконтроллера КБ1013ВК1-2, на котором построена игра Электроника ИМ-02 «Ну, погоди!» в этой статье я рассматриваю как чёрный ящик. Если вы хотите больше узнать по этому микроконтроллеру и устройству эмулятора, то посмотрите мою статью: Создаём эмулятор легендарной игры «Ну, погоди!» на базе Raspberry Pi Pico
Использование MAME для эмуляции «Ну, погоди!»
MAME поддерживает эмуляцию «Ну, погоди!» среди прочих игр серий Game & Watch и Электроника. Главное — найти нужный ROM (прошивку) и Artwork (оформление). Я уже использовал их в своём эмуляторе для Raspberry Pi Pico. Установил MAME на ноутбук, записал в директории /usr/local/share/games/mame/roms/ /usr/local/share/games/mame/artwork/ архивы с ROM и Artwork. Запустил эмуляцию командой:
mame nupogodi
Всё работало великолепно. Но захотелось мне запустить «Ну, погоди!» на своём стареньком нетбуке 2012 года, и меня ждал сюрприз — мощности компьютера не хватало, я видел слайд-шоу. Похожий результат был и при запуске на Raspberry Pi Zero 2 W.
Мысль, что как это для игры начала 80-х не хватает такого мощного по меркам 80-х годов железа, не давала покоя. Вспомнил, что в дистрибутиве RetroPie есть поддержка MAME, и я уже запускал советские игровые автоматы в RetroPie, но, к сожалению, там используются старые версии MAME, где не было эмуляции «Ну, погоди!».
Желание запустить игру без тормозов победило мою лень, и я взялся переделать свой старый эмулятор для Raspberry Pi Pico.
Так как использование параметра командной строки MAME, позволяющего пропускать отрисовывание части кадров, позволяла добиться более плавной эмуляции. Я сделал предположение, что проблема в логике отрисовки.
Наверное, можно было бы ре��ить проблему, написав патч к MAME, но я решил адаптировать для Linux эмулятор, который я писал для Raspberry Pico.
Мне казалось, что разобраться с графикой и звуком будет сложно, но благодаря библиотеке SDL, которая позволяет абстрагироваться от работы с графикой и звуком, я разобрался и получил готовый прототип за вечер, и потом несколько дней его отшлифовывал.
В результате получился небольшой туториал по SDL c практическим примером.
Установка SDL
В случае Debian (Raspbian) для разработки приложения, использующего SDL, необходимо установить пакеты:
sudo apt install libsdl2-dev
Если вы просто хотите запускать приложение, то можно ограничиться:
sudo apt install libsdl2-2.0-0
При сборке программ линкеру необходимо указывать $(shell pkg-config --libs sdl2) -lm -lSDL2_image, иначе он не сможет собрать ваше приложение.
Makefile для всего моего эмулятора выглядит следующим образом:
CC=gcc CFLAGS=-Wall -g -I. -Icpu -Idevice -Idata # Include all header dirs VPATH=cpu:device:data # Source search paths SOURCES=emu.c $(wildcard cpu/*.c) $(wildcard device/*.c) $(wildcard data/*.c) OBJECTS=$(SOURCES:.c=.o) TARGET=emu LDFLAGS=$(shell pkg-config --libs sdl2) -lm -lSDL2_image all: $(TARGET) $(TARGET): $(OBJECTS) $(CC) $(OBJECTS) -o $(TARGET) $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJECTS) $(TARGET)
Отрисовка в полноэкранном режиме
Я сразу начал с разработки полноэкранного приложения, потому что работа в окошке меня не привлекала.
SDL_Init(SDL_INIT_VIDEO); SDL_Window *win = SDL_CreateWindow("", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 0, 0, SDL_WINDOW_FULLSCREEN);
Получение значений скан-кодов нажатых клавиш
Работу со скан-кодами и keycode в Linux я рассматривал в своей статье по разработке драйвера клавиатуры. SDL абстрагируется от того, как к нам приходят скан-коды. С помощью функции SDL_GetKeyboardState(), мы можем получить список клавиш клавиатуры с их состояниями, что очень удобно при разработке игры, не нужны ухищрения, которые используются при разработке консольных приложений.
Основной цикл и работа эмулятора
Сердцем любой игры является основной цикл. Его задачи на каждой итерации:
опросить устройства ввода,
промоделировать поведение игры за определённый промежуток времени,
передать звуковой карте сгенерированный звук,
сообщить видеоконтроллеру, что отрисовать.
Основной цикл выглядит следующим образом:
void main_loop() { bool run = true; device_init(); const int frame_delay = 1000 / FRAMES_PS; while (run) { uint32_t frame_start = SDL_GetTicks(); if (!process_input()) { break; } system_run(SYS_FREQ / FRAMES_PS); // 32768 - one second render(); push_audio(); show(); uint32_t frame_time = SDL_GetTicks() - frame_start; if (frame_delay > frame_time) SDL_Delay(frame_delay - frame_time); } }
Константа SYS_FREQ — частота эмулируемого микропроцессора игры «Ну, погоди!»
Константа FRAMES_PS — желаемое количество кадров в секунду. Чтобы не сильно нагружать процессор и не писать логику для кеширования, я принял её равной 30 кадрам в секунду.
device_init() — инициализация эмулятора микроконтроллера.
process_input() — обработка клавиш клавиатуры, нажимаемых пользователем.
system_run() — запускает эмуляцию на заданное количество тактов. Мы принимаем количество тактов равным количеству тактов за время показа одного кадра: SYS_FREQ / FRAMES_PS.
render() — отрисовка кадра.
push_audio() — передача заполненного аудиобуфера ауди��подсистеме.
show() — отображение кадра на дисплее.
Мы эмулируем с фиксированным количеством кадров в секунду, поэтому необходимо подождать, если мы управились за меньше времени, чем выделено под один кадр:
uint32_t frame_time = SDL_GetTicks() - frame_start; if (frame_delay > frame_time) SDL_Delay(frame_delay - frame_time);
Парсинг SVG-файла
SVG — достаточно простой векторный формат, используемый для хранения изображений. Если кратко, изображение хранится в виде перечня графических примитивов линий, прямоугольников, окружностей, а для более сложных графических элементов используются кривые Безье.
Экран «Ну, погоди!» представлен в виде SVG-файла, каждому сегменту поставлен в соответствие элемент, помеченный тегом <title>x.y.z</title>, где:
x — номер регистра вывода в группе,
y — номер бита в регистре вывода,
z — группа регистров вывода.
Для парсинга и работы с SVG-файлами разработано множество библиотек, но я использовал пропатченную библиотеку nanosvg, входящую в состав MAME, так как она поддерживает обработку тегов title.
Растеризация SVG-файла
Главной особенностью векторного способа представления изображения является то, что качество картинки не меняется при изменении масштаба, но это требует дополнительных вычислительных мощностей. Судя по всему, из-за того, что MAME каждый раз отрисовывает графические примитивы — он показывает слабые результаты на слабых компьютерах.
Поэтому я принял решение растеризовать изображение, наподобие того, как я делал для Raspberry Pico, но с учётом того, что эмулятор выполняется на полноценном компьютере.
Задача растеризации SVG-файла у меня стояла немного необычная. Нужно было создать растровое изображение для каждого сегмента LCD-дисплея.
Результат парсинга SVG-файла библиотекой nanosvg — структура, где ключевым полем является связный список из графических примитивов.
Растровое изображение — это представление изображения в виде массива точек, где для каждой задан цвет (RGB) и прозрачность.
Для получения растровых изображений перебрал названия для всех сегментов, а для каждого сегмента:
фильтровал элементы, ему соответствующие,
определял границы (bounds) сегмента, его размер и положение,
выполнял растеризацию сегмента,
из растрового изображения получал текстуру, чтобы графический адаптер мог быстро отрисовать сегмент.
Фильтрация элементов
Фильтрация элементов в коде выглядит следующим образом:
NSVGshape* filter_shapes_by_title(NSVGshape* head, const char* title) { NSVGshape dummy = {0}; NSVGshape* tail = &dummy; for (NSVGshape* s = head; s != NULL; s = s->next) { if (strcmp(s->title, title) == 0) { NSVGshape* copy = malloc(sizeof(NSVGshape)); if (!copy) continue; *copy = *s; // shallow copy — paths, fill stay common copy->next = NULL; tail->next = copy; tail = copy; } } tail->next = NULL; return dummy.next; }
Обратите внимание, что мы создаём копии для каждого NSVGShape, чтобы не нарушить исходный связный список из NSVGShape. Поэтому после завершения растеризации, нужно не забыть освободить память:
while (seg_shapes) { NSVGshape* next = seg_shapes->next; free(seg_shapes); seg_shapes = next; }
Определение положения и размеров сегментов
Каждый сегмент я храню в виде структуры:
struct segment { int x; int y; int w; int h; SDL_Texture *texture; bool visible; };
Массив bounds структуры NSVGshape содержит координаты верхнего левого угла и нижнего правого угла фигуры.
Положение сегмента находится просто. Нужно найти минимальные bounds[0] (x) и bounds[1] (y) из всех NSVGshape, используемых для отрисовки сегмента.
Ширина сегмента определяется как разность максимального bounds[2] и минимального bounds[0], а высота, как разность максимального bounds[3] и минимального bounds[1].
float minx = 1e9f, miny = 1e9f; float maxx = -1e9f, maxy = -1e9f; for (NSVGshape *shape = svgImage->shapes; shape != NULL; shape = shape->next) { if (!strcmp(shape->title, title)) { if (shape->bounds[0] < minx) minx = shape->bounds[0]; if (shape->bounds[1] < miny) miny = shape->bounds[1]; if (shape->bounds[2] > maxx) maxx = shape->bounds[2]; if (shape->bounds[3] > maxy) maxy = shape->bounds[3]; } } segment->x = minx; segment->y = miny; segment->w = (int)(maxx - minx); segment->h = (int)(maxy - miny);
Растеризация сегментов
Когда у нас есть структура NSVGimage с отфильтрованным связным списком из NSVGshape, можно выполнить растеризацию при помощи функции nsvgRasterize().
unsigned char *rasterImage = (unsigned char *)malloc(segment->w * segment->h * 4); memset(rasterImage, 0, segment->w * segment->h * 4); nsvgRasterize(rasterizer, svgImage, -segment->x, -segment->y, 1, rasterImage, segment->w, segment->h, segment->w * 4);
Обратите внимание, что перед растеризацией изображения мы должны выделить память для растрового изображения при помощи malloc(), также желательно проинициализировать этот участок памяти с помощью memset().
1 — это используемый масштаб, в нашем случае мы не масштабируем, масштабировать будем при отрисовке.
Значения segment->x и segment->y передаём с "-", потому что это смещение внутри растрового изображения.
После растеризации изображения при помощи nanosvg мы получаем именно массив точек. Но чтобы с ним работать в SDL, нужно на его основе создать структуры SDL_Surface или SDL_Texture.
Получение текстур для сегментов
Преобразование растрового изображения в SDL_Surface
SDL_Surface — структура, содержащая указатель на массив из пиксельных данных и метаинформацию (размер, формат пикселей и т. д.) позволяющую этот массив интерпретировать. Массив пиксельных данных можно преобразовать в SDL_Texture, используя функцию SDL_CreateRGBSurfaceWithFormatFrom()
SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormatFrom( rasterImage, segment->w, segment->h, 32, // color depth segment->w * 4, // pitch SDL_PIXELFORMAT_ARGB8888);
rasterImage— растровое изображение в виде массива с информацией о каждом пикселе,segment->w— ширина растра в пикселях,segment->h— высота растра в пикселях,32— глубина цвета в нашем случае 32 бит, т. е. каждый пиксель кодируется 4 байтами (4 * 8бит),segment->w * 4— количество байт, необходимых для кодирования одной строки,SDL_PIXELFORMAT_ARGB8888— информация о цвете хранится следующим образом — 8 бит альфа-канала, потом 8 бит для красной, зелёной и синей составляющих.
Параметры функции помогают интерпретировать массив данных, который передаётся в массиве img, так как он, кроме информации о цвете, больше ничего не содержит.
Если вы перепутаете формат пикселей, как это сделал я, будете долго гадать, почему у вас изображение с искажёнными цветами.
Получение прозрачного фона
Полученная SDL_Surface содержит изображение сегмента со сплошным (непрозрачным фоном), когда мы будем выводить такие сегменты, они могут перекрывать друг друга и изображение всего дисплея «Ну, погоди!» будет искажённое. Чтобы решить эту проблему, преобразуем в изображение с прозрачным фоном:
Uint32 colorkey = SDL_MapRGB(surface->format, 255, 255, 255); SDL_SetColorKey(surface, SDL_TRUE, colorkey);
Там, где у нас был белый цвет, он стал прозрачным.
Преобразование SDL_Surface в SDL_Texture
С растровыми изображениями можно работать, используя структуру SDL_Surface, но в этом случае бо́льшую часть работы будет выполнять CPU, но можно переложить всю работу на графический адаптер, если преобразовать SDL_Surface в SDL_Texture.
SDL_Texture — растровое изображение, оптимизированное для обработки драйвером графического адаптера. Её можно получить из SDL_Surface при помощи функции SDL_CreateTextureFromSurface()
В функцию нужно передать renderer и созданную структуру SDL_Surface. После того, как вы создали SDL_Texture, SDL_Surface не нужна, и её необходимо освободить при помощи SDL_FreeSurface().
SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface);
Генерация звука в реальном времени
Почему-то для меня казалось, что добиться эмуляции звука в программе для Linux сложно, так как так ядро не является realtime. На самом деле оказалось всё гораздо проще.
Добиться 100% синхронизации звука и изображения невозможно, да и звук в любом случае будет выводиться с небольшой задержкой. Но несовершенство восприятия человеком сглаживает эти недочёты.
Звук необходимо сгенерировать для определённого промежутка времени и передать его в виде аудиобуфера аудиокарте.
Важным параметром является дискретизация звука — количество отсчётов сигнала в секунду. Чем больше отсчётов в секунду — тем более точно будет воспроизводиться звук.
В случае нашего эмулятора частота дискретизации 32768, так как мы записываем значение из порта вывода каждый такт микроконтроллера. Это не является стандартной частотой дискретизации аудиопотока, но SDL2 может выполнять ресемплинг (интерполирование под частоту дискретизации, поддерживаемой аудиокартой), этим я и воспользовался.
Перед использованием аудиоподсистему необходимо проинициализировать. Инициализация выглядит следующим образом:
SDL_AudioDeviceID init_audio() { if (SDL_Init(SDL_INIT_AUDIO) < 0) { fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); return 0; } SDL_AudioSpec want, have; SDL_zero(want); SDL_zero(have); want.freq = SAMPLE_RATE; want.format = AUDIO_U8; // 8-bit unsigned want.channels = 1; // mono want.samples = BUFFER_SAMPLES; want.callback = NULL; current_buff = buffer; dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); if (dev == 0) { fprintf(stderr, "SDL_OpenAudioDevice failed: %s\n", SDL_GetError()); SDL_Quit(); return 0; } if (have.format != AUDIO_U8 || have.channels != 1 || have.freq != SAMPLE_RATE) { fprintf(stderr, "Audioformat is not supported\n"); SDL_CloseAudioDevice(dev); SDL_Quit(); return 0; } return dev; } В структуре `want` мы передаём параметры семплирования, которые хотим установить, в структуре `have` возвращаются параметры, используемые аудиосистемой SDL. ```cpp want.freq = SAMPLE_RATE; want.format = AUDIO_U8; // 8-bit unsigned want.channels = 1; // mono want.samples = BUFFER_SAMPLES; want.callback = NULL; current_buff = buffer; dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); if (dev == 0) { fprintf(stderr, "SDL_OpenAudioDevice failed: %s\n", SDL_GetError()); SDL_Quit(); return 0; }
В SDL2 звук в реальном времени генерировать более сложным способом с использованием callback-функций для заполнения аудиобуфера, или с помощью помещения сформированного аудиобуфера в очередь.
В первом случае аудиосистема запрашивает данные (pull-модель), во втором мы отправляем данные (push-модель).
Чтобы не усложнять, я использовал второй способ.
При заполнении и передачи аудиобуфера нужно учитывать тот факт, что возможна ситуация «buffer underrun», когда аудиосистема уже вывела весь буфер, а новых данных аудиосистеме не поступило. Это будет слышно как неприятные щелчки. Я эту ситуацию исправил вставкой тишины в этом случае.
Это устраняет артефакты, но может приводить к накоплению рассинхрона с изображением, но при тестировании я артефактов не заметил, но это субъективно.
SDL_QueueAudio(dev, buffer, cnt); if (SDL_GetQueuedAudioSize(dev) < 2048) { static u8 silence[2048] = { 128 }; SDL_QueueAudio(dev, silence, sizeof(silence)); }
Улучшения отрисовки игрового экрана
Когда я уже получил готовый работающий эмулятор, я заметил, что сильно заметны пиксели на краю сегментов, это исправляется вызовом одной функции
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "2");
Также ненужный курсор можно убрать, вызвав следующую функцию:
SDL_ShowCursor(SDL_DISABLE);
Работа моего эмулятора на Raspberry Pi Zero 2 W
Для убедительности приведу видео, как работает мой эмулятор, на Raspberry Pi Zero 2 W. Никаких тормозов не наблюдается.
Как работает эмуляция в MAME, не привожу, так как подёргивания и отсутствие отзывчивости сильно раздражают, если не верите на слово, можете установить MAME и посмотреть, как это выглядит.
Выводы
Я сделал свой эмулятор максимально простым, чтобы можно было понять логику работы в течение вечера. Добавление опций добавило бы дополнительный шум, который бы мешал пониманию.
Эмулятор можно улучшить, добавив множество фич, которые практически не повлияют на производительность:
хранить Artwork, ROM внутри исполняемого файла,
сохранять верное соотношение сторон для экрана (сейчас изображение немного искажается, но я не знаю, лучше ли будут полосы по бокам изображения),
собрать со статической линковкой, чтобы упростить переносимость на другие компьютеры,
добавить обработку опций командной строки, чтобы можно было настраивать параметры эмулятора,
убрать зависимость от wayland и X11, чтобы эмулятор можно было запускать из виртуальной консоли, и он работал напрямую с DRM-драйвером,
добавить поддержку эмуляции других игр.
Но это уже отдельные темы, и интересные задачи для заинтересованного читателя.
При разработке эмулятора я ещё раз убедился в том, что иногда изобретение велосипеда не только позволяет повысить уровень знаний, но и решить реальные проблемы. А универсальные решения могут потребовать гораздо больше ресурсов, чем своё собранное на коленке, но реально решающее твою проблему. Но всё зависит от того, где это решение будет эксплуатироваться, и какие требования к безопасности предъявляются.
© 2026 ООО «МТ ФИНАНС»

