Dolphin Bootable ISO working!
This commit is contained in:
@@ -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"]
|
||||
@@ -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.");
|
||||
|
||||
+100
-161
@@ -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 <script_dir>/../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) + "<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)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user