Henadzi Matuts, Software Engineer

GitHub Profile

Publicatons

Reuromancer

10 May 2018

by Henadzi Matuts

published on https://habr.com/post/357972/

Реверсим «Нейроманта». Часть 2: Рендерим шрифт


Привет, ты читаешь продолжение статьи, посвящённой реверс-инжинирингу «Нейроманта» - видеоигры, выпущенной компанией Interplay Productions в 1988 году по мотивам одноимённого романа Уильяма Гибсона. И, если ты не видел первую часть, то рекомендую начать с неё, там я рассказываю о своей мотивации и делюсь первыми результатами.

Реверсим «Нейроманта». Часть 1: Спрайты

А мы продолжаем буквально с того же места, на котором остановились в прошлый раз.


Исходя из предположения, что в .PIC хранятся такие же битмапы, можно попробовать применить к .PIC-файлам те же самые функции, которыми я распаковывал .IMH-ресурсы. Беру первый попавшийся .PIC (R1.PIC) и читаю его в буфер:

R1.PIC: 4934 байта

0x0000:     0008 010a 000f 020c 0102 020f 0600 0f0f
0x0010:     0004 060e 0000 0607 0309 010f 0a0d 0d0f
0x0020:     981d 0000 2002 0611 74a2 38c5 d003 e9cb
0x0030:     fb18 ac3b 8fbf 2713 459e 8c3f 3aa1 6ca7
            ...
0x1330:     6f8a c3f5 d9c7 53e5 f47c d945 51d9 c753
0x1340:     e5bf 1ebf 3f00

Отлично, этот файл начинается так же, как и любой .IMH:

  • 0x00:0x1F - 32 байта неизвестного назначения [в прошлой части я ошибочно полагал, что это палитра, но что это на самом деле - я до сих пор не выяснил];
  • word 0x20 - байтовый размер данных после декомпрессии [я понял это несколько позже, когда заметил, что размер выходных данных алгоритма декомпрессии совпадает с этим значением];
  • word 0x22 - ноль, отделяющий данные [вполне возможно, что это второй ворд длины, но здесь я не встречал таких больших значений].

Теперь осторожно, под отладчиком, пробуем это декодировать. Функция декомпрессии завершается успешно и возвращает длину разжатых данных:

R1.PIC_decompd: 7576 байт

0x0000:     e901 1130 1113 0111 3011 1301 1130 1113
0x0010:     0111 3011 1301 3130 0113 f801 3330 1333
            ...
0x1d80:     00ff 0816 00fe 8000 0108 0100 0180 ff08
0x1d90:     2b00 ff80 0288 0200

Но то, что я здесь вижу, несколько отличается от того, что я наблюдал после декомпрессии .IMH-ресурсов. Нет, это по-прежнему выглядит как битмап, закодированный тем же Run-Length алгоритмом, но без заголовка [struct rle_hdr_t из прошлой части], содержащего пиксельные размеры. Это нехорошо, ведь я использовал эти размеры для своевременной остановки алгоритма декодирования [функция decode_rle() из прошлой части] и построения .BMP-заголовка. Если предположить, что там закодировано только одно изображение, то для остановки алгоритма хватило бы и длины входных данных, а вот для сохранения картинки в .BMP уже никак не обойтись без линейных размеров.

Но будем решать проблемы по мере их поступления. Допустим, что в каждом .PIC действительно хранится по одному изображению, и их пиксельные размеры одинаковы. Переделываю функцию decode_rle() так, чтобы она считала количество обработанных входных байт, а не раскодированных выходных. Пропускаю через неё буфер r1_pic_decompd. Завершается успешно, вернув мне битмап размером 34048 байта (17024 * 2, с учётом того, что в одном байте записаны значения двух пикселей):

R1.PIC_decoded: 17024 байта

0x0000:     0111 3011 1301 1130 1113 0111 3011 1301
0x0010:     1130 1113 0131 3013 1301 3330 1333 0333
            ...
0x4270:     7777 7777 7777 7777 7708 8800 0088 8880

Ширина и высота по-проженему неизвестны, но я думаю, что имею дело с бэкграундами локаций, а значит, можно попытаться замерить их прямо в игре. Но всё ещё проще, ведь среди экспортированных .IMH-ресурсов нашлось изображение внутриигрового пользовательского интерфейса в натруральную величину (NEURO.IMH). Открываю изображение в Paint и делаю замеры:


Перемножение ширины и высоты даёт нужные 34048 байта - можно смело заворачивать этот и все остальные задники в .BMP (всего 55 штук):




На этой волне можно попробовать расколоть типы ресурсов неизвестного назначения - .BIH и .ANH. Действую по старой схеме: читаю рандомный файл; если похоже на то, что можно разжать - разжимаю; если похоже на RLE - декодирую. И, в общем-то, план сработал, но то, что внутри, так сказать… неоднозначно. Беру, к примеру, содержимое ресурса R1.BIH после декомпрессии:

R1.BIH_decompd: 2151 байт

0x0000:     00 00 00 00 00 00 fe 00 2a 00 00 00 00 00 fb 00  ......ю.*.....ы.
0x0010:     fd 00 fc 00 00 00 c7 00 46 5a 5a 5f 9b 5f 9f 73  э.ь...З.FZZ_._џs
0x0020:     46 73 5a 77 00 5f 02 73 01 00 2e 00 7a 00 01 02  FsZw._.s....z...
0x0030:     13 00 03 04 05 12 00 7f 2c 00 08 14 00 2e 13 00  ........,.......
0x0040:     0e 3d 00 01 00 0e 12 00 ff ff 0e 14 00 00 00 03  .=......яя......
0x0060:     01 11 16 db 00 01 1f 02 20 0e 14 00 00 00 0e 12  ...Ы.... .......
0x0070:     00 ff ff 12 06 10 00 ff cc ff 05 10 00 06 07 00  .яя....яМя......
0x0080:     01 08 04 04 00 01 07 04 bc ff 01 0b 13 00 0c 03  ........јя......
0x0090:     17 05 10 00 ff 05 00 04 f9 ff 05 10 00 0c 07 00  ....я...щя......
0x00A0:     01 0f 04 0a 00 05 10 00 0d 0b 00 01 10 13 00 0c  ................
0x00B0:     03 04 df ff 01 13 13 00 17 02 06 10 00 ff f8 ff  ..Яя.........яшя
0x00C0:     05 10 00 17 07 00 01 19 04 ed ff 13 00 1b 02 01  .........ня.....
0x00D0:     1a 06 10 00 ff fc ff 05 10 00 1b 07 00 01 1d 04  ....яья.........
0x00E0:     04 00 01 1e 13 00 ff 00 04 ff ff e8 13 00 81 c3  ......я..яяи..ЃГ
0x00F0:     04 00 8b 1f 8b 47 14 01 87 ca 00 83 97 cc 00 00  .....G...К.ѓ—М..
0x0100:     cb e8 01 00 90 5b 81 eb f4 00 c3 cb cb cb 59 6f  Ли..ђ[Ѓлф.ГЛЛЛYo
0x0110:     75 27 76 65 20 6a 75 73 74 20 73 70 65 6e 74 20  u've just spent 
0x0120:     74 68 65 20 6e 69 67 68 74 20 73 6c 65 65 70 69  the night sleepi
0x0130:     6e 67 20 66 61 63 65 2d 64 6f 77 6e 20 69 6e 20  ng face-down in 
0x0140:     61 20 70 6c 61 74 65 20 6f 66 20 73 79 6e 74 68  a plate of synth
0x0150:     2d 73 70 61 67 68 65 74 74 69 20 69 6e 20 61 20  -spaghetti in a 
0x0160:     62 61 72 20 63 61 6c 6c 65 64 20 74 68 65 20 43  bar called the C
0x0170:     68 61 74 73 75 62 6f 2e 20 41 66 74 65 72 20 72  hatsubo. After r
0x0180:     75 62 62 69 6e 67 20 74 68 65 20 73 61 75 63 65  ubbing the sauce
0x0190:     20 6f 75 74 20 6f 66 20 79 6f 75 72 20 65 79 65   out of your eye
0x01A0:     73 2c 20 79 6f 75 20 63 61 6e 20 73 65 65 20 43  s, you can see C
0x01B0:     68 69 62 61 20 73 6b 79 20 74 68 72 6f 75 67 68  hiba sky through
0x01C0:     20 74 68 65 20 77 69 6e 64 6f 77 2c 20 74 68 65   the window, the
0x01D0:     20 63 6f 6c 6f 72 20 6f 66 20 74 65 6c 65 76 69   color of televi
0x01E0:     73 69 6f 6e 20 74 75 6e 65 64 20 74 6f 20 61 20  sion tuned to a 
0x01F0:     64 65 61 64 20 63 68 61 6e 6e 65 6c 2e 0d 0d 41  dead channel...A
            ...
0x0840:     3f 00 0d 0d 52 61 74 7a 20 72 65 66 75 73 65 73  ?...Ratz refuses
0x0850:     20 74 6f 20 74 61 6b 65 20 79 6f 75 72 20 63 72   to take your cr
0x0860:     65 64 69 74 73 2e 00                             edits..

[Да, это именно то, о чём вы подумали.] Другие файлы с именами R%n.BIH имеют схожую структуру: некоторое количество байт сверху, за которыми следует полотно текста. Очевидно, я имею дело с внутриигровыми строками, разделёнными по локациям, к которым они относятся [R1 - первая, R2 - вторая и так далее. Стартовая локация, к примеру, подгружает задник из R1.PIC, а первый текст, который мы там увидим, это: You've just spent the night sleeping face-down in a plate of synth-spaghetti in a bar called the Chatsubo]. Байты из вехней части организуют некую управляющую структуру, но в отрыве от кода разобрать её не удастся, попробую заглянуть в другие .BIH файлы:

CORNERS.BIH_decompd: 128 байт

0x0000:     ff ff f0 00 ff f0 0f ff ff 0f ff ff f0 ff ff ff  яяр.яр.яя.яяряяя
0x0010:     f0 ff ff ff 0f ff ff ff 0f ff ff ff 0f ff ff ff  ряяя.яяя.яяя.яяя
0x0020:     00 00 ff ff 0f ff 00 ff 0f ff ff 0f 0f ff ff f0  ..яя.я.я.яя..яяр
0x0030:     0f ff ff f0 0f ff ff ff 0f ff ff ff 0f ff ff ff  .яяр.яяя.яяя.яяя
0x0040:     0f ff ff ff 0f ff ff ff 0f ff ff ff f0 ff ff ff  .яяя.яяя.яяяряяя
0x0050:     f0 ff ff ff ff 0f ff ff ff f0 0f ff ff ff f0 00  ряяяя.яяяр.яяяр.
0x0060:     ff ff ff f0 ff ff ff f0 ff ff ff f0 ff ff ff 0f  яяяряяяряяяряяя.
0x0070:     ff ff ff 0f ff ff f0 ff ff f0 0f ff 00 0f ff ff  яяя.яяряяр.я..яя
ROOMPOS.BIH_decompd: 1160 байт

0x0000:     68 8f 75 0d 4b 69 00 02 8e 63 02 17 71 74 29 02  hЏu.Ki..Ћc..qt).
0x0010:     0e 63 02 17 68 8f 75 15 72 69 24 02 8e 63 02 17  .c..hЏu.ri$.Ћc..
0x0020:     15 74 7a 02 16 63 02 17 68 8f 75 0d 2a 69 00 02  .tz..c..hЏu.*i..
0x0030:     8e 63 02 17 08 74 8c 02 0e 63 02 17 70 63 75 0d  Ћc...tЊ..c..pcu.
            ...
0x0470:     0e 6b 02 0f 6e 8f 75 0d 08 6f 8c 02 8e 69 02 11  .k..nЏu..oЊ.Ћi..
0x0480:     08 74 8c 02 0e 69 02 11                          .tЊ..i..

То, что я здесь вижу, совсем не похоже на паттерн, который я наблюдал в R%n.BIH. Они даже не похожи друг на друга! Содержимое CORNERS.BIH напоминает битмап, а ROOMPOS.BIH, судя по названию, может иметь отношение к позиционированию объектов на локации, но его содержимое - непонятно. Кроме этих, ещё есть: COPEN%n.BIH, DB%nBIH, FIJU0.BIH, HITACHI0.BIH и много других, но они похожи на R%n.BIH хотя бы тем, что содержат в себе читабельный текст, а вот заголовки местами различаются. Оставлю это на потом и посмотрю, что там с .ANH.

Здесь ситуация лучше. Все .ANH озаглавены как R%n.ANH, значит, они так или иначе относятся к локациям. Их не много: если для всех n присутствуют R%n.PIC и R%n.BIH, то соответсвующий R%n.ANH встречается лишь для некоторых n. Они сжаты всё тем же алгоритмом, посмотрим, что внутри:

R1.ANH_decomp: 1100 байт

0x0000  04 00 4e 01 0f 00 17 00 00 00 0c 00 00 00 02 00  ..N.............
0x0010  0e 00 03 00 0e 00 01 00 0e 00 02 00 0e 00 02 00  ................
0x0020  0e 00 03 00 0e 00 01 00 0e 00 02 00 0e 00 0e 00  ................
0x0030  00 00 15 00 19 00 03 00 72 00 11 00 72 00 03 00  ........r...r...
0x0040  ba 00 23 25 03 17 fd 87 00 87 3e 00 ff 44 01 00  є.#%..э...>.яD..
        ...
0x0160  73 00 04 00 73 00 03 00 73 00 14 00 73 00 04 00  s...s...s...s...
0x0170  00 00 03 00 00 00 03 00 73 00 04 00 73 00 04 00  ........s...s...
0x0180  00 00 03 00 00 00 03 00 73 00 04 00 73 00 0e 00  ........s...s...
0x0190  00 00 17 00 00 00 03 00 73 00 04 00 73 00 04 00  ........s...s...
0x01A0  00 00 03 00 00 00 03 00 73 00 04 00 73 00 1b 30  ........s...s..0
0x01B0  0b 10 01 00 ff 03 09 00 ff 30 08 00 fc 13 30 08  ....я...я0..ь.0.
        ...
0x0430  30 02 05 fe 08 88 02 08 fb 88 08 00 08 00 71 36  0..ю.€..ы€....q6
0x0440  02 05 fe 00 80 02 08 ff 88 03 08 00              ..ю.Ђ..я€...

Увы, здесь мало полезного, ну и ладно, буду разбираться по ходу дела. Пока можно заняться другими вещами. [Как-то раз, запустив в игре стартовую локацию, я зметил, что задник этой локации анимирован. И это интересно, учитывая, что в .PIC лежат статичные изображения. Пока не проверено, но я думаю, что именно в .ANH содержатся эти анимации.]


Некоторое время потратил на написание простенькой оконной утилитки - просмотрщика ресурсов. В процессе пришлось переехать с 2017-й студии на 2015-ю в связи с тем, что в первой сломано MFC. В том же солюшене я создал проект библиотеки LibNeuroRoutines, в которую постепенно буду добавлять реверснутые процедуры из оригинальной игры. Первым же делом туда попали функции декомпрессии и декодирования ресурсов. Удобно держать эти вещи отдельно.



Перечитав заметку о реверс-инжиниринге на вики проекта ScummVM, решил пореверсить местный рендеринг текста. [Сперва я надеялся просто извлечь шрифты, но, в итоге, это позволило мне добиться гораздо большего.] Сделать первые шаги в этом направлении было легко - изучая функцию main в дизассемблированном листинге, я обнаружил функцию, принимающую на вход адрес строки, отображаемой в главном меню игры - “New/Load”:

    ...
    sub     ax, ax
    push    ax
    mov     ax, 1
    push    ax
    mov     ax, 5098h       ; "New/Load"
    push    ax
    call    sub_13C6E       ; sub_13C6E("New/Load", 1, 0)
    ...

Выполнив функцию sub_13C6E под отладчиком, убеждаюсь в том, что именно она выводит на экран переданную строку:


И тут можно было бы начать её трассировать, но есть нюанс, она не принимает ничего похожего на координаты. При этом текст отрисовывается точно в центре рамки. Может, она также отрисовывается в этой функции? Но при чём здесь аргументы 1 и 0? Тогда я обратил внимание на вызов другой функции, сразу над sub_13C6E:

    ...
    sub     ax, ax
    push    ax
    push    ax
    mov     ax, 1
    push    ax
    mov     ax, 0Ah
    push    ax
    mov     ax, 14h
    push    ax
    mov     ax, 5
    push    ax
    mov     ax, 6
    push    ax
    call    sub_13A9E       ; sub_13A9E(6, 5, 20, 10, 1, 0, 0)
    add     sp, 0Eh
    sub     ax, ax
    push    ax
    mov     ax, 1
    push    ax
    mov     ax, 5098h       ; "New/Load"
    push    ax
    call    draw_string     ; draw_string("New/Load", 1, 0)
    ...

Потрассировав этот код я увидел, что функция sub_13A9E рендерит рамку, а функция draw_string - текст в ней. Вероятно, что эти вызовы связаны через какие-то глобальные переменные. В любом случае, начинать разбираться лучше с sub_13A9E, тем более, что она принимает интересный набор аргументов.

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

Вот, что там происходит: во первых, в сегменте данных заполняется некая структура (в комментариях я указал очерёдность выполнения и конкретные операции, вычисляющие записываемое значение):

; sub_13A9E(6, 5, 20, 10, 1, 0, 0)
;          (a, b,  c,  d, e, f, g)

    .dseg
                ...
word[0x65FA]:   0x20    ; 10: word[0x6602] - 8 = 32
word[0x65FC]:   0x98    ; 11: word[0x6604] - 8 = 152
word[0x65FE]:   0x7F    ; 12: word[0x6606] + 8 = 127
word[0x6600]:   0xAF    ; 13: word[0x6608] + 8 = 175
word[0x6602]:   0x28    ;  2: b << 3 = 40
word[0x6604]:   0xA0    ;  3: c << 3 = 160
word[0x6606]:   0x77    ;  4: (d << 3) + word[0x6602] - 1 = 119 
word[0x6608]:   0xA7    ;  5: (e << 3) + word[0x6604] - 1 = 167
word[0x660A]:   0x28    ;  6: word[0x6602] = 40
word[0x660C]:   0xA0    ;  7: word[0x6604] = 160
word[0x660E]:   0x77    ;  8: word[0x6606] = 119
word[0x6610]:   0xA7    ;  9: word[0x6608] = 167
word[0x6612]:   0x06    ;  1: a = 6
word[0x6614]:   0x00    ; 14: 0
                ...
word[0x66D6]:   0x30    ; 17: (d << 2) + 8 = 48
word[0x66D8]:   0x02    ; 15: 2
word[0x66DA]:   0x22FB  ; 16: seg11

Нельзя не заметить, что в вордах по адресам 0x65FA и 0x65FC расположились координаты левого (32) верхнего (152) угла рамки, соответсвенно. Если пойти дальше и вычесть эти значения из тех, что записаны в 0x65FE и 0x6600, то мы получим 95 и 23. Добавив по единице, выйдет аккурат ширина (96) и высота (24) рамки. Весьма занятно. Теперь, если проименовать аргументы функции draw_frame (sub_13A9E) от a до e [последние два аргумента функцией не используются, вероятно они просто добавлены компилятором], то можно вывести следующие выражения:

left   = b * 8 - 8 = (b - 1) * 8
top    = c * 8 - 8 = (c - 1) * 8
width  = (d * 8) + (b * 8) + 8 - ((b * 8) - 8) = (d * 8) + 16 = (d + 2) * 8
height = (e * 8) + (c * 8) + 8 - ((c * 8) - 8) = (e * 8) + 16 = (e + 2) * 8

После заполнения рассмотренной структуры, по адресу [0x66DA]:[0x66D8] (22FB:0002 - сегмент:смещение) строится битмап-изображение рамки вычисленных размеров, которое затем построчно переносится в VGA-память, начиная с адреса (в VGA-буфере), соответсвующего вычисленной координате левого верхнего угла (152 * 320 + 32 = 0xBE20, A000:BE20):

              SEG11:
22FB:0002     0000 0000 3000 1800 
22FB:000A     000000000000000000000000...000000000000000000000000
22FB:003A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:006A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:009A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:00CA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:00FA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:012A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:015A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:018A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:01BA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:01EA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:021A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:024A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:027A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:02AA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:02DA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:030A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:033A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:036A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:039A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:03CA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:03FA     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:042A     0FFFFFFFFFFFFFFFFFFFFFFF...FFFFFFFFFFFFFFFFFFFFFFF0
22FB:045A     000000000000000000000000...000000000000000000000000
              VGA:
A000:BE20     000000000000000000000000...000000000000000000000000... 
              + 0x140 (320)
A000:BF60     000F0F0F0F0F0F0F0F0F0F0F...0F0F0F0F0F0F0F0F0F0F0F00...
              + 0x140 (320)
A000:C0A0     000F0F0F0F0F0F0F0F0F0F0F...0F0F0F0F0F0F0F0F0F0F0F00...
              + 0x1000 (320 * 21)
A000:DAE0     000000000000000000000000...000000000000000000000000

Можно двигаться дальше, к функции draw_string.


Углубляясь в draw_string, зацепился за следующий участок кода:

loc_1DB47:
    mov     bx, dx
    inc     dx
    sub     ax, ax
    mov     al, ss:[bx]
    shl     ax, 1
    jz      short loc_1DB97
    shl     ax, 1
    shl     ax, 1
    add     ax, 0FA6Eh
    mov     si, ax
    mov     cx, 8
    mov     bx, 4BA1h

loc_1DB62:
    lodsb
    mov     ah, al
    sub     al, al
    rol     ax, 1
    rol     ax, 1
    xlat    byte ptr ss:[bx]
    stosb
    sub     al, al
    rol     ax, 1
    rol     ax, 1
    xlat    byte ptr ss:[bx]
    stosb
    sub     al, al
    rol     ax, 1
    rol     ax, 1
    xlat    byte ptr ss:[bx]
    stosb
    sub     al, al
    rol     ax, 1
    rol     ax, 1
    xlat    byte ptr ss:[bx]
    stosb
    add     di, ss:4BA5h
    loop    loc_1DB62
    sub     di, ss:4BA7h
    jmp     short loc_1DB47

loc_1DB97:
    ...

Именно “зацепился”, из-за большого скопления инструкций lodsb, stosb, xlat,[для себя я сделал вывод, что именно эти инструкции делают большую часть полезной работы, в конце-концов, всё это программирование сводится к тривиальному перемещению данных из одной области памяти в другую, не так ли?] и начал его трассировать. Иду от метки loc_1DB47:

  • в регистре dx находится адрес исходной строки “New/Load”, сохраняем его в bx и инкрементируем;
  • зануляем ax (sub ax, ax);
  • помещаем в al код символа исходной строки, на которую указывает bx (mov al, ss:[bx], al = 0x4E ('N'));
  • сдвигаем код символа на один бит влево и, если результат равен нулю, прыгаем на loc_1DB97 (проверка на нуль-терминатор);
  • двигаем ax ещё на два бита влево и прибавляем 0xFA6E (вместе с предыдущим сдвигом равносильно: ax = ax * 8 + 0xFA6E, ax = 0xFCDE);
  • сохраняем ax в si (а значит в ax - адрес);
  • сохраняем в cx - 8, а в bx - 0x4BA1 (вероятно, тоже адрес).

Учитывая дальнейшую инструкцию loop loc_1DB62, здесь был заготовлен цикл на 8 (cx) итераций, разбираюсь с ним:

  • инструкцией lodsb в al загружается значение байта по адресу ds:si, si после этого инкрементируется. В моём случае ds:si = F000:FCDE, в al загружается значение 0xC6, si становится равным 0xFCDF;
  • младший байт ax записывается в старший (mov ah, al, ax = 0xC6C6);
  • al зануляется, ax циклически сдвигается на два бита влево (ax = 0x1803);
  • инструкция xlat использует al как индекс в байтовом массиве по адресу ds:bx и сохраняет записанное там значение в al, при этом сегмент ds может быть переопределён. Здесь идёт обращение к массиву по адресу ss:bx (47EA:4BA1 = {0xFF, 0xF0, 0x0F, 0x00}), и в результате выполнения инструкции: al = ss:bx[al] = 0x00;
  • иструкцией stosb значение из al записывается по адресу es:di, di после этого инкрементируется. У меня es:di = 22FB:0192, и это важно, ведь именно там расположен битмап рамки, в которой будет отображён текст. После выполнения инструкции по адресу 22FB:0192 будет записано значение 0x00 (al), а di станет равным 0x0193.

Последние три шага повторяются трижды, затем к di прибавляется ворд, сохранённый по адресу ss:4BA5 (0x2C), и инструкция loop ещё 7 раз прогоняет код от метки loc_1DB62 до самой себя. После завершения цикла к di прибавляется ворд, сохранённый по адресу ss:4BA7 (0x17C), и программа прыгает назад, на метку loc_1DB47.

Таким вот замысловатым способом здесь по очереди обрабатываются все символы исходной строки. В результате, в рамке формируется заданный текст [подсветите нули, или уменьшите страницу]:

FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
FFF00FFF00FFFFFFFFFFFFFFFFFFFFFF00F0000FFFFFFFFFFFFFFFFFFFFFFF000FFF
FFF000FF00FFFFFFFFFFFFFFFFFFFFF00FFF00FFFFFFFFFFFFFFFFFFFFFFFFF00FFF
FFF0000F00FF0000FFF00FFF00FFFF00FFFF00FFFFFF0000FFFF0000FFFFFFF00FFF
FFF00F0000F00FF00FF00F0F00FFF00FFFFF00FFFFF00FF00FFFFFF00FFF00000FFF
FFF00FF000F000000FF0000000FF00FFFFFF00FFF0F00FF00FFF00000FF00FF00FFF
FFF00FFF00F00FFFFFF0000000F00FFFFFFF00FF00F00FF00FF00FF00FF00FF00FFF
FFF00FFF00FF0000FFFF00F00FF0FFFFFFF0000000FF0000FFFF000F00FF000F00FF
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF

Здесь, в приципе, понятно всё, кроме магического числа 0xFA6E, прибавив к которому код символа, умноженный на восемь, мы получили адрес в сегменте F000. Это явно находится за пределами памяти самой программы, а значит нужно гуглить схему памяти MS-DOS. И нагуглил, конкретно, там интересует вот что: FFA6:E ROM graphics character table. Учитывая сегментную адресацию, запись FFA6:E эквивалентна F000:FA6E, а по этому адресу “зашит” шрифт для отображения символов в родной досовской кодировке CP437. На каждый символ там отведено по 8 байт. Вот как это работает на примере буквы ‘N’:

1. берём код символа: 0x4E
2. умножаем на восемь: 0x4E * 8 = 0x270
3. смещаемся от начала шрифта на полученное значение: 0xFA6E + 0x270 = 0xFCDE
4. читаем восемь байт по этому адресу: C6 E6 F6 DE CE C6 C6 00
5. записывем прочитанные байты в столбик, в двоичной системе:
    C6: 11000110
    E6: 11100110 
    F6: 11110110 
    DE: 11011110  (здесь единицами нарисована буква 'N')
    CE: 11001110 
    C6: 11000110 
    C6: 11000110 
    00: 00000000

А вот этот код:

    sub     al, al
    rol     ax, 1
    rol     ax, 1
    xlat    byte ptr ss:[bx]

можно проиллюстрировать следующим образом:

Всё достоточно просто для того, чтобы реализовать эту логику самостоятельно и вывести шрифт на экран. [Чем я и занялся, оставив позиционирование текста на потом.]


Под это дело, в одном солюшене с ResourceBrowser и LibNeuroRoutines, создал проект NeuromancerWin32. В качестве мультимедиа-бэкенда решил использовать SFML. До этого у меня уже был очень позитивный опыт работы с SDL2, но здесь захотелось попробовать что-нибудь новое. SDL2 мне нравилась, в том числе, тем, что она реализована на C и, соответсвенно, из коробки имеет C-совместимый интерфейс. SFML, в свою очередь, написана на C++. Чтобы не усложнять, свой проект я буду вести на C, и, к счастью, SFML имеет официальный С-биндинг - CSFML.

Уже со старта CSFML порадовала простотой использования. Вот, например, всё, что нужно для создания окна:

sfEvent event;
sfVideoMode mode = { 320, 200, 32 };
sfRenderWindow *window =
    sfRenderWindow_create(mode, "NeuromancerWin32", sfClose, NULL);

while (sfRenderWindow_isOpen(window))
{
    while (sfRenderWindow_pollEvent(window, &event))
    {
        if (event.type == sfEvtClosed)
        {
            sfRenderWindow_close(window);
        }
    }

    sfRenderWindow_clear(window, sfBlack);
    sfRenderWindow_display(window)
}

sfRenderWindow_destroy(window);

Оригинальная игра для вывода графики на экран использует 256-цветный режим VGA (mode 0x13). В этом режиме рисование сводится к записи значений цвета пикселей в VGA-память (A000:0000, 320 * 200 байт). Один байт, при этом, соответсвует одному пикселю. Я решил действовать схожим образом, но с поправкой на то, что будет использоваться 32-битный видеорежим. Таким образом, я завёл себе буфер uint8_t *g_vga[320*200*4] (каждый пиксель в 32-битном режиме представлен 4-мя компонентами - RGBA), который будет служить мне аналогом VGA-памяти из оригинала. В этом буфере я буду рисовать, а затем, при помощи SFML, выводить содержимое на экран:

sfRenderWindow *g_ window = NULL;
sfTexture *g_texture = NULL;

uint8_t *g_vga[320*200*4];
...

    g_texture = sfTexture_create(320, 200);

...

void render()
{
    sfTexture_updateFromPixels(g_texture, g_vga, 320, 200, 0, 0);

    sfSprite *sprite = sfSprite_create();
    sfSprite_setTexture(sprite, g_texture, 1);

    sfRenderWindow_clear(g_window, sfBlack);
    sfRenderWindow_drawSprite(g_window, sprite, NULL);
    sfRenderWindow_display(g_window);

    sfSprite_destroy(sprite);
}

...

    sfTexture_destroy(g_texture);

Тестируем:

for (int i = 0; i < 320 * 240 * 4; i++)
{
    g_vga[i] = rand() % 256;
}

while (sfRenderWindow_isOpen(g_window))
{
    ...
    render();
}

То, что нужно. После этого написал функцию, которая по заднным координатам мапает битмап заданных размеров на VGA-буфер: void draw_to_vga(int32_t l, int32_t t, uint32_t w, uint32_t h, uint8_t *pixels) [логика переноса битмапа на VGA, на мой взгляд, тривиальна]:

uint8_t red_rectangle[96*48];
memset(red_rectangle, 0x44, 96*48);

draw_to_vga(10, 10, 96, 48, red_rectangle);

...

render();

Реализовал процедуру, формирующую битмап (8x8) с заданным символом:

static uint8_t cp437_font[1024] = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x00 NULL
    ...
    0x30, 0x78, 0xCC, 0xCC, 0xFC, 0xCC, 0xCC, 0x00, // 0x41 A
    0xFC, 0x66, 0x66, 0x7C, 0x66, 0x66, 0xFC, 0x00, // 0x42 B
    0x3C, 0x66, 0xC0, 0xC0, 0xC0, 0x66, 0x3C, 0x00, // 0x43 C
    ...
};
static uint8_t cp437_font_pixels[4] = {
    0xFF, 0xF0, 0x0F, 0x00
};
static uint8_t cp437_font_mask[4] = {
    0xC0, 0x30, 0x0C, 0x03
};

void build_character(char c, uint8_t *dst)
{
    uint32_t index = c * 8;

    /* unprintable */
    if (c < 0x20 || c > 0x7E)
    {
        return;
    }
    memset(dst, 0, 32);

    for (int i = 0; i < 8; i++)
    {
        uint8_t al = cp437_font[index++];

        for (int j = 0; j < 4; j++)
        {
            dst[i * 4 + j] =
                cp437_font_pixels[(al & cp437_font_mask[j]) >> (6 - j * 2)];
        }
    }
}

Посимвольно вывел алфавит на экран:

memset(g_vga, 0xFF, 320 * 200 * 4);

uint8_t character_bm[32];
int left = 2, top = 2;

for (char c = 0x20; c <= 0x7E; c++)
{
    if (left + 8 >= 320)
    {
        left = 2;
        top += 10;
    }

    build_character(c, character_bm);
    draw_to_vga(left, top, 8, 8, character_bm);

    left += 8;
}

render();

На основе build_character сделал функцию build_string(char *s, uint32_t w, uint32_t h, uint8_t *dst), которая печатает строку целиком:

memset(g_vga, 0xFF, 320 * 200 * 4);

uint8_t string[320 * 20];

build_string("The future is here.\n"
    "It’s just not widely distributed yet.", 320, 20, dst);
draw_to_vga(20, 88, 320, 20, string);

render();


Изначально я планировал написать больше. На самом деле, на момент написания этого текста у меня сделано почти работающее главное меню и плюс-минус реализована система рендеринга из оригинала. Но эта часть получилась и без того больше предыдущей, и её пора заканчивать. Так что уже в следующей я добью меню и рендеринг и выкачу это всё в один присест.

Реверсим «Нейроманта». Часть 3: Добили рендеринг, делаем игру