diff --git a/modules/title/emunand.py b/modules/title/emunand.py index 078d7a7..3482b9e 100644 --- a/modules/title/emunand.py +++ b/modules/title/emunand.py @@ -7,15 +7,128 @@ import shutil import libWiiPy +class _EmuNANDStructure: + def __init__(self, emunand_root): + self.emunand_root: pathlib.Path = emunand_root + + self.import_dir = self.emunand_root.joinpath("import") + self.meta_dir = self.emunand_root.joinpath("meta") + self.shared1_dir = self.emunand_root.joinpath("shared1") + self.shared2_dir = self.emunand_root.joinpath("shared2") + self.sys_dir = self.emunand_root.joinpath("sys") + self.ticket_dir = self.emunand_root.joinpath("ticket") + self.title_dir = self.emunand_root.joinpath("title") + self.tmp_dir = self.emunand_root.joinpath("tmp") + self.wfs_dir = self.emunand_root.joinpath("wfs") + + self.import_dir.mkdir(exist_ok=True) + self.meta_dir.mkdir(exist_ok=True) + self.shared1_dir.mkdir(exist_ok=True) + self.shared2_dir.mkdir(exist_ok=True) + self.sys_dir.mkdir(exist_ok=True) + self.ticket_dir.mkdir(exist_ok=True) + self.title_dir.mkdir(exist_ok=True) + self.tmp_dir.mkdir(exist_ok=True) + self.wfs_dir.mkdir(exist_ok=True) + + +def _do_wad_install(emunand_struct: _EmuNANDStructure, title: libWiiPy.title.Title): + # Save the upper and lower portions of the Title ID, because these are used as target install directories. + tid_upper = title.tmd.title_id[:8] + tid_lower = title.tmd.title_id[8:] + + # Tickets are installed as .tik in /ticket// + ticket_dir = emunand_struct.ticket_dir.joinpath(tid_upper) + if not ticket_dir.exists(): + ticket_dir.mkdir() + ticket_out = open(ticket_dir.joinpath(tid_lower + ".tik"), "wb") + ticket_out.write(title.wad.get_ticket_data()) + ticket_out.close() + + # The TMD and normal contents are installed to /title///content/, with the tmd being named + # title.tmd and the contents being named .app. + title_dir = emunand_struct.title_dir.joinpath(tid_upper) + if not title_dir.exists(): + title_dir.mkdir() + title_dir = title_dir.joinpath(tid_lower) + if not title_dir.exists(): + title_dir.mkdir() + content_dir = title_dir.joinpath("content") + if not content_dir.exists(): + content_dir.mkdir() + tmd_out = open(content_dir.joinpath("title.tmd"), "wb") + tmd_out.write(title.wad.get_tmd_data()) + tmd_out.close() + for content_file in range(0, title.tmd.num_contents): + if title.tmd.content_records[content_file].content_type == 1: + content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower() + content_out = open(content_dir.joinpath(content_file_name + ".app"), "wb") + content_out.write(title.get_content_by_index(content_file)) + content_out.close() + if not title_dir.joinpath("data").exists(): + title_dir.joinpath("data").mkdir() # Empty directory used for save data for the title. + + # Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map. + content_map_path = emunand_struct.shared1_dir.joinpath("content.map") + content_map = libWiiPy.title.SharedContentMap() + existing_hashes = [] + if content_map_path.exists(): + content_map.load(open(content_map_path, "rb").read()) + for record in content_map.shared_records: + existing_hashes.append(record.content_hash) + for content_file in range(0, title.tmd.num_contents): + if title.tmd.content_records[content_file].content_type == 32769: + if title.tmd.content_records[content_file].content_hash not in existing_hashes: + content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash) + content_out = open(emunand_struct.shared1_dir.joinpath(content_file_name + ".app"), "wb") + content_out.write(title.get_content_by_index(content_file)) + content_out.close() + content_map_out = open(emunand_struct.shared1_dir.joinpath("content.map"), "wb") + content_map_out.write(content_map.dump()) + content_map_out.close() + + # The "footer" or meta file is installed as title.met in /meta///. Only write this if meta + # is not nothing. + meta_data = title.wad.get_meta_data() + if meta_data != b'': + meta_dir = emunand_struct.meta_dir.joinpath(tid_upper) + if not meta_dir.exists(): + meta_dir.mkdir() + meta_dir = meta_dir.joinpath(tid_lower) + if not meta_dir.exists(): + meta_dir.mkdir() + meta_out = open(meta_dir.joinpath("title.met"), "wb") + meta_out.write(title.wad.get_meta_data()) + meta_out.close() + + # The Title ID needs to be added to uid.sys, which is essentially a log of all titles that are installed or have + # ever been installed. + uid_sys_path = emunand_struct.sys_dir.joinpath("uid.sys") + uid_sys = libWiiPy.title.UidSys() + existing_tids = [] + if uid_sys_path.exists(): + uid_sys.load(open(uid_sys_path, "rb").read()) + for entry in uid_sys.uid_entries: + existing_tids.append(entry.title_id) + else: + uid_sys.create() + existing_tids.append(uid_sys.uid_entries[0].title_id) + if title.tmd.title_id not in existing_tids: + uid_sys.add(title.tmd.title_id) + uid_sys_out = open(uid_sys_path, "wb") + uid_sys_out.write(uid_sys.dump()) + uid_sys_out.close() + + def handle_emunand_title(args): emunand_path = pathlib.Path(args.emunand) # Code for if the --install argument was passed. if args.install: - wad_path = pathlib.Path(args.install) + input_path = pathlib.Path(args.install) - if not wad_path.exists(): - raise FileNotFoundError(wad_path) + if not input_path.exists(): + raise FileNotFoundError(input_path) # Check if the EmuNAND path already exists, and ensure that it is a directory if it does. if emunand_path.exists(): if emunand_path.is_file(): @@ -23,94 +136,26 @@ def handle_emunand_title(args): else: emunand_path.mkdir() - # Check for required EmuNAND directories, and create them if they don't exist. - ticket_dir = emunand_path.joinpath("ticket") - title_dir = emunand_path.joinpath("title") - shared_dir = emunand_path.joinpath("shared1") - meta_dir = emunand_path.joinpath("meta") + emunand_struct = _EmuNANDStructure(emunand_path) - if not ticket_dir.exists(): - ticket_dir.mkdir() - if not title_dir.exists(): - title_dir.mkdir() - if not shared_dir.exists(): - shared_dir.mkdir() - if not meta_dir.exists(): - meta_dir.mkdir() - - wad_file = open(wad_path, "rb").read() - title = libWiiPy.title.Title() - title.load_wad(wad_file) - - # Save the upper and lower portions of the Title ID, because these are used as target install directories. - tid_upper = title.tmd.title_id[:8] - tid_lower = title.tmd.title_id[8:] - - # Tickets are installed as .tik in /ticket// - ticket_dir = ticket_dir.joinpath(tid_upper) - if not ticket_dir.exists(): - ticket_dir.mkdir() - ticket_out = open(ticket_dir.joinpath(tid_lower + ".tik"), "wb") - ticket_out.write(title.wad.get_ticket_data()) - ticket_out.close() - - # The TMD and normal contents are installed to /title///content/, with the tmd being named - # title.tmd and the contents being named .app. - title_dir = title_dir.joinpath(tid_upper) - if not title_dir.exists(): - title_dir.mkdir() - title_dir = title_dir.joinpath(tid_lower) - if not title_dir.exists(): - title_dir.mkdir() - content_dir = title_dir.joinpath("content") - if not content_dir.exists(): - content_dir.mkdir() - tmd_out = open(content_dir.joinpath("title.tmd"), "wb") - tmd_out.write(title.wad.get_tmd_data()) - tmd_out.close() - for content_file in range(0, title.tmd.num_contents): - if title.tmd.content_records[content_file].content_type == 1: - content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower() - content_out = open(content_dir.joinpath(content_file_name + ".app"), "wb") - content_out.write(title.get_content_by_index(content_file)) - content_out.close() - if not title_dir.joinpath("data").exists(): - title_dir.joinpath("data").mkdir() # Empty directory used for save data for the title. - - # Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map. - content_map_path = shared_dir.joinpath("content.map") - content_map = libWiiPy.title.SharedContentMap() - existing_hashes = [] - if content_map_path.exists(): - content_map.load(open(content_map_path, "rb").read()) - for record in content_map.shared_records: - existing_hashes.append(record.content_hash) - for content_file in range(0, title.tmd.num_contents): - if title.tmd.content_records[content_file].content_type == 32769: - if title.tmd.content_records[content_file].content_hash not in existing_hashes: - content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash) - content_out = open(shared_dir.joinpath(content_file_name + ".app"), "wb") - content_out.write(title.get_content_by_index(content_file)) - content_out.close() - content_map_out = open(shared_dir.joinpath("content.map"), "wb") - content_map_out.write(content_map.dump()) - content_map_out.close() - - # The "footer" or meta file is installed as title.met in /meta///. Only write this if meta - # is not nothing. - meta_data = title.wad.get_meta_data() - if meta_data != b'': - meta_dir = meta_dir.joinpath(tid_upper) - if not meta_dir.exists(): - meta_dir.mkdir() - meta_dir = meta_dir.joinpath(tid_lower) - if not meta_dir.exists(): - meta_dir.mkdir() - meta_out = open(meta_dir.joinpath("title.met"), "wb") - meta_out.write(title.wad.get_meta_data()) - meta_out.close() - - print("Title successfully installed to EmuNAND!") + if input_path.is_dir(): + wad_files = list(input_path.glob("*.[wW][aA][dD]")) + if not wad_files: + raise FileNotFoundError("No WAD files were found in the provided input directory!") + wad_count = 0 + for wad in wad_files: + wad_file = open(wad, "rb").read() + title = libWiiPy.title.Title() + title.load_wad(wad_file) + _do_wad_install(emunand_struct, title) + wad_count += 1 + print(f"Successfully installed {wad_count} WAD(s) to EmuNAND!") + else: + wad_file = open(input_path, "rb").read() + title = libWiiPy.title.Title() + title.load_wad(wad_file) + _do_wad_install(emunand_struct, title) + print("Successfully installed WAD to EmuNAND!") # Code for if the --uninstall argument was passed. elif args.uninstall: diff --git a/modules/title/info.py b/modules/title/info.py index 2c853db..fed2226 100644 --- a/modules/title/info.py +++ b/modules/title/info.py @@ -2,6 +2,7 @@ # https://github.com/NinjaCheetah/WiiPy import pathlib +import binascii import libWiiPy @@ -14,10 +15,21 @@ def _print_tmd_info(tmd: libWiiPy.title.TMD): print(f" Title Version: {tmd.title_version} ({tmd.title_version_converted})") else: print(f" Title Version: {tmd.title_version}") + print(f" TMD Version: {tmd.tmd_version}") + # IOSes just have an all-zero TID, so don't bothering showing that. if tmd.ios_tid == "0000000000000000": print(f" IOS Version: N/A") else: print(f" Required IOS: IOS{int(tmd.ios_tid[-2:], 16)} ({tmd.ios_tid})") + if tmd.issuer.decode().find("CP00000004") != 1: + print(f" Certificate: CP00000004 (Retail)") + print(f" Certificate Issuer: Root-CA00000001") + elif tmd.issuer.decode().find("CP00000007") != 1: + print(f" Certificate: CP00000007 (Development)") + print(f" Certificate Issuer: Root-CA00000002") + elif tmd.issuer.decode().find("CP10000000") != 1: + print(f" Certificate: CP10000000 (Arcade)") + print(f" Certificate Issuer: Root-CA10000000") print(f" Region: {tmd.get_title_region()}") print(f" Title Type: {tmd.get_title_type()}") print(f" vWii Title: {bool(tmd.vwii)}") @@ -35,6 +47,56 @@ def _print_tmd_info(tmd: libWiiPy.title.TMD): print(f" Content Hash: {content.content_hash.decode()}") +def _print_ticket_info(ticket: libWiiPy.title.Ticket): + # Get all important keys from the TMD and print them out nicely. + print(f"Ticket Info") + print(f" Title ID: {ticket.title_id.decode()}") + # This type of version number really only applies to the System Menu and IOS. + if ticket.title_id.decode()[:8] == "00000001": + print(f" Title Version: {ticket.title_version} " + f"({libWiiPy.title.title_ver_dec_to_standard(ticket.title_version, ticket.title_id.decode())})") + else: + print(f" Title Version: {ticket.title_version}") + print(f" Ticket Version: {ticket.ticket_version}") + if ticket.signature_issuer.find("XS00000003") != 1: + print(f" Certificate: XS00000003 (Retail)") + print(f" Certificate Issuer: Root-CA00000001") + elif ticket.signature_issuer.find("XS00000006") != 1: + print(f" Certificate: XS00000006 (Development)") + print(f" Certificate Issuer: Root-CA00000002") + else: + print(f" Certificate Info: {ticket.signature_issuer}") + match ticket.common_key_index: + case 0: + key = "Common" + case 1: + key = "Korean" + case 3: + key = "vWii" + case _: + key = "Unknown (Likely Common)" + print(f" Common Key: {key}") + print(f" Title Key (Encrypted): {binascii.hexlify(ticket.title_key_enc).decode()}") + print(f" Title Key (Decrypted): {binascii.hexlify(ticket.get_title_key()).decode()}") + + +def _print_wad_info(title: libWiiPy.title.Title): + print(f"WAD Info") + match title.wad.wad_type: + case "Is": + print(f" WAD Type: Standard Installable") + case "ib": + print(f" WAD Type: boot2") + case _: + print(f" WAD Type: Unknown ({title.wad.wad_type})") + print(f" Has Meta/Footer: {bool(title.wad.wad_meta_size)}") + print(f" Has CRL: {bool(title.wad.wad_crl_size)}") + print("") + _print_ticket_info(title.ticket) + print("") + _print_tmd_info(title.tmd) + + def handle_info(args): input_path = pathlib.Path(args.input) @@ -45,11 +107,13 @@ def handle_info(args): tmd = libWiiPy.title.TMD() tmd.load(open(input_path, "rb").read()) _print_tmd_info(tmd) - #elif input_path.suffix.lower() == ".tik": - # tik = libWiiPy.title.Ticket() - # tik.load(open(input_path, "rb").read()) - #elif input_path.suffix.lower() == ".wad": - # title = libWiiPy.title.Title() - # title.load_wad(open(input_path, "rb").read()) + elif input_path.suffix.lower() == ".tik": + tik = libWiiPy.title.Ticket() + tik.load(open(input_path, "rb").read()) + _print_ticket_info(tik) + elif input_path.suffix.lower() == ".wad": + title = libWiiPy.title.Title() + title.load_wad(open(input_path, "rb").read()) + _print_wad_info(title) else: raise TypeError("This does not appear to be a TMD, Ticket, or WAD! No info can be provided.") diff --git a/wiipy.py b/wiipy.py index 7e3319a..86dd8bc 100644 --- a/wiipy.py +++ b/wiipy.py @@ -47,7 +47,8 @@ if __name__ == "__main__": help="path to the target EmuNAND directory") emunand_title_install_group = emunand_title_parser.add_mutually_exclusive_group(required=True) emunand_title_install_group.add_argument("--install", metavar="WAD", type=str, - help="install the target WAD to an EmuNAND") + help="install the target WAD(s) to an EmuNAND (can be a single file or a " + "folder of WADs)") emunand_title_install_group.add_argument("--uninstall", metavar="TID", type=str, help="uninstall a title with the provided Title ID from an EmuNAND")