138 lines
4.4 KiB
Python
138 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Builds GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) using xorrisofs /
|
|
mkisofs + Swiss-GC system-area headers.
|
|
|
|
The Swiss-GC headers contain a working GCN apploader that boots any DOL via
|
|
the El Torito catalog — no custom PPC assembly needed.
|
|
|
|
Headers are cached in <script_dir>/../buildtools/iso/ and downloaded from the
|
|
Swiss-GC repository on first use.
|
|
|
|
Disc layout (produced by xorrisofs):
|
|
sectors 0-15 system area (boot.bin + bi2 + apploader, from Swiss header)
|
|
sector 16 ISO 9660 PVD
|
|
sector 17 El Torito Boot Record
|
|
... directory records, file data
|
|
Dusk.dol (El Torito boot image — loaded by apploader)
|
|
dusk.dsk (asset archive — found at runtime via ISO 9660)
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
|
|
SWISS_BASE = (
|
|
"https://raw.githubusercontent.com/emukidid/swiss-gc/"
|
|
"6f78e41940d2699b69ec62636d57411ae2857f1e/buildtools/iso/"
|
|
)
|
|
|
|
REGIONS = [
|
|
('j', 'J', 'NTSC-J', 0),
|
|
('u', 'E', 'NTSC-U', 1),
|
|
('e', 'P', 'PAL', 2),
|
|
]
|
|
|
|
|
|
def _buildtools_iso_dir():
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
return os.path.join(here, '..', 'buildtools', 'iso')
|
|
|
|
|
|
def _get_hdr(region_suffix):
|
|
"""Return the raw bytes of the Swiss system-area header for *region_suffix*
|
|
('j', 'u', or 'e'), downloading it if necessary."""
|
|
cache_dir = _buildtools_iso_dir()
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
path = os.path.join(cache_dir, f'eltorito-{region_suffix}.hdr')
|
|
if not os.path.exists(path):
|
|
url = SWISS_BASE + f'eltorito-{region_suffix}.hdr'
|
|
print(f'Downloading {url} ...', flush=True)
|
|
with urllib.request.urlopen(url) as resp:
|
|
data = resp.read()
|
|
with open(path, 'wb') as f:
|
|
f.write(data)
|
|
with open(path, 'rb') as f:
|
|
return f.read()
|
|
|
|
|
|
def _patch_hdr(hdr_data, game_id, title):
|
|
"""Patch game ID (bytes 0-5) and title (bytes 0x20-0x3FF) into the
|
|
system-area header. Everything else (magic, bi2, apploader) is unchanged."""
|
|
hdr = bytearray(hdr_data)
|
|
hdr[0:6] = game_id.encode('ascii')
|
|
tb = title.encode('ascii')[:0x3E0]
|
|
hdr[0x20:0x20 + len(tb)] = tb
|
|
# zero remaining title bytes so stale Swiss title doesn't bleed through
|
|
hdr[0x20 + len(tb):0x400] = bytes(0x3E0 - len(tb))
|
|
return bytes(hdr)
|
|
|
|
|
|
def _find_mkisofs():
|
|
for tool in ('xorrisofs', 'mkisofs'):
|
|
if shutil.which(tool):
|
|
return tool
|
|
sys.exit('Error: neither xorrisofs nor mkisofs found. '
|
|
'Install xorriso (apt install xorriso) or mkisofs.')
|
|
|
|
|
|
def _build_iso(platform, game_id, title, region_suffix, dol_path, dsk_path,
|
|
out_path):
|
|
hdr_data = _patch_hdr(_get_hdr(region_suffix), game_id, title)
|
|
|
|
# Write patched header to a temp file alongside the output so we have a
|
|
# stable path (avoids tempfile cleanup races on some systems).
|
|
hdr_tmp = out_path + '.hdr.tmp'
|
|
try:
|
|
with open(hdr_tmp, 'wb') as f:
|
|
f.write(hdr_data)
|
|
|
|
mkisofs = _find_mkisofs()
|
|
dol_name = os.path.basename(dol_path)
|
|
|
|
cmd = [
|
|
mkisofs,
|
|
'-R', '-J',
|
|
'-G', hdr_tmp,
|
|
'-no-emul-boot',
|
|
'-eltorito-platform', 'PPC',
|
|
'-b', dol_name,
|
|
'-o', out_path,
|
|
dol_path,
|
|
dsk_path,
|
|
]
|
|
subprocess.run(cmd, check=True)
|
|
finally:
|
|
if os.path.exists(hdr_tmp):
|
|
os.remove(hdr_tmp)
|
|
|
|
size = os.path.getsize(out_path)
|
|
print(f'Created {out_path} ({size:,} bytes)')
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description='Build GCN/Wii disc images from a DOL and asset file.')
|
|
ap.add_argument('platform', choices=['GCN', 'WII'])
|
|
ap.add_argument('dol', help='Path to Dusk.dol')
|
|
ap.add_argument('dsk', help='Path to dusk.dsk asset archive')
|
|
ap.add_argument('title', help='Game title string')
|
|
ap.add_argument('build_dir', help='Output directory')
|
|
args = ap.parse_args()
|
|
|
|
id_prefix = 'G' if args.platform == 'GCN' else 'R'
|
|
|
|
for region_suffix, region_char, region_name, _ in REGIONS:
|
|
game_id = f'{id_prefix}DK{region_char}01'
|
|
out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso')
|
|
_build_iso(args.platform, game_id, args.title,
|
|
region_suffix, args.dol, args.dsk, out_path)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|