Last active
April 21, 2020 20:38
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
+------------------------+---------+------------------------------------------+ | |
| 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