diff --git a/modules/title/emunand.py b/modules/title/emunand.py new file mode 100644 index 0000000..756a0ef --- /dev/null +++ b/modules/title/emunand.py @@ -0,0 +1,148 @@ +# "modules/title/emunand.py" from WiiPy by NinjaCheetah +# https://github.com/NinjaCheetah/WiiPy + +import os +import pathlib +import shutil +import libWiiPy + + +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) + + if not wad_path.exists(): + raise FileNotFoundError(wad_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(): + raise ValueError("A file already exists with the provided directory name!") + 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") + + 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() + title_dir = title_dir.joinpath("content") + if not title_dir.exists(): + title_dir.mkdir() + tmd_out = open(title_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(title_dir.joinpath(content_file_name + ".app"), "wb") + content_out.write(title.get_content_by_index(content_file)) + content_out.close() + + # 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!") + + # Code for if the --uninstall argument was passed. + elif args.uninstall: + target_tid = args.uninstall + if len(target_tid) != 16: + raise ValueError("Invalid Title ID! Title IDs must be 16 characters long.") + + # Setup required EmuNAND directories. + ticket_dir = emunand_path.joinpath("ticket") + title_dir = emunand_path.joinpath("title") + meta_dir = emunand_path.joinpath("meta") + + # Save the upper and lower portions of the Title ID, because these are used as target install directories. + tid_upper = target_tid[:8] + tid_lower = target_tid[8:] + + if not title_dir.joinpath(tid_upper).joinpath(tid_lower).exists(): + print(f"Title with Title ID {target_tid} does not appear to be installed!") + + # Begin by removing the Ticket, which is installed to /ticket//.tik + if ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik").exists(): + os.remove(ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik")) + + # The TMD and contents are stored in /title///. Remove the TMD and all contents, but don't + # delete the entire directory if anything exists in data. + title_dir = title_dir.joinpath(tid_upper).joinpath(tid_lower) + if not title_dir.joinpath("data").exists(): + shutil.rmtree(title_dir) + elif title_dir.joinpath("data").exists() and not os.listdir(title_dir.joinpath("data")): + shutil.rmtree(title_dir) + else: + # There are files in data, so we only want to delete the content directory. + shutil.rmtree(title_dir.joinpath("content")) + + # On the off chance this title has a meta entry, delete that too. + if meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists(): + shutil.rmtree(meta_dir.joinpath(tid_upper).joinpath(tid_lower)) diff --git a/modules/title/wad.py b/modules/title/wad.py index aae5a63..ada6ec2 100644 --- a/modules/title/wad.py +++ b/modules/title/wad.py @@ -3,7 +3,6 @@ import os import pathlib -import binascii import libWiiPy @@ -90,9 +89,9 @@ def handle_wad(args): raise FileNotFoundError(input_path) # Check if the output path already exists, and if it does, ensure that it is both a directory and empty. if output_path.exists(): - if output_path.is_dir() and next(os.scandir(output_path), None): - raise ValueError("Output folder is not empty!") - elif output_path.is_file(): + # if output_path.is_dir() and next(os.scandir(output_path), None): + # raise ValueError("Output folder is not empty!") + if output_path.is_file(): raise ValueError("A file already exists with the provided directory name!") else: os.mkdir(output_path) @@ -129,7 +128,7 @@ def handle_wad(args): skip_hash = False for content_file in range(0, title.tmd.num_contents): - content_file_name = "000000" + str(binascii.hexlify(content_file.to_bytes()).decode()) + ".app" + content_file_name = f"{content_file:08X}".lower() + ".app" content_out = open(output_path.joinpath(content_file_name), "wb") content_out.write(title.get_content_by_index(content_file, skip_hash)) content_out.close() diff --git a/wiipy.py b/wiipy.py index 60f439d..dc8e497 100644 --- a/wiipy.py +++ b/wiipy.py @@ -6,6 +6,7 @@ from importlib.metadata import version from modules.archive.ash import * from modules.archive.u8 import * +from modules.title.emunand import * from modules.title.fakesign import * from modules.title.nus import * from modules.title.wad import * @@ -15,7 +16,7 @@ if __name__ == "__main__": parser = argparse.ArgumentParser( description="A simple command line tool to manage file formats used by the Wii.") parser.add_argument("--version", action="version", - version=f"WiiPy v1.2.2, based on libWiiPy v{version('libWiiPy')} (from branch \'main\')") + version=f"WiiPy v1.3.0, based on libWiiPy v{version('libWiiPy')} (from branch \'main\')") subparsers = parser.add_subparsers(title="subcommands", dest="subcommand", required=True) # Argument parser for the ASH subcommand. @@ -32,6 +33,22 @@ if __name__ == "__main__": ash_parser.add_argument("--dist-bits", metavar="DIST_BITS", type=int, help="number of bits in each distance tree leaf (default: 11)", default=11) + # Argument parser for the EmuNAND subcommand. + emunand_parser = subparsers.add_parser("emunand", help="handle Wii EmuNAND directories", + description="handle Wii EmuNAND directories") + emunand_subparsers = emunand_parser.add_subparsers(title="emunand", dest="emunand", required=True) + # Title EmuNAND subcommand. + emunand_title_parser = emunand_subparsers.add_parser("title", help="manage titles on an EmuNAND", + description="manage titles on an EmuNAND") + emunand_title_parser.set_defaults(func=handle_emunand_title) + emunand_title_parser.add_argument("emunand", metavar="EMUNAND", type=str, + 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") + emunand_title_install_group.add_argument("--uninstall", metavar="TID", type=str, + help="uninstall a title with the provided Title ID from an EmuNAND") + # Argument parser for the fakesign subcommand. fakesign_parser = subparsers.add_parser("fakesign", help="fakesign a TMD, Ticket, or WAD (trucha bug)", description="fakesign a TMD, Ticket, or WAD (trucha bug)")