diff --git a/rust/minimal-example/.cargo/config b/rust/minimal-example/.cargo/config new file mode 100644 index 0000000..1d9037a --- /dev/null +++ b/rust/minimal-example/.cargo/config @@ -0,0 +1,11 @@ +[target.i686-pc-windows-msvc] +rustflags = [ + # Allows us to export the correct stdcall symbol names for Windows 32-bit binaries. + # Rust has no way to explicitly export a symbol named "_RVExtension@12", it cuts off the @12. + # This overrides the linker's /DEF argument, to force it to export the symbols we want. + "-Clink-arg=/DEF:Win32.def", + # Generate a map file so we can see what symbols exist, and what we're exporting. + "-Clink-arg=/MAP:SymbolInfo.map", + # Add exported symbol info to the map file + "-Clink-arg=/MAPINFO:EXPORTS" +] \ No newline at end of file diff --git a/rust/minimal-example/.gitignore b/rust/minimal-example/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/rust/minimal-example/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/rust/minimal-example/Cargo.toml b/rust/minimal-example/Cargo.toml new file mode 100644 index 0000000..91eec12 --- /dev/null +++ b/rust/minimal-example/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test_extension" +version = "0.1.0" +authors = ["Spoffy"] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type=["cdylib"] + +[dependencies] + diff --git a/rust/minimal-example/README.md b/rust/minimal-example/README.md new file mode 100644 index 0000000..6c081d2 --- /dev/null +++ b/rust/minimal-example/README.md @@ -0,0 +1,67 @@ +# Readme + +This project is an example of how to create a cross-platform Arma extension in Rust. +The extension has no dependencies except on the core Rust libraries, and builds into a single .dll. + +The extension will run on Linux and Windows, both 32-bit and 64-bit. + +It supports: +* RVExtensionVersion +* RVExtension +* RVExtensionArgs +* RVExtensionRegisterCallback + +## Author + +Spoffy (https://github.com/Spoffy/Rust-Arma-Extension-Example) + +## Motivation + +Rust is a memory-safe, fast language that, while it has a bit of a learning curve, is much less prone to weird bugs in production than C and C++ (in my opinion). +It has the added advantage that its standard library is cross-platform, so it's possible to easily build Windows and Linux compatible extensions - particularly when utilising things such as file access and networking. + +## Installation + +You'll need to install an appropriate rust toolchain. It's recommended to use `rustup` for this on both Windows and Linux, as you'll likely be needing to cross-compile. Also, it's really easy. + +Once you have a toolchain installed, clone this package, then execute `cargo build`, to build the library as appropriate for your platform. + +If you're running on 64-bit Windows or Linux, you'll need to rename the .dll or .so to end in `\_x64.(so/dll)`. It's also recommended (but not required) to remove the 'lib' prefix on Linux, as it's not needed. + +You then need to add your .dll or .so to the Arma server's root directory (same as where the server binary is), or into an addon. (mods/@MyExtension/MyThing.dll). I've personally found the root directory to be more reliable for testing purposes. + +## 32-bit Linux Compilation (Cross-Compiled) + +Most Linux servers these days are 64-bit. However, the Arma Linux server is typically 32-bit. As such, you'll generally need to cross-compile. + +If you're using `rustup` to manage toolchains, you can just do `rustup target add i686-unknown-linux-gnu`, then build the .so using `cargo build --target=i686-unknown-linux-gnu`. + +## 32-bit Windows Compilation (Cross-Compiled) + +Firstly, you'll need to make sure you have a 32-bit toolchain. I recommend using `rustup`, so you can run `rustup target add i686-pc-windows-msvc`. + +To cross-compile a 32-bit Windows library on 64-bit windows, simply type `cargo build --target=i686-pc-windows-msvc`. + +Only the MSVC toolchain is supported for 32-bit Windows compilation, due to the nature of the hacks needed to make it work (see .cargo/config for specifics). + +### 32-bit Compilation Workarounds + +**This is an implementation detail. If you just want to build the example, you do not need to read this section**. + +Broadly speaking, Windows uses "stdcall" calling conventions to call functions in the DLL. In 32-bit Windows, the symbol names for these functions are 'decorated', meaning they look something like this: `_RVExtension@12`. The @12 being the number of bytes of parameters the functions take. + +As it happens, in the rust compiler's infinite wisdom, it generates a '.def' file for the MSVC toolchain linker, that describes the exported symbols. Unfortunately for us, there's no way to make it export the decorated functions (that I've been able to find). + +Instead, we override the .def generated by Rust with our own file `Win32.def`, which exports the symbols with the correct names. + +This means we don't transitively export any dependencies that our Rust code imports, which is a bit of a shame, but it doesn't seem to break anything. +This also is why the GCC toolchain is Windows is *not* supported. If someone wants to explore that possibility, feel free! + +## Alternatives + +This is meant more as an example piece of code, than something to be used in production (though it's entirely possible to use this in production). + +As such, it isn't a particularly *nice* interface between Arma and Rust - it's pretty much the bare minimum to create a safe implementation. + +Synixebrett has created a much more fully-featured Rust-Arma interface using some rather fancy Rust macros. If you're looking for something that hides away most of the implementation for you, it's probably worth checking out: https://github.com/synixebrett/arma-rs + diff --git a/rust/minimal-example/Win32.def b/rust/minimal-example/Win32.def new file mode 100644 index 0000000..9b5c68e --- /dev/null +++ b/rust/minimal-example/Win32.def @@ -0,0 +1,6 @@ +LIBRARY +EXPORTS + _RVExtensionVersion@8 + _RVExtension@12 + _RVExtensionArgs@20 + diff --git a/rust/minimal-example/src/lib.rs b/rust/minimal-example/src/lib.rs new file mode 100644 index 0000000..b177ae9 --- /dev/null +++ b/rust/minimal-example/src/lib.rs @@ -0,0 +1,114 @@ +use std::os::raw::c_char; +use std::ffi::{CString, CStr}; +use std::str; +use std::cmp; + +type size_t = usize; + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn RVExtensionVersion(output_ptr: *mut i8, output_size: size_t) { + unsafe { write_str_to_ptr("Test Extension v.1.00", output_ptr, output_size) }; +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn RVExtension( + //Use a u8 here to make copying into the response easier. Fundamentally the same as a c_char. + response_ptr: *mut c_char, + response_size: size_t, + request_ptr: *const c_char, +) { + // get str from arma + let request: &str = {unsafe { CStr::from_ptr(request_ptr) }}.to_str().unwrap(); + + // send str to arma + unsafe { write_str_to_ptr(request, response_ptr, response_size) }; +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn RVExtensionArgs( + response_ptr: *mut c_char, + response_size: size_t, + function_name_ptr: *const c_char, + args_ptr: *const *const c_char, + argsCount: i32 +) { + let function_name: &str = {unsafe { CStr::from_ptr(function_name_ptr) }}.to_str().unwrap(); + let mapped_args = unsafe { + //This is a safe cast, as long as argsCount isn't negative, which it never should be. Even so, debug_assert it. + debug_assert!(argsCount >= 0); + let arg_ptrs = std::slice::from_raw_parts(args_ptr, argsCount as usize); + arg_ptrs.iter() + .map(|&ptr| CStr::from_ptr(ptr)) + .map(|cstr| cstr.to_str()) + }; + + //Args here + let args: Vec<&str> = match(mapped_args.collect()) { + Ok(args) => args, + Err(_) => return + }; + + //Handle here + let response_message = "Test Response"; + unsafe { write_str_to_ptr(response_message, response_ptr, response_size) }; +} + +type ArmaCallback = extern fn(*const c_char, *const c_char, *const c_char) -> i32; + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn RVExtensionRegisterCallback( + callback: ArmaCallback +) { + let name = "Test Extension"; + let func = "Test Function"; + let data = "Test Data"; + + call_extension_callback(callback, name, func, data); +} + +/// A safer-way to call an extension callback. +/// Verifies input is ASCII, and turns &str into null-byte terminated C strings. +pub fn call_extension_callback(callback: ArmaCallback, name: &str, func: &str, data: &str) -> Option<()> { + if !(name.is_ascii() && func.is_ascii() && data.is_ascii()) {return None}; + + //Verify we created all of the CStrings successfully. + let cstr_name = CString::new(name).ok()?; + let cstr_func = CString::new(func).ok()?; + let cstr_data = CString::new(data).ok()?; + + //into_raw() releases ownership of them. Arma becomes responsible for cleaning the strings up. + callback(cstr_name.into_raw(), cstr_func.into_raw(), cstr_data.into_raw()); + Some(()) +} + +/// Copies an ASCII rust string into a memory buffer as a C string. +/// Performs necessary validation, including: +/// * Ensuring the string is ASCII +/// * Ensuring the string has no null bytes except at the end +/// * Making sure string length doesn't exceed the buffer. +/// # Returns +/// :Option with the number of ASCII characters written - *excludes the C null terminator* +unsafe fn write_str_to_ptr(string: &str, ptr: *mut c_char, buf_size: size_t) -> Option { + //We shouldn't encode non-ascii string as C strings, things will get weird. Better to abort, I think. + if !string.is_ascii() {return None}; + //This should never fail, honestly - we'd have to have manually added null bytes or something. + let cstr = CString::new(string).ok()?; + let cstr_bytes = cstr.as_bytes(); + //C Strings end in null bytes. We want to make sure we always write a valid string. + //So we want to be able to always write a null byte at the end. + let amount_to_copy = cmp::min(cstr_bytes.len(), buf_size - 1); + //We provide a guarantee to our unsafe code, that we'll never pass anything too large. + //In reality, I can't see this ever happening. + if amount_to_copy > isize::MAX as usize {return None} + //We'll never copy the whole string here - it will always be missing the null byte. + ptr.copy_from(cstr.as_ptr(), amount_to_copy); + //strncpy(ptr, cstr.as_ptr(), amount_to_copy); + //Add our null byte at the end + ptr.add(amount_to_copy).write(0x00); + Some(amount_to_copy) +} +