You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

191 lines
5.9 KiB

#!/usr/bin/env python3
from argparse import ArgumentParser, FileType
from hashlib import md5
from requests import get
from struct import pack
from urllib.parse import urlencode
# char magic[8]
# u32 game_id
# u32 console_id
# u32 forum_topic_id
# u32 flags
# u32 is_final
# u32 achievement_count
# u32 leaderboard_count
# u32 reserved1[3]
# u8 hash[16]
# char title[32]
# char publisher[32]
# char developer[32]
# char genre[32]
# char release_date[32]
# char console_name[32]
HEADER_STRUCT = "<8sIIIIIII12x16s32s32s32s32s32s32s" # 0x100 bytes
# u32 id
# u32 points
# u32 flags
# u32 creation_time
# u32 modified_time
# u32 description_len
# u32 mem_addr_len
# u32 completion_time
# u32 reserved1[8]
# char title[32]
# char author[32]
# char reserved2[128]
# char description[256]
# char mem_addr[512]
ACHIEVEMENT_STRUCT = "<IIIIIII4x32x32s32s128x256s512s" # 0x400 bytes
# u32 id
# u32 hidden
# u32 lower_is_better
# u32 description_len
# u32 mem_len
# u32 reserved1[11]
# char title[32]
# char format[32]
# char reserved2[128]
# char description[256]
# char mem[512]
LEADERBOARD_STRUCT = "<IIIII44x32s32s128x256s512s" # 0x400 bytes
def log(str):
if args.verbose:
print(str)
def rc_hash_nintendo_ds(input):
offset = 0
input.seek(0)
header = input.read(512)
if len(header) != 512:
raise Exception("Failed to read header")
if header[0] == 0x2E and header[1] == 0x00 and header[2] == 0x00 and header[3] == 0xEA and \
header[0xB0] == 0x44 and header[0xB1] == 0x46 and header[0xB2] == 0x96 and header[0xB3] == 0:
# SuperCard header detected, ignore it
log("Ignoring SuperCard header")
offset = 512
input.seek(offset)
header = input.read(512)
if len(header) != 512:
raise Exception("Failed to read header")
arm9_addr = header[0x20] | (header[0x21] << 8) | (header[0x22] << 16) | (header[0x23] << 24)
arm9_size = header[0x2C] | (header[0x2D] << 8) | (header[0x2E] << 16) | (header[0x2F] << 24)
arm7_addr = header[0x30] | (header[0x31] << 8) | (header[0x32] << 16) | (header[0x33] << 24)
arm7_size = header[0x3C] | (header[0x3D] << 8) | (header[0x3E] << 16) | (header[0x3F] << 24)
icon_addr = header[0x68] | (header[0x69] << 8) | (header[0x6A] << 16) | (header[0x6B] << 24)
if arm9_size + arm7_size > 16 * 1024 * 1024:
# sanity check - code blocks are typically less than 1MB each - assume not a DS ROM
raise Exception("arm9 code size (%u) + arm7 code size (%u) exceeds 16MB" % (arm9_size, arm7_size))
log("Hashing 352 byte header")
hash_buffer = header[:0x160]
log("Hashing %u byte arm9 code (at %08X)" % (arm9_size, arm9_addr))
input.seek(arm9_addr + offset)
hash_buffer += input.read(arm9_size)
log("Hashing %u byte arm7 code (at %08X)" % (arm7_size, arm7_addr))
input.seek(arm7_addr + offset)
hash_buffer += input.read(arm7_size)
log("Hashing 2560 byte icon and labels data (at %08X)" % icon_addr)
input.seek(icon_addr + offset)
icon_buf = input.read(0xA00)
if len(icon_buf) < 0xA00:
# some homebrew games don't provide a full icon block, and no data after the icon block.
# if we didn't get a full icon block, fill the remaining portion with 0s
log("Warning: only got %u bytes for icon and labels data, 0-padding to 2560 bytes" % len(icon_buf))
icon_buf += b"\0" * (0xA00 - len(icon_buf))
hash_buffer += icon_buf
with open("test.bin", "wb") as f:
f.write(hash_buffer)
return md5(hash_buffer)
def api_get(request, vars={}):
vars["r"] = request
j = get("http://retroachievements.org/dorequest.php?" + urlencode(vars)).json()
if j["Success"]:
return j
else:
raise Exception("API request failed")
def bsach(input, output, username, password):
hash = rc_hash_nintendo_ds(input)
log("Hash: %s" % hash.hexdigest())
game_id = api_get("gameid", {"m": hash})["GameID"]
log("Game ID: %u" % game_id)
token = api_get("login", {"u": username, "p": password})["Token"]
patch_data = api_get("patch", {"u": "Pk11", "t": token, "g": 12711})["PatchData"]
output.write(pack(HEADER_STRUCT,
b"BSACHv1",
patch_data["ID"],
patch_data["ConsoleID"],
patch_data["ForumTopicID"],
patch_data["Flags"],
patch_data["IsFinal"],
len(patch_data["Achievements"]),
len(patch_data["Leaderboards"]),
hash.digest(),
patch_data["Title"].encode("utf8"),
patch_data["Publisher"].encode("utf8"),
patch_data["Developer"].encode("utf8"),
patch_data["Genre"].encode("utf8"),
patch_data["Released"].encode("utf8"),
patch_data["ConsoleName"].encode("utf8")
))
for achievement in patch_data["Achievements"]:
output.write(pack(ACHIEVEMENT_STRUCT,
achievement["ID"],
achievement["Points"],
achievement["Flags"],
achievement["Created"],
achievement["Modified"],
len(achievement["Description"]),
len(achievement["MemAddr"]),
achievement["Title"].encode("utf8"),
achievement["Author"].encode("utf8"),
achievement["Description"].encode("utf8"),
achievement["MemAddr"].encode("utf8")
))
for leaderboard in patch_data["Leaderboards"]:
output.write(pack(LEADERBOARD_STRUCT,
leaderboard["ID"],
leaderboard["Hidden"],
leaderboard["LowerIsBetter"],
len(leaderboard["Description"]),
len(leaderboard["Mem"]),
leaderboard["Title"].encode("utf8"),
leaderboard["Format"].encode("utf8"),
leaderboard["Description"].encode("utf8"),
leaderboard["Mem"].encode("utf8")
))
log("%s successfully created with %d achievements and %d leaderboards" % (output.name, len(patch_data["Achievements"]), len(patch_data["Leaderboards"])))
if __name__ == "__main__":
parser = ArgumentParser(description="Downloads RetroAchievements to an nds-bootstrap friendly format")
parser.add_argument("input", type=FileType("rb"), help="input nds")
parser.add_argument("output", type=FileType("wb"), help="output bsach")
parser.add_argument("username", type=str, help="RetroAchievements username")
parser.add_argument("password", type=str, help="RetroAchievements password")
parser.add_argument("-v", "--verbose", action="store_true", help="enable args.verbose logging")
args = parser.parse_args()
bsach(args.input, args.output, args.username, args.password)