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

How Collections Live and Die in Memory

Identify vectors, strings, handles, component pools, reference counts, and destruction paths without assuming one compiler's private layout.

Lesson 3.10
Read or listenPrint chapter or book
About 40 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.10Key ideas, reference and questions

A one-page summary of “How Collections Live and Die in Memory” you can keep beside your notes. Plain Markdown.

Lesson 3.10 — How Collections Live and Die in Memory: cheatsheet

  • Remember: A readable, non-null pointer does not prove it still points at the same object. The memory may have been freed and reused.
  • Also useful: Constructors show how an object comes to exist, and destructors show when old addresses stop being safe to trust.

Key terms

  • lifetime: These are lifetime questions.
  • heap: The heap is memory handed out by an allocator for objects whose lifetime does not simply end when the creating function returns.
  • capacity_end: The gap between end and capacity_end is the container's spare room.
  • linked list: A linked list stores each element in its own heap block, called a node, together with the address of the next node, and often the previous one too.
  • node: A linked list stores each element in its own heap block, called a node, together with the address of the next node, and often the previous one too.

Formulas and patterns

  • begin <= end <= capacity_end; (end - begin) is divisible by element_size; the addresses belong to readable memory; the resulting count fits a sensible limit

Review

  • Q: What does the generation part of an index-plus-generation handle prove? A: The slot still represents the same logical object generation
  • Q: Which of these is a lifetime question about an object that a game keeps in a list? A: Can the object disappear while another system still remembers it?
Download cheatsheet (.md)

Imagine that offset 0x40 points to a weapon. Knowing that does not answer the dangerous questions:

  • Does the player own the weapon?
  • Can several players share it?
  • Can it disappear while another system still remembers it?
  • Is the value a pointer, an index, or a generation-checked handle?

These are lifetime questions. A reliable memory tool needs them because an address that was correct one frame ago may now belong to a different allocation.

The heap is memory handed out by an allocator for objects whose lifetime does not simply end when the creating function returns. A spawned enemy, a resized list, or a text buffer can live across many calls; the game later releases or reuses its allocation. Unlike a call’s stack frame, cleanup is controlled by an owner or a game event. An individually allocated object commonly stays at one address while that allocation lives, but containers can move their elements when they grow and an allocator can reuse a freed address.

Follow one address through two different owners:

0x5000enemy A · health 100
0x5080free
0x5100chat text
Before: enemy A lives at 0x5000.
0x5000enemy B · health 87
0x5080free
0x5100chat text
After A is freed, the allocator reuses 0x5000 for B. The address is readable but no longer identifies A.

A pointer being non-null and readable therefore does not prove object identity. Check its owner, generation or other identity fields, and the event that ends its lifetime. This is the deeper version of the brief address warning in Lesson 1.8.

Recognize behavior before naming a container

Section titled “Recognize behavior before naming a container”

Compiler libraries have implementation details that can change. Instead of memorizing one screenshot of std::vector, recognize its behavior.

A vector-like container commonly needs three logical facts:

Fact Meaning
begin address of the first element
end one element past the last live element
capacity end one element past the allocated storage

The element count is (end - begin) / element_size. Before using that formula, verify all of these:

begin <= end <= capacity_end
(end - begin) is divisible by element_size
the addresses belong to readable memory
the resulting count fits a sensible limit

Worked through with real numbers, a vector of 32-byte entities might look like this:

begin = 0x0453_1000
end = 0x0453_1140
capacity_end = 0x0453_1400
end - begin = 0x140 = 1×256 + 4×16 = 320 bytes
capacity_end - begin = 0x400 = 4×256 = 1024 bytes
320 / 32 = 10 live elements
1024 / 32 = 32 elements of allocated room
0x045310000
1
2
· · ·
9
0x0453114010
· · ·
31
live: 0x140 ÷ 32 = 10 elements
allocated, never constructed: 22
The element storage behind begin, end, and capacity_end. Boxes are elements, not bytes.

The gap between end and capacity_end is the container’s spare room. That is exactly why the count has to come from end rather than from the size of the allocation — using the allocation would report 32 entities, 22 of which were never constructed.

Spare room runs out. When end reaches capacity_end, the next append cannot fit in the same block.

Predict first

A vector of 32-byte entities is full: begin = 0x0453_1000 and end = capacity_end = 0x0453_1400, so it holds 0x400 ÷ 32 = 32 elements. Your tool cached element 3's address, 0x0453_1000 + 3 × 32 = 0x0453_1060.

The game appends a 33rd entity. What is at 0x0453_1060 afterwards?

Answer: Freed memory that can be handed to anything. With no room left, the vector allocates a bigger block, copies the 32 elements into it, adds the new one and frees the old block. Element 3 now lives at the new begin + 0x60.

Growing a full vector moves every element32-byte entities, begin = 0x04531000. A tool cached element 3 at 0x04531000 + 3 × 32 = 0x04531060.
  1. 1 Full: 32 of 32 slots used

    Old block at 0x04531000
    beginend = capacity_end
    0
    …
    30x04531060
    …
    31

    32 × 32 bytes = 0x400, so end = 0x04531400 = capacity_end: no room for one more.

  2. 2 Append a 33rd: allocate, copy, add

    New block at 0x06120000
    0
    …
    30x06120060
    …
    31
    32new

    A bigger block (here 64 slots) receives copies of all 32 elements, then the new one.

  3. 3 The old block is freed

    Old block, now freed
    0
    …
    30x04531060: stale
    …
    31
    New block at 0x06120000
    0
    …
    30x06120060
    …
    31
    32

    Element 3 is now at 0x06120000 + 0x60. The cached 0x04531060 points into freed memory.

32-byte entities, begin = 0x04531000. A tool cached element 3 at 0x04531000 + 3 × 32 = 0x04531060.

Notice how much rests on element_size being correct. Guess 16 bytes instead of 32 and the same three pointers report 320 / 16 = 20 elements, half of them the second halves of real entities. Every address is readable and the count looks entirely reasonable. The divisibility check rejects many wrong guesses, but not one that happens to divide evenly like this, so the element size has to come from somewhere else: the stride the code actually uses when it walks from one element to the next.

Those checks describe meaning. They remain useful even if a compiler changes field order.

Live elements versus allocated room. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

begin = 0x04531000; capacity_bytes = 1024
used_bytes = end - begin
check 0 <= used_bytes <= capacity_bytes
check used_bytes % stride == 0
count = used_bytes / stride
report checked count and remaining uncertainty

Result: 10. Confirm the stride from observed accesses; readable, divisible bytes alone do not prove it.

Final values: used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true; count = 10.

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.

    used = 320; stride = 32.

  2. Step 2, line 2. End is 0x4531140; capacity end is 0x04531400.

    used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024.

  3. Step 3, line 3. Compare the live range with the allocation.

    used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true.

  4. Step 4, line 4. The remainder after dividing by stride is 0.

    used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true.

  5. Step 5, line 5. The structural checks allow this count.

    used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true; count = 10.

  6. Step 6, line 6. Result: 10. Confirm the stride from observed accesses; readable, divisible bytes alone do not prove it.

    used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true; count = 10.

One collection can arrange the same fields in different ways

Section titled “One collection can arrange the same fields in different ways”

Lesson 1.3 grouped one player’s ID, health, position, and team into a record. With several players, one implementation can put complete records side by side. Player 1 is highlighted here:

id 0
hp 0
pos 0
team 0
id 1
hp 1
pos 1
team 1
player 0
player 1
Array of structures: fields for one player sit together.

Another implementation can keep a separate collection for each kind of field. Player 1’s information is now spread among the collections:

id 0
id 1
hp 0
hp 1
pos 0
pos 1
team 0
team 1
IDs
health
positions
teams
Structure of arrays: one field type sits together, while one player's fields are spread out.

The diagrams show logical order, not exact byte sizes. Both layouts represent the same players. The first can be convenient when code handles one complete player at a time. The second can reduce unrelated data read into the CPU cache when code sweeps across only positions or only health. Neither is automatically faster for every workload. Watch the addresses a repeating instruction visits: a fixed stride through records suggests the first arrangement; separate dense runs of one field suggest the second. An entity-component system can take the separation further by putting components in distinct pools, as shown later in this lesson.

Fixed offsets and scaled offsets answer different questions

Section titled “Fixed offsets and scaled offsets answer different questions”

Machine code often exposes an address formula. Read the formula before naming the container:

base + fixed_offset → one possible field
base + index × stride → one possible array element
base + index × stride + offset → one field inside an array element
+0x00record 0
+0x08field
+0x30record 1
+0x38field
+0x60record 2
+0x68field
0x30 bytes
0x30 bytes
0x30 bytes
base + index × 0x30 + 0x08 lands on the highlighted field of each record. Not to scale.

For example, base + index * 0x30 + 0x08 suggests records that are 0x30 bytes apart and a candidate field eight bytes into each record. Test several indexes and compare the same behavior at each calculated address.

The formula shows how the code computes an address, not the declared type. A compiler can unroll a small loop into three fixed-offset stores, making an array look like a structure. Conversely, a table-driven system can compute what is logically a field offset, making a structure look indexed. Access patterns over time settle it:

  • repeated neighboring strides support an array-of-records model;
  • many unrelated functions reusing the same constants support named fields;
  • allocation size gives an upper bound, not a complete layout;
  • instruction width constrains a field’s size, not necessarily its full type;
  • one readable value never distinguishes a pointer, handle, counter, or bit set.

Other containers trade arithmetic for other clues

Section titled “Other containers trade arithmetic for other clues”

A vector lets code reach element n with arithmetic on one block. A short C++ string may instead keep its text inside the object and switch to a heap pointer when the text grows; a linked list, search tree, or hash table reaches its elements by following pointers or hashing a key. The optional container reference shows each shape’s clue in the code that walks it. Before walking any linked shape, a scanner needs a maximum-node limit and a visited-address set, because corrupted data can form a cycle that would otherwise trap it forever.

Choose the model from what the game does with the data, as Lesson 3.3’s collection choices do, and then ask whether items can be inserted or removed while you read them. A readable address alone cannot identify a container.

A field that leads to another object may own it alone, share it through reference counts, or name it indirectly. The optional ownership reference shows the clues that unique and shared owners leave. A handle is the indirect form.

Games often avoid long-lived raw pointers by storing a small handle:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Handle {
index: u32,
generation: u32,
}
fn resolve<'a, T>(handle: Handle, slots: &'a [Slot<T>]) -> Option<&'a T> {
let slot = slots.get(handle.index as usize)?;
(slot.generation == handle.generation).then_some(slot.value.as_ref()?)
}
struct Slot<T> {
generation: u32,
value: Option<T>,
}

The index chooses a slot. The generation proves that the slot still represents the same logical object. If a deleted entity’s slot is reused, its generation changes and old handles stop resolving. ✅

For example, a saved handle for slot 12 with generation 3 resolves only while slot 12 still reports generation 3. If that enemy dies and a new enemy occupies slot 12 with generation 4, the old handle is rejected. The slot may hold a real living object at a valid address; the generation mismatch is what reveals that it is the wrong object.

Follow destruction as carefully as construction

Section titled “Follow destruction as carefully as construction”

A memory snapshot shows one state. Construction and destruction show how the state came to exist and which parts belong together.

A constructor candidate often:

  • receives a newly allocated or caller-provided object address;
  • writes repeated default values at fixed offsets;
  • calls base constructors before initializing derived fields;
  • writes one or more vptr candidates, sometimes replacing an earlier base vptr;
  • returns the same object or passes it into a registration system.
allocate the block
base constructor:
vptr = base table,
base fields = defaults
derived constructor:
vptr = derived table,
derived fields = defaults
registered and used
by the game
destructor: unregister,
release children
block freed: every cached
address to it is stale

None of those clues identifies a constructor alone, because an ordinary reset function can also fill fields with defaults. What a reset function does not do is run right after an allocation, call base constructors first, and switch the vptr from the base table to the derived table.

Destructors show ownership and the point after which a cached address is stale. For a memory tool, that lifetime boundary matters more than recovering the original C++ class name. Ask:

  1. Which child objects are destroyed?
  2. Which pointers are merely cleared?
  3. Which reference counts are released?
  4. Which collections remove the object?
  5. Is memory freed immediately or returned to a pool?

Set a breakpoint on an owned toy object’s destructor and delete it through the normal game action. The call chain and the fields the destructor touches answer those five questions. Repeat the experiment, because one run may include unrelated cleanup.

Component pools store fields in separate collections

Section titled “Component pools store fields in separate collections”

In an entity-component system, an entity handle may resolve into several pools:

entity 42
transform pool, slot 7
health pool, slot 19
inventory pool: absent

A sparse set often combines a sparse index array with a dense component array. Deletion may swap the final dense element into the removed slot, so a component’s address can move even while the entity remains valid.

slot 0A
slot 1B
slot 2C
slot 3D
A dense component array. Entity B's component is about to be removed.
slot 0A
slot 1D
slot 2C
Swap-remove: the last element, D, moves into the hole. D's entity did not change, but its component now lives at a different address.

That is why a stable entity ID is often more meaningful than a cached component pointer.

Do not immediately use #[repr(C)] struct Player for a layout that is only partly known. A staged representation shows which parts are still unknown:

#[derive(Debug, Clone, Copy)]
struct SuspectedPlayerLayout {
object_size: usize,
health_offset: Option<usize>,
weapon_link_offset: Option<usize>,
weapon_link_kind: LinkKind,
}
#[derive(Debug, Clone, Copy)]
enum LinkKind {
Unknown,
RawPointer,
SharedOwner,
GenerationalHandle,
}

An Option says the field has not been confirmed. Unknown prevents a guess from silently becoming a fact. Once source, symbols, and repeated behavior agree, the model can become more precise.

The derive line generates formatting and copying for this small description of a layout, as Lesson 3.4 explains; it does not make an unconfirmed game-memory layout safe to read.

  • object size agrees with allocation and construction;
  • every field offset is seen at multiple call sites;
  • container count and capacity pass arithmetic checks;
  • traversal has bounds and cycle detection;
  • ownership claims agree with destruction behavior;
  • cached addresses are invalidated when the game changes scenes;
  • the layout is stored with the build it belongs to;
  • disagreements remain visible instead of being “fixed” by guessing.

Those checks assume we know what each field’s stored bytes represent. If the game’s visible health is 125 but no candidate field contains 125, the next question is whether the game stores a transformed value. Lesson 14.9 follows that question without throwing away the object and lifetime checks we just built.

Optional reference: other containers and owners

Section titled “Optional reference: other containers and owners”

These layouts come from particular libraries and containers. The vector header, lifetime, and handle checks above do not depend on them.

Optional reference: strings can store short text inside themselves

Many C++ string implementations use a small-string optimization. Short names may live directly inside the string object, while longer names use a heap buffer. This explains a confusing sight: one player name looks like text at offset 0x20, but a longer name makes the same bytes look like a pointer.

Here is one real implementation’s layout, the 32-bit Microsoft C++ library’s std::string, holding the three-character example name Ivy and then a longer one. The name is only sample text; its length is what matters:

+0x00Ivy, then a zero byte
+0x10size 3
+0x14capacity 15
16 bytes: the text itself
Short: up to 15 characters live inside the object.
+0x00pointer to heap text
+0x10size 40
+0x14capacity ≥ 40
the same 16 bytes now start with a pointer
Long: the first four bytes point to a heap buffer, and the capacity tells you which mode you are in.

Do not read an assumed pointer until you identify the mode flag or length behavior. Compare the same object with names on both sides of the suspected short-string limit. 🔬

A vector keeps its elements side by side, so code finds element n with arithmetic. Three other common containers give up that arithmetic in exchange for something else, and each exchange leaves a recognisable pattern in the code that walks it.

Optional reference: a linked list, every element points to the next

A linked list stores each element in its own heap block, called a node, together with the address of the next node, and often the previous one too. It exists to make inserting and removing cheap. Taking the first of 10,000 elements out of a vector means sliding the other 9,999 down one place; taking it out of a doubly linked list means rewriting two pointers in its neighbours.

next
prev
next
prev
node A
prev · payload · next
node B
prev · payload · next
node C
prev · payload · next

The price is that a list has no arithmetic shortcut. Reaching the 1,000th node takes 1,000 reads, each one loading the next pointer out of the node just reached. That repeated load from a fixed offset inside the current node, such as mov eax, [eax+4] in a loop, is the list’s clue. The nodes are wherever the allocator put them, not in one block.

Optional reference: a search tree, each comparison discards half

A binary search tree node holds a key and two child pointers: smaller keys go to the left, larger ones to the right. Finding a key means comparing and stepping left or right. In a balanced tree, each step discards roughly half the remaining possibilities, so a tree of 1,000,000 entries needs about 20 steps: 2²⁰ = 1,048,576. std::map typically maintains such a balanced tree. Its clue is a loop that compares a key and then loads one of two pointers depending on the result. Its failure is lopsidedness: keys inserted in sorted order all go the same way and turn the tree into a slow list, which is why real implementations rebalance themselves.

Optional reference: a hash table, arithmetic picks the bucket

A hash table turns the key into a number, its hash, and uses that number to pick one slot, or bucket, in an array of buckets. Only the entries in that bucket are checked. With a suitable hash and enough buckets for the entries, lookup is usually close to constant time as the table grows; collisions or a full table can make it slower. With 16 buckets, a hash of 0x5A3 selects bucket 0x5A3 & 0xF = 3, because the mask 0xF keeps only the last hex digit. The clue is arithmetic on the key (multiplies, shifts, XORs), then that mask or a remainder, then a short walk through one bucket. Two keys can land in the same bucket, a collision, which is why each bucket may hold a short list. Lesson 11.3 shows the same idea inside Lua’s tables.

Optional reference: invariants for walking linked structures

Validate structural invariants:

  • a doubly linked node’s next.prev should return to the node;
  • tree children should eventually end at a sentinel or null;
  • bucket indices must stay below the bucket count;
  • traversal needs a maximum-node limit and a visited-address set.

The visited set is important. Corrupted data could form a cycle and otherwise trap a scanner forever.

Optional reference: unique ownership

If one owner’s destructor directly destroys the pointed object, and moves clear the old pointer, unique ownership is likely. That resembles C++ unique_ptr, but use a neutral label until symbols confirm it.

Optional reference: shared ownership

Shared objects often have a separate control block with strong and weak reference counts. Copies increment a count; releases decrement it; a transition to zero calls a destructor. The object pointer and control-block pointer may travel together.

owner 1
object pointer, control pointer
the object
owner 2
object pointer, control pointer
control block
strong count 2, weak count 1
a weak observer

Never edit a suspected count. The calls around it show whether its updates are atomic, as a thread-safe reference count’s must be; a plain integer that happens to rise and fall is not enough to call something a reference count.

Knowledge check

Reject a stale identity

What does the generation part of an index-plus-generation handle prove?

Finished Lesson 3.10?

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.