A readable pointer is not data
Most of the mistakes I make when reading a running process are not disassembly mistakes. They are epistemics mistakes: I find a value that looks right, and I stop. This post is the list of rules I now hold myself to. Every one of them came from a session where I had already announced the answer.
The setting is always the same: a large native binary, a struct I want to read from the outside, and no symbols worth the name. The examples are anonymised, the numbers are real.
1. A pointer that reads back proves nothing
A committed page returns something. That is all a successful read tells you. It does not tell you the data is there, or was ever there, or is what the pointer’s name in your notes says it is.
I once validated a mesh reader with the test “does the vertex buffer pointer read back?” It passed for 1094 of 1102 meshes and I retracted a correct earlier claim on the strength of it. The cross check, comparing each mesh’s vertex extents against the collision hull of the same mesh in the same local space, told a different story: 22 matched, 23 were empty, 155 were garbage. The same buffer read as MEM_FREE, then as zeros, then as a pile of 4x3 instance matrices in world space, within one hour. The engine frees the CPU copy after the GPU upload and leaves the pointer standing.
The rule: a test needs a known good answer to compare against, not a success code. And it needs three outcomes, not two. Matches, empty and garbage are different findings. Counting “empty” as a pass because the values are small was the second mistake inside the same test.
outcome count what it means
match 22 data is there
empty 23 page is committed, content gone
garbage 155 page was reused by something else
2. The same value for everyone is a default, not a field
A field that reads exactly the same for every player is almost never the field you want. It is the class default of a component that exists on the client and is not replicated.
Health at +0x130 read 100 for every player in the match. Two components of two different players were byte-identical from +0x100 to +0x1B0. The only instance-specific value in that range was a pointer at +0x140, and behind it, at +0x38, sat the replicated health as a double. Across twelve players it read 100.00, 63.44, 76.30, 37.00.
The plausible-looking default broke two features silently: every health bar was full, and is_alive = health > 0 made every corpse alive, so the “dead” filter filtered nothing.
The rule: log the spread over instances, not a single value. N read, min..max is the number that matters. No spread, no source. The same applies to a missing pointer: it is not “enemy”, it is “unknown”, and unknown is not an answer.
3. The camera never shares a byte with the entity
The camera position is interpolated, smoothed, and offset to eye height. It sits on the same spot as the player to within centimetres, and it matches the player’s position in no single byte.
Scanning memory for the exact camera bytes found 502 hits. 256 of them were an array with a stride of 0xBE0, 136 more at 0x200. Render buffers and constant buffers, all of them copies of the camera. Not one was an entity. That cost three sessions before anyone noticed that the instrument was wrong, not the scan.
The rule: search numerically per axis with a tolerance, and leave the vertical axis free, because eyes and feet differ. And check that the player is alive first. A death camera reads as a perfectly stable position that belongs to nobody.
4. The copy constructor is the struct layout
When a patch moves a struct, do not search field by field. The copy constructor walks the struct member by member: scalars inline, strings and sub-objects through their own copy constructors. Follow those calls and the ordered list of displacements is the complete field map.
You find the constructor from its end. The last field is mov r32, [src+size-4] followed by mov [dst+size-4], r32, so search for the two displacements and take the function boundaries from .pdata. Then read both builds the same way and align the two instruction sequences. The insert blocks name the exact byte where a field was added.
old build new build delta
+0x000 .. +0x2F8 +0x000 .. +0x2F8 0
+0x2F8 .. +0x300 +8 inserted
+0x2F8 .. +0x5A0 +0x300 .. +0x5A8 +8
+0x5A8 .. +0x5B0 +16 inserted
Three inserts, found in seconds instead of a session of eyeballing hex. The more valuable output is running the same diff over the other structs and seeing them come out clean. Knowing what you do not have to touch saves more time than the find.
Two things that bite when you build this: the mnemonic alone is not a good comparison token, because ten mnemonics over three thousand fields will align four in a row by accident. Use mnemonic plus distance to the previous field; the distance is shift-invariant. And choose the constructor across both images together. Several functions end on the same last dword, and picking “the largest” per image independently pairs the wrapper object’s constructor with the real one.
5. Deltas are not uniform
If a server and a client are built by different compilers, every region of the “same” struct has its own delta. Not linear, not constant.
Two player structs, identical stride on both sides, laid out completely differently inside. Measured region by region:
region delta server -> client
skill struct +0xC0 (stride 0x180 -> 0x240)
aim block +0xE8
timers +0x120
team +0x128
damage unknown
A feature ported with the skill-struct delta wrote cooldowns into the wrong fields, and for two of the skills the error happened to be invisible. Fields ported with the timer delta landed on empty memory.
The rule: offsets from another build are a source of meaning, never of addresses. Every candidate is re-derived from the binary you are actually reading, or measured live against known behaviour. If neither is possible, the candidate is not ready for code.
The same holds within one build. After a patch that shifted a base class, the inherited offsets moved by -0x18, -0x10, -0x20 and -0x38 on four related classes, and by zero on a fifth.
6. The binary often carries the answer as data
Before guessing any offset, look for the table that the engine itself uses.
Engines with delta-compressed network state carry their field tables in the binary: {name, offset, bits} triples, one per replicated field, with the field names as strings. Find the string "pos.trBase[0]", scan for its address, and the single hit is the table entry. Decode the neighbourhood and you have 110 named offsets in one pass.
That took fifteen minutes. The offset table it replaced had been ported from a related project and was wrong from +0x5C onward, because one trajectory struct was 40 bytes instead of 36. Items stood at invented positions, viewheight read torsoAnim, team read armor, and the code had been running that way for a year.
A related trick when there are no names at all: .data on disk almost always has a smaller raw size than its virtual size. Anything readable in a dump above RVA + SizeOfRawData was written by the running process, not the linker. Filter the strings to that window and the current map name falls out, with no signature and no disassembler. That is the cheapest anchor a reader can use to validate itself against rule 1.
The pattern
Every rule above is the same rule. A value that reads back is a hypothesis. It becomes a finding when it agrees with something you already know independently: a hull that matches a mesh, a spread across players, a table the engine itself trusts, a second build that aligns. Until then, the honest state is “unverified”, and code built on it is a guess with a compile step.