Skip to content
Lesson 10.3Game Files, Mods, and Artifact Trust

Binary Saves and Hex Editing

Read offsets and bytes in a hex editor, decode a small binary save, compare two files, and update a checksum.

Lesson 10.3
Read or listenPrint chapter or book
About 32 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 10.3Key ideas, reference and questions

A one-page summary of “Binary Saves and Hex Editing” you can keep beside your notes. Plain Markdown.

Lesson 10.3 — Binary Saves and Hex Editing: cheatsheet

  • Remember: A file offset identifies a position inside stored bytes; it does not identify where a loaded field lives in process memory.

Key terms

  • magic number: This fixed starting pattern is a magic number: the loader refuses other formats before decoding fields.
  • length-prefixed string: A length-prefixed string stores its byte count before its text.

Review

  • Q: You change the gold in a GSAV save from 250 to 1,250 with a hex editor, and the game now refuses to load it. What else has to change? A: The checksum at offsets 15 to 18, rewritten as the new byte sum 822
  • Q: A GSAV save has 05 in its name-length byte at offset 11. At which offset does its checksum start? A: 17
Download cheatsheet (.md)

Lesson 10.2 edited Flare’s text avatar.txt save. Its build=5,1,1,2 line records a character whose Physical attribute is 5 and is readable in any text editor. Many saves are not. They store numbers as raw bytes, the same way a u32 sits in memory, because that is smaller and faster to load. Open one in a text editor and most bytes turn into boxes, blanks, or stray symbols. Worse, saving it from a text editor can quietly change bytes, for example by converting line endings, which corrupts the file.

A hex editor shows a file as what it is: a numbered sequence of bytes, each written as two hexadecimal digits. In byte-overwrite mode, it can save the exact replacement bytes without decoding the whole file as text. HxD and ImHex are common examples; they show the same three columns, so nothing in this lesson depends on which one you use.

This lesson uses a toy save format made for the book, GSAV, and a Rust lab that reads and writes it. Here is the complete save for a character called Nia, at level 3, with 250 gold. It is 19 bytes long:

47 53 41 56 01 00 FA 00 00 00 03 03 4E 69 61 4A 03 00 00

Everything here applies to offline, single-player saves. A value kept on a game’s server is not in any file on your disk.

Opened in a hex editor, those 19 bytes look like this:

Offset 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F Text
00000000 47 53 41 56 01 00 FA 00 00 00 03 03 4E 69 61 4A GSAV........NiaJ
00000010 03 00 00 ...
  • The offset column on the left gives the position of the first byte on each row, counted in hex from the start of the file. A row holds 16 bytes, numbered 00 to 0F along the top, so the second row starts at byte 16, which is 0x10 (1 × 16 + 0).
  • The byte column in the middle shows each byte as two hex digits. To find the offset of any byte, add its row offset and its column number: the last byte sits on row 00000010 in column 02, so its offset is 0x10 + 0x02 = 0x12, which is 16 + 2 = 18.
  • The character column on the right shows each byte as a character. ASCII is the standard table that gives every English letter, digit, and common symbol a number from 0 to 127. Bytes from 0x20 (a space) to 0x7E (~) are printable ASCII and appear as themselves; every other byte appears as a dot.

The character column is where text stands out: GSAV at the start and Nia in the middle. It also shows things that are not text. The byte at 0x0F is 4A, which is 4 × 16 + 10 = 74, the ASCII code for the letter J, so the column shows a J even though that byte is part of a number. Nothing in the file marks which bytes are meant as text, so the editor shows every byte both ways.

Each byte belongs to one field of the format. A strip of 16 boxes is one row:

0x0047
0x0153
0x0241
0x0356
0x0401
0x0500
0x06FA
0x0700
0x0800
0x0900
0x0A03
0x0B03
0x0C4E
0x0D69
0x0E61
0x0F4A
magic GSAV
version
gold
level, length
name Nia
sum
Row 00000000: sixteen bytes and the fields they belong to. Byte 0x0A is the level and 0x0B the name's length. The checksum starts at 0x0F and runs on into the next row.
0x1003
0x1100
0x1200
rest of the checksum
Row 00000010: the last three bytes. The checksum starts at 0x0F on the first row and ends here, so one field can straddle two rows.

The full layout, with offsets in hex and in decimal:

The save format, field by field

Offset Size in bytes Field Bytes in Nia’s save
0x00 (0) 4 magic number 47 53 41 56
0x04 (4) 2 version, u16 01 00
0x06 (6) 4 gold, u32 FA 00 00 00
0x0A (10) 1 level, u8 03
0x0B (11) 1 name length, u8 03
0x0C (12) as many as the length says name 4E 69 61
right after the name 4 checksum, u32 4A 03 00 00

The sections below read each field in turn.

An offset in a file is a distance in bytes from the file’s first byte: offset 0x06 means “skip six bytes, then read.” It is not a memory address. When the game loads this save, it reads the bytes and copies each field into its own structures somewhere in its memory, so the gold that sat at file offset 6 ends up at an address that has nothing to do with 6 and usually changes from one run to the next. Two things follow:

  • Changing byte 6 on disk does not change a running game. The game sees the new value only when it loads the file again, and a game that is still running can overwrite the edit when it saves on exit, so close it first.
  • An address found with a memory scanner cannot be typed into a hex editor’s “go to offset” box, or the other way round. Lesson 5.1 met the same split for executables, where the section table translates between file offsets and addresses.

The magic number and the version say what the file is

Section titled “The magic number and the version say what the file is”

47 53 41 56 spells GSAV in ASCII. This fixed starting pattern is a magic number: the loader refuses other formats before decoding fields. The next two bytes, 01 00, give layout version 1. An unknown version must be refused because its fields may have moved. The lab returns UnsupportedVersion in that case.

Lesson 10.2 explains the format and version checks.

Multi-byte numbers are stored lowest byte first

Section titled “Multi-byte numbers are stored lowest byte first”

Games for x86 PCs usually write numbers to disk exactly as they sit in memory, which is little endian: the byte worth the least comes first. Lesson 1.8 explains why each box is worth 256 times the one before it. Gold occupies offsets 6 to 9, which hold FA 00 00 00. 0xFA is 15 × 16 + 10 = 250 and the other three bytes are 0, so the gold is 250. Try other values: changing gold only overwrites these four bytes, and no later field moves.

How the save stores goldType a gold value and read the four bytes a hex editor shows at offsets 0x06 to 0x09, lowest byte first.

Offsets 0x06 to 0x09 of the save

A u32, lowest byte first: what the save holds

The four gold bytes
offset 0x06offset 0x09
FA× 1
00× 256
00× 65,536
00× 16,777,216
Add each byte times its place
250 = 250

Two wrong readings of a number

Same bytes, biggest first
0xFA000000 = 4,194,304,000
Typed as the text "250"
32 35 303 character bytes, not a u32

250 is stored as FA 00 00 00. Read biggest byte first, the same four bytes would mean 4,194,304,000.

The version works the same way with two bytes: 01 00 is 1 × 1 + 0 × 256 = 1. The level at offset 10 is a single byte, 03, so there is no order to get wrong: it is 3.

Reading in the wrong order gives a number that is obviously off, which makes a useful check: read biggest byte first, FA 00 00 00 is 0xFA000000, which is 250 × 16,777,216 = 4,194,304,000 gold. When one byte order gives a plausible value and the other gives billions, the plausible one is almost always the right one.

A length-prefixed string stores its byte count before its text. Here offset 11 contains 03, so the next three bytes, 4E 69 61, spell Nia. The checksum begins at offset 12 + 3 = 15. A five-byte name moves it to offset 17 and grows the whole file to 21 bytes.

The loader checks that 12 + length + 4 equals the file’s real size before reading the name or checksum. A damaged length of 200 cannot describe this 19-byte file. Lesson 10.2 works through those bounds and the terminator alternative.

The layout above was handed to you. A real game’s layout usually is not, so its fields are found by comparison, the byte-level version of Lesson 10.2’s two-save diff: save once, change exactly one value in the game, save again, and compare the two files. Most hex editors have a compare view that highlights the bytes that differ.

Suppose Nia earns 1,000 gold, taking her from 250 to 1,250, and nothing else changes. Offsets 0x00 to 0x05 are identical in both saves, so the strips start at 0x06:

0x06FA
0x0700
0x0800
0x0900
0x0A03
0x0B03
0x0C4E
0x0D69
0x0E61
0x0F4A
0x1003
0x1100
0x1200
Save A, with 250 gold.
0x06E2
0x0704
0x0800
0x0900
0x0A03
0x0B03
0x0C4E
0x0D69
0x0E61
0x0F36
0x1003
0x1100
0x1200
gold
checksum
Save B, with 1,250 gold. Exactly three bytes differ: 0x06, 0x07, and 0x0F.

Offsets 6 and 7 changed from FA 00 to E2 04. 0xE2 is 14 × 16 + 2 = 226, so reading lowest byte first gives 226 + 4 × 256 = 226 + 1,024 = 1,250: the new gold. Gold therefore starts at offset 6. Offsets 8 and 9 hold 00 in both saves, so they could be the upper half of a 4-byte gold field or something unrelated that happens to be zero. Two bytes can hold at most 256 × 256 − 1 = 65,535, so a third save with more gold than that settles it: if byte 8 changes, the field is wider than two bytes.

Offset 0x0F changed as well, from 4A to 36, although nothing else in the game changed. A byte that changes whenever any other value changes is the mark of a checksum, the subject of the next section.

In a real game the diff also catches bytes that change on their own, such as play time, a timestamp, or a random seed. Lesson 10.2 separates those by repeating the comparison with several values; the real field follows the in-game number every time.

The final four bytes store a checksum, computed from the bytes before them. This toy format adds those bytes and stores the sum as a little-endian u32. The original save’s sum is 842:

magic 47 53 41 56 71 + 83 + 65 + 86 = 305 total 305
version 01 00 1 + 0 = 1 total 306
gold FA 00 00 00 250 + 0 + 0 + 0 = 250 total 556
level 03 3 = 3 total 559
length 03 3 = 3 total 562
name 4E 69 61 78 + 105 + 97 = 280 total 842

The stored bytes 4A 03 00 00 also mean 74 + 3 × 256 = 842. Editing gold changes its contribution to that sum. For 1,250 gold, E2 04 00 00 contributes 226 + 4 = 230 in place of 250, so the new sum is 842 − 250 + 230 = 822. Its checksum is 36 03 00 00. The complete consistent file is:

47 53 41 56 01 00 E2 04 00 00 03 03 4E 69 61 36 03 00 00

Changing gold to 9,999 without updating the checksum instead gives a computed sum of 646 while the stored value stays 842, so the loader refuses the file. The lab validates the original before editing and recomputes the checksum afterward; giving existing damage a fresh checksum would conceal it.

A plain sum detects some damage but cannot establish who approved the bytes. Lesson 10.8 keeps the full calculation, loader trace and scene, and the distinction from a hash or signature.

A hex editor writes straight back to the file it opened, so a few mechanical details decide whether an edit can be undone:

  • Keep an untouched copy of the save before the first edit, and keep it until the game has loaded the edited save and saved it again. A wrong byte is only undone by that copy.
  • Type over bytes; do not insert them. Hex editors have an overwrite mode, which replaces bytes in place, and an insert mode, which adds bytes and pushes everything after them along. In a fixed layout, one inserted byte moves every later field: the loader then reads the checksum from the wrong place and finds the file one byte too long. Changing gold overwrites exactly four bytes.
  • A longer name is the one edit that must insert bytes. Renaming Nia to Grace means inserting two bytes, changing the length byte from 03 to 05, and recomputing the checksum at its new offset, 17. Getting any one of the three wrong makes the loader refuse the file.
  • A program that edits saves should write the result to a temporary file and then replace the original in one step, as in Lesson 10.2, so a crash mid-write cannot leave half a save.

Everything above depends on each field being stored as plain bytes at a known place. Two common designs break that assumption:

  • A compressed save stores an encoded form of the data. gzip, a common compression format, starts its files with 1F 8B, the same bytes Lesson 9.3 found at the start of Wesnoth’s network payloads. The gold field is not anywhere in the file as plain bytes, and changing one compressed byte changes how everything after it decompresses; gzip’s own CRC-32 at the end of the stream then fails. The file has to be decompressed, edited, and compressed again with a tool that understands the format.
  • An encrypted save looks like random bytes: no readable text in the character column, and every value from 00 to FF appearing about equally often. Changing the encrypted bytes, the ciphertext, changes what they decrypt to in ways nobody can choose, and authenticated encryption, covered in Lesson 10.10, refuses the altered file outright. Without the key there is nothing to edit.

The two-save comparison tells these cases apart quickly. A plain format changes a handful of bytes, as it did above; a compressed file, or one encrypted afresh on every save, changes almost everything from the first difference onward. If the save turns out to be text after all, Lesson 10.2’s key/value editing is simpler than any hex edit.

hex_save_lab.rs

rust-labs/src/bin/ · Rust, 572 lines

Show the codeHide the code
//! Parse, validate, and patch a toy binary save file.
//!
//! The format is small enough to read by eye in a hex editor. Every field sits
//! at a fixed offset except the name, whose length is stored in the byte just
//! before it, so the checksum's offset depends on that length:
//!
//! ```text
//! offset   size  field
//!  0        4    magic: the ASCII bytes "GSAV" (47 53 41 56)
//!  4        2    format version, u16 little endian
//!  6        4    gold, u32 little endian
//! 10        1    level, u8
//! 11        1    name length n, u8
//! 12        n    name, ASCII
//! 12 + n    4    checksum, u32 little endian: the sum of every earlier byte
//! ```
//!
//! The example save for `Nia`, level 3, with 250 gold is 19 bytes long:
//!
//! ```text
//! 47 53 41 56 01 00 FA 00 00 00 03 03 4E 69 61 4A 03 00 00
//! ```
//!
//! Everything here works on byte slices in memory, so the rules can be tested
//! without touching a real save.
//!
//! ```text
//! cargo run --bin hex_save_lab
//! cargo test --bin hex_save_lab
//! ```

use std::cmp::Ordering;
use std::fmt::{self, Write as _};

/// The four bytes every save starts with: `G`, `S`, `A`, `V` in ASCII.
const MAGIC: [u8; 4] = *b"GSAV";
/// The only format version this parser has a layout for.
const SUPPORTED_VERSION: u16 = 1;

const MAGIC_OFFSET: usize = 0;
const VERSION_OFFSET: usize = 4;
const GOLD_OFFSET: usize = 6;
const LEVEL_OFFSET: usize = 10;
const NAME_LENGTH_OFFSET: usize = 11;
const NAME_OFFSET: usize = 12;
const CHECKSUM_SIZE: usize = 4;

/// The smallest possible save: the 12-byte fixed header, an empty name, and
/// the four checksum bytes.
const MIN_SAVE_SIZE: usize = NAME_OFFSET + CHECKSUM_SIZE;

/// Bytes per row in the hex dump, as most hex editors show them.
const HEX_ROW: usize = 16;
/// Two hex digits per byte plus one space between neighbours.
const HEX_COLUMN_WIDTH: usize = HEX_ROW * 3 - 1;

/// The example save exactly as it would sit on disk.
const EXAMPLE_SAVE: [u8; 19] = [
    0x47, 0x53, 0x41, 0x56, // magic "GSAV"
    0x01, 0x00, // version 1
    0xFA, 0x00, 0x00, 0x00, // gold 250
    0x03, // level 3
    0x03, // name length 3
    0x4E, 0x69, 0x61, // "Nia"
    0x4A, 0x03, 0x00, 0x00, // checksum 0x34A = 842
];

/// The decoded contents of one save.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Save {
    version: u16,
    gold: u32,
    level: u8,
    name: String,
}

/// Everything that can make a byte slice unusable as a save.
#[derive(Clone, Debug, PartialEq, Eq)]
enum SaveError {
    /// The slice ends before a field the format requires.
    TooShort { needed: usize, found: usize },
    /// Bytes remain after the checksum, so the length byte and the file size
    /// disagree.
    TrailingBytes { expected: usize, found: usize },
    /// The first four bytes are not `GSAV`, so this is some other kind of file.
    BadMagic([u8; 4]),
    /// A version this parser has no layout for.
    UnsupportedVersion(u16),
    /// The name is not plain ASCII, or is too long for its one-byte length.
    BadName,
    /// The stored checksum does not equal the sum of the bytes it covers.
    ChecksumMismatch { stored: u32, computed: u32 },
}

impl fmt::Display for SaveError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::TooShort { needed, found } => {
                write!(
                    formatter,
                    "save is too short: needs {needed} bytes, found {found}"
                )
            }
            Self::TrailingBytes { expected, found } => {
                write!(
                    formatter,
                    "save has extra bytes: expected {expected}, found {found}"
                )
            }
            Self::BadMagic(magic) => write!(formatter, "not a GSAV save: starts {magic:02X?}"),
            Self::UnsupportedVersion(version) => {
                write!(formatter, "save version {version} is not supported")
            }
            Self::BadName => write!(formatter, "the name must be ASCII and at most 255 bytes"),
            Self::ChecksumMismatch { stored, computed } => write!(
                formatter,
                "checksum mismatch: stored {stored}, computed {computed}"
            ),
        }
    }
}

impl std::error::Error for SaveError {}

/// Copies `N` bytes starting at `offset`, or reports how many bytes the read
/// needed if the slice ends first.
fn read_array<const N: usize>(bytes: &[u8], offset: usize) -> Result<[u8; N], SaveError> {
    let end = offset.saturating_add(N);
    bytes
        .get(offset..end)
        .and_then(|slice| <[u8; N]>::try_from(slice).ok())
        .ok_or(SaveError::TooShort {
            needed: end,
            found: bytes.len(),
        })
}

/// Overwrites `N` bytes starting at `offset`, exactly like typing over them in
/// a hex editor's overwrite mode. Nothing after the field moves.
fn write_array<const N: usize>(
    bytes: &mut [u8],
    offset: usize,
    value: [u8; N],
) -> Result<(), SaveError> {
    let found = bytes.len();
    let end = offset.saturating_add(N);
    let slot = bytes
        .get_mut(offset..end)
        .ok_or(SaveError::TooShort { needed: end, found })?;
    slot.copy_from_slice(&value);
    Ok(())
}

/// Adds up every byte in `covered`.
///
/// A valid save covers at most the 12 header bytes plus a 255-byte name, 267
/// bytes in all, and 267 × 255 = 68,085 is far below the `u32` limit, so the
/// sum never actually wraps. Wrapping addition only keeps an oversized input
/// from panicking.
fn checksum(covered: &[u8]) -> u32 {
    covered
        .iter()
        .copied()
        .map(u32::from)
        .fold(0, u32::wrapping_add)
}

/// Decodes and validates a complete save.
///
/// The checks run in the order a loader needs them: enough bytes for the fixed
/// header, the magic, the version, a length byte that agrees with the file
/// size, the checksum over everything before it, and readable text.
fn parse(bytes: &[u8]) -> Result<Save, SaveError> {
    if bytes.len() < MIN_SAVE_SIZE {
        return Err(SaveError::TooShort {
            needed: MIN_SAVE_SIZE,
            found: bytes.len(),
        });
    }

    let magic = read_array::<4>(bytes, MAGIC_OFFSET)?;
    if magic != MAGIC {
        return Err(SaveError::BadMagic(magic));
    }

    let version = u16::from_le_bytes(read_array::<2>(bytes, VERSION_OFFSET)?);
    if version != SUPPORTED_VERSION {
        return Err(SaveError::UnsupportedVersion(version));
    }

    // The name's length decides where the checksum lives, so the file size
    // must agree with it before any later field is read.
    let [name_length] = read_array::<1>(bytes, NAME_LENGTH_OFFSET)?;
    let checksum_offset = NAME_OFFSET + usize::from(name_length);
    let expected = checksum_offset + CHECKSUM_SIZE;
    match bytes.len().cmp(&expected) {
        Ordering::Less => {
            return Err(SaveError::TooShort {
                needed: expected,
                found: bytes.len(),
            });
        }
        Ordering::Greater => {
            return Err(SaveError::TrailingBytes {
                expected,
                found: bytes.len(),
            });
        }
        Ordering::Equal => {}
    }

    // In bounds: the length check above made `bytes.len()` equal
    // `checksum_offset + 4`.
    let covered = &bytes[..checksum_offset];
    let stored = u32::from_le_bytes(read_array::<4>(bytes, checksum_offset)?);
    let computed = checksum(covered);
    if stored != computed {
        return Err(SaveError::ChecksumMismatch { stored, computed });
    }

    let name_bytes = &covered[NAME_OFFSET..];
    if !name_bytes.is_ascii() {
        return Err(SaveError::BadName);
    }
    let [level] = read_array::<1>(bytes, LEVEL_OFFSET)?;

    Ok(Save {
        version,
        gold: u32::from_le_bytes(read_array::<4>(bytes, GOLD_OFFSET)?),
        level,
        name: name_bytes.iter().copied().map(char::from).collect(),
    })
}

/// Serializes a save and writes a fresh checksum over every byte before it.
fn encode(save: &Save) -> Result<Vec<u8>, SaveError> {
    if save.version != SUPPORTED_VERSION {
        return Err(SaveError::UnsupportedVersion(save.version));
    }
    if !save.name.is_ascii() {
        return Err(SaveError::BadName);
    }
    let name_length = u8::try_from(save.name.len()).map_err(|_| SaveError::BadName)?;

    let checksum_offset = NAME_OFFSET + save.name.len();
    let mut bytes = vec![0_u8; checksum_offset + CHECKSUM_SIZE];
    write_array(&mut bytes, MAGIC_OFFSET, MAGIC)?;
    write_array(&mut bytes, VERSION_OFFSET, save.version.to_le_bytes())?;
    write_array(&mut bytes, GOLD_OFFSET, save.gold.to_le_bytes())?;
    write_array(&mut bytes, LEVEL_OFFSET, [save.level])?;
    write_array(&mut bytes, NAME_LENGTH_OFFSET, [name_length])?;
    bytes[NAME_OFFSET..checksum_offset].copy_from_slice(save.name.as_bytes());

    let sum = checksum(&bytes[..checksum_offset]);
    write_array(&mut bytes, checksum_offset, sum.to_le_bytes())?;
    Ok(bytes)
}

/// Changes the gold field in a copy of the save and recomputes the checksum,
/// the way a correct save editor must.
///
/// The original is validated first, so a damaged save is never "repaired" by
/// giving it a checksum that matches its damage.
fn patch_gold(original: &[u8], gold: u32) -> Result<Vec<u8>, SaveError> {
    let save = parse(original)?;
    let checksum_offset = NAME_OFFSET + save.name.len();

    let mut patched = original.to_vec();
    write_array(&mut patched, GOLD_OFFSET, gold.to_le_bytes())?;
    let sum = checksum(&patched[..checksum_offset]);
    write_array(&mut patched, checksum_offset, sum.to_le_bytes())?;
    Ok(patched)
}

/// The edit that breaks the save: the gold bytes change, the checksum does not.
fn patch_gold_without_checksum(original: &[u8], gold: u32) -> Result<Vec<u8>, SaveError> {
    parse(original)?;
    let mut patched = original.to_vec();
    write_array(&mut patched, GOLD_OFFSET, gold.to_le_bytes())?;
    Ok(patched)
}

/// Lists every offset where two files hold different bytes, as a hex editor's
/// compare view does. Bytes present in only the longer file count as
/// different.
fn differing_offsets(left: &[u8], right: &[u8]) -> Vec<usize> {
    let shared = left.len().min(right.len());
    let longer = left.len().max(right.len());
    left.iter()
        .zip(right)
        .enumerate()
        .filter_map(|(offset, (a, b))| (a != b).then_some(offset))
        .chain(shared..longer)
        .collect()
}

/// The character a hex editor shows for one byte: printable ASCII as itself,
/// everything else as a dot.
fn printable(byte: u8) -> char {
    if byte == b' ' || byte.is_ascii_graphic() {
        char::from(byte)
    } else {
        '.'
    }
}

/// Formats bytes the way a hex editor shows them: an offset column, sixteen
/// bytes per row in hex, and a character column.
fn hex_dump(bytes: &[u8]) -> String {
    let width = HEX_COLUMN_WIDTH;
    let mut dump = String::new();
    for (row, chunk) in bytes.chunks(HEX_ROW).enumerate() {
        let offset = row * HEX_ROW;
        let hex = chunk
            .iter()
            .map(|byte| format!("{byte:02X}"))
            .collect::<Vec<_>>()
            .join(" ");
        let text: String = chunk.iter().copied().map(printable).collect();
        // Writing into a `String` cannot fail, so the result carries nothing.
        let _ = writeln!(dump, "{offset:08X}  {hex:<width$}  {text}");
    }
    dump
}

fn main() -> Result<(), SaveError> {
    println!("1. The example save, as a hex editor shows it");
    print!("{}", hex_dump(&EXAMPLE_SAVE));

    let save = parse(&EXAMPLE_SAVE)?;
    let checksum_offset = NAME_OFFSET + save.name.len();
    println!("\n2. Decoded: {save:?}");
    println!(
        "   sum of bytes 0 to {} = {}",
        checksum_offset - 1,
        checksum(&EXAMPLE_SAVE[..checksum_offset])
    );
    println!(
        "   encoding those fields again gives the same bytes: {}",
        encode(&save)? == EXAMPLE_SAVE
    );

    println!("\n3. The same save with 1,250 gold and a recomputed checksum");
    let richer = patch_gold(&EXAMPLE_SAVE, 1250)?;
    print!("{}", hex_dump(&richer));
    println!(
        "   offsets that differ from the original: {:?}",
        differing_offsets(&EXAMPLE_SAVE, &richer)
    );

    println!("\n4. Gold set to 9,999 without fixing the checksum");
    let broken = patch_gold_without_checksum(&EXAMPLE_SAVE, 9999)?;
    match parse(&broken) {
        Ok(save) => println!("   unexpectedly accepted: {save:?}"),
        Err(error) => println!("   the loader refuses it: {error}"),
    }

    println!("\n5. Other damage the parser refuses");
    let mut wrong_magic = EXAMPLE_SAVE;
    wrong_magic[MAGIC_OFFSET] = b'X';
    let cases: [(&str, &[u8]); 3] = [
        ("first byte typed over with X", &wrong_magic),
        ("file cut off after 10 bytes", &EXAMPLE_SAVE[..10]),
        ("last checksum byte missing", &EXAMPLE_SAVE[..18]),
    ];
    for (label, bytes) in cases {
        match parse(bytes) {
            Ok(save) => println!("   {label}: unexpectedly accepted {save:?}"),
            Err(error) => println!("   {label}: {error}"),
        }
    }

    println!("\n6. A five-letter name moves the checksum from offset 15 to 17");
    let grace = encode(&Save {
        name: String::from("Grace"),
        ..save
    })?;
    print!("{}", hex_dump(&grace));
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        checksum, differing_offsets, encode, hex_dump, parse, patch_gold,
        patch_gold_without_checksum, Save, SaveError, EXAMPLE_SAVE, NAME_LENGTH_OFFSET,
    };

    fn nia() -> Save {
        Save {
            version: 1,
            gold: 250,
            level: 3,
            name: String::from("Nia"),
        }
    }

    #[test]
    fn the_example_decodes_to_its_documented_fields() {
        assert_eq!(parse(&EXAMPLE_SAVE), Ok(nia()));
    }

    #[test]
    fn the_example_checksum_is_the_byte_sum_842() {
        // 305 (GSAV) + 1 (version) + 250 (gold) + 3 + 3 + 280 (Nia) = 842.
        assert_eq!(checksum(&EXAMPLE_SAVE[..15]), 842);
        assert_eq!(842, 0x34A);
        assert_eq!(EXAMPLE_SAVE[15..], [0x4A, 0x03, 0x00, 0x00]);
    }

    #[test]
    fn encoding_the_fields_reproduces_the_example_bytes() {
        assert_eq!(encode(&nia()).as_deref(), Ok(&EXAMPLE_SAVE[..]));
    }

    #[test]
    fn patching_gold_to_1250_rewrites_gold_and_checksum() {
        let patched = patch_gold(&EXAMPLE_SAVE, 1250).expect("the example is valid");
        // 1250 = 0x4E2, and 842 - 250 + (0xE2 + 0x04) = 822 = 0x336.
        assert_eq!(
            patched,
            [
                0x47, 0x53, 0x41, 0x56, 0x01, 0x00, 0xE2, 0x04, 0x00, 0x00, 0x03, 0x03, 0x4E, 0x69,
                0x61, 0x36, 0x03, 0x00, 0x00,
            ]
        );
        assert_eq!(checksum(&patched[..15]), 822);
        assert_eq!(parse(&patched).map(|save| save.gold), Ok(1250));
    }

    #[test]
    fn comparing_two_saves_finds_the_gold_and_checksum_bytes() {
        let patched = patch_gold(&EXAMPLE_SAVE, 1250).expect("the example is valid");
        assert_eq!(differing_offsets(&EXAMPLE_SAVE, &patched), vec![6, 7, 15]);
    }

    #[test]
    fn gold_patched_without_the_checksum_is_rejected() {
        let broken =
            patch_gold_without_checksum(&EXAMPLE_SAVE, 9999).expect("the example is valid");
        // 9999 = 0x270F, and 842 - 250 + (0x0F + 0x27) = 646.
        assert_eq!(broken[6..10], [0x0F, 0x27, 0x00, 0x00]);
        assert_eq!(
            parse(&broken),
            Err(SaveError::ChecksumMismatch {
                stored: 842,
                computed: 646,
            })
        );
    }

    #[test]
    fn one_changed_level_byte_moves_the_sum_by_one() {
        let mut edited = EXAMPLE_SAVE;
        edited[10] = 4;
        assert_eq!(
            parse(&edited),
            Err(SaveError::ChecksumMismatch {
                stored: 842,
                computed: 843,
            })
        );
    }

    #[test]
    fn wrong_magic_is_rejected_before_anything_else() {
        let mut wrong = EXAMPLE_SAVE;
        wrong[0] = b'X';
        assert_eq!(parse(&wrong), Err(SaveError::BadMagic(*b"XSAV")));
    }

    #[test]
    fn an_unknown_version_is_rejected() {
        let mut newer = EXAMPLE_SAVE;
        newer[4] = 2;
        assert_eq!(parse(&newer), Err(SaveError::UnsupportedVersion(2)));
    }

    #[test]
    fn short_files_are_rejected() {
        assert_eq!(
            parse(&[]),
            Err(SaveError::TooShort {
                needed: 16,
                found: 0,
            })
        );
        assert_eq!(
            parse(&EXAMPLE_SAVE[..10]),
            Err(SaveError::TooShort {
                needed: 16,
                found: 10,
            })
        );
        assert_eq!(
            parse(&EXAMPLE_SAVE[..18]),
            Err(SaveError::TooShort {
                needed: 19,
                found: 18,
            })
        );
    }

    #[test]
    fn a_length_byte_larger_than_the_file_is_rejected() {
        let mut lying = EXAMPLE_SAVE;
        lying[NAME_LENGTH_OFFSET] = 200;
        // 12 header bytes + 200 name bytes + 4 checksum bytes = 216.
        assert_eq!(
            parse(&lying),
            Err(SaveError::TooShort {
                needed: 216,
                found: 19,
            })
        );
    }

    #[test]
    fn trailing_bytes_are_rejected() {
        let mut longer = EXAMPLE_SAVE.to_vec();
        longer.push(0);
        assert_eq!(
            parse(&longer),
            Err(SaveError::TrailingBytes {
                expected: 19,
                found: 20,
            })
        );
    }

    #[test]
    fn a_longer_name_moves_the_checksum() {
        let grace = Save {
            name: String::from("Grace"),
            ..nia()
        };
        let bytes = encode(&grace).expect("the name is short ASCII");
        // 12 + 5 = 17, so the checksum sits at 17..21 and the file is 21 bytes.
        // 559 (bytes 0 to 10) + 5 (length) + 482 (Grace) = 1046 = 0x416.
        assert_eq!(bytes.len(), 21);
        assert_eq!(bytes[17..], [0x16, 0x04, 0x00, 0x00]);
        assert_eq!(parse(&bytes), Ok(grace));
    }

    #[test]
    fn names_that_do_not_fit_the_format_are_refused() {
        let accented = Save {
            name: String::from("Zoë"),
            ..nia()
        };
        let too_long = Save {
            name: "A".repeat(256),
            ..nia()
        };
        assert_eq!(encode(&accented), Err(SaveError::BadName));
        assert_eq!(encode(&too_long), Err(SaveError::BadName));
    }

    #[test]
    fn the_dump_matches_a_hex_editor_row() {
        let dump = hex_dump(&EXAMPLE_SAVE);
        let mut lines = dump.lines();
        assert_eq!(
            lines.next(),
            Some("00000000  47 53 41 56 01 00 FA 00 00 00 03 03 4E 69 61 4A  GSAV........NiaJ")
        );
        let second = lines.next().expect("19 bytes need two rows");
        assert!(second.starts_with("00000010  03 00 00 "));
        assert!(second.ends_with("  ..."));
        assert_eq!(lines.next(), None);
    }
}

hex_save_lab.rs implements the GSAV format in portable safe Rust:

  • every offset is a named constant, such as GOLD_OFFSET (6) and NAME_LENGTH_OFFSET (11), so no bare numbers are scattered through the code;
  • parse checks, in the loader’s order, that the file is long enough, the magic is GSAV, the version is 1, the length byte agrees with the file size, and the stored checksum equals the recomputed sum, and it returns a distinct SaveError for each failure, such as BadMagic, TooShort, or ChecksumMismatch, instead of panicking;
  • patch_gold overwrites the four gold bytes in a copy and then recomputes the checksum, while patch_gold_without_checksum makes the broken edit so its rejection can be seen;
  • patch_gold runs parse on the original first, because recomputing the checksum of an already damaged save would give the damage a valid checksum;
  • hex_dump prints bytes in the three-column layout above, and differing_offsets is the compare view.

Its tests pin every number in this lesson: the sum 842, the recomputed 822, the rejected 646, the differing offsets 6, 7, and 15, and the checksum’s move to offset 17 for a five-letter name. Build and run it with:

cargo run --manifest-path rust-labs/Cargo.toml --bin hex_save_lab
cargo test --manifest-path rust-labs/Cargo.toml --bin hex_save_lab

The save has shown why a file offset and a live memory address answer different questions. The same byte-reading habit helps with other game resources: next we connect a visible surface to a texture file, then follow the files that define units and bundle mods.

Knowledge check

Finish the edit

You change the gold in a GSAV save from 250 to 1,250 with a hex editor, and the game now refuses to load it. What else has to change?

Finished Lesson 10.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.

Chapter 10: 10 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.