#!/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 /../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()