Dolphin Bootable ISO working!

This commit is contained in:
2026-05-07 17:38:41 -05:00
parent 2cea43dc70
commit 65ca5ae4c4
3 changed files with 170 additions and 194 deletions
+1 -1
View File
@@ -2,6 +2,6 @@ FROM ghcr.io/extremscorner/libogc2
WORKDIR /workdir WORKDIR /workdir
RUN apt update && \ RUN apt update && \
dkp-pacman -Syu --noconfirm && \ 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 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"] VOLUME ["/workdir"]
+69 -32
View File
@@ -9,49 +9,86 @@
#include "asset/asset.h" #include "asset/asset.h"
#include "util/string.h" #include "util/string.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/endian.h"
#define ISO_SECTOR_SIZE 2048u
#define ISO_PVD_SECTOR 16u
errorret_t assetInitDolphinDVD(void) { errorret_t assetInitDolphinDVD(void) {
DVD_Init(); DVD_Init();
DVD_Mount(); DVD_Mount();
// Read disc header to find FST location // ISO 9660 Primary Volume Descriptor is at sector 16.
u8 *hdr = (u8 *)assetDolphinDVDRead(0, 0x440); u8 *pvd = (u8 *)assetDolphinDVDRead(
if(!hdr) errorThrow("Failed to read DVD disc header."); (s64)ISO_PVD_SECTOR * ISO_SECTOR_SIZE, ISO_SECTOR_SIZE
u32 fstOff = assetDolphinDVDReadBigEndian32(hdr + 0x424); );
u32 fstSize = assetDolphinDVDReadBigEndian32(hdr + 0x428); if(!pvd) errorThrow("Failed to read ISO 9660 PVD.");
memoryFree(hdr);
// Read the FST // Sanity-check: type=1, identifier="CD001"
u8 *fst = (u8 *)assetDolphinDVDRead((s64)fstOff, fstSize); if(pvd[0] != 1 || pvd[1] != 'C' || pvd[2] != 'D' ||
if(!fst) errorThrow("Failed to read DVD FST."); pvd[3] != '0' || pvd[4] != '0' || pvd[5] != '1') {
memoryFree(pvd);
// Root entry (index 0) bytes 8-11 = total entry count errorThrow("Not a valid ISO 9660 disc.");
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;
}
} }
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); u8 *dir = (u8 *)assetDolphinDVDRead(
if(!data) errorThrow("Failed to read asset file from DVD."); (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_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) { if(!src) {
memoryFree(data); memoryFree(data);
errorThrow("Failed to create zip source from DVD buffer."); errorThrow("Failed to create zip source from DVD buffer.");
+100 -161
View File
@@ -1,198 +1,137 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Assembles minimal GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) from a Builds GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) using xorrisofs /
.dol executable and an asset .dsk file. mkisofs + Swiss-GC system-area headers.
Disc layout The Swiss-GC headers contain a working GCN apploader that boots any DOL via
0x000000 boot.bin (disc header, 0x440 bytes) the El Torito catalog — no custom PPC assembly needed.
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.) Headers are cached in <script_dir>/../buildtools/iso/ and downloaded from the
and disc backup systems. It is NOT suitable for booting on bare modchip Swiss-GC repository on first use.
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 Disc layout (produced by xorrisofs):
full Wii partition/encryption layer is not implemented here; the produced sectors 0-15 system area (boot.bin + bi2 + apploader, from Swiss header)
images work in Dolphin emulator and on Wii ODE devices with GCN-compat mode. 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 argparse
import os import os
import shutil
import struct import struct
import subprocess
import sys import sys
import urllib.request
GCN_MAGIC = 0xC2339F3D SWISS_BASE = (
WII_MAGIC = 0x5D1C9EA3 "https://raw.githubusercontent.com/emukidid/swiss-gc/"
SECTOR = 0x8000 # 32 KB — standard disc layout alignment "6f78e41940d2699b69ec62636d57411ae2857f1e/buildtools/iso/"
)
REGIONS = [ REGIONS = [
('J', 'NTSC-J', 0), ('j', 'J', 'NTSC-J', 0),
('E', 'NTSC-U', 1), ('u', 'E', 'NTSC-U', 1),
('P', 'PAL', 2), ('e', 'P', 'PAL', 2),
] ]
def align_up(value, boundary): def _buildtools_iso_dir():
return (value + boundary - 1) & ~(boundary - 1) here = os.path.dirname(os.path.abspath(__file__))
return os.path.join(here, '..', 'buildtools', 'iso')
def be32(value): def _get_hdr(region_suffix):
return struct.pack('>I', value & 0xFFFFFFFF) """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): def _patch_hdr(hdr_data, game_id, title):
bi2 = bytearray(0x2000) """Patch game ID (bytes 0-5) and title (bytes 0x20-0x3FF) into the
struct.pack_into('>I', bi2, 0x18, region_code) system-area header. Everything else (magic, bi2, apploader) is unchanged."""
return bytes(bi2) 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(): def _find_mkisofs():
""" for tool in ('xorrisofs', 'mkisofs'):
Minimal apploader stub. The IPL calls three entry points in order: if shutil.which(tool):
init(report_fn) -> void return tool
main(&dst, &size, &offset) -> bool (0 = no more sections) sys.exit('Error: neither xorrisofs nor mkisofs found. '
close() -> entry_point address 'Install xorriso (apt install xorriso) or mkisofs.')
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): def _build_iso(platform, game_id, title, region_suffix, dol_path, dsk_path,
""" out_path):
Build a flat GCN FST for a list of (name, disc_offset, size) tuples hdr_data = _patch_hdr(_get_hdr(region_suffix), game_id, title)
all sitting in the root directory.
FST entry layout (12 bytes, big-endian): # Write patched header to a temp file alongside the output so we have a
byte 0 : type (0 = file, 1 = directory) # stable path (avoids tempfile cleanup races on some systems).
bytes 1-3 : name_offset into string table hdr_tmp = out_path + '.hdr.tmp'
bytes 4-7 : file_offset (files) / parent_dir_index (dirs) try:
bytes 8-11 : file_size (files) / next_index (dirs) with open(hdr_tmp, 'wb') as f:
""" f.write(hdr_data)
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 mkisofs = _find_mkisofs()
struct.pack_into('>I', entry_data, 0, 0x01000000) # type=1, name_off=0 dol_name = os.path.basename(dol_path)
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): cmd = [
name_off = len(string_table) mkisofs,
string_table += name.encode('ascii') + b'\x00' '-R', '-J',
base = i * 12 '-G', hdr_tmp,
struct.pack_into('>I', entry_data, base, (0 << 24) | (name_off & 0xFFFFFF)) '-no-emul-boot',
struct.pack_into('>I', entry_data, base + 4, disc_off) '-eltorito-platform', 'PPC',
struct.pack_into('>I', entry_data, base + 8, size) '-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) size = os.path.getsize(out_path)
print(f'Created {out_path} ({size:,} bytes)')
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(): def main():
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
description='Build GCN/Wii disc images from a DOL and asset file.') description='Build GCN/Wii disc images from a DOL and asset file.')
ap.add_argument('platform', choices=['GCN', 'WII'], ap.add_argument('platform', choices=['GCN', 'WII'])
help='Target platform') ap.add_argument('dol', help='Path to Dusk.dol')
ap.add_argument('dol', help='Path to Dusk.dol') ap.add_argument('dsk', help='Path to dusk.dsk asset archive')
ap.add_argument('dsk', help='Path to dusk.dsk asset archive') ap.add_argument('title', help='Game title string')
ap.add_argument('title', help='Game title string') ap.add_argument('build_dir', help='Output directory')
ap.add_argument('build_dir', help='Output directory') args = ap.parse_args()
args = ap.parse_args()
with open(args.dol, 'rb') as f: id_prefix = 'G' if args.platform == 'GCN' else 'R'
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_suffix, region_char, region_name, _ in REGIONS:
game_id = f'{id_prefix}DK{region_char}01'
for region_char, region_name, region_code in REGIONS: out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso')
game_id = f'{id_prefix}DK{region_char}01' _build_iso(args.platform, game_id, args.title,
out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso') region_suffix, args.dol, args.dsk, out_path)
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__': if __name__ == '__main__':
main() main()