diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a3bc1a33..dcb82360 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -110,6 +110,28 @@ jobs:
path: ./git-artifcats/Dusk
if-no-files-found: error
+ build-gamecube-iso:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ - name: Set up Docker
+ uses: docker/setup-docker-action@v5
+ - name: Build GameCube ISO
+ run: ./scripts/build-gamecube-iso-docker.sh
+ - name: Copy output files.
+ run: |
+ mkdir -p ./git-artifcats/Dusk
+ cp build-gamecube-iso/Dusk-NTSC-J.iso ./git-artifcats/Dusk/Dusk-NTSC-J.iso
+ cp build-gamecube-iso/Dusk-NTSC-U.iso ./git-artifcats/Dusk/Dusk-NTSC-U.iso
+ cp build-gamecube-iso/Dusk-PAL.iso ./git-artifcats/Dusk/Dusk-PAL.iso
+ - name: Upload GameCube ISO
+ uses: actions/upload-artifact@v6
+ with:
+ name: dusk-gamecube-iso
+ path: ./git-artifcats/Dusk
+ if-no-files-found: error
+
build-wii:
runs-on: ubuntu-latest
steps:
@@ -124,10 +146,32 @@ jobs:
mkdir -p ./git-artifcats/Dusk/apps/Dusk
cp build-wii/Dusk.dol ./git-artifcats/Dusk/apps/Dusk/boot.dol
cp build-wii/dusk.dsk ./git-artifcats/Dusk/apps/Dusk/dusk.dsk
- cp docker/dolphin/meta.xml ./git-artifcats/Dusk/apps/Dusk/meta.xml
+ cp build-wii/meta.xml ./git-artifcats/Dusk/apps/Dusk/meta.xml
- name: Upload Wii binary
uses: actions/upload-artifact@v6
with:
name: dusk-wii
path: ./git-artifcats/Dusk
+ if-no-files-found: error
+
+ build-wii-iso:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ - name: Set up Docker
+ uses: docker/setup-docker-action@v5
+ - name: Build Wii ISO
+ run: ./scripts/build-wii-iso-docker.sh
+ - name: Copy output files.
+ run: |
+ mkdir -p ./git-artifcats/Dusk
+ cp build-wii-iso/Dusk-NTSC-J.iso ./git-artifcats/Dusk/Dusk-NTSC-J.iso
+ cp build-wii-iso/Dusk-NTSC-U.iso ./git-artifcats/Dusk/Dusk-NTSC-U.iso
+ cp build-wii-iso/Dusk-PAL.iso ./git-artifcats/Dusk/Dusk-PAL.iso
+ - name: Upload Wii ISO
+ uses: actions/upload-artifact@v6
+ with:
+ name: dusk-wii-iso
+ path: ./git-artifcats/Dusk
if-no-files-found: error
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3a660a3e..ddd7eaae 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -14,6 +14,12 @@ cmake_policy(SET CMP0079 NEW)
option(DUSK_BUILD_TESTS "Enable tests" OFF)
+# Game identity — override these per-project
+set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
+set(DUSK_GAME_AUTHOR "YouWish" CACHE STRING "Game author / coder")
+set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description")
+set(DUSK_GAME_LONG_DESCRIPTION "No description yet." CACHE STRING "Full description")
+
# Prep cache
set(DUSK_CACHE_TARGET "dusk-target")
@@ -74,6 +80,10 @@ endif()
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
DUSK_TARGET_SYSTEM="${DUSK_TARGET_SYSTEM}"
+ DUSK_GAME_NAME="${DUSK_GAME_NAME}"
+ DUSK_GAME_AUTHOR="${DUSK_GAME_AUTHOR}"
+ DUSK_GAME_SHORT_DESCRIPTION="${DUSK_GAME_SHORT_DESCRIPTION}"
+ DUSK_GAME_LONG_DESCRIPTION="${DUSK_GAME_LONG_DESCRIPTION}"
)
# Toolchains
diff --git a/cmake/targets/dolphin.cmake b/cmake/targets/dolphin.cmake
index 0d31dcee..a8cd9ddf 100644
--- a/cmake/targets/dolphin.cmake
+++ b/cmake/targets/dolphin.cmake
@@ -1,3 +1,7 @@
+# Build type: FAT (SD/USB via libfat) or ISO (DVD disc via libogc DVD driver)
+set(DUSK_DOLPHIN_BUILD_TYPE "FAT" CACHE STRING "Dolphin asset source: FAT (SD/USB) or ISO (DVD disc)")
+set_property(CACHE DUSK_DOLPHIN_BUILD_TYPE PROPERTY STRINGS "FAT" "ISO")
+
# Target definitions
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_DOLPHIN
@@ -5,8 +9,10 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_DISPLAY_WIDTH=640
DUSK_DISPLAY_HEIGHT=480
DUSK_THREAD_PTHREAD
+ DUSK_DOLPHIN_BUILD_TYPE="${DUSK_DOLPHIN_BUILD_TYPE}"
)
+
# Custom compiler flags
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti -fno-exceptions")
@@ -26,11 +32,16 @@ find_package(cglm REQUIRED)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
cglm
m
- fat
PkgConfig::zip
)
-# Postbuild
+if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
+ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC DUSK_DOLPHIN_BUILD_ISO)
+else()
+ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE fat)
+endif()
+
+# Postbuild: ELF -> DOL
set(DUSK_BINARY_TARGET_NAME_DOL "${DUSK_BUILD_DIR}/Dusk.dol")
add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
COMMAND elf2dol
diff --git a/cmake/targets/gamecube.cmake b/cmake/targets/gamecube.cmake
index 269b60d6..f8234be9 100644
--- a/cmake/targets/gamecube.cmake
+++ b/cmake/targets/gamecube.cmake
@@ -8,3 +8,17 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
# bba
)
+
+# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
+if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
+ add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
+ COMMAND ${Python3_EXECUTABLE}
+ "${CMAKE_SOURCE_DIR}/tools/makedolphiniso.py"
+ "GCN"
+ "${DUSK_BINARY_TARGET_NAME_DOL}"
+ "${DUSK_ASSETS_ZIP}"
+ "${DUSK_GAME_NAME}"
+ "${DUSK_BUILD_DIR}"
+ COMMENT "Building GameCube ISO images (NTSC-J, NTSC-U, PAL)"
+ )
+endif()
diff --git a/cmake/targets/wii.cmake b/cmake/targets/wii.cmake
index 83fedd4a..af29731b 100644
--- a/cmake/targets/wii.cmake
+++ b/cmake/targets/wii.cmake
@@ -2,4 +2,26 @@ include(cmake/targets/dolphin.cmake)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_WII
-)
\ No newline at end of file
+)
+
+# Generate Homebrew Channel meta.xml from project identity variables
+string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
+configure_file(
+ "${CMAKE_SOURCE_DIR}/docker/dolphin/meta.xml.in"
+ "${DUSK_BUILD_DIR}/meta.xml"
+ @ONLY
+)
+
+# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
+if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
+ add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
+ COMMAND ${Python3_EXECUTABLE}
+ "${CMAKE_SOURCE_DIR}/tools/makedolphiniso.py"
+ "WII"
+ "${DUSK_BINARY_TARGET_NAME_DOL}"
+ "${DUSK_ASSETS_ZIP}"
+ "${DUSK_GAME_NAME}"
+ "${DUSK_BUILD_DIR}"
+ COMMENT "Building Wii ISO images (NTSC-J, NTSC-U, PAL)"
+ )
+endif()
diff --git a/docker/dolphin/meta.xml b/docker/dolphin/meta.xml
deleted file mode 100644
index d9c45e05..00000000
--- a/docker/dolphin/meta.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- Dusk
- 1.00
-
- YouWish
- Dusk game
- No description yet.
-
-
diff --git a/docker/dolphin/meta.xml.in b/docker/dolphin/meta.xml.in
new file mode 100644
index 00000000..ee6a7e3c
--- /dev/null
+++ b/docker/dolphin/meta.xml.in
@@ -0,0 +1,10 @@
+
+
+ @DUSK_GAME_NAME@
+ @PROJECT_VERSION@
+ @DUSK_BUILD_DATE@
+ @DUSK_GAME_AUTHOR@
+ @DUSK_GAME_SHORT_DESCRIPTION@
+ @DUSK_GAME_LONG_DESCRIPTION@
+
+
diff --git a/scripts/build-gamecube-iso-docker.sh b/scripts/build-gamecube-iso-docker.sh
new file mode 100755
index 00000000..b5340f76
--- /dev/null
+++ b/scripts/build-gamecube-iso-docker.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
+docker run --rm -v $(pwd):/workdir dusk-dolphin /bin/bash -c "./scripts/build-gamecube-iso.sh"
diff --git a/scripts/build-gamecube-iso.sh b/scripts/build-gamecube-iso.sh
new file mode 100755
index 00000000..b04180d6
--- /dev/null
+++ b/scripts/build-gamecube-iso.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+if [ -z "$DEVKITPRO" ]; then
+ echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
+ exit 1
+fi
+
+mkdir -p build-gamecube-iso
+cmake -S. -Bbuild-gamecube-iso \
+ -DDUSK_TARGET_SYSTEM=gamecube \
+ -DDUSK_DOLPHIN_BUILD_TYPE=ISO \
+ -DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/GameCube.cmake"
+cd build-gamecube-iso
+make -j$(nproc) VERBOSE=1
diff --git a/scripts/build-wii-iso-docker.sh b/scripts/build-wii-iso-docker.sh
new file mode 100755
index 00000000..043a4bb1
--- /dev/null
+++ b/scripts/build-wii-iso-docker.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
+docker run --rm -v $(pwd):/workdir dusk-dolphin /bin/bash -c "./scripts/build-wii-iso.sh"
diff --git a/scripts/build-wii-iso.sh b/scripts/build-wii-iso.sh
new file mode 100755
index 00000000..04b084e3
--- /dev/null
+++ b/scripts/build-wii-iso.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+if [ -z "$DEVKITPRO" ]; then
+ echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
+ exit 1
+fi
+
+mkdir -p build-wii-iso
+cmake -S. -Bbuild-wii-iso \
+ -DDUSK_TARGET_SYSTEM=wii \
+ -DDUSK_DOLPHIN_BUILD_TYPE=ISO \
+ -DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/Wii.cmake"
+cd build-wii-iso
+make -j$(nproc) VERBOSE=1
diff --git a/src/duskdolphin/asset/CMakeLists.txt b/src/duskdolphin/asset/CMakeLists.txt
index ac94ce41..32883cf8 100644
--- a/src/duskdolphin/asset/CMakeLists.txt
+++ b/src/duskdolphin/asset/CMakeLists.txt
@@ -1,11 +1,16 @@
# Copyright (c) 2026 Dominic Masters
-#
+#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
-# Sources
-
-target_sources(${DUSK_LIBRARY_TARGET_NAME}
- PUBLIC
- assetdolphin.c
-)
\ No newline at end of file
+if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
+ target_sources(${DUSK_LIBRARY_TARGET_NAME}
+ PUBLIC
+ assetdolphindvd.c
+ )
+else()
+ target_sources(${DUSK_LIBRARY_TARGET_NAME}
+ PUBLIC
+ assetdolphinfat.c
+ )
+endif()
diff --git a/src/duskdolphin/asset/assetdolphindvd.c b/src/duskdolphin/asset/assetdolphindvd.c
new file mode 100644
index 00000000..ff5c3bff
--- /dev/null
+++ b/src/duskdolphin/asset/assetdolphindvd.c
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2026 Dominic Masters
+ *
+ * This software is released under the MIT License.
+ * https://opensource.org/licenses/MIT
+ */
+
+#include "assetdolphindvd.h"
+#include "asset/asset.h"
+#include "util/string.h"
+#include
+#include
+#include
+
+#define DUSK_DVD_ALIGN 32u
+#define DUSK_DVD_ALIGN_UP(n) \
+ (((u32)(n) + DUSK_DVD_ALIGN - 1u) & ~(DUSK_DVD_ALIGN - 1u))
+
+static u32 dvdBe32(const u8 *p) {
+ return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3];
+}
+
+static void *dvdRead(s64 offset, u32 size) {
+ u32 padded = DUSK_DVD_ALIGN_UP(size);
+ void *buf = memalign(DUSK_DVD_ALIGN, padded);
+ if(!buf) return NULL;
+ DCInvalidateRange(buf, padded);
+ dvdcmdblk block;
+ if(DVD_ReadPrio(&block, buf, padded, offset, 0) <= 0) {
+ free(buf);
+ return NULL;
+ }
+ return buf;
+}
+
+errorret_t assetInitDolphin(void) {
+ DVD_Init();
+ DVD_Mount();
+
+ // Read disc header to find FST location
+ u8 *hdr = (u8 *)dvdRead(0, 0x440);
+ if(!hdr) errorThrow("Failed to read DVD disc header.");
+ u32 fstOff = dvdBe32(hdr + 0x424);
+ u32 fstSize = dvdBe32(hdr + 0x428);
+ free(hdr);
+
+ // Read the FST
+ u8 *fst = (u8 *)dvdRead((s64)fstOff, fstSize);
+ if(!fst) errorThrow("Failed to read DVD FST.");
+
+ // Root entry (index 0) bytes 8-11 = total entry count
+ u32 numEntries = dvdBe32(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 = dvdBe32(e + 4);
+ fileLen = dvdBe32(e + 8);
+ break;
+ }
+ }
+ free(fst);
+
+ if(!fileOff) errorThrow("Failed to find asset file on DVD.");
+
+ u8 *data = (u8 *)dvdRead((s64)fileOff, fileLen);
+ if(!data) errorThrow("Failed to read asset file from DVD.");
+
+ zip_error_t zerr;
+ zip_source_t *src = zip_source_buffer_create(data, fileLen, 1, &zerr);
+ if(!src) {
+ free(data);
+ errorThrow("Failed to create zip source from DVD buffer.");
+ }
+
+ ASSET.zip = zip_open_from_source(src, ZIP_RDONLY, &zerr);
+ if(!ASSET.zip) {
+ zip_source_free(src);
+ errorThrow("Failed to open asset zip from DVD.");
+ }
+
+ errorOk();
+}
+
+errorret_t assetDisposeDolphin(void) {
+ errorOk();
+}
diff --git a/src/duskdolphin/asset/assetdolphindvd.h b/src/duskdolphin/asset/assetdolphindvd.h
new file mode 100644
index 00000000..75b0fbaa
--- /dev/null
+++ b/src/duskdolphin/asset/assetdolphindvd.h
@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) 2026 Dominic Masters
+ *
+ * This software is released under the MIT License.
+ * https://opensource.org/licenses/MIT
+ */
+
+#pragma once
+#include "error/error.h"
+
+typedef struct {
+ uint8_t nothing;
+} assetdolphin_t;
+
+errorret_t assetInitDolphin(void);
+errorret_t assetDisposeDolphin(void);
diff --git a/src/duskdolphin/asset/assetdolphin.c b/src/duskdolphin/asset/assetdolphinfat.c
similarity index 71%
rename from src/duskdolphin/asset/assetdolphin.c
rename to src/duskdolphin/asset/assetdolphinfat.c
index f441ece3..5d64172c 100644
--- a/src/duskdolphin/asset/assetdolphin.c
+++ b/src/duskdolphin/asset/assetdolphinfat.c
@@ -1,10 +1,11 @@
/**
* Copyright (c) 2026 Dominic Masters
- *
+ *
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
+#include "assetdolphinfat.h"
#include "asset/asset.h"
#include "util/string.h"
#include
@@ -17,27 +18,21 @@
#include
errorret_t assetInitDolphin(void) {
- // Init FAT driver.
if(!fatInitDefault()) errorThrow("Failed to initialize FAT filesystem.");
char_t **dolphinSearchPath = (char_t **)ASSET_DOLPHIN_PATHS;
char_t foundPath[ASSET_FILE_PATH_MAX];
foundPath[0] = '\0';
do {
- // Try open dir
DIR *pdir = opendir(*dolphinSearchPath);
if(pdir == NULL) continue;
- // Scan if file is present
while(true) {
struct dirent* pent = readdir(pdir);
if(pent == NULL) break;
- if(stringCompareInsensitive(pent->d_name, ASSET_FILE_NAME) != 0) {
- continue;
- }
+ if(stringCompareInsensitive(pent->d_name, ASSET_FILE_NAME) != 0) continue;
- // Copy out filename
snprintf(
foundPath,
ASSET_FILE_PATH_MAX,
@@ -47,27 +42,19 @@ errorret_t assetInitDolphin(void) {
);
break;
}
-
- // Close dir.
- closedir(pdir);
- // Did we find the file here?
+ closedir(pdir);
if(foundPath[0] != '\0') break;
} while(*(++dolphinSearchPath) != NULL);
- // Did we find the asset file?
- if(foundPath[0] == '\0') {
- errorThrow("Failed to find asset file on FAT filesystem.");
- }
+ if(foundPath[0] == '\0') errorThrow("Failed to find asset file on FAT filesystem.");
ASSET.zip = zip_open(foundPath, ZIP_RDONLY, NULL);
- if(ASSET.zip == NULL) {
- errorThrow("Failed to open asset file on FAT filesystem.");
- }
+ if(ASSET.zip == NULL) errorThrow("Failed to open asset file on FAT filesystem.");
+
errorOk();
}
-errorret_t assetDisposeDolphin() {
- // Nothing doing.
+errorret_t assetDisposeDolphin(void) {
errorOk();
-}
\ No newline at end of file
+}
diff --git a/src/duskdolphin/asset/assetdolphin.h b/src/duskdolphin/asset/assetdolphinfat.h
similarity index 62%
rename from src/duskdolphin/asset/assetdolphin.h
rename to src/duskdolphin/asset/assetdolphinfat.h
index f55dc81f..6b418b47 100644
--- a/src/duskdolphin/asset/assetdolphin.h
+++ b/src/duskdolphin/asset/assetdolphinfat.h
@@ -1,13 +1,12 @@
/**
* Copyright (c) 2026 Dominic Masters
- *
+ *
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
-#include "asset/assetfile.h"
static const char_t *ASSET_DOLPHIN_PATHS[] = {
"/",
@@ -34,16 +33,5 @@ typedef struct {
uint8_t nothing;
} assetdolphin_t;
-/**
- * Initializes the Dolphin asset system.
- *
- * @return An error code indicating success or failure.
- */
errorret_t assetInitDolphin(void);
-
-/**
- * Disposes of the Dolphin asset system, freeing any allocated resources.
- *
- * @return An error code indicating success or failure.
- */
-errorret_t assetDisposeDolphin(void);
\ No newline at end of file
+errorret_t assetDisposeDolphin(void);
diff --git a/src/duskdolphin/asset/assetplatform.h b/src/duskdolphin/asset/assetplatform.h
index a49bd838..d728ebc9 100644
--- a/src/duskdolphin/asset/assetplatform.h
+++ b/src/duskdolphin/asset/assetplatform.h
@@ -1,14 +1,19 @@
/**
* Copyright (c) 2026 Dominic Masters
- *
+ *
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
-#include "assetdolphin.h"
+
+#ifdef DUSK_DOLPHIN_BUILD_ISO
+ #include "assetdolphindvd.h"
+#else
+ #include "assetdolphinfat.h"
+#endif
#define assetInitPlatform assetInitDolphin
#define assetDisposePlatform assetDisposeDolphin
-typedef assetdolphin_t assetplatform_t;
\ No newline at end of file
+typedef assetdolphin_t assetplatform_t;
diff --git a/tools/makedolphiniso.py b/tools/makedolphiniso.py
new file mode 100644
index 00000000..c7ae7258
--- /dev/null
+++ b/tools/makedolphiniso.py
@@ -0,0 +1,198 @@
+#!/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()