Скриншот одного из портированных примеров
Скриншот одного из портированных примеров

Мотивация к написанию статьи

Уважаемые коллеги, доброго времени суток!

Этой статьей я хочу показать приёмы портирования программ между аппаратно-программными платформами, и привлечь внимание к книге «Секреты программирования игр» Андрэ Ламота, 1995, которую вспомнят добрым словом многие разработчики компьютерных игр, и другим не менее полезным книгам этого автора.

Нам интересны приёмы портации, но мы так же проведём ревью, проверим насколько долговечен код 25-тилетней давности, и насколько сложно его портировать на любые современные платформы.

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

Мотивация к созданию порта

В 1996 году, когда с массовыми компьютерами всё только начиналось, мне в руки попала книга «Секреты программирования игр» Андре Ламота. Ему было 22 года, а мне 20. Я был студентом и изучал в университете физику и компьютерные науки, а так же плотно «висел» с 14 лет на программировании на всевозможных языках и математике.

Книга меня впечатлила, она была понятна. Это книга о складывании высоконагруженных движков, выжимающих всё, что можно, из математики, алгоритмов и железа, и я рекомендую её к прочтению и пониманию всем инженерам, причастным к сфере разработки программ.

Книга учит «с нуля» писать такие движки и игры
Книга учит «с нуля» писать такие движки и игры

Когда спустя почти 20 лет, мне понадобился учебный материал для моих студентов, я вновь обратился к данной книге. Однако, возникли проблемы – её примеры разработаны под DOS, и они не работали под Windows либо Linux.

Кто такой Андре Ламот и почему вам следует прочесть его книги?

Андре Ламот – культовая фигура в мире разработки игр. Им созданы и портированы сотни видеоигр под всевозможные платформы.

Андре работал с idSoftware в момент создания ими Wolfenstein и Doom. Чтобы оценить влияние книг Андре на мир разработки, достаточно прочесть отзывы под его постами в Linkedin

Первоначально, я рекомендую к чтению «Секреты программирования игр» от 1995 г., как фундаментальную, но простую для понимания книгу, охватывающую полноту разработки 2D и 3D игровых движков и игр на их базе. Эту книгу осилят и новички, и школьники, а изложенные там методы оптимизации, к примеру, позволили мне снизить время выполнения вычислений при моделировании физических процессов – с часов и дней – до реалтайма!

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

Легкий путь не сработал

Конечно, я не искал трудных путей, и попробовал обойтись без порта, ограничившись DOSBox и Borland C++ 3.1 от 1992 года. Но отладчик в DOS неудобен, а сам DOSBox часто закрывался с ошибками. Watcom C/C++ (Open Watcom 1.9) слёг с теми же симптомами.

Тогда я решил портировать примеры из книги в Windows. Порт занял неделю (в свободное от работы свободы время). Все примеры из книги работают, все проверены.

Постановка задачи

  1. Примеры включают в себя 45 программ: от простейших – до полноценного 3D движка вроде Doom-а.

  2. Необходимо внести минимальные изменения в исходный код, а лучше – не вносить вообще – ведь примеры привязаны к тексту и листингам книги.

  3. Хотелось бы автоматизировать внесение изменений в 62 .c и 12 .h файлов и отделаться минимальным программированием.

  4. Основная цель: программы должны работать; у студентов должна быть возможность их отладки и модификации.

Идея

  1. Сделать аналоги (обёртки) функций DOS и микродвижок-прослойку между OS Windows и портируемыми программами.

  2. Микродвижок выполнить на WinAPI и разместить в двух файлах: DOSEmu.cpp и DOSEmu.h. Последний содержит «подменённые» функции DOS, и при подключении к собираемым программам, они «подхватят» эти функции.

  3. Требуемые куски кода стараться вырезать из кодовой базы эмуляторов DOS и программ с Github.

  4. Максимально избавится от ручного труда, для чего вносить изменения в исходники всех портируемых программ автоматически и одновременно, инструментом «Search->Replace in files» в IDE Code::Blocks.

Первичная оценка

Следовало оценить сработает ли идея, и стоит ли браться за работу вообще.
Листинги программ показали, что идея реализуема – код включает математику, функции C standard library (libc), немногие ассемблерные вставки и немногие вызовы DOS API.
Главное же – кода мало. Ибо когда вы видите многотомную кодовую «лапшу», это верный признак того, что нужно бежать: и от этого кода и от его создателей.

План реализации

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

Демонстрация алгоритма трассировки лучей ближе к концу книги
Демонстрация алгоритма трассировки лучей ближе к концу книги

Реализация

Я не буду приводить подробный листинг, а остановлюсь на некоторых интересных деталях DOS и моментах, связанных с портированием.

Шаг 1. Унифицируем грамматику С

В программах есть ключевые слова «доисторической» грамматики С: _far, __far, __huge, итд., специфичные для модели памяти ранних процессоров Intel с 16 битной адресацией, и не поддерживаемые современными компиляторами. Они исключены так:

//------------------------------------------
// OBSOLETE COMPILER DIRECTIVES
//------------------------------------------
#define _far
#define __far
#define __huge
#define __near
#define _interrupt
//------------------------------------------

Шаг 2. Унифицируем библиотеки С

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

//------------------------------------------
// MEMORY FUNC ALIASES
//------------------------------------------
#define _fmalloc		malloc
#define _ffree			free
#define _fmemcpy		memcpy
#define _fmemset		memset
#define _fmemmove		memmove
//------------------------------------------

Так же везде закомментированы два устаревших заголовочных файла:

// #include <graph.h>
// #include <bios.h>

Шаг 3. Избавляемся от несовместимых ассемблерных вставок

Хороший программист знает, что алгоритмы пишут и отлаживают на доменных языках, а затем, редкие, критические к скорости участки, переписывают на ассемблере.
Именно такова кодовая база примеров книги, и «избавление» от несовместимого с современными процессорами ассемблера шло раскомментированием С-кода и комментированием ассемблерных вставок (ведь исходники необходимо сохранить в оригинале).

Шаг 4. Эмулируем «программные прерывания» OS DOS

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

Чтобы вызвать функцию другого процесса, необходимо установить в регистрах микропроцессора передаваемые ей аргументы, и вызвать команду процессора «прерывание». Тогда процесс, установивший функцию-«обработчик» данного «прерывания», получит выполнение, и вернёт в регистрах результат своего выполнения тому процессу, который его вызвал. То есть «перывание» – это аналог «межпроцессной» ассемблерной инструкции call.

Значительную часть функционала DOS инкапсулируют несколько прерываний, закрепленных за функциями процессов DOS.

Свою функцию за «прерыванием» закрепляют через:

void _dos_setvect(int interrupt_id, void* fn)

А для вызова «прерывания» служит:

int int86(int interrupt_id, union REGS *r_in, union REGS *r_out)

где поля структуры REGS содержат данные, помещаемые в (из) регистры микропроцессора:

Структура REGS
//------------------------------------------
// <dos.h> <i86.h> STRUCTS AND STUBES
//------------------------------------------
// INTERRUTPS HANDING

/* word registers */

struct WORDREGS {
        unsigned short ax;
        unsigned short bx;
        unsigned short cx;
        unsigned short dx;
        unsigned short si;
        unsigned short di;
        unsigned short cflag;
};
struct BYTEREGS {
        unsigned char al, ah;
        unsigned char bl, bh;
        unsigned char cl, ch;
        unsigned char dl, dh;
};

union REGS {
        struct WORDREGS  x;
        struct WORDREGS  w;
        struct BYTEREGS  h;
};

Поэтому достаточно написать такую «подмену» функции int86(), чтобы эмулировать большую часть специфичного функционала DOS (изменение видеорежима, чтение состояния мыши, клавиатуры, итд.).

Функция int86()
int int86(int interrupt_id, union REGS *r, union REGS *r_out)
{
	switch(interrupt_id)
	{
	case VIDEOBIOS_INT__:
		{
			switch(r->w.ax)
			{
			case VGA_VIDEOMODE_13h__:
				_setvideomode(_MRES256COLOR);
				break;
			case VIDEOBIOS_FUNC_SETPALETTE__:
				{
					BYTE* raw_palette = (BYTE*)MK_FP(s->es, r->w.dx);
					_setpalette(raw_palette);
				}
				break;
			}
		}
		break;

	case KEYBOARD_INT_16h__:
		{
			switch(r->h.ah)
			{
			case KEYBOARD_INT_16h_AH_01:
				r_out->w.cflag = (PRESSED_RAW_DOS_KEY == 0) ? I386_FLAG_ZF : 0;
				break;
			case KEYBOARD_INT_16h_AH_00:
				r->h.ah = PRESSED_RAW_DOS_KEY;
				break;
			}
		}
		break;

	case MOUSE_INT__:
		{
			switch(r->h.al)
			{
			case MOUSE_SHOW__:
				ShowCursor(TRUE);
				break;
			case MOUSE_HIDE__:
				ShowCursor(FALSE);//hides the cursor
				break;
		}
		break;

		// …
		// …
		// …
	}

}

Казалось бы, чем в 2022 году интересен механизм прерываний 1980-х? Обоснованно полагаю, что разработчикам многотомных API следует брать на вооружение приёмы, укладывающие API в 2-3 функции.

Шаг 5. Эмулируем «порты ввода-вывода»

«Порты ввода-вывода» – это разновидность HAL (Hardware Abstraction Level), механизм пользуемый для унификации работы с оборудованием.

Запись последовательности байт в закрепленный за устройством «порт», – это команда, которую следует выполнить устройству.

Для программы «порт» – это ячейка, куда (откуда) можно писать (читать) данные (обычно байты).

В DOS для записи и чтения данных в «порт» служат функции:

void _outp(int port, int value);
unsigned char _inp(int port);

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

Вот моя эмуляция функции _inp() (функция _outp() выглядит похоже):

_inp()
unsigned char _inp(int port)
{
	AUTOLOCK(*vgaScreenPrimary);
	VGAScreen* scr = vgaScreenPrimary;

	unsigned char ret = 0;

	switch(port)
	{
	case PALETTE_DATA__:
		{
			ret = vgaScreenPrimary->m_screen->m_surfacePalette[scr->INOUT_palette_offset*3 + scr->INOUT_palette_write_count];
			scr->INOUT_palette_write_count++;

			if(vgaScreenPrimary->m_VGA_RENDER_state & RENDER_CORRECT_PALETTE)
			{
				ret = ((unsigned int)ret) >> 2;
			}
		}
		break;
	case VGA_INPUT_STATUS_1__:
		{
			static unsigned int state = 0;
			ret = (state++ % 2) ? VGA_VSYNC_MASK__ : 0;
		}
		break;
	case KEY_BUFFER__:
		{ // LOCK START
			AUTOLOCK(emuKernel);
			ret = PRESSED_RAW_DOS_KEY;
		} // LOCK END
		break;

	//------------------------------------------
	// SERIAL WORK
	//------------------------------------------
	case COM_1__SER_LSR:
	case COM_2__SER_LSR:
		ret = 0x20; // TELL COM WRITE READY
		break;
	case COM_1__SER_RBF:
	case COM_2__SER_RBF:
		ret = m_curr_income_serial_port_byte;
		break;
	//------------------------------------------

	default:
		ret = 0;
	}

	return ret;
}

Шаг 6. Эмулируем сеть

В книге есть единственная сетевая игра «Танки». Обмен состояниями между экземплярами игры идёт через устройство «COM порт» по API «порты ввода-вывода». Можно эмулировать эту связь через TCP/IP, но это займёт время и не повлияет на основную цель проекта (дать учебный материал студентам). Поэтому я использовал «ненормальное программирование»: «сетевой трафик» между экземплярами игры идёт через WinAPI функцию SendMessage(), и их следует запускать на одном компьютере.

Вы будете писать сетевую игру «Танки»
Вы будете писать сетевую игру «Танки»

Шаг 7. Эмулируем видео субсистемы

В портируемых программах задействованы все основные виды вывода изображений DOS:

  1. Символьный вывод в консоль функциями из <stdio.h> (print(), _settextposition(), …).

  2. Вывод графических примитивов (точка, линия, эллипс, прямоугольник …) функциями из <graph.h>.

  3. Запись массивов пикселей в видеопамять напрямую, для чего видеоадаптер переводят в «графический режим».

Возиться совмещением консоли и графики в едином окне не хотелось, поэтому опять трюк из «ненормального программирования»: программа начинает работу в консольном режиме, а при попытке перевода видеоадаптера в «графический режим» открывается дополнительное окно. Таким образом, консольный вывод идёт в окно консоли, а графика выводится в окно графики.

Алгоритмы для графических примитивов (_rectangle(), _ellipse(),_lineto() …) найдены и взяты с Github.

Видеопамять в DOS находится в ОЗУ, начиная с адреса 0xA0000000. Поэтому в исходниках программ встречается запись

unsigned char far *video_buffer = 0xA0000000;

Я выделил буфер unsigned char* MEMORY_0xA0000000 = malloc(…) и заменил в исходниках 0xA0000000 на MEMORY_0xA0000000

unsigned char far *video_buffer = MEMORY_0xA0000000;

После записи в такую «видеопамять» необходимо вызвать _redraw_screen(), для переноса изменений буфера на реальный экран. К слову, это 1-2 места на портируемую программу.

Шаг 8. BIOS

По мере портирования выяснилось, что алгоритмы программ используют фрагменты BIOS, а именно шрифты, «зашитые» по адресу 0xF000FA6E. Поэтому я заменил в исходниках адрес 0xF000FA6E на глобальную переменную MEMORY_0xF000FA6E, которую определил так:

unsigned char far *MEMORY_0xF000FA6E = VGA_FONT_8X8;

А сам шрифт позаимствовал (конечно, с указанием авторства) из библиотеки SDL (Simple DirectMedia Layer):

Цифры 5, 6, 7 шрифта (заданы битовой маской)
unsigned char VGA_FONT_8X8[8*256] = {

// …
// …
// …

    /*
    * 53 0x35 '5'
    */
    0xfe,           /* 11111110 */
    0xc0,           /* 11000000 */
    0xc0,           /* 11000000 */
    0xfc,           /* 11111100 */
    0x06,           /* 00000110 */
    0xc6,           /* 11000110 */
    0x7c,           /* 01111100 */
    0x00,           /* 00000000 */

    /*
    * 54 0x36 '6'
    */
    0x38,           /* 00111000 */
    0x60,           /* 01100000 */
    0xc0,           /* 11000000 */
    0xfc,           /* 11111100 */
    0xc6,           /* 11000110 */
    0xc6,           /* 11000110 */
    0x7c,           /* 01111100 */
    0x00,           /* 00000000 */

    /*
    * 55 0x37 '7'
    */
    0xfe,           /* 11111110 */
    0xc6,           /* 11000110 */
    0x0c,           /* 00001100 */
    0x18,           /* 00011000 */
    0x30,           /* 00110000 */
    0x30,           /* 00110000 */
    0x30,           /* 00110000 */
    0x00,           /* 00000000 */

// …
// …
// …

}

Шаг 9. Немного фетиша

В ОЗУ DOS по адресу 0x0000046C расположена ячейка счётчика (таймера), инкрементируемого 18.2 раз в секунду. Он используется в вычислениях интервалов времени, например, так:

unsigned int far *clock = (unsigned int far *)0x0000046C; // pointer, 18.2 clicks/sec

while(abs(*clock - now) < clicks){} }

Логично создать переменную PMEM_0x0000046C, и обновлять её в отдельном потоке с частотой 18.2 раз в секунду, но тут возникают коллизии при доступе к ней из разных потоков.

Поэтому я добавил потокобезопасную функцию с именем ULONG PMEM_0x0000046C(), возвращающую значение таймера, и заменил в исходниках строку

 unsigned int far *clock = (unsigned int far )0x0000046C;

на

ULONG (*_clock)() = PMEM_0x0000046C;

Дальнейшее «переписывание» исходников свелось к автоматической замене *clock на обрамленную в скобки *_clock:

while(abs((*_clock)() - now) < clicks){} }

Шаг 10. Звук

Со звуком в DOS дела обстоят так: в начале 1990-х было несколько основных звуковых карт со специфическими, достаточно корявыми API. Поэтому уже авторами портируемых программ была написана микро-библиотека из функций-обёрток (wrappers), инкапсулирующих работу со звуком.

Сюрпризом оказались и сами звуковые файлы – формат VOC ушёл в небытие уже к концу 1990-х.

Поэтому с кодом звуковой библиотеки я обошёлся наиболее жестоко – я удалил всё содержимое функций-обёрток, превратив их в заглушки, всегда возвращающие статус «выполнено успешно» (кстати, подобным образом я «заглушил» и часть функций DOS в эмуляции COM порта и клавиатуры), сконвертировал VOC файлы в WAV-формат и свёл всю эмуляцию звуковой субсистемы DOS к вызову единственной функции WinAPI – PlaySound(). Всё это заняло, наверное, ~20 минут работы.

Движок

Получившийся микро-движок «заводит» в среде Windows программы DOS с минимальным изменением кода последних (вам будет интересно сравнить исходный и портированный код, например, в Total Commander).

Посчитаем основные WinAPI функции, задействованные в движке, и посмотрим на их применение.

  1. CreateThread – для запуска потока, эмулирующего таймер DOS и потока обновляющего графическое окно.

  2. EnterCriticalSection, LeaveCriticalSection – для синхронизации потоков.

  3. MapVirtualKey, GetAsyncKeyState – для работы с клавиатурой и мышью.

  4. StretchDIBits, SetDIBitsToDevice – для, говоря математическим языком, «отображения множества» из буфера видеопамяти DOS в «множество» в видеопамяти графического окна Windows. Менее поэтическим языком, это значит, что эти функции масштабируют изображение и отрисовывают видеобуфер DOS на экране.

  5. PlaySound – для эмуляции звука.

С полтора десятка оставшихся WinAPI функций, впрочем, как и бОльшая часть кода микро-движка, – это «накладные» расходы для запуска оконного приложения в Windows. За что следует поблагодарить, как гласят слухи и легенды, наших собратьев из Мексики, приложивших усилия к разработке WinAPI в 1990-х.

«Помарки» в исходниках или несколько слов в рубрику «Хозяйке на заметку»

На все портируемые исходники было лишь несколько незначительных «помарок» с поучительным разбором вызванных ими происшествий.

  1. С ошибкой «нарушение доступа к памяти» падала процедура трассировки лучей. Полагаю, причина в разной реализации арифметики с плавающей запятой в старых и новых процессорах (см., например, Floating-point migration issues). Казалось бы, незначительные погрешности в точности из-за различий в операциях над float приводят к некорректной работе математических алгоритмов и падению программ!
    Глубоко вникать в процедуру трассировки лучей я не стал, а доработал алгортим в месте падения «напильником»:

     	//------------------------------------------
     	// ALGORITHM ERRORS FIXING
     	//------------------------------------------
     	if(cell_y > (WORLD_ROWS-1)) cell_y = (WORLD_ROWS-1);
     	if(cell_y < 0) cell_y = 0;
     	if(cell_x > (WORLD_COLUMNS-1)) cell_x = (WORLD_COLUMNS-1);
     	if(cell_x < 0) cell_x = 0;
     	//------------------------------------------
  2. В зависимости от опций компиляторы или присваивают не инициализированным контейнерам типа int нулевые значения, или нет. Несколько программ падали именно из-за этого. Установите правилом инициализировать переменные при их объявлении!

  3. Корректная работа при компиляции в Debug и «зависания» в Release.
    Компиляторы при оптимизации (и не только), способны привнести ошибки в алгоритмы. И GCC, и Clang при оптимизации -O2 делают непредсказуемым поведение функции, если в её сигнатуре указано, что функция возвращает значение, но в реализации она ничего не возвращает.

Рассмотрим функцию:

Render_Sliver_32()
{
	Render_Sliver(sliver_object, sliver_scale, sliver_column);
}

По правилам С, если не указан тип возвращаемого значения функции, то таковым считается int, однако Render_Sliver_32() ничего не возвращает.
Поэтому вызов Render_Sliver_32() – намертво повесит программу, скомпилированную GCC или Clang с опцией –O2. А если сделать объявление так:

void Render_Sliver_32()
{
	Render_Sliver(sliver_object, sliver_scale, sliver_column);
}

то всё заработает корректно.

Как вы узнаете из книг Андре Ламота, при создании критически важных программ, оптимизацию отключают, но данном случае я добавил void в сигнатуры немногих «недообъявленных» функций.

Выводы и впечатления от работы

Портация шла легко и быстро: программы «заводились» мгновенно – стоило добавить в движок обёртки для задействованных функций DOS. Изменения в коде программ почти не требовались, за исключением упомянутых мелких правок. (Например, сетевая игра «Танки» «завелась» сразу, как только была добавлена нехитрая эмуляция COM порта.)
Реализация этого проекта ещё раз подтвердила известное правило: хорошие инженеры пишут краткий, внятный, кросплатформенный код, а их работы не устаревают. Чего желаю всем!

Исходники

Исходники портированных программ и движок можно взять с Github. Возможно, это пригодится тем, кому требуется с минимальными тратами времени и сил «заводить» DOS программы.

Продолжая добрую традицию, заданную @8street в статье «Как я портировал DOS игру», оставляю следующий постскриптум:

P.S. Джун нужен кому? Просьба в личку.

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


  1. foxThread
    17.10.2022 03:44
    +5

    Я как картинки первые две увидел, так скупая слеза потекла. В книжке правда они черно белые были :-)


  1. Deosis
    17.10.2022 07:06
    +12

    Казалось бы, чем в 2022 году интересен механизм прерываний 1980-х?
    Обоснованно полагаю, что разработчикам многотомных API следует брать на
    вооружение приёмы, укладывающие API в 2-3 функции.

    Внутри которых свитч по первому аргументу на много тысяч строк.


  1. lisovsky1
    17.10.2022 11:42

    Почему все так привязываются к DOSbox? Ведб тот же DOS можно спокойно поставить на виртуальную машину! В VirtualBox как вариант.


    1. maxlilt
      17.10.2022 16:04

      В DOSbox есть слоты сохранения состояния. И CRT шейдеры.


  1. raspberry_pi_soft
    17.10.2022 12:19
    +2

    Отличная статья, прочитал с удовольствием! Как-то надо было запустить старую программу пульта оператора под DOS на новой аппаратуре, новую программу под Linux только писали, а в старой была куча отлаженных, проверенных вещей. Можно было использовать для отладки этой новой аппаратуры.

    Запустил программу в dosemu, общение с внешним миром сделал через общий RAM drive, в основной системе крутился скрипт конвертора протоколов.


  1. axe_chita
    17.10.2022 20:37
    +1

    Статья что надо, как говориться, «самая мякотка и сплошное удовольствие»!


  1. demimurych
    19.10.2022 07:12

    Значительную часть функционала DOS инкапсулируют несколько прерываний, закрепленных за функциями процессов DOS.

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

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

    Впрочем, с появлением protected mode, естественно никто теперь управлять єтим контроллером не даст.