diff --git a/docker/dolphin/Dockerfile b/docker/dolphin/Dockerfile index 6a39baf1..5dbd3f95 100644 --- a/docker/dolphin/Dockerfile +++ b/docker/dolphin/Dockerfile @@ -2,6 +2,6 @@ FROM ghcr.io/extremscorner/libogc2 WORKDIR /workdir RUN apt update && \ dkp-pacman -Syu --noconfirm && \ - apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl && \ + apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl xorriso && \ dkp-pacman -S --needed --noconfirm gamecube-sdl2 ppc-liblzma ppc-libzip libogc2 gamecube-tools ppc-libmad ppc-zlib-ng ppc-liblzma ppc-bzip2 ppc-zstd VOLUME ["/workdir"] \ No newline at end of file diff --git a/src/duskdolphin/asset/assetdolphindvd.c b/src/duskdolphin/asset/assetdolphindvd.c index 7034e420..b4339bdc 100644 --- a/src/duskdolphin/asset/assetdolphindvd.c +++ b/src/duskdolphin/asset/assetdolphindvd.c @@ -9,49 +9,86 @@ #include "asset/asset.h" #include "util/string.h" #include "util/memory.h" -#include "util/endian.h" + +#define ISO_SECTOR_SIZE 2048u +#define ISO_PVD_SECTOR 16u errorret_t assetInitDolphinDVD(void) { DVD_Init(); DVD_Mount(); - // Read disc header to find FST location - u8 *hdr = (u8 *)assetDolphinDVDRead(0, 0x440); - if(!hdr) errorThrow("Failed to read DVD disc header."); - u32 fstOff = assetDolphinDVDReadBigEndian32(hdr + 0x424); - u32 fstSize = assetDolphinDVDReadBigEndian32(hdr + 0x428); - memoryFree(hdr); + // ISO 9660 Primary Volume Descriptor is at sector 16. + u8 *pvd = (u8 *)assetDolphinDVDRead( + (s64)ISO_PVD_SECTOR * ISO_SECTOR_SIZE, ISO_SECTOR_SIZE + ); + if(!pvd) errorThrow("Failed to read ISO 9660 PVD."); - // Read the FST - u8 *fst = (u8 *)assetDolphinDVDRead((s64)fstOff, fstSize); - if(!fst) errorThrow("Failed to read DVD FST."); - - // Root entry (index 0) bytes 8-11 = total entry count - u32 numEntries = assetDolphinDVDReadBigEndian32(fst + 8); - u8 *strTable = fst + numEntries * 12u; - - u32 fileOff = 0, fileLen = 0; - for(u32 i = 1; i < numEntries; i++) { - u8 *e = fst + i * 12u; - if(e[0] != 0) continue; - - u32 nameOff = ((u32)e[1] << 16) | ((u32)e[2] << 8) | (u32)e[3]; - const char_t *name = (const char_t *)(strTable + nameOff); - if(stringCompareInsensitive(name, ASSET_FILE_NAME) == 0) { - fileOff = assetDolphinDVDReadBigEndian32(e + 4); - fileLen = assetDolphinDVDReadBigEndian32(e + 8); - break; - } + // Sanity-check: type=1, identifier="CD001" + if(pvd[0] != 1 || pvd[1] != 'C' || pvd[2] != 'D' || + pvd[3] != '0' || pvd[4] != '0' || pvd[5] != '1') { + memoryFree(pvd); + errorThrow("Not a valid ISO 9660 disc."); } - memoryFree(fst); - if(!fileOff) errorThrow("Failed to find asset file on DVD."); + // Root Directory Record starts at PVD+156. + // ISO 9660 stores multi-byte fields in both byte orders; use the BE copies + // (offset +6 for LBA, +14 for size) since the GameCube is big-endian. + u32 rootLBA = assetDolphinDVDReadBigEndian32(pvd + 156 + 6); + u32 rootSize = assetDolphinDVDReadBigEndian32(pvd + 156 + 14); + memoryFree(pvd); - u8 *data = (u8 *)assetDolphinDVDRead((s64)fileOff, fileLen); - if(!data) errorThrow("Failed to read asset file from DVD."); + u8 *dir = (u8 *)assetDolphinDVDRead( + (s64)rootLBA * ISO_SECTOR_SIZE, rootSize + ); + if(!dir) errorThrow("Failed to read ISO 9660 root directory."); + + // Scan directory records for dusk.dsk. + // ISO 9660 level-1 names are uppercase with a ";1" version suffix, e.g. + // "DUSK.DSK;1". We strip the suffix before comparing case-insensitively. + u32 fileLBA = 0, fileSize = 0; + u32 pos = 0; + while(pos < rootSize) { + u8 recLen = dir[pos]; + if(recLen == 0) { + // Sector padding — skip to the start of the next sector. + pos = (pos + (ISO_SECTOR_SIZE - 1u)) & ~(ISO_SECTOR_SIZE - 1u); + continue; + } + + u8 flags = dir[pos + 25]; + u8 nameLen = dir[pos + 32]; + + if(!(flags & 0x02) && nameLen > 1) { // skip directories and "." / ".." + const char_t *isoName = (const char_t *)(dir + pos + 33); + + // Build a null-terminated copy of the base name (strip ";N" version). + char_t baseName[32]; + u8 baseLen = 0; + while(baseLen < nameLen && isoName[baseLen] != ';' && baseLen < 31) { + baseName[baseLen] = isoName[baseLen]; + baseLen++; + } + baseName[baseLen] = '\0'; + + if(stringCompareInsensitive(baseName, ASSET_FILE_NAME) == 0) { + fileLBA = assetDolphinDVDReadBigEndian32(dir + pos + 6); + fileSize = assetDolphinDVDReadBigEndian32(dir + pos + 14); + break; + } + } + pos += recLen; + } + memoryFree(dir); + + if(!fileLBA) errorThrow("Failed to find asset file on ISO."); + + u8 *data = (u8 *)assetDolphinDVDRead( + (s64)fileLBA * ISO_SECTOR_SIZE, fileSize + ); + if(!data) errorThrow("Failed to read asset file from ISO."); zip_error_t zerr; - zip_source_t *src = zip_source_buffer_create(data, fileLen, 1, &zerr); + zip_source_t *src = zip_source_buffer_create(data, fileSize, 1, &zerr); if(!src) { memoryFree(data); errorThrow("Failed to create zip source from DVD buffer."); diff --git a/tools/makedolphiniso.py b/tools/makedolphiniso.py index c7ae7258..f1cd13ba 100644 --- a/tools/makedolphiniso.py +++ b/tools/makedolphiniso.py @@ -1,198 +1,137 @@ #!/usr/bin/env python3 """ -Assembles minimal GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) from a -.dol executable and an asset .dsk file. +Builds GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) using xorrisofs / +mkisofs + Swiss-GC system-area headers. -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 Swiss-GC headers contain a working GCN apploader that boots any DOL via +the El Torito catalog — no custom PPC assembly needed. -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. +Headers are cached in /../buildtools/iso/ and downloaded from the +Swiss-GC repository on first use. -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. +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 -GCN_MAGIC = 0xC2339F3D -WII_MAGIC = 0x5D1C9EA3 -SECTOR = 0x8000 # 32 KB — standard disc layout alignment +SWISS_BASE = ( + "https://raw.githubusercontent.com/emukidid/swiss-gc/" + "6f78e41940d2699b69ec62636d57411ae2857f1e/buildtools/iso/" +) REGIONS = [ - ('J', 'NTSC-J', 0), - ('E', 'NTSC-U', 1), - ('P', 'PAL', 2), + ('j', 'J', 'NTSC-J', 0), + ('u', 'E', 'NTSC-U', 1), + ('e', 'P', 'PAL', 2), ] -def align_up(value, boundary): - return (value + boundary - 1) & ~(boundary - 1) +def _buildtools_iso_dir(): + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(here, '..', 'buildtools', 'iso') -def be32(value): - return struct.pack('>I', value & 0xFFFFFFFF) +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 make_bi2(region_code): - bi2 = bytearray(0x2000) - struct.pack_into('>I', bi2, 0x18, region_code) - return bytes(bi2) +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 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 _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 make_fst(files): - """ - Build a flat GCN FST for a list of (name, disc_offset, size) tuples - all sitting in the root directory. +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) - 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) + # 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) - # 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 + mkisofs = _find_mkisofs() + dol_name = os.path.basename(dol_path) - 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) + 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) - 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) + 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'], - 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() + 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() - 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' - 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)') + 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() + main()