Pattern Scanning
In this guide, we will implement AOB scanning to find byte patterns in memory.
In this guide, we will implement AOB scanning to find byte patterns in memory.
Pattern scanning is a technique used to find addresses dynamically by searching for known byte sequences in a process's memory.
This is useful when offsets change between game updates but the underlying code patterns remain the same.
Instead of hardcoding pointers that shift every patch, you scan for a unique signature and compute the address relative to the match.
Patterns are written as hex bytes separated by spaces, with ?? acting as a wildcard for bytes that can vary (e.g. addresses or pointers that change per session):
49 20 6c 69 6b 65 20 ?? ?? ?? ?? ?? ?? 20 70 69 7a 7a 61
In this example, the first seven bytes are fixed (49 20 6c 69 6b 65 20, ASCII for "I like "), the next six bytes are wildcards — they match any value — and the trailing six bytes (20 70 69 7a 7a 61, ASCII for " pizza") are also fixed.
The diagram below illustrates the scanning flow: read a memory page, slide a window across it byte-by-byte, check for a pattern match, and repeat.
We define a Pattern comptime function that parses the pattern at compile time, returning a type with matches, scan, and scanIterator methods. The inline for unrolls the comparison loop, and the mask and bytes are baked directly into the binary — zero runtime overhead:
pub fn Pattern(comptime pattern: []const u8) type {
const data = comptime blk: {
const pattern_len = std.mem.countScalar(u8, pattern, ' ') + 1;
var mask: std.bit_set.Static(pattern_len) = undefined;
var bytes: [pattern_len]u8 = undefined;
var idx = 0;
var bytes_index = 0;
var it = std.mem.splitScalar(u8, pattern, ' ');
while (it.next()) |val| {
const byte: ?u8 = std.fmt.parseUnsigned(u8, val, 0x10) catch null;
if (byte) |byte_val| {
mask.unset(idx);
bytes[bytes_index] = byte_val;
idx += 1;
bytes_index += 1;
} else {
mask.set(idx);
idx += 1;
}
}
break :blk .{
.mask = mask,
.bytes = bytes[0..bytes_index].*,
};
};
return struct {
const pattern_data = data;
pub fn matches(bytes: []const u8) bool {
if (bytes.len != pattern_data.mask.capacity())
return false;
var byte_index: usize = 0;
inline for (0..pattern_data.mask.capacity()) |idx| {
if (!pattern_data.mask.isSet(idx)) {
if (bytes[idx] != pattern_data.bytes[byte_index])
return false;
byte_index += 1;
}
}
return true;
}
pub fn scan(buffer: []const u8) ?usize {
var it = std.mem.window(u8, buffer, pattern_data.mask.capacity(), 1);
while (it.next()) |window| {
if (matches(window))
return @intFromPtr(window.ptr) - @intFromPtr(buffer.ptr);
}
return null;
}
pub const Iterator = struct {
it: std.mem.WindowIterator(u8),
buffer: [*]const u8,
pub fn next(this: *@This()) ?usize {
while (this.it.next()) |window| {
if (matches(window))
return @intFromPtr(window.ptr) - @intFromPtr(this.buffer);
}
return null;
}
};
pub fn scanIterator(buffer: []const u8) Iterator {
return .{
.it = std.mem.window(u8, buffer, pattern_data.mask.capacity(), 1),
.buffer = buffer.ptr,
};
}
};
}
Combining the Pattern, PageIterator, and MemoryAccessor from previous chapters gives us a complete memory scanner. Since Pattern is now a comptime type, we instantiate it once at compile time and use .matches, .scan, or .scanIterator on it directly:
// I like ?????? pizza
const CPPattern = Pattern("49 20 6c 69 6b 65 20 ?? ?? ?? ?? ?? ?? 20 70 69 7a 7a 61");
// Example usage
pub fn main(init: std.process.Init) !void {
const process_handle = std.os.windows.GetCurrentProcess();
const mem: MemoryAccessor = .init(process_handle);
const allocator = std.heap.smp_allocator;
const example = "I like ?????? pizza";
var buf: [example.len * 4]u8 = undefined;
@memcpy(&buf, "I like cheese pizza" ++ "I like tomato pizza" ++ "I like salmon pizza" ++ "I like garlic pizza");
std.mem.doNotOptimizeAway(buf);
const stdout: std.Io.File = .stdout();
var stdout_writer_buf: [1024]u8 = undefined;
var stdout_writer = stdout.writer(init.io, &stdout_writer_buf);
try stdout_writer.interface.print("Examples at {X}\n", .{@intFromPtr(&buf)});
var arr: std.ArrayList(u8) = .empty;
defer arr.deinit(allocator);
var it: PageIterator = .init(mem);
while (it.next()) |mbi| {
try stdout_writer.flush();
if ((@as(u32, @bitCast(mbi.Protect)) & @as(u32, @bitCast(win32.PAGE_READWRITE))) == 0 or
(@as(u32, @bitCast(mbi.Protect)) & @as(u32, @bitCast(win32.PAGE_GUARD))) != 0 or
(@as(u32, @bitCast(mbi.State)) & @as(u32, @bitCast(win32.MEM_COMMIT))) == 0)
continue;
arr.clearRetainingCapacity();
try arr.resize(allocator, mbi.RegionSize);
mem.readBytes(@intFromPtr(mbi.BaseAddress), arr.items) catch continue;
var it2 = CPPattern.scanIterator(arr.items);
while (it2.next()) |offset| {
const addr = @intFromPtr(mbi.BaseAddress) + offset;
try stdout_writer.interface.print("Found pattern at: {X}, {s}\n", .{ addr, arr.items[offset .. offset + example.len] });
try stdout_writer.flush();
}
}
}
Examples at 4AF31FE980
Found pattern at: 4AF31FE980, I like cheese pizza
Found pattern at: 4AF31FE993, I like tomato pizza
Found pattern at: 4AF31FE9A6, I like salmon pizza
Found pattern at: 4AF31FE9B9, I like garlic pizza
Found pattern at: 4AF31FEA00, I like cheese pizza
Found pattern at: 4AF31FEA13, I like tomato pizza
Found pattern at: 4AF31FEA26, I like salmon pizza
Found pattern at: 4AF31FEA39, I like garlic pizza
The naive sliding-window approach shown above is O(n·m). For larger patterns, you can use Boyer-Moore or SIMD (_mm_cmpestri / _mm256_cmpeq_epi8) to accelerate scanning significantly.
In Zig, std.mem.eql uses SIMD automatically when available. You can also use @Vector for manual SIMD comparisons.
std.mem.find searches for a needle in haystack and returns the index of the first occurrence, using Boyer-Moore-Horspool on large inputs and linear search on small inputs. Returns null if not found.