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.
Cheatsheet for Lesson 3.10
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 betweenendandcapacity_endis 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?
A field layout is only half a model
Section titled “A field layout is only half a model”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:
0x5000.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_sizethe addresses belong to readable memorythe resulting count fits a sensible limitWorked through with real numbers, a vector of 32-byte entities might look like this:
begin = 0x0453_1000end = 0x0453_1140capacity_end = 0x0453_1400
end - begin = 0x140 = 1×256 + 4×16 = 320 bytescapacity_end - begin = 0x400 = 4×256 = 1024 bytes
320 / 32 = 10 live elements1024 / 32 = 32 elements of allocated roombegin, 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.
1 Full: 32 of 32 slots used
Old block at 0x04531000beginend = capacity_end0…30x04531060…3132 × 32 bytes = 0x400, so end = 0x04531400 = capacity_end: no room for one more.
2 Append a 33rd: allocate, copy, add
copy 32 elements
New block at 0x061200000…30x06120060…3132newA bigger block (here 64 slots) receives copies of all 32 elements, then the new one.
3 The old block is freed
Old block, now freed0…30x04531060: stale…31New block at 0x061200000…30x06120060…3132Element 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 uncertaintyResult: 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
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
used = 320; stride = 32.
Step 2, line 2. End is 0x4531140; capacity end is 0x04531400.
used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024.
Step 3, line 3. Compare the live range with the allocation.
used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true.
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.
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.
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:
Another implementation can keep a separate collection for each kind of field. Player 1’s information is now spread among the collections:
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 fieldbase + index × stride → one possible array elementbase + index × stride + offset → one field inside an array elementbase + 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.
Raw pointers, smart pointers, and handles
Section titled “Raw pointers, smart pointers, and handles”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.
Handles
Section titled “Handles”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.
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:
- Which child objects are destroyed?
- Which pointers are merely cleared?
- Which reference counts are released?
- Which collections remove the object?
- 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:
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.
That is why a stable entity ID is often more meaningful than a cached component pointer.
Make the model honest
Section titled “Make the model honest”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.
Checks a recovered layout should pass
Section titled “Checks a recovered layout should pass”- 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:
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.
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.prevshould 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.
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.
Reject a stale identity
What does the generation part of an index-plus-generation handle prove?
A reused slot increments its generation, so an older handle cannot silently resolve to a different object.
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.
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.