Skip to content

Instantly share code, notes, and snippets.

@zambonin
Last active April 21, 2020 20:38
Show Gist options
  • Save zambonin/ca772f70408a3c16a0d5f73c0bca1641 to your computer and use it in GitHub Desktop.
Save zambonin/ca772f70408a3c16a0d5f73c0bca1641 to your computer and use it in GitHub Desktop.
Script that reconstructs the order of unlocked secrets for the save game file of The Binding of Isaac.
Script that reconstructs the order of unlocked secrets in the `so.sol`
auxiliary save game file for The Binding of Isaac (2011 roguelite game).
The main save file for Isaac is called `serial.txt`, located inside the game
folder. It is the only file backed up by Steam Cloud [1], and its secrets field
does not hold the order in which these were unlocked. If the secondary file
`so.sol` is missing, it will be recreated without this information.
The idea is to use Steam achievement timestamps to recreate the unlocking order
of Isaac secrets. If a secret has no corresponding achievements, it is randomly
inserted after the secret which is semantically closest to its criteria, if
both exist.
The `so.sol` file is in the AMF0 binary format, and its `lockor` array field
holds the information for the lock order. It is replaced according to the new
order derived from the user's achievements and the rule above.
The Minerva AMF editor [2] and the AMF0 format specification [3] were integral
in the creation of this script. A reference sheet for the game's secrets [4]
was also used and an updated version is alongside this script.
The script is used as follows. It will also ask for a valid Steam API key to
fetch achievement data. WARNING: The save file is edited inplace, that is, it
will be _overwritten_. Make a backup just in case!
$ python /path/to/achv_to_secret.py <steamid> /path/to/so.sol
[1] https://steamdb.info/app/113200/ufs/
[2] https://github.com/gmariani/minerva/
[3] https://github.com/Xaiter/Binding-of-Isaac/blob/master/ReferenceSheet.txt
[4] https://wwwimages2.adobe.com/content/dam/acom/en/devnet/pdf/amf0-file-format-specification.pdf
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# pylint: disable=C0103, C0326, C0330, R0914, W0632
from __future__ import absolute_import
from getpass import getpass
from json import loads
from mmap import mmap
from operator import itemgetter
from random import choice
from struct import pack, unpack
from sys import argv
from typing import List, Tuple
from urllib import parse, request
def get_achv_timestamps(steamid: str, api_key: str) -> List[int]:
base_url = "https://api.steampowered.com/ISteamUserStats"
isaac_appid = 113200
user_achiev_url = f"{base_url}/GetPlayerAchievements/v1/"
user_params = {"key": api_key, "appid": isaac_appid, "steamid": steamid}
encoded_user_params = parse.urlencode(user_params)
full_user_url = f"{user_achiev_url}?{encoded_user_params}"
with request.urlopen(full_user_url) as req:
user_data = loads(req.read().decode("utf-8"))
times = [
achv["unlocktime"]
for achv in user_data["playerstats"]["achievements"]
]
return times
def read_secrets(sol_path: str) -> Tuple[int, int, List[int]]:
with open(sol_path, "r+b") as fp:
mm = mmap(fp.fileno(), 0)
key = b"lockor"
header_start = mm.find(key)
header_end = header_start + len(key)
array_start = header_end + 1 + 4 # marker + len of array
ecma_mark, array_len = unpack(">BI", mm[header_end:array_start])
assert ecma_mark == 0x8
pos = array_start
secrets = []
for _ in range(array_len):
str_len = int.from_bytes(mm[pos : pos + 2], byteorder="big")
# len of name + name itself + marker
marker_pos = 2 + str_len + 1
marker = mm[pos + marker_pos - 1]
if marker == 0x0: # double
entry_len = marker_pos + 8
_, _, _, content = unpack(
f">h{str_len}sbd", mm[pos : pos + entry_len]
)
elif marker == 0x2: # string
new_marker_pos = pos + marker_pos
other_str_len = int.from_bytes(
mm[new_marker_pos : new_marker_pos + 2], byteorder="big"
)
# previous stuff + len of string + string itself
entry_len = marker_pos + 2 + other_str_len
_, _, _, _, content = unpack(
f">h{str_len}sbh{other_str_len}s",
mm[pos : pos + entry_len],
)
secrets.append(int(content))
pos += entry_len
assert mm[pos + 2] == 0x9 # end of array
return array_start, pos, secrets
def organize_secrets(achievs: List[int], secrets: List[int]) -> List[int]:
def put_secret(_id: int, count: int) -> int:
if _id not in order_ach:
return False
pos = order_ach.index(_id)
return choice(order_ach[pos : pos + count])
raw_ordered_achv = sorted(enumerate(achievs), key=itemgetter(1))
order_ach = [idx for idx, timestamp in raw_ordered_achv if timestamp]
xx, br, lg, ls, lc, cq, tr = (
None, # easier indexing
put_secret(12, 3), # Book of Revelations
put_secret(34, 3), # Little Gish
put_secret(32, 3), # Little Steve
put_secret(33, 3), # Little Chad
put_secret(22, 5), # Conquest
put_secret(38, 7), # Triachnid
)
secret_to_achv = [
xx, 36, 35, 37, 12, 12, 12, br, 0, 1, 2, 3, 38, 8, 10, 36, 35,
37, 27, 9, 11, 13, 32, 33, 34, 26, 16, 14, 15, 30, lg, ls, lc, 29,
25, 22, 19, 20, 17, 31, 21, 38, 4, 5, 6, 7, 24, 23, 28, 39, 40,
41, 42, 18, 49, 47, 49, 49, 44, 45, 50, 46, 48, 43, 51, 54, 55, 52,
56, 57, 58, 53, 59, 60, 75, 74, 65, 64, 63, 66, 67, 68, 69, 70, 71,
72, 73, cq, 62, tr, 61, 76, 77, 78, 79, 80, 81, 82, 83, 84, 86, 87,
85, 88, 97, 98, 90, 93, 92, 91, 95, 94, 96, 89,
]
sorted_info = sorted(
(achievs[secret_to_achv[idx]], secret_to_achv[idx], idx)
for idx in secrets
)
return [idx for _, _, idx in sorted_info]
def write_secrets(
sol_path: str, secrets: List[int], start_pos: int, end_pos: int
):
with open(sol_path, "r+b") as fp:
mm = mmap(fp.fileno(), 0)
mm[5] = 0xA3 # Minerva doesn't like files without this byte
old_size = len(mm)
footer = mm[end_pos:]
pos = start_pos
for idx, secret in enumerate(reversed(secrets)):
actual_order = bytes(str(idx), "utf-8")
str_len = len(actual_order)
entry = pack(f">h{str_len}sbd", str_len, actual_order, 0, secret)
mm[pos : pos + len(entry)] = entry
pos += len(entry)
mm.resize(old_size - end_pos + pos)
mm[pos:] = footer
assert mm[pos + 2] == 0x9
if __name__ == "__main__":
assert len(argv) == 3
STEAMID, SOL_PATH = argv[1:]
API_KEY = getpass("Paste your API key: ")
START_POS, END_POS, SECRETS = read_secrets(SOL_PATH)
ACHIEVS = get_achv_timestamps(STEAMID, API_KEY)
ORDERED_SECRETS = organize_secrets(ACHIEVS, SECRETS)
write_secrets(SOL_PATH, ORDERED_SECRETS, START_POS, END_POS)
+------------------------+---------+------------------------------------------+
| secret_name | game_id | achiev_id |
+------------------------+---------+------------------------------------------+
| Maggy | 1 | 36 |
| Cain | 2 | 35 |
| Judas | 3 | 37 |
| Mom Kill | 4 | 12 |
| The Horsemen | 5 | 12 |
| Cube of Meat | 6 | 12 |
| Book of Revelations | 7 | N/A (defeat any Horseman, after 12) |
| Transcendence | 8 | 0 |
| The Nail | 9 | 1 |
| The Quarter | 10 | 2 |
| Dr. Fetus | 11 | 3 |
| The Poop | 12 | 38 |
| Spider Bite | 13 | 8 |
| Spelunker Hat | 14 | 10 |
| Yum Heart | 15 | 36 |
| Lucky Foot | 16 | 35 |
| The Book of Belial | 17 | 37 |
| The Small Rock | 18 | 27 |
| Monstro's Tooth | 19 | 9 |
| Lil' Chubby | 20 | 11 |
| Loki's Horns | 21 | 13 |
| Steven | 22 | 32 |
| C.H.A.D. | 23 | 33 |
| Gish | 24 | 34 |
| Super Bandage | 25 | 26 |
| The Relic | 26 | 16 |
| The Sack of Pennies | 27 | 14 |
| The Robo-Baby | 28 | 15 |
| The Book of Sin | 29 | 30 |
| Little Gish | 30 | N/A (defeat Gish, after 34) |
| Little Steve | 31 | N/A (defeat Steven, after 32) |
| Little Chad | 32 | N/A (defeat C.H.A.D., after 33) |
| The Gamekid | 33 | 29 |
| The Halo | 34 | 25 |
| Mr. Mega | 35 | 22 |
| Mom's Pill Bottle | 36 | 19 |
| The Common Cold | 37 | 20 |
| The D6 | 38 | 17 |
| The Pinking Shears | 39 | 31 |
| The Parasite | 40 | 21 |
| ??? | 41 | 38 |
| Everything is Terrible | 42 | 4 |
| The Wafer | 43 | 5 |
| Money = Power | 44 | 6 |
| It Lives! | 45 | 7 |
| The Bean | 46 | 24 |
| Mom's Contacts | 47 | 23 |
| The Necronomicon | 48 | 28 |
| Basement Boy | 49 | 39 |
| Cave Boy | 50 | 40 |
| Depths Boy | 51 | 41 |
| Womb Boy | 52 | 42 |
| Golden God | 53 | 18 |
| Eve | 54 | 49 |
| Mom's Knife | 55 | 47 |
| Dead Bird | 56 | 49 |
| Whore of Babylon | 57 | 49 |
| Razor Blade | 58 | 44 |
| Guardian Angel | 59 | 45 |
| Bomb Bag | 60 | 50 |
| Demon Baby | 61 | 46 |
| Forget Me Now | 62 | 48 |
| Monster Manuel | 63 | 43 |
| Lump of Coal | 64 | 51 |
| The D20 | 65 | 54 |
| Celtic Cross | 66 | 55 |
| Abel | 67 | 52 |
| Curved Horn | 68 | 56 |
| Sacrifical Knife | 69 | 57 |
| Rainbow Baby | 70 | 58 |
| Blood Lust | 71 | 53 |
| Bloody Penny | 72 | 59 |
| Blood Rights | 73 | 60 |
| The Polaroid | 74 | 75 |
| Dad's Key | 75 | 74 |
| Lucky Toe | 76 | 65 |
| Candle | 77 | 64 |
| Burnt Penny | 78 | 63 |
| Guppy's Tail | 79 | 66 |
| Epic Fetus | 80 | 67 |
| Dead Fish | 81 | 68 |
| SMB Super Fan | 82 | 69 |
| Spider Butt | 83 | 70 |
| Counterfeit Penny | 84 | 71 |
| Guppy's Hairball | 85 | 72 |
| Egg Sack | 86 | 73 |
| Conquest (broken) | 87 | N/A (blow up 30 tinted rocks, after 22) |
| Samson | 88 | 62 |
| Triachnid | 89 | N/A (defeat It Lives 15 times, after 38) |
| Platinum God | 90 | 61 |
| Isaac's Head | 91 | 76 |
| Maggy's Faith | 92 | 77 |
| Cain's Eye | 93 | 78 |
| Judas's Tongue | 94 | 79 |
| Eve's Bird Foot | 95 | 80 |
| ???'s Soul | 96 | 81 |
| Samson's Lock | 97 | 82 |
| The Left Hand | 98 | 83 |
| Eternal Mom | 99 | 84 |
| Eternal Satan | 100 | 86 |
| Eternal Cathedral | 101 | 87 |
| Eternal Heart | 102 | 85 |
| Hard Game | 103 | 88 |
| Eternal Personalities | 104 | 97 |
| Eternal God | 105 | 98 |
| Eternal Isaac | 106 | 90 |
| Eternal Judas | 107 | 93 |
| Eternal Maggy | 108 | 92 |
| Eternal Cain | 109 | 91 |
| Eternal Eve | 110 | 95 |
| Eternal Samson | 111 | 94 |
| Eternal ??? | 112 | 96 |
| Eternal Life | 113 | 89 |
+------------------------+---------+------------------------------------------+
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment