Files
dusk/tools/makedolphiniso.py
T
2026-05-07 12:18:30 -05:00

199 lines
6.5 KiB
Python

#!/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) + "<filename>\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()