9 Commits

8 changed files with 272 additions and 131 deletions

View File

@@ -19,7 +19,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install ccache for Nuitka
run: sudo apt update && sudo apt install -y ccache libicu70
run: |
sudo apt update && \
sudo apt install -y ccache patchelf
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
@@ -36,7 +38,7 @@ jobs:
cd ~
tar cvf WiiPy.tar wiipy
- name: Upload Application
uses: actions/upload-artifact@v4.3.0
uses: actions/upload-artifact@v4
with:
path: ~/WiiPy.tar
name: WiiPy-Linux-bin
@@ -63,7 +65,7 @@ jobs:
cd ~
tar cvf WiiPy.tar wiipy
- name: Upload Application
uses: actions/upload-artifact@v4.3.0
uses: actions/upload-artifact@v4
with:
path: ~/WiiPy.tar
name: WiiPy-macOS-x86_64-bin
@@ -90,7 +92,7 @@ jobs:
cd ~
tar cvf WiiPy.tar wiipy
- name: Upload Application
uses: actions/upload-artifact@v4.3.0
uses: actions/upload-artifact@v4
with:
path: ~/WiiPy.tar
name: WiiPy-macOS-arm64-bin
@@ -114,7 +116,12 @@ jobs:
- name: Build Application
run: .\Build.ps1
- name: Upload Application
uses: actions/upload-artifact@v4.3.0
uses: actions/upload-artifact@v4
with:
path: D:\a\WiiPy\WiiPy\WiiPy.dist
name: WiiPy-Windows-bin
- name: Upload Onefile Application
uses: actions/upload-artifact@v4
with:
path: D:\a\WiiPy\WiiPy\wiipy.exe
name: WiiPy-Windows-bin
name: WiiPy-Windows-onefile-bin

41
commands/archive/lz77.py Normal file
View File

@@ -0,0 +1,41 @@
# "commands/archive/lz77.py" from WiiPy by NinjaCheetah
# https://github.com/NinjaCheetah/WiiPy
import pathlib
import libWiiPy
from modules.core import fatal_error
def handle_lz77_compress(args):
input_path = pathlib.Path(args.input)
if args.output is not None:
output_path = pathlib.Path(args.output)
else:
output_path = pathlib.Path(input_path.name + ".lz77")
if not input_path.exists():
fatal_error(f"The specified file \"{input_path}\" does not exist!")
lz77_data = input_path.read_bytes()
data = libWiiPy.archive.compress_lz77(lz77_data)
output_path.write_bytes(data)
print("LZ77 file compressed!")
def handle_lz77_decompress(args):
input_path = pathlib.Path(args.input)
if args.output is not None:
output_path = pathlib.Path(args.output)
else:
output_path = pathlib.Path(input_path.name + ".out")
if not input_path.exists():
fatal_error(f"The specified file \"{input_path}\" does not exist!")
lz77_data = input_path.read_bytes()
data = libWiiPy.archive.decompress_lz77(lz77_data)
output_path.write_bytes(data)
print("LZ77 file decompressed!")

View File

@@ -26,8 +26,15 @@ def handle_u8_unpack(args):
if not input_path.exists():
fatal_error(f"The specified input file \"{input_path}\" does not exist!")
u8_data = input_path.read_bytes()
# U8 archives are sometimes compressed. In the event that the provided data is LZ77 data, assume it's a compressed
# U8 archive and decompress it before continuing. Standard checks will then catch it if it was something else.
if u8_data[0:4] == b'LZ77':
u8_data = libWiiPy.archive.decompress_lz77(u8_data)
# Output path is deliberately not checked in any way because libWiiPy already has those checks, and it's easier
# and cleaner to only have one component doing all the checks.
libWiiPy.archive.extract_u8(input_path.read_bytes(), str(output_path))
libWiiPy.archive.extract_u8(u8_data, str(output_path))
print("U8 archive unpacked!")

View File

@@ -118,7 +118,7 @@ def handle_emunand_info(args):
print(f" {disc.upper()}")
print("")
if missing_ioses:
print(f"Some titles installed are missing their required IOS. These missing IOSes are marked with a * in the "
print(f"Some titles installed are missing their required IOS. These missing IOSes are marked with \"*\" in the "
f"title list above. If these IOSes are not installed, the titles requiring them will not launch. The "
f"IOSes required but not installed are:")
for missing in missing_ioses:
@@ -156,7 +156,7 @@ def handle_emunand_install_missing(args):
for title in installed_titles:
tmd = emunand.get_title_tmd(title)
if tmd.ios_tid.upper() not in installed_ioses:
if tmd.ios_tid not in missing:
if int(tmd.ios_tid[8:], 16) not in missing:
missing.append(int(tmd.ios_tid[8:], 16))
missing.sort()
if is_vwii:

View File

@@ -8,7 +8,7 @@ import libWiiPy
from modules.core import fatal_error
def _print_tmd_info(tmd: libWiiPy.title.TMD):
def _print_tmd_info(tmd: libWiiPy.title.TMD, signing_cert=None):
# Get all important keys from the TMD and print them out nicely.
print("Title Info")
ascii_tid = ""
@@ -43,6 +43,9 @@ def _print_tmd_info(tmd: libWiiPy.title.TMD):
elif tmd.signature_issuer.find("CP00000007") != -1:
print(f" Certificate: CP00000007 (Development)")
print(f" Certificate Issuer: Root-CA00000002 (Development)")
elif tmd.signature_issuer.find("CP00000005") != -1:
print(f" Certificate: CP00000005 (Development/Unknown)")
print(f" Certificate Issuer: Root-CA00000002 (Development)")
elif tmd.signature_issuer.find("CP10000000") != -1:
print(f" Certificate: CP10000000 (Arcade)")
print(f" Certificate Issuer: Root-CA10000000 (Arcade)")
@@ -69,6 +72,21 @@ def _print_tmd_info(tmd: libWiiPy.title.TMD):
print(f" vWii Title: {bool(tmd.vwii)}")
print(f" DVD Video Access: {tmd.get_access_right(tmd.AccessFlags.DVD_VIDEO)}")
print(f" AHB Access: {tmd.get_access_right(tmd.AccessFlags.AHB)}")
if signing_cert is not None:
try:
if libWiiPy.title.verify_tmd_sig(signing_cert, tmd):
signing_str = "Valid (Unmodified TMD)"
elif tmd.get_is_fakesigned():
signing_str = "Fakesigned"
else:
signing_str = "Invalid (Modified TMD)"
except ValueError:
if tmd.get_is_fakesigned():
signing_str = "Fakesigned"
else:
signing_str = "Invalid (Modified TMD)"
print(f" Signature: {signing_str}")
else:
print(f" Fakesigned: {tmd.get_is_fakesigned()}")
# Iterate over the content and print their details.
print("\nContent Info")
@@ -83,7 +101,7 @@ def _print_tmd_info(tmd: libWiiPy.title.TMD):
print(f" Content Hash: {content.content_hash.decode()}")
def _print_ticket_info(ticket: libWiiPy.title.Ticket):
def _print_ticket_info(ticket: libWiiPy.title.Ticket, signing_cert=None):
# Get all important keys from the TMD and print them out nicely.
print(f"Ticket Info")
ascii_tid = ""
@@ -112,6 +130,9 @@ def _print_ticket_info(ticket: libWiiPy.title.Ticket):
elif ticket.signature_issuer.find("XS00000006") != -1:
print(f" Certificate: XS00000006 (Development)")
print(f" Certificate Issuer: Root-CA00000002 (Development)")
elif ticket.signature_issuer.find("XS00000004") != -1:
print(f" Certificate: XS00000004 (Development/Unknown)")
print(f" Certificate Issuer: Root-CA00000002 (Development)")
else:
print(f" Certificate Info: {ticket.signature_issuer} (Unknown)")
match ticket.common_key_index:
@@ -129,6 +150,22 @@ def _print_ticket_info(ticket: libWiiPy.title.Ticket):
print(f" Decryption 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()}")
if signing_cert is not None:
try:
if libWiiPy.title.verify_ticket_sig(signing_cert, ticket):
signing_str = "Valid (Unmodified Ticket)"
elif ticket.get_is_fakesigned():
signing_str = "Fakesigned"
else:
signing_str = "Invalid (Modified Ticket)"
except ValueError:
if ticket.get_is_fakesigned():
signing_str = "Fakesigned"
else:
signing_str = "Invalid (Modified Ticket)"
print(f" Signature: {signing_str}")
else:
print(f" Fakesigned: {ticket.get_is_fakesigned()}")
def _print_wad_info(title: libWiiPy.title.Title):
@@ -163,10 +200,30 @@ def _print_wad_info(title: libWiiPy.title.Title):
print(f" Installed Size (MB): {min_size}-{max_size} MB")
print(f" Has Meta/Footer: {bool(title.wad.wad_meta_size)}")
print(f" Has CRL: {bool(title.wad.wad_crl_size)}")
tmd_cert = None
ticket_cert = None
try:
tmd_cert = title.cert_chain.tmd_cert
ticket_cert = title.cert_chain.ticket_cert
if title.get_is_signed():
signing_str = "Legitimate (Unmodified TMD + Ticket)"
elif title.get_is_fakesigned():
signing_str = "Fakesigned"
elif (libWiiPy.title.verify_tmd_sig(tmd_cert, title.tmd)
and not libWiiPy.title.verify_ticket_sig(ticket_cert, title.ticket)):
signing_str = "Piratelegit (Unmodified TMD, Modified Ticket)"
elif (not libWiiPy.title.verify_tmd_sig(tmd_cert, title.tmd)
and libWiiPy.title.verify_ticket_sig(ticket_cert, title.ticket)):
signing_str = "Edited (Modified TMD, Unmodified Ticket)"
else:
signing_str = "Illegitimate (Modified TMD + Ticket)"
except ValueError:
signing_str = "Illegitimate (Modified TMD + Ticket)"
print(f" Signing Status: {signing_str}")
print("")
_print_ticket_info(title.ticket)
_print_ticket_info(title.ticket, ticket_cert)
print("")
_print_tmd_info(title.tmd)
_print_tmd_info(title.tmd, tmd_cert)
def handle_info(args):

View File

@@ -8,119 +8,6 @@ import libWiiPy
from modules.core import fatal_error
def handle_nus_title(args):
title_version = None
wad_file = None
output_dir = None
can_decrypt = False
tid = args.tid
if args.wii:
wiiu_nus_enabled = False
else:
wiiu_nus_enabled = True
# Check if --version was passed, because it'll be None if it wasn't.
if args.version is not None:
try:
title_version = int(args.version)
except ValueError:
fatal_error("The specified Title Version must be a valid integer!")
# If --wad was passed, check to make sure the path is okay.
if args.wad is not None:
wad_file = pathlib.Path(args.wad)
if wad_file.suffix != ".wad":
wad_file = wad_file.with_suffix(".wad")
# If --output was passed, make sure the directory either doesn't exist or is empty.
if args.output is not None:
output_dir = pathlib.Path(args.output)
if output_dir.exists():
if output_dir.is_file():
fatal_error("A file already exists with the provided directory name!")
else:
output_dir.mkdir()
# Download the title from the NUS. This is done "manually" (as opposed to using download_title()) so that we can
# provide verbose output.
title = libWiiPy.title.Title()
# Announce the title being downloaded, and the version if applicable.
if title_version is not None:
print(f"Downloading title {tid} v{title_version}, please wait...")
else:
print(f"Downloading title {tid} vLatest, please wait...")
print(" - Downloading and parsing TMD...")
# Download a specific TMD version if a version was specified, otherwise just download the latest TMD.
if title_version is not None:
title.load_tmd(libWiiPy.title.download_tmd(tid, title_version, wiiu_endpoint=wiiu_nus_enabled))
else:
title.load_tmd(libWiiPy.title.download_tmd(tid, wiiu_endpoint=wiiu_nus_enabled))
title_version = title.tmd.title_version
# Write out the TMD to a file.
if output_dir is not None:
output_dir.joinpath(f"tmd.{title_version}").write_bytes(title.tmd.dump())
# Download the ticket, if we can.
print(" - Downloading and parsing Ticket...")
try:
title.load_ticket(libWiiPy.title.download_ticket(tid, wiiu_endpoint=wiiu_nus_enabled))
can_decrypt = True
if output_dir is not None:
output_dir.joinpath("tik").write_bytes(title.ticket.dump())
except ValueError:
# If libWiiPy returns an error, then no ticket is available. Log this, and disable options requiring a
# ticket so that they aren't attempted later.
print(" - No Ticket is available!")
if wad_file is not None and output_dir is None:
fatal_error("--wad was passed, but this title has no common ticket and cannot be packed into a WAD!")
# Load the content records from the TMD, and begin iterating over the records.
title.load_content_records()
content_list = []
for content in range(len(title.tmd.content_records)):
# Generate the content file name by converting the Content ID to hex and then removing the 0x.
content_file_name = hex(title.tmd.content_records[content].content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
print(f" - Downloading content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id}, "
f"Size: {title.tmd.content_records[content].content_size} bytes)...")
content_list.append(libWiiPy.title.download_content(tid, title.tmd.content_records[content].content_id,
wiiu_endpoint=wiiu_nus_enabled))
print(" - Done!")
# If we're supposed to be outputting to a folder, then write these files out.
if output_dir is not None:
output_dir.joinpath(content_file_name).write_bytes(content_list[content])
title.content.content_list = content_list
# Try to decrypt the contents for this title if a ticket was available.
if output_dir is not None:
if can_decrypt is True:
for content in range(len(title.tmd.content_records)):
print(f" - Decrypting content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id})...")
dec_content = title.get_content_by_index(content)
content_file_name = f"{title.tmd.content_records[content].content_id:08X}".lower() + ".app"
output_dir.joinpath(content_file_name).write_bytes(dec_content)
else:
print("Title has no Ticket, so content will not be decrypted!")
# If --wad was passed, pack a WAD and output that.
if wad_file is not None:
# Get the WAD certificate chain.
print(" - Building certificate...")
title.load_cert_chain(libWiiPy.title.download_cert_chain(wiiu_endpoint=wiiu_nus_enabled))
# Ensure that the path ends in .wad, and add that if it doesn't.
print("Packing WAD...")
if wad_file.suffix != ".wad":
wad_file = wad_file.with_suffix(".wad")
# Have libWiiPy dump the WAD, and write that data out.
pathlib.Path(wad_file).write_bytes(title.dump_wad())
print(f"Downloaded title with Title ID \"{args.tid}\"!")
def handle_nus_content(args):
tid = args.tid
cid = args.cid
@@ -198,6 +85,122 @@ def handle_nus_content(args):
print(f"Downloaded content with Content ID \"{cid}\"!")
def handle_nus_title(args):
title_version = None
wad_file = None
output_dir = None
can_decrypt = False
tid = args.tid
wiiu_nus_enabled = False if args.wii else True
endpoint_override = args.endpoint if args.endpoint else None
# Check if --version was passed, because it'll be None if it wasn't.
if args.version is not None:
try:
title_version = int(args.version)
except ValueError:
fatal_error("The specified Title Version must be a valid integer!")
# If --wad was passed, check to make sure the path is okay.
if args.wad is not None:
wad_file = pathlib.Path(args.wad)
if wad_file.suffix != ".wad":
wad_file = wad_file.with_suffix(".wad")
# If --output was passed, make sure the directory either doesn't exist or is empty.
if args.output is not None:
output_dir = pathlib.Path(args.output)
if output_dir.exists():
if output_dir.is_file():
fatal_error("A file already exists with the provided directory name!")
else:
output_dir.mkdir()
# Download the title from the NUS. This is done "manually" (as opposed to using download_title()) so that we can
# provide verbose output.
title = libWiiPy.title.Title()
# Announce the title being downloaded, and the version if applicable.
if title_version is not None:
print(f"Downloading title {tid} v{title_version}, please wait...")
else:
print(f"Downloading title {tid} vLatest, please wait...")
print(" - Downloading and parsing TMD...")
# Download a specific TMD version if a version was specified, otherwise just download the latest TMD.
if title_version is not None:
title.load_tmd(libWiiPy.title.download_tmd(tid, title_version, wiiu_endpoint=wiiu_nus_enabled,
endpoint_override=endpoint_override))
else:
title.load_tmd(libWiiPy.title.download_tmd(tid, wiiu_endpoint=wiiu_nus_enabled,
endpoint_override=endpoint_override))
title_version = title.tmd.title_version
# Write out the TMD to a file.
if output_dir is not None:
output_dir.joinpath(f"tmd.{title_version}").write_bytes(title.tmd.dump())
# Download the ticket, if we can.
print(" - Downloading and parsing Ticket...")
try:
title.load_ticket(libWiiPy.title.download_ticket(tid, wiiu_endpoint=wiiu_nus_enabled,
endpoint_override=endpoint_override))
can_decrypt = True
if output_dir is not None:
output_dir.joinpath("tik").write_bytes(title.ticket.dump())
except ValueError:
# If libWiiPy returns an error, then no ticket is available. Log this, and disable options requiring a
# ticket so that they aren't attempted later.
print(" - No Ticket is available!")
if wad_file is not None and output_dir is None:
fatal_error("--wad was passed, but this title has no common ticket and cannot be packed into a WAD!")
# Load the content records from the TMD, and begin iterating over the records.
title.load_content_records()
content_list = []
for content in range(len(title.tmd.content_records)):
# Generate the content file name by converting the Content ID to hex and then removing the 0x.
content_file_name = hex(title.tmd.content_records[content].content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
print(f" - Downloading content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id}, "
f"Size: {title.tmd.content_records[content].content_size} bytes)...")
content_list.append(libWiiPy.title.download_content(tid, title.tmd.content_records[content].content_id,
wiiu_endpoint=wiiu_nus_enabled,
endpoint_override=endpoint_override))
print(" - Done!")
# If we're supposed to be outputting to a folder, then write these files out.
if output_dir is not None:
output_dir.joinpath(content_file_name).write_bytes(content_list[content])
title.content.content_list = content_list
# Try to decrypt the contents for this title if a ticket was available.
if output_dir is not None:
if can_decrypt is True:
for content in range(len(title.tmd.content_records)):
print(f" - Decrypting content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id})...")
dec_content = title.get_content_by_index(content)
content_file_name = f"{title.tmd.content_records[content].content_id:08X}".lower() + ".app"
output_dir.joinpath(content_file_name).write_bytes(dec_content)
else:
print("Title has no Ticket, so content will not be decrypted!")
# If --wad was passed, pack a WAD and output that.
if wad_file is not None:
# Get the WAD certificate chain.
print(" - Building certificate...")
title.load_cert_chain(libWiiPy.title.download_cert_chain(wiiu_endpoint=wiiu_nus_enabled,
endpoint_override=endpoint_override))
# Ensure that the path ends in .wad, and add that if it doesn't.
print("Packing WAD...")
if wad_file.suffix != ".wad":
wad_file = wad_file.with_suffix(".wad")
# Have libWiiPy dump the WAD, and write that data out.
pathlib.Path(wad_file).write_bytes(title.dump_wad())
print(f"Downloaded title with Title ID \"{args.tid}\"!")
def handle_nus_tmd(args):
tid = args.tid

View File

@@ -81,7 +81,6 @@ def handle_wad_convert(args):
else:
fatal_error("No valid encryption target was specified!")
output_path = pathlib.Path(args.output)
if args.output is None:
match target:
case "development":
@@ -92,6 +91,8 @@ def handle_wad_convert(args):
output_path = pathlib.Path(input_path.stem + "_vWii" + input_path.suffix)
case _:
fatal_error("No valid encryption target was specified!")
else:
output_path = pathlib.Path(args.output)
if not input_path.exists():
fatal_error(f"The specified WAD file \"{input_path}\" does not exist!")
@@ -253,7 +254,7 @@ def handle_wad_pack(args):
title = libWiiPy.title.Title()
title.load_tmd(tmd_file.read_bytes())
title.load_ticket(ticket_file.read_bytes())
title.wad.set_cert_data(cert_file.read_bytes())
title.load_cert_chain(cert_file.read_bytes())
# Footers are not super common and are not required, so we don't care about one existing until we get to
# the step where we'd pack it.
footer_file = pathlib.Path(list(input_path.glob("*.[fF][oO][oO][tT][eE][rR]"))[0])

View File

@@ -5,6 +5,7 @@ import argparse
from importlib.metadata import version
from commands.archive.ash import *
from commands.archive.lz77 import *
from commands.archive.theme import *
from commands.archive.u8 import *
from commands.nand.emunand import *
@@ -30,7 +31,7 @@ if __name__ == "__main__":
# Argument parser for the ASH subcommand.
ash_parser = subparsers.add_parser("ash", help="compress/decompress an ASH file",
description="compress/decompress an ASH file")
ash_subparsers = ash_parser.add_subparsers(title="emunand", dest="emunand", required=True)
ash_subparsers = ash_parser.add_subparsers(title="ash", dest="ash", required=True)
# ASH compress parser.
ash_compress_parser = ash_subparsers.add_parser("compress", help="compress a file into an ASH file",
description="compress a file into an ASH file; by default, this "
@@ -146,6 +147,28 @@ if __name__ == "__main__":
iospatch_parser.add_argument("-ns", "--no-shared", action="store_true",
help="set all patched content to be non-shared")
# Argument parser for the LZ77 subcommand.
lz77_parser = subparsers.add_parser("lz77", help="compress/decompress data using LZ77 compression",
description="compress/decompress data using LZ77 compression")
lz77_subparsers = lz77_parser.add_subparsers(title="lz77", dest="lz77", required=True)
# LZ77 compress parser.
lz77_compress_parser = lz77_subparsers.add_parser("compress", help="compress a file with LZ77 compression",
description="compress a file with LZ77 compression; by default, "
"this will output to <input file>.lz77")
lz77_compress_parser.set_defaults(func=handle_lz77_compress)
lz77_compress_parser.add_argument("input", metavar="IN", type=str, help="file to compress")
lz77_compress_parser.add_argument("-o", "--output", metavar="OUT", type=str,
help="file to output the compressed data to (optional)")
# LZ77 decompress parser.
lz77_decompress_parser = lz77_subparsers.add_parser("decompress", help="decompress an LZ77-compressed file",
description="decompress an LZ77-compressed file; by default, "
"this will output to <input file>.out")
lz77_decompress_parser.set_defaults(func=handle_lz77_decompress)
lz77_decompress_parser.add_argument("input", metavar="IN", type=str,
help="LZ77-compressed file to decompress")
lz77_decompress_parser.add_argument("-o", "--output", metavar="OUT", type=str,
help="file to output the decompressed data to (optional)")
# Argument parser for the NUS subcommand.
nus_parser = subparsers.add_parser("nus", help="download data from the NUS",
description="download from the NUS")
@@ -163,8 +186,10 @@ if __name__ == "__main__":
help="download the title to a folder")
nus_title_out_group.add_argument("-w", "--wad", metavar="WAD", type=str,
help="pack a wad with the provided name")
nus_title_parser.add_argument("--wii", help="use original Wii NUS instead of the Wii U servers",
nus_title_parser.add_argument("--wii", help="use the original Wii NUS endpoint instead of the Wii U endpoint",
action="store_true")
nus_title_parser.add_argument("-e", "--endpoint", metavar="ENDPOINT", type=str,
help="use the specified NUS endpoint instead of the official one")
# Content NUS subcommand.
nus_content_parser = nus_subparsers.add_parser("content", help="download a specific content from the NUS",
description="download a specific content from the NUS")