From 0af1cae39c8b40ea14f4a4dc2574f26c0f195951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 17:59:23 +0200 Subject: [PATCH 1/7] SDL: Generate text from key events when SDL_StartTextInput fails SDL 3.4.0 to 3.4.4 refuse text input on Wayland compositors without zwp_text_input_v3, such as WSLg's Weston, so no SDL_EVENT_TEXT_INPUT ever arrives and text fields can't be typed into. Fixed upstream in later 3.4.x. Co-Authored-By: Claude Opus 5.5 (1M context) --- SDL/SDLMain.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/SDL/SDLMain.cpp b/SDL/SDLMain.cpp index d797a7df55..9624f4a396 100644 --- a/SDL/SDLMain.cpp +++ b/SDL/SDLMain.cpp @@ -108,6 +108,9 @@ static int g_audioFramesPerBuffer = 0; static bool g_textFocusChanged; static bool g_textFocus; +// Set when SDL_StartTextInput fails, so we generate KeyInputFlags::CHAR from key events ourselves. +// SDL 3.4.0 to 3.4.4 refuse text input on Wayland compositors without zwp_text_input_v3 (like WSLg's Weston). +static bool g_textInputFallback; double g_audioStartTime = 0.0; // Window state to be transferred to the main SDL thread. @@ -1523,6 +1526,17 @@ static void ProcessSDLEvent(SDL_Window *window, const SDL_Event &event, InputSta #endif case SDL_EVENT_KEY_DOWN: { + if (g_textInputFallback && g_textFocus && !(event.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI))) { + // Layout-aware, with shift applied. Repeats are wanted here, like real text input. + SDL_Keycode c = SDL_GetKeyFromScancode(event.key.scancode, event.key.mod, false); + if (c >= 0x20 && c != 0x7F && !(c & SDLK_SCANCODE_MASK)) { + KeyInput key; + key.flags = KeyInputFlags::CHAR; + key.unicodeChar = c; + key.deviceId = DEVICE_ID_KEYBOARD; + NativeKey(key); + } + } if (event.key.repeat > 0) { break; } @@ -1848,7 +1862,10 @@ void UpdateTextFocus(SDL_Window *window) { if (g_textFocusChanged) { DEBUG_LOG(Log::System, "Updating text focus: %d", g_textFocus); if (g_textFocus) { - SDL_StartTextInput(window); + if (!SDL_StartTextInput(window) && !g_textInputFallback) { + WARN_LOG(Log::System, "SDL_StartTextInput failed (%s), generating text from key events instead. Update SDL, or set SDL_VIDEO_DRIVER=x11, for full text input.", SDL_GetError()); + g_textInputFallback = true; + } } else { SDL_StopTextInput(window); } From b4d5d53b8a80f2a472440c9f3ccd47107a096c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 18:02:26 +0200 Subject: [PATCH 2/7] ImDebugger: Enable text input while an imgui text field is active Since db8ff43, a leftover local shadowed lastImguiEnabled_ in runImDebugger, so TEXT_GOTFOCUS was never sent and SDL never started text input for imgui. Drive it from io.WantTextInput instead, so it's only on while editing. The regular UI and the debugger now say independently whether they want text input, and the platform only gets TEXT_LOSTFOCUS once neither does, so leaving an imgui field doesn't break type-to-search on the screen underneath. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/System/Request.cpp | 43 +++++++++++++++++++++++++++++++++++++++ Common/System/Request.h | 14 ++++++++++--- SDL/SDLMain.cpp | 1 - UI/NativeApp.cpp | 27 +++++++++++------------- 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/Common/System/Request.cpp b/Common/System/Request.cpp index a37ffebed1..9b3a0ce080 100644 --- a/Common/System/Request.cpp +++ b/Common/System/Request.cpp @@ -156,6 +156,49 @@ void RequestManager::Clear() { callbackMap_.clear(); } +static std::mutex g_textInputLock; +static bool g_textInputWanted[(size_t)TextInputUser::COUNT]; + +static void SendUIEvent(UIEventNotification notification) { + g_requestManager.MakeSystemRequest(SystemRequestType::NOTIFY_UI_EVENT, NO_REQUESTER_TOKEN, nullptr, nullptr, "", "", (int64_t)notification, 0); +} + +void System_SetTextInputWanted(TextInputUser user, bool wanted) { + bool anyWanted = false; + { + std::lock_guard guard(g_textInputLock); + g_textInputWanted[(size_t)user] = wanted; + for (bool w : g_textInputWanted) { + anyWanted = anyWanted || w; + } + } + // GOTFOCUS always goes through, as platforms may need it to show a keyboard again. + if (wanted) { + SendUIEvent(UIEventNotification::TEXT_GOTFOCUS); + } else if (!anyWanted) { + SendUIEvent(UIEventNotification::TEXT_LOSTFOCUS); + } +} + +void System_NotifyUIEvent(UIEventNotification notification) { + switch (notification) { + case UIEventNotification::TEXT_GOTFOCUS: + System_SetTextInputWanted(TextInputUser::UI, true); + break; + case UIEventNotification::TEXT_LOSTFOCUS: + System_SetTextInputWanted(TextInputUser::UI, false); + break; + case UIEventNotification::POPUP_CLOSED: + // Platforms may hide an on-screen keyboard on this, but text input itself goes through the above. + System_SetTextInputWanted(TextInputUser::UI, false); + SendUIEvent(notification); + break; + default: + SendUIEvent(notification); + break; + } +} + void System_CreateGameShortcut(const Path &path, std::string_view title) { g_requestManager.MakeSystemRequest(SystemRequestType::CREATE_GAME_SHORTCUT, NO_REQUESTER_TOKEN, nullptr, nullptr, path.ToString(), title, 0); } diff --git a/Common/System/Request.h b/Common/System/Request.h index aec7d1d4b7..cbcd742fbf 100644 --- a/Common/System/Request.h +++ b/Common/System/Request.h @@ -182,9 +182,17 @@ inline void System_ShareFile(const Path &path, std::string_view mimeType = "") { g_requestManager.MakeSystemRequest(SystemRequestType::SHARE_FILE, NO_REQUESTER_TOKEN, nullptr, nullptr, path.ToString(), mimeType, 0); } -inline void System_NotifyUIEvent(UIEventNotification notification) { - g_requestManager.MakeSystemRequest(SystemRequestType::NOTIFY_UI_EVENT, NO_REQUESTER_TOKEN, nullptr, nullptr, "", "", (int64_t)notification, 0); -} +// TEXT_GOTFOCUS/TEXT_LOSTFOCUS (and POPUP_CLOSED) sent through here count as the regular UI's use of text input. +void System_NotifyUIEvent(UIEventNotification notification); + +// Several users can want text input at the same time (for example type-to-search under the imgui debugger). +// The platform only gets TEXT_LOSTFOCUS once none of them does. +enum class TextInputUser { + UI, + ImDebugger, + COUNT, +}; +void System_SetTextInputWanted(TextInputUser user, bool wanted); inline void System_SetKeepScreenBright(bool keepScreenBright) { g_requestManager.MakeSystemRequest(SystemRequestType::SET_KEEP_SCREEN_BRIGHT, NO_REQUESTER_TOKEN, nullptr, nullptr, "", "", (int64_t)keepScreenBright); diff --git a/SDL/SDLMain.cpp b/SDL/SDLMain.cpp index 9624f4a396..206fa9e53c 100644 --- a/SDL/SDLMain.cpp +++ b/SDL/SDLMain.cpp @@ -1019,7 +1019,6 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string g_textFocus = true; g_textFocusChanged = true; break; - case UIEventNotification::POPUP_CLOSED: case UIEventNotification::TEXT_LOSTFOCUS: g_textFocus = false; g_textFocusChanged = true; diff --git a/UI/NativeApp.cpp b/UI/NativeApp.cpp index cdc5528810..9bdf4ae4c0 100644 --- a/UI/NativeApp.cpp +++ b/UI/NativeApp.cpp @@ -212,7 +212,7 @@ static std::vector g_inputEventQueue; static std::unique_ptr imDebugger_; static ImCommand imCmd_{}; // needed to buffer commands in case imgui wasn't created yet. static bool imguiInited_ = false; -static bool lastImguiEnabled_ = false; +static bool imguiWantTextInput_ = false; static ImGuiContext *ctx_ = nullptr; class GlobalListener : public ControlListener { @@ -297,20 +297,7 @@ static void RunAchievementsOverrideUpdate(std::function func) { } void runImDebugger(Draw::DrawContext *draw) { - bool lastImguiEnabled_ = false; // temp - if (lastImguiEnabled_ && g_Config.bShowImDebugger) { -#if !defined(MOBILE_DEVICE) - // On mobile devices (specifically iOS) we don't want to pop the keyboard - // on activating imgui. Instead, we should do it when a text edit field in imgui gets focus, - // although we'll still have ugly overlap problems. - System_NotifyUIEvent(UIEventNotification::TEXT_GOTFOCUS); -#endif - VERBOSE_LOG(Log::System, "activating keyboard"); - } else if (lastImguiEnabled_ && !g_Config.bShowImDebugger) { - System_NotifyUIEvent(UIEventNotification::TEXT_LOSTFOCUS); - VERBOSE_LOG(Log::System, "deactivating keyboard"); - } - lastImguiEnabled_ = g_Config.bShowImDebugger; + bool wantTextInput = false; if (g_Config.bShowImDebugger) { if (!imguiInited_) { // TODO: Do this only on demand. @@ -383,10 +370,20 @@ void runImDebugger(Draw::DrawContext *draw) { // Convert to drawlists. ImGui::Render(); + wantTextInput = io.WantTextInput; } else { uiContext->SetOverrideScreenFrame(nullptr); SetOverrideScreenFrame(nullptr); } + + // Enable text input (needed for KeyInputFlags::CHAR events) only while an imgui text field is active. + // On mobile devices (specifically iOS) this would pop the keyboard, with ugly overlap problems. +#if !defined(MOBILE_DEVICE) + if (wantTextInput != imguiWantTextInput_) { + System_SetTextInputWanted(TextInputUser::ImDebugger, wantTextInput); + imguiWantTextInput_ = wantTextInput; + } +#endif } void renderImDebugger(Draw::DrawContext *draw) { From 297acd302673c363230a4e6b1a9dfe0fd47db143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 18:14:59 +0200 Subject: [PATCH 3/7] ImDebugger: Let the toggle key through while imgui has the keyboard Only EmuScreen and PauseScreen let it past imgui's keyboard capture, so on other screens the debugger couldn't be closed by key while a field had focus. Co-Authored-By: Claude Opus 5.5 (1M context) --- UI/NativeApp.cpp | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/UI/NativeApp.cpp b/UI/NativeApp.cpp index 9bdf4ae4c0..11342b4ec0 100644 --- a/UI/NativeApp.cpp +++ b/UI/NativeApp.cpp @@ -1570,9 +1570,11 @@ bool NativeKey(const KeyInput &key) { // Filtering, detailed rules needed for good imgui behavior without having to ask the screen about what to do. InputMode inputMode = g_screenManager->PassInputToMapper(); + bool imguiHasKeyboard = false; if (g_Config.bShowImDebugger && imguiInited_) { if (ImGui::GetIO().WantCaptureKeyboard) { inputMode &= ~InputMode::Keyboard; + imguiHasKeyboard = true; } if (ImGui::GetIO().WantCaptureMouse) { inputMode &= ~InputMode::Mouse; @@ -1580,21 +1582,21 @@ bool NativeKey(const KeyInput &key) { } bool passKeyThrough = false; - if (inputMode != InputMode::None) { - if ((inputMode & InputMode::ImDebuggerToggle) && (key.flags & (KeyInputFlags::UP | KeyInputFlags::DOWN))) { - InputMapping mapping(key.deviceId, key.keyCode); - std::vector pspButtons; - bool mappingFound = KeyMap::InputMappingToPspButton(mapping, &pspButtons); - if (mappingFound) { - for (auto b : pspButtons) { - if (b == VIRTKEY_TOGGLE_DEBUGGER || b == VIRTKEY_PAUSE) { - // TRUE - passKeyThrough = true; - } + // The debugger toggle also goes through while imgui has the keyboard, so it can always be closed again. + if (((inputMode & InputMode::ImDebuggerToggle) || imguiHasKeyboard) && (key.flags & (KeyInputFlags::UP | KeyInputFlags::DOWN))) { + InputMapping mapping(key.deviceId, key.keyCode); + std::vector pspButtons; + bool mappingFound = KeyMap::InputMappingToPspButton(mapping, &pspButtons); + if (mappingFound) { + for (auto b : pspButtons) { + if (b == VIRTKEY_TOGGLE_DEBUGGER || (b == VIRTKEY_PAUSE && (inputMode & InputMode::ImDebuggerToggle))) { + passKeyThrough = true; } } } + } + if (inputMode != InputMode::None) { if (key.deviceId == DEVICE_ID_MOUSE) { if (inputMode & InputMode::Mouse) { passKeyThrough = true; From 1b1e3aba1f8c277cfa576d60801127fd93550d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 15:29:13 +0200 Subject: [PATCH 4/7] Fix installing save state zips Save state zips are detected with stripChars past the last slash, since the whole directory is stripped. ExtractZipContents then computed a negative directory length, created a directory named after each file, failed to open it for writing and reported "Storage full". Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Util/GameManager.cpp | 12 +++++++---- unittest/TestZipSlip.cpp | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Core/Util/GameManager.cpp b/Core/Util/GameManager.cpp index 628d4882bc..6d368c3a95 100644 --- a/Core/Util/GameManager.cpp +++ b/Core/Util/GameManager.cpp @@ -721,10 +721,14 @@ bool GameManager::ExtractZipContents(struct zip *z, const Path &dest, const ZipF Path outFilename = dest / zippedName.substr(info.stripChars); bool isDir = zippedName.empty() || zippedName.back() == '/'; - if (!isDir && zippedName.find('/') != std::string::npos) { - outFilename = dest / zippedName.substr(info.stripChars, zippedName.rfind('/') - info.stripChars); - } else if (!isDir) { - outFilename = dest; + if (!isDir) { + // stripChars can reach past the last slash (save states strip their whole directory). + size_t lastSlash = zippedName.rfind('/'); + if (lastSlash != std::string::npos && lastSlash > (size_t)info.stripChars) { + outFilename = dest / zippedName.substr(info.stripChars, lastSlash - info.stripChars); + } else { + outFilename = dest; + } } Path outPath(outFilename); diff --git a/unittest/TestZipSlip.cpp b/unittest/TestZipSlip.cpp index 40a6da70d8..3f3621a787 100644 --- a/unittest/TestZipSlip.cpp +++ b/unittest/TestZipSlip.cpp @@ -80,6 +80,49 @@ static bool TestZipSlipExtraction() { return true; } +// Save state zips are detected with stripChars past the last slash, since the whole +// directory is stripped. The files must land directly in the destination. +static bool TestSaveStateZipExtraction() { + // Absolute, since CreateFullPath does nothing with relative paths. + Path tempRoot = File::GetCurDirectory() / "unittest_zip_savestate_test"; + File::DeleteDirRecursively(tempRoot); + EXPECT_TRUE(File::CreateDir(tempRoot)); + + Path destDir = tempRoot / "dest"; + EXPECT_TRUE(File::CreateDir(destDir)); + + Path zipPath = tempRoot / "states.zip"; + int errorp = 0; + zip_t *z = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &errorp); + EXPECT_TRUE(z != nullptr); + static const char *const contents = "data"; + EXPECT_TRUE(zip_dir_add(z, "SAVE/", ZIP_FL_ENC_UTF_8) >= 0); + EXPECT_TRUE(zip_dir_add(z, "SAVE/PPSSPP_STATE/", ZIP_FL_ENC_UTF_8) >= 0); + for (const char *name : { "SAVE/PPSSPP_STATE/UCES01401_1.00_1.jpg", "SAVE/PPSSPP_STATE/UCES01401_1.00_1.ppst" }) { + zip_source_t *source = zip_source_buffer(z, contents, strlen(contents), 0); + EXPECT_TRUE(source != nullptr); + EXPECT_TRUE(zip_file_add(z, name, source, ZIP_FL_ENC_UTF_8) >= 0); + } + EXPECT_TRUE(zip_close(z) == 0); + + z = zip_open(zipPath.c_str(), 0, &errorp); + EXPECT_TRUE(z != nullptr); + + ZipFileInfo info; + DetectZipFileContents(z, &info); + EXPECT_EQ_INT((int)info.contents, (int)ZipFileContents::SAVE_STATES); + + GameManager manager; + EXPECT_TRUE(manager.ExtractZipContents(z, destDir, info, true)); + zip_close(z); + + EXPECT_TRUE(File::Exists(destDir / "UCES01401_1.00_1.jpg") && !File::IsDirectory(destDir / "UCES01401_1.00_1.jpg")); + EXPECT_TRUE(File::Exists(destDir / "UCES01401_1.00_1.ppst") && !File::IsDirectory(destDir / "UCES01401_1.00_1.ppst")); + + File::DeleteDirRecursively(tempRoot); + return true; +} + static bool TestIsSafePathComponent() { EXPECT_TRUE(IsSafePathComponent("ULUS10041")); EXPECT_TRUE(IsSafePathComponent("NPJH-50465")); @@ -106,5 +149,7 @@ bool TestZipSlip() { return false; if (!TestZipSlipExtraction()) return false; + if (!TestSaveStateZipExtraction()) + return false; return true; } From 8c83a40d0d8b4f3e3e8a57df7382982051637470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 11:10:23 +0200 Subject: [PATCH 5/7] Windows: Reset the fullscreen double-click on focus loss, title bar clicks and UI changes The simulated double-click counted any two client clicks within the double-click time, whatever happened in between, so a click that left the pause menu followed by one in the game toggled fullscreen, which then got saved. Only count two clicks in the same UI state, and start over when the window loses focus or the title bar or its buttons are clicked. May be what's behind #22447. Co-Authored-By: Claude Opus 5.5 (1M context) --- Windows/MainWindow.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Windows/MainWindow.cpp b/Windows/MainWindow.cpp index 3cf7f60693..073ec990f9 100644 --- a/Windows/MainWindow.cpp +++ b/Windows/MainWindow.cpp @@ -165,6 +165,13 @@ namespace MainWindow { static bool hasFocus = true; static bool g_keepScreenBright = false; + // The simulated double-click that toggles fullscreen. Only two clicks on the running game count, so + // focus loss, a click outside the client area, or a UI state change in between resets it. + static double lastMouseDownTime = 0.0; + static float lastMouseDownX = -1.0f; + static float lastMouseDownY = -1.0f; + static GlobalUIState lastMouseDownUIState = UISTATE_MENU; + static bool disasmMapLoadPending = false; static bool memoryMapLoadPending = false; static bool g_wasMinimized = false; @@ -825,6 +832,7 @@ namespace MainWindow { trapMouse = true; } if (wParam == WA_INACTIVE) { + lastMouseDownTime = 0.0; System_PostUIMessage(UIMessage::LOST_FOCUS); WindowsRawInput::LoseFocus(); g_InputManager.LoseFocus(); @@ -1208,27 +1216,31 @@ namespace MainWindow { SetCapture(hWnd); // Simulate doubleclick, doesn't work with RawInput enabled - static double lastMouseDownTime; - static float lastMouseDownX = -1.0f; - static float lastMouseDownY = -1.0f; const double now = time_now_d(); - if ((now - lastMouseDownTime) < 0.001 * GetDoubleClickTime()) { + const GlobalUIState uiState = GetUIState(); + if ((now - lastMouseDownTime) < 0.001 * GetDoubleClickTime() && uiState == lastMouseDownUIState) { const float dx = lastMouseDownX - x; const float dy = lastMouseDownY - y; const float distSq = dx * dx + dy * dy; - if (distSq < 3.0f*3.0f && !g_Config.bShowTouchControls && !g_Config.bShowImDebugger && !g_Config.bMouseControl && GetUIState() == UISTATE_INGAME && g_Config.bFullscreenOnDoubleclick) { + if (distSq < 3.0f*3.0f && !g_Config.bShowTouchControls && !g_Config.bShowImDebugger && !g_Config.bMouseControl && uiState == UISTATE_INGAME && g_Config.bFullscreenOnDoubleclick) { g_Config.bFullScreen = !g_Config.bFullScreen; SendApplyFullscreenState(); } lastMouseDownTime = 0.0; } else { lastMouseDownTime = now; + lastMouseDownUIState = uiState; } lastMouseDownX = x; lastMouseDownY = y; } break; + case WM_NCLBUTTONDOWN: + // The title bar or the window's buttons, so not part of a double-click on the game. + lastMouseDownTime = 0.0; + return DefWindowProc(hWnd, message, wParam, lParam); + case WM_MOUSEMOVE: if (!touchHandler.hasTouch() || (GetMessageExtraInfo() & MOUSEEVENTF_MASK_PLUS_PENTOUCH) != MOUSEEVENTF_FROMTOUCH_NOPEN) From 0583bdf335301ee9360c3f6e553761ed29ac98cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 19:30:38 +0200 Subject: [PATCH 6/7] CmdLine: Fix inverse booleans, add --no-memstick An inverse boolean option was inverted twice, so --windowed forced fullscreen on. --no-memstick runs with the memory stick removed, for testing that from headless (which otherwise always inserts one). Co-Authored-By: Claude Opus 5.5 --- Core/CmdLine.cpp | 12 +++++++----- Core/CmdLine.h | 2 ++ unittest/UnitTest.cpp | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 44801d626a..827af1d693 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -151,14 +151,11 @@ static ParseParamResult ParseParameterStr(int argc, const char *argv[], size_t & } // Check for boolean/inverse-boolean parameter - these don't take a separate param value. - if (param.type == CmdParamType::Bool && equals(arg, std::string("--") + param.longName)) { + // SetValue() does the inverting, so both get "true". + if ((param.type == CmdParamType::Bool || param.type == CmdParamType::BoolInverse) && equals(arg, std::string("--") + param.longName)) { ParseParamResult result = SetValue(options, param, "true"); i++; // Skip --param return result; - } else if (param.type == CmdParamType::BoolInverse && equals(arg, std::string("--") + param.longName)) { - ParseParamResult result = SetValue(options, param, "false"); - i++; // Skip --param - return result; } // Check for long form with space: --param value @@ -202,6 +199,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(compare), CmdParamType::Bool, "compare", 'c', "Enable comparison mode", CmdLineMode::Headless}, {POFF(bench), CmdParamType::Bool, "bench", 'b', "Enable benchmark mode", CmdLineMode::Headless}, {POFF(oldAtrac), CmdParamType::Bool, "old-atrac", '\0', "Use old ATRAC decoder"}, + {POFF(memStickInserted), CmdParamType::BoolInverse, "no-memstick", '\0', "Run with the memory stick removed"}, {POFF(log), CmdParamType::String, "log", '\0', "Output log to FILE", CmdLineMode::Application}, {POFF(enableLogging), CmdParamType::Bool, "log", '\0', "Full log output, not just emulated printfs", CmdLineMode::Headless}, {POFF(screenshotFilename), CmdParamType::String, "screenshot", '\0', "Compare rendered output against a reference screenshot FILE", CmdLineMode::Headless}, @@ -554,6 +552,10 @@ void CommandLineOptions::ApplyToConfig() const { g_Config.bAutoRun = false; g_Config.bSaveSettings = false; } + if (memStickInserted.has_value()) { + g_Config.bMemStickInserted = memStickInserted.value(); + g_Config.DoNotSaveSetting(&g_Config.bMemStickInserted); + } if (escapeExit.has_value()) { g_Config.bPauseExitsEmulator = escapeExit.value(); } diff --git a/Core/CmdLine.h b/Core/CmdLine.h index e0b7e03148..18e882449e 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -43,6 +43,8 @@ struct CommandLineOptions { std::optional startScreen; std::optional escapeExit; + // Run as if no memory stick were inserted. + std::optional memStickInserted; std::optional pauseMenuExit; // Enables the WebSocket debugger on startup, on this port (0 = pick automatically). diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index 3a55f8191a..51d92272a3 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -3029,6 +3029,21 @@ bool TestCmdLine() { EXPECT_EQ_INT(options.timeoutWall.value_or(0), 4); EXPECT_EQ_INT(options.timeoutEmulated.value_or(0), 5); } + // Inverse booleans: --windowed turns fullscreen off, and --no-memstick removes the stick. + { + const char *argv[] = { + "ppsspp", + "--windowed", + "--no-memstick", + }; + int argc = ARRAY_SIZE(argv); + CommandLineOptions options; + options.Parse(argc, argv, CmdLineMode::Application); + EXPECT_TRUE(options.fullscreen.has_value()); + EXPECT_FALSE(options.fullscreen.value_or(true)); + EXPECT_TRUE(options.memStickInserted.has_value()); + EXPECT_FALSE(options.memStickInserted.value_or(true)); + } // Test GL version override { const char *argv[] = { From 2f21ca68377f6988b577425b26596c0646e5e2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 11 Oct 2026 19:30:39 +0200 Subject: [PATCH 7/7] Savedata, sceIo: Behave like hardware with the memory stick removed With "Memory Stick inserted" off, the savedata modes that show no dialog now fail with their own "no memory stick" error, as recorded on a PSP with the stick pulled out (pspautotests utility/savedata/nomemstick). ms0: opens, stats and directory opens fail with SCE_KERNEL_ERROR_NODEV. SOCOM: Fireteam Bravo 2's boot-time AUTOLOAD loaded its save anyway, or said there was no data rather than no memory stick. test.py gets a test_flags table for tests that need their own headless flags; this one runs with --no-memstick. Fixes #21846 Co-Authored-By: Claude Opus 5.5 --- Core/Dialog/PSPSaveDialog.cpp | 52 ++++++++++++++++++++++++++++------- Core/HLE/sceIo.cpp | 20 ++++++++++++++ pspautotests | 2 +- test.py | 14 +++++++++- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/Core/Dialog/PSPSaveDialog.cpp b/Core/Dialog/PSPSaveDialog.cpp index b754b27856..53fca2b2a1 100755 --- a/Core/Dialog/PSPSaveDialog.cpp +++ b/Core/Dialog/PSPSaveDialog.cpp @@ -1114,10 +1114,48 @@ void PSPSaveDialog::ExecuteIOAction() { ioThreadStatus = SAVEIO_READY; } +// What a mode that shows no dialog returns without a memory stick, or 0 for one not tested on +// hardware. They all start fine and fail when they run (pspautotests utility/savedata/nomemstick). +static u32 NoMemstickResult(SceUtilitySavedataType mode) { + switch (mode) { + case SCE_UTILITY_SAVEDATA_TYPE_AUTOLOAD: + return SCE_UTILITY_SAVEDATA_ERROR_LOAD_NO_MS; + case SCE_UTILITY_SAVEDATA_TYPE_AUTOSAVE: + return SCE_UTILITY_SAVEDATA_ERROR_SAVE_NO_MS; + case SCE_UTILITY_SAVEDATA_TYPE_AUTODELETE: + return SCE_UTILITY_SAVEDATA_ERROR_DELETE_NO_MS; + case SCE_UTILITY_SAVEDATA_TYPE_SIZES: + return SCE_UTILITY_SAVEDATA_ERROR_SIZES_NO_MS; + case SCE_UTILITY_SAVEDATA_TYPE_LIST: + case SCE_UTILITY_SAVEDATA_TYPE_FILES: + case SCE_UTILITY_SAVEDATA_TYPE_MAKEDATASECURE: + case SCE_UTILITY_SAVEDATA_TYPE_MAKEDATA: + case SCE_UTILITY_SAVEDATA_TYPE_READDATASECURE: + case SCE_UTILITY_SAVEDATA_TYPE_READDATA: + case SCE_UTILITY_SAVEDATA_TYPE_WRITEDATASECURE: + case SCE_UTILITY_SAVEDATA_TYPE_WRITEDATA: + case SCE_UTILITY_SAVEDATA_TYPE_ERASESECURE: + case SCE_UTILITY_SAVEDATA_TYPE_ERASE: + case SCE_UTILITY_SAVEDATA_TYPE_DELETEDATA: + case SCE_UTILITY_SAVEDATA_TYPE_GETSIZE: + return SCE_UTILITY_SAVEDATA_ERROR_RW_NO_MEMSTICK; + default: + return 0; + } +} + void PSPSaveDialog::ExecuteNotVisibleIOAction() { auto &result = ioRequest_.common.result; SceUtilitySavedataType utilityMode = (SceUtilitySavedataType)(u32)ioRequest_.mode; + if (MemoryStick_State() != PSP_MEMORYSTICK_STATE_INSERTED) { + const u32 noMemstickResult = NoMemstickResult(utilityMode); + if (noMemstickResult != 0) { + result = noMemstickResult; + return; + } + } + switch (utilityMode) { case SCE_UTILITY_SAVEDATA_TYPE_LOAD: // Only load and exit case SCE_UTILITY_SAVEDATA_TYPE_AUTOLOAD: @@ -1138,16 +1176,10 @@ void PSPSaveDialog::ExecuteNotVisibleIOAction() { result = ioParam_.GetFilesList(&ioRequest_, requestAddr, ioListSaveDirName_); break; case SCE_UTILITY_SAVEDATA_TYPE_GETSIZE: - { - bool sizeResult = ioParam_.GetSize(&ioRequest_); - // TODO: According to JPCSP, should test/verify this part but seems edge casey. - if (MemoryStick_State() != PSP_MEMORYSTICK_STATE_INSERTED) { - result = SCE_UTILITY_SAVEDATA_ERROR_RW_NO_MEMSTICK; - } else if (sizeResult) { - result = 0; - } else { - result = SCE_UTILITY_SAVEDATA_ERROR_RW_NO_DATA; - } + if (ioParam_.GetSize(&ioRequest_)) { + result = 0; + } else { + result = SCE_UTILITY_SAVEDATA_ERROR_RW_NO_DATA; } break; case SCE_UTILITY_SAVEDATA_TYPE_DELETEDATA: diff --git a/Core/HLE/sceIo.cpp b/Core/HLE/sceIo.cpp index f89c8edcde..4ce8b4fa44 100644 --- a/Core/HLE/sceIo.cpp +++ b/Core/HLE/sceIo.cpp @@ -946,10 +946,22 @@ static void __IoSchedSync(FileNode *f, int fd, int usec) { f->hasAsyncResult = false; } +// With the memory stick out, there's no device behind ms0: (pspautotests utility/savedata/nomemstick). +static bool IsRemovedMemstickPath(const char *path) { + if (MemoryStick_State() == PSP_MEMORYSTICK_STATE_INSERTED) { + return false; + } + return startsWithNoCase(path, "ms0:") || startsWithNoCase(path, "fatms0:"); +} + static u32 sceIoGetstat(const char *filename, u32 addr) { // TODO: Improve timing (although this seems normally slow..) int usec = 1000; + if (IsRemovedMemstickPath(filename)) { + return hleDelayResult(hleLogWarning(Log::sceIo, SCE_KERNEL_ERROR_NODEV, "memory stick removed"), "io getstat", usec); + } + // A real PSP refuses to stat the root of a volume - io/stat records sceIoGetstat("ms0:/") // coming back as an invalid argument rather than describing the directory. const char *colon = strchr(filename, ':'); @@ -1573,6 +1585,10 @@ static FileNode *__IoOpen(int &error, const char *filename, int flags, int mode) error = SCE_KERNEL_ERROR_ERRNO_FILE_NOT_FOUND; return nullptr; } + if (IsRemovedMemstickPath(filename)) { + error = SCE_KERNEL_ERROR_NODEV; + return nullptr; + } int access = FILEACCESS_NONE; if (flags & PSP_O_RDONLY) @@ -2549,6 +2565,10 @@ static u32 sceIoDopen(const char *path) { return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_FILE_NOT_FOUND, "nullptr file not found"); } + if (IsRemovedMemstickPath(path)) { + return hleLogWarning(Log::sceIo, SCE_KERNEL_ERROR_NODEV, "memory stick removed"); + } + double startTime = time_now_d(); bool listingExists = false; diff --git a/pspautotests b/pspautotests index 83c833136e..3066d2f076 160000 --- a/pspautotests +++ b/pspautotests @@ -1 +1 @@ -Subproject commit 83c833136e94366e2ace5128d11aa4274fd883b1 +Subproject commit 3066d2f076e00a7d8dca2975bc898ca486e597bc diff --git a/test.py b/test.py index 292f40e069..7290f121cf 100755 --- a/test.py +++ b/test.py @@ -511,6 +511,7 @@ tests_good = [ "utility/savedata/filelist", "utility/savedata/getsize", "utility/savedata/makedata", + "utility/savedata/nomemstick", "utility/systemparam/systemparam", "umd/api/api", "umd/callbacks/umd", @@ -655,6 +656,13 @@ tests_next = [ ] +# Tests that need extra headless flags, so they run in a headless process of their own. The flags +# have to match how the .expected was recorded. +test_flags = { + "utility/savedata/nomemstick": ["--no-memstick"], +} + + # These are the tests we ignore (not important, or impossible to run) tests_ignored = [ "kirk/kirk", @@ -772,7 +780,11 @@ def main(): if skipped: print("Skipping %d known failures on %s: %s" % (len(skipped), skip_arch, ", ".join(skipped))) - returncode = run_tests(tests, args) + plain = [t for t in tests if t not in test_flags] + returncode = run_tests(plain, args) + for test in tests: + if test in test_flags: + returncode = run_tests([test], args + test_flags[test]) or returncode if teamcity: return 0 return returncode