Skip to content
Lesson 14.1Versioned Evidence, State, and Integrity

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.

Lesson 14.1
Read or listenPrint chapter or book
About 50 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 14.1Key ideas, reference and questions

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: LayoutV42 is 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, 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.
  • 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
Download cheatsheet (.md)

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.

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 rcx reads a four-byte health value at offset 0x138.

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”
Identify exact build
Find candidate object roots
Collect member access instructions
Cluster offsets by function and width
Apply value and relationship invariants
Compare controlled state changes
Score competing layouts
Generate versioned reader
Replay regression captures

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 separate

Result: 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
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    inserted = 4.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

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.

Health and max health: layout and the relation between themTwo four-byte fields sit side by side. A clamp keeps one value no larger than the other.
  1. 1 Two four-byte fields at 0x138 and 0x13C

    Object bytes
    0x1380x13F
    health0x138 to 0x13B
    max health0x13C to 0x13F

    Health occupies 0x138 to 0x13B and max health 0x13C to 0x13F, in this exact build only: recheck after an update.

  2. 2 A clamp relates their values

    Values
    0
    ≤
    H
    ≤
    M

    With 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.

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.

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 total

Every 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 x3.0
+0x04 y0.0
+0x08 facing x1.0
+0x0C facing y0.0
+0x10 health100
position
facing, a unit vector
Bytes 0x00 to 0x13 of the toy layout at tick 5: where the guard stands, which way it faces, and its health.
+0x14 state1
+0x15 padding· · ·
+0x18 seen_for5
+0x1C search_ticks0
+0x20 target_id7
one state byte, three padding bytes
timers
who it hunts
Bytes 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 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:

  1. fingerprint the new executable and symbols that remain available;
  2. locate the same behavioral function through call relationships or a carefully versioned pattern;
  3. collect member accesses again instead of adding a constant to every offset;
  4. replay the same controlled game transitions;
  5. rebuild the offset table;
  6. compare field relationships, not just individual values;
  7. create a new layout record and keep the old record immutable;
  8. run captured-snapshot regression tests for both builds.

As a flow:

fingerprint the new build
find the same behavioral
function again
collect member accesses again
(not: add a constant offset)
replay the same controlled
game transitions
rebuild the offset table
compare field relationships
new immutable layout record
regression test both builds

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:

0x30position
0x48yaw
0x138health
0x13Cmax_health
0x150entity_id
Build 41: five fields at their original offsets.
0x30position
0x48yaw
0x50team (new)
0x140health
0x144max_health
0x158entity_id
Build 42: inserting 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_health when 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.

  • 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.

Knowledge check

Accept a recovered field carefully

Which checks most strongly support an offset as a health field in a new build?

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.

Chapter 14: 10 lessons

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.