- 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 объектно-ориентировано, а передавать функции типа
Далее нам нужна структура, которую мы будем "передавать между языками программирования" и которую оба языка смогут понять. Мы будем передавать указатель на эту структуру в C# метод, и уже в C# коде мы завернём эти указатели в юзабельные функции.
Теперь нам нужно написать C# код, который будет принимать эту структуру.
Создаём C# проект типа Class Library и убеждаемся, что в .csproj файле включены NativeAOT и unsafe код:
Создаём C# структуру с таким же представлением в памяти, как
Чтобы воспользоваться этой структурой, осталось выполнить ещё два пункта. Сначала написать метод, с помощью которого наш C++ код сможет передать там эту структуру:
Затем ещё два метода, которыми мы уже будем пользоваться в C#, чтобы вызывать функции, закодированные в этой структуре. Помним, что char в C++ занимает один байт, а в C# два, так что нужно вручную конвертировать C# строки в UTF8 массивы C# byte перед отправкой в функции, принимающие C++ char*:
Все методы/поля, кроме указателей на функции, должны быть статическими - иначе потеряем эквивалентность с C++ структурой. По-хорошему стоит для функций и статического поля завести отдельный класс, но для наколенного решения так сойдёт.
И возвращаемся в plugin.cpp, чтобы позвать оттуда экспортированный метод
И наконец, собираем и устанавливаем получившуюся шаблонку. C++ проект собираем и .SF файл просто кладём в папку SAMPFUNCS. С C# проектом чуть сложнее:
- Создаём Publish Profile с публикацией в папку,
- Настраиваем публикацию: Deployment mode - Self-contained, Target runtime - win-x86. Таким образом у нас получится нативная DLL библиотека, подгружаемая в GTA SA.
- Публикуем и копируем получившийся DLL в ту же папку SAMPFUNCS.
Далее вкратце пример того, как эту шаблонку использовать.
В C# проект добавляем ещё один класс. Он будет содержать метод с бизнес логикой обработки чат-команды, и функцию-прослойку для вызова этого метода из C++. Наша тестовая функция просто берёт аргументы команды, делит их на слова и отправляет их в чат одно за другим, с задержкой:
Запуск бизнес логики в отдельном потоке позволяет нам пользоваться задержками (
Далее пишем прослойку в C++:
- Задаём указатель на функцию, эквивалентную
- Добавляем в наш метод
- Регистрируем чат-команду и отправляем её на обработку C# функции.
Повторяем процесс сборки и установки, запускаем SAMP, вводим
Самое сложное позади - теперь когда написаны прослойки, можно творить в своём методе
Если вы хотите заняться написанием 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
.