From eef3e6fc3033a933db1061e900d2476b899eda36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Engin=20Okan=20=C3=87etin?= <65574436+MysteriousAeon@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:45:24 +0300 Subject: [PATCH] Add Linux launcher UI --- launcher/recomp-ui/launcher_main.cpp | 202 +++++++++++++++++++++++++-- tools/build-linux.sh | 51 +++++-- tools/test_appimage_layout.sh | 2 + 3 files changed, 231 insertions(+), 24 deletions(-) diff --git a/launcher/recomp-ui/launcher_main.cpp b/launcher/recomp-ui/launcher_main.cpp index 83bb525..5f2cf3c 100644 --- a/launcher/recomp-ui/launcher_main.cpp +++ b/launcher/recomp-ui/launcher_main.cpp @@ -2,7 +2,13 @@ #include "launcher_profile.h" #include "sha1.h" +#ifdef _WIN32 #include +#else +#include +#include +extern char **environ; +#endif #include #include @@ -354,10 +360,25 @@ void copy_text(char (&target)[N], const char* source) { } std::filesystem::path mod_settings_path() { +#ifdef _WIN32 if (const char* appdata = std::getenv("APPDATA")) { return std::filesystem::path(appdata) / "MetroidPrimeHuntersRecomp" / "mods.ini"; } +#else + if (const char* xdg = std::getenv("XDG_CONFIG_HOME")) { + if (xdg[0]) { + return std::filesystem::path(xdg) / + "MetroidPrimeHuntersRecomp" / "mods.ini"; + } + } + if (const char* home = std::getenv("HOME")) { + if (home[0]) { + return std::filesystem::path(home) / ".config" / + "MetroidPrimeHuntersRecomp" / "mods.ini"; + } + } +#endif return std::filesystem::temp_directory_path() / "MetroidPrimeHuntersRecomp-mods.ini"; } @@ -506,12 +527,22 @@ bool save_mod_state(ModState& state) { return false; } } +#ifdef _WIN32 if (!MoveFileExW(temporary.c_str(), state.settings_path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { state.last_error = "Could not replace launcher mod settings."; std::filesystem::remove(temporary, error); return false; } +#else + std::filesystem::rename(temporary, state.settings_path, error); + if (error) { + state.last_error = "Could not replace launcher mod settings: " + + error.message(); + std::filesystem::remove(temporary, error); + return false; + } +#endif state.last_error.clear(); return true; } @@ -1018,6 +1049,8 @@ int nds_persist_setup(void* context, const char* rom_path, return save_mod_state(*state) ? 0 : 1; } +#ifdef _WIN32 + std::wstring widen(const char* source) { if (!source || !source[0]) return {}; const int count = MultiByteToWideChar( @@ -1065,13 +1098,15 @@ void append_binding_args(std::wstring& command, const ModState& mods) { for (const BindingOption& option : kBindingOptions) { std::wstring flag = L"--mph-bind-"; flag += widen(option.id); - append_arg(command, flag.c_str(), widen((mods.*(option.member)).c_str())); + append_arg(command, flag.c_str(), + widen((mods.*(option.member)).c_str())); } for (const BindingOption& option : kPadBindingOptions) { // Row id "pad-" maps to --mph-pad-bind-. std::wstring flag = L"--mph-pad-bind-"; flag += widen(option.id + std::strlen("pad-")); - append_arg(command, flag.c_str(), widen((mods.*(option.member)).c_str())); + append_arg(command, flag.c_str(), + widen((mods.*(option.member)).c_str())); } } @@ -1127,21 +1162,61 @@ void append_live_overlay_dev_args(std::wstring& command, append_arg(command, L"--live-overlay-cache", cache.wstring()); } +#else + +void append_arg(std::vector& args, const std::string& name, + const std::string& value) { + args.push_back(name); + args.push_back(value); +} + +void append_binding_args(std::vector& args, + const ModState& mods) { + for (const BindingOption& option : kBindingOptions) { + append_arg(args, std::string("--mph-bind-") + option.id, + mods.*(option.member)); + } + for (const BindingOption& option : kPadBindingOptions) { + append_arg(args, + std::string("--mph-pad-bind-") + + (option.id + std::strlen("pad-")), + mods.*(option.member)); + } +} + +#endif + bool launch_runner(const std::filesystem::path& game_dir, const char* rom, int display_layout, bool adaptive, const ModState& mods, int supersampling, int antialiasing) { - const std::wstring rom_wide = widen(rom); - if (rom_wide.empty()) return false; + if (!rom || !rom[0]) return false; + + std::filesystem::path data_dir = game_dir; +#ifndef _WIN32 + if (const char* env = std::getenv("MPH_RECOMP_DATA_DIR"); + env && env[0]) + data_dir = std::filesystem::absolute(env); +#endif +#ifdef _WIN32 const std::filesystem::path runner = game_dir / "nds_runner.exe"; +#else + const std::filesystem::path runner = game_dir / "nds_runner"; +#endif const std::filesystem::path config = game_dir / "game.toml"; + if (!std::filesystem::is_regular_file(runner) || !std::filesystem::is_regular_file(config)) { +#ifdef _WIN32 MessageBoxW(nullptr, L"The release is incomplete: nds_runner.exe or game.toml is " L"missing. Re-extract the full release ZIP.", L"Metroid Prime Hunters Recomp", MB_OK | MB_ICONERROR); +#else + std::fprintf(stderr, + "The release is incomplete: nds_runner or game.toml is missing.\n"); +#endif return false; } @@ -1155,7 +1230,7 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, mods.bios_path.c_str()); bool no_dumps_mode = false; if (bios.empty()) { - bios = game_dir / "bios"; + bios = data_dir / "bios"; bool conventional_dumps = true; for (const NdsDump& dump : kNdsDumps) { if (!std::filesystem::is_regular_file(bios / dump.file)) @@ -1163,14 +1238,19 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, } if (!conventional_dumps) { no_dumps_mode = true; - // The bios folder still hosts the persisted per-install identity. + // The bios folder still hosts the persisted per-install identity. std::error_code error; std::filesystem::create_directories(bios, error); } } + const std::filesystem::path firmware_state = firmware_state_path( mods.settings_path, no_dumps_mode); +#ifdef _WIN32 + const std::wstring rom_wide = widen(rom); + if (rom_wide.empty()) return false; + std::wstring command = quote(runner.wstring()) + L" " + quote(bios.wstring()) + L" --interactive --rom " + quote(rom_wide) + @@ -1212,6 +1292,7 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, // a player launching through the UI expects Nintendo WFC to work, // so the launcher turns it on and points it at Wiimmfi. L" --network on --wfc on --wfc-provider wiimmfi"; + append_arg(command, L"--firmware-state-path", firmware_state.wstring()); // beads-yjp.16: the firmware console nickname. Passed only when the // player both configured a name and left the identity feature on; @@ -1220,7 +1301,8 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, // "ndsrecomp"). Re-validated here so a hand-edited mods.ini can never // hand the runner a name it will refuse to start on. if (valid_player_name(mods.player_name)) - append_arg(command, L"--player-name", widen(mods.player_name.c_str())); + append_arg(command, L"--player-name", + widen(mods.player_name.c_str())); if (no_dumps_mode) command += L" --freebios --generated-firmware"; append_binding_args(command, mods); @@ -1240,6 +1322,92 @@ bool launch_runner(const std::filesystem::path& game_dir, const char* rom, CloseHandle(process.hThread); CloseHandle(process.hProcess); return true; + +#else + + std::vector args; + args.push_back(runner.string()); + args.push_back(bios.string()); + + args.push_back("--interactive"); + + append_arg(args, "--rom", rom); + append_arg(args, "--config", config.string()); + append_arg(args, "--screen-layout", + adaptive || mods.prime_controls || display_layout == 1 + ? "separate" : "stacked"); + append_arg(args, "--adaptive-widescreen", adaptive ? "top" : "none"); + append_arg(args, "--internal-resolution", + std::to_string(mods.hd_rendering + ? mods.internal_resolution : 1)); + append_arg(args, "--texture-upscale", + std::to_string(mods.hd_rendering + ? mods.texture_upscale : 1)); + append_arg(args, "--supersampling", std::to_string(supersampling)); + append_arg(args, "--antialiasing", std::to_string(antialiasing)); + append_arg(args, "--relative-mouse-touch", + mods.prime_controls ? "on" : "off"); + append_arg(args, "--relative-mouse-sensitivity", + std::to_string(mods.mouse_sensitivity)); + append_arg(args, "--relative-mouse-invert-y", + mods.mouse_invert_y ? "on" : "off"); + append_arg(args, "--relative-mouse-fire-key", "l"); + append_arg(args, "--mph-prime-controls", + mods.prime_controls ? "on" : "off"); + append_arg(args, "--mph-virtual-stylus-sensitivity", + std::to_string(mods.virtual_stylus_sensitivity)); + append_arg(args, "--mph-pad-aim-sensitivity", + std::to_string(mods.pad_aim_sensitivity)); + append_arg(args, "--startup-mode", "automatic"); + append_arg(args, "--boot", "direct"); + append_arg(args, "--network", "on"); + append_arg(args, "--wfc", "on"); + append_arg(args, "--wfc-provider", "wiimmfi"); + append_arg(args, "--firmware-state-path", firmware_state.string()); + + if (valid_player_name(mods.player_name)) + append_arg(args, "--player-name", mods.player_name); + + if (no_dumps_mode) { + args.push_back("--freebios"); + args.push_back("--generated-firmware"); + } + + append_binding_args(args, mods); + + std::vector argv; + argv.reserve(args.size() + 1); + for (std::string& arg : args) + argv.push_back(arg.data()); + argv.push_back(nullptr); + + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) + return false; + + const int chdir_rc = + posix_spawn_file_actions_addchdir_np(&actions, + data_dir.c_str()); + if (chdir_rc != 0) { + posix_spawn_file_actions_destroy(&actions); + std::fprintf(stderr, "posix_spawn chdir setup failed: %s\n", + std::strerror(chdir_rc)); + return false; + } + + pid_t pid = 0; + const int rc = posix_spawn( + &pid, runner.c_str(), &actions, nullptr, argv.data(), environ); + posix_spawn_file_actions_destroy(&actions); + + if (rc != 0) { + std::fprintf(stderr, "posix_spawn failed: %s\n", + std::strerror(rc)); + return false; + } + + return true; +#endif } } // namespace @@ -1272,9 +1440,17 @@ int main(int argc, char** argv) { }; const std::filesystem::path exe = std::filesystem::weakly_canonical( std::filesystem::absolute(argv[0])).parent_path(); + + std::filesystem::path data_dir = exe; +#ifndef _WIN32 + if (const char* env = std::getenv("MPH_RECOMP_DATA_DIR"); + env && env[0]) + data_dir = std::filesystem::absolute(env); +#endif + ModState mod_state{}; mod_state.settings_path = mod_settings_path(); - mod_state.default_bios_dir = exe / "bios"; + mod_state.default_bios_dir = data_dir / "bios"; load_mod_state(mod_state); RecompLauncherCModProvider mod_provider = make_mod_provider(&mod_state); copy_text(settings.bios_path, mod_state.bios_path.c_str()); @@ -1332,7 +1508,8 @@ int main(int argc, char** argv) { // next to the exe. A remembered path whose file is gone falls back too, // so a moved or deleted ROM presents the bundled default rather than a // stale selection the user cannot launch. - std::filesystem::path default_rom = exe / "Metroid Prime Hunters.nds"; + std::filesystem::path default_rom = + data_dir / "Metroid Prime Hunters.nds"; if (!mod_state.rom_path.empty()) { std::error_code rom_error; const std::filesystem::path remembered(mod_state.rom_path); @@ -1361,12 +1538,19 @@ int main(int argc, char** argv) { { const std::string typed = settings.player_name; if (!typed.empty() && !valid_player_name(typed)) { +#ifdef _WIN32 MessageBoxW(nullptr, L"The player name must be 1-10 characters: letters, digits, " L"spaces, and common punctuation. Launching with the default " L"name instead.", L"Metroid Prime Hunters Recomp", MB_OK | MB_ICONINFORMATION); +#else + std::fprintf(stderr, + "The player name must be 1-10 characters: letters, digits, " + "spaces, and common punctuation. Launching with the default " + "name instead.\n"); +#endif mod_state.player_name.clear(); } else { mod_state.player_name = typed; diff --git a/tools/build-linux.sh b/tools/build-linux.sh index c2bd0bb..a524ce7 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -1,21 +1,23 @@ #!/usr/bin/env bash # Build a Metroid Prime Hunters Recomp Linux x86_64 AppImage. # -# The current recomp-ui launcher is Windows-only, so this packages the title -# runner directly. Put a legally dumped Metroid Prime Hunters .nds and a bios/ -# folder beside the AppImage; AppRun auto-detects the ROM. +# Packages the native Linux recomp-ui launcher together with the title runner. +# Put a legally dumped Metroid Prime Hunters .nds and an optional bios/ folder +# beside the AppImage; launcher state remains outside the read-only AppImage. set -euo pipefail APP_NAME="MetroidPrimeHuntersRecomp" TITLE_TARGET="metroidprimehuntersrecomp" ROM_SHA1="90164d1ac127ee5f9815ea4ae7de798c7b5fc629" RUNNER_NAME="nds_runner" +LAUNCHER_NAME="mph-recomp-ui" VERSION="0.1.0" JOBS="$(nproc 2>/dev/null || echo 4)" DO_PACKAGE=1 REPO="$(cd "$(dirname "$0")/.." && pwd)" FRAMEWORK_ROOT="$(cd "$REPO/../ndsrecomp" && pwd)" +RECOMP_UI_ROOT="$(cd "$REPO/../recomp-ui" && pwd)" OUT="$REPO/release-linux" while [ $# -gt 0 ]; do @@ -34,6 +36,7 @@ done GAME_BUILD="$REPO/build-linux-release" RUNNER_BUILD="$FRAMEWORK_ROOT/runner/build-mph-linux-release" +LAUNCHER_BUILD="$REPO/launcher/recomp-ui/build-linux-release" TITLE_BANK_DIR="$REPO/generated/recomp" cd "$REPO" @@ -41,6 +44,10 @@ test -f "$FRAMEWORK_ROOT/recompiler/CMakeLists.txt" || { echo "ERROR: sibling ndsrecomp checkout is missing." >&2 exit 1 } +test -f "$RECOMP_UI_ROOT/recomp_ui.cmake" || { + echo "ERROR: sibling recomp-ui checkout is missing." >&2 + exit 1 +} test -f "$REPO/Metroid Prime Hunters.nds" || { echo "ERROR: verified Metroid Prime Hunters ROM is missing from the repo root." >&2 exit 1 @@ -62,14 +69,28 @@ cmake -S "$FRAMEWORK_ROOT/runner" -B "$RUNNER_BUILD" -G "Unix Makefiles" \ echo " build runner" cmake --build "$RUNNER_BUILD" -j"$JOBS" +echo " configure Linux launcher" +cmake -S "$REPO/launcher/recomp-ui" -B "$LAUNCHER_BUILD" -G "Unix Makefiles" \ + -DCMAKE_BUILD_TYPE=Release \ + -DNDSRECOMP_ROOT="$FRAMEWORK_ROOT" \ + -DRECOMP_UI_ROOT="$RECOMP_UI_ROOT" +echo " build Linux launcher" +cmake --build "$LAUNCHER_BUILD" --target "$LAUNCHER_NAME" -j"$JOBS" + if [ "$DO_PACKAGE" = "0" ]; then echo "done: $RUNNER_BUILD/$RUNNER_NAME" + echo "done: $LAUNCHER_BUILD/$LAUNCHER_NAME" exit 0 fi BIN="$RUNNER_BUILD/$RUNNER_NAME" +LAUNCHER_BIN="$LAUNCHER_BUILD/$LAUNCHER_NAME" test -f "$BIN" || { echo "ERROR: runner not built: $BIN" >&2; exit 1; } -strings "$BIN" | grep -q mph_arm9_fmv_runtime || { +test -f "$LAUNCHER_BIN" || { + echo "ERROR: launcher not built: $LAUNCHER_BIN" >&2 + exit 1 +} +grep -a -q mph_arm9_fmv_runtime "$BIN" || { echo "ERROR: runner does not contain the MPH FMV runtime bank." >&2 exit 1 } @@ -101,6 +122,8 @@ APPDIR="$WORK/AppDir" mkdir -p "$APPDIR/usr/bin/bios" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" cp "$BIN" "$APPDIR/usr/bin/$RUNNER_NAME" +cp "$LAUNCHER_BIN" "$APPDIR/usr/bin/$LAUNCHER_NAME" +cp -a "$LAUNCHER_BUILD/assets" "$APPDIR/usr/bin/assets" cp "$REPO/game.toml" "$APPDIR/usr/bin/game.toml" cp "$REPO/README.md" "$APPDIR/usr/bin/README.md" cp "$REPO/LICENSE" "$APPDIR/usr/bin/LICENSE" @@ -123,7 +146,7 @@ cat > "$APPDIR/usr/share/applications/$APP_NAME.desktop" </dev/null || true if [ ! -f "$RUNDIR/bios/README.txt" ] && [ -f "$HERE/usr/bin/bios/README.txt" ]; then cp "$HERE/usr/bin/bios/README.txt" "$RUNDIR/bios/README.txt" 2>/dev/null || true fi -ROM="" -for f in "$RUNDIR"/*.nds "$RUNDIR"/*.NDS; do - [ -e "$f" ] && ROM="$f" && break -done cd "$RUNDIR" 2>/dev/null || true if [ "$#" -eq 0 ]; then - if [ -n "$ROM" ]; then - exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --rom "$ROM" --config "$HERE/usr/bin/game.toml" --screen-layout separate --adaptive-widescreen top --startup-mode automatic - fi - exec "$HERE/usr/bin/nds_runner" "$RUNDIR/bios" --interactive --config "$HERE/usr/bin/game.toml" --screen-layout separate --adaptive-widescreen top --startup-mode automatic + export MPH_RECOMP_DATA_DIR="$RUNDIR" + exec "$HERE/usr/bin/mph-recomp-ui" fi exec "$HERE/usr/bin/nds_runner" "$@" EOF -chmod +x "$APPDIR/AppRun" "$APPDIR/usr/bin/$RUNNER_NAME" +chmod +x "$APPDIR/AppRun" \ + "$APPDIR/usr/bin/$RUNNER_NAME" \ + "$APPDIR/usr/bin/$LAUNCHER_NAME" echo "[4/4] package AppImage" -"$LINUXDEPLOY_BIN" --appimage-extract-and-run --appdir "$APPDIR" --executable "$APPDIR/usr/bin/$RUNNER_NAME" \ +"$LINUXDEPLOY_BIN" --appimage-extract-and-run --appdir "$APPDIR" \ + --executable "$APPDIR/usr/bin/$RUNNER_NAME" \ + --executable "$APPDIR/usr/bin/$LAUNCHER_NAME" \ --desktop-file "$APPDIR/usr/share/applications/$APP_NAME.desktop" \ --icon-file "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" >/dev/null diff --git a/tools/test_appimage_layout.sh b/tools/test_appimage_layout.sh index 570dea7..e56edc6 100644 --- a/tools/test_appimage_layout.sh +++ b/tools/test_appimage_layout.sh @@ -8,7 +8,9 @@ fi APPDIR="$(cd "$1" && pwd)" test -x "$APPDIR/usr/bin/nds_runner" +test -x "$APPDIR/usr/bin/mph-recomp-ui" test -f "$APPDIR/usr/bin/game.toml" +test -d "$APPDIR/usr/bin/assets" test -f "$APPDIR/usr/bin/bios/README.txt" for pattern in '*.nds' '*.NDS' '*.sav' '*.bin' '*.rom' '*.gpr' 'biosnds9.rom' 'biosnds7.rom' 'firmware.bin'; do