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

TheLeftExit

Участник
Автор темы
33
15
Последние версии .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

Участник
Автор темы
33
15
Так, я выше написал, что для запуска логики с задержками нужно запускать отдельный поток - это хреновый совет. Пока сам пользовался этим решением, наткнулся на проблемы в сценариях, когда код в потоке 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.