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

Історія написання бота для Stronghold Kingdoms
Довгий час я підходив до питання написання бота для цієї гри, але те досвіду не вистачало, то лінь, то не з того боку заходити намагався.
У підсумку, набравшись досвіду написання і зворотної розробки коду на C # я вирішив домогтися свого.
 
Так, як Ви могли помітити, C # не спроста — гра написана саме на ньому, з використанням. Net 2.0, що надалі вставило мені деякі палки в колеса.
 
 
Спочатку я думав написати сокетного бота, який би лише емулювати мережевий протокол (який ніяк не шифрується), а маючи «вихідні коди» (результат декомпіляції 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 + "\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<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 + "\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 + фреймворки, а використовувати старі версії мені чомусь не захотілося я зробив так:
Для зручності ввів екзекуцію коду в реальному часі на самому Шарп — втомився я перезапускати гру після перекомпіляції раз за разом.
Користувався цим туторіали .
 
 
Підсумок
Плюси такого рішення:
 
     
  1. Доступ до всього ігрового коду, ніби ви маєте її вихідні коди.
  2.  
  3. Можете зробити власну систему преміум-карт з чергою будівель, вивченням досліджень без обмежень і навіть більше:
     
       
    • Алгоритм перепродажу товарів серед оточуючих вас регіонів.
    •  
    • Автопостройка села «за макетом» знятому з уже наявною, як приклад.

    •  
    • Автонайм різних юнітів.
    •  
    • Автопочінка замку поки вас немає.
    •  
    • Атоматіческая збір гарантованої карти за час.
    •  
     
  4.  
  5. Ну і звичайно динамічне виконання коду.
  6.  
  7. Смішна захист від виявлення. Ну і ще пара умов для того щоб не слати підозрілі запити-пустушки.
  8.  
Мінуси:
 
     
  1. Доведеться патчити клієнт з кожною версією ручками. Або можна написати патчер з використанням Mono.Cecil або аналогом під фреймворці.
  2.  
  3. На відміну від преміум-карт доведеться тримати клієнт завжди включеним і онлайн.
  4.  
  5. Гра немаленька, так що вивчати «API» доведеться точно не годину. Хоча при бажанні і інструментах розбирається в років — було б бажання. Та й в будь-якому випадку краще ніж з пакетами возитися.
  6.  
 
Ось так виглядає все це справа:
 
 
Зацікавився рекомендую поглянути на наступні класи гри:
 Список класів
     
  • GameEngine
  •  
  • GameEngine.Instance

  •  
  • GameEngine.Instance.World
  •  
  • WorldMap
  •  
  • WorldMap.VillageData
  •  
  • RemoteServices
  •  
  • RemoteServices.Instance
  •  
  • AllVillagesPanel
  •  
  • VillageMap
  •  
 
 
На момент написання статті версія гри була 2.0.18.6.
Завантажити саме цю версію з виконуваним файлом гри і ботом можна тут .
Не хвилюйтеся, особистих даних не краду. =) Втомився від гри, тому ділюся з спільнотою досвідом.
 
Вихідні коди доступні тут .
Якщо зберетеся використовувати вихідні коди — використовуйте як бібліотеки класів чистий виконуваний файл (Не пропатченний вами), а так само відключіть копіювання цього посилання в кінцевий каталог, щоб бува не замінити пропатченний.
 
Прошу вибачення за стиль написання статті — пишу вперше. Можливо багато скачу від теми до теми, або мало технічних аспектів описую.
Можливо вам здасться що тут багато води, але ця стаття спочатку затівалася як невелика історія — ймовірно тому.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.