Code Guidelines
Project Settings
As a general rule of thumb, start with reloaded-library-template.
Use the following settings:
- ✅ Include Documentation
- ✅ Cross Platform Testing Instructions & GitHub Actions Test Runs
- ✅ Run Automated Tests against Wine
- 📔 When writing libraries that call OS APIs. (Including simple file reads, etc.)
- ✅ Build C Libraries in CI
- ✅ Std by Default (via STD feature)
About Optimizing for Binary Size
These are the guidelines for writing compact code.
In other words, building binaries with small sizes.
Consider parts of this page as an extension of johnthagen/min-sized-rust.
While the former is about how you can tweak compilation options to achieve a small binary, this will give some guidance on how to structure new code to meet the code size guidelines for the mod loader.
When to NOT Over-Optimize for Size
In some edge cases concessions must be made.
There's always a tradeoff in terms of what we can do.
Some systems are simply too complex to make replacements for.
For example:
- No sane person would make a full UI framework for R3.
- No sane person would make an SDL replacement for controllers.
- You're not going to buy 100s of controllers to match SDL's gamecontrollerdb.txt.
- Having the user's gamepad pre-configured is much more valuable than the ~100kB of code size.
Before over-optimizing, always consider the tradeoffs involved.
Sometimes the extra space used by a non-specialized off the shelf library may be acceptable.
Although Reloaded3 strives for maximum efficiency and 'perfectionism', sometimes having things 'just work' on the user machines can be just as 'perfect' as squeezing every byte out.
Sometimes you can just improve the existing solution.
Replacing gamecontrollerdb.txt
with a binary version for example, would save binary size,
and disk space.
When Optimizing for Size is Encouraged
Apply this to any reusable code that could possibly run inside a 32-bit environment.
Below are some examples of good places to optimize.
General Purpose Libraries/Code:
Basically all universal 'Essential' mods in this wiki, and the libraries they may use.
- ✅ DLL Injector: Can be used to inject into child processes.
- ✅ Code Hooking Library: Used by all code mods.
- ✅ Virtual FileSystem: Used by most games.
- ✅ File Emulation Framework: Used by many games.
Middleware/Engine Specific Code:
- ✅ Middleware Handling Mods (Layer 1): Used in various games of various sizes.
General Purpose Tools:
- ⚠️ Animated Texture Injector:
- Unlikely to be optimizing here for code size... BUT.
- MAKE SURE users use optimized texture formats, otherwise 32-bit games will very quickly run out of address space.
Game Specific Code:
- ⚠️ Game {X} Support (Layer 2)
- Unless the game's extremely simple, consider keeping the code small.
- 'Simple' meaning 'no sane person would stuff it with 100s of stupidly sized textures'.
Mod Management Specific Code:
These can be ported to embedded systems, so should be optimized.
- ✅ Any code for the
loader
server. - ✅ Any code that can be used inside mods.
Guidelines
[Size & Perf] Be smart with library usage
Sometimes you can be a bit smarter than libraries.
This includes the standard library.
Below is an example.
Suppose you want to take an action on every directory up to a certain file path. For example, to ensure that all directories in a path exist.
The standard 'idiomatic' way would be to write it like this:
fn process_path(path: &str) {
let mut current_path = PathBuf::with_capacity(path.len());
current_path.push("/");
for component in path.split('/') {
if !component.is_empty() {
current_path.push(component);
// Do something with directory
handle_directory(¤t_path);
}
}
}
However, you can do better. You can work with a raw string directly?
unsafe fn process_path(path: &str) {
let mut current_path = String::with_capacity(path.len());
current_path.push('/');
for component in path.split('/') {
if !component.is_empty() {
current_path.push_str(component);
// Do something with directory.
handle_directory(¤t_path);
current_path.push('/');
}
}
}
But how is this better?
Consider the implementation of push in PathBuf
and note all the edge cases:
- If
path
is absolute, it replaces the current path- If
path
has a root but no prefix (e.g.,\windows
), it replaces everything except for the prefix (if any) ofself
.- If
path
has a prefix but noroot
, it replacesself
.- If
self
has a verbatim prefix (e.g.\\?\C:\windows
) andpath
is not empty, the new path is normalized: all references to.
and..
are removed.
In this context, because we are constructing a unix path, which is guaranteed to begin with /
,
and are always appending a directory.
We don't need to handle any of these edge cases. The code for that is unnecessary, and will unnecessarily bloat the binary and make things slower.
Sometimes it's better to use libc
rather than std
for minimizing code size.
[Size & Perf] Review (Vet) Library Source Code
Have a peek into implementation of 3rd party libraries.
In particular small libraries that likely haven't had many eyes on them.
When writing code, especially C# code where the barrier of entry is low, it's easy for authors to write code which is far from optimal.
Take for example A Semi-Popular Avalonia Icon Library.
To load a material design icon from this library...
- You Embed all 7447 Icons Inside your Binary (+ ~3.5MB binary size)
- Load an Icon from Embedded Resource
- Parse Icon SVG for Properties using Uncompiled Regex
- Parse Icon SVG Path to create Icon
The loading process restarts every single time you want to load an icon. If you have 10 buttons with the same icon, the that entire loading process (from extracting embedded resource) repeats 10 times.
Also changing the colour of the icon repeats all of the loading steps again.
This is a bit of a more extreme example, but it is very easy to take a dependency on something that may not be very optimal. A lot of stars on GitHub does not necessarily always have to speak on the quality of the code.
For smaller libraries, consider a quick run down through their source code.
If you see something that could be optimized, consider making a PR. Or write your own alternative.
[Size] Use Feature Flags for Optional Functionality
Use feature flags to disable functionality which is not always a hard requirement.
As a practical example, consider an archive library like C# Nx Archive Library I wrote as part of my day-to-day job.
In the Nx
library, multiple compression algorithms are supported, namely Zstandard
and LZ4
.
However, LZ4 is only used for very special cases, 99% of the time, Zstandard is used. Therefore it would be worthwhile to be able to disable LZ4 support.
5.1 Cargo.toml
Put support for non-standard compression as a flag.
[dependencies]
lz4 = { version = "1.24", optional = true }
zstd = { version = "0.11", optional = true }
[features]
default = ["zstd-compression", "lz4-compression"]
zstd-compression = ["zstd"]
lz4-compression = ["lz4"]
5.2 Usage
Users can now choose which compression algorithms to include
# Include both LZ4 and Zstandard (default)
archive-lib = "0.1.0"
# Include only ZStd
archive-lib = { version = "0.1.0", default-features = false, features = ["zstd-compression"] }
Other Benefits
In addition to reducing binary size, this approach also reduces compilation time, which for Rust projects can be extremely valuable.
Portability Guidelines
The modding framework strives to be portable to even embedded systems.
Below are guidelines to ensure your code is portable to a wide range of platforms.
Start with the Reloaded Library Template.
1. Use no_std
as a Foundation
When aiming for maximum portability, start with no_std
as your base:
#![no_std]
// The template will take care of this for you.
This approach ensures your code doesn't rely on the standard library (std
).
We will instead be leveraging subsets of std
like core
and alloc
.
use core::fmt;
use core::ops::Add;
These are not platform-specific and can be used across all platforms.
Prefer no_std
code and libraries where possible.
2. Abstraction Layers with Auto-Enabled Features
Create trait-based abstractions for platform-specific features
This approach uses build.rs
to automatically enable features based on the target platform,
allowing for clean separation of platform-specific code.
We'll be using a filesystem
as it is an easy to understand example.
However do note, sometimes you can find libraries (example)
that can supply missing std
functionality.
2.1 Project Structure
my_portable_lib/
├── Cargo.toml
├── build.rs
└── src/
├── lib.rs
├── fs.rs
└── platforms/
├── std.rs
├── vita.rs
└── switch.rs
2.2 build.rs
This automatically enables a feature based on the build target.
This will make it easier to add conditional compiles like down the line.
#[cfg(feature = "switch")]
is cleaner then#[cfg(all(target = "aarch64-nintendo-switch-freestanding", not(feature = "std")))]
fn main() {
let target = std::env::var("TARGET").unwrap();
match target.as_str() {
"armv7-sony-vita-newlibeabihf" => {
println!("cargo:rustc-cfg=feature=\"vita\"");
}
"aarch64-nintendo-switch-freestanding" => {
println!("cargo:rustc-cfg=feature=\"switch\"");
}
_ => {
println!("cargo:rustc-cfg=feature=\"std\"");
}
}
}
vita
feature forarmv7-sony-vita-newlibeabihf
target.switch
feature foraarch64-nintendo-switch-freestanding
target.std
feature for all other targets.
2.3 Cargo.toml
Make sure Cargo.toml
includes the features.
[package]
name = "my_portable_lib"
version = "0.1.0"
[features]
default = []
std = []
vita = []
switch = []
2.4 lib.rs
mod fs;
pub use fs::FileSystem;
#[cfg(feature = "std")]
mod platforms {
mod std;
pub use self::std::FS;
}
#[cfg(feature = "vita")]
mod platforms {
mod vita;
pub use self::vita::FS;
}
#[cfg(feature = "switch")]
mod platforms {
mod switch;
pub use self::switch::FS;
}
// This provides access to abstraction without
// knowing the specific implementation
pub use fs::FileSystem;
pub use platforms::Fs as FS;
// Platform-agnostic usage with zero-cost abstraction
pub fn use_file_system() {
// Use FS::open(), FS::rename(), FS::delete()
// without knowing the specific implementation
let _ = FS::open("example.txt");
let _ = FS::rename("old.txt", "new.txt");
let _ = FS::delete("unwanted.txt");
}
2.5 fs.rs
Defines the FileSystem
abstraction.
pub trait FileSystem {
fn open(path: &str) -> Result<File, FileError>;
fn rename(from: &str, to: &str) -> Result<(), FileError>;
fn delete(path: &str) -> Result<(), FileError>;
}
// Define File and FileError here
2.6 Platform-Specific Implementations
use crate::fs::{FileSystem, File, FileError};
pub struct FS;
impl FileSystem for FS {
fn open(path: &str) -> Result<File, FileError> {
// Implementation using std::fs
}
fn rename(from: &str, to: &str) -> Result<(), FileError> {
// Implementation using std::fs
}
fn delete(path: &str) -> Result<(), FileError> {
// Implementation using std::fs
}
}
platforms/vita.rs
and platforms/switch.rs
would follow a similar pattern, implementing the
FileSystem
trait for their respective platforms.
2.7 Usage in External Crate
use my_portable_lib::FS;
fn main() {
let _ = FS::open("example.txt");
}
This structure allows for:
- Automatic feature selection based on the build target.
- Clean separation of platform-specific code.
- A common interface (
FileSystem
trait) for all platforms. - Platform-agnostic usage through the
FileSystem
type alias.
The build.rs
script enables the appropriate feature based on the target, and the rest of the
code uses these features to conditionally compile the correct implementation.
3. Enable Alternative Allocators
This requires nightly Rust.
However, you can use allocator-api2 to polyfill this for stable if desired.
When writing portable libraries, allow for use of alternative allocators to support various memory management needs.
Sometimes switching the allocator can also bring performance benefits.
Not just portability benefits! Therefore, it's a good idea to follow this guideline. For example you may want to use a separate allocator for small, short lived objects.
struct IndexBuffer<A: Allocator = Global> {
data: Vec<u32, A>,
}
impl<A: Allocator> IndexBuffer<A> {
// Constructor for custom allocator
pub fn new_in(allocator: A) -> Self {
Self {
data: Vec::new_in(allocator),
}
}
}
impl IndexBuffer {
// Constructor with default allocator
pub fn new() -> Self {
Self::new_in(Global)
}
}
// And if working with manual memory
impl<A: Allocator> Drop for IndexBuffer<A> {
fn drop(&mut self) {
// Code here.
}
}
This enables for usage like
// Using default allocator
let t1 = Tessellator::new();
// Using custom allocator
use std::alloc::System;
let t2 = Tessellator::new_in(System);
For custom allocators prefer static dispatch. If possible, prefer using zero sized types for allocators, as to not incur overhead.
Adding an allocator parameter can affect inlining.
Make sure to benchmark your code, even if the parameter is zero sized.
3.1 Allocating uninitialized memory.
In place where you would use alloc::alloc::alloc
or alloc::alloc::dealloc
, consider using the provided allocator.
// Constructor for custom allocator
pub fn new_in(allocator: A) -> Self {
let layout = Layout::array::<u32>(5).unwrap();
// ❌ Don't use `alloc` (global allocator)
let alloc = alloc(layout);
// ✅ Instead use supplied allocator
let alloc = allocator.allocate(layout);
}
When possible, avoid unsafe allocation and use safe constructs.
Creating an array of 5 elements, in custom allocator, uninitialized.
Requires Nightly Rust.
let mut boxed_array = Box::<[i32], A>::new_uninit_slice_in(5, allocator);
let mut boxed_array = unsafe {
// Deferred initialization:
boxed_array[0].as_mut_ptr().write(1);
boxed_array[1].as_mut_ptr().write(2);
boxed_array[2].as_mut_ptr().write(3);
boxed_array.assume_init()
};
// More verbose alternative to first line (commented)
// let mut boxed_array: Box<[MaybeUninit<i32>], A> = Box::new_uninit_slice_in(5, allocator);
3.2 Guidance for Using Allocators
Using custom allocators can be a bit tricky.
Because allocator_api
currently isn't very well documented.
For additional guidance, see Demystifying Rust Allocators page.
4. Stack Memory Allocation
When writing portable code, be aware of the varying thread stack sizes across different platforms
Often you might want to allocate things on the stack for performance, but that can't always be done.
4.1 Common Platform Stack Sizes
Note that these sizes can vary based on specific configurations:
- Windows:
- Default: 1 MiB
- Can be configured up to 8 MB
- macOS:
- Default: 8 MiB
- Linux:
- Default: 8 MiB
As a general rule of thumb, try to avoid allocating objects larger than 100K on the stack.
Esoteric and Embedded Platforms
This is provided for completion.
If you're an external contributor, you're not expected to keep these in mind. More Info Here
For these sorts of platforms, such as sixth-generation consoles and embedded devices, assume the stack size is around 64K on main thread and around 4K on non-main threads. For these platforms, avoid >1000 byte stack allocations.
4.2 Mixing Stack and Heap Allocation
Sometimes it's possible to allocate on Stack with Heap fallback.
For cases where you need fast allocation but it's possible the number of items is small.
SmallVec is a vector-like container that stores a small number of elements on the stack, falling back to the heap for larger allocations.
Example usage:
use smallvec::{SmallVec, smallvec};
// A SmallVec that can hold up to 4 elements on the stack
let mut vec: SmallVec<[u32; 4]> = smallvec![1, 2, 3];
vec.push(4); // Still on the stack
vec.push(5); // This will cause a heap allocation
assert_eq!(vec[0], 1);
assert_eq!(vec.len(), 5);
This helps boost performance by avoiding heap allocation for small collections.
5. Implement Abstractions for External Code
When interacting with external libraries or low-level systems, create safe Rust abstractions to manage unsafe operations.
Don't trust that users downstream will manage memory correctly. Not even I trust myself.
Most important libraries will already have bindings.
But if you need to create new ones, follow the pattern below.
Consider a C library that provides functions to create and free an object (e.g. array):
// C library functions
void* create_array(size_t size);
void free_array(void* ptr);
5.1 Declare the C exports
This should be automated with bindgen
// FFI declarations
extern "C" {
fn create_array(size: usize) -> *mut std::ffi::c_void;
fn free_array(ptr: *mut std::ffi::c_void);
}
5.2 Create a safe Rust wrapper
This wrapper ensures memory is not leaked.
pub struct SafeArray {
ptr: *mut u8,
len: usize,
}
impl SafeArray {
pub fn new(size: usize) -> Option<Self> {
let ptr = unsafe { create_array(size) as *mut u8 };
if ptr.is_null() {
None
} else {
Some(SafeArray { ptr, len: size })
}
}
}
impl Drop for SafeArray {
fn drop(&mut self) {
unsafe { free_array(self.ptr as *mut std::ffi::c_void) }
}
}
In this specific example, implementing Deref
for automatic conversion to a slice is useful:
use core::ops::{Deref, DerefMut};
impl Deref for SafeArray {
type Target = [u8];
fn deref(&self) -> &Self::Target {
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl DerefMut for SafeArray {
fn deref_mut(&mut self) -> &mut Self::Target {
unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) }
}
}
5.3 Use the safe Rust Abstraction
let array = SafeArray::new(10).expect("Failed to allocate array");
// Use array as a normal Rust slice
array[0] = 42;
// Memory is automatically freed when `array` goes out of scope
5.4 Summary
- The
SafeArray
struct encapsulates the raw pointer and length. Deref
andDerefMut
implementations provide safe access to the array contents.- The
Drop
implementation ensures the memory is freed when theSafeArray
is dropped. - All unsafe operations are contained within the implementation, providing a safe public API.
- Error handling is implemented for allocation failures.
Always document unsafe code and explain why it's necessary and how safety is upheld.
5.5 How to Implement this Pattern
The standard for Rust is creating a -sys
crate with the C exports, and a -safe
crate with the safe Rust abstractions.
In other words:
- The
-sys
crate should contain the raw FFI declarations (create_array
andfree_array
).- This video can be used as a primer.
- Combine with bindgen docs and cc docs.
- The
-safe
crate should define the safe abstractions (SafeArray
struct).- The end of this guide and its part 2 give some good advice.
- The
-safe
crate depends on the-sys
crate, providing a safe API for end-users.
This separation allows users to choose between raw bindings and safe abstractions, and facilitates maintenance and updates of the FFI layer.
Reloaded3 does not integrate with native package managers.
Do not attempt to dynamic link to non-system libraries. There is not yet an available mechanism
that says e.g. dynamic link zstd if installed
. It is unlikely that there will be one
any time soon.
6. Testing Across Different Platforms & Architectures
To ensure correctness, please test your code on multiple platforms.
As a general rule of thumb, test your code against:
- At least 1 CPU architecture (e.g. x64 + ARM64)
- Different word sizes (e.g. 32-bit + 64-bit)
- Different endianess (e.g. little-endian + big-endian)
- Do this if serializing data, e.g. doing networking or writing archive formats, etc.
The reloaded-library-template is set up to do this out of the box.
Provided you enabled cross-platform testing when creating the library.
Use cross
for testing on different architectures.
If you need to locally test, do use cross
.
Example: cross test --target powerpc-unknown-linux-gnu
(32-bit, big-endian)
You should find more detailed instructions in the reloaded-library-template.
7. Avoid Panic
Panic should be avoided where possible in portable reloaded3 libraries.
panic!
in Rust can lead to several issues:
- It creates unwind tables, increasing binary size.
- It can negatively impact performance when mixed together with
Result<T, E>
. - It's inconvenient for C exports; because you have to catch it, which adds overhead and inconvenience.
- If not caught, it leads to undefined behaviour.
- You need to recursively document it in the docstrings; which is painful.
7.1 Don't Panic unless Absolutely Necessary
You should only panic on truly unrecoverable errors
// Define a simple error enum
#[derive(Debug, PartialEq)]
enum ValidationError {
TooSmall,
TooBig,
}
// ❌ Don't use methods that can panic
fn risky_validate(num: i32) -> i32 {
assert!(num >= 1, "Number too small"); // This can panic
assert!(num <= 100, "Number too big"); // This can panic
num
}
// ✅ Do use Result with an enum error type to handle potential errors
fn safe_validate(num: i32) -> Result<i32, ValidationError> {
if num < 1 {
Err(ValidationError::TooSmall)
} else if num > 100 {
Err(ValidationError::TooBig)
} else {
Ok(num)
}
}
Here the validation panic is undesireable, as it can be handled gracefully by the caller.
Only panic if you have 100% unrecoverable errors where you absolutely have to abort the program.
7.2 Use Result<T, E>
Instead of Panicking
Always prefer returning a Result<T, E>
instead of using operations that might panic.
This allows the caller to handle errors gracefully, and also lets them be aware of all possible error cases.
// Define an error enum
#[derive(Debug, PartialEq)]
enum DivisionError {
DivideByZero,
}
// ❌ Don't use methods that can panic
fn risky_divide(a: i32, b: i32) -> i32 {
a / b // This will panic if b is zero
}
// ✅ Do use Result with an enum error type to handle potential errors
fn safe_divide(a: i32, b: i32) -> Result<i32, DivisionError> {
if b == 0 {
Err(DivisionError::DivideByZero)
} else {
Ok(a / b) // Compiler will infer it can't be 0
}
}
If you can guarantee that b
cannot be zero, use unchecked_div
.
// ✅ Do use unchecked_div if you can guarantee b is not zero
fn divide(a: i32, b: i32) -> i32 {
unsafe { a.unchecked_div(b) }
}
7.3 Avoid Indexing (Use Option<&T>
)
Indexing with []
can panic if the index is out of bounds.
Instead, use get()
which returns an Option<&T>
.
// ❌ Don't use indexing
let first = my_vec[0]; // This can panic
// ✅ Do use get() and handle the None case
let first = my_vec.get(0).copied().unwrap_or_default();
If you can guarantee at compile time that the index is within bounds, use unsafe
indexing
// ✅ Do use unsafe indexing if bounds are guaranteed
let first = unsafe { *my_vec.get_unchecked(0) };
7.4 Careful Use of unwrap()
and expect()
Avoid using unwrap()
or expect()
in production code, as these will panic on None
or Err
values.
// ❌ Don't use unwrap() or expect()
let value = some_operation().unwrap();
// ✅ Do handle the error case explicitly
let value = match some_operation() {
Ok(v) => v,
Err(e) => handle_error(e),
};
If you can guarantee at compile time the result will be valid, use a debug only check.
With the debug unwraps crate.
// ✅ Do use debug_unwrap_unchecked() if you can guarantee the result is valid
// This will only check result in debug builds.
let value = unsafe { some_operation().debug_unwrap_unchecked() };
Any use of this should be covered in tests, and ideally with a comment explaining why the guarantee is upheld if needed.
7.5 Safe Alternatives to Panicking Operations
Some standard library functions can panic.
Avoid panicking if possible, we don't want code bloat explained above.
For example, the std
function copy_from_slice
, panics if the slices have different lengths:
// ❌ Don't use copy_from_slice, which can panic
fn copy_data(destination: &mut [u8], source: &[u8]) {
destination.copy_from_slice(source); // Panics if lengths differ
}
// ✅ Do implement a safe alternative
fn safe_copy_data(destination: &mut [u8], source: &[u8]) -> Result<(), SomeErrorType> {
if destination.len() < source.len() {
return Err(SomeErrorType::DestinationTooSmall);
}
let copy_len = source.len();
unsafe { copy_nonoverlapping(source.as_ptr(), destination.as_mut_ptr(), copy_len); }
Ok(())
}
7.6 Use #[no_panic]
Attribute
You can use the #[no_panic]
attribute from the no-panic
crate.
This will cause a compile-time error if the function could potentially panic.
use no_panic::no_panic;
#[no_panic]
fn critical_function(x: u32) -> u32 {
x + 1 // This function is guaranteed not to panic
}