libWiiPy/src/libWiiPy/nand/emunand.py

259 lines
11 KiB
Python

# "nand/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Code for handling setting up and modifying a Wii EmuNAND.
import os
import pathlib
import shutil
from dataclasses import dataclass as _dataclass
from typing import List
from ..title.ticket import Ticket
from ..title.title import Title
from ..title.tmd import TMD
from ..title.content import SharedContentMap as _SharedContentMap
from .sys import UidSys as _UidSys
class EmuNAND:
"""
An EmuNAND object that allows for creating and modifying Wii EmuNANDs. Requires the path to the root of the
EmuNAND, and can optionally take in a callback function to send logs to.
Parameters
----------
emunand_root : str, pathlib.Path
The path to the EmuNAND root directory.
callback : function
A callback function to send EmuNAND logs to.
Attributes
----------
emunand_root : pathlib.Path
The path to the EmuNAND root directory.
"""
def __init__(self, emunand_root: str | pathlib.Path, callback: callable = None):
self.emunand_root = pathlib.Path(emunand_root)
self.log = callback if callback is not None else None
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 install_title(self, title: Title, skip_hash=False) -> None:
"""
Install the provided Title object to the EmuNAND. This mimics a real WAD installation done by ES.
This will create some system files required if they do not exist, but note that this alone is not enough for
a working EmuNAND, other than for Dolphin which can fill in the gaps.
Parameters
----------
title : libWiiPy.title.Title
The loaded Title object to install.
skip_hash : bool, optional
Skip the hash check and install the title regardless of its hashes. Defaults to false.
"""
# 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 <tid_lower>.tik in /ticket/<tid_upper>/
ticket_dir = self.ticket_dir.joinpath(tid_upper)
ticket_dir.mkdir(exist_ok=True)
ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump())
# The TMD and normal contents are installed to /title/<tid_upper>/<tid_lower>/content/, with the tmd being named
# title.tmd and the contents being named <cid>.app.
title_dir = self.title_dir.joinpath(tid_upper)
title_dir.mkdir(exist_ok=True)
title_dir = title_dir.joinpath(tid_lower)
title_dir.mkdir(exist_ok=True)
content_dir = title_dir.joinpath("content")
if content_dir.exists():
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
content_dir.mkdir(exist_ok=True)
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
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_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
title_dir.joinpath("data").mkdir(exist_ok=True) # 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 = self.shared1_dir.joinpath("content.map")
content_map = _SharedContentMap()
existing_hashes = []
if content_map_path.exists():
content_map.load(content_map_path.read_bytes())
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)
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump())
# The "footer" or meta file is installed as title.met in /meta/<tid_upper>/<tid_lower>/. Only write this if meta
# is not nothing.
meta_data = title.wad.get_meta_data()
if meta_data != b'':
meta_dir = self.meta_dir.joinpath(tid_upper)
meta_dir.mkdir(exist_ok=True)
meta_dir = meta_dir.joinpath(tid_lower)
meta_dir.mkdir(exist_ok=True)
meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data())
# Ensure we have a uid.sys file created.
uid_sys_path = self.sys_dir.joinpath("uid.sys")
uid_sys = _UidSys()
if not uid_sys_path.exists():
uid_sys.create()
def uninstall_title(self, tid: str) -> None:
"""
Uninstall the Title with the specified Title ID from the EmuNAND. This will leave shared contents unmodified.
Parameters
----------
tid : str
The Title ID of the Title to uninstall.
"""
# Save the upper and lower portions of the Title ID, because these are used as target install directories.
tid_upper = tid[:8]
tid_lower = tid[8:]
if not self.title_dir.joinpath(tid_upper).joinpath(tid_lower).exists():
raise ValueError(f"Title with Title ID {tid} does not appear to be installed!")
# Begin by removing the Ticket, which is installed to /ticket/<tid_upper>/<tid_lower>.tik
if self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik").exists():
os.remove(self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik"))
# The TMD and contents are stored in /title/<tid_upper>/<tid_lower>/. Remove the TMD and all contents, but don't
# delete the entire directory if anything exists in data.
title_dir = self.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 self.meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists():
shutil.rmtree(self.meta_dir.joinpath(tid_upper).joinpath(tid_lower))
@_dataclass
class InstalledTitles:
"""
An InstalledTitles object that is used to track a title type and any titles that belong to that type that are
installed to an EmuNAND.
Attributes
----------
type : str
The type (Title ID high) of the installed titles.
titles : List[str]
The Title ID low of each installed title.
"""
type: str
titles: List[str]
def get_installed_titles(self) -> List[InstalledTitles]:
"""
Scans for installed titles and returns a list of InstalledTitles objects, which each contain a title type
(Title ID high) and a list of Title ID lows that are installed under it.
Returns
-------
List[InstalledTitles]
The titles installed to the EmuNAND.
"""
# Scan for TID highs present.
tid_highs = [d for d in self.title_dir.iterdir() if d.is_dir()]
# Iterate through each one, verify that every TID low directory contains a TMD, and then add it to the list.
installed_titles = []
for high in tid_highs:
tid_lows = [d for d in high.iterdir() if d.is_dir()]
valid_lows = []
for low in tid_lows:
if low.joinpath("content", "title.tmd").exists():
valid_lows.append(low.name.upper())
installed_titles.append(self.InstalledTitles(high.name.upper(), valid_lows))
return installed_titles
def get_title_tmd(self, tid: str) -> TMD:
"""
Gets the TMD for a title installed to the EmuNAND, and returns it as a TMD objects. Returns an error if the
TMD for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the TMD for.
Returns
-------
TMD
The TMD for the Title.
"""
# Validate the TID, then build a path to the TMD file to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
tmd_path = self.title_dir.joinpath(tid_high, tid_low, "content", "title.tmd")
if not tmd_path.exists():
raise FileNotFoundError(f"Title with Title ID {tid} does not appear to be installed!")
tmd = TMD()
tmd.load(tmd_path.read_bytes())
return tmd
def get_title_ticket(self, tid: str) -> Ticket:
"""
Gets the Ticket for a title installed to the EmuNAND, and returns it as a Ticket object. Returns an error if
the Ticket for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the Ticket for.
Returns
-------
Ticket
The Ticket for the Title.
"""
# Validate the TID, then build a path to the Ticket files to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
ticket_path = self.ticket_dir.joinpath(tid_high, f"{tid_low}.tik")
if not ticket_path.exists():
raise FileNotFoundError(f"No Ticket exists for the title with Title ID {tid}!")
ticket = Ticket()
ticket.load(ticket_path.read_bytes())
return ticket