diff --git a/arm9/source/language.inl b/arm9/source/language.inl index 12b95c1..69b9607 100644 --- a/arm9/source/language.inl +++ b/arm9/source/language.inl @@ -83,10 +83,12 @@ STRING(DELETE_RENAME_FILE, "\\X - DELETE/[+\\R] RENAME file") STRING(START_MENU, "START Menu") STRING(POWER_OFF, "Power off") STRING(REBOOT, "Reboot") +STRING(OPEN_TITLE_MANAGER, "Title manager...") STRING(LANGUAGE, "Language...") STRING(SELECT_LANGUAGE, "Select Language") STRING(NITROFS_NOT_MOUNTED, "NitroFS could not be mounted, please load GodMode9i from TWiLight Menu++ or nds-hb-menu.") STRING(NITROFS_UNMOUNTED, "Another title's NitroFS has been mounted, please reload GodMode9i to change the language.") +STRING(TITLE_MANAGER, "Title Manager") // File options STRING(BOOT_FILE, "Boot file") @@ -137,7 +139,9 @@ STRING(DUMP_ALL_TRIMMED, "All (Trimmed ROM)") STRING(DUMP_ROM, "ROM") STRING(DUMP_ROM_TRIMMED, "ROM (Trimmed)") STRING(DUMP_SAVE, "Save") -STRING(DUMP_DS_SAVE, "DS Save") +STRING(DUMP_DS_SAVE, "DS save") +STRING(DUMP_PUBLIC_SAVE, "Public save") +STRING(DUMP_PRIVATE_SAVE, "Private save") STRING(DUMP_METADATA, "Metadata") STRING(DO_NOT_REMOVE_CARD, "Do not remove the NDS card.") STRING(DO_NOT_REMOVE_CART, "Do not remove the GBA cart.") diff --git a/arm9/source/startMenu.cpp b/arm9/source/startMenu.cpp index 0db1048..01ede32 100644 --- a/arm9/source/startMenu.cpp +++ b/arm9/source/startMenu.cpp @@ -1,10 +1,12 @@ #include "startMenu.h" #include "config.h" +#include "driveOperations.h" #include "font.h" #include "language.h" #include "main.h" #include "screenshot.h" +#include "titleManager.h" #include #include @@ -15,12 +17,14 @@ enum class StartMenuItem : u8 { powerOff = 0, reboot = 1, - langauge = 2 + titleManager = 2, + langauge = 3, }; -constexpr std::array startMenuStrings = { +constexpr std::array startMenuStrings = { &STR_POWER_OFF, &STR_REBOOT, + &STR_OPEN_TITLE_MANAGER, &STR_LANGUAGE }; @@ -42,9 +46,11 @@ void startMenu() { if(!isRegularDS) { startMenuItems = { StartMenuItem::powerOff, - StartMenuItem::reboot, - StartMenuItem::langauge + StartMenuItem::reboot }; + if(nandMounted && (sdMounted || flashcardMounted)) + startMenuItems.push_back(StartMenuItem::titleManager); + startMenuItems.push_back(StartMenuItem::langauge); } else { startMenuItems = { StartMenuItem::powerOff, @@ -90,6 +96,9 @@ void startMenu() { fifoSendValue32(FIFO_USER_02, 1); while(1) swiWaitForVBlank(); break; + case StartMenuItem::titleManager: + titleManager(); + break; case StartMenuItem::langauge: languageMenu(); break; diff --git a/arm9/source/titleManager.cpp b/arm9/source/titleManager.cpp new file mode 100644 index 0000000..aa96c93 --- /dev/null +++ b/arm9/source/titleManager.cpp @@ -0,0 +1,325 @@ +#include "titleManager.h" +#include "driveOperations.h" +#include "file_browse.h" +#include "fileOperations.h" +#include "font.h" +#include "language.h" +#include "screenshot.h" + +#include +#include +#include +#include +#include + +struct TitleInfo { + TitleInfo(std::string appPath, std::string pubPath, std::string prvPath, const char *gameTitle, const char *gameCode, u8 romVersion, std::u16string bannerTitle) : appPath(appPath), pubPath(pubPath), prvPath(prvPath), romVersion(romVersion), bannerTitle(bannerTitle) { + strcpy(this->gameTitle, gameTitle); + strcpy(this->gameCode, gameCode); + } + + std::string appPath; + std::string pubPath; + std::string prvPath; + char gameTitle[13]; + char gameCode[5]; + u8 romVersion; + std::u16string bannerTitle; +}; + +enum TitleDumpOption { + none = 0, + rom = 1, + publicSave = 4, + privateSave = 8, + all = rom | publicSave | privateSave +}; + +void dumpTitle(TitleInfo &title) { + u16 pressed = 0, held = 0; + int optionOffset = 0; + + std::vector allowedOptions({TitleDumpOption::all, TitleDumpOption::rom}); + if(title.pubPath.length() > 0) + allowedOptions.push_back(TitleDumpOption::publicSave); + if(title.prvPath.length() > 0) + allowedOptions.push_back(TitleDumpOption::privateSave); + + char dumpName[32]; + snprintf(dumpName, sizeof(dumpName), "%s_%s_%02X", title.gameTitle, title.gameCode, title.romVersion); + + char dumpToStr[256]; + snprintf(dumpToStr, sizeof(dumpToStr), STR_DUMP_TO.c_str(), dumpName, sdMounted ? "sd" : "fat"); + + int y = font->calcHeight(dumpToStr) + 1; + + while (true) { + font->clear(false); + + font->print(0, 0, false, dumpToStr); + + int row = y; + for(TitleDumpOption option : allowedOptions) { + switch(option) { + case TitleDumpOption::all: + font->print(3, row++, false, STR_DUMP_ALL); + break; + case TitleDumpOption::rom: + font->print(3, row++, false, STR_DUMP_ROM); + break; + case TitleDumpOption::publicSave: + font->print(3, row++, false, STR_DUMP_PUBLIC_SAVE); + break; + case TitleDumpOption::privateSave: + font->print(3, row++, false, STR_DUMP_PRIVATE_SAVE); + break; + case TitleDumpOption::none: + row++; + break; + } + } + + font->print(3, ++row, false, STR_A_SELECT_B_CANCEL); + + // Show cursor + font->print(0, y + optionOffset, false, "->"); + + font->update(false); + + // Power saving loop. Only poll the keys once per frame and sleep the CPU if there is nothing else to do + do { + scanKeys(); + pressed = keysDownRepeat(); + held = keysHeld(); + swiWaitForVBlank(); + } while (!(pressed & (KEY_UP| KEY_DOWN | KEY_A | KEY_B | KEY_L)) +#ifdef SCREENSWAP + && !(pressed & KEY_TOUCH) +#endif + ); + + if (pressed & KEY_UP) + optionOffset--; + if (pressed & KEY_DOWN) + optionOffset++; + + if (optionOffset < 0) // Wrap around to bottom of list + optionOffset = allowedOptions.size() - 1; + + if (optionOffset >= (int)allowedOptions.size()) // Wrap around to top of list + optionOffset = 0; + + if (pressed & KEY_A) { + TitleDumpOption selectedOption = allowedOptions[optionOffset]; + + // Ensure directories exist + char folderPath[16]; + sprintf(folderPath, "%s:/gm9i", (sdMounted ? "sd" : "fat")); + if (access(folderPath, F_OK) != 0) { + font->clear(false); + font->print(0, 0, false, STR_CREATING_DIRECTORY); + font->update(false); + mkdir(folderPath, 0777); + } + sprintf(folderPath, "%s:/gm9i/out", (sdMounted ? "sd" : "fat")); + if (access(folderPath, F_OK) != 0) { + font->clear(false); + font->print(0, 0, false, STR_CREATING_DIRECTORY); + font->update(false); + mkdir(folderPath, 0777); + } + + // Dump to /gm9i/out + char path[64]; + if(selectedOption & TitleDumpOption::rom) { + snprintf(path, sizeof(path), "%s:/gm9i/out/%s.nds", sdMounted ? "sd" : "fat", dumpName); + fcopy(title.appPath.c_str(), path); + } + + if((selectedOption & TitleDumpOption::publicSave) && title.pubPath.length() > 0) { + snprintf(path, sizeof(path), "%s:/gm9i/out/%s.pub", sdMounted ? "sd" : "fat", dumpName); + fcopy(title.pubPath.c_str(), path); + } + + if((selectedOption & TitleDumpOption::privateSave) && title.prvPath.length() > 0) { + snprintf(path, sizeof(path), "%s:/gm9i/out/%s.prv", sdMounted ? "sd" : "fat", dumpName); + fcopy(title.prvPath.c_str(), path); + } + + return; + } + + if (pressed & KEY_B) + return; + +#ifdef SCREENSWAP + // Swap screens + if (pressed & KEY_TOUCH) { + screenSwapped = !screenSwapped; + screenSwapped ? lcdMainOnBottom() : lcdMainOnTop(); + } +#endif + + // Make a screenshot + if ((held & KEY_R) && (pressed & KEY_L)) { + screenshot(); + } + } +} + +void titleManager() { + if(!nandMounted || !(sdMounted || flashcardMounted)) + return; + + char oldPath[PATH_MAX]; + getcwd(oldPath, PATH_MAX); + + std::vector titles; + for(u32 tidHigh : {0x00030004, 0x00030005, 0x00030015, 0x00030017}) { + char path[64]; + snprintf(path, sizeof(path), "nand:/title/%08lx", tidHigh); + if(access(path, F_OK) == 0) { + chdir(path); + std::vector dirContents; + getDirectoryContents(dirContents); + for(const DirEntry &entry : dirContents) { + if(entry.name[0] == '.') + continue; + + u8 version; + snprintf(path, sizeof(path), "nand:/title/%08lx/%s/content/title.tmd", tidHigh, entry.name.c_str()); + FILE *tmd = fopen(path, "rb"); + if(tmd) { + fseek(tmd, 0x1E7, SEEK_SET); + fread(&version, sizeof(version), 1, tmd); + fclose(tmd); + + char gameTitle[13] = {0}; + char gameCode[7] = {0}; + u8 romVersion; + char16_t title[0x80]; + char pubPath[64], prvPath[64]; + snprintf(path, sizeof(path), "nand:/title/%08lx/%s/content/000000%02x.app", tidHigh, entry.name.c_str(), version); + FILE *app = fopen(path, "rb"); + if(app) { + fread(gameTitle, 1, 12, app); + fread(gameCode, 1, 6, app); + fseek(app, 12, SEEK_CUR); + fread(&romVersion, 1, 1, app); + + u32 ofs; + fseek(app, 0x68, SEEK_SET); + fread(&ofs, sizeof(u32), 1, app); + if(ofs >= 0x8000 && fseek(app, ofs, SEEK_SET) == 0) { + fseek(app, 0x240 + (0x80 * 2), SEEK_CUR); + fread(title, 2, 0x80, app); + } else { + title[0] = u'\0'; + } + + fclose(app); + + // Check if saves exist + snprintf(pubPath, sizeof(pubPath), "nand:/title/%08lx/%s/data/public.sav", tidHigh, entry.name.c_str()); + if(access(pubPath, F_OK) != 0) + pubPath[0] = '\0'; + snprintf(prvPath, sizeof(prvPath), "nand:/title/%08lx/%s/data/private.sav", tidHigh, entry.name.c_str()); + if(access(prvPath, F_OK) != 0) + prvPath[0] = '\0'; + + titles.emplace_back(path, pubPath, prvPath, gameTitle, gameCode, romVersion, title); + + } + } + } + } + } + + chdir(oldPath); + + // Sort alphabetically by banner title + std::sort(titles.begin(), titles.end(), [](TitleInfo lhs, TitleInfo rhs) { + for(size_t i = 0; i < lhs.bannerTitle.length(); i++) { + char16_t lchar = tolower(lhs.bannerTitle[i]); + char16_t rchar = tolower(rhs.bannerTitle[i]); + if(lchar == u'\0') + return true; + else if(rchar == u'\0') + return false; + else if(lchar < rchar) + return true; + else if(lchar > rchar) + return false; + } + + return false; + }); + + u16 pressed = 0, held = 0; + int cursorPosition = 0, scrollOffset = 0; + while(1) { + font->clear(false); + font->printf(0, 0, false, Alignment::left, Palette::blackGreen, "%*c", SCREEN_COLS, ' '); + font->print(0, 0, false, STR_TITLE_MANAGER, Alignment::center, Palette::blackGreen); + + for(int i = 0; i < ((int)titles.size() - scrollOffset) && i < ENTRIES_PER_SCREEN; i++) { + const TitleInfo &title = titles[scrollOffset + i]; + Palette pal = scrollOffset + i == cursorPosition ? Palette::white : Palette::gray; + font->print(0, 1 + i, false, title.bannerTitle.substr(0, title.bannerTitle.find(u'\n')), Alignment::left, pal); + font->printf(-1, 1 + i, false, Alignment::right, pal, " (%s)", title.gameCode); + } + + font->update(false); + + do { + swiWaitForVBlank(); + scanKeys(); + pressed = keysDown(); + held = keysDownRepeat(); + } while(!(held & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT | KEY_A | KEY_B | KEY_L +#ifdef SCREENSWAP + | KEY_TOUCH +#endif + ))); + + if(held & KEY_UP) { + cursorPosition--; + if(cursorPosition < 0) + cursorPosition = titles.size() - 1; + } else if(held & KEY_DOWN) { + cursorPosition++; + if(cursorPosition > (int)titles.size() - 1) + cursorPosition = 0; + } else if(held & KEY_LEFT) { + cursorPosition -= ENTRIES_PER_SCREEN; + if(cursorPosition < 0) + cursorPosition = 0; + } else if(held & KEY_RIGHT) { + cursorPosition += ENTRIES_PER_SCREEN; + if(cursorPosition > (int)titles.size() + 1) + cursorPosition = titles.size() - 1; + } else if(pressed & KEY_A) { + dumpTitle(titles[cursorPosition]); + } else if(pressed & KEY_B) { + return; + } + + // Scroll screen if needed + if (cursorPosition < scrollOffset) + scrollOffset = cursorPosition; + if (cursorPosition > scrollOffset + ENTRIES_PER_SCREEN - 1) + scrollOffset = cursorPosition - ENTRIES_PER_SCREEN + 1; + +#ifdef SCREENSWAP + // Swap screens + if (pressed & KEY_TOUCH) { + screenSwapped = !screenSwapped; + screenSwapped ? lcdMainOnBottom() : lcdMainOnTop(); + } +#endif + + if((pressed & KEY_L) && (keysHeld() & KEY_R)) { + screenshot(); + } + } +} \ No newline at end of file diff --git a/arm9/source/titleManager.h b/arm9/source/titleManager.h new file mode 100644 index 0000000..f74371b --- /dev/null +++ b/arm9/source/titleManager.h @@ -0,0 +1,6 @@ +#ifndef TITLE_MANAGER_H +#define TITLE_MANAGER_H + +void titleManager(void); + +#endif // TITLE_MANAGER_H \ No newline at end of file diff --git a/nitrofiles/languages/en-US/language.ini b/nitrofiles/languages/en-US/language.ini index 4825536..bdb69c0 100644 --- a/nitrofiles/languages/en-US/language.ini +++ b/nitrofiles/languages/en-US/language.ini @@ -79,10 +79,12 @@ DELETE_RENAME_FILE=\X - DELETE/[+\R] RENAME file START_MENU=START Menu POWER_OFF=Power off REBOOT=Reboot +OPEN_TITLE_MANAGER=Title manager... LANGUAGE=Language... SELECT_LANGUAGE=Select Language NITROFS_NOT_MOUNTED=NitroFS could not be mounted, please load GodMode9i from TWiLight Menu++ or nds-hb-menu. NITROFS_UNMOUNTED=Another title's NitroFS has been mounted, please reload GodMode9i to change the language. +TITLE_MANAGER=Title Manager BOOT_FILE=Boot file BOOT_FILE_DIRECT=Boot file (Direct) @@ -129,7 +131,9 @@ DUMP_ALL_TRIMMED=All (Trimmed ROM) DUMP_ROM=ROM DUMP_ROM_TRIMMED=ROM (Trimmed) DUMP_SAVE=Save -DUMP_DS_SAVE=DS Save +DUMP_DS_SAVE=DS save +DUMP_PUBLIC_SAVE=Public save +DUMP_PRIVATE_SAVE=Private save DUMP_METADATA=Metadata DO_NOT_REMOVE_CARD=Do not remove the NDS card. DO_NOT_REMOVE_CART=Do not remove the GBA cart.