Гайд Написание ASI/SF плагинов на C#

TheLeftExit

Участник
Автор темы
38
25
Последние версии .NET позволяют писать на шарпе DLL библиотеки, которые можно подгружать как нативные библиотеки - например, через LoadLibrary+GetProcAddress в С++ коде. Подробнее от Microsoft.

Если вы хотите заняться написанием ASI/SF плагинов на компилируемом языке с развитой средой разработки, офигенными документацией и автозаполнением, и продвинутой и интуитивной базовой библиотекой - теперь такая возможность есть.

Но придётся всё равно немного испачкать руки о C++ и низкоуровневый C#.

Я написал небольшой семпл на гитхабе, в котором бизнес логика плагина написана на C#, а также реализована прослойка между обычным SF проектом на C++ и нашей сборкой на C#. К семплу прилагается минимальное описание, что происходит, на англйийском. Ниже я опишу чуть подробнее и на русском, что в семпле происходит и как создать такой же с нуля.

Гайд написан руками C# разработчика, так что я хотя и буду пытаться описать шаги для новичка в C#, надёжнее будет если читатель имеет базу работы с языком и последними его фичами. Используются VS2022 и .NET 10 (preview).




Для начала скачиваем SAMPFUNCS SDK и распаковываем базовый проект (либо ставим VSIX и создаём проект по шаблону). Открываем проект в Visual Studio, заходим в свойства проекта, поднимаем Windows SDK Version до 10.0, Platform Toolset до чего-нибудь что установлено, и C++ Language Standard до 17. Мне очень не хватало этой инфы когда я впервые создал проект и пытался его хотя бы собрать, даже вне контекста описанного далее.

Открываем plugin.cpp. Сейчас мы будем писать прослойку.

Для начала вспоминаем, что не существует готового C# API для SAMPFUNCS, поэтому нам придётся экспортировать нужные функции вручную. C# умеет хранить и интерпретировать указатели на функции - фича низкоуровневая и как раз созданная для сценариев вроде нашего. Поэтому нам нужно превратить SAMPFUNCS функции, которыми мы планируем пользоваться из C# кода, в такие указатели.

SAMPFUNCS C++ API объектно-ориентировано, а передавать функции типа SF->getSAMP()->getChat()->AddChatMessage(..., ...) в C# в сыром виде проблематично. Так что напишем свои статические обёртки поверх интересующих нас функций:

C++:
static void CALLBACK SendChat(const char* input) {
    SF->getSAMP()->getPlayers()->localPlayerData->Say((char*)input);
}

static void CALLBACK LogToChat(const char* input) {
    SF->getSAMP()->getChat()->AddChatMessage(0xAAAAAA, input);
}

Далее нам нужна структура, которую мы будем "передавать между языками программирования" и которую оба языка смогут понять. Мы будем передавать указатель на эту структуру в C# метод, и уже в C# коде мы завернём эти указатели в юзабельные функции.

C#:
struct CSharpExports {
    void(__stdcall* SendChat)(const char*);
    void(__stdcall* LogToChat)(const char*);
};

// Пример использования
CSharpExports exports = {
    &SendChat,
    &LogToChat
};

Теперь нам нужно написать C# код, который будет принимать эту структуру.

Создаём C# проект типа Class Library и убеждаемся, что в .csproj файле включены NativeAOT и unsafe код:
XML:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>


Создаём C# структуру с таким же представлением в памяти, как CSharpExports:
C#:
public unsafe struct SampFuncs
{
    public delegate* unmanaged[Stdcall]<byte*, void> _sendToChat;
    public delegate* unmanaged[Stdcall]<byte*, void> _logToChat;
}

Чтобы воспользоваться этой структурой, осталось выполнить ещё два пункта. Сначала написать метод, с помощью которого наш C++ код сможет передать там эту структуру:

C#:
public unsafe struct SampFuncs
{
    private static SampFuncs _instance;

    [UnmanagedCallersOnly(EntryPoint = "RegisterExports", CallConvs = [typeof(CallConvStdcall)])]
    public static void RegisterExports(SampFuncs* funcs)
    {
        _instance = *funcs;
    }

    public delegate* unmanaged[Stdcall]<byte*, void> _sendToChat;
    public delegate* unmanaged[Stdcall]<byte*, void> _logToChat;
}

Затем ещё два метода, которыми мы уже будем пользоваться в C#, чтобы вызывать функции, закодированные в этой структуре. Помним, что char в C++ занимает один байт, а в C# два, так что нужно вручную конвертировать C# строки в UTF8 массивы C# byte перед отправкой в функции, принимающие C++ char*:

C#:
public unsafe struct SampFuncs
{
    private static SampFuncs _instance;

    [UnmanagedCallersOnly(EntryPoint = "RegisterExports", CallConvs = [typeof(CallConvStdcall)])]
    public static void RegisterExports(SampFuncs* funcs)
    {
        _instance = *funcs;
    }

    public delegate* unmanaged[Stdcall]<byte*, void> _sendToChat;
    public delegate* unmanaged[Stdcall]<byte*, void> _logToChat;

    public static void SendToChat(ReadOnlySpan<char> message)
    {
        Span<byte> bytes = stackalloc byte[Encoding.UTF8.GetByteCount(message) + 1];
        bytes.Clear();
        Encoding.UTF8.GetBytes(message, bytes);
        fixed (byte* pBytes = bytes) _instance._sendToChat(pBytes);
    }

    public static void LogToChat(ReadOnlySpan<char> message)
    {
        Span<byte> bytes = stackalloc byte[Encoding.UTF8.GetByteCount(message) + 1];
        bytes.Clear();
        Encoding.UTF8.GetBytes(message, bytes);
        fixed (byte* pBytes = bytes) _instance._logToChat(pBytes);
    }
}

Все методы/поля, кроме указателей на функции, должны быть статическими - иначе потеряем эквивалентность с C++ структурой. По-хорошему стоит для функций и статического поля завести отдельный класс, но для наколенного решения так сойдёт.

И возвращаемся в plugin.cpp, чтобы позвать оттуда экспортированный метод RegisterExports:

C++:
// где-то сверху
#include "plugin.h"
#include <game_api.h>
#include <thread>
#include <filesystem>
#include <string>

using namespace std;
using namespace std::filesystem;

// где-то после struct CSharpExports

// указатель на функцию, идентичную RegisterExports
typedef void(__stdcall* CSharpExportCallback)(const CSharpExports*);

static void RegisterCallbacks() {
    // для удобства подгружаем нашу будущую C# сборку прямо из папки с .sf
    const string managedModuleName = "ClassLibrary1.dll";
    char pathRaw[256];
    auto length = GetModuleFileName(SF->getSAMP()->getPluginInfo()->getPluginHandle(), pathRaw, 255);
    auto moduleFolder = string(pathRaw, length);
    auto modulePath = (path(moduleFolder).parent_path() / managedModuleName).string();
    // подгружаем нашу C# сборку и метод RegisterExports
    HINSTANCE handle = LoadLibrary(modulePath.c_str());
    CSharpExportCallback exportCallback = (CSharpExportCallback)GetProcAddress(handle, "RegisterExports");
    
    CSharpExports exports = {
        &SendChat,
        &LogToChat
    };
    exportCallback(&exports);
}

static void CALLBACK mainloop() { 
    static bool initialized = false;
    if (!initialized && GAME && GAME->GetSystemState() == eSystemState::GS_PLAYING_GAME && SF->getSAMP()->IsInitialized()) {
        initialized = true;
        RegisterCallbacks();
    }
}

И наконец, собираем и устанавливаем получившуюся шаблонку. C++ проект собираем и .SF файл просто кладём в папку SAMPFUNCS. С C# проектом чуть сложнее:
- Создаём Publish Profile с публикацией в папку,
- Настраиваем публикацию: Deployment mode - Self-contained, Target runtime - win-x86. Таким образом у нас получится нативная DLL библиотека, подгружаемая в GTA SA.
- Публикуем и копируем получившийся DLL в ту же папку SAMPFUNCS.

Далее вкратце пример того, как эту шаблонку использовать.

В C# проект добавляем ещё один класс. Он будет содержать метод с бизнес логикой обработки чат-команды, и функцию-прослойку для вызова этого метода из C++. Наша тестовая функция просто берёт аргументы команды, делит их на слова и отправляет их в чат одно за другим, с задержкой:
C#:
public static unsafe class MyClass
{
    [UnmanagedCallersOnly(EntryPoint = "Foo", CallConvs = [typeof(CallConvStdcall)])]
    public static void Foo(byte* input)
    {
        var inputString = Marshal.PtrToStringUTF8((nint)input) ?? string.Empty;
        new Thread(() => FooCore(inputString)).Start();
    }

    private static void FooCore(string input)
    {
        foreach(var word in input.Split(' '))
        {
            if (word.Length > 0)
            {
                SampFuncs.SendToChat(word);
                Thread.Sleep(500);
            }
        }
    }
}

Запуск бизнес логики в отдельном потоке позволяет нам пользоваться задержками (Thread.Sleep) и не фризить саму игру.

Далее пишем прослойку в C++:
- Задаём указатель на функцию, эквивалентную Foo,
- Добавляем в наш метод RegisterCallbacks код для импорта этой функции из C# сборки,
- Регистрируем чат-команду и отправляем её на обработку C# функции.

C++:
typedef void(__stdcall* CSharpCommandCallback)(const char*);

static CSharpCommandCallback Foo;

static void RegisterCallbacks() {
    /* ... */
    HINSTANCE handle = LoadLibrary(modulePath.c_str());
    /* ... */
    Foo = (CSharpCommandCallback)GetProcAddress(handle, "Foo");
    /* ... */
}

static void CALLBACK OnBb(string args) {
    Foo(args.c_str());
}

static void CALLBACK mainloop() { 
    static bool initialized = false;
    if (!initialized && GAME && GAME->GetSystemState() == eSystemState::GS_PLAYING_GAME && SF->getSAMP()->IsInitialized()) {
        initialized = true;
        RegisterCallbacks();
        SF->getSAMP()->registerChatCommand("test", &OnBb);
    }
}

Повторяем процесс сборки и установки, запускаем SAMP, вводим /test 1 2 3 - и наш персонаж должен выдать три сообщения с интервалом в 500 мс (и возможно поймать один-два антифлуда).

Самое сложное позади - теперь когда написаны прослойки, можно творить в своём методе FooCore что угодно с использованием базовых библиотек .NET и функций, которые мы импортировали через структуру SampFuncs.
 

TheLeftExit

Участник
Автор темы
38
25
Так, я выше написал, что для запуска логики с задержками нужно запускать отдельный поток - это хреновый совет. Пока сам пользовался этим решением, наткнулся на проблемы в сценариях, когда код в потоке DllMain и фоновом потоке выполнялся одновременно, и в результате непотокобезопасное SF C++ API неверно себя вело - например, при двух одновременных AddChatMessage в одном из них текст превращался в рванку.

Решим проблему так, как оно уже решено в Moonloader - там при вызове функции wait управление возвращается игре, и дальнейший код становится в очередь на выполнение в основном потоке спустя N миллисекунд. Так удобно выходит, что в C# на таком же механизме построена асинхронка (async/await), и мы можем с помощью кастомного SynchronizationContext буквально приравнять классическое шарповое await Task.Delay(100); к луашному wait(100).

Добавим в плюсовый mainloop ещё одну ветку:

C++:
static HINSTANCE managedLibrary;
static CSharpLoopCallback managedLoopCallback;

static void LoadManagedLibrary() {
    /* ... */
    managedLibrary = LoadLibrary(modulePath.c_str());

    CSharpInitFunction initFunction = (CSharpInitFunction)GetProcAddress(_Notnull_ managedLibrary, "Init");
    CSharpExports exports;
    initFunction(&exports);

    managedLoopCallback = (CSharpLoopCallback)GetProcAddress(_Notnull_ managedLibrary, "MainLoop");
}

static void CALLBACK mainloop() {
    static bool initialized = false;
    if (!initialized && GAME && GAME->GetSystemState() == eSystemState::GS_PLAYING_GAME && SF->getSAMP()->IsInitialized()) {
        initialized = true;
        LoadManagedLibrary();
    }
    if (initialized) {
        managedLoopCallback();
    }
}

Добавляем в C# проект статический метод с вышеуказанным названием MainLoop:

C#:
[UnmanagedCallersOnly(EntryPoint = "MainLoop", CallConvs = [typeof(CallConvStdcall)])]
public static void MainLoop()
{
    SFSynchronizationContext.LoopProc(); // что это такое - ниже
}

Затем пишем свой SynchronizationContext:

C#:
public class SFSynchronizationContext : SynchronizationContext
{
    private static readonly Lock _dispatchLock = new();
    private static uint mainThreadId;

    private static Queue<(SendOrPostCallback d, object? state, ManualResetEventSlim?)> _queue = new();

    public override void Send(SendOrPostCallback d, object? state)
    {
        if (Win32.GetCurrentThreadId() == mainThreadId) // а вдруг
        {
            d(state);
            return;
        }
        var mre = new ManualResetEventSlim();
        lock (_dispatchLock)
        {
            _queue.Enqueue((d, state, mre));
        }
        mre.Wait();
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        lock (_dispatchLock)
        {
            _queue.Enqueue((d, state, null));
        }
    }

    public static void Initialize()
    {
        mainThreadId = Win32.GetCurrentThreadId();
        SetSynchronizationContext(new SFSynchronizationContext());
    }

    public static void LoopProc()
    {
        lock (_dispatchLock)
        {
            while (_queue.Any())
            {
                var (d, state, mre) = _queue.Dequeue();
                d(state);
                if (mre is not null)
                {
                    mre.Set();
                    mre.Dispose();
                }
            }
        }
    }
}

public static unsafe partial class Win32
{
    [LibraryImport("kernel32.dll")]
    public static partial uint GetCurrentThreadId();
}
В любом C# приложении, любой код после await DoSomethingAsync(); заворачивается в делегат и отправляется в метод Send контекста, установленного в текущем потоке. В нашем случае, Send ставит этот делегат в очередь на выполнение в ближайшем LoopProc.

Теперь, если наш импортированный в самом начале C# метод Init выглядит вот так:
C#:
[UnmanagedCallersOnly(EntryPoint = "Init", CallConvs = [typeof(CallConvStdcall)])]
public static void Init(CSharpExports* exports) {
    _exports = *exports;
    SFSynchronizationContext.Initialize();
    Program.Main();
}

То Program.Main может содержать вот такое:
C#:
public static async void Main() // легальный сценарий для async void
{
    SampFuncs.LogToChat($"SFSharp loaded!");
    while (true)
    {
        await Task.Delay(1000); // возвращаем управление SF
        SampFuncs.LogToChat($"..."); // выполняется в одном из будущих вызовов mainloop
    }
}

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

Для запуска кода каждый кадр можно ждать 1 миллисекунду, или чуть поднапрячься и написать свой метод, который будет возвращать таску (или что-нибудь полегче), которая выполнится на выходе из ближайшего LoopProc.
 
  • Нравится
Реакции: #Northn

TheLeftExit

Участник
Автор темы
38
25
Если кому-то интересна эта тема - спустя месяц разработки и догфудинга получился независимый от SF фреймворк на шарпе, который сам по себе немного SF копирует, с хуками и прочими плюшками: https://github.com/TheLeftExit/SF
 
  • Нравится
Реакции: NegrVkletke

NullPhantom

Участник
29
31
Шарп + Си и плюсы - это лучшая максимально сильнейшая комбинация
Можно логику писать на плюсах, менюшку на шарпе и т.д
 

NegrVkletke

Известный
69
14
Очень мощно и смело! Попробую что-то сотворить.
native AOT творит чудеса, со стороны разработчиков .NET было бы мощно добавить поддержку рефлексии под компиляцию AOT
Автор красавчик, воплощает смелые идеи
 
  • Нравится
Реакции: TheLeftExit

NegrVkletke

Известный
69
14
Словил невероятный кайф от того что подобные решения в целом возможны, и существую в наше время умельцы которые приносят новшества!
Запустить эту имбу удалось только на ванильном сампе (R5 + ласт версия SF) к сожалению, полагаю что для добавления поддержки работы версии игры которая идет от разработчиков Arizona RP нужно копаться в их версии samp и SF?
Немного знаком с темой реверса, подскажите пожалуйста на что нужно обратить внимание чтобы перенести ваш проект на их версии Samp и SF.
1755143216919.png



Еще поразило то, сколько сил вы потратили на этот проект(это можно увидеть по вашему гитхабу)! Большое вам спасибо за такой вклад
 
Последнее редактирование:

TheLeftExit

Участник
Автор темы
38
25
Запустить эту имбу удалось только на ванильном сампе (R5 + ласт версия SF) к сожалению, полагаю что для добавления поддержки работы версии игры которая идет от разработчиков Arizona RP нужно копаться в их версии samp и SF?
Немного знаком с темой реверса, подскажите пожалуйста на что нужно обратить внимание чтобы перенести ваш проект на их версии Samp и SF.
https://github.com/TheLeftExit/SF завязан на конкретную версию SAMP только оффсетами - см. Interop/Classes и Interop/Hooking/Hooks (и в dllmain.c ещё один оффсет но он в `gtasa` так что наверное не влияет). Оффсеты классов брал из общедоступных ресурсов, см. readme, скорее всего получится найти оффсеты на другие версии там.
Про аризону вообще не шарю что они используют, так что подсказать не могу.

Рад что не мне одному помогло :)
 
  • Нравится
Реакции: NegrVkletke

NegrVkletke

Известный
69
14
Получилось полностью перенести ваш проект на версии SAMP 0.3.7 R3-1 и SF 5.5.0 без особых проблем, но при запуске аризоновской версии при хуках диалогов происходит краш по нарушению безопасности :(
Полагаю аризона делает хуки диалогов и ломает их полностью
Для поиска CDialogCloseHook использовал следующий паттерн
60 FF ?? ?? ?? E8 ?? ?? ?? ?? 58 61 FF ?? ?? ?? ?? ??
1755448395263.png
 

SR_team

like pancake
BH Team
4,919
6,617
C++ Language Standard до 17
Так удобно выходит, что в C# на таком же механизме построена асинхронка (async/await), и мы можем с помощью кастомного SynchronizationContext буквально приравнять классическое шарповое await Task.Delay(100); к луашному wait(100).
Поднимай language Standard до 20 и в C++ будут коритины - сам co_await аналог await в C#.

Полагаю аризона делает хуки диалогов и ломает их полностью
хукаем, но не ломаем. Тот же lua camhack использует дефолт диалоги для настроек и все работает. Думаю это ты нашел не тот адрес паттерном из-за хуков или что-то не так делаешь
 
  • Нравится
Реакции: TheLeftExit

TheLeftExit

Участник
Автор темы
38
25
Получилось полностью перенести ваш проект на версии SAMP 0.3.7 R3-1 и SF 5.5.0 без особых проблем, но при запуске аризоновской версии при хуках диалогов происходит краш по нарушению безопасности :(
Полагаю аризона делает хуки диалогов и ломает их полностью
Для поиска CDialogCloseHook использовал следующий паттерн
60 FF ?? ?? ?? E8 ?? ?? ?? ?? 58 61 FF ?? ?? ?? ?? ?
Что может пойти не так с позиции моего кода:
- мой наколенный JumpHook вообще не читает что там по адресу, а пишет относительный адрес до &HookProc. Если там же что-то ещё хукает - всё плохо. То есть совместимости с другими хуками ноль (не считая sampfuncs).
- есть механизм чтобы, если находится sampfuncs, хукнуть сампфункс потому что вышеупомянутый наколенный хук с ним не дружит. Можно глянуть, куда хукает твоя версия sf, и поставить хук в это место в sf. Подробнее в CDialogCloseHook.txt, CDialogCloseHook_SF.cs (HookManager.cs берёт _SF версию хука, если SF подгружен).

Как я искал адрес, куда хукаться в SF:
- убираешь шарповый плагин чтоб не мешался
- цепляешься к gta_sa дебаггером (у меня visual studio)
- из окна Modules берёшь базовые адреса samp.dll и sampfuncs.asi
- прибавляешь к базовому адресу samp.dll оффсет CDialog::Close, затем смотришь этот адрес в окне Disassembly
- мысленно дисассемблируешь байты по этому адресу и считаешь, куда оно прыгает относительно базового адреса sampfuncs.asi
- открываешь sampfuncs.asi в IDA по посчитанному адресу, и смотришь куда бы там хукнуться
have fun :)

если твоя версия SF совместима в твоей сборкой аризоны, то проще правда хукнуться в SF и не заморачиваться - SF за тебя нормально хукнёт где надо, а в сам SF вроде никто не хукает
 
Последнее редактирование:
  • Нравится
Реакции: NegrVkletke