Skip to lesson
Game Hacking Academy
Preparing lesson text…
Exit reader mode
Voice and speed

Preparing read-aloud controls…

Lesson 5.11 Listening edition

Parallel Scanning and Stable Snapshots

Use immutable memory snapshots, bounded workers, cancellation, and sorted results to make scanning faster without losing reproducibility.

About 32 minutes

This is an optional performance study after the chapter’s pattern scanner, memory scanner, and debugger. None of them requires multiple workers. Lesson 5.7 searched one copied code range at a time; here we keep its exact-byte and wildcard comparisons and change only how several stable ranges are searched. That separates the concurrency question from whether a byte pattern really identifies the intended instruction.

Section: Divide the scan into independent ranges.

The simple pattern scanner is intentionally single-threaded. Its next advanced step is not “put every Windows call on a thread.” The safe boundary is:

Lesson diagram

Only the capture phase touches the running Wesnoth process. Workers scan ordinary Vec<u8> buffers owned by the scanner.

In brief: Copy from the target first; let workers search the owned buffers so matching does not depend on extra live process reads.

That separation improves more than speed:

a page cannot disappear halfway through a worker comparison;

workers do not share a process handle;

the matching algorithm uses checked local slices;

captured bytes can be saved and replayed in tests;

concurrency begins after the unstable operating-system boundary.

Section: Check both safety and progress.

Concurrent code needs two different kinds of promises:

safety asks, “What bad result must never happen?”

progress asks, “What useful ending must eventually happen?”

For this scanner:

Lesson table

“Uses atomics” is not a complete concurrency argument. Name which safety fact each atomic protects and which condition lets each loop finish.

Section: Copy only the intended code.

The capture stage clips every Windows memory run to the live wesnoth.exe module and keeps only readable executable pages:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The real tool also caps all copied executable bytes at 128 MiB. Memory safety prevents corruption, but it does not prevent a logically unbounded allocation from exhausting the machine.

If a page changes between VirtualQueryEx and ReadProcessMemory, that one region is skipped with a diagnostic. A partial buffer never reaches a worker.

Section: Use an atomic index as a tiny work queue.

Each worker asks for the next region number:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

No region is handed out twice. When the index passes the slice, the worker finishes.

Two workers claim different regions from one atomic counter.
Lesson diagram

Fetching the next index when a worker becomes free is dynamic scheduling. It is better than permanently giving worker 1 the first quarter of regions and worker 2 the second quarter: a worker that receives several tiny regions can continue helping while another scans a large one.

Alternative: A shared work basket lets the next free worker take another region, instead of leaving it idle behind a finished fixed assignment.

Region sizes can still be badly uneven. If measurement later justifies splitting one large snapshot into fixed-size chunks, adjacent chunks must overlap by pattern.len() - 1 bytes or a pattern crossing the boundary can disappear. The merge step must then remove duplicate addresses from the overlap. Faster scheduling is not correct if it creates blind spots.

Why not create one thread per region? A module can contain many runs, and thread creation itself costs memory and scheduling time. The tool uses at most available_parallelism() workers and never more workers than snapshots.

Section: Bound results across every worker.

A weak pattern can match thousands of locations. A per-thread limit is not enough: eight workers with a 4,096-item limit could still produce 32,768 items.

With a toy global cap of three, suppose worker 1 has recorded two matches and worker 2 finds two more. Both cannot simply read “2 of 3 used” and then append: that would publish four. Each must reserve one slot through the shared counter; only one of worker 2’s two matches can obtain the remaining slot. This is the safety promise in the table above. Cancellation is the separate progress promise: workers stop scanning once no more slots can be reserved.

The scanner reserves each result through one atomic counter:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

fetch_update performs “check below cap and increment” as one atomic operation. When the cap is full, the worker sets a shared cancellation flag. Other workers notice and stop.

Lesson diagram

The memory ordering communicates two small state transitions:

Release publishes cancellation;

Acquire makes another worker observe that published state.

The bytes themselves need no mutex or atomic because they are immutable.

Section: Restore deterministic output.

Thread completion order changes from run to run. Addresses should not:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Sorting after the merge makes logs, tests, and human review repeatable. Deduplication is defensive; clipped memory regions should not overlap, but downstream correctness should not depend on that hope.

The same design works for an offline archive or asset scanner: first collect bounded, immutable input chunks, let workers inspect separate chunks, then apply one global output limit and sort results. The game-specific part here is the capture boundary, where bytes come from a changing process.

This distinction matters:

parallel execution order may vary;

published result order should be stable.

Section: Complete buildable tool.

Additional detail: Complete lab source: parallel_pattern_scanner.rs.

Code example. The code’s comments explain: 📏 VirtualQueryEx can return a run wider than the module; clip it. ⚠️ The process can change a page between query and copy. Skip that observation rather than feeding a partial buffer to worker threads. 🔀 Remote memory is copied before parallel work begins. Workers receive immutable local slices, so no process handle or changing page is shared. ✅ Parallel completion order is nondeterministic; sorting makes output and later uniqueness checks repeatable.

The source is also available as parallel_pattern_scanner.rs.

Section: Build and run it.

Start the exact 32-bit Wesnoth 1.14.9 course build in a local match:

Terminal window

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The tool searches for the verified gold-subtraction bytes:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

A supported build should produce one address. Zero means the build, capture, or signature assumption is wrong. More than one means the signature is ambiguous. Parallelism does not change what those counts mean.

Section: Measure before claiming a speedup.

Parallel scanning has fixed costs:

copying the process is still sequential;

creating workers costs time;

a three-byte pattern is cheap to compare;

merging and sorting remain;

small modules may fit in cache and finish faster on one thread.

Measure the capture and scan phases separately with Instant. If capture dominates, adding workers cannot solve that bottleneck. This is Amdahl’s law: total speedup is capped by the fraction of the work that stays serial, no matter how many workers you add to the rest. Concretely, if capture takes 80 percent of the run and scanning the other 20 percent, parallelizing the scan perfectly still only shrinks that 20 percent. Ten workers cannot beat roughly a 1.25x overall speedup, because the 80 percent you didn’t parallelize is still there.

Narration: If copying takes longer than matching, faster matching will barely change the total waiting time. Time both phases before adding workers.

For course-sized modules, the architecture lesson is more important than the benchmark number.

Section: Failure cases to test.

Create local byte buffers and verify:

a match at the first and last legal offsets;

no match;

overlapping matches;

more matches than the global cap;

one empty snapshot list;

a base address whose base + offset would overflow;

worker panic reporting;

sorted output across repeated runs.

Do these tests without a game process. Only the small capture layer requires Windows and Wesnoth.

Section: What makes the parallel result trustworthy.

The finished scanner relies on five linked properties:

copying process bytes first gives workers stable local input;

one global match cap bounds the total result, regardless of worker count;

cancellation stops unnecessary work after the cap is reached;

sorting makes the returned address order reproducible;

one unique match still needs semantic verification before use, however fast the scan ran.

Chapter 6 starts from that verified site and asks how a small D L L, dynamic link library, can enter a supported process, redirect one call, and restore the original path.