[Из песочницы] Написание бота для Stronghold Kingdoms

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

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

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

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

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

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

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

if (DDText.getText (0×17) == «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 (0×17) == «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 = 0×75; // 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 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 + »\r\n» + 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 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 + "\r\nLine: " + loCompiled.Errors[x].Line.ToString() + " - " + loCompiled.Errors[x].ErrorText;

richTextBox_Out.Text = lcErrorMsg + »\r\n\r\n» + 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:\r\n\r\n» + loResult.ToString (); } catch (Exception ex) { Console.WriteLine (»\n======| EX INFO |======»); Console.WriteLine (ex); Console.WriteLine (»======| ======= |======\n»); } } } } Изначально я хотел воткнуть в бота NLua (библиотека Lua для C#), но поскольку он поддерживает только 3.5+ фреймворки, а использовать старые версии мне почему-то не захотелось я сделал так: Для удобства ввел экзекуцию кода в реальном времени на самом шарпе — утомился я перезапускать игру после перекомпиляций раз за разом.Пользовался этим туториалом.

Итог Плюсы такого решения: Доступ ко всему игровому коду, будто вы имеете ее исходные коды. Можете сделать собственную систему премиум-карт с очередью построек, изучением исследований без ограничений и даже больше: Алгоритм перепродажи товаров среди окружающих вас регионов. Автопостройка деревни «по макету» снятому с уже имеющейся, как пример. Автонайм различных юнитов. Автопочинка замка пока вас нет. Атоматический сбор гарантированной карты за время. Ну и конечно динамическое исполнение кода. Смешная защита от обнаружения. Ну и еще пара условий для того чтобы не слать подозрительные запросы-пустышки. Минусы: Придется патчить клиент с каждой версией ручками. Либо можно написать патчер с использованием Mono.Cecil или аналогом во фреймворке. В отличие от премиум-карт придется держать клиент всегда включенным и онлайн. Игра немаленькая, так что изучать «API» придется точно не час. Хотя при желании и инструментах разбирается в лет — было бы желание. Да и в любом случае лучше чем с пакетами возиться. Вот так выглядит все это дело: 891718f328c638f31b8ced5c36a62c86.png

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

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

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

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

© Habrahabr.ru