Buffer Strategies
In this guide, we will optimize reads and writes by batching them to reduce syscall overhead
In this guide, we will optimize reads and writes by batching them to reduce syscall overhead
Each call to NtReadVirtualMemory or NtWriteVirtualMemory requires a syscall — a context switch from user mode to kernel mode.
This is expensive. Reading 100 individual u32s one at a time is roughly 100x slower than reading a single 400-byte block and parsing it in userspace.
The overhead is not in the data transfer; it is in the transition itself. Minimizing syscalls is the single largest optimization you can make.
Instead of reading every field, simply read the entire data structure you need as a single batch. This avoids having to do multiple syscalls and context switches and is often simpler to implement.
// Instead of reading one field at a time:
const name_ptr = try memory_accessor.read(instance_ptr + Config.get("Offset/Instance/Name"), usize);
const str_ptr = try memory_accessor.read(name_ptr, usize);
const str_len = try memory_accessor.read(name_ptr + @sizeOf(usize) * 2, usize);
const str_cap = try memory_accessor.read(name_ptr + @sizeOf(usize) * 3, usize);
if(str_cap < 16) {
const str_data = try memory_accessor.read(name_ptr, [16]u8);
std.debug.print("Got short string: {s}\n", .{ str_data[0..str_len] });
} else {
const str_data = try allocator.alloc(u8, str_len);
defer allocator.free(str_data);
try memory_accessor.readSlice(str_ptr, u8, str_data);
std.debug.print("Got long string: {s}\n", .{ str_data });
}
// Read the entire data structure at once:
const StringData = extern struct {
sso: extern union {
short: [16]u8,
ptr: usize,
},
len: usize,
capacity: usize,
};
const string_data = try memory_accessor.read(name_ptr, StringData);
// Now read each field locally - zero syscalls when reading the struct.
if(string_data.capacity < 16) {
std.debug.print("Got short string: {s}\n", .{ string_data.sso.short[0..string_data.len] });
} else {
const str_data = try allocator.alloc(u8, str_len);
defer allocator.free(str_data);
try memory_accessor.readSlice(string_data.sso.ptr, u8, str_data);
std.debug.print("Got long string: {s}\n", .{ str_data });
}
For collections like vectors of children, the same principle applies - one syscall for the vector header, one syscall for all elements, then pure local iteration:
pub fn doSomethingWithChildren(mem: MemoryAccessor, instance_ptr: usize, allocator: std.mem.Allocator) !void {
// Read vector (24 bytes) in one call
const VectorLayout = extern struct {
begin: usize,
end: usize,
// capacity: usize - Read only what you need, you don't need capacity here so don't read it!
};
// First read vector layout (1 syscall)
const vec: VectorLayout = try mem.read(instance_ptr + Config.get("Offset/Instance/Children"), VectorLayout);
const InstanceSharedPtr = extern struct {
ptr: usize,
count: usize,
};
const children = try allocator.alloc(InstanceSharedPtr, (vec.end - vec.begin)/@sizeOf(InstanceSharedPtr));
defer allocator.free(children);
// Read ALL children in one syscall
try mem.readSlice(vec.begin, InstanceSharedPtr, children);
// Now iterate locally
for (children) |child| {
// ...
}
}
Writes can also be batched. Instead of issuing one syscall per write, queue the writes in a local buffer and flush them all at once.
This is especially useful when patching many small values in a tight loop.
const fill_me_with_null = 0xDEADBEEFABADC0DE;
// Instead of:
for(0..4096) |i|
try memory_accessor.write(fill_me_with_null + i, @as(u8, 0));
// Try:
const null_buf: [4096]u8 = @splat(0);
try memory_accessor.write(fill_me_with_null, null_buf);
As covered in the Page Iterator chapter, the most efficient scanning strategy is to iterate over each committed page, read it entirely into a local buffer with a single syscall, then scan that buffer in userspace.
This means zero syscalls during the scan itself — every comparison, every pointer arithmetic operation happens entirely in your own process.