diff --git a/PAKS.md b/PAKS.md index d9d629d1f..8e0fadfb4 100644 --- a/PAKS.md +++ b/PAKS.md @@ -8,6 +8,23 @@ Paks are platform specific. Inside the Emus and Tools folders you will find (or Some platforms have multiple devices with unique qualities. NextUI differentiates these devices from the base platform with the `DEVICE` envar. eg. the "rg35xxplus" platform has two unique devices "cube" for the RG CubeXX, and "wide" for the RG34xx. It also supports "hdmi" for when outputting to HDMI. A pak can choose to use or ignore this envar. +# In-game pak shortcuts + +A foreground tool such as a game guide can opt into the in-game pak picker by adding `in_game_shortcut` to its `pak.json`: + + { + "name": "Game Guide", + "short_name": "GUIDE", + "type": "TOOL", + "in_game_shortcut": true + } + +The user can then select it under Settings > In-Game > Pak Shortcut and open it with X from MinArch's root in-game menu. MinArch pauses game audio and keeps the core and game resident until the pak's `launch.sh` returns. The pak receives the current game path in `NEXTUI_ROM_PATH` and its emulator tag in `NEXTUI_EMU_TAG`. + +The optional `short_name` is used for the X button hint and is limited to 12 characters. If omitted, the hint uses `name` instead. + +Only foreground tools that exit cleanly should opt in. An in-game shortcut should not launch another emulator, leave background processes running, change display modes, or power off or reboot the device. Because MinArch remains resident, the pak should also avoid opening an audio device. + # The types of emulator pak There are three basic types of emulator paks, which you chose depends on your goals and your desired level of NextUI integration. @@ -104,4 +121,4 @@ But if a binary takes more than one second to initialize you might need to just # Caveats -NextUI currently only supports the RGB565 pixel format and does not implement the OpenGL libretro APIs. It may be possible to use the stock firmware's retroarch instead of NextUI's minarch to run certain cores but that is left as an exercise for the reader. \ No newline at end of file +NextUI currently only supports the RGB565 pixel format and does not implement the OpenGL libretro APIs. It may be possible to use the stock firmware's retroarch instead of NextUI's minarch to run certain cores but that is left as an exercise for the reader. diff --git a/skeleton/BASE/README.txt b/skeleton/BASE/README.txt index 5f6b965bd..a47a2df4f 100644 --- a/skeleton/BASE/README.txt +++ b/skeleton/BASE/README.txt @@ -60,6 +60,10 @@ TRIMUI BRICK / BRICK PRO / SMART PRO S Buttons with no action of their own can be assigned a pak to launch: L3/R3 on the Brick, L4/R4 on the Brick Pro, and HOME on the Brick Pro and Smart Pro S. Assign them under Settings > Assignments. Assignments only apply in the main menu, not in-game. HOME no longer acts as a second menu button. +ALL + + Compatible guide paks can be assigned under Settings > In-Game > Pak Shortcut. Open the in-game menu and press X to launch the assigned guide, then exit the guide to return to the paused game. + ---------------------------------------- Quicksave & auto-resume diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index e1ec3ba7b..c04eebd27 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -9,6 +9,47 @@ #include "utils.h" NextUISettings settings = {0}; +// CFG_init restores values through the public setters so validation stays in one +// place. Those setters normally persist immediately; suppress that while the +// settings file itself is being read or CFG_sync would truncate it mid-load. +static bool cfg_loading = false; + +static void CFG_loadPakShortcutFile(void) +{ + const char *shared_userdata = getenv("SHARED_USERDATA_PATH"); + if (!shared_userdata || !shared_userdata[0]) + return; + + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/pakshortcut.txt", shared_userdata); + FILE *file = fopen(path, "r"); + if (!file) + return; + + char value[sizeof(settings.pakShortcut)]; + if (fgets(value, sizeof(value), file)) + { + value[strcspn(value, "\r\n")] = 0; + strncpy(settings.pakShortcut, value, sizeof(settings.pakShortcut) - 1); + settings.pakShortcut[sizeof(settings.pakShortcut) - 1] = '\0'; + } + fclose(file); +} + +static void CFG_syncPakShortcutFile(void) +{ + const char *shared_userdata = getenv("SHARED_USERDATA_PATH"); + if (!shared_userdata || !shared_userdata[0]) + return; + + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/pakshortcut.txt", shared_userdata); + FILE *file = fopen(path, "w"); + if (!file) + return; + fprintf(file, "%s\n", settings.pakShortcut); + fclose(file); +} // deprecated uint32_t THEME_COLOR1_255; @@ -66,6 +107,7 @@ void CFG_defaults(NextUISettings *cfg) .muteLeds = CFG_DEFAULT_MUTELEDS, .fnAction = {CFG_DEFAULT_FN_ACTION, CFG_DEFAULT_FN_ACTION, CFG_DEFAULT_FN_ACTION}, + .pakShortcut = CFG_DEFAULT_PAK_SHORTCUT, .screenTimeoutSecs = CFG_DEFAULT_SCREENTIMEOUTSECS, .suspendTimeoutSecs = CFG_DEFAULT_SUSPENDTIMEOUTSECS, @@ -141,6 +183,7 @@ static void setPaletteNameRaw(const char *name); void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) { + cfg_loading = true; CFG_defaults(&settings); settings.onFontChange = cb; settings.onColorSet = ccb; @@ -366,6 +409,15 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) CFG_setFnAction(2, value); continue; } + if (strncmp(line, "pakShortcut=", 12) == 0) + { + char *value = line + 12; + value[strcspn(value, "\r\n")] = 0; + // Migrate the short-lived minuisettings.txt representation to + // its independent file, which older config writers cannot drop. + CFG_setPakShortcut(value); + continue; + } if (sscanf(line, "artWidth=%i", &temp_value) == 1) { CFG_setGameArtWidth((double)temp_value / 100.0); @@ -523,6 +575,13 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) fclose(file); } + // This setting is intentionally independent of minuisettings.txt: every + // foreground utility historically rewrites that monolithic file, and an + // older utility would discard fields introduced by a newer build. + CFG_loadPakShortcutFile(); + + cfg_loading = false; + // load gfx related stuff until we drop the indirection CFG_setColor(1, CFG_getColor(COLOR_MAIN)); CFG_setColor(2, CFG_getColor(COLOR_ACCENT)); @@ -948,6 +1007,20 @@ void CFG_setFnAction(int index, const char* action) CFG_sync(); } +const char* CFG_getPakShortcut(void) +{ + return settings.pakShortcut; +} + +void CFG_setPakShortcut(const char* action) +{ + if (!action) + action = ""; + strncpy(settings.pakShortcut, action, sizeof(settings.pakShortcut) - 1); + settings.pakShortcut[sizeof(settings.pakShortcut) - 1] = '\0'; + CFG_syncPakShortcutFile(); +} + double CFG_getGameArtWidth(void) { return settings.gameArtWidth; @@ -1453,6 +1526,10 @@ void CFG_get(const char *key, char *value) { sprintf(value, "%s", CFG_getFnAction(2)); } + else if (strcmp(key, "pakShortcut") == 0) + { + sprintf(value, "%s", CFG_getPakShortcut()); + } else if (strcmp(key, "artWidth") == 0) { sprintf(value, "%i", (int)(CFG_getGameArtWidth()) * 100); @@ -1579,6 +1656,9 @@ void CFG_get(const char *key, char *value) void CFG_sync(void) { + if (cfg_loading) + return; + // write to file char settingsPath[MAX_PATH]; const char *shared_userdata = getenv("SHARED_USERDATA_PATH"); @@ -1711,6 +1791,7 @@ void CFG_print(void) printf("\t\"fn1action\": \"%s\",\n", settings.fnAction[0]); printf("\t\"fn2action\": \"%s\",\n", settings.fnAction[1]); printf("\t\"fn3action\": \"%s\",\n", settings.fnAction[2]); + printf("\t\"pakShortcut\": \"%s\",\n", settings.pakShortcut); printf("\t\"artWidth\": %i,\n", (int)(settings.gameArtWidth * 100)); printf("\t\"wifi\": %i,\n", settings.wifi); printf("\t\"defaultView\": %i,\n", settings.defaultView); diff --git a/workspace/all/common/config.h b/workspace/all/common/config.h index cdd577866..4b9b7c38c 100644 --- a/workspace/all/common/config.h +++ b/workspace/all/common/config.h @@ -148,6 +148,9 @@ typedef struct // Action strings, "" == unassigned. See CFG_getFnAction(). char fnAction[FN_BUTTON_COUNT][256]; + // Tools pak exposed on X in MinArch's root in-game menu. + char pakShortcut[256]; + // Power uint32_t screenTimeoutSecs; uint32_t suspendTimeoutSecs; @@ -237,6 +240,7 @@ typedef struct #define CFG_DEFAULT_EXTRACTEDFILENAME false #define CFG_DEFAULT_MUTELEDS false #define CFG_DEFAULT_FN_ACTION "" // unassigned +#define CFG_DEFAULT_PAK_SHORTCUT "" // unassigned #define CFG_DEFAULT_GAMEARTWIDTH 0.45 #define CFG_DEFAULT_WIFI false #define CFG_DEFAULT_VIEW SCREEN_GAMELIST @@ -393,6 +397,10 @@ void CFG_setMuteLEDs(bool); // Returns "" for an out of range index. const char* CFG_getFnAction(int index); void CFG_setFnAction(int index, const char* action); +// The metadata-approved Tools pak exposed on X in MinArch's root in-game menu. +// Uses the same FN_ACTION_PAK_PREFIX representation as button assignments. +const char* CFG_getPakShortcut(void); +void CFG_setPakShortcut(const char* action); // Set game art width percentage. double CFG_getGameArtWidth(void); void CFG_setGameArtWidth(double zeroToOne); diff --git a/workspace/all/common/utils.c b/workspace/all/common/utils.c index d3e69813b..4bc50740e 100644 --- a/workspace/all/common/utils.c +++ b/workspace/all/common/utils.c @@ -478,6 +478,72 @@ int getInt(char* path) { } return i; } + +bool pakSupportsInGameShortcut(const char* pak_path) { + if (!pak_path || !pak_path[0]) return false; + + char json_path[512]; + if (snprintf(json_path, sizeof(json_path), "%s/pak.json", pak_path) >= (int)sizeof(json_path)) + return false; + + char* json = allocFile(json_path); + if (!json) return false; + + const char* key = "\"in_game_shortcut\""; + char* value = strstr(json, key); + if (value) { + value += strlen(key); + while (isspace((unsigned char)*value)) value++; + if (*value == ':') value++; + else value = NULL; + } + if (value) { + while (isspace((unsigned char)*value)) value++; + } + + bool supported = value && strncmp(value, "true", 4) == 0 && + !isalnum((unsigned char)value[4]) && value[4] != '_'; + free(json); + return supported; +} + +static bool getPakJsonString(const char* json, const char* key, char* value, size_t value_size) { + char quoted_key[64]; + if (!json || !value || value_size == 0 || + snprintf(quoted_key, sizeof(quoted_key), "\"%s\"", key) >= (int)sizeof(quoted_key)) + return false; + + const char* start = strstr(json, quoted_key); + if (!start) return false; + start += strlen(quoted_key); + while (isspace((unsigned char)*start)) start++; + if (*start++ != ':') return false; + while (isspace((unsigned char)*start)) start++; + if (*start++ != '"') return false; + + size_t length = 0; + while (*start && *start != '"' && length < PAK_SHORT_NAME_MAX && length + 1 < value_size) + value[length++] = *start++; + value[length] = '\0'; + return length > 0; +} + +bool getPakShortcutName(const char* pak_path, char* name, size_t name_size) { + if (!pak_path || !pak_path[0] || !name || name_size == 0) return false; + name[0] = '\0'; + + char json_path[512]; + if (snprintf(json_path, sizeof(json_path), "%s/pak.json", pak_path) >= (int)sizeof(json_path)) + return false; + + char* json = allocFile(json_path); + if (!json) return false; + + bool found = getPakJsonString(json, "short_name", name, name_size) || + getPakJsonString(json, "name", name, name_size); + free(json); + return found; +} void putInt(char* path, int value) { char buffer[8]; sprintf(buffer, "%d", value); diff --git a/workspace/all/common/utils.h b/workspace/all/common/utils.h index 9444164ea..617970f92 100644 --- a/workspace/all/common/utils.h +++ b/workspace/all/common/utils.h @@ -5,6 +5,9 @@ #include #include +#define PAK_SHORT_NAME_MAX 12 + + int prefixMatch(char* pre, const char* str); int suffixMatch(char* suf,const char* str); int exactMatch(const char* str1, const char* str2); @@ -42,6 +45,9 @@ void getFile(char* path, char* buffer, size_t buffer_size); void putInt(char* path, int value); int getInt(char* path); +bool pakSupportsInGameShortcut(const char* pak_path); +bool getPakShortcutName(const char* pak_path, char* name, size_t name_size); + uint64_t getMicroseconds(void); int clamp(int x, int lower, int upper); diff --git a/workspace/all/minarch/ma_menu.c b/workspace/all/minarch/ma_menu.c index 5a5c130d0..f60a0883c 100644 --- a/workspace/all/minarch/ma_menu.c +++ b/workspace/all/minarch/ma_menu.c @@ -1,8 +1,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -43,7 +45,7 @@ enum { void MSG_init(void) { digits = SDL_CreateRGBSurface(SDL_SWSURFACE,SCALE2(DIGIT_WIDTH*DIGIT_COUNT,DIGIT_HEIGHT),FIXED_DEPTH, 0,0,0,0); SDL_FillRect(digits, NULL, RGB_BLACK); - + SDL_Surface* digit; char* chars[] = { "0","1","2","3","4","5","6","7","8","9","/",".","%","x","(",")", NULL }; char* c; @@ -145,6 +147,7 @@ static struct { char base_path[256]; char bmp_path[256]; char txt_path[256]; + char pak_label[PAK_SHORT_NAME_MAX + 1]; int disc; int total_discs; int slot; @@ -166,6 +169,53 @@ static struct { } }; +static int Menu_launchPakShortcut(void) { + const char* action = CFG_getPakShortcut(); + if (!action || !action[0]) return 0; // unassigned + + size_t prefix_len = strlen(FN_ACTION_PAK_PREFIX); + if (strncmp(action, FN_ACTION_PAK_PREFIX, prefix_len) != 0) return 0; + + const char* rel = action + prefix_len; + if (!rel[0]) return 0; + + // Keep the core and game resident, but hand foreground input and audio to the pak + SND_pauseAudio(true); + system("gametimectl.elf stop_all"); + PAD_quit(); + + char pak_path[512]; + snprintf(pak_path, sizeof(pak_path), "%s/Tools/%s/%s", SDCARD_PATH, PLATFORM, rel); + + char launch_path[512]; + snprintf(launch_path, sizeof(launch_path), "%s/launch.sh", pak_path); + if (!exists(launch_path)) return 0; // stale binding, eg. the pak was deleted + + // Exposing these lets the pak open context aware files/etc. + setenv("NEXTUI_ROM_PATH", game.path, 1); + setenv("NEXTUI_EMU_TAG", core.tag, 1); + + LOG_info("Launching in-game pak shortcut: %s\n", launch_path); + pid_t pid = fork(); + int result = -1; + if (pid == 0) { + execl("/bin/sh", "sh", launch_path, (char*)NULL); + _exit(127); + } + if (pid > 0) { + while (waitpid(pid, &result, 0) < 0 && errno == EINTR) {} + } + if (pid < 0 || !WIFEXITED(result) || WEXITSTATUS(result) != 0) + LOG_warn("In-game pak shortcut exited abnormally (status %d)\n", result); + + PAD_init(); + PAD_reset(); + system("gametimectl.elf resume"); + SND_pauseAudio(false); + GFX_clearAll(); + return 1; +} + void Menu_init(void) { menu.overlay = SDL_CreateRGBSurfaceWithFormat(SDL_SWSURFACE, DEVICE_WIDTH,DEVICE_HEIGHT, @@ -173,7 +223,18 @@ void Menu_init(void) { SDL_SetSurfaceBlendMode(menu.overlay, SDL_BLENDMODE_BLEND); Uint32 color = SDL_MapRGBA(menu.overlay->format, 0, 0, 0, 0); SDL_FillRect(screen, NULL, color); - + + strcpy(menu.pak_label, "PAK"); + const char* action = CFG_getPakShortcut(); + size_t prefix_len = strlen(FN_ACTION_PAK_PREFIX); + if (action && strncmp(action, FN_ACTION_PAK_PREFIX, prefix_len) == 0) { + char pak_path[512]; + snprintf(pak_path, sizeof(pak_path), "%s/Tools/%s/%s", SDCARD_PATH, PLATFORM, action + prefix_len); + char pak_label[sizeof(menu.pak_label)]; + if (getPakShortcutName(pak_path, pak_label, sizeof(pak_label))) + strcpy(menu.pak_label, pak_label); + } + char emu_name[256]; getEmuName(game.path, emu_name); sprintf(menu.minui_dir, SHARED_USERDATA_PATH "/.minui/%s", emu_name); @@ -1829,6 +1890,10 @@ void Menu_loop(void) { dirty = 1; } } + else if (PAD_justPressed(BTN_X)) { + if (Menu_launchPakShortcut()) + dirty = 1; + } if (dirty && (selected==ITEM_SAVE || selected==ITEM_LOAD)) { Menu_updateState(); @@ -1932,7 +1997,13 @@ void Menu_loop(void) { SDL_FreeSurface(text); if (show_setting && !GetHDMI()) GFX_blitHardwareHints(screen, show_setting); - else GFX_blitButtonGroup((char*[]){ BTN_SLEEP==BTN_POWER?"POWER":"MENU","SLEEP", NULL }, 0, screen, 0); + else { + const char* action = CFG_getPakShortcut(); + if (action && action[0]) + GFX_blitButtonGroup((char*[]){ "X", menu.pak_label, NULL }, 0, screen, 0); + else + GFX_blitButtonGroup((char*[]){ BTN_SLEEP==BTN_POWER?"POWER":"MENU", "SLEEP", NULL }, 0, screen, 0); + } GFX_blitButtonGroup((char*[]){ "B","BACK", "A","OKAY", NULL }, 1, screen, 1); // list diff --git a/workspace/all/settings/fnbuttonmenu.cpp b/workspace/all/settings/fnbuttonmenu.cpp index 5659c16f8..26e140568 100644 --- a/workspace/all/settings/fnbuttonmenu.cpp +++ b/workspace/all/settings/fnbuttonmenu.cpp @@ -44,7 +44,7 @@ PakEntry pakEntry(const std::string &rel, const std::string &name) // Tools paks, including those one subfolder deep (Tools///.pak), // which is how larger pak collections tend to organize themselves. -std::vector enumerateToolPaks() +std::vector enumerateToolPaks(bool in_game_only = false) { std::vector paks; @@ -59,7 +59,8 @@ std::vector enumerateToolPaks() std::string name(ent->d_name); if (isPak(tools_path, name)) { - paks.push_back(pakEntry(name, name)); + if (!in_game_only || pakSupportsInGameShortcut((tools_path + "/" + name).c_str())) + paks.push_back(pakEntry(name, name)); continue; } @@ -73,6 +74,7 @@ std::vector enumerateToolPaks() if (sub_ent->d_name[0] == '.') continue; std::string sub_name(sub_ent->d_name); if (!isPak(sub_path, sub_name)) continue; + if (in_game_only && !pakSupportsInGameShortcut((sub_path + "/" + sub_name).c_str())) continue; paks.push_back(pakEntry(name + "/" + sub_name, sub_name)); } closedir(sub); @@ -87,7 +89,7 @@ std::vector enumerateToolPaks() // Row for one button. The label is read live from the config rather than from the // cycled index, so picking from the submenu updates the row too. -class FnMenuItem : public MenuItem +class ActionMenuItem : public MenuItem { public: using MenuItem::MenuItem; @@ -162,7 +164,7 @@ MenuList* buildFnButtonMenu() } auto *submenu = new MenuList(MenuItemType::Fixed, button + " button", std::move(subItems)); - items.push_back(new FnMenuItem{ListItemType::Generic, button + " button", + items.push_back(new ActionMenuItem{ListItemType::Generic, button + " button", "The pak to launch when this button is pressed in the main menu.", values, labels, [i]() -> std::any { return std::string(CFG_getFnAction(i)); }, @@ -178,3 +180,44 @@ MenuList* buildFnButtonMenu() "Resets all options in this menu to their default values.", ResetCurrentMenu}); return new MenuList(MenuItemType::Fixed, "Assignments", std::move(items)); } + +AbstractMenuItem* buildPakShortcutItem() +{ + const auto paks = enumerateToolPaks(true); + std::vector values = {std::string("")}; + std::vector labels = {"None"}; + + std::string current = CFG_getPakShortcut(); + bool found = current.empty(); + for (const auto &p : paks) { + if (p.action == current) + found = true; + values.push_back(p.action); + labels.push_back(p.label); + } + if (!found) { + values.push_back(current); + size_t separator = current.find(':'); + std::string label = separator == std::string::npos ? current : current.substr(separator + 1); + labels.push_back(label + " (unavailable)"); + } + + std::vector subItems; + for (size_t i = 0; i < values.size(); i++) { + std::string action = std::any_cast(values[i]); + subItems.push_back(new MenuItem{ListItemType::Button, labels[i], "", + [action](AbstractMenuItem &) -> InputReactionHint { + CFG_setPakShortcut(action.c_str()); + return Exit; + }}); + } + auto *submenu = new MenuList(MenuItemType::Fixed, "Pak Shortcut", std::move(subItems)); + + return new ActionMenuItem{ListItemType::Generic, "Pak Shortcut", + "The pak opened with X from the in-game menu.", + values, labels, + []() -> std::any { return std::string(CFG_getPakShortcut()); }, + [](const std::any &value) { CFG_setPakShortcut(std::any_cast(value).c_str()); }, + []() { CFG_setPakShortcut(CFG_DEFAULT_PAK_SHORTCUT); }, + DeferToSubmenu, submenu}; +} diff --git a/workspace/all/settings/fnbuttonmenu.hpp b/workspace/all/settings/fnbuttonmenu.hpp index 182691997..f9662f486 100644 --- a/workspace/all/settings/fnbuttonmenu.hpp +++ b/workspace/all/settings/fnbuttonmenu.hpp @@ -11,3 +11,6 @@ // Returns nullptr when the device has no assignable buttons, in which case the caller // should leave the menu out entirely. MenuList* buildFnButtonMenu(); + +// Builds the In-Game "Pak Shortcut" row and its metadata-filtered picker. +AbstractMenuItem* buildPakShortcutItem(); diff --git a/workspace/all/settings/settings.cpp b/workspace/all/settings/settings.cpp index 7a3eeda49..baf09f3eb 100644 --- a/workspace/all/settings/settings.cpp +++ b/workspace/all/settings/settings.cpp @@ -1014,6 +1014,7 @@ int main(int argc, char *argv[]) auto minarchMenu = new MenuList(MenuItemType::List, "In-Game", { + buildPakShortcutItem(), new MenuItem{ListItemType::Generic, "Notifications", "Save state notifications", {}, {}, nullptr, nullptr, DeferToSubmenu, notificationsMenu}, new MenuItem{ListItemType::Generic, "RetroAchievements", "Achievement tracking settings", {}, {}, nullptr, nullptr, DeferToSubmenu, retroAchievementsMenu}, });