Save Formats and Safe Editing
See how text saves encode state, how controlled comparisons reveal fields, and why writes must be atomic.
Cheatsheet for Lesson 10.2
A one-page summary of “Save Formats and Safe Editing” you can keep beside your notes. Plain Markdown.
Lesson 10.2 — Save Formats and Safe Editing: cheatsheet
- Remember: Atomic replacement avoids a half-written file; durability asks whether completed data survives the failures being considered.
- Also useful: Two controlled saves are a before-and-after experiment: change one visible value so the file difference has a question to answer.
Key terms
- magic number: That fixed starting pattern is a magic number.
C8: A damaged byteC8means12 × 16 + 8 = 200; its claimed name ends at12 + 200 = 212, beyond a 19-byte file.- Atomic: Atomic means an observer should see the old file or the new file, not a half-written mixture.
- Durable: Durable means completed data survives the failures the system promises to handle.
- crash consistency: The property we are designing is crash consistency: after interruption, the files should describe an old valid state, a new valid state, or an unambiguous recoverable state.
Formulas and patterns
xp=0;build=5,1,1,2
Review
- Q: Which update strategy best protects a save file if the program crashes during writing? A: Write and validate a temporary file, then atomically replace the destination
- Q: Why should a game be closed before you edit its save file? A: The game may hold state in memory and overwrite your edit on exit
Locate the save by behavior
Section titled “Locate the save by behavior”Lesson 10.1 followed one Flare character’s Physical value from running state into avatar.txt. Now we will prove the exact path, change that file once, and ask Flare to load it. Use Flare 1.12 with a disposable character. Start Process Monitor and filter to the game process.

Save the game once, then look for file writes near that timestamp.

Close the game before editing. Many games keep state in memory and overwrite the save during shutdown.
For the actual Flare 1.12 lab, filter Process Name is flare.exe, create a character in the Empyrean campaign, then choose Save & Exit. The important writes are:
C:\Users\<you>\AppData\Roaming\flare\userdata\saves\empyrean\1\avatar.txtC:\Users\<you>\AppData\Roaming\flare\userdata\saves\empyrean\1\stash_HC.txtOpen avatar.txt first. It contains the character XP and build values; stash_HC.txt is the shared hardcore stash. Copy both files before changing either one.
Make two controlled saves
Section titled “Make two controlled saves”Compare two disposable saves from the same character. Make one intended change through the game, such as spending one available point on Physical, and record the visible result. For example:
- save A has
build=5,1,1,2; - save B has
build=6,1,1,2after Physical rises from 5 to 6; - no other intentional action occurs between saves.
Compare them:
fn differing_offsets(left: &[u8], right: &[u8]) -> Vec<usize> { left.iter() .zip(right) .enumerate() .filter_map(|(index, (a, b))| (a != b).then_some(index)) .collect()}If the files differ almost everywhere, compression, encryption, timestamps, or a checksum may be involved.
This helper compares only the shared prefix because zip ends with the shorter file. Check the two total lengths separately; an inserted field or newline also counts as a difference.
The two-save comparison is a controlled experiment, not proof that every changed byte belongs to gold. Games may update play time, random state, timestamps, object order, or integrity data during the same save. Repeat the comparison with several values and one intentional change at a time. A real field should follow the chosen change consistently; unrelated changing bytes are background state.
Prefer a typed text format
Section titled “Prefer a typed text format”If the save is readable key/value text, parse lines instead of replacing every matching number:
use std::collections::BTreeMap;
fn parse_key_values(text: &str) -> anyhow::Result<BTreeMap<String, String>> { text.lines() .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) .map(|line| { let (key, value) = line.split_once('=') .context("expected key=value")?; Ok((key.trim().to_owned(), value.trim().to_owned())) }) .collect()}This small example makes the field names visible, but collecting directly into a map would keep only one of two repeated build= lines. The complete editor below checks that the target line occurs exactly once and preserves the other lines and their newline style.
Then require the exact field and change its complete value:
let build = values.get_mut("build").context("save has no build field")?;*build = "30,30,30,30".to_owned();In the exact Flare 1.12 capture, the new character has:
xp=0build=5,1,1,2The four build numbers are Physical, Mental, Offense, and Defense. The original visible hack changes them to:
build=30,30,30,30Reloading the disposable character shows all four values as 30.
The magic number and the version say what the file is
Section titled “The magic number and the version say what the file is”The book’s invented binary GSAV save records Nia at level 3 with 250
gold. Its first four bytes are 47 53 41 56. As ASCII codes they are:
0x47 = 4 × 16 + 7 = 71:G;0x53 = 5 × 16 + 3 = 83:S;0x41 = 4 × 16 + 1 = 65:A;0x56 = 5 × 16 + 6 = 86:V.
That fixed starting pattern is a magic number. The loader checks it before treating bytes as fields. A PNG likewise has a fixed signature; renaming either file changes neither its bytes nor its format.
The next two bytes, 01 00, are the little-endian layout version 1.
A loader uses the version to select a known layout; an editor must refuse
an unknown version because fields may have moved. The
GSAV lab returns UnsupportedVersion for anything
other than 1.
A length-prefixed string
Section titled “A length-prefixed string”GSAV puts the name’s byte length at offset 11 (0x0B), then the text at
offset 12. For Nia, 03 announces three bytes:
N i aThe checksum follows the text, so it starts at 12 + 3 = 15. Changing
the name to the five-byte Grace moves it to 12 + 5 = 17 and grows the
whole save to 17 + 4 = 21 bytes. A tool assuming offset 15 would now
read the wrong field.
Check the prefix against the actual file size before trusting it. A
damaged byte C8 means 12 × 16 + 8 = 200; its claimed name ends at
12 + 200 = 212, beyond a 19-byte file. The lab requires
12 + length + 4 to equal the real size and returns an error otherwise.
Other formats end the text with a terminator, often C’s 00 byte. A prefix
counts bytes; a terminator marks the end. A nearby number matching the
text’s byte count is a clue, then test it with names of different lengths.
Neither convention measures the number of displayed characters.
Write atomically
Section titled “Write atomically”Do not overwrite the original in place. Write a temporary sibling, flush it, then rename:
use std::{fs, io::Write, path::Path};
fn atomic_replace(path: &Path, contents: &[u8]) -> anyhow::Result<()> { let temporary = path.with_extension("gha-new"); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(&temporary)?; file.write_all(contents)?; file.sync_all()?; drop(file); fs::rename(&temporary, path)?; Ok(())}The important change is that the original path is not used as scratch space:
The original helper writes directly to the closed game’s disposable save file. Opening it for replacement truncates the old contents before the new data is completely written, so a failure can leave a partial save.
fn atomic_replace(path: &Path, contents: &[u8]) -> anyhow::Result<()> { fs::write(path, contents)?; // Truncates the original before writing finishes. Ok(())}Use prepare then replace: exclusively create a sibling temporary file, write and flush it, close its handle, then request replacement. With create_new(true), a leftover temporary file causes an error instead of being overwritten; inspect or remove it before retrying.
Improved code
fn atomic_replace(path: &Path, contents: &[u8]) -> anyhow::Result<()> { let temporary = path.with_extension("gha-new"); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(&temporary)?; file.write_all(contents)?; file.sync_all()?; drop(file); fs::rename(&temporary, path)?; Ok(())}Show the diff
fn atomic_replace(path: &Path, contents: &[u8]) -> anyhow::Result<()> { fs::write(path, contents)?; // Truncates the original before writing finishes. let temporary = path.with_extension("gha-new"); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(&temporary)?; file.write_all(contents)?; file.sync_all()?; drop(file); fs::rename(&temporary, path)?; Ok(())}Takeaway: construct the complete replacement away from the original, and keep a backup until the game accepts it.
On Windows, replacing an existing file may require a platform-specific replace operation. Keep the backup until the game successfully loads the new save.
Prepare, validate, commit, and recover
Section titled “Prepare, validate, commit, and recover”Use four explicit stages: prepare, validate, commit, recover:
- Prepare: read the original and build new bytes somewhere else.
- Validate: confirm the target field, format, ranges, and complete output.
- Commit: replace the directory entry in one filesystem operation.
- Recover: keep enough information to return to the last known-good save.
Atomic replacement and durability solve different problems. Atomic means an observer should see the old file or the new file, not a half-written mixture. Durable means completed data survives the failures the system promises to handle. Flushing the temporary file improves durability, but no single call can make the edit, the game’s other save files, cloud synchronization, and a power failure one universal transaction.
The property we are designing is crash consistency: after interruption, the files should describe an old valid state, a new valid state, or an unambiguous recoverable state. “The normal path completed once” is weaker; crash consistency also accounts for every point where execution could stop.
Design the on-disk states so each one has an obvious response:
| Files present | Meaning | Safe response |
|---|---|---|
| original only | no edit in progress | open normally |
| original + temporary | preparation was interrupted | validate or remove only the temporary |
| new original + backup | replacement completed | test the new save; retain backup |
| missing original + backup | recovery is needed | restore backup before launching |
Do not choose by modification time alone. Validate the expected filename, format, and contents, then ask the user before replacing the only known-good copy.
Run the complete save editor
Section titled “Run the complete save editor”The original avatar.txt contains xp=0 and build=5,1,1,2. A separate temporary file receives xp=0 and build=30,30,30,30, is flushed and closed, then replaces the original in one operation. The original values survive in the backup. The game reload shows all four attributes as 30.
Read the steps
- Close the game. avatar.txt still contains xp=0 and build=5,1,1,2. Keep these original bytes while preparing a replacement.
- Require exactly one build= line. Preserve every other line and the existing newline style.
- Prepare all four new attributes as 30. The original file is unchanged; xp remains 0.
- Write the complete replacement to a temporary sibling. Flush its bytes and close its handle before replacing the original.
- One replacement operation installs the complete new file and preserves the original as a backup. An existing backup would make this lab refuse.
- Reload the disposable character. All four visible attributes are 30. Keep the backup until the game has accepted and saved the edited state.
File movement depicts one directory replacement, with complete old and new contents. Flushing improves durability; the backup remains available for recovery.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Close Flare, build the Windows lab workspace, and pass the exact save path and new build value:
.\target\i686-pc-windows-msvc\release\flare_save_editor.exe ` "$env:APPDATA\flare\userdata\saves\empyrean\1\avatar.txt" ` build ` "30,30,30,30"The tool requires exactly one build= line, preserves CRLF versus LF, writes and flushes a sibling temporary file, and uses the windows crate’s ReplaceFileW with write-through. Windows installs the new file atomically and places the original at avatar.txt.gha-backup. It refuses to overwrite an existing backup.
The backup is part of the tool’s data model, not clutter to delete immediately. The edit is not considered successful until Flare parses the new file, loads the character, and later saves it normally. That final save matters because the game wrote it: if the edited value is still there, the change survived the game’s own loader and serializer.
The complete program is flare_save_editor.rs; the reusable Windows replacement boundary is file_replace.rs.
Complete lab source: flare_save_editor.rs
flare_save_editor.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use std::{
fs::{self, File},
io::Write,
path::PathBuf,
};
use anyhow::Context;
use gha_windows_labs::replace_file_with_backup;
let mut arguments = std::env::args().skip(1);
let path = PathBuf::from(
arguments
.next()
.context("usage: flare_save_editor <avatar.txt> <field> <new-value>")?,
);
let field = arguments.next().context("missing field name")?;
let new_value = arguments.next().context("missing new value")?;
anyhow::ensure!(
field
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_'),
"field names may contain only ASCII letters, digits, and underscores"
);
anyhow::ensure!(
!new_value.chars().any(|ch| matches!(ch, '\r' | '\n')),
"the new value must stay on one line"
);
let text =
fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
let ended_with_newline = text.ends_with('\n');
let mut matches = 0_usize;
let mut output_lines = Vec::new();
for line in text.lines() {
let replacement = line
.split_once('=')
.filter(|(key, _)| key.trim() == field)
.map(|_| {
matches += 1;
format!("{field}={new_value}")
});
output_lines.push(replacement.unwrap_or_else(|| line.to_owned()));
}
anyhow::ensure!(
matches == 1,
"expected exactly one {field}= line, found {matches}; no file was changed"
);
let mut output = output_lines.join(newline);
if ended_with_newline {
output.push_str(newline);
}
let temporary = path.with_extension("txt.gha-new");
let backup = path.with_extension("txt.gha-backup");
anyhow::ensure!(
!temporary.exists(),
"temporary file already exists at {}",
temporary.display()
);
{
let mut file = File::create(&temporary)?;
file.write_all(output.as_bytes())?;
file.sync_all()?;
}
if let Err(error) = replace_file_with_backup(&path, &temporary, &backup) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
println!(
"Changed {field} in {}. Original saved at {}.",
path.display(),
backup.display()
);
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This file replacement lab uses ReplaceFileW and must run on Windows.");
}#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::{ fs::{self, File}, io::Write, path::PathBuf, };
use anyhow::Context; use gha_windows_labs::replace_file_with_backup;
let mut arguments = std::env::args().skip(1); let path = PathBuf::from( arguments .next() .context("usage: flare_save_editor <avatar.txt> <field> <new-value>")?, ); let field = arguments.next().context("missing field name")?; let new_value = arguments.next().context("missing new value")?; anyhow::ensure!( field .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '_'), "field names may contain only ASCII letters, digits, and underscores" ); anyhow::ensure!( !new_value.chars().any(|ch| matches!(ch, '\r' | '\n')), // 🛡️ A newline would create a second field and turn a value edit into a // structural save-file injection. "the new value must stay on one line" );
let text = fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?; // 📦 Preserve the file's existing Windows/Unix line convention and whether // it ended with a newline; unrelated formatting should not become a diff. let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; let ended_with_newline = text.ends_with('\n'); let mut matches = 0_usize; let mut output_lines = Vec::new();
for line in text.lines() { let replacement = line .split_once('=') .filter(|(key, _)| key.trim() == field) .map(|_| { matches += 1; format!("{field}={new_value}") }); output_lines.push(replacement.unwrap_or_else(|| line.to_owned())); } anyhow::ensure!( matches == 1, // 🎯 Zero means the requested field is absent; several means the edit is // ambiguous. In both cases the original must remain untouched. "expected exactly one {field}= line, found {matches}; no file was changed" );
let mut output = output_lines.join(newline); if ended_with_newline { output.push_str(newline); } let temporary = path.with_extension("txt.gha-new"); let backup = path.with_extension("txt.gha-backup"); anyhow::ensure!( !temporary.exists(), "temporary file already exists at {}", temporary.display() );
{ // 🧹 Flush and close the complete sibling file before asking Windows to // atomically swap names. The original is never used as scratch space. let mut file = File::create(&temporary)?; file.write_all(output.as_bytes())?; file.sync_all()?; } if let Err(error) = replace_file_with_backup(&path, &temporary, &backup) { // 🔁 Replacement failed, so remove only our disposable temporary file; // `path` still names the untouched original. let _ = fs::remove_file(&temporary); return Err(error); } println!( "Changed {field} in {}. Original saved at {}.", path.display(), backup.display() ); Ok(())}
#[cfg(not(windows))]fn main() { eprintln!("This file replacement lab uses ReplaceFileW and must run on Windows.");}Read it in five chunks: validate the three arguments, locate exactly one field, preserve the file’s newline style, flush a temporary file, then ask Windows to install it while keeping a backup. If any earlier chunk fails, the original save is untouched.
Checksums and integrity fields
Section titled “Checksums and integrity fields”If one small value change also changes a separate fixed-size region, the file may contain a checksum. Lesson 10.8 works through the GSAV sum; Lesson 10.3 keeps the exact two-save hex comparison.
Do not patch until you understand:
- which bytes are covered;
- which algorithm is used;
- where the expected checksum is stored;
- byte order;
- whether a secret key is involved.
If the format is cryptographically signed, use supported modding tools instead of attempting to defeat the signature.
Debug the loader only if needed
Section titled “Debug the loader only if needed”Process Monitor can show the file read, and a debugger breakpoint on file APIs can show the buffer passed into parsing.

In 32-bit Flare on Windows, open x64dbg’s Symbols tab, choose kernelbase.dll, find CreateFileA, and break at its first instruction. Saving the game puts the same avatar.txt path on the stack, independently confirming the location found with Process Monitor.
The aim is to learn the format, not to disable validation.
Validate the result
Section titled “Validate the result”- keep the original backup;
- load the disposable character;
- confirm only the intended field changed;
- save again normally;
- confirm the game can reload that new save.
If loading fails, restore the backup and compare the serializer’s whitespace, encoding, terminators, ordering, and integrity field.
Flare made its fields readable as text. A different game may store the same kind of character state as raw bytes. The next lesson keeps the same copy–compare–edit–reload discipline but shows how to read that binary layout with a hex editor.
Avoid a half-written save
Which update strategy best protects a save file if the program crashes during writing?
A temporary file keeps the original intact until a complete replacement is ready. Backups and post-write validation add further recovery points.
Finished Lesson 10.2?
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.