CoffeeShop - What Modern Wii-U Homebrew Development Looks Like

The Wii U came out in 2012 and was, by any honest measure, a flop. Nintendo moved on, poured everything into the Switch and stopped pretending the thing existed. The modding community never got that memo. More than a decade later they are still at it, and the tooling is genuinely better now than when the console was alive.
So i built for it. CoffeeShop is a mod manager for the Wii U. It runs directly on the console, connects to community-hosted repositories over Wi-Fi and lets you browse, download, install and manage SDCafiine mods without ever touching the SD card (video tutorial). This post is the long version of what that took: the hardware, the toolchain, what it is like to develop for a platform whose manufacturer would rather you did not, and every way it broke before it worked.
Never heard of Wii U homebrew? Doesn't matter. There is a fair bit of background worth having before any of the code makes sense.
What it actually does
Mods on the Wii U run through a thing called SDCafiine, a plugin for Aroma (the custom firmware, more on that later) that sits between a game and its own files. During gameplay it intercepts file reads and quietly redirects them to a folder on the SD card. A game tries to load a model or texture from its internal storage, SDCafiine checks the SD card first, and if there is a replacement sitting there, the game gets that one instead. The original game data is never touched. Nothing is patched, nothing is permanent, files just get swapped at runtime.
The normal way to install a mod is the kind of workflow that makes you miss app stores: download a ZIP on your PC, extract it, work out the correct folder structure, copy it to exactly the right place on the SD card, put the card back in the console and hope. For one or two mods, fine. For managing a pile of them across several games, comparing versions or switching between them, it gets old fast.
CoffeeShop does the folder-wrangling for you. It pulls metadata from structured JSON repositories hosted on plain static web servers, shows the mods as a browsable UI, handles downloads and extraction, tracks what is installed, notices when updates exist and warns you when two mods want to touch the same file. All of it on the Wii U itself, over the console's own Wi-Fi.
What it does, in list form:
- Browse and install mods from community-hosted repositories
- Per-game mod list with icons, tags and metadata
- Download queue with progress and error recovery
- Activate and deactivate mods without uninstalling them
- Conflict detection between active mods
- Update badges when a newer version shows up
- Settings tab with repo management, cache control and a log viewer
The machine
Before any of the development stuff, it helps to know what you are actually dealing with.
The Wii U has two CPUs, which already tells you something. The Espresso is an IBM PowerPC 750-derivative, three cores at 1.24 GHz, and it runs games and homebrew. The Starbuck is an ARM processor that handles the OS internals. Homebrew only ever lives on the PowerPC side.
The two processors run separate operating systems that talk over an internal interface. PowerPC runs Cafe OS, Nintendo's application environment where games and the home menu execute. ARM runs the IOSU, a security-focused microkernel that owns hardware access, boot verification and code signing. Every digital signature check happens over there in the IOSU. Cafe OS cannot touch sensitive hardware or cryptographic material directly, it has to ask the IOSU, which decides whether to allow it. The upshot: even if you find a bug in a game and run arbitrary code on the PowerPC side, you still do not own the machine. The interesting half is behind another locked door.
Storage is a mix. The main filesystem is proprietary, but the SD card slot is plain FAT32, reachable through POSIX-ish wrappers. Networking goes through the built-in Wi-Fi chip, exposed to homebrew as sockets.
The CPU is the constraint you feel first. PowerPC 750 is a late-1990s design, older than a lot of what modern C++ quietly assumes about the hardware underneath it. It is big-endian where x86 is little-endian, it has none of the SIMD extensions you would normally reach for, and you are cross-compiling from a modern machine to a platform that stopped being manufactured over a decade ago. The toolchain hides most of this, but it quietly decides what compiles without a fight and what does not.
The process model
The Wii U's process model is stricter than anything you meet on a desktop, and understanding it is the whole difference between software that behaves and software that hangs.
Cafe OS does not let you create processes. It reserves fixed memory regions for a predetermined set of process slots, each identified by a RAMPID (a slot identifier that says where in memory a process lives). The complete list: kernel, root, a single background app slot, the home menu, an error display process and a single foreground app slot. That is it. No fork(), no spawning anything on a whim. When you write a Wii U app, you are always the foreground app, RAMPID 7, forever.
At any given moment exactly one foreground app and one background app can be loaded. The foreground one gets the bulk of the memory. The background one runs on scraps, pinned to a single core. Press Home and the running app slides into the background slot while the home menu takes the front. Go back and the slots switch again. Launch a second app from the menu and whatever was in the background gets evicted, no ceremony.
This is not multitasking the way a desktop OS does it. It is a very deliberate slot-swap. Both slots keep their process data in memory at once, but only one of them is ever executing.
One more thing about RAM, because it is almost funny. The Wii U ships with 2 GB of DDR3. Cafe OS eats a full 1 GB of that, half the machine, just to exist. Games and homebrew split the other gigabyte. This was already considered a problem at launch, enough that Nintendo reportedly planned SDK updates to shrink the OS footprint. Those updates never shipped. The console got discontinued first.
On top of that, the foreground app can claim an extra 40 MB of MEM1 while it is actually in front. The moment the user switches to the home menu or a background app, that block gets yanked back automatically. Managing that handover cleanly is on you, through callbacks. Hold onto this detail, it comes back to bite me a few sections down.
What the Wii U learned from the Wii
The OS design makes a lot more sense once you know what it was reacting to.
The original Wii ran what Nintendo called IOS (the ARM-side operating system, no, not the one on the iPhone) as a set of versioned modules. Every piece of software, games on disc included, had a specific IOS version hardcoded into it. When a game booted, the ARM processor shut down and restarted with the exact IOS version that game wanted. So multiple IOS versions lived on the console at once, game discs often shipped with system update partitions to install the version they needed, and the ARM OS effectively rebooted every single time you launched a game.
It also meant there was no shared, stable OS for the PowerPC side to lean on. On the Wii, each game bundled its own copy of the system libraries, statically linked into the binary. No process isolation. No common kernel watching over applications. No shared address space management. A Wii game ran on essentially bare metal, with full hardware access and whatever runtime it happened to bring along.
The most visible symptom of this was the Home button menu. When you pressed Home during a Wii game, the overlay that popped up was not part of the console's OS. It was part of the game disc itself. Nintendo shipped a standard Home Menu implementation that developers were expected to include, but it was bundled per game. That is why some third-party Wii games had subtly different Home Menu layouts, slightly different behaviour or missing features that first-party titles had: the developers paid varying amounts of attention to that corner of the bundle.
The Wii U fixed all of it. Cafe OS is a proper shared kernel. Games link dynamically against system libraries that live in the console's memory, not in their own package. The Home Menu is an independent OS process (RAMPID 5) managed by the kernel, and it has nothing to do with whatever game is running. You press Home and Cafe OS transitions the foreground slot, the game gets no say in what that looks like. All the OS-level jobs the Wii Menu used to carry (version tracking, update management, process control) moved out of the menu app and into Cafe OS itself.
The IOSU side changed too. Instead of a versioned, per-game ARM OS, the Wii U has one unified IOSU that all software shares. It owns hardware access and security. Games cannot bypass it or bring their own copy.
The price for all this cleaner design: 1 GB of RAM permanently gone to running it. On a 2 GB console that stings. The Wii's approach was chaotic, but at least it did not cost you half your addressable memory.
Developing against the manufacturer
Nintendo has never been friendly toward homebrew. Their legal history with fan-games, chip sellers and emulator developers is well documented. They do not publish SDKs, do not document the hardware, and build a mechanism into every console specifically to stop unsigned code from running.
That mechanism is a chain of trust. The boot ROM, burned into hardware and unmodifiable, checks the digital signature of the next boot stage before it runs it. That stage checks the signature of the one after it. This carries all the way up to the application level: every piece of software on a Wii U has to be signed by Nintendo's private key, or the system refuses to run it. Nintendo's private key is, unsurprisingly, not public. So you cannot just sign your own code. The only way to run anything unsigned is to find and exploit a bug that skips the signature check entirely.
This shapes the whole scene. Every bit of infrastructure the homebrew community uses, every header file, every system call wrapper, every emulator, was built by reverse engineering. The WiiUBrew wiki is where that reverse-engineered knowledge lives. The people who wrote WUT, the main Wii U homebrew SDK, worked out what the OS calls actually do by watching behaviour, not by reading docs, because there are no docs. Cemu, the emulator that is the main development tool, was built the same way.
There is also a permanent legal fog. In most places, modifying hardware you own is legal. Running your own code on a device you own is legal. Distributing the tools that make that possible sits in a grey zone Nintendo has historically gone after hard. Aroma, the custom firmware most Wii U homebrew needs, only runs if the user already applied an exploit to their own console. The software itself ships no exploits and no copyrighted Nintendo code. Whether that is enough cover depends heavily on where you are and what exactly you did.
In practice it means building on shared infrastructure that could get targeted at any point, using tools with zero official support when something breaks, on a platform where the manufacturer is quietly rooting against you. It also means the community around it is genuinely knowledgeable and collaborative in a way official ecosystems rarely are, because nobody is being handed answers. Everyone is figuring it out together.
CoffeeShop itself contains none of Nintendo's code. It is a C++ application that runs in the Aroma environment using community-maintained SDKs, it is third-party software for a platform and it does not enable piracy. But the context matters, so it is worth saying out loud.
Aroma and the entry point
Getting homebrew to run on the Wii U at all means getting past Nintendo's code signing. For most of the console's homebrew history that meant a browser exploit on every boot, or leaning on a vulnerability in a downloaded Nintendo DS game to inject code at startup (a trick called haxchi). These worked, but they were fragile, annoying to maintain and limited: you could not easily run plugins and homebrew apps at the same time, and if anything went sideways you bootstrapped the whole thing again.
The current standard is Aroma, a custom firmware environment Maschell built up over several years. Aroma installs persistently to the SD card. After a one-time setup (which does need an exploit, but only that once), it starts on every boot by itself. The original Nintendo home menu keeps working. Official games run as normal. Aroma just sits as a layer between the OS and everything on top of it.
Technically it adds two levels of extensibility. Aroma Modules are persistent chunks of code that stay resident in memory and export functions to other components. One handles kernel-level access, one patches OS functions, one runs the plugin backend. They are always there in the background.
On top of modules sits a plugin system. Plugins load from the SD card and can intercept and modify OS behaviour at runtime, gameplay included. SDCafiine, the file-redirection plugin the whole mod thing is built on, is an Aroma plugin. So is the component that makes homebrew apps show up on the home menu at all.
For distribution, Aroma introduced the .wuhb (Wii U Homebrew Bundle) format. A .wuhb file packs the executable, app metadata (name, icon, author) and any content the app needs into one file. Drop it in the right folder on the SD card and Aroma's home menu integration shows it as a launchable app. That is how CoffeeShop ships.
For developers, Aroma means a reasonably stable API maintained by people who actually care about backwards compatibility. The alternative is aiming at raw system calls that can change between firmware versions with no warning and no changelog.
One consequence that matters a lot later: the Home button is intercepted by Aroma before it reaches any application. VPAD_BUTTON_HOME never arrives in homebrew code. This wrecks the obvious approach to exit handling, which i will get to.
The toolchain
WUT (Wii U Toolchain) is the homebrew SDK. It gives you C/C++ wrappers around the native OS system calls. Without it you would be manually resolving function addresses out of symbol tables and calling them with the right PowerPC calling convention by hand. WUT turns that into usable headers: coreinit for core OS functions, ProcUI for process management, vpad for gamepad input, nn::ac for network connections, nsysnet for sockets, sysapp for launching system applications.
To get why WUT is shaped the way it is, you need a short detour into how Wii U executables work. The native format is RPX, a modified ELF with compressed sections and Windows-style dynamic linking. Libraries use the same format with a different extension: RPL. All the system libraries (coreinit.rpl, gx2.rpl, vpad.rpl, nsysnet.rpl and dozens more) live in the console's memory, permanently loaded. When a game or app launches, the OS loader dynamically links it against those. coreinit.rpl loads first, before even the main executable, because everything else needs it for memory management and thread primitives.
This is the exact opposite of the Wii, where each game statically bundled its own copy of every library. On the Wii U the libraries are shared OS infrastructure. For homebrew that means WUT hands you stub libraries to link against at build time, and the real resolution happens at runtime on the console against whatever coreinit.rpl and friends are actually loaded. You call OSDynLoad_Acquire("gx2.rpl", &handle) and OSDynLoad_FindExport(handle, 0, "GX2Init", &fn) for a function pointer, or you use WUT's headers and let the linker deal with it.
devkitPro is the package manager that provides the compiler. devkitPPC is the specific PowerPC toolchain, a GCC cross-compiler that runs on x86_64 Linux, macOS or Windows and spits out PowerPC binaries. The C standard library is newlib, not glibc, so some standard functions you assume are always there are missing or behave differently. Dynamic linking does not exist for homebrew in the usual sense: the RPX format handles it through the OS loader, but you cannot use shared libraries you built yourself, only the system RPLs. Binary size grows with every third-party library you add, though on modern SD cards nobody cares.
The absence that hurt most is std::filesystem. Every directory operation is POSIX: opendir/readdir/stat/rename/mkdir. This is not just a convenience thing. Every recursive directory walk, every existence check, every move or delete gets written by hand. The recursive rmrf() that uninstalls a mod is about 20 lines of POSIX calls that std::filesystem::remove_all would have been one line of. std::thread is out too, threading goes through OSThread from coreinit. fopen() and the rest of stdio work fine through WUT's wrappers. For sockets, read()/write() do not work on the Wii U, you use recv()/send().
portlibs gives you the rest cross-compiled for PowerPC: SDL2, libcurl, zlib, libpng, freetype, mbedTLS and more. Same libraries as anywhere else, just built for the target.
The build system is CMake with wut.cmake pulled in. One footgun: you must call /opt/devkitpro/portlibs/wiiu/bin/powerpc-eabi-cmake, not plain cmake. devkitPro ships its own wrapper that sets the toolchain file and environment correctly. Plain cmake gives you subtly broken builds or fails to find portlibs at all, and it does not tell you which. Two output steps matter:
wut_create_rpx()makes a.rpx, the Wii U's native executable format, based on ELF (same format Linux uses) with Nintendo-specific extensions.wut_create_wuhb()packs the RPX plus a content folder (fonts, images, config files) into a.wuhb. That is the distribution format Aroma uses.
The content folder is embedded in the bundle and readable at /vol/content/ at runtime, read-only. Writable data goes straight to the SD card. The .wuhb lands at SD:/wiiu/apps/coffeeshop/coffeeshop.wuhb and Aroma's home menu picks it up automatically.
Compiler flags: -mcpu=750 -meabi -mhard-float. C++ exceptions can be enabled but cost performance. RTTI is optional. Static library link order in CMake matters, and this one is a menace: wut has to be last in the target link libraries list, or the linker throws mysterious undefined reference errors that point nowhere useful.
One more thing about running against both Cemu and real hardware: hardware-specific init (network bring-up, socket library init) is guarded behind a BUILD_HW compile flag in my case. In Cemu builds those paths are compiled out entirely. This is exactly why some bugs only showed up on hardware, the code that triggered them was not even in the emulator build. Clean separation, but the emulator and hardware binaries are genuinely not the same thing.
Starting the project
The stack was C++17, SDL2 for rendering and UI, libcurl for HTTP, nlohmann/json for parsing. The first build that did anything had SDL2 up, a window, VPAD input reading and a basic config struct. That is the whole "hello world" milestone on this platform, and it took a while.
Most of the work happened in Cemu, the Wii U emulator for Linux, macOS and Windows. Cemu loads .wuhb files directly, maps the SD card to a folder on the host and makes the loop fast: build, reload, look, repeat, no real hardware involved. It is itself a product of the community's reverse engineering. For most things, Cemu behaves like hardware. For some things it very much does not, which is why hardware testing still matters for every release, especially anything touching networking, the filesystem or the process lifecycle. All three of the exit freezes further down were hardware-only. Cemu never once reproduced them.
Getting that first build running took longer than it should have, entirely because the CMake setup for WUT has those footguns around devkitPro environment variables. Once that was sorted, iteration got quick.
The architecture fell into clear pieces early: a repository system to fetch and parse mod metadata, an image cache for thumbnails, a download queue with a background worker thread, a filesystem layer for install and activation, a conflict checker and an SDL2 UI on top surfacing all of it.
The repository format
The repo format i designed is pretty straightforward. A repo.json on any static web server lists the available games:
{
"formatVersion": 1,
"games": [
{ "id": "mario-kart-8", "meta": "https://example.com/mario-kart-8/game.json" }
]
}
Each game.json holds game metadata (name, title IDs for the different regions, icon URL) and a list of mods. Each mod entry has an ID, name, author, version, download URL (a ZIP), thumbnail, screenshots, tags, license, requirements and changelog. The formatVersion field is there so future breaking changes can be spotted and handled instead of silently exploding.
Repos can live anywhere that serves raw files: GitHub, Gitea, a random VPS. Multiple repos get merged at runtime, so a user can pull from several sources at once. The template repo ships with a validation script and a GitHub Action that checks the structure on every pull request, because trusting people to hand-write valid JSON is how you spend your evenings debugging other people's typos.
One early trap: the test repo was on Gitea. The URLs i used were the /src/branch/main/ kind, which serve the HTML page for the file, not the raw content. Gitea raw URLs are /raw/branch/main/. Every repository URL had to be fixed. Not interesting, but exactly the kind of thing that quietly eats an hour.
The process management loop
That fixed process slot model from earlier has a direct, unavoidable consequence for how every Wii U app has to be built.
Because the OS runs foreground and background transitions at the kernel level, your app cannot just spin a game loop and quit when it feels like it. It has to take part in the OS's state machine for as long as it is alive. The mechanism is ProcUI.
ProcUIProcessMessages() drives it. Called once per frame, it returns one of four states:
PROCUI_STATUS_IN_FOREGROUND → normal operation, render and update
PROCUI_STATUS_RELEASE_FOREGROUND → OS needs the foreground; free MEM1 NOW
PROCUI_STATUS_IN_BACKGROUND → suspended, running on one core, minimal work only
PROCUI_STATUS_EXITING → OS wants the app gone; clean up and call SYSLaunchMenu()
RELEASE_FOREGROUND is the one that catches people out. When the user presses Home, the OS does not just grab the foreground. It asks your app to hand it over first. Your app has to free its MEM1 resources and acknowledge the transition. If it does not answer, the OS waits. It does not time out. It just waits, forever, and so does the console.
WUT wraps all of this in WHBProcIsRunning(), which goes false when EXITING is reached. The minimal main loop is:
while (WHBProcIsRunning()) {
update();
render();
}
WHBProcShutdown();
This is not optional infrastructure. If you invent your own exit condition and break out of the loop before ProcUI has actually signalled shutdown, WHBProcShutdown() hangs. That was the root cause of the third freeze below, and it took me embarrassingly long to accept.
The other constraint: on the Wii U you never exit by returning from main(). The OS always needs to know what to launch next. Exiting always goes through SYSLaunchMenu() (or a similar sysapp call), which queues a transition request that ProcUI eventually delivers as the EXITING state. The loop winds down through the message system, not through a direct return.
Networking
Network init is explicit and manual. nn::ac::Initialize() and nn::ac::Connect() bring up Wi-Fi. socket_lib_init() starts the socket stack. At shutdown both have to be finalized, in order: socket_lib_finish() first, then nn::ac::Finalize(). Skip it and WHBProcShutdown() hangs. That was the second freeze. Sensing a theme yet.
libcurl works well on the Wii U with two caveats worth burning into memory. SSL certificate verification has to be turned off (CURLOPT_SSL_VERIFYPEER set to 0), because there is no CA bundle on the platform. This is a known limitation, not me being lazy. And always set connection timeouts and low-speed limits. Without them a stalled download hangs the worker thread until the heat death of the console.
Progress reporting uses CURLOPT_XFERINFOFUNCTION. The callback pulls double duty: it updates the progress in the download queue UI, and it checks a cancellation flag. If the flag is set, it returns 1, which tells libcurl to abort right there. This is the correct way to stop a download thread. You do not try to kill it from outside, you tell curl to stop cooperating and let the thread walk out on its own.
SDL2 on the Wii U
SDL2 via portlibs runs well. The renderer backend is OpenGL ES underneath, but that is invisible through the SDL2 abstraction. SDL2_ttf does fonts, SDL2_image does PNG and JPG, SDL2_mixer does audio.
The single most important SDL2 rule here: SDL_CreateTextureFromSurface is expensive. Never call it per frame like i did at first (yeah, i know). The pattern all through CoffeeShop is to create textures once, cache them, and only call SDL_RenderCopy in the render loop. For text, TTF_RenderUTF8_Blended makes a surface, the surface becomes a texture, and the texture stays alive as long as the text does not change.
SDL texture creation has to happen on the main thread. This matters for the image cache: a background thread pulls image data via libcurl and writes raw bytes to the SD card cache, then flips an atomic flag. The main thread sees the flag and creates the SDL texture on the next frame. Move texture creation into the background thread and you get crashes, reliably.
Semi-transparent overlays need SDL_SetRenderDrawBlendMode set to SDL_BLENDMODE_BLEND before you draw the overlay rectangle, then reset afterward. Without it the default blend mode does not composite right and the text behind the overlay bleeds through, which looks exactly as broken as it sounds.
Thumbnail handling
Thumbnails threw up two problems. First, pulling image data off the network on every start would be slow. So the image cache writes raw bytes to SD:/wiiu/apps/coffeeshop/cache/ on the first download and loads from there on every start after.
Second, aspect ratio. SDL_RenderCopy with no source rectangle scales the image to fill the whole destination rectangle, stretching anything that is not exactly the right shape into something cursed. The fix is a centre crop:
int srcH = (texture_width * target_height) / target_width;
SDL_Rect srcRect = {0, 0, texture_width, srcH};
SDL_RenderCopy(renderer, texture, &srcRect, &destRect);
This scales to the full target width and crops vertically from the top, keeping the aspect ratio while still filling the card.
Input handling
VPAD is the WUT API for the GamePad. VPADRead() fills a VPADStatus struct with button state and analog stick values. Buttons are bitmasks. As mentioned, VPAD_BUTTON_HOME never shows up, Aroma eats it, and exit has to go through SYSLaunchMenu().
Analog sticks return floats from -1.0 to 1.0. There is no automatic deadzone, so you write your own or the cursor drifts on worn hardware. There is also no automatic key repeat for held buttons, so navigation repeat is manual timer tracking. Small things, but they are the difference between a UI that feels finished and one that feels like a tech demo.
Grid navigation uses modulo arithmetic for rows and columns. Overflow navigation (press right at the end of a row and you jump to the next game, press left at the start and you go back) got added after the first round of testing, and it makes the browse view noticeably nicer than forcing a dedicated game-select button.
There was a button conflict on Y. It was mapped to both the download queue (in the browse tab) and deinstall (in the installed tab). Switching tabs while the queue was open produced confusing nonsense. The download queue toggle moved to Plus, and Y became an installed-tab action only.
The download queue
The queue runs a background worker thread. The main loop pushes download requests into a thread-safe queue. The worker picks them up, runs the curl transfer to a temp file, checks the ZIP magic bytes, extracts into the sdcafiine folder and writes a modinfo.json into the mod directory:
{
"id": "my-mod",
"version": "1.2.0",
"repo": "https://example.com/repo.json"
}
The installed scanner reads these to know what is installed, compare against current repo versions and flag updates. Mods without a modinfo.json are treated as corrupted and flagged at startup.
Deactivating a mod moves its folder from SD:/wiiu/sdcafiine/TitleID/ModID/ to SD:/wiiu/apps/coffeeshop/disabled/TitleID/ModID/. Reactivating moves it back. Rename, not copy, to keep SD card writes down. SDCafiine only reads from the sdcafiine directory, so a folder that is not there is simply inactive. No flag to track, the filesystem is the state.
The region problem
SDCafiine matches mods by Title ID. The same game has a different Title ID per region: Mario Kart 8 is 000500001010eb00 in Japan, 000500001010ec00 in the US and 000500001010ed00 in Europe. A mod installed under the European Title ID simply will not load on a US console. SDCafiine looks up the exact ID of the running game and finds nothing.
The repository format handles this by listing multiple Title IDs per game entry. When a user installs a mod for a game with more than one Title ID in the repo, CoffeeShop cannot know the console's region on its own, so it has to ask. A RegionSelectScreen pops up, the user picks their region, and the download goes to the right Title ID path. One extra tap per game, and only the first time for each game.
At startup, CacheManager::cleanupStaleZips() and cleanupCorruptMods() run before the UI shows. The first clears half-finished .zip files left behind by interrupted downloads. The second finds mod directories missing a modinfo.json and removes them. Both exist because a download process can be interrupted at any point: a crash, a dropped connection, or the user just yanking the power. On this platform you assume the worst about how your program exits, because the worst is common.
Conflict detection
Activating a mod runs a conflict check first. The ConflictChecker takes the file list of the mod going active and the file lists of everything already active, and returns which mods clash and which specific files collide. If there is a conflict, a dialog shows the affected mods and up to three example paths.
The conflict dialog was a mess early on. Text overflowed the card, buttons sat in the wrong places, and the semi-transparent overlay let text bleed through because SDL_BLENDMODE_BLEND was not set before drawing the overlay rectangle (see above, i clearly learned this lesson more than once). The dialog got rebuilt with fixed card dimensions, a defined left margin for every text element, a divider above the buttons and explicit line spacing.
Audio
SDL2_mixer handles sound effects and background music. There is a three-state music toggle: off, main theme, alternative theme. Sound effects for navigation, download start, download end, errors, mod activation and deactivation. The setting persists in config.json.
The shutdown sequence is order-sensitive: Mix_FreeChunk, Mix_FreeMusic, Mix_CloseAudio, Mix_Quit. Out of order or skipped, the audio subsystem hangs on shutdown. Another entry on this platform's long "you must finalize this explicitly and in the right order" list.
Logging and debugging
There is no convenient printf-to-terminal while the app runs on hardware. So logging is three layers, each covering a different way things can go wrong.
Early log. main() opens early.log on the SD card immediately after WHBProcInit(), before the main logger or anything else comes up. Every write is followed by fsync(). That is not paranoia, it is the only way to catch crashes that happen during init, before a buffer would ever get flushed. If the app dies during startup, early.log has every message up to the last line written before it went down.
UDP log. WHBLogUdpInit() streams log output over UDP to a udplogserver on the development machine. This is the main tool during active development in Cemu, where the network is always there. On hardware it works once Wi-Fi is up, which is not guaranteed during early startup or shutdown, which is of course exactly when the interesting bugs happen.
File logger plus in-app viewer. The main Logger writes to app.log on the SD card and keeps a rolling in-memory buffer of recent lines. The Settings tab has a built-in log viewer: hit "View log" and a scrollable overlay shows those lines, colour-coded by severity (errors red, warnings yellow). This one matters specifically for debugging on hardware with no development machine attached, because the log is readable right there on the console.
For crashes, Aroma's crash handler dumps register state. Stack traces without debug symbols are rough to read, but early.log plus the file logger plus the crash dump is usually enough to pin the problem. Every bit of the exit freeze diagnosis below was done the dumb, reliable way: add a log call before and after every cleanup step, run it, see where the output stops.
The exit problem i had
The first hardware test ran fine right up until you tried to leave. Every single exit path froze the console. Three separate bugs wearing one trench coat.
Freeze 1: the download worker
The first exit approach set m_running = false to break the main loop, then called join() on the worker thread. join() never came back, because the thread was sitting inside a libcurl network call, waiting.
Fix: the cancellation flag in the curl progress callback. Set it, the callback returns 1, libcurl aborts the transfer, the thread finishes on its own, join() completes. Same lesson as before: do not try to kill the thread from outside, tell curl to stop cooperating and let the thread leave.
Freeze 2: the network stack
Thread fixed, still froze. The elog() trace showed it hanging inside WHBProcShutdown(). Cause: nn::ac::Initialize() and socket_lib_init() were called at startup, but nn::ac::Finalize() and socket_lib_finish() were never called before shutdown. The network stack does not clean up after itself. Order: socket_lib_finish() first, then nn::ac::Finalize().
Freeze 3: ProcUI state
Both fixed, still froze. This one was subtle enough that i went looking in all the wrong places first.
WHBProcShutdown() expects to be called after WHBProcIsRunning() has gone false naturally, through the ProcUI message loop. Break out of the loop early with your own boolean and WHBProcShutdown() finds ProcUI in an in-between state, then waits for a transition that is never coming.
The actual fix was to not call WHBProcShutdown() at all. The shutdown sequence calls SYSLaunchMenu(), which hands control back to the home menu, and the OS tears the process down as part of that transition. WHBProcShutdown() never gets reached, so there is nothing left to hang. The comment in the source is blunt about it: WHBProcShutdown() omitted - hangs when loop exits via m_running=false.
The main loop condition ended up reflecting the hybrid:
while (m_running && !m_screens.empty() && WHBProcIsRunning()) {
update();
render();
}
// WHBProcShutdown() omitted - hangs when loop exits via m_running=false
SYSLaunchMenu();
It is not the textbook ProcUI pattern. It works because the OS does not demand a graceful WHBProcShutdown() when you leave through SYSLaunchMenu(). The process gets cleaned up either way.
None of the three reproduced in Cemu. The emulator's ProcUI is more forgiving about internal state at shutdown, so all three needed real hardware to even exist. This is the whole argument for hardware testing in one bug report.
The app icon issue i had as well
wut_create_wuhb() has an ICON parameter, i used it, and the icon showed up fine in the home launcher. Great.
Then the app tried to load that same icon as an SDL texture at runtime, to show it inside the app, and the file was just not there. Turns out ICON embeds the image into the WUHB bundle's metadata section, which the OS handles and does not expose as a file at /vol/content/. The content folder and the metadata section are two different places, and i had assumed they were one.
Fix: copy icon.png into meta/content/ as well. That path gets embedded into the content bundle and is readable at /vol/content/icon.png at runtime. Same file, duplicated into two spots in the bundle, because the two consumers want it in two places.
The icon also needed a centre crop. The coffee cup has whitespace around it, and without cropping it rendered smaller than it should. Same crop maths as the thumbnails.
Settings performance (because i was just stupid i guess)
The Settings tab was painfully slow. Multiple seconds of lag switching to it, and a visible hitch on every input inside it.
The cause was all me. buildSettingsItems() was being called in two places: inside handleSettingsInput() on every input event, and inside renderSettings() on every frame. And inside buildSettingsItems() was InstalledScanner::scan() walking the sdcafiine folder, dirSize() recursively measuring cache folders and statvfs() for free space. So: SD card I/O, running 60 times a second, plus again on every button press. On an SD card. On a Wii U.
Fix: cache the built items in a member variable. Rebuild only on onEnter() and after any button action that actually changes state. A m_textureCacheDirty flag separates the item data from the rendered textures: items get rebuilt rarely, textures get rebuilt from the item data when the dirty flag is set, and neither one touches the SD card in the render loop.
The second issue was in renderText(), which called TTF_RenderUTF8_Blended, SDL_CreateTextureFromSurface and SDL_DestroyTexture on every single call. With 20 visible items that is 20 texture allocations a frame. Fix: cache the text textures in the item structs inside buildTextureCache() and use only SDL_RenderCopy in the render loop. buildTextureCache() runs once when the dirty flag trips, and after that the render path is pure GPU copy. None of this was a platform limitation. It was just me doing the obvious wrong thing and paying for it.
Tests
The test setup uses Catch2 and runs as a normal x86_64 binary on the development machine. Only components with no WUT, SDL or curl dependency are testable this way, which is a smaller set than you would like but exactly the set where a silent parsing bug hurts most.
In practice that covers the ConflictChecker (7 scenarios: no conflict, single conflict, multiple conflicts, the three-file display cap, empty mod list, empty active list, both empty), RepoManager::parseGameFromJson (11 scenarios: valid input, missing required fields, invalid mod IDs, invalid download URLs, optional fields absent, a mix of valid and invalid mods) and InstalledScanner::hasUpdate for version comparison edge cases.
The goal is not line coverage for a badge. It is confidence that parsing will not crash or quietly swallow garbage when someone's community repo has a typo, and that conflict detection is right at the boundaries. CI runs through GitHub Actions on every push, using devkitPro's Docker images for the build step.
Cemu vs. real hardware
Cemu is excellent for iteration: fast builds, no SD card shuffling, debuggable on the host. It loads .wuhb files directly and maps /vol/external01/ to a host folder you pick.
It also does not behave identically to hardware, and pretending otherwise costs you a release. Network timing differs. Some WUT calls behave slightly differently. ProcUI diverges in subtle ways, as all three exit freezes cheerfully demonstrated. Crashes on hardware do not reliably show up in Cemu.
So the workflow is boring on purpose: develop in Cemu for most of it, then test on hardware for every release candidate and for anything touching network, filesystem or process lifecycle. The three things that will embarrass you are exactly the three things the emulator is most relaxed about.
Gallery



Was pouring a few months into a mod manager for a console Nintendo would rather everyone forget a reasonable use of time? Probably not, in the way a spreadsheet would score it. But the thing runs, mods install over Wi-Fi in two button presses, and the people who reverse-engineered this hardware knew it better than Nintendo's own docs ever admitted out loud. i'll take that trade.
CoffeeShop is open source under GPLv3. The latest release is on GitHub. It needs a Wii U with Aroma installed and a community-hosted repository to pull from. There is a repository template on GitHub too, if you want to host your own.
If you want to go deeper on how the Wii U homebrew ecosystem actually fits together, Maschell's series on building a homebrew environment for the Wii U and the Aroma release post are the most thorough technical writeups out there.
Comments