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

Pattern Scanning

In this guide, we will implement AOB scanning to find byte patterns in memory.

Pattern Scanning

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.

Pattern Format

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.

Scanning Process

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.

flowchart LR A[Read memory page] --> B[Slide window byte-by-byte] B --> C{Pattern matches?} C -->|Yes| D[Record address] C -->|No| B D --> E[Next page] E --> A

Pattern Type

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,
            };
        }
    };
}

Full Example: MemoryAccessor + PageIterator

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();
        }
    }
}

Results

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

Performance Notes

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.