Летом мне попалась статья Вадима Башурова «История игрушки» 2011 года про создание игры «Поле Чудес» в далёком 1992 году — очень рекомендую, если вы, как и я, ее пропустили. Вадим упомянул, что исходники, к большому сожалению, утерялись. Мне очень захотелось восстановить их хотя бы в каком‑то виде, и я решил заняться этим на досуге.
Цель простая: кросс-компилируемый 16-битный .exe для MS-DOS, который повторяет логику оригинала и использует оригинальные ресурсы. Я не целился в byte-accurate реверс, решил переписать на C.
Статью я построю как небольшой туториал по реверсу 16-битных DOS‑приложений для начинающих, и пройдусь по ключевым алгоритмам и тому, как в игре реализован игровой процесс. Желательны базовые знания 16-битного ассемблера и C.
Вспомним несколько сущностей из 1992 года:
PASCAL calling convention — это соглашения о том, кто чистит стек и как передаются параметры. В отличие от cdecl, у Pascal параметры обычно кладутся в стек слева направо, а стек очищает вызываемая функция (callee). Возвращаемые значения чаще всего идут через AX (Integer/Word) или через DX:AX (LongInt).
Модели памяти в MS‑DOS — это договорённость между компилятором и компоновщиком о том, как будет устроена программа: будут ли вызовы near или far, сколько сегментов кода и сколько сегментов данных, и как всё это между собой стыкуется. У POLE.EXE модель памяти Medium — один сегмент данных (DS), много сегментов кода.
Инструменты
Для начала нужно собрать инструменты и провести небольшую подготовку. Понадобится дизассемблер: подойдёт IDA. Ghidra даёт отличный C‑листинг, но без автоподхвата библиотечных функций Паскаля в самом начале будет тяжеловато, поэтому оставим Гидру на потом.
Дальше — SoftICE + Bochs (есть замечательная статья, как их подружить) и DOSBox‑X. Ещё нужен компилятор под DOS, 16-битный, real mode. Я остановился на Open Watcom 2: это современный проект, его до сих пор обновляют, и у меня уже был с ним опыт.
Предварительное исследование
Перед тем как нырять в недра IDA, полезно сначала просто «пощупать» объект. Начнём со статьи Вадима — из неё можно вытащить довольно много вводных:
-
игра написана на Turbo Pascal (раз 1992 год, то самые вероятные версии — 5.0, 5.5 или 6.0);
-
для графики используются ассемблерные вставки;
-
видео‑режим — EGA 640×350, 16 цветов;
-
рисование идёт прямо в видеопамять, и в статье даже мелькает характерный пример:
scr: array[] of byte absolute $A000:0000; -
планарная организация памяти (кошмар, да);
-
упоминается «механизм задвижек» (latches);
-
и ещё одна важная фраза: «видеопамять использовалась даже для хранения данных».
Вадим также вспоминает, что картинки рисовались в Dr. Halo и были в RLE‑формате. Это звучит как большая подсказка к тому, как запакованы ассеты: значит, где‑то в игре должен быть RLE‑декодер, причём, возможно, близкий к реализации Dr. Halo. (Впоследствии окажется, что всё не совсем так — но об этом позже.)
В базовом дистрибьютиве игры обычно идут четыре файла — POLE.EXE, POLE.OVL, POLE.LIB и POLE.FNT. Внутри POLE.OVL есть огрызки русских слов, а расширение .FNT немного намекает, что это может быть шрифт. POLE.LIB выглядит как бинарная каша, на данном этапе что там внутри — непонятно.
В комплекте с Open Watcom идет чудесная утилита DOSTRACE, с помощью которой удобно отследить все системные вызовы POLE.EXE
Что делает POLE.EXE при запуске
Я запустил trace -a -b -e -f -i -n -r -s -w -x pole.exe в DOSBOX. Появился файл TRACE.LOG с кучей всяких вызовов; оставим только интересное:
open("pole.lib", 2) = 6
read(6, 2147:3CFA, 128) = 128 ":$$$$x02x02x02x02x02"...}
read(6, 252F:0000, 4608) = 4608 {0xdf,0x00,0xac,0x00,0, ...}
read(6, 264F:0000, 4608) = 4608 {0xdf,0x00,0xac,0x00,0, ...}
read(6, 276F:0000, 4608) = 4608 {0xdf,0x00,0xac,0x00,0, ...}
read(6, 288F:0000, 4608) = 4608 {0xdf,0x00,0xac,0x00,0, ...}
read(6, 29AF:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 29BF:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 29CF:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 29DF:0000, 256) = 256 {0x13,0x00,0x10,0x00,0x00,7,...}
read(6, 29EF:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 29FF:0000, 256) = 256 {0x13,0x00,0x10,0x00,0x00,7,...}
read(6, 2A0F:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 2A1F:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 2A2F:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
read(6, 2A3F:0000, 256) = 256 {0x14,0x00,0x10,0x00,0x00,7,...}
Четыре знака доллара и множество двоек запоминаем — и идём в IDA, искать упоминания POLE.LIB.
Один кусочек я разберу подробно, чтобы прям с головой погрузиться в 16-битный мир MS‑DOS. Эту часть можно спокойной пропустить и перейти сразу к «Распаковываем POLE.LIB»
Поиск по «POLE.LIB» отправил нас в эту функцию:
seg000:0053 sub_10053 proc near ; CODE XREF: PROGRAM+1B↓p
seg000:0053
seg000:0053 var_304 = byte ptr -304h
seg000:0053 var_208 = dword ptr -208h
seg000:0053 var_204 = word ptr -204h
seg000:0053 var_202 = word ptr -202h
seg000:0053 var_200 = byte ptr -200h
seg000:0053 var_180 = byte ptr -180h
seg000:0053 var_80 = byte ptr -80h
push bp ; сохраним BP вызывающей функции
mov bp, sp ; теперь bp это стек фрейм нашей фунции - аргументы буду лежать выше, локалы - ниже
sub sp, 304h ; вычитаем размер локалов
mov di, offset aPoleLib ; "pole.lib"
push cs ; кладем на стек указатель на строку "pole.lib"
push di
lea di, [bp+var_180] ; кладем указатель на строку которую мы хотим получить (pascal string)
push ss
push di
mov ax, 0FFh ; видимо это максимальная допустимая длина для возвращаемой строки
push ax
call @$basg$qm6Stringt14Byte ; Store string basg = basic assign
lea di, [bp+var_200]
push ss ; seg(FileRec)
push di ; off(FileRec) -> кладем на стек указатель на var_200 = FileRec
lea di, [bp+var_180]
push ss ; seg(pasString)
push di ; off(pasString) -> кладем на стек указатель var_180 = pasString
call @Assign$qm4Filem6String ; Assign(var f: File; name: String)
lea di, [bp+var_200]
push ss ; seg(FileRec)
push di ; off(FileRec) -> кладем на стек указатель на var_200 = FileRec
mov ax, 80h
push ax ; кладем на стек 0x80, это recsize (128байт)
call @Reset$qm4File4Word ; Reset(var f: File; recsize: Word)
call @IOResult$qv ; IOResult: Word{AX}
or ax, ax
Хочу отдельно отметить, что IDA так прекрасно подхватила библиотечные Turbo Pascal‑функции благодаря сигнатурам (FLIRT) — в том числе работе Nick Pisanov.
Наша функция кладёт на стек указатель на строку «pole.lib», затем 0xFF(максимальная длина). Делаем из нее паскаль‑строку — call @$basg$qm6Stringt14Byte
Дальше вызывается Assign: он связывает файл «pole.lib» со структурой File на стеке и заполняет служебные поля. А вот потом структура передаётся в Reset, причём вместе с recsize = 0×80h, то есть 128 байт.
И да — я очень рекомендую сразу переименовывать функции, переменные, аргументы и даже внутренние loc, пока вы понимаете код по горячим следам. Например, тут я бы назвал var_200 как fileRec, var_180 как pasString, а саму функцию — что‑то вроде load_resources().
Дальше мы проверяем результат и если файл существует, то читаем 1 запись длиной в 0×80 байт (128) в var_80 — можно ее назвать header128
mov al, [bp+var_80_header128] ; берем первый байт из header
xor ah, ah ; обнуляем старший байт регистра
mov word ptr [bp+var_208+2], ax ; кладем первый байт хедера внутрб var_208 на 2 позицию?
; зачем - непонятно, но дальше var_208 хранит указатель
; 4 байта seg:offs, наверно так компилятор разложил локалы или
; просто была dword переменная temp
mov ax, 1
cmp ax, word ptr [bp+var_208+2] ; смотрим есть ли хотя бы один чанк сейчас?
jg short loc_1017A ; если количества чанков < 1 прыгаем в конец
mov [bp+var_202], ax ; если нет положим 1 в var_202 (что-то типа for i=1
jmp short loc_10114 ; и перейдем к чтению
Дальше начинается очень показательный цикл, который по сути распаковывает POLE.LIB в память. var_202 выглядит как итератор — переименовываем в i.
loc_10110: ; CODE XREF: sub_10053+125↓j
inc [bp+i] ; увеличиваем итератор - i++
loc_10114: ; CODE XREF: sub_10053+BB↑j
mov di, [bp+i]
mov al, [bp+di+header128] ; hdr128[ i ] ( i начинается с 1, потому что нулевой байт хедера это
; количество ассетов, а с первой позиции видимо идут размеры каждого
xor ah, ah ; обнуляем старший байт - получается в ax теперь слово
mov [bp+var_204], ax ; кладем в var_204
mov ax, [bp+var_204]
mov cx, 7
shl ax, cl ; ax << 7 - видимо размеры для выделения памяти через GetMEM
push ax
call @GetMem$q4Word ; GetMem возвращает указатель на выделенную память в DX:AX
mov cx, ax ; CX = offset
mov bx, dx ; BX = segment
mov ax, [bp+i] ;
dec ax
mov di, ax
shl di, 1 ; тут такая математика - нам из номера чанка надо получить смещение в таблице
shl di, 1 ; так как номер начинается с 1 то di = (i-1)*4 - dec и два shl ,1
mov [di+0A08h], cx ; оффсет
mov [di+0A0Ah], bx ; сегмент
...
...
push di
push [bp+var_204] ; тут у нас лежит количество записей для ассета (не байты, а именно записи
; длиной 128байт)
...
...
call @BlockRead$qm4Filem3Any4Wordm4Word ; BlockRead(var f: File; var buf; count: Word;var result: Word)
call @__IOCheck$qv ; Exit if error
mov ax, [bp+var_202]
cmp ax, word ptr [bp+var_208+2]
jnz short loc_10110 ; И прыгаем вверх цикла
Логика такая:
-
Читаем 128-байтный хедер. Нулевой байт — это количество ассетов, а дальше идёт таблица размеров: header128[i], где i начинается с 1.
-
Каждый header128[i] — это количество записей по 128(0×80) байт. Поэтому чтобы получить размер в байтах значение надо сдвинуть на 7 (умножить на 27=128)
-
Дальше вызывается GetMem: выделяем память под ассет, и возвращённый far‑указатель (DX:AX) складываем в глобальную таблицу указателей по адресу DS:0A08. Адрес жёстко зашит — потом вся игра будет обращаться к ресурсам через эту таблицу. Индекс в таблице считается как (i-1)*4, потому что каждый элемент — это offset:segment (4 байта).
-
И сразу же читаем данные из POLE.LIB прямо в выделенную память: BlockRead(FileRec, ptr, count, result), где count — это как раз количество 128-байтных записей из хедера (а не байт).
read(6, 2147:3CFA, 128) = 128 ":$$$$x02x02x02x02x02x02x02x02x02x02..."
Получается, что эта строка из TRACE.LOG означает вот что —«:» — это 0x3A, то есть 58 ассетов. $ — это 0x24, значит первые четыре ассета имеют длину 0x24*128 = 4608 байт. Дальше идёт россыпь 0x02, то есть ассеты по 2 записи: 2*128 = 256 байт, что идеально сходится с логом TRACE.EXE
Распаковываем POLE.LIB
Отлично — значит теперь я могу написать распаковщик и уже нормально анализировать сами картинки. А ещё лучше — сразу сделать паковщик‑распаковщик: чтобы можно было подменять один ассет на другой и смотреть, что именно меняется в игре.
Судя по тому, что ассеты читаются последовательно, просто один за другим по размерам, никаких жёстких смещений внутри файла нет. Но если изменить размер ассета или их количество, придётся пересобирать и заголовок: там как раз лежит число ассетов и таблица их длин.
Довольно быстро стало понятно, что 256-байтовые ассеты — это значки на барабане. И я получил свой первый результат просто заменив все такие блоки на один и тот же.
Следующим этапом я планировал разобрать сам формат RLE. И так как в статье была подсказка про Dr. Halo, я решил не копаться в недрах дизасма в поисках декодера, а просто открыть распакованные ассеты в редакторе. И вот тут случился провал: Dr. Halo сохранял файлы в каком‑то совершенно другом формате и наотрез отказывался открывать мои *.rle. Я попробовал две версии — без шансов.
Посетовав на то, что, возможно, у Вадима была какая‑то другая сборка/версия, я решил посмотреть на формат глазами — просто как на последовательность байт:
14 00 10 00 00 00 07 00 86 07 88 00 86 07 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 0B 00 86 07
01 00 86 01 01 00 86 07 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 07 00 87 00 86 01 87 00 00 07 00 01 00 92 01 01 00 00 07 00 01 00 92
01 01 00 00 07 00 01 00 92 01 01 00 00 07 00 01 00 92 01 01 00 00 07 00 87 00 86 01 87 00 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 0B
00 86 07 01 00 86 01 01 00 86 07 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 0B 00 86 07 01 00 86 01 01 00 86 07 00 07 00 86 07 88 00 86 07
Очевидно, что первые два слова — это размеры спрайта в little‑endian: 0×0014 × 0×0010, то есть 20×16 пикселей. Немного поигравшись с байтами нашел интересную особенность: если менять первое слово и запускать игру, визуально ничего не происходит. А вот если менять второе — спрайт меняет свое изначальное положение на экране, то есть вероятно ширина не используется в декодере, а интересует его только высота.
Если высота — 0×10, значит после заголовка логично ожидать 16 строк данных. Попробуем их найти.
Сразу скажу: я сначала пытался написать декодер в лоб, но постоянно ловил какие‑то странные результаты — как будто я неправильно понимаю границы строк или пропускаю управляющие байты, но какие‑то знакомые очертания уже присутствуют:

Я, честно говоря, еще не сталкивался с такого рода изображениями, и потратил куда больше времени в первый раз, чем хотелось бы
В итоге выяснилось, что я неправильно трактовал начало строки. После пропуска 6 байт хедера (да, там ещё два служебных нуля — пока непонятно, зачем) идёт слово с количеством байт в строке, а уже потом начинается сам RLE поток: два типа последовательностей — управляющий байт и цвет. Ну и да: надо было просто внимательнее прочитать дизасм функции в самом начале, а не пускаться во все тяжкие.
То есть базовый алгоритм примерно такой. Если управляющий байт > 0×80, то это RLE‑пробег длиной byte — 0×80. Если <= 0×80, то это просто сырые пиксели: управляющий байт задаёт их количество, а дальше в потоке идут сами цвета. Например, последовательность:85 00 03 01 02 03читается так:
0×85 → это 5 пикселей цвета 0 (пять чёрных), затем 0×03 → три «сырых» пикселя, и дальше идут их цвета: 1, 2, 3.
Итак: формат ассета понятен как и структура заголовка POLE.LIB — значит, можно делать утилиту для просмотра. Я еще заметил почти в каждом блоке есть мусорный хвост из начала еще одной картинки, попробуем их посмотреть
И действительно: в конце почти каждого блока болтается небольшой хвост — начало следующей картинки. Похоже на банальный баг утилиты, которая паковала POLE.LIB.
Но что‑то совершенно не то с цветами.
Ищем в IDA по слову «palette» и находим такой кусок —
mov ax, 6
push ax
mov al, 2Eh ; '.'
push ax
call @SetPalette$q4Word8Shortint ; SetPalette(Word,Shortint)
mov ax, 4
push ax
mov al, 14h
push ax
call @SetPalette$q4Word8Shortint ; SetPalette(Word,Shortint)
mov ax, 5
push ax
mov al, 27h ; '''
push ax
call @SetPalette$q4Word8Shortint ; SetPalette(Word,Shortint)
xor ax, ax
Очень похоже, что игра дополнительно крутит EGA‑палитру — посмотрим, во что игра ремапит эти номера через таблицу и заменим их у себя
-
цвет 6 меняем на 0×2E — 46 rgb(0xFF,0xAA,0×55)
-
цвет 4 меняем на 0×14 — 20 rgb0xAA,0×55,0×00)
-
цвет 5 меняем на 0×27 — 39 rgb(0xFF,0xAA,0xAA)
Победа! Назовем эту функции bgi_init и пойдем дальше.
Теперь у нас есть все ресурсы, их номера и где лежат указатели на них в DS
Содержимое POLE.LIB
Тут становится видно ещё одну важную штуку: прозрачность реализована с помощью colorkey — то есть при рисовании спрайта один конкретный цвет считается прозрачным и просто не рисуется.
Причём colorkey, похоже, зависит от типа ресурса:
-
для персонажей это очевидно зелёный фон;
-
для иконок барабана и логотипа — серый
-
отдельно стоит пузырь для текста: он лежит на синем фоне (как выяснится позже, не только он)
Подружим SoftICE и IDA
Очень удобно, когда можно синхронизировать имена функций, сегментов и переменных между IDA и SoftICE. Но «из коробки» это обычно не работает.
Дальше, чтобы SoftICE смог этот MAP съесть, его нужно прогнать через MSYM.EXE (идёт в комплекте SoftICE) и получить.SYM.
Тут я немного помучался: SoftICE капризничает из‑за оформления (ему критичен CR‑LF), плюс ему не нравятся сегменты в некоторых форматах, и ещё лучше заранее вычистить лишние пробелы и вообще любые подозрительные символы из имён. Вот маленький скрипт, который приводит MAP в «съедобный» вид — gist
-
Сначала выгоняем MAP‑файл из IDA: File → Produce file → Create MAP file
-
python3 ida_map_to_softice.py POLE.EXE.map POLEFIX.MAP -
Уже в DOS:
C:> MSYMPOLEFIX.MAP
На выходе получаем POLEFIX.SYM. Я обычно переименовываю его в POLE.SYM и кладу рядом с POLE.EXE в образ/дистрибутив для Bochs. (Поскольку файл будет часто обновляться, удобно поднять сеть в Bochs под DOS — например, через ne2k — и заливать обновления по FTP.)
Теперь запускаем SoftICE: C:> ldr POLE.EXE И включаем удобный режим, чтобы отладчик жил прямо в терминале: altscr on
Отлично! Попробуем в связке SoftICE и IDA разобраться что делают функции! Схема нашего кода такая —
Start Stop Length Name Class
0000 074A 074B seg000 CODE
074B 0A6F 0325 seg001 CODE
0A70 0A76 0007 seg002 CODE
0A77 0AD8 0062 seg003 CODE
0AD9 0C78 01A0 seg004 CODE
0C79 0D7C 0104 dseg DATA
0D7D 1164 03E8 seg006 STACK
Что соответствует модели памяти MEDIUM для MS‑DOS — много кода, один сегмент данных, вызовы внутри сегмента вероятней всего будут near, а между — far. И один dseg для всех. Компилятор удобно сложил все авторские функции в один сегмент — seg000, а остальные достались библиотекам.
Пойдем с первой функции в первом сегменте (seg000):
seg000:0000 sub_10000 proc near ; CODE XREF: sub_107B9+6A
seg000:0000 push bp
seg000:0001 mov bp, sp
seg000:0003 sub sp, 2
seg000:0006 call @RESTORECRTMODE$qv ; RESTORECRTMODE(void)
seg000:000B xor ax, ax
seg000:000D call @Halt$q4Word ; Halt(Word)
seg000:000D sub_10000 endp
Выглядит как выход из программы. Проверим, что это действительно выходная точка: поставим на неё брейкпоинт и нажмём ESC.
В работающей программе жмём Ctrl+D, попадаем в SoftICE, пишем: bpx sub_10000
Возвращаемся обратно по Ctrl+D. Теперь нажимаем ESC — и сразу снова оказываемся в SoftICE: брейкпоинт сработал. Значит, всё сходится: переименовываем функцию в exit_game и идём дальше.
Вторая функция нами уже переименованна — load_resources, откроем следующую
seg000:03A5 sub_103A5 proc near ; CODE XREF: sub_109A4+19A↓p
seg000:03A5 ; sub_109A4+1EE↓p ...
seg000:03A5 var_114 = word ptr -114h
seg000:03A5 var_112 = word ptr -112h
seg000:03A5 var_110 = byte ptr -110h
seg000:03A5 var_10F = byte ptr -10Fh
seg000:03A5 var_10E = byte ptr -10Eh
seg000:03A5 var_10D = byte ptr -10Dh
seg000:03A5 var_10C = word ptr -10Ch
seg000:03A5 var_10A = word ptr -10Ah
seg000:03A5 var_108 = word ptr -108h
seg000:03A5 var_106 = word ptr -106h
seg000:03A5 var_104 = word ptr -104h
seg000:03A5 var_102 = word ptr -102h
seg000:03A5 var_100 = byte ptr -100h
seg000:03A5 arg_0 = dword ptr 4
seg000:03A5 arg_4 = word ptr 8
seg000:03A5 arg_6 = word ptr 0Ah
seg000:03A5 arg_8 = byte ptr 0Ch
seg000:03A5 arg_A = word ptr 0Eh
seg000:03A5 arg_C = word ptr 10h
seg000:03A5 arg_E = dword ptr 12h
seg000:03A5
seg000:03A5 push bp
seg000:03A6 mov bp, sp
seg000:03A8 sub sp, 114h
seg000:03AC les di, [bp+arg_E]
Видим большое количество локальных переменных и аргументов — ставим брейкпоинт на нее и нажимаем Ctrl+D
Медленно рисуется слово «КАПИТАЛ ШОУ» — можно сделать смелое предположение, что это какая‑то функция вывода текста. Откроем окно стека командой STKWIN и просто понаблюдаем, что там происходит.

Делаем вывод: arg_0E — это координата Y, arg_10 — X, а arg_0C — цвет (на «КАПИТАЛ ШОУ» хорошо заметен переход от чёрного к белому).
Очень интересная техника: чтобы получить жирный шрифт с обводкой, букву несколько раз рисуют чёрным, каждый раз чуть сдвигая X/Y, а потом поверх один раз рисуют белым — уже ровно по центру.
Найдем слово
Для начала найдём его смещение самым ленивым способом — просто посмотрим содержимое DS во время игры. То, что мы ищем, — это Pascal‑string с загаданным словом, значит правильное смещение должно указывать на первый байт длины (length byte).
Заходим в SoftICE и просто скроллим DS, пока не попадётся наше слово. (патч для mda_viewer, который показывает CP866)
d ds:0
════════════════════════════════════════════════════════════════════════════════
1AC8:0000 00 00 15 00 07 8F 9F 92-80 97 8E 8A 00 00 00 16 .....ПЯТАЧОК....
1AC8:0010 00 09 82 88 8D 8D 88 2D-8F 93 95 00 30 00 06 8A ..ВИННИ-ПУХ.0..К
1AC8:0020 90 8E 8B 88 8A 00 00 00-00 31 00 05 88 80 2D 88 РОЛИК....1..ИА-И
1AC8:0030 80 00 00 00 00 00 32 00-07 8A 80 90 8B 91 8E 8D А.....2..КАРЛСОН
1AC8:0040 00 00 00 33 00 04 91 8E-82 80 00 00 00 00 00 00 ...3..СОВА......
...
════════════════════════════════════════════════════════════════════════════════
Сверху сразу видно строки с именами персонажей — их можно тут же переименовать в IDA (если вдруг не подхватились автоматически). Жмём D дальше.
════════════════════════════════════════════════════════════════════════════════
1AC8:067E 07 94 8B 80 92 92 85 90-40 B2 A5 B0 AC A8 AD 2E .ФЛАТТЕР@▓е░мин.
1AC8:068E 8C 06 32 76 E8 05 0E 8D-80 93 97 8D 9B 89 00 92 М.2vш..НАУЧНЫЙ.Т
1AC8:069E 85 90 8C 88 8D 2E 8C 06-32 76 E8 06 07 00 00 00 ЕРМИН.М.2vш.....
...
════════════════════════════════════════════════════════════════════════════════
Вот оно, наше слово! Теперь важно убедиться, что это действительно текущий ответ, а не какой‑то случайный буфер. Проверяем просто: заменяем слово прямо в памяти и пробуем «угадать» его в игре.
Нажимаем e (edit), переходим в редактирование и заменяем все символы на один и тот же байт (например, 0x94). Возвращаемся в игру, выбираем «Скажу слово», вводим ФФФФФФФ — и о ура, слово принимается как правильное.
Значит, адрес верный: переименовываем в IDA: dseg:067E — pstring_current_word. Рядом обнаруживается и тема. Её тоже удобно сразу назвать dseg:0694 — pstring_current_theme
Что особенно интересно: при загрузке игра выбирает, какие три слова будут использованы в раундах, а затем читает их из файла в цикле, начиная с первого. В этот момент в памяти проносятся ещё закодированные слова и темы — это хорошо видно, если просто наблюдать за DS.
А когда очередь доходит до искомого слова, игра вычитает из каждого символа 0×20, и строка превращается в читаемую. Похоже на простой трюк, чтобы нельзя было просто открыть файл и мгновенно подсмотреть все ответы.
Вот простой однострочник чтобы распаковать в консоли POLE.OVL в читаемый текст — gist
Начинаем писать
Когда заполненность осмысленных имён в IDA подошла уже к 70–80%, я понял: кажется, это реально получится переписать.
Сначала я сделал простую болванку: переключение в mode 10h и вывод одного пикселя. Потом решил сразу замахнуться на стартовый экран. Открыл в IDA Function Calls — там куча библиотечных графических функций Паскаля, и я довольно быстро понял, что часть из них проще написать с нуля. Плюс там же всплыли несколько ключевых функций самой игры, вокруг которых всё и крутится: draw_rle_sprite, wait_and_get_keys, print_text, ega_latch_fill
Дальше я решил перенести все имена из IDA в Ghidra. К счастью, в комплекте с Ghidra есть Python‑плагин XML Exporter: просто кладём его в IDA по инструкции, перезапускаем, потом выбираем: Edit → Plugins → XML Exporter
Тут я столкнулся с двумя проблемами: во‑первых, в более новых версиях IDA чуть поменялся API для плагинов, и экспортер начинал капризничать. Очень выручил вот этот патч, во‑вторых, экспортер называл паскалевские строки типом string1, а в Ghidra такого типа по умолчанию нет. Можно, конечно, добавить, но я пошел проще: просто заменил string1 на string в XML.
Ещё у меня была проблема с экспортом STACK_VAR, но после возни я понял, что быстрее руками проставить локалы и аргументы: функций не так много, а так даже понятнее, что именно происходит.
Готовый XML просто кидаем в окно нового проекта. В Auto‑Analyze я снял галочку со Stack (Pascal calling convention, стек чистит сама функция — лучше не пытаться доверять это автоанализу), а остальное оставил по умолчанию.
После небольшой рутины Ghidra начинает выдавать очень даже читабельный код
К сожалению, в Ghidra все доступные calling convention (по крайнем мере у меня) по умолчанию предполагают C‑шную раскладку параметров. Для Turbo Pascal это не подходит, поэтому в декомпилированном виде аргументы у вызовов часто выглядят «задом наперёд». Приходится держать это в голове и мысленно разворачивать.
Посмотрим функцию start_screen:
Например, вот такие строки:
@SetFillStyle$q4Wordt1(7,1);
// на деле это SetFillStyle(1,7): solid fill, цвет серый
@SETCOLOR$q4WORD(7);
// текущий цвет для контуров — тоже серый
@SetLineStyle$q4Wordt1t1(3,0,0);
// 0,0,3: Solid, без битового паттерна, 3 — толщина
Ниже — уже чистый Ghidra‑кусок начала заставки. В таком виде он немного пугает, но если постепенно переименовывать переменные и распутывать координаты, становится наглядней
counter = 0;
while (true) {
@BAR$q7INTEGERt1t1t1(0x159 - counter, 0x26c - counter, 0x159 - counter, counter + 0x14);
@DELAY$q4WORD(10);
if (counter == 0xa0) break;
counter = counter + 1;
}
loop_waitAndgetKeys(0x28); // интересная функция: ждёт клавишу или таймаут и продолжает
Дальше начинается кропотливая часть: внимательное переписывание и адаптация под C.
Я сразу добавил логирующую функцию для будущей отладки — и это оказалось одним из самых правильных решений. Идея простая: пишем события в буфер, а когда он достигает заданного размера — сбрасываем на диск. Плюс добавил функцию снятия скриншотов в PCX (скоро будет понятно, зачем).
Про «видеопамять как хранилище данных»
Вадим писал, что видеопамять использовалась даже для хранения данных, что это значит?
В дизасме мне постоянно попадалась константа 0×7D00. Похоже на смещение в видеопамяти, которое выходит за границы видимой области — во многих функциях рисования смещение в VRAM передаётся как аргумент. Либо A000:0000, если рисуем прямо на экран, либо A000:7D00, если рисуем за экраном, во внутренний буфер. (Посчитать границы видимой области можно очень просто — 640×350 видимый экран, в одном байте 8 пикселей, значит 640/8 = 80 байт на строку, 80 * 350 = 28 000 (0×6D60), то есть с A000:6D60 начинается невидимая часть).
Как посмотреть, что происходит в этом «закулисье»?
EGA позволяет менять адрес начала отображаемой области, значит можно заставить экран показывать память, начиная с A000:7D00, как будто это и есть видимая часть. Для этого достаточно подправить регистры Start Address (0Ch/0Dh) через порты.
В SoftICE это делается прямо на лету: Ctrl+D — заходим в SoftICE и вводим команды:
o 3d4 0c <Enter>
o 3d5 7d <Enter>
o 3d4 0d <Enter>
o 3d5 00 <Enter>
снова Ctrl+D — возвращаем управление игре
Чтобы понимать, почему так вообще делают, надо вспомнить, как устроен EGA mode 10h. Это 640×350, 16 цветов (4 бита на пиксель), но хранятся они не как 4 бита подряд, а по плоскостям (planar). Причём CPU в каждый момент времени видит не все плоскости сразу: доступ настраивается через регистры EGA‑контроллера. Поэтому если просто сделать memdump видеопамяти A000:0000, мы увидим только одну плоскость — по сути 1-битную маску, а не нормальную картинку.
Поэтому обычный memcpy тут не справится — и вот это как раз то, о чём Вадим писал, когда упоминал «механизм задвижек» (latches).
CPU в EGA не имеет прямого доступа ко всем плоскостям одновременно: в каждый момент времени доступна к записи только одна плоскость (которую надо выставить портами). Но у EGA есть хитрый режим Write Mode 1, где контроллер перехватывает и чтение, и запись видеопамяти.
Схема такая:
-
при чтении по адресу A000:xxxx EGA загружает в latch‑регистры байты всех четырёх плоскостей для этого адреса;
-
при записи EGA игнорирует записываемое значение и использует только адрес: он берёт данные из latch и пишет их во все 4 плоскости сразу.
Посмотрим, как это реализовано у Вадима (листинг из Ghidra я переписал в читаемый вид):
// копирует прямоугольник (bytesPerLine * (maxRowIndex+1)) из A000:srcOff в A000:dstOff
// строки идут с шагом 80 байт
void ega_vram_move_blocks_wm1(
unsigned bytesPerLine, // сколько байт копировать в строке
unsigned maxRowIndex, // последняя строка (0..maxRowIndex)
unsigned srcOff, // offset в A000
unsigned dstOff // offset в A000
) {
unsigned row;
// GC Mode Register: Write Mode 1
outp(0x3CE, 0x05);
outp(0x3CF, 0x01);
for (row = 0; row <= maxRowIndex; row++) {
Move(MK_FP(0xA000, srcOff), MK_FP(0xA000, dstOff), bytesPerLine);
srcOff += 0x50; // переходим на след строку
dstOff += 0x50;
}
}
И вот особенность Write Mode 1: строго говоря, Move ничего не копирует в привычном смысле. Он просто делает чтение и запись по адресам, а всю реальную работу выполняет EGA‑контроллер.
Я даже пробовал написать максимально странный тест, чтобы убедиться, что важно не значение байта, а сам факт обращения по адресам:
rw_loop:
mov al, [si] ; читаем из A000:... (важен сам адрес чтения)
inc si
xor al, al ; затираем AL — содержимое не важно
mov es:[di], al ; пишем что угодно (важен адрес записи)
inc di
loop rw_loop
И оно всё равно работает. Потому что в WM1 важны только два адреса: откуда прочитали (чтобы загрузить latch) и куда записали (чтобы выгрузить latch). Значение, которое реально проходит через AL, можно хоть занулять — EGA его не слушает.
И тут становится понятно, зачем хранить данные прямо в видеопамяти: если источник уже в видеопамяти, то копирование делается силами EGA за один проход, а не за четыре. Это и есть тот самый практический смысл задвижек.
RLE-декодер
Вот та самая процедура отрисовки RLE. Похоже, Вадим писал её вручную на ассемблере — хотя до конца не ясно, целиком ли она самописная, или это Pascal‑процедура с inline‑вставками: местами встречаются конструкции, которые выглядят как после компилятора, а местами — явно ручная оптимизация.
cmp al, 0FFh
jle loc_XXXXX
Если не помнить наизусть, что jle это знаковый условный переход, эта конструкция может показаться странной. Если читать это в лоб: jle — jump less or equal, то есть «прыгай, если меньше или равно». Но AL — байт, а байт всегда в диапазоне 00h..FFh. Получается, что AL <= FFh истинно вообще всегда. Зачем тогда эта ветка?
Но jle делает signed-сравнение (знаковое), а не unsigned. В знаковой интерпретации байт живёт так: 00h..7Fh это 0..127 , а 80h..FFh — -128..-1
А значит FFh — это -1. И сравнение превращается в проверку: AL <= -1 (signed)
Что истинно ровно для диапазона 80h..FFh. То есть эта пара инструкций — просто завуалированная проверка установлен старший бит. AL >= 80h (unsigned)
Почему не написать что-то очевидное вроде test al, 80h? Хороший вопрос. Я проверял варианты (cmp al,80h, test al,80h, test al,al) на скорости (1000 итераций) — на практике разницы не увидел. Поэтому моя рабочая гипотеза такая: автор просто считал управляющий байт знаковым (signed char) и сравнивал с -1, не привязываясь к «магической» константе 80h. Turbo Pascal 5.5 начинает генерировать нечто похожее как раз когда в исходнике появляется сравнение со значением -1.
Немногим позже я нашёл описание формата, который использовался для картинок: Dr. Halo CUT.
…Each encoded run begins with a one-byte Run Count value. The number of pixels in the run is the seven least significant bits of the Run Count byte and ranges in value from 1 to 127
В нём прямо видно, что управляющий признак задаётся старшим битом — значит, «технически» вначале я зареверсил всё правильно. Это проверка установлен ли старший бит, просто в коде она может выглядеть как signed сравнение.
Места, которые выглядят явно ручными
С другой стороны, в этой же процедуре много фрагментов, которые не похожи на скомпилированный паскаль. Самый характерный — умножение на 80. Функция постоянно вычисляет смещение в видеопамяти по координатам:offset = 80*Y + (X/8)80 здесь потому, что в EGA 640 пикселей по ширине → 640/8 = 80 байт на строку.
У Вадима Y*80 сделано без mul:
mov bx, [bp+Y_coord] ; Y в BX
mov ax, bx ; Y -> AX
mov cl, 6 ; CL = 6
shl ax, cl ; AX = Y*64
sub cl, 2 ; вычитаем 2 и теперь в CL = 4
shl bx, cl ; BX = Y*16
add bx, ax ; BX = Y*80
То есть вместо медленного mul используется разложение Y*64 + Y*16: два сдвига и сложение. Turbo Pascal обычно генерирует mul 80 (или, если очень постараться, два отдельных mov cl,6 / mov cl,4). А вот трюк с sub cl, 2 выглядит слишком рукописно — я пытался воспроизвести это компилятором и у меня не получилось.
Так что ощущение такое: либо процедура целиком ассемблерная, либо Pascal + несколько тяжёлых inline-вставок
У меня не получилось сделать настолько же быструю версию на чистом C, поэтому я оставил её ассемблерной вставкой через #pragma aux. Правда, пришлось чуть переписать под передачу аргументов в регистрах — не один в один как в оригинальном дизасме. Более подробный разбор по строкам я оставил прямо в коде.
P.S. И действительно, в редакторе Dr. Halo есть режим загрузки/сохранения отдельных участков изображения, но я до него не добрался в первый заход.
Проход девушки
Лучше один раз показать, чем рассказывать.
Механика очень красивая: длинный кусок фона всего прохода копируется в offscreen‑буфер, выделяется небольшая зона (чуть больше спрайта девушки), в нее сохраняется фрагмент фона под будущий спрайт, всё рисуется за экраном, а затем уже собранный кадр быстро переносится силами EGA в видимую часть и никакого мерцания.
Звуки которые издает девушка состоят из трех частей — сначала разгон ее появления (небольшое глиссандо вверх)
// разгон звука: step 20..100
for (;;) {
tp_sound((u16)(g_soundONOF_soundMultiplier * step));
pit_delay_ms(1);
tp_nosound();
pit_delay_ms((u16)((100u - step) / 5u));
if (step == 100u) break;
step = (u16)(step + 1u);
}
Потом идут сами шаги — короткие случайные писки:
// случайный писк на phase==0: (Random(100)+1000) * multiplier
if (phase == 0u) {
u16 f = (u16)((u16)(rand() % 100u) + 1000u);
f = (u16)(f * g_soundONOF_soundMultiplier);
tp_sound(f);
}
И наконец — уход с экрана: такое же глиссандо, только в обратную сторону.
Глобал g_soundONOF_soundMultiplier у меня исторически так и остался называться, потому что в любой процедуре, где он встречался, им умножали частоту звука. Уже под конец выяснилось, что чаще всего он просто 0 или 1: есть звук / нет звука.
Девушка проходит по всем совпадениям букв, и если совпадений больше не остаётся — отправляется на x = 1000 (причём Вадим уверяет девушку, что там есть буква):
if ( g_WordState.matchCount < currentMatchIndexLetterIndex) {
letter_X = 1000;
}
но как-только заходит за 583 пиксель ее жизнь(цикл) завершается.
Барабан

16 итераций, 32 фазы: координаты спрайтов считаются через sin() и cos():
x = center_X + round(cos(ang) * Radius_X);
y = center_Y + round(sin(ang) * Radius_Y);
Как игра вычисляет, что именно выпало на барабане. В Ghidra это выглядит пугающе:
g_wheelSector = (0x1c - (int)g_wheelAnimCounter / 2) % 0x10;
Но это просто преобразование счётчика анимации в номер сектора.
Секторов всего 16, а g_wheelAnimCounter крутится в два раза чаще — потому что есть промежуточные положения между секторами. Поэтому в формуле сразу видно деление на 2 и mod 16.
Остаётся «магическое» число 0×1C (28). Откуда оно берётся?
Если прикинуть геометрию, то при g_wheelAnimCounter = 0 спрайт оказывается в крайней правой точке окружности: sin(0) = 0, значит Y остаётся в центре, а cos(0) = 1, X уходит на радиус вправо. Нулевой сектор — «ПЛЮС». Стрелка указывает не на него, а на сектор, который находится от «ПЛЮСА» на 12 шагов по часовой стрелке. Поэтому базовая формула — это просто сдвиг на 12
sector = (K - counter/2) mod 16, где К=12
Но тут есть нюанс: counter/2 бегает от 0 до 16, и выражение легко уходит в отрицательные значения. Самый простой трюк — добавить ещё 16, чтобы не ломать модульную математику. Думаю, в исходнике логика была именно такая:
sector = (12 - counter/2 + 16) mod 16 , a 12+16 как раз наши магические 28 (0×1C)
Анимация с помощью копирования блоков
Все анимационные выезжания сделаны гениально просто — через быстрое копирование прямоугольных блоков прямо внутри EGA‑видеопамяти. На этой анимации красный прямоугольник — это SOURCE (откуда копируем), а зелёный — DESTINATION (куда копируем).

Технически это делается функцией ega_vram_move_blocks(src, dest, max_row_index, bytes_per_line): прямоугольник копируется построчно, ширина задаётся в байтах на строку, а высота в строках. Внутри EGA контроллер настраивается на Write Mode 1, чтение ES:[SI] загружает латчи, а запись в ES:[DI] размножает их по всем 4 плоскостям, поэтому прямоугольники переносятся «железом» очень быстро и без перерисовки пикселей по одному.
Игровой процесс
Тут пара интересных моментов — плитки как в Wolfenstein рисуются с прозрачностью, по факту рисуется только сама фактура. Но боковые стены — полновесные. Интересный момент — анимация появления рисуется для каждого персонажа, просто для третьего игрока (человека) это происходит за экраном.
Есть шесть компьютерных оппонентов, каждый запуск рандомно заполняется таблица тех, кто с вами будет играть.
Если вам казалось, что иногда компьютерные оппоненты жульничают, на самом деле они просто иногда не жульничают
if (useCheat == 0u) {
// случайная неиспользованная буква
for (;;) {
u16 li = tp_random(32);
if (g_used_letter[li] == 0u) return li;
}
}
А вот как вычисляется, когда можно:
if ((unopened * 2u) > len && unopened > 1u) {
useCheat = 0u;
} else {
u16 r = tp_random((u16)(g_roundWins + 2u));
useCheat = (r == 0u) ? 0u : 1u;
}
Если закрыто больше половины слова (и закрыто минимум две буквы) — чит запрещён. Иначе чит возможен; при двух открытых буквах он бывает только в словах длиной 3–4, причём чем ближе к финалу, тем выше вероятность, что противник будет мухлевать.
Шрифты
В POLE.FNT запакованно три шрифта,
Шрифт 8×8 — 2048 байт, в базовом и очень популярном для DOS формате FNT. Визуально он почти один в один похож на PU_VEGA_Pankov-8×8-AR.FNT: совпадение процентов на 99, отличается буквально мелочами — например, у Панькова ноль перечёркнут. Шрифт 8×14 тоже на 99% совпадает с VEGA_Pankov_8×14 из популярных наборов CP866-шрифтов. И тут остаётся вопрос к Вадиму: он сам подправлял нули и ещё пару символов, или просто взял шрифты из какой‑то подборки? Или наоборот — эти шрифты кто‑то когда‑то извлёк из игры и они пошли гулять по сборникам?
Со шрифтом 8×6 всё интереснее: я его нигде не смог найти. Интересно, что в финальной версии игры этот шрифт, похоже, вообще нигде не используется.
OPENPOLE
Через какое‑то время код стал не такой пугающий, и я решил написать статью — получилось восстановить все авторские процедуры, кроме графического модуля.
EGAVGA.BGI из комплекта Turbo Pascal плохо поддается статическому анализу — при загрузке, он генерит таблицу со смещениями своих функций, поэтому мне показалось проще в начале их просто переписать, благо там были только рисование линий и прямоугольников. Процедуры из CRT‑модуля я тоже решил не трогать, например DELAY (вы можете его помнить по Runtime Error 200) и KeyPressed, а реализовать «по мотивам»
Оригинальный код содержал большое количество глобальных переменных, логику я оставил такую‑же, некоторые функции ничего не принимают и не возвращают, а меняют game state через глобальные переменные.
Получалось все, далеко не с первого раза:
И та самая «пляская цветных пикселов», которую упоминал Вадим
Посмотреть полный код можно на Github — собирается все с помощью Open Watcom C 2, или под ДОСом с помощью Open Watcom 1.9. Я буду постепенно добивать жестко прописанные константы и повторяющиеся куски, кажется если отвязать игровую логику от DOS‑вызовов можно очень просто сделать SDL-порт.
В Releases добавил утилиту редактирования базы слов, как-будто это 1992 год:
Кстати, в других играх Вадима, в которых есть .LIB файл, графические ресурсы упакованы по тому же алгоритму:
Я слышал, что автор оригинальной игры, Вадим, бывает на Хабре, и если ему на глаза попадётся эта статья, хотелось бы задать пару вопросов:
-
Как именно паковался POLE.LIB? Откуда у каждого спрайта появился хвост от соседнего? И как вообще была устроена работа с Dr. Halo: был ли один общий холст со всеми ассетами, или картинки готовились по отдельности?
-
Для чего был нужен шрифт 8×6? Он выглядит как рабочий, но в финальной версии игры я так и не нашёл, где он используется.
-
Какие ещё программы/игры были сделаны на этой же кодовой базе (кроме «Кинга» и «Морского боя»)?
-
RLE-декодер: он полностью самописный на ассемблере или это Pascal-процедура с inline-вставками?
Спасибо, что дочитали до конца.
Автор: dmitrygerasimuk


