Реверс-инжиниринг «Казаков», часть вторая: Увеличение очереди

в 16:51, , рубрики: asm, cossacks, game development, казаки, реверс-инжиниринг

Реверс-инжиниринг «Казаков», часть вторая: Увеличение очереди - 1

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

Вступление

После публикации предыдущей статьи и возникшего интереса со стороны сообщества LCN меня спросили, смогу ли я увеличить объём очереди заказа боевых единиц с пяти до 20 или до 50. «Почему бы и нет», подумал я, «да и вообще – если повезёт, то нужно будет только один байт с 0x05 на 0x14 заменить, и всё».

Если бы я знал тогда, чем это обернётся… Но я не знал, так что поехали!

С чего начнём?

Естественно, с поиска нужного участка машинного кода. Тут есть выбор: Можно искать место, в котором обрабатываются нажатия кнопок мыши и попытаться отследить код до ветки, отвечающей за заказ боевых единиц. Или же можно обойти все места, в которых проверяется состояние клавиши Shift. Мне второй вариант показался более перспективным. Запускаем всеми любимую для таких дел программу и смотрим в список импортированных функций.

Хм, GetKeyState звучит многообещающе. Что там у нас по перекрёстным ссылкам? Сто восемнадцать вызовов? Многовато, нужно отсеять те вызовы, в которых не проверяется клавиша Shift. Функция GetKeyState принимает только один параметр, а именно код клавиши, который для Shift равняется 0x10. В моём dmcr.exe это соответствует такому куску машинного кода:

push	10h				6A 10
call	GetKeyState			FF 15 EC C1 5C 00

Поиск этой последовательности байт выдал 38 адресов, на которых вызывается GetKeyState(VK_SHIFT). Расставляем по точке останова на каждый из них, запускаем отладчик и снимаем лишние, пока не доберёмся до нужной процедуры. Если быть точным, то процедур две: Одна для заказа боевых единиц и одна для отмены. Но так как они различаются только адресом вызываемой в них функции, то далее мы будем рассматривать их как одну процедуру.

Вот что нас там ждёт:

Реверс-инжиниринг «Казаков», часть вторая: Увеличение очереди - 2

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

Патч первый или зацикливаемся на ассемблере

Для цикла нам потребуются счётчик, инкрементирование, сравнение и условный прыжок. Тело цикла оставим без изменений за исключением правки смещения вызова функции соответственно новому адресу инструкции call.

После некоторого курения мануалов изучения документации был создан следующий набросок машинного кода:

; Сохраняем регистр перед изменениями
push	cx				66 51
; Обнуляем регистр
xor	cx, cx				66 31 C9

; Тело цикла
; Сохраняем регистр-счётчик перед каждым исполнением цикла
push	cx				66 51
; Отрезок кода, отвечающий за заказ боевой единицы
mov	dx, word ptr [ebp+arg_0]	66 8B 55 F0
push	edx				52
xor	eax, eax			33 C0
mov	al, byte_10FC290		A0 90 C2 0F 01
xor	eax, 85h			35 85 00 00 00
push	eax				50
call	sub_4FD01E			E8 .. .. .. ..
add	esp, 8				83 C4 08

; Восстанавливаем, инкрементируем и сравниваем регистр-счётчик
pop	cx				66 59
inc	cx				66 41
cmp	cx, 14h				66 83 F9 14
; Прыжок в начало цикла, если счётчик меньше 20
jl					7C DA

; Восстанавливаем регистр после окончания цикла
pop	cx				66 59

Небольшое отступление про cx и 66h

Регистр cx считается «регистром цикла» и используется вместе с инструкцией loop. Хоть я и решил использовать вместо loop обычную комбинацию из inc, cmp и jl, в качестве регистра-счётчика я всё равно оставил cx. Однако при подборе машинных команд у меня возникла проблема: Что бы я не делал, в итоге всегда выходили операции с регистром ecx вместо его младшего брата. Пришлось прибегнуть к помощи онлайн ассемблера. Какого же было моё удивление, когда в ответ на мой набросок он выдал по большей части те же самые операционные коды, но с префиксом 0x66. В документации операционный код 66h описывается как «Operand-size override prefix. Reserved and may result in unpredictable behavior». При таком описании неудивительно, что он не бросился мне в глаза раньше. Префикс 0x66 заставляет машинные коды, оперирующие 32-битными регистрами переключиться на их 16-битных собратьев и наоборот.

Несмотря на то, что этот патч приводит к желаемому результату, он имеет один большой недостаток: Не вмешиваясь в логику игры можно переопределить размер очереди производства, создаваемой с помощью клавиши-модификатора Shift, но не более того. Патчить очередь перед каждой игрой в зависимости от условий игры не очень привлекательная перспектива, поэтому в сообществе довольно быстро было озвучено желание иметь разные модификаторы очереди на клавишах Shift, Alt, и ~. Что ж, вызов принят!

Патч второй или «программа максимум»

Заменив последовательность из пяти повторяющихся блоков одним циклом, мы освободили приличное количество байт. Но как в образовавшееся пространство встроить проверку нескольких клавиш и регулировку цикла в зависимости от результата? Самое простое на мой взгляд решение это последовательные вызовы GetKeyState, чередующиеся с присвоениями регистру соответствующего значения, с которым будет сравниваться счётчик в цикле. Если вызов GetKeyState показывает, что клавиша не нажата, то инструкция присвоения перепрыгивается. Таким образом у нас вместо развилок в зависимости от состояния клавиш будет ряд последовательных проверок и присвоений, завершающийся одним циклом:

; В том случае, если ни одна клавиша не нажата, цикл выполнится один раз
mov	ebx, 01h			BB 01 00 00 00

; Проверка клавиши
push	10h				6A 10
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
; В случае отрицательного результата перепрыгиваем mov, оставляя предыдущее значение в регистре
jz					74 05
mov	ebx, 05h			BB 05 00 00 00

; Следующая клавиша
push	12h				6A 12
call	GetKeyState			FF 15 EC C1 5C 00
[...]

В этот раз я решил использовать регистр ebx для сохранения количества исполнений цикла и регистр esi как счётчик цикла. Для этого есть две причины. Следуя соглашению о вызове функций эти регистры являются «постоянными», т.е. если в теле функции в них вносятся изменения, то функция обязана сохранить их значения в стеке и восстановить их перед завершением. Это освобождает меня от необходимости самому выполнять push и pop перед каждым исполнением цикла. Вторая причина в том, что в отличии от регистра cx мне больше не требуется префикс 0x66, а это экономия одного байта на каждой операции с регистрами кроме mov.

В итоге мы имеем клавиши-модификаторы Shift, Alt, TAB, F1 и F2. От клавиши ~ пришлось отказаться, так как на разных раскладках ей соответствуют разные идентификаторы, например VK_OEM_3 и VK_OEM_5.

Финальный код патча

; Сохраняем регистр перед изменением
push	ebx				53

; В том случае, если ни одна клавиша не нажата, цикл выполнится один раз
mov	ebx, 01h			BB 01 00 00 00

; Shift: 5 боевых единиц
push	10h				6A 10
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
jz					74 05
mov	ebx, 05h			BB 05 00 00 00

; Alt: 20 боевых единиц
push	12h				6A 12
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
jz					74 05
mov	ebx, 14h			BB 14 00 00 00

; TAB: 50 боевых единиц
push	09h				6A 09
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
jz					74 05
mov	ebx, 32h			BB 32 00 00 00

; F1: 15 боевых единиц
push	70h				6A 70
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
jz					74 05
mov	ebx, 0Fh			BB 0F 00 00 00

; F2: 36 боевых единиц
push	71h				6A 71
call	GetKeyState			FF 15 EC C1 5C 00
movsx	ecx, ax				0F BF C8
and	ecx, 8000h			81 E1 00 80 00 00
test	ecx, ecx			85 C9
jz					74 05
mov	ebx, 24h			BB 24 00 00 00

; Сохраняем и обнуляем регистр-счётчик цикла
push	esi				56
xor	esi, esi			31 F6

; Тело цикла
mov	dx, word ptr [ebp+arg_0]	66 8B 55 F0
push	edx				52
xor	eax, eax			33 C0
mov	al, byte_10FC290		A0 90 C2 0F 01
xor	eax, 85h			35 85 00 00 00
push	eax				50
call	sub_4FD01E			E8 .. .. .. ..
add	esp, 8				83 C4 08

; Инкрементируем, сравниваем, прыгаем в начало цикла
inc	esi				46
cmp	esi, ebx			39 DE
jl					7C E1

; Восстанавливаем регистры
pop	esi				5E
pop	ebx				5B

Послесловие

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

Ссылки

Автор: Ereb

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js