diff --git a/.github/workflows/wiiu.yml b/.github/workflows/wiiu.yml
new file mode 100644
index 0000000..df462a9
--- /dev/null
+++ b/.github/workflows/wiiu.yml
@@ -0,0 +1,59 @@
+name: Build Wii U WUHB
+
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - '.github/workflows/wiiu.yml'
+ - 'CMakeLists.txt'
+ - 'assets/wiiu/**'
+ - 'shadowflare/**'
+ - 'thirdparty/twl/**'
+ - 'thirdparty/tal/**'
+ - 'tools/wiiu/**'
+ pull_request:
+ paths:
+ - '.github/workflows/wiiu.yml'
+ - 'CMakeLists.txt'
+ - 'assets/wiiu/**'
+ - 'shadowflare/**'
+ - 'thirdparty/twl/**'
+ - 'thirdparty/tal/**'
+ - 'tools/wiiu/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ container:
+ image: devkitpro/devkitppc:20260203
+
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@v6
+
+ - name: Install build tools
+ run: |
+ apt-get update
+ apt-get install -y ninja-build git
+
+ - name: Build the release WUHB
+ run: sh tools/wiiu/build-wuhb.sh
+
+ - name: Verify the WUHB
+ run: test -s build/wiiu/release/OpenShadowFlare.wuhb
+
+ - name: Upload the WUHB
+ uses: actions/upload-artifact@v4
+ with:
+ name: openshadowflare-wiiu-release
+ path: build/wiiu/release/OpenShadowFlare.wuhb
+ if-no-files-found: error
diff --git a/CMakeLists.txt b/CMakeLists.txt
index fa0f2a4..1b8c8c7 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -6,7 +6,7 @@ project(
LANGUAGES C CXX
)
-if(NINTENDO_SWITCH)
+if(NINTENDO_SWITCH OR WIIU)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
endif()
@@ -39,12 +39,14 @@ set_property(
CACHE OPENSHADOWFLARE_PRESENTATION_BACKEND
PROPERTY STRINGS lgl nxfb
)
-
-add_subdirectory(thirdparty/lwl)
-add_subdirectory(thirdparty/lal)
+if(OPENSHADOWFLARE_BUILD_EXE OR BUILD_TESTING)
+ add_subdirectory(thirdparty/lwl)
+ add_subdirectory(thirdparty/lal)
+endif()
add_subdirectory(thirdparty/twl)
add_subdirectory(thirdparty/tal)
-if(OPENSHADOWFLARE_PRESENTATION_BACKEND STREQUAL "lgl" OR BUILD_TESTING)
+if((OPENSHADOWFLARE_BUILD_EXE AND
+ OPENSHADOWFLARE_PRESENTATION_BACKEND STREQUAL "lgl") OR BUILD_TESTING)
add_subdirectory(thirdparty/lgl)
endif()
diff --git a/assets/wiiu/icon.png b/assets/wiiu/icon.png
new file mode 100644
index 0000000..0fd9310
Binary files /dev/null and b/assets/wiiu/icon.png differ
diff --git a/documentation/wiiu-port.md b/documentation/wiiu-port.md
new file mode 100644
index 0000000..21371a4
--- /dev/null
+++ b/documentation/wiiu-port.md
@@ -0,0 +1,40 @@
+# Wii U port
+
+The Wii U build compiles the small C99 ShadowFlare runtime and packages it as an
+Aroma-compatible Homebrew Bundle (`.wuhb`). It does not include retail game data.
+
+## Install the toolchain
+
+Install devkitPro's Wii U toolchain and Ninja:
+
+```bash
+sudo dkp-pacman -S --needed wiiu-dev ninja
+```
+
+Set `DEVKITPRO` if devkitPro is not installed at `/opt/devkitpro`.
+
+## Build
+
+From the repository root:
+
+```bash
+sh tools/wiiu/build-wuhb.sh
+```
+
+The bundle is written to:
+
+```text
+build/wiiu/release/OpenShadowFlare.wuhb
+```
+
+## Move the game files
+
+The retail data is loaded from the SD card and is never bundled. Copy your
+ShadowFlare `System` folder and `SFlare.Cfg` to:
+
+```text
+sdcard/wiiu/OpenShadowFlare/ShadowFlare/
+ SFlare.Cfg
+ System/
+ ...
+```
diff --git a/shadowflare/CMakeLists.txt b/shadowflare/CMakeLists.txt
index 7b58221..33a10a4 100644
--- a/shadowflare/CMakeLists.txt
+++ b/shadowflare/CMakeLists.txt
@@ -234,3 +234,27 @@ if(OPENSHADOWFLARE_ENABLE_DEBUG_TOOLS)
Twl::Twl
)
endif()
+if(WIIU)
+ target_compile_definitions(
+ shadowflare_core PRIVATE
+ SF_RETAIL_ROOT_FALLBACK="/vol/external01/wiiu/OpenShadowFlare/ShadowFlare")
+
+ if(NOT COMMAND wut_create_rpx)
+ message(FATAL_ERROR
+ "wut_create_rpx is unavailable. Configure the Wii U build with "
+ "$ENV{DEVKITPRO}/portlibs/wiiu/bin/powerpc-eabi-cmake.")
+ endif()
+ wut_create_rpx(osf)
+ if(COMMAND wut_create_wuhb)
+ wut_create_wuhb(
+ osf
+ NAME "OpenShadowFlare"
+ AUTHOR "OpenShadowFlare contributors"
+ ICON "${PROJECT_SOURCE_DIR}/assets/wiiu/icon.png"
+ CONTENT "${PROJECT_SOURCE_DIR}/thirdparty/twl/backends/wiiu/content"
+ )
+ else()
+ message(WARNING
+ "wut_create_wuhb is unavailable: only osf.rpx will be produced.")
+ endif()
+endif()
diff --git a/shadowflare/assets/retail_paths.c b/shadowflare/assets/retail_paths.c
index 105e5c4..64e0a86 100644
--- a/shadowflare/assets/retail_paths.c
+++ b/shadowflare/assets/retail_paths.c
@@ -176,6 +176,11 @@ bool sf_retail_root_find(
root[0] = '\0';
if (requested_root)
return sf_retail_try_root(root, capacity, requested_root);
+#ifdef SF_RETAIL_ROOT_FALLBACK
+ /* A build may pin a data root when the platform has no meaningful executable
+ * path or working directory (e.g. a console loading from a fixed SD path). */
+ if (sf_retail_try_root(root, capacity, SF_RETAIL_ROOT_FALLBACK)) return true;
+#endif
if (sf_retail_executable_directory(
executable_directory, sizeof(executable_directory), executable_path)) {
if (sf_retail_try_root(root, capacity, executable_directory)) return true;
diff --git a/thirdparty/tal/CMakeLists.txt b/thirdparty/tal/CMakeLists.txt
index 6593504..ab6757e 100644
--- a/thirdparty/tal/CMakeLists.txt
+++ b/thirdparty/tal/CMakeLists.txt
@@ -48,6 +48,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_sources(tal PRIVATE tal_alsa.c)
find_package(ALSA REQUIRED)
target_link_libraries(tal PRIVATE ALSA::ALSA)
+elseif(WIIU)
+ target_sources(tal PRIVATE tal_wiiu.c)
else()
target_sources(tal PRIVATE tal_null.c)
endif()
diff --git a/thirdparty/tal/tal_wiiu.c b/thirdparty/tal/tal_wiiu.c
new file mode 100644
index 0000000..7180353
--- /dev/null
+++ b/thirdparty/tal/tal_wiiu.c
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2026 Michael Binder and contributors
+ *
+ * This file is part of TAL.
+ *
+ * TAL is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * TAL is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with TAL. If not, see .
+ */
+
+#include "tal_internal.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#define TAL_WIIU_BUFFER_FRAMES 8192u
+#define TAL_WIIU_LEAD_FRAMES 1024u
+#define TAL_WIIU_VOICE_PRIORITY 31u
+#define TAL_WIIU_VOLUME_FULL 0x8000u
+
+typedef struct {
+ Tal *tal;
+ AXVoice *voice;
+ int16_t *buffer;
+ uint32_t buffer_frames;
+ uint32_t write_offset;
+ OSMutex mutex;
+ bool mutex_ready;
+ bool ax_owned;
+ bool callback_ready;
+ bool running;
+} TalWiiU;
+
+static TalWiiU *tal_wiiu_active;
+
+static size_t tal_wiiu_header_size(void) {
+ return (sizeof(TalWiiU) + 63u) & ~(size_t) 63u;
+}
+
+size_t tal_backend_memory_alignment(void) {
+ return 64u;
+}
+
+size_t tal_backend_memory_required(const TalConfig *config) {
+ (void) config;
+ return tal_wiiu_header_size() +
+ (size_t) TAL_WIIU_BUFFER_FRAMES * sizeof(int16_t);
+}
+
+void tal_backend_lock(Tal *tal) {
+ TalWiiU *wiiu = tal ? (TalWiiU *) tal->backend : NULL;
+ if (wiiu && wiiu->mutex_ready) OSLockMutex(&wiiu->mutex);
+}
+
+void tal_backend_unlock(Tal *tal) {
+ TalWiiU *wiiu = tal ? (TalWiiU *) tal->backend : NULL;
+ if (wiiu && wiiu->mutex_ready) OSUnlockMutex(&wiiu->mutex);
+}
+
+static void tal_wiiu_fill(TalWiiU *wiiu, uint32_t count) {
+ const uint32_t block_max = wiiu->tal->config.mix_block_frames;
+ while (count > 0u) {
+ uint32_t chunk = count;
+ const uint32_t until_wrap = wiiu->buffer_frames - wiiu->write_offset;
+ if (chunk > until_wrap) chunk = until_wrap;
+ if (chunk > block_max) chunk = block_max;
+ if (tal_internal_render(
+ wiiu->tal, wiiu->buffer + wiiu->write_offset, chunk) !=
+ TAL_RESULT_OK) {
+ tal_internal_zero(
+ wiiu->buffer + wiiu->write_offset, (size_t) chunk * sizeof(int16_t));
+ }
+ DCFlushRange(
+ wiiu->buffer + wiiu->write_offset,
+ (uint32_t) ((size_t) chunk * sizeof(int16_t)));
+ wiiu->write_offset += chunk;
+ if (wiiu->write_offset >= wiiu->buffer_frames)
+ wiiu->write_offset -= wiiu->buffer_frames;
+ count -= chunk;
+ }
+}
+
+static void tal_wiiu_frame_callback(void) {
+ TalWiiU *wiiu = tal_wiiu_active;
+ if (!wiiu || !wiiu->mutex_ready) return;
+ OSLockMutex(&wiiu->mutex);
+ if (wiiu->running && wiiu->voice) {
+ const uint32_t play =
+ AXGetVoiceCurrentOffsetEx(wiiu->voice, wiiu->buffer);
+ if (play < wiiu->buffer_frames) {
+ uint32_t target = play + TAL_WIIU_LEAD_FRAMES;
+ uint32_t count;
+ if (target >= wiiu->buffer_frames) target -= wiiu->buffer_frames;
+ count = (target + wiiu->buffer_frames - wiiu->write_offset) %
+ wiiu->buffer_frames;
+ if (count > 0u) tal_wiiu_fill(wiiu, count);
+ }
+ }
+ OSUnlockMutex(&wiiu->mutex);
+}
+
+static bool tal_wiiu_start_voice(TalWiiU *wiiu, const TalConfig *config) {
+ AXVoiceOffsets offsets;
+ AXVoiceVeData volume;
+ AXVoiceDeviceMixData mix[6];
+ wiiu->voice = AXAcquireVoice(TAL_WIIU_VOICE_PRIORITY, NULL, NULL);
+ if (!wiiu->voice) return false;
+
+ AXVoiceBegin(wiiu->voice);
+ AXSetVoiceType(wiiu->voice, 0);
+
+ tal_internal_zero(&volume, sizeof(volume));
+ volume.volume = TAL_WIIU_VOLUME_FULL;
+ AXSetVoiceVe(wiiu->voice, &volume);
+
+ tal_internal_zero(mix, sizeof(mix));
+ mix[0].bus[0].volume = TAL_WIIU_VOLUME_FULL;
+ mix[1].bus[0].volume = TAL_WIIU_VOLUME_FULL;
+ AXSetVoiceDeviceMix(wiiu->voice, AX_DEVICE_TYPE_TV, 0, mix);
+ AXSetVoiceDeviceMix(wiiu->voice, AX_DEVICE_TYPE_DRC, 0, mix);
+
+ AXSetVoiceSrcType(wiiu->voice, AX_VOICE_SRC_TYPE_LINEAR);
+ AXSetVoiceSrcRatio(
+ wiiu->voice,
+ (float) config->sample_rate / (float) AXGetInputSamplesPerSec());
+
+ tal_internal_zero(&offsets, sizeof(offsets));
+ offsets.dataType = AX_VOICE_FORMAT_LPCM16;
+ offsets.loopingEnabled = AX_VOICE_LOOP_ENABLED;
+ offsets.loopOffset = 0u;
+ offsets.endOffset = wiiu->buffer_frames - 1u;
+ offsets.currentOffset = 0u;
+ offsets.data = wiiu->buffer;
+ AXSetVoiceOffsets(wiiu->voice, &offsets);
+
+ AXSetVoiceState(wiiu->voice, AX_VOICE_STATE_PLAYING);
+ AXVoiceEnd(wiiu->voice);
+ return true;
+}
+
+TalResult tal_backend_init(
+ Tal *tal, void *memory, size_t memory_size, const TalConfig *config) {
+ TalWiiU *wiiu;
+ if (!tal || !memory || !config ||
+ memory_size < tal_backend_memory_required(config)) {
+ return TAL_RESULT_INVALID_ARGUMENT;
+ }
+
+ if (config->channels != 1u) {
+ return TAL_RESULT_BACKEND_UNAVAILABLE;
+ }
+
+ wiiu = (TalWiiU *) memory;
+ wiiu->tal = tal;
+ wiiu->buffer = (int16_t *) ((uint8_t *) memory + tal_wiiu_header_size());
+ wiiu->buffer_frames = TAL_WIIU_BUFFER_FRAMES;
+ wiiu->write_offset = 0u;
+ OSInitMutex(&wiiu->mutex);
+ wiiu->mutex_ready = true;
+
+ DCFlushRange(
+ wiiu->buffer,
+ (uint32_t) ((size_t) wiiu->buffer_frames * sizeof(int16_t)));
+
+ if (!AXIsInit()) {
+ AXInitParams params;
+ tal_internal_zero(¶ms, sizeof(params));
+ params.renderer = AX_INIT_RENDERER_48KHZ;
+ params.pipeline = AX_INIT_PIPELINE_SINGLE;
+ AXInitWithParams(¶ms);
+ wiiu->ax_owned = true;
+ }
+ if (!AXIsInit()) {
+ return TAL_RESULT_BACKEND_UNAVAILABLE;
+ }
+
+ if (!tal_wiiu_start_voice(wiiu, config)) {
+ tal_backend_shutdown(tal);
+ return TAL_RESULT_BACKEND_FAILURE;
+ }
+
+ tal_wiiu_active = wiiu;
+ wiiu->running = true;
+ if (AXRegisterAppFrameCallback(tal_wiiu_frame_callback) == 0)
+ wiiu->callback_ready = true;
+ return TAL_RESULT_OK;
+}
+
+void tal_backend_shutdown(Tal *tal) {
+ TalWiiU *wiiu = tal ? (TalWiiU *) tal->backend : NULL;
+ if (!wiiu) return;
+ if (wiiu->mutex_ready) {
+ OSLockMutex(&wiiu->mutex);
+ wiiu->running = false;
+ OSUnlockMutex(&wiiu->mutex);
+ }
+ if (wiiu->callback_ready) {
+ AXDeregisterAppFrameCallback(tal_wiiu_frame_callback);
+ wiiu->callback_ready = false;
+ }
+ if (wiiu->voice) {
+ AXVoiceBegin(wiiu->voice);
+ AXSetVoiceState(wiiu->voice, AX_VOICE_STATE_STOPPED);
+ AXVoiceEnd(wiiu->voice);
+ AXFreeVoice(wiiu->voice);
+ wiiu->voice = NULL;
+ }
+ if (wiiu->ax_owned) {
+ AXQuit();
+ wiiu->ax_owned = false;
+ }
+ tal_wiiu_active = NULL;
+}
+
+TalResult tal_backend_update(Tal *tal) {
+ (void) tal;
+ return TAL_RESULT_OK;
+}
diff --git a/thirdparty/twl/CMakeLists.txt b/thirdparty/twl/CMakeLists.txt
index 5e4388a..cea723f 100644
--- a/thirdparty/twl/CMakeLists.txt
+++ b/thirdparty/twl/CMakeLists.txt
@@ -74,6 +74,14 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
find_package(X11 REQUIRED)
find_package(OpenGL REQUIRED)
target_link_libraries(twl PRIVATE X11::X11 OpenGL::GL)
+elseif(WIIU)
+ target_sources(
+ twl
+ PRIVATE
+ backends/wiiu/window.c
+ backends/wiiu/input.c
+ backends/wiiu/presentation.c
+ )
else()
target_sources(twl PRIVATE backends/null/backend.c)
endif()
diff --git a/thirdparty/twl/backends/wiiu/backend.h b/thirdparty/twl/backends/wiiu/backend.h
new file mode 100644
index 0000000..bb33b4e
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/backend.h
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2026 Michael Binder and contributors
+ *
+ * This file is part of TWL.
+ *
+ * TWL is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * TWL is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with TWL. If not, see .
+ */
+
+#ifndef TWL_WIIU_BACKEND_H
+#define TWL_WIIU_BACKEND_H
+
+#include "twl_internal.h"
+
+#include
+#include
+#include
+#include
+
+/* Compiled GFD shader bundled with the .wuhb. See shaders/README.md for how it
+ * is produced from shaders/texture_blit.{vsh,psh}. */
+#ifndef TWL_WIIU_SHADER_PATH
+#define TWL_WIIU_SHADER_PATH "/vol/content/texture_blit.gsh"
+#endif
+
+typedef struct {
+ WHBGfxShaderGroup shader;
+ GX2RBuffer tv_position_buffer;
+ GX2RBuffer drc_position_buffer;
+ GX2RBuffer texcoord_buffer;
+ GX2Texture texture;
+ GX2Sampler sampler;
+ uint32_t frame_width;
+ uint32_t frame_height;
+ int32_t pointer_x;
+ int32_t pointer_y;
+ bool touching;
+ bool proc_ready;
+ bool gfx_ready;
+ bool shader_ready;
+ bool texture_ready;
+ bool vpad_ready;
+ bool quit_pushed;
+} TwlWiiU;
+
+#endif
diff --git a/thirdparty/twl/backends/wiiu/content/README.md b/thirdparty/twl/backends/wiiu/content/README.md
new file mode 100644
index 0000000..d8e47c5
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/content/README.md
@@ -0,0 +1,8 @@
+# Wii U bundle content
+
+Files in this directory are bundled into `OpenShadowFlare.wuhb` and mounted at
+`/vol/content` on the console.
+
+The Wii U TWL backend loads `texture_blit.gsh` from here at startup. It is the
+committed compiled output of `../shaders/texture_blit.{vsh,psh}`; see
+`../shaders/README.md` to regenerate it.
diff --git a/thirdparty/twl/backends/wiiu/content/texture_blit.gsh b/thirdparty/twl/backends/wiiu/content/texture_blit.gsh
new file mode 100644
index 0000000..6c20ce8
Binary files /dev/null and b/thirdparty/twl/backends/wiiu/content/texture_blit.gsh differ
diff --git a/thirdparty/twl/backends/wiiu/input.c b/thirdparty/twl/backends/wiiu/input.c
new file mode 100644
index 0000000..7176bbc
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/input.c
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2026 Michael Binder and contributors
+ *
+ * This file is part of TWL.
+ *
+ * TWL is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * TWL is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ */
+
+/*
+ * Wii U GamePad (VPAD) input. The touchscreen drives the game pointer; the
+ * buttons map to the key events the runtime already understands, so no game
+ * code changes are needed. WHBProcIsRunning also services ProcUI and reports
+ * when the user leaves via the HOME menu.
+ */
+
+#include "backend.h"
+
+#include
+#include
+
+#include
+
+/* The GamePad's calibrated touch resolution. The game frame is stretched to
+ * fill this screen, so touch coordinates scale linearly to frame coordinates. */
+#define TWL_WIIU_TP_WIDTH 854
+#define TWL_WIIU_TP_HEIGHT 480
+
+static const struct {
+ uint32_t mask;
+ TwlKey key;
+} twl_wiiu_button_keys[] = {
+ {VPAD_BUTTON_UP, TWL_KEY_UP},
+ {VPAD_BUTTON_DOWN, TWL_KEY_DOWN},
+ {VPAD_BUTTON_LEFT, TWL_KEY_LEFT},
+ {VPAD_BUTTON_RIGHT, TWL_KEY_RIGHT},
+ {VPAD_BUTTON_A, TWL_KEY_RETURN},
+ {VPAD_BUTTON_PLUS, TWL_KEY_RETURN},
+ {VPAD_BUTTON_B, TWL_KEY_ESCAPE},
+ {VPAD_BUTTON_MINUS, TWL_KEY_ESCAPE},
+ {VPAD_BUTTON_X, TWL_KEY_I},
+ {VPAD_BUTTON_R, TWL_KEY_R},
+};
+
+static int32_t twl_wiiu_clamp(int32_t value, int32_t low, int32_t high) {
+ if (value < low) return low;
+ if (value > high) return high;
+ return value;
+}
+
+static void twl_wiiu_push(Twl *twl, TwlEvent *event) {
+ event->timestamp_us = twl_backend_time_microseconds(twl);
+ twl_internal_push_event(twl, event);
+}
+
+static void twl_wiiu_push_key(Twl *twl, TwlEventType type, TwlKey key) {
+ TwlEvent event;
+ twl_internal_zero(&event, sizeof(event));
+ event.type = type;
+ event.key = key;
+ twl_wiiu_push(twl, &event);
+}
+
+static void twl_wiiu_push_pointer(
+ Twl *twl, TwlEventType type, int32_t x, int32_t y) {
+ TwlEvent event;
+ twl_internal_zero(&event, sizeof(event));
+ event.type = type;
+ event.x = x;
+ event.y = y;
+ event.button = 1u;
+ twl_wiiu_push(twl, &event);
+}
+
+/* There is no keyboard, so ZL fills the fixed name "Player" one code point at a
+ * time, matching the TWL_EVENT_TEXT contract. */
+static void twl_wiiu_push_text(Twl *twl, const char *text) {
+ const char *cursor;
+ for (cursor = text; *cursor; ++cursor) {
+ TwlEvent event;
+ twl_internal_zero(&event, sizeof(event));
+ event.type = TWL_EVENT_TEXT;
+ event.codepoint = (uint32_t) (unsigned char) *cursor;
+ twl_wiiu_push(twl, &event);
+ }
+}
+
+static void twl_wiiu_read_touch(
+ Twl *twl, TwlWiiU *wiiu, VPADStatus *status) {
+ VPADTouchData touch;
+ memset(&touch, 0, sizeof(touch));
+ VPADGetTPCalibratedPointEx(
+ VPAD_CHAN_0, VPAD_TP_854X480, &touch, &status->tpNormal);
+ if (touch.touched) {
+ const int32_t frame_w = (int32_t) wiiu->frame_width;
+ const int32_t frame_h = (int32_t) wiiu->frame_height;
+ int32_t content_w = TWL_WIIU_TP_WIDTH;
+ int32_t content_h = TWL_WIIU_TP_HEIGHT;
+ int32_t origin_x = 0;
+ int32_t origin_y = 0;
+ int32_t x;
+ int32_t y;
+ if ((int64_t) TWL_WIIU_TP_WIDTH * frame_h >=
+ (int64_t) TWL_WIIU_TP_HEIGHT * frame_w) {
+ content_w = TWL_WIIU_TP_HEIGHT * frame_w / frame_h;
+ origin_x = (TWL_WIIU_TP_WIDTH - content_w) / 2;
+ } else {
+ content_h = TWL_WIIU_TP_WIDTH * frame_h / frame_w;
+ origin_y = (TWL_WIIU_TP_HEIGHT - content_h) / 2;
+ }
+ x = twl_wiiu_clamp(
+ ((int32_t) touch.x - origin_x) * frame_w / content_w, 0, frame_w - 1);
+ y = twl_wiiu_clamp(
+ ((int32_t) touch.y - origin_y) * frame_h / content_h, 0, frame_h - 1);
+ if (!wiiu->touching || x != wiiu->pointer_x || y != wiiu->pointer_y) {
+ twl_wiiu_push_pointer(twl, TWL_EVENT_POINTER_MOVE, x, y);
+ }
+ if (!wiiu->touching) {
+ twl_wiiu_push_pointer(twl, TWL_EVENT_POINTER_DOWN, x, y);
+ }
+ wiiu->pointer_x = x;
+ wiiu->pointer_y = y;
+ wiiu->touching = true;
+ } else if (wiiu->touching) {
+ twl_wiiu_push_pointer(
+ twl, TWL_EVENT_POINTER_UP, wiiu->pointer_x, wiiu->pointer_y);
+ wiiu->touching = false;
+ }
+}
+
+static void twl_wiiu_read_gamepad(Twl *twl, TwlWiiU *wiiu) {
+ VPADStatus status;
+ VPADReadError error;
+ size_t index;
+ if (VPADRead(VPAD_CHAN_0, &status, 1, &error) <= 0 ||
+ error != VPAD_READ_SUCCESS) {
+ return;
+ }
+
+ twl_wiiu_read_touch(twl, wiiu, &status);
+
+ for (index = 0;
+ index < sizeof(twl_wiiu_button_keys) / sizeof(twl_wiiu_button_keys[0]);
+ ++index) {
+ const uint32_t mask = twl_wiiu_button_keys[index].mask;
+ if (status.trigger & mask) {
+ twl_wiiu_push_key(twl, TWL_EVENT_KEY_DOWN, twl_wiiu_button_keys[index].key);
+ }
+ if (status.release & mask) {
+ twl_wiiu_push_key(twl, TWL_EVENT_KEY_UP, twl_wiiu_button_keys[index].key);
+ }
+ }
+
+ if (status.trigger & VPAD_BUTTON_ZL) {
+ twl_wiiu_push_text(twl, "Player");
+ }
+}
+
+void twl_backend_pump_events(Twl *twl) {
+ TwlWiiU *wiiu = twl ? (TwlWiiU *) twl->backend : NULL;
+ if (!wiiu) {
+ return;
+ }
+ if (!WHBProcIsRunning()) {
+ if (!wiiu->quit_pushed) {
+ TwlEvent event;
+ twl_internal_zero(&event, sizeof(event));
+ event.type = TWL_EVENT_QUIT;
+ twl_wiiu_push(twl, &event);
+ wiiu->quit_pushed = true;
+ }
+ return;
+ }
+ twl_wiiu_read_gamepad(twl, wiiu);
+}
diff --git a/thirdparty/twl/backends/wiiu/presentation.c b/thirdparty/twl/backends/wiiu/presentation.c
new file mode 100644
index 0000000..51e40a1
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/presentation.c
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2026 Michael Binder and contributors
+ *
+ * This file is part of TWL.
+ *
+ * TWL is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * TWL is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ */
+
+#include "backend.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+/* Expand the RGB555 frame (red in the low 5 bits) into the RGBA8 texture. */
+static void twl_wiiu_upload(TwlWiiU *wiiu, const TwlSurface *surface) {
+ const uint16_t *src = (const uint16_t *) surface->pixels;
+ uint8_t *dst = (uint8_t *) GX2RLockSurfaceEx(&wiiu->texture.surface, 0, 0);
+ const size_t src_stride = surface->stride_bytes / sizeof(uint16_t);
+ const uint32_t dst_pitch = wiiu->texture.surface.pitch;
+ const uint32_t width = wiiu->frame_width;
+ const uint32_t height = wiiu->frame_height;
+ uint32_t x;
+ uint32_t y;
+ if (!dst) {
+ return;
+ }
+ for (y = 0; y < height; ++y) {
+ const uint16_t *src_row = src + (size_t) y * src_stride;
+ uint8_t *dst_row = dst + (size_t) y * dst_pitch * 4u;
+ for (x = 0; x < width; ++x) {
+ const uint16_t pixel = src_row[x];
+ const uint8_t r5 = (uint8_t) (pixel & 0x1fu);
+ const uint8_t g5 = (uint8_t) ((pixel >> 5) & 0x1fu);
+ const uint8_t b5 = (uint8_t) ((pixel >> 10) & 0x1fu);
+ uint8_t *texel = dst_row + (size_t) x * 4u;
+ texel[0] = (uint8_t) ((r5 << 3) | (r5 >> 2));
+ texel[1] = (uint8_t) ((g5 << 3) | (g5 >> 2));
+ texel[2] = (uint8_t) ((b5 << 3) | (b5 >> 2));
+ texel[3] = 0xffu;
+ }
+ }
+ GX2RUnlockSurfaceEx(&wiiu->texture.surface, 0, 0);
+}
+
+static void twl_wiiu_draw(TwlWiiU *wiiu, GX2RBuffer *positions) {
+ GX2SetCullOnlyControl(GX2_FRONT_FACE_CCW, FALSE, FALSE);
+ GX2SetDepthOnlyControl(FALSE, FALSE, GX2_COMPARE_FUNC_ALWAYS);
+ GX2SetFetchShader(&wiiu->shader.fetchShader);
+ GX2SetVertexShader(wiiu->shader.vertexShader);
+ GX2SetPixelShader(wiiu->shader.pixelShader);
+ GX2SetPixelTexture(&wiiu->texture, 0);
+ GX2SetPixelSampler(&wiiu->sampler, 0);
+ GX2RSetAttributeBuffer(positions, 0, positions->elemSize, 0);
+ GX2RSetAttributeBuffer(
+ &wiiu->texcoord_buffer, 1, wiiu->texcoord_buffer.elemSize, 0);
+ GX2DrawEx(GX2_PRIMITIVE_MODE_TRIANGLE_STRIP, 4, 0, 1);
+}
+
+TwlResult twl_backend_prepare_frame(Twl *twl, const TwlSurface *surface) {
+ static int logged;
+ TwlWiiU *wiiu = twl ? (TwlWiiU *) twl->backend : NULL;
+ if (!wiiu || !surface || !surface->pixels) {
+ return TWL_RESULT_INVALID_ARGUMENT;
+ }
+ if (surface->format != TWL_PIXEL_RGB555 ||
+ surface->width != wiiu->frame_width ||
+ surface->height != wiiu->frame_height) {
+ if (logged < 4) {
+ WHBLogPrintf(
+ "[twl-wiiu] prepare_frame rejected surface: fmt=%d %ux%u "
+ "(expected %ux%u RGB555)",
+ (int) surface->format, surface->width, surface->height,
+ wiiu->frame_width, wiiu->frame_height);
+ ++logged;
+ }
+ return TWL_RESULT_INVALID_ARGUMENT;
+ }
+
+ twl_wiiu_upload(wiiu, surface);
+
+ WHBGfxBeginRender();
+
+ WHBGfxBeginRenderTV();
+ WHBGfxClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ twl_wiiu_draw(wiiu, &wiiu->tv_position_buffer);
+ WHBGfxFinishRenderTV();
+
+ WHBGfxBeginRenderDRC();
+ WHBGfxClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ twl_wiiu_draw(wiiu, &wiiu->drc_position_buffer);
+ WHBGfxFinishRenderDRC();
+
+ return TWL_RESULT_OK;
+}
+
+TwlResult twl_backend_display_frame(Twl *twl) {
+ TwlWiiU *wiiu = twl ? (TwlWiiU *) twl->backend : NULL;
+ if (!wiiu) {
+ return TWL_RESULT_INVALID_ARGUMENT;
+ }
+ WHBGfxFinishRender();
+ return TWL_RESULT_OK;
+}
diff --git a/thirdparty/twl/backends/wiiu/shaders/README.md b/thirdparty/twl/backends/wiiu/shaders/README.md
new file mode 100644
index 0000000..4af4c4e
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/shaders/README.md
@@ -0,0 +1,41 @@
+# Wii U blit shaders
+
+`texture_blit.vsh` / `texture_blit.psh` are the Latte-assembly source for the
+full-screen texture blit used by the Wii U TWL backend. They are compiled once
+into `../content/texture_blit.gsh`, which is committed, bundled into the `.wuhb`,
+and loaded at runtime with `WHBGfxLoadGFDShaderGroup`.
+
+The repository build and CI **do not** run the shader compiler; they only bundle
+the committed `.gsh`. You only need the tool below if you change a shader.
+
+## Regenerating texture_blit.gsh
+
+The compiler is `latte-assembler` from decaf-emu. Only a Windows binary is
+published (), so on Linux
+run it through Wine:
+
+```sh
+wine latte-assembler.exe assemble \
+ --vsh texture_blit.vsh \
+ --psh texture_blit.psh \
+ ../content/texture_blit.gsh
+```
+
+Then commit the regenerated `../content/texture_blit.gsh`.
+
+### Notes for this compiler version
+
+The published `v0.1` binary is older than the assembly examples shipped with
+current wut, so:
+
+- attribute/uniform types use **GLSL names** (`vec4`, `vec2`, ...), not
+ `Float4`/`Float2`;
+- keep the metadata minimal (attribute vars + `SPI_VS_OUT_ID` /
+ `SPI_PS_INPUT_CNTL`). The verbose `SQ_PGM_RESOURCES_*`, `SQ_VTX_SEMANTIC_*`
+ and `VGT_*` fields from the newer examples are rejected by this build.
+
+## Status
+
+The shader assembles cleanly and renders the title screen correctly under Cemu.
+If a future screen looks vertically flipped, flip the `V` texture coordinates in
+the backend quad in `presentation.c`, not the shader.
diff --git a/thirdparty/twl/backends/wiiu/shaders/texture_blit.psh b/thirdparty/twl/backends/wiiu/shaders/texture_blit.psh
new file mode 100644
index 0000000..c5ef22f
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/shaders/texture_blit.psh
@@ -0,0 +1,18 @@
+; Wii U texture-blit pixel shader (Latte assembly for latte-assembler).
+;
+; Samples the uploaded framebuffer texture (t0) with sampler s0 using the
+; interpolated texture coordinate (semantic 0, arriving in R0) and writes it to
+; the render target. The framebuffer is uploaded as RGBA8 by the backend, so no
+; channel swizzling is needed here.
+;
+; Compiled to texture_blit.gsh with latte-assembler; see shaders/README.md.
+
+; $MODE = "UniformRegister"
+; $NUM_SPI_PS_INPUT_CNTL = 1
+; $SPI_PS_INPUT_CNTL[0].SEMANTIC = 0
+; $SPI_PS_INPUT_CNTL[0].DEFAULT_VAL = 1
+
+00 TEX: ADDR(32) CNT(1) VALID_PIX
+ 0 SAMPLE R0, R0.xy0x, t0, s0
+01 EXP_DONE: PIX0, R0.xyzw
+END_OF_PROGRAM
diff --git a/thirdparty/twl/backends/wiiu/shaders/texture_blit.vsh b/thirdparty/twl/backends/wiiu/shaders/texture_blit.vsh
new file mode 100644
index 0000000..a76cedd
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/shaders/texture_blit.vsh
@@ -0,0 +1,25 @@
+; Wii U texture-blit vertex shader (Latte assembly for latte-assembler).
+;
+; Passes a full-screen quad's clip-space position straight through and forwards
+; the texture coordinate to the pixel shader. Attribute register mapping follows
+; the convention "attribute at location N arrives in register R(N+1)".
+;
+; aPosition (location 0) -> R1 -> POS0
+; aTexCoord (location 1) -> R2 -> PARAM0 (semantic 0)
+;
+; Compiled to texture_blit.gsh with latte-assembler; see shaders/README.md.
+
+; $MODE = "UniformRegister"
+; $ATTRIB_VARS[0].name = "aPosition"
+; $ATTRIB_VARS[0].type = "vec4"
+; $ATTRIB_VARS[0].location = 0
+; $ATTRIB_VARS[1].name = "aTexCoord"
+; $ATTRIB_VARS[1].type = "vec2"
+; $ATTRIB_VARS[1].location = 1
+; $NUM_SPI_VS_OUT_ID = 1
+; $SPI_VS_OUT_ID[0].SEMANTIC_0 = 0
+
+00 CALL_FS NO_BARRIER
+01 EXP_DONE: POS0, R1.xyzw
+02 EXP_DONE: PARAM0, R2.xyzw NO_BARRIER
+END_OF_PROGRAM
diff --git a/thirdparty/twl/backends/wiiu/window.c b/thirdparty/twl/backends/wiiu/window.c
new file mode 100644
index 0000000..5eedfe8
--- /dev/null
+++ b/thirdparty/twl/backends/wiiu/window.c
@@ -0,0 +1,271 @@
+/*
+ * Copyright (C) 2026 Michael Binder and contributors
+ *
+ * This file is part of TWL.
+ *
+ * TWL is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * TWL is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ */
+
+#include "backend.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+static const float twl_wiiu_texcoords[] = {
+ 0.0f, 1.0f,
+ 1.0f, 1.0f,
+ 0.0f, 0.0f,
+ 1.0f, 0.0f,
+};
+
+static void twl_wiiu_letterbox_positions(
+ float *positions, uint32_t content_w, uint32_t content_h,
+ uint32_t screen_w, uint32_t screen_h) {
+ float scale_x = 1.0f;
+ float scale_y = 1.0f;
+ size_t vertex;
+ if (content_w > 0u && content_h > 0u && screen_w > 0u && screen_h > 0u) {
+ const uint64_t content = (uint64_t) content_w * screen_h;
+ const uint64_t screen = (uint64_t) screen_w * content_h;
+ if (screen > content) {
+ scale_x = (float) content / (float) screen;
+ } else if (content > screen) {
+ scale_y = (float) screen / (float) content;
+ }
+ }
+ {
+ const float xs[4] = {-scale_x, scale_x, -scale_x, scale_x};
+ const float ys[4] = {-scale_y, -scale_y, scale_y, scale_y};
+ for (vertex = 0u; vertex < 4u; ++vertex) {
+ positions[vertex * 4u + 0u] = xs[vertex];
+ positions[vertex * 4u + 1u] = ys[vertex];
+ positions[vertex * 4u + 2u] = 0.0f;
+ positions[vertex * 4u + 3u] = 1.0f;
+ }
+ }
+}
+
+static bool twl_wiiu_create_buffer(
+ GX2RBuffer *buffer, uint32_t elem_size, uint32_t elem_count,
+ const void *data) {
+ void *mapped;
+ memset(buffer, 0, sizeof(*buffer));
+ buffer->flags = GX2R_RESOURCE_BIND_VERTEX_BUFFER |
+ GX2R_RESOURCE_USAGE_CPU_READ |
+ GX2R_RESOURCE_USAGE_CPU_WRITE |
+ GX2R_RESOURCE_USAGE_GPU_READ;
+ buffer->elemSize = elem_size;
+ buffer->elemCount = elem_count;
+ if (!GX2RCreateBuffer(buffer)) {
+ return false;
+ }
+ mapped = GX2RLockBufferEx(buffer, 0);
+ if (!mapped) {
+ return false;
+ }
+ memcpy(mapped, data, (size_t) elem_size * elem_count);
+ GX2RUnlockBufferEx(buffer, 0);
+ return true;
+}
+
+static bool twl_wiiu_create_texture(
+ TwlWiiU *wiiu, uint32_t width, uint32_t height) {
+ GX2Texture *texture = &wiiu->texture;
+ memset(texture, 0, sizeof(*texture));
+ texture->surface.dim = GX2_SURFACE_DIM_TEXTURE_2D;
+ texture->surface.width = width;
+ texture->surface.height = height;
+ texture->surface.depth = 1;
+ texture->surface.mipLevels = 1;
+ texture->surface.format = GX2_SURFACE_FORMAT_UNORM_R8_G8_B8_A8;
+ texture->surface.aa = GX2_AA_MODE1X;
+ texture->surface.use = GX2_SURFACE_USE_TEXTURE;
+ texture->surface.tileMode = GX2_TILE_MODE_LINEAR_ALIGNED;
+ texture->surface.swizzle = 0;
+ texture->viewFirstMip = 0;
+ texture->viewNumMips = 1;
+ texture->viewFirstSlice = 0;
+ texture->viewNumSlices = 1;
+ texture->compMap = 0x00010203u;
+ if (!GX2RCreateSurface(
+ &texture->surface,
+ GX2R_RESOURCE_BIND_TEXTURE | GX2R_RESOURCE_USAGE_CPU_WRITE |
+ GX2R_RESOURCE_USAGE_GPU_READ)) {
+ return false;
+ }
+ GX2InitTextureRegs(texture);
+ return true;
+}
+
+static void twl_wiiu_teardown(TwlWiiU *wiiu) {
+ if (!wiiu) {
+ return;
+ }
+ if (wiiu->texture_ready) {
+ GX2RDestroySurfaceEx(&wiiu->texture.surface, 0);
+ wiiu->texture_ready = false;
+ }
+ if (wiiu->tv_position_buffer.flags) {
+ GX2RDestroyBufferEx(&wiiu->tv_position_buffer, 0);
+ }
+ if (wiiu->drc_position_buffer.flags) {
+ GX2RDestroyBufferEx(&wiiu->drc_position_buffer, 0);
+ }
+ if (wiiu->texcoord_buffer.flags) {
+ GX2RDestroyBufferEx(&wiiu->texcoord_buffer, 0);
+ }
+ if (wiiu->shader_ready) {
+ WHBGfxFreeShaderGroup(&wiiu->shader);
+ wiiu->shader_ready = false;
+ }
+ if (wiiu->gfx_ready) {
+ WHBGfxShutdown();
+ wiiu->gfx_ready = false;
+ }
+ if (wiiu->vpad_ready) {
+ VPADShutdown();
+ wiiu->vpad_ready = false;
+ }
+ if (wiiu->proc_ready) {
+ WHBProcShutdown();
+ wiiu->proc_ready = false;
+ }
+}
+
+size_t twl_backend_memory_alignment(void) {
+ return _Alignof(TwlWiiU);
+}
+
+size_t twl_backend_memory_required(const TwlConfig *config) {
+ (void) config;
+ return sizeof(TwlWiiU);
+}
+
+TwlResult twl_backend_init(
+ Twl *twl, void *memory, size_t memory_size, const TwlConfig *config) {
+ TwlWiiU *wiiu;
+ char *shader_file;
+ if (!twl || !memory || memory_size < sizeof(TwlWiiU) || !config) {
+ return TWL_RESULT_INVALID_ARGUMENT;
+ }
+ wiiu = (TwlWiiU *) memory;
+ wiiu->frame_width = config->width ? config->width : 640u;
+ wiiu->frame_height = config->height ? config->height : 480u;
+
+ WHBProcInit();
+ wiiu->proc_ready = true;
+ VPADInit();
+ wiiu->vpad_ready = true;
+ WHBLogCafeInit();
+ WHBLogUdpInit();
+
+ if (!WHBGfxInit()) {
+ WHBLogPrint("[twl-wiiu] ERROR: WHBGfxInit failed");
+ twl_wiiu_teardown(wiiu);
+ return TWL_RESULT_BACKEND_FAILURE;
+ }
+ wiiu->gfx_ready = true;
+
+ shader_file = WHBReadWholeFile(TWL_WIIU_SHADER_PATH, NULL);
+ if (!shader_file) {
+ WHBLogPrintf(
+ "[twl-wiiu] ERROR: could not read shader %s", TWL_WIIU_SHADER_PATH);
+ twl_wiiu_teardown(wiiu);
+ return TWL_RESULT_BACKEND_FAILURE;
+ }
+ if (!WHBGfxLoadGFDShaderGroup(&wiiu->shader, 0, shader_file)) {
+ WHBLogPrint("[twl-wiiu] ERROR: WHBGfxLoadGFDShaderGroup failed");
+ WHBFreeWholeFile(shader_file);
+ twl_wiiu_teardown(wiiu);
+ return TWL_RESULT_BACKEND_FAILURE;
+ }
+ WHBGfxInitShaderAttribute(
+ &wiiu->shader, "aPosition", 0, 0, GX2_ATTRIB_FORMAT_FLOAT_32_32_32_32);
+ WHBGfxInitShaderAttribute(
+ &wiiu->shader, "aTexCoord", 1, 0, GX2_ATTRIB_FORMAT_FLOAT_32_32);
+ WHBGfxInitFetchShader(&wiiu->shader);
+ WHBFreeWholeFile(shader_file);
+ wiiu->shader_ready = true;
+
+ {
+ const GX2ColorBuffer *tv = WHBGfxGetTVColourBuffer();
+ const GX2ColorBuffer *drc = WHBGfxGetDRCColourBuffer();
+ float tv_positions[16];
+ float drc_positions[16];
+ twl_wiiu_letterbox_positions(
+ tv_positions, wiiu->frame_width, wiiu->frame_height,
+ tv ? tv->surface.width : wiiu->frame_width,
+ tv ? tv->surface.height : wiiu->frame_height);
+ twl_wiiu_letterbox_positions(
+ drc_positions, wiiu->frame_width, wiiu->frame_height,
+ drc ? drc->surface.width : wiiu->frame_width,
+ drc ? drc->surface.height : wiiu->frame_height);
+ if (!twl_wiiu_create_buffer(
+ &wiiu->tv_position_buffer, 4u * sizeof(float), 4u, tv_positions) ||
+ !twl_wiiu_create_buffer(
+ &wiiu->drc_position_buffer, 4u * sizeof(float), 4u, drc_positions) ||
+ !twl_wiiu_create_buffer(
+ &wiiu->texcoord_buffer, 2u * sizeof(float), 4u, twl_wiiu_texcoords)) {
+ WHBLogPrint("[twl-wiiu] ERROR: attribute buffer creation failed");
+ twl_wiiu_teardown(wiiu);
+ return TWL_RESULT_INSUFFICIENT_MEMORY;
+ }
+ }
+
+ if (!twl_wiiu_create_texture(
+ wiiu, wiiu->frame_width, wiiu->frame_height)) {
+ WHBLogPrint("[twl-wiiu] ERROR: texture creation failed");
+ twl_wiiu_teardown(wiiu);
+ return TWL_RESULT_INSUFFICIENT_MEMORY;
+ }
+ wiiu->texture_ready = true;
+
+ GX2InitSampler(
+ &wiiu->sampler, GX2_TEX_CLAMP_MODE_CLAMP, GX2_TEX_XY_FILTER_MODE_LINEAR);
+
+ twl_internal_set_display_size(twl, wiiu->frame_width, wiiu->frame_height);
+ return TWL_RESULT_OK;
+}
+
+void twl_backend_shutdown(Twl *twl) {
+ TwlWiiU *wiiu = twl ? (TwlWiiU *) twl->backend : NULL;
+ if (!wiiu) {
+ return;
+ }
+ twl_wiiu_teardown(wiiu);
+ WHBLogCafeDeinit();
+ WHBLogUdpDeinit();
+}
+
+uint64_t twl_backend_time_microseconds(const Twl *twl) {
+ (void) twl;
+ return (uint64_t) OSTicksToMicroseconds(OSGetSystemTime());
+}
+
+void twl_backend_sleep_microseconds(Twl *twl, uint64_t duration) {
+ (void) twl;
+ OSSleepTicks(OSMicrosecondsToTicks(duration));
+}
diff --git a/tools/wiiu/build-wuhb.sh b/tools/wiiu/build-wuhb.sh
new file mode 100644
index 0000000..abfa5cf
--- /dev/null
+++ b/tools/wiiu/build-wuhb.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env sh
+
+set -eu
+
+ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+DEVKITPRO=${DEVKITPRO:-/opt/devkitpro}
+WIIU_CMAKE="$DEVKITPRO/portlibs/wiiu/bin/powerpc-eabi-cmake"
+BUILD_DIR="$ROOT_DIR/build/wiiu/release"
+OUTPUT_WUHB="$BUILD_DIR/OpenShadowFlare.wuhb"
+
+if [ ! -x "$WIIU_CMAKE" ]; then
+ echo "The devkitPro Wii U CMake wrapper was not found: $WIIU_CMAKE" >&2
+ echo "Install wiiu-dev and set DEVKITPRO if it is not /opt/devkitpro." >&2
+ exit 1
+fi
+
+if ! command -v ninja >/dev/null 2>&1; then
+ echo "Ninja is required to build the Wii U port." >&2
+ exit 1
+fi
+
+export DEVKITPRO
+
+"$WIIU_CMAKE" -S "$ROOT_DIR" -B "$BUILD_DIR" \
+ -G Ninja \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_TESTING=OFF \
+ -DOPENSHADOWFLARE_BUILD_EXE=OFF
+cmake --build "$BUILD_DIR" --parallel
+
+WUHB=$(find "$BUILD_DIR" -type f -name '*.wuhb' ! -path "$OUTPUT_WUHB" -print -quit)
+if [ -z "$WUHB" ]; then
+ echo "CMake did not produce a WUHB under: $BUILD_DIR" >&2
+ exit 1
+fi
+
+if [ "$WUHB" != "$OUTPUT_WUHB" ]; then
+ cp "$WUHB" "$OUTPUT_WUHB"
+fi
+echo "WUHB: $OUTPUT_WUHB"