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

Preparing read-aloud controls…

Lesson 3.3 Listening edition

Rust: Collections, Bounds, and Bytes

Read iterator chains, overlapping windows, complete records, explicit byte conversion, integer limits, and C layout.

About 24 minutes

A memory reader and a packet parser both receive sequences of bytes. Before using those bytes, they need to check the available length, decide how to group elements, and choose the numeric interpretation. Ordinary Rust collections let us learn those decisions in a small local example. The previous lesson explains the Option and Result values returned when a check fails.

Section: Choose a collection for the question.

The guarded purchase in Lesson 1.4 used two Player records, Ada and Bo. A fixed array was enough when we knew there were exactly two. What if players can join, we need one player’s score by name, or we must handle pending actions in arrival order? Those are different questions, and different collections make each one natural:

Lesson table

T stands for the kind of item stored. A vector of u32 values, for example, is written Vec<u32>. K and V stand for a map’s key and value types. A queue can be implemented with Rust’s VecDeque, which lets us add at the back and take from the front.

The array we used has a fixed number of slots. A vector keeps numbered slots but can grow, which fits a history of gold samples:

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

A hash map answers “what value belongs to this key?” A queue answers “what item arrived next?” The following Rust fragments use all three, including a lookup or queue removal with no answer.

A grid can be a vector of rows, so tiles[row][column] reaches a tile. A graph stores which waypoints connect; Lesson 4.8 uses one to search for a route. These are arrangements of data, not magic algorithms. Choose by the operation you need: next by position, look up by key, take the oldest, or visit neighbors. Lesson 3.10 shows how their layouts can be recognized in a running game.

Alternative: Picture a vector as numbered lockers, a hash map as a coat check where a ticket finds your coat, and a queue as a line at a shop.

Lesson diagram

Section: Try the growable collections in Rust.

Different questions call for different collections. A vector keeps a growable sequence of samples. A hash map finds a value by a key. A queue keeps actions in arrival order. These fragments reuse the Primer’s Mira stamina values, separately from its complete two-runner program. The use line makes the two standard library collection types available by their short names:

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

vec! starts a growable vector with two samples, push adds a third, and [1] selects the second sample because positions start at zero. The hash map associates the key "Mira" with 8, so get("Mira") can find the amount without scanning a list of names. The queue adds actions at the back and takes the oldest from the front; after the first pop, "rest" is still waiting.

Both the map lookup and the queue removal may fail to produce an item: the name might be absent or the queue empty. Their results therefore use Some(value) or None. Lesson 3.2 develops that possible-value shape with checked subtraction and fallible operations. The grid/graph distinction above connects these choices to map tiles and paths.

Section: Read an iterator chain one item at a time.

An iterator yields a sequence of items on demand. filter keeps items that satisfy a predicate, map transforms each kept item, and fold combines items into one running answer. The closures describe those operations; a consuming operation such as fold drives the sequence.

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

For a budget of 8, filter keeps 3 and 8, map produces 6 and 16, and fold produces 22. This is a query, not a purchase: each price is checked against the same unchanged budget. It does not ask whether buying all the items together would be affordable.

into_iter consumes this array and yields its u32 values. filter passes a reference to its predicate so an item can still be yielded after the check; *cost reads the borrowed number. map then receives each kept number by value. If you need to borrow the collection instead, start with .iter(); for copyable numbers.iter().copied() yields values while keeping the original collection.

Section: Express a repeated query with an iterator.

A second query counts runners with at least 4 stamina. Unlike the doubled total above, its result is how many items pass the predicate:

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

.iter().copied() keeps the original array while yielding cheap u32 copies. The filter predicate still borrows each candidate, so *stamina reads its number. count consumes the kept values. The equivalent loop is:

Code example. The code’s comments explain: ready is 2

Both forms produce 2. Choose the form that makes the question and each value transformation clearest.

Section: Use windows for neighboring samples.

Suppose three position samples are 2, 6, and 10. We want each change between adjacent samples. windows(2) yields borrowed slices [2, 6] and [6, 10]. They overlap because the middle sample belongs to both neighbor pairs.

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

Every yielded window has exactly two elements, so its two indexes are valid. The example’s small coordinates also keep the subtraction in i32 range; arbitrary external coordinates need a checked subtraction or a wider type. collect consumes the iterator into a new Vec<i32>. A slice shorter than the window size yields no windows. A zero window size would panic, so validate a reader-supplied size before calling it.

Section: Use chunks_exact for complete records.

chunks_exact(2) divides a buffer into separate two-byte groups. Unlike windows, the groups do not overlap. A final single byte does not form a complete record. Rust exposes it through remainder() so the parser can decide whether to reject it or buffer it for later.

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

The buffer [4, 0, 9, 0] decodes as two little-endian numbers, 4 and 9. Appending 255 makes this complete-packet parser return an error. For a stream being received in pieces, keeping that byte for the next read may be appropriate; Lesson 9.1 explains that distinction. This function fixes the group size at two. A zero size would panic.

Clarification: chunks_exact does not certify that the entire buffer contains complete records. Checking the remainder supplies that guarantee.

Section: Check slice bounds before decoding a number.

A slice borrows a contiguous sequence and includes its length. bytes[3] assumes index 3 exists; it panics if that assumption is false. bytes.get(3) instead returns Some(&byte) or None. The same checked operation accepts a range: get(..4) asks for the first four bytes.

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

The range check establishes the length. try_into produces an exact [u8; 4] array from that slice; .ok() turns its Result into an Option, and ? exits if it is missing. from_le_bytes interprets [hexadecimal 2 C, hexadecimal 0 1, 0, 0] as 300. 300_u32.to_le_bytes() reverses that conversion and produces the same four bytes. Use from_be_bytes and to_be_bytes when the format says the high byte comes first.

Predict first

Decimal 100 is hexadecimal 6 4, so 100_u32.to_le_bytes() gives the four bytes 64 00 00 00, lowest byte first.

A reader passes those same four bytes to u32::from_be_bytes. What number does it get?

Answer: this value, which is the large number shown. from_be_bytes treats the byte at +0 as the highest byte, so hexadecimal 6 4 lands in the top byte, which is worth 256 times 256 times 256 equals the large number shown: 100 times the large number shown equals the large number shown. The bytes did not change; only the reading order did.

Write a u32 low byte first, then read it back.

Reading the first four bytes leaves any later bytes unused. A format that requires exactly four bytes must also check the total length. Byte conversion interprets a number; it does not decide whether that number is a valid health amount, length, or address.

Section: Choose how integer limits are handled.

An unsigned eight-bit value, u8, holds 0 through 255. The mathematical sum 250 + 10 is 260 and does not fit. Rust supplies explicit arithmetic policies so the programmer chooses the intended behavior:

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

checked_* reports that an answer cannot be represented. wrapping_* keeps the low bits, making this sum 4 after passing 255. saturating_* stops at the type’s limit. Use checked arithmetic for a size or balance that must be rejected if it overflows; wrapping for an intentionally cyclic counter; saturating for a bounded meter.

Plain integer overflow may panic when overflow checks are enabled or wrap when disabled. Explicit methods make the behavior independent of that build setting. Saturation is to the type limit: a game with maximum health 100 still needs a separate clamp to 100, even if health is a u8.

Section: Separate in-memory layout from a file format.

Rust’s ordinary struct representation does not promise a C field layout. #[repr(C)] requests the target platform’s C ordering and alignment:

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

On the Windows x64 target used in this book, tag is at offset 0, gold at offset 4, and zone at offset 8. Three padding bytes align gold; two tail padding bytes make the full size 12. The same fields written consecutively into a file would occupy only 7 bytes. Padding is part of the type’s memory layout, not an implied serialized field.

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

These operations measure the compiled type on the current target. repr(C) does not select byte order, prove that remote bytes contain valid field values, or make every C++ class compatible with this struct. Decode external file or packet fields explicitly rather than copying the raw bytes of a Rust struct, including its padding.

Code note: An offset describes where a field is within a particular layout. Keep the target architecture and format version with that offset.

Section: Cheatsheet: sequences and representations.

Lesson table

The Iterator reference, slice reference, and type-layout reference give the complete definitions. Next, borrows and interfaces explain how functions share this data without losing its owner.