Skip to lesson
Game Hacking Academy
Preparing lesson text…
Exit reader mode
Voice and speed

Preparing read-aloud controls…

Lesson 3.10 Listening edition

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.

About 40 minutes

Section: A field layout is only half a model.

Imagine that offset hexadecimal 4 0 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:

Labelled memory cells
Labelled memory cells

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.

In brief: A readable, non-null pointer does not prove it still points at the same object. The memory may have been freed and reused.

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

Lesson table

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Labelled memory cells

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.

Alternative: Think of a bookshelf with empty slots: begin is the left end, end is just after the last book, capacity end is the shelf’s far edge. Count books, not slots.

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 equals this value and end equals capacity_end equals this value, so it holds hexadecimal 4 0 0 divided by 32 equals 32 elements. Your tool cached element 3's address, this value + 3 times 32 equals this value.

The game appends a 33rd entity. What is at this value 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 + hexadecimal 6 0.

Growing a full vector moves every element.

Notice how much rests on element_size being correct. Guess 16 bytes instead of 32 and the same three pointers report 320 / 16 equals 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.

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

Labelled memory cells

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

Labelled memory cells

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 C P U, central processing unit, 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.

Section: Fixed offsets and scaled offsets answer different questions.

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Labelled memory cells

For example, base + index * hexadecimal 3 0 + hexadecimal 0 8 suggests records that are hexadecimal 3 0 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.

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

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

Section: Handles.

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

Narration: This is why games often store a handle instead of a raw pointer: when an enemy dies, old references fail cleanly instead of silently reaching whoever reuses the slot.

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

Lesson diagram

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.

In brief: Constructors show how an object comes to exist, and destructors show when old addresses stop being safe to trust.

Section: Component pools store fields in separate collections.

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

Lesson diagram

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.

Labelled memory cells
Labelled memory cells

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

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

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

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

Additional detail: 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 hexadecimal 2 0, 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:

Labelled memory cells
Labelled memory cells

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.

Additional detail: 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.

Lesson diagram

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 e a x, [e a x+4] in a loop, is the list’s clue. The nodes are wherever the allocator put them, not in one block.

Additional detail: 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 the large number shown entries needs about 20 steps: 2 to the power of 20 equals the large number shown. 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.

Additional detail: 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 hexadecimal 5 A 3 selects bucket hexadecimal 5 A 3 & hexadecimal F equals 3, because the mask hexadecimal F 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.

Additional detail: 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.

Additional detail: 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.

Additional detail: 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.

Lesson diagram

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.