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"