Object Layouts Across Game Updates
Object identity, field offsets, and layouts must be recovered for each build through several independent access patterns, starting from a toy guard whose padded layout is known.
Cheatsheet for Lesson 14.1
A one-page summary of “Object Layouts Across Game Updates” you can keep beside your notes. Plain Markdown.
Lesson 14.1 — Object Layouts Across Game Updates: cheatsheet
- Remember: Matching an object’s shape is different from proving its identity and current ownership path.
- Also useful: An inserted field resembles an inserted book chapter: later positions may shift, earlier ones may stay put, and padding can absorb part of the change.
Key terms
LayoutV42:LayoutV42is metadata in the analysis tool, not a struct read directly from the game.seen_for: It has seen the player on ticks 1 to 5, soseen_foris 5, and chase steps of 1 on ticks 3, 4, and 5 have taken it to (3, 0), still facing east.- enum: The guard's state is an enum, a type whose values come from a fixed list of names.
Review
- Q: Which checks most strongly support an offset as a health field in a new build? A: A damage path stores there, controlled changes match, range relations hold, and identity is stable across restarts
- Q: Analysis notes say 'offset 0x58 is mana'. What is missing for that to be a complete, correct statement? A: Which exact build, which kind of object, and how many bytes are read
The build manifest identifies which executable is present. It does not tell us whether an old player layout still describes that executable. That second question needs evidence from the running objects and the code that uses them.
An old field offset can still point into mapped memory after an update, which makes a broken tool look plausible. To recover the layout, compare C++ object shape, vtables, container headers, pointer chains, and x86-64 field accesses against the fingerprint of the exact build. Independent clues matter more than one address that happens to return a reasonable number.
An offset belongs to one exact build
Section titled “An offset belongs to one exact build”If one build reads health from [rcx+0x138], the number 0x138 is true only
for that build. The complete statement is:
In this exact build, a method receiving the player object in
rcxreads a four-byte health value at offset0x138.
An update can recompile the class with a field added, removed, or reordered, and the same offset then lands on something else. An offset therefore belongs to one exact build, and it has to be checked again after every update before it is used.
The target invariant is:
A layout is used only on the build and the object it was recovered from, and every field read is bounded and checked for a sensible value.
Unpacked, that is four separate demands. Use an offset only on the exact build you proved it on. Use it only on an object you have confirmed is the one you meant. Never read past the end of that object. And check that the value coming back makes sense before believing it — a four-byte read that succeeds is not the same thing as a four-byte read that returned health.
Recover a layout from several independent checks
Section titled “Recover a layout from several independent checks”One memory scan cannot settle a structure: a four-byte 100 at some offset fits health, ammunition, or a coincidence equally well. Static access patterns, runtime value transitions, object identity, and relationships among fields each rule out different wrong answers, so a layout is accepted only where they agree.
An inserted field shifts only later fields. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
old health offset = 8; position offset = 0
insert selected bytes before health
new health offset = 8 + inserted_bytes
compare the old offset with the new access
recover the offset from the health writer
keep earlier fields and build identity separateResult: health is at+12, position stays at +0. Adding the same shift to every old offset would corrupt the earlier position access.
Final values: inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B; new_health_offset = 12; old_offset_read = 60; actual_health = 80; profile_matches = false; recovered_health_offset = 12.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
inserted = 4.
Step 2, line 2. Insert fields after position and before health.
inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B.
Step 3, line 3. This simplified aligned layout moves health by 4 bytes.
inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B; new_health_offset = 12.
Step 4, line 4. The old offset now names an inserted field that happens to contain60.
inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B; new_health_offset = 12; old_offset_read = 60; actual_health = 80; profile_matches = false.
Step 5, line 5. A controlled health change identifies the writer’s new field access.
inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B; new_health_offset = 12; old_offset_read = 60; actual_health = 80; profile_matches = false; recovered_health_offset = 12.
Step 6, line 6. Result: health is at+12, position stays at +0. Adding the same shift to every old offset would corrupt the earlier position access.
inserted = 4; old_health_offset = 8; position_offset = 0; old_profile_build = A; current_build = B; new_health_offset = 12; old_offset_read = 60; actual_health = 80; profile_matches = false; recovered_health_offset = 12.
Build an offset table
Section titled “Build an offset table”Suppose several methods access one candidate object:
| Offset | Width | Access pattern | Runtime observation | Candidate meaning |
|---|---|---|---|---|
0x08 |
8 | indirect call through loaded pointer | points into read-only module data whose entries point into executable code | vtable pointer |
0x30 |
12 | three adjacent float loads | changes with movement | position vector |
0x48 |
4 | float compare against zero | changes with view rotation | yaw or pitch candidate |
0x138 |
4 | subtract, clamp, store | follows damage and healing | health candidate |
0x13C |
4 | upper bound for previous field | stable per entity class | max-health candidate |
The relationship 0 <= health <= max_health is stronger than “both numbers
look reasonable.” The clamp that stores health at 0x138 reads its upper bound
from 0x13C, so one instruction sequence ties the two candidates together and
each supports the other.
1 Two four-byte fields at 0x138 and 0x13C
Object bytes0x1380x13Fhealth0x138 to 0x13Bmax health0x13C to 0x13FHealth occupies 0x138 to 0x13B and max health 0x13C to 0x13F, in this exact build only: recheck after an update.
2 A clamp relates their values
Values0≤H≤MWith H for health and M for max health, the clamp keeps 0 ≤ H ≤ M.
Two four-byte fields sit side by side. A clamp keeps one value no larger than the other.
Distinguish identity from shape
Section titled “Distinguish identity from shape”Two objects can have similar bytes but different roles. Validate identity with several signals:
- vtable pointer falls inside the module’s expected read-only data section, and several entries reached through it fall inside executable code;
- entity ID matches the manager entry used to reach the object;
- generation changes when the slot is reused;
- position is finite and within broad world bounds;
- health relationships hold;
- repeated methods use the same object pointer.
A shape check says bytes resemble a player. An identity check says this is the player object reached through the expected ownership path in this snapshot.
Represent uncertainty in the recovered type
Section titled “Represent uncertainty in the recovered type”LayoutV42 is metadata in the analysis tool, not a struct read directly from
the game. Its usize fields are candidate byte offsets for one exact build.
The separate Confidence value records how much independent evidence supports
each candidate.
#[derive(Debug, Clone, Copy)]struct LayoutV42 { position: usize, health: usize, max_health: usize, entity_id: usize, generation: usize,}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]enum Confidence { Candidate, Repeated, CrossValidated,}Generated metadata stores the confidence level beside each numeric offset,
together with the accesses and value transitions that earned it, so a
Candidate never becomes a trusted field just because it has a convenient
name.
The guard’s structure, byte by byte
Section titled “The guard’s structure, byte by byte”A recovered layout is easier to judge after reading one whose source is known.
Lesson 4.10 numbered a toy guard’s states for a
C++ layout. Here is the whole structure. The guard’s Rust lab,
npc_brain_lab.rs, cannot show one itself,
because its Rust types use Rust’s default layout, which lets the compiler order
fields however it likes. In C++, the fields of a plain structure sit in memory
in the order they are declared:
struct Guard { // offset float x, y; // 0x00, 0x04 float facing_x; // 0x08 float facing_y; // 0x0C uint32_t health; // 0x10 uint8_t state; // 0x14: 0 Patrol, 1 Chase, 2 Attack, 3 Search, 4 Flee // 0x15 to 0x17: padding uint32_t seen_for; // 0x18 uint32_t search_ticks; // 0x1C uint32_t target_id; // 0x20}; // 0x24 bytes in totalEvery float and uint32_t takes 4 bytes, so the offsets count up in fours:
0, 4, 8, 12 (0x0C), and 16 (0x10). The state is stored as a single byte at
20, which is 0x14 (1 × 16 + 4). The next field is 4 bytes wide, and its
alignment requires it to start at a multiple of 4. Bytes 21, 22, and 23 are
not, so the compiler leaves them as padding, and seen_for starts at 24, which
is 0x18 (1 × 16 + 8). search_ticks follows at 28, 0x1C (1 × 16 + 12), and
target_id at 32, 0x20 (2 × 16). The structure ends at 36 bytes, 0x24
(2 × 16 + 4).
Here is the guard at the end of tick 5 of
Lesson 4.10’s run, mid-chase. It has seen
the player on ticks 1 to 5, so seen_for is 5, and chase steps of 1 on ticks 3,
4, and 5 have taken it to (3, 0), still facing east. Its health is still the 100
that the lab gives every new guard:
0x00 to 0x13 of the toy layout at tick 5: where the guard stands, which way it faces, and its health.0x14 to 0x23. The highlighted byte is the state machine's current state, 1 for Chase. The target id 7 is a made-up entity number for the player.The target field is an id rather than a pointer. Some games store a raw pointer to the target object instead, and that pointer goes stale when the target is destroyed and its memory reused, the problem that generation-checked handles in Lesson 3.10 solve.
A state value that means nothing
Section titled “A state value that means nothing”A byte’s 8 bits give 2⁸ = 256 different values, 0 to 255, but only 0 to 4 mean
anything to the guard’s code. Write 9 into the state byte of a toy program that
uses the lab’s five-state guard, and the result depends on how the compiler built the
switch statement that picks a branch for each state. Compilers often turn a
switch over small numbers into a table of jump targets, one code address per
value. If the compiled code checks the range first, 9 goes to a default branch.
If it indexes the table without a check, it reads whatever bytes lie past the
table’s end as an address, jumps there, and usually crashes.
The guard’s state is an enum, a type whose values come from a fixed list of
names. In the Rust lab, safe code cannot store 9 in a GuardState at all. Rust
treats an enum holding a value outside its list as undefined behavior: the
language promises nothing about what the program then does, so safe code is
never allowed to create one. A tool that reads this byte from another process
therefore decodes it as a plain integer first and accepts it only when it is
one of the five known values, the kind of check that
rejecting impossible layouts
applies to every field.
Which claims about the guard a run settles
Section titled “Which claims about the guard a run settles”Suppose the guard’s 36 bytes came from a C++ build whose source you do not have, and you watched them through the demo run. Each line of the layout above is then a claim, and each needs its own check. The run settles some claims and cannot reach others:
| Claim | Check | What the demo run showed |
|---|---|---|
+0x14 is the state |
it changes exactly when the guard’s behavior changes | 0, then 1 from tick 3 (chase), 2 on tick 7 (attack), 3 from tick 8 (search), 0 from tick 13: settled |
+0x18 is seen_for |
it rises by 1 per tick in sight, and drops to 0 when sight is lost | 1 to 7 on ticks 1 to 7, then 0: settled |
+0x1C is search_ticks |
it counts while the guard searches | 0 to 4 on ticks 8 to 12: settled |
+0x10 is health |
it falls by the damage when the guard is hit | 100 on every tick, but nothing hit the guard: still open |
+0x15 to +0x17 are padding |
no instruction reads or writes them | not checked yet |
The health line shows the limit of any run: it settles only what it made
happen. The demo never damaged the guard, so it shows only that +0x10 held
100. The Confidence value above sums up exactly this evidence, one field at a
time.
Predict first
Through all 16 ticks of the demo, bytes +0x15 to +0x17 read 00 00 00.
Does that settle the padding claim?
Answer: No: it needs a check that no instruction touches them, in every state. Padding means bytes that no instruction uses. Access breakpoints, which fire on any read or write, that never fire on 0x15 to 0x17 settle it, but only for the states the run reached.
The demo reached Patrol, Chase, Attack and Search, never Flee. A run where the guard is hurt down to 25 health or less, the point at which it flees, tests the health claim and the padding claim in that last state at the same time.
Migrate after an update by matching behavior
Section titled “Migrate after an update by matching behavior”When a new build moves fields:
- fingerprint the new executable and symbols that remain available;
- locate the same behavioral function through call relationships or a carefully versioned pattern;
- collect member accesses again instead of adding a constant to every offset;
- replay the same controlled game transitions;
- rebuild the offset table;
- compare field relationships, not just individual values;
- create a new layout record and keep the old record immutable;
- run captured-snapshot regression tests for both builds.
As a flow:
Step 3 deserves emphasis, because the tempting shortcut is to diff two known offsets and add that difference to all the others. Layouts do not move like that. A field inserted in the middle shifts everything after it and nothing before it, and alignment can absorb part of the shift:
team at 0x50 pushes every later field down by 8 bytes. position and yaw, both before the insertion, do not move at all.Nothing here moved by one constant. Two fields did not move at all, one
appeared, and the rest shifted by the size of the insertion. A tool that added
+8 everywhere would read position eight bytes into the wrong place and
report numbers that still look like perfectly plausible coordinates.
Compiler optimization can split, inline, reorder, or eliminate accesses. A missing old pattern does not prove the underlying concept disappeared.
Reject partial reads and impossible layouts
Section titled “Reject partial reads and impossible layouts”A reader should validate the whole requested field range before interpreting
bytes. Use checked addition for base + offset, enforce maximum object size,
and reject unreadable page crossings. Decode into local values; do not create a
reference whose lifetime pretends remote bytes belong to the current process.
Validation should reject:
- non-finite vectors;
health > max_healthwhen the game model forbids it;- a vtable pointer outside expected read-only module data, or initial entries that do not point into an expected executable code section;
- duplicate live entity IDs in one snapshot;
- generation changes during a multi-field read;
- an unsupported build rather than falling back to the nearest known layout.
Score candidates without hiding the reasons
Section titled “Score candidates without hiding the reasons”A score helps rank candidates but does not replace the underlying facts:
| Check | Example weight |
|---|---|
| Controlled health transition matched | +4 |
| Damage function stores at offset | +3 |
| Range relation with max health holds | +2 |
| Offset merely contains a plausible number | +1 |
| Generation changed during read | reject |
| Build identity unknown | reject |
The score is stored with the list of checks that produced it, so a high score can be audited.
Glossary terms introduced here
Section titled “Glossary terms introduced here”- Layout: the byte positions and interpretation of fields in an object.
- Shape check: validation that bytes have a plausible structure.
- Identity check: validation that the object is the intended instance.
- Generation: a counter distinguishing reuse of the same storage slot.
- Cross-validation: confirming a field with independent checks, such as an access pattern, a value transition, and a range relation, that all agree.
- Build fingerprint: stable data used to identify one exact binary version.
An old offset is a hypothesis about a new build, not a migrated layout. Recover the new object’s fields from its access patterns and relationships, check its shape and identity, and keep the evidence beside each confidence score. That record shows which uncertainty remains when the build changes again.
Build identity and a recovered layout tell us what a read would mean. They do not grant permission to perform it. The next lesson follows the access rights on the Windows handle that lets a tool reach another process.
Accept a recovered field carefully
Which checks most strongly support an offset as a health field in a new build?
Independent static, dynamic, relational, and identity checks cross-validate the field. One plausible number is only a candidate.
Finished Lesson 14.1?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.