Skip to content
Lesson 3.8Typed Memory, Ownership, and Object Layouts

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.

Lesson 3.8
Read or listenPrint chapter or book
About 35 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 3.8Key ideas, reference and questions

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 +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.
  • this: The compiler supplies a hidden argument called this.
  • FieldOffset: ObjectAddress and FieldOffset are 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] while ecx holds that object. Which address contains yaw? A: 0x12340040, the object base plus the 0x40 offset
  • 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
Download cheatsheet (.md)

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.

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.
bytes changed
an instruction read them
the value flows somewhere
one field behaves like yaw
several fields share a base
objects share methods
this is the active player

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.

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 address
0x12340000 + 0x40 = 0x12340040

An offset is a distance, not an address by itself. Many player objects can use the same yaw offset while living at different base addresses.

The yaw field is the same distance from every player’s baseTwo separate allocations: one at 0x12340000, the other at a different base. Each has yaw 0x40 bytes after its start.
  1. 1 First player: yaw is 0x40 bytes after its base

    Player 1
    base0x12340000
    other fields+ 0x40 bytes
    yaw0x12340040

    The first allocation starts at 0x12340000, so its yaw is at 0x12340000 + 0x40 = 0x12340040.

  2. 2 Second player, base B: yaw is at B + 0x40

    Player 1
    base0x12340000
    other fields+ 0x40 bytes
    yaw0x12340040
    Player 2
    baseB
    other fields+ 0x40 bytes
    yawB + 0x40

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

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.
+0x04x
+0x08y
+0x0Cz
unknown
+0x40yaw
+0x44pitch
unknown
+0x225name
unknown
+0x338dead
position
view angles
The confirmed landmarks, drawn in order but not to scale. Each 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:

+0x0000
??
??
??
+0x0464
00
00
00
+0x0800
00
B4
42
dead
padding
health = 100
yaw = 90.0
assume no padding and “health” at +0x01 reads three padding bytes and one byte of the real health
A dead flag, a 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. ❌

Start with behavior you can control:

  1. Find the local player’s yaw with a float scan while turning left and right.
  2. Put a hardware access breakpoint on that address.
  3. Turn again and inspect the instruction that uses it.
  4. 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 base
0x12340040 - 0x40 = 0x12340000
0x12340000object base
· · ·
0x12340040yaw
the instruction's offset, 40h
The breakpoint stopped on movss 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.

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:   nop

Ada 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
  1. 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).

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

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

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

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

0x0019FF80main: return address and locals
0x0019FF50update_world: return address and locals
0x0019FF30apply_damage: return address and locals
esp →scratch: the next call writes here
in use, released newest first
free
One thread's stack on 32-bit x86. Each call claims a frame below the previous one. When 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 base
call update_player

Inside the method, reads often use that same base register:

mov eax, [ecx+338h] ; read one member from this player
test eax, eax
jne player_is_dead

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

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 vptr
call dword ptr [eax+0Ch] ; call virtual slot 3

That is two lookups before any game code runs. Followed with real numbers:

ecx = 0x1234_0000 the object
read [ecx] -> 0x00A1_B200 the vtable this object uses
0x00A1_B200 + 0x0C = 0x00A1_B20C the slot being called
read [0x00A1_B20C] -> 0x004C_7710 the function that finally runs
0x00A1B200slot 0
0x00A1B204slot 1
0x00A1B208slot 2
0x00A1B20C0x004C7710
slot 3
The vtable the object's vptr points at: a row of function addresses, four bytes each.

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

ecx holds a player
ecx holds a bot
one call site:
mov eax, [ecx]
call dword ptr [eax+0Ch]
the player class's vtable,
slot 3
the player's update
the bot class's vtable,
slot 3
the bot's update

Do not label every first pointer a vptr. Test it:

  1. The object field should point into a readable table, usually in a module’s read-only data.
  2. Several table entries should look like valid pointers.
  3. Those entries should lead into executable sections.
  4. Objects believed to share a class should usually share the same table pointer.
  5. 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.

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.

One memory window is not enough to recover a structure. A repeatable sequence is:

  1. Anchor one field. Find a value with an obvious controlled behavior, such as yaw, health, or ammunition.
  2. 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 sits 0x40 bytes (4 × 16 = 64) past the address in ecx.
  3. Propose an object base. Subtract the displacement from the known field address: a yaw at 0x12340040 read through [ecx+40h] gives a base of 0x12340040 − 0x40 = 0x12340000.
  4. Collect nearby candidates. Watch adjacent aligned values as position, view, health, and state change.
  5. Compare instances. Inspect the local player and several bots at the same offsets.
  6. Assign types carefully. Check byte width, signedness, endianness, value range, and how instructions consume the field.
  7. Trace methods. Look for repeated this + offset accesses and virtual calls.
  8. 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.

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

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.

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
Windows VM

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:

ObjectAddress::new
refuses null
field(offset)
checked_add
read_f32, read_u32:
bytes copied across the process boundary
plausibility checks
a pointer read
from the game
ObjectAddress
field address
raw values
PlayerSnapshot,
a local copy

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:

Terminal window
cargo run --manifest-path windows-labs/Cargo.toml `
--target i686-pc-windows-msvc `
--bin object_layout_probe

The 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=false
entity[1] 0x17021480 name="bot_1" pos=(90.25, 74.00, 5.00) yaw=88.70 pitch=0.00 dead=false

Addresses 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.
Knowledge check

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?

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.

Chapter 3: 11 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.