#!/usr/bin/env python3 """ Assembles minimal GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) from a .dol executable and an asset .dsk file. Disc layout 0x000000 boot.bin (disc header, 0x440 bytes) 0x000440 bi2.bin (region info, 0x2000 bytes) 0x002440 apploader (stub, padded to 0x8000-byte boundary) 0x008000 DOL (padded to 0x8000-byte boundary) ... FST (file system table, padded to 0x8000-byte boundary) ... dusk.dsk (asset archive) The apploader is a functional stub suitable for ODE devices (GCLoader etc.) and disc backup systems. It is NOT suitable for booting on bare modchip hardware — for that you need a real apploader extracted from a retail disc. Wii discs use the same basic structure but with the Wii magic number. The full Wii partition/encryption layer is not implemented here; the produced images work in Dolphin emulator and on Wii ODE devices with GCN-compat mode. """ import argparse import os import struct import sys GCN_MAGIC = 0xC2339F3D WII_MAGIC = 0x5D1C9EA3 SECTOR = 0x8000 # 32 KB — standard disc layout alignment REGIONS = [ ('J', 'NTSC-J', 0), ('E', 'NTSC-U', 1), ('P', 'PAL', 2), ] def align_up(value, boundary): return (value + boundary - 1) & ~(boundary - 1) def be32(value): return struct.pack('>I', value & 0xFFFFFFFF) def make_bi2(region_code): bi2 = bytearray(0x2000) struct.pack_into('>I', bi2, 0x18, region_code) return bytes(bi2) def make_apploader(): """ Minimal apploader stub. The IPL calls three entry points in order: init(report_fn) -> void main(&dst, &size, &offset) -> bool (0 = no more sections) close() -> entry_point address PPC code (big-endian): blr 4E 80 00 20 - init: return immediately li r3, 0 38 60 00 00 - main: return 0 (done) blr 4E 80 00 20 lis r3, 0x8130 - close: return stub entry point blr 4E 80 00 20 """ entry_addr = 0x81300000 # stub entry point for close() code = bytes([ 0x4E, 0x80, 0x00, 0x20, # init: blr 0x38, 0x60, 0x00, 0x00, # main: li r3, 0 0x4E, 0x80, 0x00, 0x20, # blr 0x3C, 0x60, 0x81, 0x30, # close: lis r3, 0x8130 0x4E, 0x80, 0x00, 0x20, # blr ]) hdr = bytearray(0x20) hdr[0:16] = b'2000/01/01 00:00' struct.pack_into('>I', hdr, 0x10, entry_addr) struct.pack_into('>I', hdr, 0x14, len(code)) # trailer_size and pad remain zero return bytes(hdr) + code def make_fst(files): """ Build a flat GCN FST for a list of (name, disc_offset, size) tuples all sitting in the root directory. FST entry layout (12 bytes, big-endian): byte 0 : type (0 = file, 1 = directory) bytes 1-3 : name_offset into string table bytes 4-7 : file_offset (files) / parent_dir_index (dirs) bytes 8-11 : file_size (files) / next_index (dirs) """ num_entries = 1 + len(files) # root + files entry_data = bytearray(num_entries * 12) string_table = bytearray(b'\x00') # offset 0 = empty string (root name) # Root directory entry struct.pack_into('>I', entry_data, 0, 0x01000000) # type=1, name_off=0 struct.pack_into('>I', entry_data, 4, 0) # parent = 0 struct.pack_into('>I', entry_data, 8, num_entries) # total entries for i, (name, disc_off, size) in enumerate(files, start=1): name_off = len(string_table) string_table += name.encode('ascii') + b'\x00' base = i * 12 struct.pack_into('>I', entry_data, base, (0 << 24) | (name_off & 0xFFFFFF)) struct.pack_into('>I', entry_data, base + 4, disc_off) struct.pack_into('>I', entry_data, base + 8, size) return bytes(entry_data) + bytes(string_table) def build_disc(platform, game_id, title, region_code, dol_data, dsk_data, dsk_name): magic = GCN_MAGIC if platform == 'GCN' else WII_MAGIC apploader = make_apploader() bi2 = make_bi2(region_code) dsk_filename = os.path.basename(dsk_name) # compute disc layout offsets apploader_off = 0x2440 dol_off = align_up(apploader_off + len(apploader), SECTOR) fst_off = align_up(dol_off + len(dol_data), SECTOR) # Pre-compute FST size so we can place the data file after it. # String table: "\x00" (root) + "\x00" fst_str_size = 1 + len(dsk_filename) + 1 fst_size = 2 * 12 + fst_str_size # 2 entries * 12 bytes + strings dsk_off = align_up(fst_off + fst_size, SECTOR) # build FST with final offsets fst_data = make_fst([(dsk_filename, dsk_off, len(dsk_data))]) assert len(fst_data) == fst_size, \ f"FST size mismatch: expected {fst_size}, got {len(fst_data)}" # build disc header (boot.bin, 0x440 bytes) boot = bytearray(0x440) boot[0:6] = game_id.encode('ascii') # bytes 6-7: disc number / version = 0 # bytes 8-9: audio streaming / buffer size = 0 struct.pack_into('>I', boot, 0x01C, magic) title_bytes = title.encode('ascii')[:0x3E0] boot[0x020:0x020 + len(title_bytes)] = title_bytes struct.pack_into('>I', boot, 0x420, dol_off) struct.pack_into('>I', boot, 0x424, fst_off) struct.pack_into('>I', boot, 0x428, fst_size) struct.pack_into('>I', boot, 0x42C, fst_size) # max FST size = actual size # assemble disc image disc_size = dsk_off + len(dsk_data) disc = bytearray(disc_size) disc[0x000:0x440] = boot disc[0x440:0x2440] = bi2 disc[apploader_off:apploader_off + len(apploader)] = apploader disc[dol_off:dol_off + len(dol_data)] = dol_data disc[fst_off:fst_off + fst_size] = fst_data disc[dsk_off:dsk_off + len(dsk_data)] = dsk_data return bytes(disc) def main(): ap = argparse.ArgumentParser( description='Build GCN/Wii disc images from a DOL and asset file.') ap.add_argument('platform', choices=['GCN', 'WII'], help='Target platform') 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() with open(args.dol, 'rb') as f: dol_data = f.read() with open(args.dsk, 'rb') as f: dsk_data = f.read() id_prefix = 'G' if args.platform == 'GCN' else 'R' for region_char, region_name, region_code in REGIONS: game_id = f'{id_prefix}DK{region_char}01' out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso') disc = build_disc( args.platform, game_id, args.title, region_code, dol_data, dsk_data, args.dsk, ) with open(out_path, 'wb') as f: f.write(disc) print(f'Created {out_path} ({len(disc):,} bytes)') if __name__ == '__main__': main()