Написание бота для Stronghold Kingdoms

в 8:33, , рубрики: .net, reflector, бот игры, реверс-инжиниринг
История написания бота для Stronghold Kingdoms

Долгое время я подходил к вопросу написания бота для этой игры, но то опыта не хватало, то лень, то не с той стороны заходить пытался.
В итоге, набравшись опыта написания и обратной разработки кода на C# я решил добиться своего.

Да, как Вы могли заметить, C# не спроста – игра написана именно на нем, с использованием .net 2.0, что в последствии вставило мне некоторые палки в колеса.

Написание бота для Stronghold Kingdoms
Изначально я думал написать сокетного бота, который бы лишь эмулировал сетевой протокол (который никак не шифруется), а имея «исходные коды» (результат декомпиляции il-кода) легко восстанавливается в стороннем приложении.

Но мне это показалось нудным и муторным, ведь зачем городить велосипед, если имеются те самые «исходные коды».

Вооружившись Reflector’ом я начал разбираться с точкой входа игры (код которой вообще никак не обфусцирован более трех лет, диву даюсь разработчикам) – ничего особенного.

Анализ и отчасти неверное решение

Очевидно, что проект игры изначально создавался как консольное приложение:

private static void Main(string[] args) как точка входа и ее класс Program на это намекают, класс, к слову, тоже приватный.

В первую очередь, кинулся делать класс и метод публичными, опять же силами Reflector’а с дополнением к нему Reflexil, сам не зная чего ожидать.

Но внезапно я столкнулся с лаунчером, который перекачивал измененный файл.
Недолго повоевав с ним тем же Reflector'ом и проведя вскрытие выдернул оттуда код установки аргументов передающихся исполняемому файлу игры:

if (DDText.getText(0x17) == "XX")
	parameters = new string[] { "-InstallerVersion", "", "", "st" }; // st == steam
else
	parameters = new string[] { "-InstallerVersion", "", "" };
parameters[1] = SelfUpdater.CurrentBuildVersion.ToString();
parameters[2] = DDText.getText(0); // Покопавшись, узнал что это язык игры, в формате “ru”, “de”, “en” и т.д. Подгружается из файла local.txt рядом с лаунчером.
UpdateManager.SetCommandLineParameters(parameters); // А это их обертка для самого обычного System.Diagnostics.Process
UpdateManager.StartApplication();

Разбираем:

if (DDText.getText(0x17) == "XX") — Строка из файла local.txt рядом с лаунчером.
Такая у них странная проверка на steam/no-steam версии: X – не стим, XX – стим. :
parameters[1] = SelfUpdater.CurrentBuildVersion — Версия лаунчера, спокойно дергается из него же, хотя проверка в клиенте странная, как я узнал позже, и можно просто указать число гораздо более большее текущей, «про запас», т.к. проверка идет только на устаревшесть, так скзаать, версии через сравнение чисел «меньше-больше».
parameters[2] = DDText.getText(0) — Поковавшись версия, узнал что это язык игры, в формате “ru”, “de”, “en” и т.д.
Подгружается так же из файла local.txt.

К слову, версия лаунчера выглядит как-то так:

static SelfUpdater()
{
    currentBuildVersion = 0x75; // 117, т.е. 1.17 на самом деле.
}

И сделал волшебный батник:

StrongholdKingdoms.exe -InstallerVersion 117 ru

Хотя можно и так:

StrongholdKingdoms.exe -InstallerVersion 100500 ru

О чем я и говорил чуть выше.

Итак, что имеем: слегка измененный клиент и систему обхода лаунчера, если это можно так назвать.
Попробовав запустить это все, вижу, что игра работает и мои патчи ей не навредили (хотя чего бы им там вредить).

После этого попробовал подключить исполняемый файл игры к проекту в качестве библиотеки классов и подключить пространство имен игры – Kingdoms.

Затем я городил много кода: пытался и вызывать Main, и эмулировать класс Programm, но игра почему-то падала с рантайм крашем не дотнет-фреймворкоского при любой попытке заставить это работать.
Сослался на то, что игра использует много не C# библиотек и много unsafe-кода. Реальных причин так и не нашел.

Решение верное

Долго промучавшись и не найдя решения, я уже было плюнул. Но почему-то мне вспомнился форк сервера Terraria — TShock (ага, форк, как же – тоже ребята забавлялись с декомпилятором) и его загрузку модулей (модовплагинов) из DLL.

Эта идея мне показалось интересной. Погуглив нашел и способ, и код.
Слегка вникнув в него и проверив в собственном проекте, с ужасом обнаружил, что он работает (внезапно!).
Собственно, код:

System.Reflection.Assembly A = System.Reflection.Assembly.LoadFrom(System.Windows.Forms.Application.StartupPath + @"BotDLL.dll");
Type ClassType = A.GetType("BotDLL.Main", true);
object Obj = Activator.CreateInstance(ClassType);
System.Reflection.MethodInfo MI = ClassType.GetMethod("Inject");
MI.Invoke(Obj, null);

Разберем код:
System.Reflection.Assembly – Это та штука, которая отвечает за создание ссылок на файлы при подключении их к проекту, только из кода. А еще она хранит информацию о версиях вашего проекта и копирайтах (да, тот самый AssemblyInfo.cs во всех ваших проектах).
Assembly.LoadFrom(System.Windows.Forms.Application.StartupPath + @"BotDLL.dll") — Загружаем нашу библиотеку.
Затем вызываем функцию внутри этого класса Inject(), которая и является уже по сути началом бота. =)
Опробовал в стороннем приложении код который набросал – инжект сработал.

Патчинг клиента

Теперь переходим к самому интересному – внедряем код вызова в игру.
Попытавшись в наглую его всунуть в Main через замену кода с помощью Reflexil успешно был послан патчить не патчимое в результате декомпиляции. Ну или мне просто было лень, не важно.
Пошел искать в этом самом Main гарантированный вызов сторонних функций (вне основных ветвей if и т.д.) довольно быстро нашел вызов функции MySettings.load(), который загружал настройки игры при ее запуске.
Но там опять же оказалась гора кода который не захотел компилироваться без бубнов.
Зато по счастливой случайности рядом с ним находится булева функция hasLoggedIn() которая возвращает единственное bool значение как раз при запуске игры:
return (this.HasLoggedIn || (this.Username.Length > 0));
Меня это сразу обрадовало и тут же эта функция была преобразована в такую:

if (!IsStarted)
{
	System.Reflection.Assembly A = System.Reflection.Assembly.LoadFrom(System.Windows.Forms.Application.StartupPath + @"BotDLL.dll");
	Type ClassType = A.GetType("BotDLL.Main", true);
	object Obj = Activator.CreateInstance(ClassType);
	System.Reflection.MethodInfo MI = ClassType.GetMethod("Inject");
	MI.Invoke(Obj, null);
	IsStarted = true;
}
return (this.HasLoggedIn || (this.Username.Length > 0));

Разберемся с ним.
if (!IsStarted) – пришлось добавить эту проверку, а для этого ввести дополнительное поле в класс MySettings, поскольку наша функция вызывается не один раз, а несколько потоков бота нам не очень-то и нужны. Делается это все тем же Reflexil'ом.
Ну основной код мы уже разобрали чуть выше.
И в конце возвращаем то что тут и должно было быть. =)

Итак – сам бот

Функция Inject:

        public void Inject()
        {
            AllocConsole();
            Console.Title = "SHKBot";

            Console.WriteLine("DLL загружена!");
            Thread Th = new Thread(SHK);
            Th.Start();

            BotForm FBot = new BotForm();
            FBot.Show();
        }
	…
        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        static extern bool AllocConsole();

Сперва мы вызываем функцию открытия окна консоли – так будет проще для отладки.
После запускаем поток с нашим основным циклом бота – SHK();
И заодно открываем форму управления бота для удобства.

Дальше дело за малым – реализовывать нужный вам функционал.
Вот остальной мой код – здесь я реализовал систему автоматической торговли.
Чтобы она работала сперва надо «закэшировать» деревни в каждой сессии – открыть каждую из деревень, которыми собираетесь торговать.
Этот код помогает сомнительно, а до других способов автоматической прогрузки деревень я пока не докопался:

InterfaceMgr.Instance.selectVillage(VillageID);
GameEngine.Instance.downloadCurrentVillage();

Вот код функции SHK:

Скрытый текст

public void SHK()
{
	Console.WriteLine("Инжект выполнен!");

	while (!GameEngine.Instance.World.isDownloadComplete())
	{
		Console.WriteLine("Мир еще не загружен!");
		Thread.Sleep(5000); // 5 sec
		Console.Clear();
	}

	Console.WriteLine("Мир загружен! Начало выполнения операций ядра.");
	Console.WriteLine("n======| DEBUG INFO |======");
	Console.WriteLine(RemoteServices.Instance.UserID);
	Console.WriteLine(RemoteServices.Instance.UserName);

	List<int> VillageIDs = GameEngine.Instance.World.getListOfUserVillages();
	foreach (int VillageID in VillageIDs)
	{
		WorldMap.VillageData Village = GameEngine.Instance.World.getVillageData(VillageID);
		Console.WriteLine("[Инициализация] " + Village.m_villageName + " - " + VillageID);

		InterfaceMgr.Instance.selectVillage(VillageID);
		GameEngine.Instance.downloadCurrentVillage();
	}
	Console.WriteLine("======| ========== |======n");
	
	while (true)
	{
		try
		{
			// Тут можно что-нибудь свое воткнуть
		}
		catch (Exception ex)
		{
			Console.WriteLine("n======| EX INFO |======");
			Console.WriteLine(ex);
			Console.WriteLine("======| ======= |======n");
		}
	}
}

Код формы контроля:

Скрытый текст

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Threading;

using Kingdoms;
using System.CodeDom.Compiler;
using Microsoft.CSharp;
using System.Reflection;

namespace BotDLL
{
    public partial class BotForm : Form
    {
        Thread TradeThread;
        bool IsTrading = false;

        public void Log(string Text)
        {
            Console.WriteLine(Text);
            richTextBox_Log.Text = Text + "rn" + richTextBox_Log.Text;
        }

        public BotForm()
        {
            CheckForIllegalCrossThreadCalls = false;

            InitializeComponent();
            this.Show();
            Log("Форма бота отображена.");

            listBox_ResList.SelectedIndex = 0;

            Log("Запуск потока торговли...");
            TradeThread = new Thread(Trade);
            TradeThread.Start();
        }

        private void button_Trade_Click(object sender, EventArgs e)
        {
            // Если мир уже загружен и поле цели заполнено
            if (GameEngine.Instance.World.isDownloadComplete() && textBox_TradeTargetID.Text.Length > 0)
            {
                try
                {
                    if (!IsTrading) // Если не торгуем
                    {
                        button_Trade.Text = "Стоп";
                        IsTrading = true; // То торгуем
                    }
                    else // И наоборот
                    {
                        button_Trade.Text = "Торговать";
                        IsTrading = false;
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine("n======| EX INFO |======");
                    Log(ex.ToString());
                    Console.WriteLine("======| ======= |======n");
                }
            }
        }

        public void Trade()
        {
            Log("Торговый поток создан!");

            int Sleep = 0;
            while (true) // Если торгуем
            {
                Sleep = 60 + new Random().Next(-5, 60);

                if (IsTrading)
                {
                    Log("[" + DateTime.Now + "] Заход с "" + listBox_ResList.SelectedItem.ToString() + """);
                    // Получаем ID товара из списка
                    int ResID = int.Parse(listBox_ResList.SelectedItem.ToString().Replace(" ", "").Split('-')[0]);
                    int TargetID = int.Parse(textBox_TradeTargetID.Text); // Получаем ID деревни-цели
                    List<int> VillageIDs = GameEngine.Instance.World.getListOfUserVillages(); // Получаем список наших деревень

                    foreach (int VillageID in VillageIDs) // Перебираем их
                    {
                        // Если деревня прогружена (открывалась ее карта в текущей сессии хоть раз)
                        if (GameEngine.Instance.getVillage(VillageID) != null)
                        {
                            // Получаем базовую информацию о нашей деревни
                            WorldMap.VillageData Village = GameEngine.Instance.World.getVillageData(VillageID);
                            VillageMap Map = GameEngine.Instance.getVillage(VillageID); // Получаем полную информацию
                            int ResAmount = (int)Map.getResourceLevel(ResID); // Кол-во ресурса на складе
                            int MerchantsCount = Map.calcTotalTradersAtHome(); // Кол-во торговцев в ней
                            Log("В деревне " + VillageID + " есть " + MerchantsCount + " торговцев"); // Дебаг

                            int SendWithOne = int.Parse(textBox_ResCount.Text); // Кол-во ресурса на торговца
                            int MaxAmount = MerchantsCount * SendWithOne; // Кол-во ресурсов отправим
                            if (ResAmount < MaxAmount) // Если торговцы могут увезти больше чем есть
                                MerchantsCount = (int)(ResAmount / SendWithOne); // Считаем сколько смогут увезти реально

                            if (MerchantsCount > 0) // Если трейдеры дома есть
                            {
                                TargetID = GameEngine.Instance.World.getRegionCapitalVillage(Village.regionID); // Торгуем с регионом, временно
                                textBox_TradeTargetID.Text = TargetID.ToString();

                                // Вызываем высокоуровневую функцию торговли с рядом каллбеков
                                GameEngine.Instance.getVillage(VillageID).stockExchangeTrade(TargetID, ResID, MerchantsCount * SendWithOne, false);
                                AllVillagesPanel.travellersChanged(); // Подтверждаем изменения (ушли трейдеры) в GUI-клиента
                            }
                        }
                    }

                    Log("Повтор цикла торговли через " + Sleep + " секунд в " + DateTime.Now.AddSeconds(Sleep).ToString("HH:mm:ss"));
                    Console.WriteLine();
                }
                Thread.Sleep(Sleep * 1000); // Спим, чтобы не спамить. Так меньше палева.
            }
        }

        private void BotForm_FormClosing(object sender, FormClosingEventArgs e)
        {
            try
            {
                TradeThread.Abort();
            }
            catch
            {}
        }

        private void button_MapEditing_Click(object sender, EventArgs e)
        {
            button_MapEditing.Text = (!GameEngine.Instance.World.MapEditing).ToString();
            GameEngine.Instance.World.MapEditing = !GameEngine.Instance.World.MapEditing;
        }

        private void button_Exec_Click(object sender, EventArgs e)
        {
            if (richTextBox_In.Text.Length == 0 || !GameEngine.Instance.World.isDownloadComplete())
                return;

            richTextBox_Out.Text = "";

            // *** Example form input has code in a text box
            string lcCode = richTextBox_In.Text;

            ICodeCompiler loCompiler = new CSharpCodeProvider().CreateCompiler();
            CompilerParameters loParameters = new CompilerParameters();

            // *** Start by adding any referenced assemblies
            loParameters.ReferencedAssemblies.Add("System.dll");
            loParameters.ReferencedAssemblies.Add("System.Data.dll");
            loParameters.ReferencedAssemblies.Add("System.Windows.Forms.dll");
            loParameters.ReferencedAssemblies.Add("StrongholdKingdoms.exe");

            // *** Must create a fully functional assembly as a string
            lcCode = @"using System;
            using System.IO;
            using System.Windows.Forms;
            using System.Collections.Generic;
            using System.Text;

            using Kingdoms;

            namespace NSpace {
            public class NClass {
            public object DynamicCode(params object[] Parameters) 
            {
                " + lcCode +
            @" return null;
            }
            }
            }";

            // *** Load the resulting assembly into memory
            loParameters.GenerateInMemory = false;
            // *** Now compile the whole thing
            CompilerResults loCompiled =
                    loCompiler.CompileAssemblyFromSource(loParameters, lcCode);
            if (loCompiled.Errors.HasErrors)
            {
                string lcErrorMsg = "";
                lcErrorMsg = loCompiled.Errors.Count.ToString() + " Errors:";
                for (int x = 0; x < loCompiled.Errors.Count; x++)
                    lcErrorMsg = lcErrorMsg + "rnLine: " +
                                 loCompiled.Errors[x].Line.ToString() + " - " +
                                 loCompiled.Errors[x].ErrorText;

                richTextBox_Out.Text = lcErrorMsg + "rnrn" + lcCode;
                return;
            }

            Assembly loAssembly = loCompiled.CompiledAssembly;
            // *** Retrieve an obj ref – generic type only
            object loObject = loAssembly.CreateInstance("NSpace.NClass");

            if (loObject == null)
            {
                richTextBox_Out.Text = "Couldn't load class.";
                return;
            }

            object[] loCodeParms = new object[1];
            loCodeParms[0] = "SHKBot";
            try
            {
                object loResult = loObject.GetType().InvokeMember(
                                 "DynamicCode", BindingFlags.InvokeMethod,
                                 null, loObject, loCodeParms);

                //DateTime ltNow = (DateTime)loResult;
                if (loResult != null)
                    richTextBox_Out.Text = "Method Call Result:rnrn" + loResult.ToString();
            }
            catch (Exception ex)
            {
                Console.WriteLine("n======| EX INFO |======");
                Console.WriteLine(ex);
                Console.WriteLine("======| ======= |======n");
            }
        }
    }
}

Изначально я хотел воткнуть в бота NLua (библиотека Lua для C#), но поскольку он поддерживает только 3.5+ фреймворки, а использовать старые версии мне почему-то не захотелось я сделал так:
Для удобства ввел экзекуцию кода в реальном времени на самом шарпе — утомился я перезапускать игру после перекомпиляций раз за разом.
Пользовался этим туториалом.

Итог

Плюсы такого решения:

  1. Доступ ко всему игровому коду, будто вы имеете ее исходные коды.
  2. Можете сделать собственную систему премиум-карт с очередью построек, изучением исследований без ограничений и даже больше:
    • Алгоритм перепродажи товаров среди окружающих вас регионов.
    • Автопостройка деревни «по макету» снятому с уже имеющейся, как пример.
    • Автонайм различных юнитов.
    • Автопочинка замка пока вас нет.
    • Атоматический сбор гарантированной карты за время.

  3. Ну и конечно динамическое исполнение кода.
  4. Смешная защита от обнаружения. Ну и еще пара условий для того чтобы не слать подозрительные запросы-пустышки.

Минусы:

  1. Придется патчить клиент с каждой версией ручками. Либо можно написать патчер с использованием Mono.Cecil или аналогом во фреймворке.
  2. В отличие от премиум-карт придется держать клиент всегда включенным и онлайн.
  3. Игра немаленькая, так что изучать «API» придется точно не час. Хотя при желании и инструментах разбирается в лет – было бы желание. Да и в любом случае лучше чем с пакетами возиться.

Вот так выглядит все это дело:
Написание бота для Stronghold Kingdoms

Заинтересовавшимся рекомендую взглянуть на следующие классы игры:

Список классов

  • GameEngine
  • GameEngine.Instance
  • GameEngine.Instance.World
  • WorldMap
  • WorldMap.VillageData
  • RemoteServices
  • RemoteServices.Instance
  • AllVillagesPanel
  • VillageMap

На момент написания статьи версия игры была 2.0.18.6.
Скачать именно эту версию с исполняемым файлом игры и ботом можно здесь.
Не волнуйтесь, личных данных не ворую. =) Устал от игры, поэтому делюсь с сообществом опытом.

Исходные коды доступны здесь.
Если соберетесь использовать исходные коды — используйте в качестве библиотеки классов чистый исполняемый файл (не пропатченный вами), а так же отключите копирование этой ссылки в конечный каталог, дабы случайно не заменить пропатченный.

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

Автор: Riketta

Источник

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


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