-
Notifications
You must be signed in to change notification settings - Fork 3
/
rebuild-title-database.py
168 lines (142 loc) · 6.28 KB
/
rebuild-title-database.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
#!/usr/bin/env python3
# This file is a part of rebuild-title-database.
#
# Copyright (c) 2020 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.
import traceback
from argparse import ArgumentParser
from pathlib import Path
from random import randint
import sys
from pyctr.crypto import CryptoEngine, Keyslot, load_seeddb
from pyctr.type.ncch import NCCHReader, NCCHSection
from pyctr.type.tmd import TitleMetadataReader
from pyctr.util import roundup
# the size of each file and directory in a title's contents are rounded up to this
TITLE_ALIGN_SIZE = 0x8000
parser = ArgumentParser(description='Rebuilds 3DS Title Database.')
parser.add_argument('-b', '--boot9', help='boot9')
parser.add_argument('-m', '--movable', help='movable.sed', required=True)
parser.add_argument('-S', '--seeddb', help='SeedDB')
parser.add_argument('-s', '--sd', help='SD card (containing "Nintendo 3DS")', required=True)
parser.add_argument('-o', '--output', help='output directory for title info entries', required=True)
args = parser.parse_args()
crypto = CryptoEngine(boot9=args.boot9)
crypto.setup_sd_key_from_file(args.movable)
if args.seeddb:
load_seeddb(args.seeddb)
out = Path(args.output)
out.mkdir(exist_ok=True)
id0 = Path(args.sd) / 'Nintendo 3DS' / crypto.id0.hex()
# Only continue if there is one id1 directory.
# If there isn't, the user needs to remove the unwanted ones.
id1_list = [x for x in id0.iterdir() if len(x.parts[-1]) == 32]
if len(id1_list) > 1:
print('There are multiple id1 directories in', id0, file=sys.stderr)
print('Please remove the rest.', file=sys.stderr)
sys.exit(1)
elif len(id1_list) < 1:
print('No id1 directory could be found in', id0, file=sys.stderr)
sys.exit(2)
id1 = id1_list[0]
title_dir = id1 / 'title'
for tmd_path in title_dir.rglob('*.tmd'):
tmd_id = int(tmd_path.name[0:8], 16)
tmd_path_for_cid = '/' + '/'.join(tmd_path.parts[len(id1.parts):])
with tmd_path.open('rb') as tmd_fh:
with crypto.create_ctr_io(Keyslot.SD, tmd_fh, crypto.sd_path_to_iv(tmd_path_for_cid)) as tmd_cfh:
try:
tmd = TitleMetadataReader.load(tmd_cfh)
except Exception as e:
print(f'Failed to parse tmd at {tmd_path}')
traceback.print_exc()
continue
print('Parsing', tmd.title_id)
if tmd.title_id.startswith('0004008c'):
# DLC puts contents into different folders, the first content always goes in the first one
content0_path = tmd_path.parent / '00000000' / (tmd.chunk_records[0].id + '.app')
has_manual = False
else:
content0_path = tmd_path.parent / (tmd.chunk_records[0].id + '.app')
has_manual = any(x for x in tmd.chunk_records if x.cindex == 1)
content0_path_for_cid = '/' + '/'.join(content0_path.parts[len(id1.parts):])
try:
with content0_path.open('rb') as ncch_fh:
with crypto.create_ctr_io(Keyslot.SD, ncch_fh, crypto.sd_path_to_iv(content0_path_for_cid)) as ncch_cfh:
ncch = NCCHReader(ncch_cfh, load_sections=False)
ncch_product_code = ncch.product_code
# NCCH version is required for DLP child to work I think. I remember something didn't work if it wasn't
# set in the title info entry.
ncch_version = ncch.version
try:
with ncch.open_raw_section(NCCHSection.ExtendedHeader) as e:
e.seek(0x200 + 0x30)
extdata_id = e.read(8)
except KeyError:
# not an executable title
extdata_id = b'\0' * 8
except FileNotFoundError:
print(f'Could not find the main content for {tmd.title_id}: {content0_path}')
continue
tidlow_path = tmd_path.parents[1]
# this is for the tidlow directory itself, which rglob doesn't include
sizes = [1]
# Get every file and include their size, except the directories for DLC content (the contents inside still count).
# This will also find the cmd file name.
# This is quite a lazy method to do things but it works!
# cmd_id should almost certainly be found. If not, the title will be skipped at the end of the loop.
cmd_id = None
for f in tmd_path.parents[1].rglob('*'):
if f.name.endswith('.cmd'):
cmd_id = int(f.name[0:8], 16)
# exclude DLC separate directories (00000000, etc) but include all others
# this won't match the tidlow directory which is not included in this search like above
try:
bytes.fromhex(f.name)
include_if_dir = False
except ValueError:
include_if_dir = True
if not (f.name.startswith('.') or (f.is_dir() and not include_if_dir)):
sizes.append(f.stat().st_size if f.is_file() else 1)
if cmd_id is None:
print(f'Could not find a cmd file for {tmd.title_id}, skipping.')
continue
title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)
# this starts building the title info entry
title_info_entry_data = [
# title size
title_size.to_bytes(8, 'little'),
# title type, seems to usually be 0x40
0x40.to_bytes(4, 'little'),
# title version
int(tmd.title_version).to_bytes(2, 'little'),
# ncch version
ncch_version.to_bytes(2, 'little'),
# flags_0, only checking if there is a manual
(1 if has_manual else 0).to_bytes(4, 'little'),
# tmd content id
tmd_id.to_bytes(4, 'little'),
# cmd content id
cmd_id.to_bytes(4, 'little'),
# flags_1, only checking save data
(1 if tmd.save_size else 0).to_bytes(4, 'little'),
# extdataid low
extdata_id[0:4],
# reserved
b'\0' * 4,
# flags_2, only using a common value
0x100000000.to_bytes(8, 'little'),
# product code
ncch_product_code.encode('ascii').ljust(0x10, b'\0'),
# reserved
b'\0' * 0x10,
# unknown
randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
# reserved
b'\0' * 0x2c
]
title_info_entry = b''.join(title_info_entry_data)
tie_path = out / tmd.title_id
with tie_path.open('wb') as o:
o.write(title_info_entry)