Reverse-engineering notes and a working save editor for Avalanche Studios' Mad Max (2015, Steam app id 234140).
Status: solved, including convoys. Saves can be decoded, edited, re-sealed and loaded by the game. Destroyed static world objects can be put back. Scrap can be set to any value. And cleared convoys can be fully restored - verified in game, on a live playthrough and on a 100% save: the routes come back marked in red on the map, and driving to one gives a complete, working convoy.
convoy.py reset SAVE.sav OUT.sav --slot N --state 2
--state 0 brings them back undiscovered instead - present in the world, not
yet on the map.
That took a while. Six earlier attempts failed because they edited records that describe a convoy rather than the one that governs it; the history is in docs/FINDINGS.md and the answer is in docs/GAME-FILES.md.
The save's largest table is fully decoded too. The 1520-entry roster is a verbatim copy of the game's own economy table, matching 1520 of 1520 rows on ID and profile - so every entry now has a resource type, a capacity, a threat class and a world position read from the game's data rather than inferred. That is also what settles the counts: 37 camps, 97 scarecrows, 35 snipers, 13 convoys, 30 minefields. See docs/ECONOMY.md.
There is a browser editor at
https://rick-thompson.github.io/MadMax_Savegame_Editor_Project/ - it runs the
tools in this repo in your own tab, with no upload and no third-party requests.
See web/README.md, including how adding a capability to it is
one entry in tools/webapi.py.
This is published so other people can take it further. Everything here is
reproducible from the sample saves in data/.
| capability | tool | verified |
|---|---|---|
| decode / re-encode any save | madmax_save.py |
yes |
| pass the game's integrity check after an edit | madmax_save.py reseal |
yes, loads in game |
| read the header (slot, playtime, timestamps, format gen) | madmax_save.py info |
yes |
| list / diff the record stream | madmax_save.py records / rdiff |
yes |
| map and diff the section-2 tables | sec2.py |
yes |
| diff the hash-named property store | tail.py |
yes |
| edit a property record in place | tailedit.py |
yes |
| map a payload and show the gaps | mapsave.py |
yes |
| recover names for the hashes | names.py |
see HASHES.md |
| extract files from the game archives | arcx.py |
yes |
| decode ADF files and graph scripts | adf.py |
yes |
| read SARC bundles (.bl) | sarc.py |
yes |
| read RTPC entity data (.blo) | rtpc.py |
yes |
| disassemble XVM gameplay scripts | xvm.py |
yes |
| restore a destroyed scarecrow, sniper tower, etc. | mmworld.py --restore-type |
yes, in game |
| destroy objects in bulk (for testing) | mmworld.py --destroy-type |
yes, in game |
| set scrap | resource.py scrap IN OUT N |
yes, in game |
| inspect the 13 convoys | convoy.py list |
yes |
| restore cleared convoys | convoy.py reset --state 2 |
yes, in game |
| name every roster entry from the game's data | economy.py tables / dump |
yes |
| list collected / missing relics and hood ornaments | relics.py list / missing |
yes |
| refill scrap, water, fuel and ammo pickups | economy.py refill --type Scrap |
not yet in game |
The property store's key scheme. Its keys are 64-bit and opaque: nothing in
101,426 real game names, nor in 398,623 strings harvested from the binaries,
matches under lookup3 or five other functions. You can edit a record once you
know what it governs, but you cannot look one up by name. See
docs/HASHES.md.
This is narrower than it used to be. "Every key in a save is a hash" was too
strong: the 1520-entry roster is keyed by EconomyResource.ID, a world object
id the game ships in economyresources.economyresourcesc, and those are now
resolvable by lookup rather than by hashing. Only the property store is still
opaque.
The integrity value at offset 0 is not solved in closed form, so every edit must preserve file length. The delta-carry workaround makes that a non-issue in practice, and every tool here enforces it.
Roughly 30% of the payload still does not parse - mapsave.py shows the
gaps. Nothing needed for the edits above lives there, but it is where the next
findings will come from.
Requires Python 3, no dependencies.
# where the game actually loads from (Linux/Proton)
SAVES=~/.steam/debian-installation/userdata/<SteamID3>/234140/remote
python3 tools/madmax_save.py info "$SAVES/GameSave01.sav"
python3 tools/madmax_save.py verify "$SAVES/GameSave01.sav"
# what is destroyed in this save?
python3 tools/mmworld.py "$SAVES/GameSave01.sav" --status
# put every destroyed scarecrow (object type 45) back, writing to slot 7
python3 tools/mmworld.py "$SAVES/GameSave01.sav" /tmp/out.sav \
--restore-type 45 --ref data/ladder/PT1.sav --slot 7
cp /tmp/out.sav "$SAVES/GameSave07.sav"
Turn Steam Cloud off for this game first. It will silently restore the old save over your edited one.
README.md this file
docs/
FORMAT.md the file format spec - start here
FINDINGS.md the convoy investigation, including the failures
METHODOLOGY.md how to run a save-diff experiment without fooling yourself
OPEN-QUESTIONS.md what is still unsolved, and what to try next
GAME-FILES.md the game's own archives - where the convoy answer came from
ECONOMY.md the 1520-entry roster, decoded against the game's own
economy table - start here for anything roster-shaped
OBJECT-TYPES.md per-profile counts across the 0%->100% ladder
LOCATIONS.md every location/activity type, with counts and evidence
HASHES.md the name hash - where it applies, and where it does not
SCRIPTS.md the game's save/load script, disassembled
tools/ the Python utilities (see tools/README.md)
data/
ladder/ PT1-PT6, a 0% -> 100% reference ladder
gibbed/ Gibbed.MadMax's file list, 27501 game paths
samples/ a snapshot series spanning one convoy fight
field-map.csv 222 record ids that move across the ladder
experiments/ log of every in-game experiment and its result
Every byte is XOR'd with a fixed repeating 32-byte key. Under that is a 0xF0
header (slot, playtime, timestamps, payload length) followed by the payload,
usually an identical mirror copy of the payload, and zero padding to a 512-byte
boundary. The payload has three parts: a (u32 id, u32 size, value) record
stream holding story and mission state; four tables, of which the 1520-entry
24-byte one is a verbatim copy of the game's economy table (scrap piles, water,
food, ammo and the threat objects) and the 13-entry 32-byte one is the convoys; and a large hash-named property store sorted by key. A u32 at offset 0
is an integrity value that the game checks — it is not solved in closed form,
but it depends only on file length, so it can be carried across any
length-preserving edit. Full detail in docs/FORMAT.md.
The most useful things anyone could add:
- Follow the convoy graph scripts.
arcx.pyextracts them andadf.pydecodes them into named node graphs - see docs/GAME-FILES.md. The spawner turned out to be just the map icon;convoy_choreographer.gsrcand the spawning graphs are next. - The property store's key scheme. Its 64-bit keys match nothing in 101,426
real game names or 398,623 binary strings, under
lookup3or five other functions. Note this is now the only opaque keyspace left: the roster's keys turned out not to be hashes at all. See docs/HASHES.md. - The other activity types. Convoys are solved, and the method that solved
them is five repeatable steps (see
docs/GAME-FILES.md): find the activity's
.blbundle, unpack the SARC, decode the RTPC, take theobjectids of entities withsave = 1, look those up in the property store. Vantage points are the best next target:CVantagePointis aCGameObjectCreatorclass exactly likeCConvoyDataContainer, so the method should apply unchanged. Camps are harder - their per-instance.blofiles are not reachable by basename hash, so they need a staged save series instead. - A closed-form solution to the integrity value. The delta-carry workaround means edits must preserve file length. Solving it properly would lift that.
- Camp state beyond threat. All 25 roster profiles are now labelled from the game's own data, so that job is done. What is not is the rest of a camp's state: its Threat row is strictly binary (full or zero, never partial, across 37 camps x 6 ladder saves), so the three map states and the hidden 100%-complete state live somewhere else - most likely the property store, which is where the convoy answer turned out to be. A staged capture of one camp being cleared step by step would settle it. See docs/ECONOMY.md.
- Windows / other-platform confirmation. Everything here was worked out on Linux under Proton. A player has confirmed a scrap edit loads correctly on Windows, which validates the decode/reseal chain including the integrity value - the failure mode that would have broken everything. Table edits and convoy resets are still unverified there; a convoy reset is the best single test, since it touches both. See docs/OPEN-QUESTIONS.md.
Please include the before/after saves with any finding. Half the wrong turns recorded in these docs would have been caught by someone else re-running the diff.
The data/ladder/ saves are "Mad Max Savegames" by Just Chill, posted
as a Steam Community guide on 5 January 2018:
https://steamcommunity.com/sharedfiles/filedetails/?id=1256911456
The guide lists no restriction on rehosting. They are included unmodified apart from being flattened out of their original folder layout, and are used here only as analysis input. The type map and the convoy analysis both depend on having a clean 0%→100% reference ladder, and this one is the reason the object roster could be shown to be universal rather than per-save. Thanks to the author.
If you would rather fetch them yourself, download from the link above and drop
the six GameSave01.sav files into data/ladder/ as PT1.sav … PT6.sav, in
playthrough order. Nothing else needs to change.