В большинстве случаев слово «очередь» не вызывает положительных эмоций, тем более в сочетании со словом «увеличить». Но если вы любите играть с миллионами единиц ресурсов к началу игры, чтобы на десятой минуте бросить в бой тысячи солдат, то стандартного заказа по пять боевых единиц единиц с помощью клавиши 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). Расставляем по точке останова на каждый из них, запускаем отладчик и снимаем лишние, пока не доберёмся до нужной процедуры. Если быть точным, то процедур две: Одна для заказа боевых единиц и одна для отмены. Но так как они различаются только адресом вызываемой в них функции, то далее мы будем рассматривать их как одну процедуру.
Вот что нас там ждёт:
Ну конечно. Что делает компилятор, когда видит маленький цикл с небольшим, но постоянным количеством выполнений? Правильно, разворачивает его в повторяющуюся последовательность инструкций тела цикла. Надежда на однобайтовый патч непринуждённо помахала ручкой.
Патч первый или зацикливаемся на ассемблере
Для цикла нам потребуются счётчик, инкрементирование, сравнение и условный прыжок. Тело цикла оставим без изменений за исключением правки смещения вызова функции соответственно новому адресу инструкции 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
Несмотря на то, что этот патч приводит к желаемому результату, он имеет один большой недостаток: Не вмешиваясь в логику игры можно переопределить размер очереди производства, создаваемой с помощью клавиши-модификатора 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
Послесловие
На этом месте можно сказать, что задача выполнена и идти умывать руки. Или же можно написать миниатюрный патчер, позволяющий игрокам самим устанавливать размер очереди для каждой из клавиш-модификаторов… Но об этом в следующей статье.
Ссылки
- Список машинных кодов Intel 80x86
- Список операционных кодов x86
- Удобный онлайн ассемблер и дизассемблер
- Соглашение о вызове функций в GCC
- Регистры в ассемблере
Автор: Ereb