В большинстве случаев слово «очередь» не вызывает положительных эмоций, тем более в сочетании со словом «увеличить». Но если вы любите играть с миллионами единиц ресурсов к началу игры, чтобы на десятой минуте бросить в бой тысячи солдат, то стандартного заказа по пять боевых единиц единиц с помощью клавиши 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

Небольшое отступление про 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

Послесловие


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

Ссылки

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


  1. Ereb
    10.03.2016 19:53
    +3

    Патчер, о котором говорится в конце статьи, доступен по ссылке:
    https://yadi.sk/d/z6rTlJfRpvRn8


  1. VBKesha
    10.03.2016 20:14
    +5

    В казаков не играю а вот реверс-инжиниринг тема интересная. Спасибо за статью.


  1. semenyakinVS
    10.03.2016 23:13
    +2

    А разве в "Казаках" не было возможности зациклить производство на бесконечность? Или эта фича уже в "Завоевании Америки" появилась?


    1. Ereb
      11.03.2016 00:23
      +3

      Была, но в некоторых случаях она не так удобна, как кажется. Например, цена наёмных драгунов в дипломатическом центре растёт с каждым драгуном, которого вы уже наняли. Т.е. намного дешевле сразу заказать 500 единиц, чем ставить на бесконечность. Или если вы хотите не отвлекаясь заказать подряд ровно по взводу пикинёров и мушкетёров в одной казарме или чередовать производство каждые 50 единиц.


      1. semenyakinVS
        11.03.2016 00:31

        Хм, ну тоже правда. Тогда было бы ещё круче, кстати, если бы отряды сами формировались. В духе Supreme Commander.


      1. dougrinch
        11.03.2016 14:32

        Мне нравится как в planetary annihilation: titans (не знаю как в первой части, но скорее всего также) сделано. Там можно задать очередь вида: 2 танка одного типа, 1 второго, 3 третьего, повторить.


  1. MacIn
    11.03.2016 00:09
    +7

    Вторая причина в том, что в отличии от регистра cx мне больше не требуется префикс 0x66, а это экономия одного байта на каждой операции с регистрами кроме mov.

    Собственно, а назачем было в 32битном режиме изначально использовать cx вместо ecx?

    я и решил использовать вместо loop обычную комбинацию из inc, cmp и jl, в качестве регистра-счётчика я всё равно оставил cx

    Зачем inc и cmp, если можно dec и jnz?

    То же самое с ebx и esi: можно было использовать один регистр.


    1. Ereb
      11.03.2016 00:37
      +3

      Да, без ошибок не обошлось. По поводу регистра cx: к нему я пришёл, когда искал информацию про циклы в асм. В конце концов я от него отказался, как и от использования loop. Ну а на счёт dec и jnz вы, конечно, правы. Как-то даже стыдно, что сам не додумался. С другой стороны, как я уже писал, опыта с ассемблером до реверса «Казаков» у меня не было от слова совсем. Так что спасибо за совет, в следующий раз учту)


      1. grechnik
        11.03.2016 00:44
        +2

        В 32-битном режиме loop контролируется регистром ecx, не cx.


        1. Ereb
          11.03.2016 00:54
          +1

          Точно, спасибо. Видимо, информация на том сайте относилась к архитектуре 8086.


          1. MacIn
            11.03.2016 01:05
            +1

            1. Ereb
              11.03.2016 01:31
              +1

              Да, там тоже смотрел… Недостаточно тщательно. Зато сейчас вижу: том 2А, страница 3-496.

              То есть, я мог бы сделать и так?
              mov     ecx, 01h    B9 01 00 00 00
              [...]
              
              : Цикл
              push    ecx         51
              [...]
              pop     ecx         59
              loop                E2 ..


              1. MacIn
                11.03.2016 16:57
                +1

                mov ecx, 24h

                не 01h.

                Да. Или так, чтобы не возиться со стеком, раз уж у нас stdcall:

                mov esi, 24h
                m1:
                
                цикл
                
                dec esi
                jnz m1


                1. Ereb
                  11.03.2016 19:00

                  не 01h
                  В самом начале присваивается 0x01 на случай «обычного» заказа одной единицы, затем в сависимости от результатов GetKeyState другие значения (в примере выше 0x05, 0x14, 0x32, 0x0F или 0x24).

                  раз уж у нас stdcall
                  Честно говоря, достоверно мне это известно не было. Как можно проверить, что exe'шник придерживается calling convention и что регистр esi можно безопасно использовать?

                  И в случае stdcall я ведь всё равно обязан положить esi на стек, прежде чем использовать его в патче, а затем восстановить, не так ли?


                  1. MacIn
                    11.03.2016 19:42
                    +1

                    Честно говоря, достоверно мне это известно не было. Как можно проверить, что exe'шник придерживается calling convention и что регистр esi можно безопасно использовать?

                    Никак — посмотреть какие регистры сохраняет/использует фнукция, как она работает со стеком (c call/sdtcall/pascal). Если честно, я посмотрел по диагонали, но опирался на вашу врезку с cx/ecx/loop и это:

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

                    И в случае stdcall я ведь всё равно обязан положить esi на стек, прежде чем использовать его в патче, а затем восстановить, не так ли?

                    Да, я имел в виду сохранение/восстановление в цикле.


  1. cyber-security
    11.03.2016 08:40
    +3

    Добро дело делаете. Вы большой молодец.!
    Вы дизассемблировали логику игры, я снимаю шляпу… Огромный труд.
    Спасибо !


  1. mydoom
    11.03.2016 10:07

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


    1. VRV
      11.03.2016 10:22
      +4

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


      1. mydoom
        11.03.2016 10:44
        +1

        Так я и не говорю о том, чтобы совсем от них отказаться. Речь о том, что когда с миллионами ресурсов играешь, домиков нужно строить сотни (что само по себе угнетающе монотонно), и в какой-то момент их стоимость взлетает до запредельных значений, и по-настоящему большую армию создать нет возможности. В конце концов, это Казаки, а не Сим Сити. Так что поправочный коэффициент на стоимость и/или вместимость домиков, мне кажется, был бы весьма уместен.


        1. VRV
          11.03.2016 12:53
          +2

          Вы не правы, я как многократный победитель турниров на миллионах говорю что там все сбалансировано, есть нации где хотелось бы получше — алжир, но она тем и примечательна. Во всех остальных же Городские центры и Казармы 18 века дают достаточно места для построения огромных армий.


          1. mydoom
            11.03.2016 14:27

            А сколько "Городских центров" вы строили на миллионах? Я несколько лет не играл, но, насколько я помню, уже пятый получается баснословно дорогим, равно как и в случае с "Казармами" XVIII в. (всегда строил по четыре штуки того и другого). Может, конечно, я чего-то недопонял в игровой механике, но после этого мне приходилось приниматься за массированную застройку домами.


            1. VRV
              11.03.2016 21:21
              +1

              если смотреть самую популярную номинацию DEF 10 пт, то стандартная застройка — 6 гц, 4 казармы 17 века, 8 конюшен, 6 казарм 18 века, 6 церквей, остального по 1му.
              Можно экономить ресурсы и строить 5 гц и 5 церквей, всех больше места дает казарма 18 века. Чтобы построить данное кол-во зданий надо правильно использовать рыночный механизм в игре, ну или просто знать обмен)


    1. TomashUA
      11.03.2016 12:47
      +1

      Строил максимум один дом (наверное для открытия других возможностей) или не строил вообще, уже не помню. В основном создавал несколько городских центров, компактнее чем дома строить + сразу рабочая сила из нескольких источников генерировалась. При этом увеличивалось максимальное количество населения, которое можно создать.


      1. mydoom
        11.03.2016 14:29

        Естественно, что "Городских центров" нужно строить больше одного. Я говорю о ситуации, когда построить следующий уже нет возможности.