Rust: Collections, Bounds, and Bytes
Read iterator chains, overlapping windows, complete records, explicit byte conversion, integer limits, and C layout.
Cheatsheet for Lesson 3.3
A one-page summary of “Rust: Collections, Bounds, and Bytes” you can keep beside your notes. Plain Markdown.
Lesson 3.3 — Rust: Collections, Bounds, and Bytes: cheatsheet
- Remember: 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.
- Also useful: 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.
Quick reference
- Keep selected items:
.filter(predicate)— Predicate borrows its item. - Transform each item:
.map(function)— Runs as the iterator is consumed. - Accumulate an answer:
.fold(start, combine)— Arithmetic must fit or use an explicit policy. - Compare neighbors:
.windows(n)—nmust be positive; windows overlap. - Decode fixed records:
.chunks_exact(n)—nmust be positive; inspect the remainder. - Convert an exact field:
from_le_bytes/to_le_bytes— Follow the format's byte order and width. - Reject overflow:
checked_*— Caller handlesNone. - Cycle through bit patterns:
wrapping_*— Wraparound must be intended. - Stop at the type limit:
saturating_*— The game limit may be smaller. - Match C layout:
#[repr(C)]— Target alignment and padding still apply.
Review
- Q: An iterator chain filters and transforms items. Nothing consumes the chain yet. What is normally happening? A: Operations are described; item processing is deferred.
- Q: A query keeps values matching a condition, transforms them, and combines them into one answer. Which sequence fits? A: Filter, then map, then fold over the kept values.
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.
Choose a collection for the question
Section titled “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:
| Collection | Picture it as | Good first use | Important limit |
|---|---|---|---|
Array, [T; N] |
numbered slots with a fixed count | two known players, three prices | its count does not grow |
Vector, Vec<T> |
numbered slots that can grow | players joining or a history of health samples | removing from the middle shifts later items |
Hash map, HashMap<K, V> |
a key pointing to its value | find Ada’s gold by her name or ID | a key may be missing; order is not the promise |
Queue, VecDeque<T> |
a line of waiting items | handle actions in arrival order | it is for the next item, not fast arbitrary lookup |
| Grid | rows and columns | map tiles at (row, column) |
boundaries must be checked |
| Graph | places and their connections | waypoints and reachable paths | you must store and inspect the connections |
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:
let mut recent_gold = vec![100, 75];recent_gold.push(65);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.
Try the growable collections in Rust
Section titled “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:
use std::collections::{HashMap, VecDeque};
let mut recent_stamina = vec![12, 8];recent_stamina.push(6);let second_sample = recent_stamina[1];println!("Second sample: {second_sample}");
let mut stamina_by_name = HashMap::new();stamina_by_name.insert("Mira", 8);if let Some(amount) = stamina_by_name.get("Mira") { println!("Mira has {amount} stamina");}
let mut pending = VecDeque::new();pending.push_back("dash");pending.push_back("rest");if let Some(action) = pending.pop_front() { println!("Next action: {action}");}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.
Read an iterator chain one item at a time
Section titled “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.
fn doubled_total(budget: u32) -> u32 { let prices = [3_u32, 8, 12]; prices.into_iter() .filter(|cost| *cost <= budget) .map(|cost| cost * 2) .fold(0, |sum, cost| sum + cost)}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.
Keep affordable costs, transform them, then accumulate. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let prices = [3_u32, 8, 12];
let total = prices.into_iter()
.filter(|cost| *cost <= budget)
.map(|cost| cost * 2)
.fold(0, |sum, cost| sum + cost);No items remain. The total is the fold accumulator, including zero when no price passed.
Final values: budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16; total = 22.
Read every step of the lesson’s example
Step 1, line 1. The owned array contains three prices. This query does not make purchases or reduce the budget.
budget = 8; sum = 0; prices = [3, 8, 12].
Step 2, line 2. into_iter yields each u32 by value. The adaptors are lazy until fold asks for values.
budget = 8; sum = 0; prices = [3, 8, 12].
Step 3, line 3. filter borrows 3 to test it: keep.
budget = 8; sum = 0; prices = [3, 8, 12]; cost = 3; kept = true.
Step 4, line 4. map receives the kept number by value and doubles it.
budget = 8; sum = 0; prices = [3, 8, 12]; cost = 3; kept = true; doubled = 6.
Step 5, line 5. fold adds it to the accumulator and requests the next item.
budget = 8; sum = 6; prices = [3, 8, 12]; cost = 3; kept = true; doubled = 6.
Step 6, line 3. filter borrows 8 to test it: keep.
budget = 8; sum = 6; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 6.
Step 7, line 4. map receives the kept number by value and doubles it.
budget = 8; sum = 6; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 16.
Step 8, line 5. fold adds it to the accumulator and requests the next item.
budget = 8; sum = 22; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 16.
Step 9, line 3. filter borrows 12 to test it: skip.
budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16.
Step 10, line 5. No items remain. The total is the fold accumulator, including zero when no price passed.
budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16; total = 22.
Express a repeated query with an iterator
Section titled “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:
let stamina_samples = [8_u32, 3, 6];let ready = stamina_samples .iter() .copied() .filter(|stamina| *stamina >= 4) .count();println!("{ready} runners can dash"); // 2.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:
let stamina_samples = [8_u32, 3, 6];let mut ready = 0;for stamina in stamina_samples { if stamina >= 4 { ready += 1; }}// ready is 2Both forms produce 2. Choose the form that makes the question and each value transformation clearest.
Use windows for neighboring samples
Section titled “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.
fn movement_deltas(positions: &[i32]) -> Vec<i32> { positions.windows(2) .map(|pair| pair[1] - pair[0]) .collect()}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.
Overlapping windows keep neighboring samples together. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let positions = [2_i32, middle, 10];
let deltas: Vec<i32> = positions.windows(2)
.map(|pair| pair[1] - pair[0])
.collect();collect consumes the iterator into a new owned Vec. The original positions remain unchanged.
Final values: positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4; deltas = [4, 4].
Read every step of the lesson’s example
Step 1, line 1. The array owns three samples. Each two-element window borrows consecutive elements.
positions = [2, 6, 10]; middle = 6.
Step 2, line 2. The next window overlaps the previous one by one sample.
positions = [2, 6, 10]; middle = 6; pair = [2, 6].
Step 3, line 3. A window has exactly two elements, so these two indexes are in bounds.
positions = [2, 6, 10]; middle = 6; pair = [2, 6]; delta = 4.
Step 4, line 2. The next window overlaps the previous one by one sample.
positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4.
Step 5, line 3. A window has exactly two elements, so these two indexes are in bounds.
positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4.
Step 6, line 4. collect consumes the iterator into a new owned Vec. The original positions remain unchanged.
positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4; deltas = [4, 4].
Use chunks_exact for complete records
Section titled “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.
fn decode_records(bytes: &[u8]) -> Result<Vec<u16>, &'static str> { let records = bytes.chunks_exact(2); if !records.remainder().is_empty() { return Err("partial record"); } Ok(records .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) .collect())}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.
Fixed records leave a remainder that must be handled. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let records = bytes.chunks_exact(2);
let tail = records.remainder();
if !tail.is_empty() { return Err("partial record"); }
let totals: Vec<u16> = records
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();The complete packet yields two values.
Final values: length = 4; record_size = 2; tail = []; record = 9; totals = [4, 9].
Read every step of the lesson’s example
Step 1, line 1. Non-overlapping chunks contain exactly two bytes each. A zero chunk size would panic; this example fixes it at two.
length = 4; record_size = 2.
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 2, line 2. remainder borrows the short tail; the record iterator has not consumed anything yet.
length = 4; record_size = 2; tail = [].
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 3, line 4. The tail is empty, so the complete record iterator can be consumed.
length = 4; record_size = 2; tail = [].
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 4, line 5. Each two-byte pair decodes using an explicit little-endian order.
length = 4; record_size = 2; tail = []; record = 4.
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 5, line 5. The next pair becomes 9. Its bytes did not overlap the first pair.
length = 4; record_size = 2; tail = []; record = 9.
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 6, line 6. The complete packet yields two values.
length = 4; record_size = 2; tail = []; record = 9; totals = [4, 9].
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Check slice bounds before decoding a number
Section titled “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.
fn read_gold(bytes: &[u8]) -> Option<u32> { let raw: [u8; 4] = bytes.get(..4)?.try_into().ok()?; Some(u32::from_le_bytes(raw))}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
[0x2C, 0x01, 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 0x64, 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: 0x64000000, which is 1,677,721,600. from_be_bytes treats the byte at +0 as the highest byte, so 0x64 lands in the top byte, which is worth 256 × 256 × 256 = 16,777,216: 100 × 16,777,216 = 1,677,721,600. The bytes did not change; only the reading order did.
Number, bytes in memory, number again
100.to_le_bytes()
lowest byte goes to +0
In memory, offset +0 firstu32::from_le_bytes
100 is 0x00000064. to_le_bytes stores 64 00 00 00, and from_le_bytes reads them back as 100.
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.
A bounded slice becomes a number, then bytes again. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
fn read_gold(bytes: &[u8]) -> Option<u32> {
let raw: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
Some(u32::from_le_bytes(raw))
}
let encoded = gold.to_le_bytes();to_le_bytes chooses the same explicit order for writing. It does not depend on the host CPU.
Final values: length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300); encoded = 2C 01 00 00.
Read every step of the lesson’s example
Step 1, line 1. A shared slice lends bytes and their length; it does not transfer their ownership.
length = 4; gold = 300.
Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.
Step 2, line 2. The checked slice has exactly four bytes; try_into copies them into a four-byte array.
length = 4; gold = 300; raw = [2C, 01, 00, 00].
Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.
Step 3, line 3. Little-endian conversion makes the low byte first. The number is 300.
length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300).
Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.
Step 4, line 5. to_le_bytes chooses the same explicit order for writing. It does not depend on the host CPU.
length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300); encoded = 2C 01 00 00.
Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.
Choose how integer limits are handled
Section titled “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:
let count = 250_u8;let checked = count.checked_add(10); // Nonelet wrapping = count.wrapping_add(10); // 4let saturating = count.saturating_add(10); // 255checked_* 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.
Choose what an overflowing counter means. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let count: u8 = start;
let checked = count.checked_add(gain);
let wrapping = count.wrapping_add(gain);
let saturating = count.saturating_add(gain);saturating_add clamps at the largest value. Use this for a meter that must stop at its limit.
Final values: count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4; saturating = 255.
Read every step of the lesson’s example
Step 1, line 1. The mathematical sum may be wider than the destination type.
count = 250; gain = 10; mathematical = 260.
Step 2, line 2. The sum does not fit; checked_add gives None and lets the caller reject it.
count = 250; gain = 10; mathematical = 260; checked = None.
Step 3, line 3. wrapping_add keeps the low eight bits. Use this when the counter is intentionally cyclic.
count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4.
Step 4, line 4. saturating_add clamps at the largest value. Use this for a meter that must stop at its limit.
count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4; saturating = 255.
Separate in-memory layout from a file format
Section titled “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:
#[repr(C)]struct Record { tag: u8, gold: u32, zone: u16,}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.
let gold_offset = std::mem::offset_of!(Record, gold);let record_size = std::mem::size_of::<Record>();let record_alignment = std::mem::align_of::<Record>();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.
C layout includes alignment padding. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
#[repr(C)]
struct Record { tag: u8, gold: u32, zone: u16 }
let record = Record { tag: 1, gold, zone: 2 };
let offset = std::mem::offset_of!(Record, gold);
let size = std::mem::size_of::<Record>();Two tail padding bytes round the total up to a multiple of the struct alignment.
Final values: target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4; size = 12.
Read every step of the lesson’s example
Step 1, line 1. repr(C) chooses C field ordering and alignment for the target platform. It is not a disk-file format.
target = Windows x64; gold = 300.
Step 2, line 2. tag begins at zero; gold needs four-byte alignment, so three bytes of padding precede it.
target = Windows x64; gold = 300; alignment = 4.
Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.
Step 3, line 3. The value changes; the type layout does not.
target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2.
Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.
Step 4, line 4. offset_of! reports gold at offset four for this target layout.
target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4.
Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.
Step 5, line 5. Two tail padding bytes round the total up to a multiple of the struct alignment.
target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4; size = 12.
Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.
Cheatsheet: sequences and representations
Section titled “Cheatsheet: sequences and representations”| Need | Expression | Boundary to check |
|---|---|---|
| Keep selected items | .filter(predicate) |
Predicate borrows its item. |
| Transform each item | .map(function) |
Runs as the iterator is consumed. |
| Accumulate an answer | .fold(start, combine) |
Arithmetic must fit or use an explicit policy. |
| Compare neighbors | .windows(n) |
n must be positive; windows overlap. |
| Decode fixed records | .chunks_exact(n) |
n must be positive; inspect the remainder. |
| Borrow checked elements | .get(index_or_range) |
Returns None outside the bounds. |
| Convert an exact field | from_le_bytes / to_le_bytes |
Follow the format’s byte order and width. |
| Reject overflow | checked_* |
Caller handles None. |
| Cycle through bit patterns | wrapping_* |
Wraparound must be intended. |
| Stop at the type limit | saturating_* |
The game limit may be smaller. |
| Match C layout | #[repr(C)] |
Target alignment and padding still apply. |
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.
Reason about the concept
An iterator chain filters and transforms items. Nothing consumes the chain yet. What is normally happening?
Iterator adaptors are generally lazy. A consumer requests items and drives the chain.
Finished Lesson 3.3?
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.