How C++ Objects Appear in Memory
Turn repeated reads, member-function calls, and virtual dispatch into a tested object layout, then inspect it through typed snapshots.
Cheatsheet for Lesson 3.8
A one-page summary of “How C++ Objects Appear in Memory” you can keep beside your notes. Plain Markdown.
Lesson 3.8 — How C++ Objects Appear in Memory: cheatsheet
- Remember: An object has one base address, and each field sits a fixed distance from it. Many objects can share the same offsets.
- Also useful: Field address = receiver base + field offset. Shared method code does not imply shared instance storage.
Key terms
Player: If+0x40turns out not to be yaw, the object and class levels built on that field have to be checked again, which is why a jump from “bytes changed” straight to “this is aPlayerclass” is so easy to get wrong.this: The compiler supplies a hidden argument calledthis.FieldOffset:ObjectAddressandFieldOffsetare both numbers underneath, but they mean different things.
Formulas and patterns
object base + field offset = field address;0x12340000 + 0x40 = 0x12340040
Review
- Q: A player object begins at
0x12340000, and a method reads yaw from[ecx+0x40]whileecxholds that object. Which address contains yaw? A:0x12340040, the object base plus the0x40offset - Q: A reverse engineer sees one decoded instruction reading four bytes. What does that evidence establish? A: That the code touches those bytes; their meaning needs more evidence
The source code is gone, but the layout remains
Section titled “The source code is gone, but the layout remains”Imagine that a game originally contained this C++ class:
class Player {public: virtual void update(); Vec3 position; // three coordinate numbers; Chapter 7 develops the math float yaw; float pitch; char name[32]; bool dead;};The compiler does not normally copy that neat declaration into the finished .exe. Names such as Player, position, and update may disappear. The running program still needs a repeatable arrangement of bytes, though. It must know where each player’s position begins, where the name lives, and which function to call.
Reversing a class means rebuilding a description of that arrangement from what the running program does. It does not restore the original source; it produces a layout that matches the machine code and the program’s behavior. 🧠
This lesson uses AssaultCube 1.2.0.2 32-bit Windows in a local bot match. Offsets are version-specific. A different build may have a different layout.
From changing bytes to a class
Section titled “From changing bytes to a class”Recovering a class works upward through several levels, and each level is shown by a different technique:
| Level | Example | What shows it |
|---|---|---|
| Bytes | Four bytes at this address changed. | Reading the memory before and after. |
| Instruction | movss read those four bytes. |
Decoded machine code. |
| Data flow | The value reaches the view-angle calculation. | Register and memory tracing. |
| Field | Offset +0x40 behaves like yaw. |
Controlled turns across many instances. |
| Object | Several fields share one stable base address. | Repeated base + offset accesses. |
| Class behavior | Objects sharing one table dispatch related methods. | Checked indirect calls and object lifetimes. |
| Game meaning | This is the active player entity. | Behavior, call context, and lifecycle tests. |
Each level rests on the ones below it. If +0x40 turns out not to be yaw, the
object and class levels built on that field have to be checked again, which is
why a jump from “bytes changed” straight to “this is a Player class” is so
easy to get wrong.
Separate five object-layout terms
Section titled “Separate five object-layout terms”| Word | Plain-English meaning |
|---|---|
| Class | A source-code recipe that combines data with functions. |
| Object or instance | One real player, weapon, or entity created from that recipe. |
| Field or member | One piece of data inside an object, such as health or yaw. |
| Method | A function that operates on an object. |
| Structure layout | The byte distance, size, type, and alignment of each field. |
Suppose one player object begins at address 0x12340000, and its yaw is at offset 0x40. The yaw address is:
object base + field offset = field address0x12340000 + 0x40 = 0x12340040An offset is a distance, not an address by itself. Many player objects can use the same yaw offset while living at different base addresses.
1 First player: yaw is 0x40 bytes after its base
Player 1base0x12340000other fields+ 0x40 bytesyaw0x12340040The first allocation starts at 0x12340000, so its yaw is at 0x12340000 + 0x40 = 0x12340040.
2 Second player, base B: yaw is at B + 0x40
Player 1base0x12340000other fields+ 0x40 bytesyaw0x12340040Player 2baseBother fields+ 0x40 bytesyawB + 0x40Another allocation starts at some other base B. The offset belongs to the layout, not to one object, so its yaw is found the same way.
Two separate allocations: one at 0x12340000, the other at a different base. Each has yaw 0x40 bytes after its start.
What an object looks like in memory
Section titled “What an object looks like in memory”For the course build of AssaultCube, our recovered player model contains these useful landmarks:
| Offset from player base | Candidate field | How to test it |
|---|---|---|
+0x04 |
X position (f32) |
Move east/west and watch a smooth finite value change. |
+0x08 |
Y position (f32) |
Move north/south while X stays mostly steady. |
+0x0C |
Z position (f32) |
Jump or change elevation. |
+0x40 |
Yaw (f32) |
Turn left/right; expect an angle-like value. |
+0x44 |
Pitch (f32) |
Look up/down. |
+0x225 |
Name bytes | Compare several bots with different names. |
+0x338 |
Dead flag (u32) |
Observe zero while alive and a changed flag after death. |
unknown box stands for bytes nobody has tested yet.Notice the gaps. We have not claimed that every byte between 0x0C and 0x40 is useless. Those bytes may contain velocity, dimensions, pointers, flags, or padding. Unknown means unproven, not empty.
Padding is especially easy to miss. CPUs and compilers often place a field at an aligned address — usually a multiple of that field’s own size — leaving unused bytes in between. Three fields declared in this order produce a layout with a hole in it:
u32, and an f32, with the padding the compiler inserts after the one-byte flag.The three bytes at +0x01, +0x02, and +0x03 hold nothing meaningful. They
exist only so that health begins at a multiple of four. If you assume fields
sit back to back and put health at +0x01, then every offset after it is
wrong by three bytes, and each read returns a value stitched together from
pieces of two neighboring fields. ❌
Find the object base from one known field
Section titled “Find the object base from one known field”Start with behavior you can control:
- Find the local player’s yaw with a float scan while turning left and right.
- Put a hardware access breakpoint on that address.
- Turn again and inspect the instruction that uses it.
- Look for an address calculation shaped like
[register + constant].
You may find an instruction shaped like this:
movss xmm0, dword ptr [ecx+40h]Read it as: “copy the 32-bit float located 0x40 bytes after the object in ecx into xmm0.” If the stopped yaw address is 0x12340040, then ecx should be 0x12340000.
Verify the arithmetic in the debugger:
stopped field address - instruction offset = candidate object base0x12340040 - 0x40 = 0x1234000040hmovss xmm0, dword ptr [ecx+40h] at yaw's address, so ecx should hold 0x12340000.One matching subtraction is a clue, not proof. Repeat it while turning, respawning, and starting a new match.
The hidden this pointer
Section titled “The hidden this pointer”A C++ call such as player.update() looks as if it passes no player argument. A non-static member function still needs to know which player it should update. The compiler supplies a hidden argument called this.
Shared method code still needs a particular receiver
Section titled “Shared method code still needs a particular receiver”A field offset describes a position within one layout; it is not an
absolute field address. In the invented layout below, both objects store
health at base + 8. Ada’s base is 0x1000, so her health is at 0x1008.
Bo’s base is 0x2000, so his health is at 0x2008.
Choose the receiver and follow the same method body. The example uses the
x86 Microsoft __thiscall convention’s ECX receiver and limits damage so
this small subtraction cannot make health negative. It illustrates address
selection; it does not define a universal C++ layout or model the whole ABI.
Microsoft documents this receiver convention in its
__thiscall reference.
The receiver selects which object the method changes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
mov ecx, selected_base
call damage
jmp done
damage: mov eax, [ecx+8]
sub eax, amount
mov [ecx+8], eax
ret
done: nopAda has 40 health; Bo still has 50. Sharing a method and field offset did not share the field's storage.
Final values: target = Ada; amount = 20; ECX = 0x1000; EAX = 40; field address = 0x1008; Ada.health = 40; Bo.health = 50.
Read every step of the lesson’s example
Step 1, line 1. The caller supplies Ada's base, 0x1000, in ECX for this simplified x86 member call.
target = Ada; amount = 20; ECX = 0x1000; EAX = not loaded yet; field address = 0x1008; Ada.health = 60; Bo.health = 50.
Memory: Ada: 0x1008 = 60 (selected health field); Bo: 0x2008 = 50 (other instance).
Step 2, line 4. The method reads 0x1000 + 8 = 0x1008. EAX receives 60.
target = Ada; amount = 20; ECX = 0x1000; EAX = 60; field address = 0x1008; Ada.health = 60; Bo.health = 50.
Memory: Ada: 0x1008 = 60 (selected health field); Bo: 0x2008 = 50 (other instance).
Step 3, line 5. Subtract 20: the temporary register becomes 40. The field has not been stored yet.
target = Ada; amount = 20; ECX = 0x1000; EAX = 40; field address = 0x1008; Ada.health = 60; Bo.health = 50.
Memory: Ada: 0x1008 = 60 (selected health field); Bo: 0x2008 = 50 (other instance).
Step 4, line 6. Store 40 through the same receiver-relative address. Only Ada's field changes.
target = Ada; amount = 20; ECX = 0x1000; EAX = 40; field address = 0x1008; Ada.health = 40; Bo.health = 50.
Memory: Ada: 0x1008 = 40 (selected health field); Bo: 0x2008 = 50 (other instance).
Step 5, line 8. Ada has 40 health; Bo still has 50. Sharing a method and field offset did not share the field's storage.
target = Ada; amount = 20; ECX = 0x1000; EAX = 40; field address = 0x1008; Ada.health = 40; Bo.health = 50.
Memory: Ada: 0x1008 = 40 (selected health field); Bo: 0x2008 = 50 (other instance).
Identical code and identical field offsets can modify different objects. To explain one write, recover both the layout and the receiver used on that call. A pointer to the code is not a pointer to the instance’s fields.
Receiver cheatsheet: shared method code is not shared object storage; the receiver supplies the instance address; base plus field offset selects that instance’s field.
The call’s frame and the object’s lifetime
Section titled “The call’s frame and the object’s lifetime”That argument belongs to one call’s working state. Each thread has a stack
region for return addresses and temporary call data. On 32-bit x86, esp
marks its current top. Calling another function claims a new stack frame;
returning releases that frame. The object that this points to can live much
longer on the heap. The pointer in a call frame and the object it names have
different lifetimes:
apply_damage returns, esp moves back up; a later call can reuse those bytes. The player object reached through this need not disappear with this frame.This explains why a debugger’s call stack can lead you to the caller but a saved address of one of its temporary values cannot be assumed valid after the call returns. Lesson 3.10 will trace the longer lifetimes of heap objects.
For the 32-bit Microsoft C++ convention commonly called __thiscall, this usually arrives in ecx:
mov ecx, esi ; esi is the player-object basecall update_playerInside the method, reads often use that same base register:
mov eax, [ecx+338h] ; read one member from this playertest eax, eaxjne player_is_deadOn 64-bit Windows, the first integer or pointer argument normally arrives in rcx, so this is commonly in rcx. This is a calling-convention rule, not a promise that the compiler keeps the value in that register for the whole method. It may copy the pointer into rbx, rsi, or a stack slot.
The practical question is: which value stays constant while several member offsets are read from it? That value is a strong object-base candidate.
Virtual methods leave a useful clue
Section titled “Virtual methods leave a useful clue”A class can declare a method as virtual, allowing different object types to choose different implementations. Many C++ compilers implement this with:
- a vtable: a table containing function addresses;
- a vptr: a hidden pointer in each object that points to its vtable.
The purpose is to let a single call site reach different code depending on what the object actually is. The compiler cannot know at build time whether a given entity will be a human player or a bot, so rather than writing a fixed destination it writes “call whatever address is in slot 3 of this object’s table.” Each class gets its own table, and every object carries a pointer to the right one.
A 32-bit virtual call often has this general shape:
mov eax, [ecx] ; read the object's possible vptrcall dword ptr [eax+0Ch] ; call virtual slot 3That is two lookups before any game code runs. Followed with real numbers:
ecx = 0x1234_0000 the objectread [ecx] -> 0x00A1_B200 the vtable this object uses0x00A1_B200 + 0x0C = 0x00A1_B20C the slot being calledread [0x00A1_B20C] -> 0x004C_7710 the function that finally runsThe offset 0x0C is 12, and a pointer in a 32-bit process is four bytes, so
12 / 4 = 3: this is the fourth entry counting from zero, which is why it is
called slot 3. Two objects of different classes executing this identical
instruction end up in different functions, because the second step read a
different table.
Do not label every first pointer a vptr. Test it:
- The object field should point into a readable table, usually in a module’s read-only data.
- Several table entries should look like valid pointers.
- Those entries should lead into executable sections.
- Objects believed to share a class should usually share the same table pointer.
- Breaking on one table entry should show a sensible method receiving the object as
this.
This pattern is common, but the C++ language does not require one universal object layout. Link-time optimization, devirtualization, multiple inheritance, and custom engines can all change it.
Inheritance makes the map less tidy
Section titled “Inheritance makes the map less tidy”With single inheritance, the base-class part is often placed at the beginning of the derived object. A BotPlayer may therefore begin with all fields needed by Player, followed by bot-specific state.
Multiple inheritance can place several base parts, each with its own vptr,
inside one allocation. A pointer typed as the second base then points a few
bytes into the object, and a thunk, a tiny compiler-generated stub, may
adjust this before jumping to the real method. So when two candidate bases
always differ by the same small constant, they are usually two views of one
object. RTTI (run-time type information), when a release build keeps it,
can expose a class name near a virtual table; the name says nothing about where
any field sits.
One object can therefore answer three questions differently: which block was
allocated, which base view a particular pointer addresses, and its dynamic
type, the most-derived implementation virtual dispatch chooses.
Lesson 3.9 separates those identities
with a multiple-inheritance layout and the thunk that adjusts this.
A constructor and a destructor bracket that object’s life. A constructor runs right after an allocation, calls base constructors first, and switches the vptr from the base table to the derived table; an ordinary reset function may fill the same defaults but does none of those three things. A destructor marks the point after which every cached address to the object is stale, which matters more to a memory tool than the original class name. Lesson 3.10 follows that timeline from allocation to free.
Recover a structure step by step
Section titled “Recover a structure step by step”One memory window is not enough to recover a structure. A repeatable sequence is:
- Anchor one field. Find a value with an obvious controlled behavior, such as yaw, health, or ammunition.
- Find code that uses it. Break on access. The instruction’s base register and displacement give the field’s offset:
[ecx+40h]means the field sits0x40bytes (4 × 16 = 64) past the address inecx. - Propose an object base. Subtract the displacement from the known field address: a yaw at
0x12340040read through[ecx+40h]gives a base of0x12340040 − 0x40 = 0x12340000. - Collect nearby candidates. Watch adjacent aligned values as position, view, health, and state change.
- Compare instances. Inspect the local player and several bots at the same offsets.
- Assign types carefully. Check byte width, signedness, endianness, value range, and how instructions consume the field.
- Trace methods. Look for repeated
this + offsetaccesses and virtual calls. - Test lifetimes. Join, die, respawn, leave, and start a new match. The moments when pointers become null or objects are replaced mark where cached addresses stop being valid.
A field nobody has managed to change on purpose stays unnamed, as field_48 or plain padding, until a controlled change or a traced read shows what it holds.
Do not turn a remote address into a reference
Section titled “Do not turn a remote address into a reference”This is tempting but incorrect for an external tool:
// ❌ `remote_address` belongs to another process, not this tool process.let player = unsafe { &*(remote_address as *const Player) };println!("{}", player.yaw);A reference promises that valid, correctly aligned local memory exists for a lifetime. A number copied from another process cannot keep those promises.
Copy each field through ReadProcessMemory instead:
// ✅ The process boundary and the fallible read remain visible.let yaw = process.read_f32(player_base + 0x40)?;Reading fields separately is also friendlier to an incomplete layout. You do not need to invent hundreds of padding bytes just to inspect three confirmed values.
The optional probe reference applies this rule to the confirmed AssaultCube fields with checked offsets and plausibility checks.
Common false conclusions
Section titled “Common false conclusions”“Two nearby floats must be X and Y.” They could be velocity, color, or a timer. Change one behavior at a time.
“The first pointer proves a vtable.” Follow several entries and verify they point to executable methods.
“The debugger showed ecx, so it must always be this.” Confirm the calling convention and watch how the method uses the value.
“The structure worked once, so it is finished.” Compare instances and repeat after object destruction, respawn, and clean launches.
“A successful read proves the address is right.” Windows can copy readable but unrelated bytes. Validate meaning, not only access.
The layout is a versioned model
Section titled “The layout is a versioned model”An object base is the start of one instance, and each field offset is a byte
distance from that base. The hidden this argument selects the instance for
a member call; a vptr commonly leads to its virtual-method table. Padding
and inheritance can add gaps or make one object reachable through several
base views. Those observations form a layout model for the tested game build,
not a promise for every build. A remote object address remains an address in
the game process, never a valid local Rust reference.
The same technique appears in debuggers, crash analyzers, compatibility tools, profilers, and digital-forensics programs. Each one recovers structure from raw memory without the original source code.
Optional build reference
Section titled “Optional build reference”The read-only probe below applies this lesson’s layout to a live AssaultCube match. The reconstruction above does not depend on building or running it.
Optional build reference: a read-only object-layout probe
The lab implementation is windows-labs/src/bin/object_layout_probe.rs. It opens ac_client.exe with read access only, follows the verified local-player and entity-list pointers, then copies the confirmed fields into local snapshots.
Here is the path a pointer takes through the probe before anything is printed:
The most important types are deliberately small:
use std::num::NonZeroUsize;
use anyhow::Context;
#[derive(Clone, Copy, Debug)]struct ObjectAddress(NonZeroUsize);
#[derive(Clone, Copy, Debug)]struct FieldOffset(usize);
impl ObjectAddress { fn new(value: usize) -> Option<Self> { // ✅ Once constructed, an ObjectAddress can never contain null. NonZeroUsize::new(value).map(Self) }
fn field(self, offset: FieldOffset) -> anyhow::Result<usize> { self.0.get() .checked_add(offset.0) .context("object base + field offset overflowed") }}
struct PlayerLayout;
impl PlayerLayout { const X: FieldOffset = FieldOffset(0x04); const Y: FieldOffset = FieldOffset(0x08); const Z: FieldOffset = FieldOffset(0x0C); const YAW: FieldOffset = FieldOffset(0x40); const PITCH: FieldOffset = FieldOffset(0x44); const NAME: FieldOffset = FieldOffset(0x225); const DEAD: FieldOffset = FieldOffset(0x338);}ObjectAddress and FieldOffset are both numbers underneath, but they mean
different things. The wrappers make it harder to accidentally treat a distance
as a complete address. NonZeroUsize also moves the null check into
construction. After ObjectAddress::new succeeds, every later function can
rely on that invariant instead of asking “is this zero?” again. This is what a
strong type does: it stores both a value and a rule about what that value is
allowed to mean.
The field reader performs bounded copies and plausibility checks:
fn read_player(process: &Process, player: ObjectAddress) -> anyhow::Result<PlayerSnapshot> { let position = [ process.read_f32(player.field(PlayerLayout::X)?)?, process.read_f32(player.field(PlayerLayout::Y)?)?, process.read_f32(player.field(PlayerLayout::Z)?)?, ]; let yaw = process.read_f32(player.field(PlayerLayout::YAW)?)?; let pitch = process.read_f32(player.field(PlayerLayout::PITCH)?)?; let dead_raw = process.read_u32(player.field(PlayerLayout::DEAD)?)?;
anyhow::ensure!( position.iter().all(|value| value.is_finite() && value.abs() < 100_000.0), "implausible player coordinates" ); anyhow::ensure!(yaw.is_finite() && pitch.is_finite(), "non-finite view angles"); anyhow::ensure!(dead_raw <= 1, "unexpected dead flag {dead_raw}");
Ok(PlayerSnapshot { address: player, name: read_name(process, player)?, position, yaw, pitch, dead: dead_raw != 0, })}Why validate values after a successful Windows read? Because “four bytes were copied” does not mean “our class model is correct.” A stale pointer can still lead to readable memory. Rejecting impossible coordinates and flags catches some wrong-object guesses early.
Optional build reference: run the probe
Use 32-bit AssaultCube 1.2.0.2 on Windows and start a private local match with bots. From the repository root:
cargo run --manifest-path windows-labs/Cargo.toml ` --target i686-pc-windows-msvc ` --bin object_layout_probeThe output is a set of local snapshots, not live references:
local 0x16f7a3c0 name="player" pos=(112.50, 86.25, 5.00) yaw=271.20 pitch=2.10 dead=falseentity[1] 0x17021480 name="bot_1" pos=(90.25, 74.00, 5.00) yaw=88.70 pitch=0.00 dead=falseAddresses and values will differ. The important checks are structural:
- every printed object has a different plausible base address;
- the same offsets produce the same kind of value for each object;
- names are bounded and readable;
- coordinates react to movement;
- view angles react to turning;
- the probe refuses a player count above 32;
- stopping the match produces a useful null-pointer error rather than a crash.
Separate an address from an offset
A player object begins at 0x12340000, and a method reads yaw from [ecx+0x40] while ecx holds that object. Which address contains yaw?
The object base is the starting address and 0x40 is the byte distance to the member, so the field address is 0x12340000 + 0x40 = 0x12340040.
Finished Lesson 3.8?
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.