Getting Started Welcome Setting Up Game Tree GuiRoot Method
Writing a Memory Library Reading and Writing Page Iterator Pattern Scanning Buffer Strategies
Writing a Roblox Library C++ String

Reading and Writing

In this guide, we will write a simple memory library that allows us to interact with memory inside Roblox (or any process)

Reading

Let's start with reading.
Windows provide a function to do this called ReadProcessMemory.
All memory operations require a process handle which we can obtain using the OpenProcess function.
First let's write the most primitive read function that reads x amount of bytes and write it to a buffer and return the actual size read.
This allows for partial reads in case the full read failed.
We will wrap the process handle in a MemoryAccessor type so it's easier to work with.
Instead of using ReadProcessMemory, I will be using NtReadVirtualMemory instead as it has less overhead.

const std = @import("std");
const win32 = std.os.windows; // I recommend using https://github.com/marlersoft/zigwin32 instead

const NTSTATUS = i32;
pub extern "ntdll" fn NtReadVirtualMemory(
    hProcess: ?win32.HANDLE,
    lpBaseAddress: usize,
    lpBuffer: ?*anyopaque,
    nSize: usize,
    lpNumberOfBytesRead: ?*usize,
) callconv(.winapi) NTSTATUS;

pub const MemoryAccessor = struct {
    process_handle: win32.HANDLE,

    pub fn init(process_handle: win32.HANDLE) @This() {
        return .{
            .process_handle = process_handle,
        };
    }

    pub fn readBytes(this: @This(), address: usize, destination: []u8) !void {
        var bytes_read: usize = undefined;
        if(NtReadVirtualMemory(this.process_handle, address, destination.ptr, destination.len, &bytes_read) < 0)
            return error.ReadVirtualMemoryFailed;
    }
};

// Example usage
pub fn main() !void {
    const process_handle = win32.GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const memory_accessor: MemoryAccessor = .init(process_handle);

    const test_str = "I like cheese and cookies";
    const test_address = @intFromPtr(test_str);

    // print address of test_str
    std.debug.print("Address at: {X}\n", .{ test_address });

    // Read 25 bytes at the address of the string and write to buf
    var buf: [25]u8 = undefined;
    try memory_accessor.readBytes(test_address, &buf);

    // Print
    std.debug.print("Read: {s}", .{ buf });
}
#include <Windows.h>
#include <stdio.h>
#include <stdint.h>

typedef enum MemoryResult { 
    MEMORY_OK,
    MEMORY_ERROR_READ_VIRTUAL_MEMORY
} MemoryResult;

typedef struct MemoryAccessor {
    HANDLE process_handle;
} MemoryAccessor;

void MemoryAccessor_init(MemoryAccessor* const memory_accessor, const HANDLE process_handle) {
    memory_accessor->process_handle = process_handle;
}

NTSYSCALLAPI NTSTATUS NTAPI NtReadVirtualMemory(
    _In_ HANDLE ProcessHandle,
    _In_opt_ PVOID BaseAddress,
    _Out_writes_bytes_to_(NumberOfBytesToRead, *NumberOfBytesRead) PVOID Buffer,
    _In_ SIZE_T NumberOfBytesToRead,
    _Out_opt_ PSIZE_T NumberOfBytesRead
);

MemoryResult MemoryAccessor_readBytes(const MemoryAccessor* const memory_accessor, const uintptr_t address, uint8_t* const destination, const size_t num_of_bytes) {
    size_t num_of_bytes_read;
    if(NtReadVirtualMemory(memory_accessor->process_handle, (void*)address, destination, num_of_bytes, &num_of_bytes_read) < 0)
        return MEMORY_ERROR_READ_VIRTUAL_MEMORY;
    return MEMORY_OK;
}

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with OpenProcess

    // Create memory accessor
    MemoryAccessor memory_accessor;
    MemoryAccessor_init(&memory_accessor, process_handle);

    const char* const test_str = "I like cheese and cookies";
    const uintptr_t test_address = (uintptr_t)test_str;

    // Print address of test_str
    printf("Address at: %llx\n", test_address);

    // Read 25 bytes at the address of the string and write to buf
    char buf[26];
    if(MemoryAccessor_readBytes(&memory_accessor, test_address, (uint8_t*)&buf, 25) != MEMORY_OK) {
        puts("Failed to read memory");
        return -1;
    }

    // Set null terminator and print
    buf[25] = 0;
    printf("Read: %s\n", buf);
    return 0;
}
#include <Windows.h>
#include <array>
#include <iostream>
#include <cstdint>
#include <expected>
#include <variant>
#include <span>

extern "C" NTSYSCALLAPI NTSTATUS NTAPI NtReadVirtualMemory(
    _In_ HANDLE ProcessHandle,
    _In_opt_ PVOID BaseAddress,
    _Out_writes_bytes_to_(NumberOfBytesToRead, *NumberOfBytesRead) PVOID Buffer,
    _In_ SIZE_T NumberOfBytesToRead,
    _Out_opt_ PSIZE_T NumberOfBytesRead
);

class MemoryAccessor {
private:
    HANDLE process_handle;
public:
    MemoryAccessor(HANDLE _process_handle) : process_handle(_process_handle) {}
    std::expected<std::monostate, std::string> readBytes(const size_t address, std::span<uint8_t> bytes) const {
        size_t bytes_read;
        if(NtReadVirtualMemory(this->process_handle, reinterpret_cast<void*>(address), bytes.data(), bytes.size_bytes(), &bytes_read) < 0)
            std::unexpected("Error: Failed to read virtual memory");
        return std::monostamte();
    }
};

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const MemoryAccessor memory_accessor = MemoryAccessor(process_handle);

    const char* const test_str = "I like cheese and cookies";
    const uintptr_t test_address = reinterpret_cast<std::uintptr_t>(test_str);

    // Print address of test_str
    std::cout << "Address at: " << std::hex << test_address << '\n';

    // Read 25 bytes at the address of the string and write to buf
    std::array<char, 26> buf;
    if(auto result = memory_accessor.readBytes(test_address, std::span(reinterpret_cast<uint8_t*>(buf.data()), buf.size())); !result) {
        std::cout << result.error() << '\n';
        return -1;
    }

    // Set null terminator and print
    buf.at(25) = 0;
    std::cout << "Read: " << buf.data() << '\n';
    return 0;
}

As you can see, the Zig version is much easier to read and write and way shorter and less complicated than the c and c++ version.
That's why I recommend using Zig for this.

Now that we got our basic functions for reading bytes, let's extend it so we can read values and array of values directly.
We'll implement `read` which will allow us to read a value directly and `readSlice` to allow us to read an array of values.

// In MemoryAccessor
    pub fn readSlice(this: @This(), address: usize, T: type, destination: []T) !void {
        try this.readBytes(address, @as([*]u8, @ptrCast(destination.ptr))[0..destination.len * @sizeOf(T)]);
    }

    pub fn read(this: @This(), address: usize, T: type) !T {
        var result: T = undefined;
        try this.readBytes(address, @as([*]u8, @ptrCast(&result))[0..@sizeOf(T)]);
        return result;
    }
// End


// Example usage
pub fn main() !void {
    const process_handle = win32.GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const memory_accessor: MemoryAccessor = .init(process_handle);

    const test_value: u32 = 67;
    const test_value_address = @intFromPtr(&test_value);

    // Print address of test_value
    std.debug.print("Value Address at: {X}\n", .{ test_value_address });

    // Read u32 from test_value_address
    const value_read_result = try memory_accessor.read(test_value_address, u32);

    // Print read value
    std.debug.print("Read value: {d}\n", .{ value_read_result });

    const test_slice: [3]u32 = .{ 69, 420, 67 };
    const test_slice_address = @intFromPtr(&test_slice);

    // Print address of test_slice
    std.debug.print("Value Slice at: {X}\n", .{ test_slice_address });

    // Read 3 u32 from test_slice_address
    var read_slice: [3]u32 = undefined;
    try memory_accessor.readSlice(test_slice_address, u32, &read_slice);
    
    // Print slice read
    std.debug.print("Read slice:", .{});
    for(read_slice) |val|
        std.debug.print(" {d}", .{ val });
    std.debug.print("\n", .{});
}
#define MemoryAccessor_readSlice(memory_accessor, address, destination, len) MemoryAccessor_readBytes((memory_accessor), (address), (uint8_t*)(destination), (len * sizeof(typeof(*(destination)))))

#define MemoryAccessor_read(memory_accessor, address, destination) MemoryAccessor_readBytes((memory_accessor), (address), (uint8_t*)(destination), sizeof(typeof(*(destination))))

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with OpenProcess

    // Create memory accessor
    MemoryAccessor memory_accessor;
    MemoryAccessor_init(&memory_accessor, process_handle);

    const uint32_t test_value = 67;
    const uintptr_t test_value_address = (uintptr_t)&test_value;

    // Print address of test_value
    printf("Value Address at: %llx\n", test_value_address);

    // Read u32 from test_value_address
    uint32_t value_read;
    if(MemoryAccessor_read(&memory_accessor, test_value_address, &value_read) != MEMORY_OK) {
        puts("Failed to read value");
        return -1;
    }

    // Print read value
    printf("Read value: %u\n", value_read);

    const uint32_t test_slice[] = { 69, 420, 67 };
    const uintptr_t test_slice_address = (uintptr_t)test_slice;

    // Print address of test_slice
    printf("Value Slice at: %llx\n", test_slice_address);

    // Read 3 u32 from test_slice_address
    uint32_t read_slice[3];
    if(MemoryAccessor_readSlice(&memory_accessor, test_slice_address, read_slice, sizeof(read_slice)/sizeof(*read_slice)) != MEMORY_OK) {
        puts("Failed to read slice");
        return -1;
    }

    // Print slice read
    fputs("Read slice:", stdout);
    for(uint32_t* value = read_slice;value < read_slice + sizeof(read_slice)/sizeof(*read_slice);++value)
        printf(" %u", *value);
    puts("");
    return 0;
}
// In MemoryAccessor
    template<typename T>
    std::expected<std::monostate, std::string> readSlice(const size_t address, std::span<T> slice) const {
        return this->readBytes(address, std::span(reinterpret_cast<uint8_t*>(slice.data()), slice.size_bytes()));
    }

    template<typename T>
    std::expected<T, std::string> read(const size_t address) const {
        T result;
        auto read_result = this->readBytes(address, std::span(reinterpret_cast<uint8_t*>(&result), sizeof(T)));
        if(!read_result)
            return std::unexpected(read_result.error());
        return result;
    }
// End

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const MemoryAccessor memory_accessor = MemoryAccessor(process_handle);

    const uint32_t test_value = 67;
    const uintptr_t test_value_address = reinterpret_cast<std::uintptr_t>(&test_value);

    // Print address of test_value
    std::cout << "Value Address at: " << std::hex << test_value_address << '\n';

    // Read u32 from test_value_address
    auto value_read_result = memory_accessor.read<uint32_t>(test_value_address);
    if(!value_read_result) {
        std::cout << value_read_result.error() << '\n';
        return -1;
    }

    // Print read value
    std::cout << "Read value: " << std::dec << value_read_result.value() << '\n';

    const std::array<uint32_t, 3> test_slice { 69, 420, 67 };
    const uintptr_t test_slice_address = reinterpret_cast<std::uintptr_t>(test_slice.data());

    // Print address of test_slice
    std::cout << "Value Slice at: " << std::hex << test_slice_address << '\n';

    // Read 3 u32 from test_slice_address
    std::array<uint32_t, 3> read_slice;
    auto slice_read_result = memory_accessor.readSlice(test_slice_address, std::span(read_slice.begin(), read_slice.end()));
    if(!slice_read_result) {
        std::cout << value_read_result.error() << '\n';
        return -1;
    }

    // Print slice read
    std::cout << "Read slice:" << std::dec;
    for(const auto value : read_slice)
        std::cout << ' ' << value;
    std::cout << std::endl;
    return 0;
}

Writing

For writing, it is pretty simple, we can just copy our code from before and replace it with `NtWriteVirtualMemory`.

pub extern "ntdll" fn NtWriteVirtualMemory(
    hProcess: ?win32.HANDLE,
    lpBaseAddress: usize,
    lpBuffer: ?*const anyopaque,
    nSize: usize,
    lpNumberOfBytesWritten: ?*usize,
) callconv(.winapi) NTSTATUS;

// In MemoryAccessor
    pub fn writeBytes(this: @This(), address: usize, bytes: []const u8) !void {
        var bytes_written: usize = undefined;
        if(NtWriteVirtualMemory(this.process_handle, address, bytes.ptr, bytes.len, &bytes_written) < 0)
            return error.WriteVirtualMemoryFailed;
    }

    pub fn writeSlice(this: @This(), address: usize, T: type, bytes: []const T) !void {
        try this.writeBytes(address, @as([*]const u8, @ptrCast(bytes.ptr))[0..bytes.len * @sizeOf(T)]);
    }

    pub fn write(this: @This(), address: usize, T: anytype) !void {
        try this.writeBytes(address, @as([*]const u8, @ptrCast(&T))[0..@sizeOf(@TypeOf(T))]);
    }
// End

// Example usage
pub fn main() !void {
    const process_handle = win32.GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const memory_accessor: MemoryAccessor = .init(process_handle);

    var test_value: u32 = 67;
    const test_value_address = @intFromPtr(&test_value);

    // Print address of test_value
    std.debug.print("Value Address at: {X}\n", .{ test_value_address });

    // write u32 to test_value_address
    try memory_accessor.write(test_value_address, @as(u32, 420));

    // Print test_value
    std.debug.print("Test value: {d}\n", .{ test_value });

    var test_slice: [3]u32 = .{ 69, 420, 67 };
    const test_slice_address = @intFromPtr(&test_slice);

    // Print address of test_slice
    std.debug.print("Value Slice at: {X}\n", .{ test_slice_address });

    // Write 3 u32 to test_slice_address
    try memory_accessor.writeSlice(test_slice_address, u32, &[_]u32 { 67, 67, 69 });
    
    // Print test_slice
    std.debug.print("Test slice:", .{});
    for(test_slice) |val|
        std.debug.print(" {d}", .{ val });
    std.debug.print("\n", .{});
}
NTSYSCALLAPI NTSTATUS NTAPI NtWriteVirtualMemory(
    _In_ HANDLE ProcessHandle,
    _In_opt_ PVOID BaseAddress,
    _In_reads_bytes_(NumberOfBytesToWrite) PVOID Buffer,
    _In_ SIZE_T NumberOfBytesToWrite,
    _Out_opt_ PSIZE_T NumberOfBytesWritten
);

MemoryResult MemoryAccessor_writeBytes(const MemoryAccessor* const memory_accessor, const uintptr_t address, const uint8_t* const source, const size_t num_of_bytes) {
    size_t num_of_bytes_written;
    if(NtWriteVirtualMemory(memory_accessor->process_handle, (void*)address, (void*)source, num_of_bytes, &num_of_bytes_written) < 0)
        return MEMORY_ERROR_WRITE_VIRTUAL_MEMORY;
    return MEMORY_OK;
}

#define MemoryAccessor_writeSlice(memory_accessor, address, source, len) MemoryAccessor_writeBytes((memory_accessor), (address), (const uint8_t*)(source), (len * sizeof(typeof(*(source)))))

#define MemoryAccessor_write(memory_accessor, address, source) MemoryAccessor_writeBytes((memory_accessor), (address), (const uint8_t*)(source), sizeof(typeof(*source)))

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with OpenProcess

    // Create memory accessor
    MemoryAccessor memory_accessor;
    MemoryAccessor_init(&memory_accessor, process_handle);

    uint32_t test_value = 67;
    const uintptr_t test_value_address = (uintptr_t)&test_value;

    // Print address of test_value
    printf("Value Address at: %llx\n", test_value_address);

    // Write u32 from test_value_address
    const uint32_t to_write = 420;
    if(MemoryAccessor_write(&memory_accessor, test_value_address, &to_write) != MEMORY_OK) {
        puts("Failed to write value");
        return -1;
    }

    // Print test_value
    printf("Test value: %u\n", test_value);

    uint32_t test_slice[] = { 69, 420, 67 };
    const uintptr_t test_slice_address = (uintptr_t)test_slice;

    // Print address of test_slice
    printf("Value Slice at: %llx\n", test_slice_address);

    // Write 3 u32 from test_slice_address
    uint32_t write_slice[] = { 67, 67, 69 };
    if(MemoryAccessor_writeSlice(&memory_accessor, test_slice_address, write_slice, sizeof(write_slice)/sizeof(*write_slice)) != MEMORY_OK) {
        puts("Failed to write slice");
        return -1;
    }

    // Print test_slice
    fputs("Test slice:", stdout);
    for(uint32_t* value = (uint32_t*)test_slice;value < test_slice + sizeof(test_slice)/sizeof(*test_slice);++value)
        printf(" %u", *value);
    puts("");
    return 0;
}
extern "C" NTSYSCALLAPI NTSTATUS NTAPI NtWriteVirtualMemory(
    _In_ HANDLE ProcessHandle,
    _In_opt_ PVOID BaseAddress,
    _In_reads_bytes_(NumberOfBytesToWrite) const void * Buffer,
    _In_ SIZE_T NumberOfBytesToWrite,
    _Out_opt_ PSIZE_T NumberOfBytesWritten
);

// In MemoryAccessor
    std::expected<std::monostate, std::string> writeBytes(const size_t address, std::span<const uint8_t> bytes) const {
        size_t bytes_written;
        if(NtWriteVirtualMemory(this->process_handle, reinterpret_cast<void*>(address), reinterpret_cast<const void*>(bytes.data()), bytes.size_bytes(), &bytes_written) < 0)
            std::unexpected("Error: Failed to write virtual memory");
        return std::monostate();
    }

    template<typename T>
    std::expected<std::monostate, std::string> writeSlice(const size_t address, std::span<T> slice) const {
        return this->writeBytes(address, std::span(reinterpret_cast<const uint8_t*>(slice.data()), slice.size_bytes()));
    }

    template<typename T>
    std::expected<std::monostate, std::string> write(const size_t address, const T value) const {
        return this->writeBytes(address, std::span(reinterpret_cast<const uint8_t*>(&value), sizeof(T)));
    }
// End

// Example usage
int main() {
    const HANDLE process_handle = GetCurrentProcess(); // Replace with call to OpenProcess

    // Create memory accessor
    const MemoryAccessor memory_accessor = MemoryAccessor(process_handle);

    uint32_t test_value = 67;
    const uintptr_t test_value_address = reinterpret_cast<std::uintptr_t>(&test_value);

    // Print address of test_value
    std::cout << "Value Address at: " << std::hex << test_value_address << '\n';

    // Write u32 from test_value_address
    auto value_read_result = memory_accessor.write<uint32_t>(test_value_address, 420);
    if(!value_read_result) {
        std::cout << value_read_result.error() << '\n';
        return -1;
    }

    // Print test_value
    std::cout << "Test value: " << std::dec << test_value << '\n';

    std::array<uint32_t, 3> test_slice { 69, 420, 67 };
    const uintptr_t test_slice_address = reinterpret_cast<std::uintptr_t>(test_slice.data());

    // Print address of test_slice
    std::cout << "Value Slice at: " << std::hex << test_slice_address << '\n';

    // Read 3 u32 from test_slice_address
    const std::array<uint32_t, 3> write_slice { 67, 67, 69 };
    auto slice_read_result = memory_accessor.writeSlice(test_slice_address, std::span(write_slice.begin(), write_slice.end()));
    if(!slice_read_result) {
        std::cout << value_read_result.error() << '\n';
        return -1;
    }

    // Print test_slice
    std::cout << "Test slice:" << std::dec;
    for(const auto value : test_slice)
        std::cout << ' ' << value;
    std::cout << std::endl;
    return 0;
}

In the next chapter, we will implement page iterator.