From 07ee0bd28acbd51e00c10477011fbe8d4401afea Mon Sep 17 00:00:00 2001 From: Nathan Friedly Date: Mon, 6 Apr 2026 20:02:17 -0400 Subject: [PATCH 1/4] VMManager: .m3u playlist support When loading a .m3u playlist, parse the list and load the first file instead. --- pcsx2-qt/MainWindow.cpp | 10 ++++--- pcsx2/ImGui/FullscreenUI.cpp | 2 +- pcsx2/VMManager.cpp | 52 +++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 2d27d8a07e566..85769c848cf6f 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -63,7 +63,7 @@ #endif const char* MainWindow::OPEN_FILE_FILTER = - QT_TRANSLATE_NOOP("MainWindow", "All File Types (*.bin *.iso *.cue *.mdf *.chd *.cso *.zso *.gz *.elf *.irx *.gs *.gs.xz *.gs.zst *.dump);;" + QT_TRANSLATE_NOOP("MainWindow", "All File Types (*.bin *.iso *.cue *.mdf *.chd *.cso *.zso *.gz *.elf *.irx *.gs *.gs.xz *.gs.zst *.dump *.m3u);;" "Single-Track Raw Images (*.bin *.iso);;" "Cue Sheets (*.cue);;" "Media Descriptor File (*.mdf);;" @@ -74,9 +74,10 @@ const char* MainWindow::OPEN_FILE_FILTER = "ELF Executables (*.elf);;" "IRX Executables (*.irx);;" "GS Dumps (*.gs *.gs.xz *.gs.zst);;" - "Block Dumps (*.dump)"); + "Block Dumps (*.dump);;" + "M3U Playlists (*.m3u)"); -const char* MainWindow::DISC_IMAGE_FILTER = QT_TRANSLATE_NOOP("MainWindow", "All File Types (*.bin *.iso *.cue *.mdf *.chd *.cso *.zso *.gz *.dump);;" +const char* MainWindow::DISC_IMAGE_FILTER = QT_TRANSLATE_NOOP("MainWindow", "All File Types (*.bin *.iso *.cue *.mdf *.chd *.cso *.zso *.gz *.dump *.m3u);;" "Single-Track Raw Images (*.bin *.iso);;" "Cue Sheets (*.cue);;" "Media Descriptor File (*.mdf);;" @@ -84,7 +85,8 @@ const char* MainWindow::DISC_IMAGE_FILTER = QT_TRANSLATE_NOOP("MainWindow", "All "CSO Images (*.cso);;" "ZSO Images (*.zso);;" "GZ Images (*.gz);;" - "Block Dumps (*.dump)"); + "Block Dumps (*.dump);;" + "M3U Playlists (*.m3u)"); MainWindow* g_main_window = nullptr; diff --git a/pcsx2/ImGui/FullscreenUI.cpp b/pcsx2/ImGui/FullscreenUI.cpp index e3c12eb6deb7a..a1f1aeedec6ea 100644 --- a/pcsx2/ImGui/FullscreenUI.cpp +++ b/pcsx2/ImGui/FullscreenUI.cpp @@ -746,7 +746,7 @@ ImGuiFullscreen::FileSelectorFilters FullscreenUI::GetOpenFileFilters() ImGuiFullscreen::FileSelectorFilters FullscreenUI::GetDiscImageFilters() { - return {"*.bin", "*.iso", "*.cue", "*.mdf", "*.chd", "*.cso", "*.zso", "*.gz"}; + return {"*.bin", "*.iso", "*.cue", "*.mdf", "*.chd", "*.cso", "*.zso", "*.gz", "*.m3u"}; } ImGuiFullscreen::FileSelectorFilters FullscreenUI::GetAudioFileFilters() diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index 86fa6e86a8166..f362eb7a16bb5 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -46,6 +46,7 @@ #include "common/Console.h" #include "common/Error.h" #include "common/FileSystem.h" +#include "common/Path.h" #include "common/FPControl.h" #include "common/ScopedGuard.h" #include "common/SettingsWrapper.h" @@ -1235,6 +1236,41 @@ bool VMManager::HasBootedELF() return s_current_crc != 0 && s_elf_executed; } +static std::vector ParseM3UPlaylist(const std::string& m3u_path) +{ + std::vector disc_paths; + + const std::optional content = FileSystem::ReadFileToString(m3u_path.c_str()); + if (!content.has_value()) + return disc_paths; + + const std::string_view m3u_dir = Path::GetDirectory(m3u_path); + + const std::vector lines = StringUtil::SplitString(*content, '\n', false); + for (const std::string_view line : lines) + { + // Skip empty lines and comments + const std::string_view trimmed = StringUtil::StripWhitespace(line); + if (trimmed.empty() || trimmed[0] == '#') + continue; + + // Resolve relative paths + std::string disc_path; + if (Path::IsAbsolute(trimmed)) + { + disc_path = std::string(trimmed); + } + else + { + disc_path = Path::Combine(m3u_dir, trimmed); + } + + disc_paths.push_back(std::move(disc_path)); + } + + return disc_paths; +} + bool VMManager::AutoDetectSource(const std::string& filename, Error* error) { if (!filename.empty()) @@ -1268,6 +1304,20 @@ bool VMManager::AutoDetectSource(const std::string& filename, Error* error) s_elf_override = filename; return true; } + else if (StringUtil::EndsWithNoCase(filename, ".m3u")) + { + const std::vector disc_paths = ParseM3UPlaylist(filename); + if (disc_paths.empty()) + { + Error::SetStringFmt(error, TRANSLATE_FS("VMManager", "M3U playlist '{}' does not contain any valid disc paths."), filename); + return false; + } + + // For now, just load the first disc + CDVDsys_SetFile(CDVD_SourceType::Iso, disc_paths[0]); + CDVDsys_ChangeSource(CDVD_SourceType::Iso); + return true; + } else { // TODO: Maybe we should check if it's a valid iso here... @@ -2424,7 +2474,7 @@ bool VMManager::IsSaveStateFileName(const std::string_view path) bool VMManager::IsDiscFileName(const std::string_view path) { - static const char* extensions[] = {".iso", ".bin", ".img", ".mdf", ".gz", ".cso", ".zso", ".chd"}; + static const char* extensions[] = {".iso", ".bin", ".img", ".mdf", ".gz", ".cso", ".zso", ".chd", ".m3u"}; for (const char* test_extension : extensions) { From 6884a5a648015a540c26d455c3648614002ec6be Mon Sep 17 00:00:00 2001 From: Nathan Friedly Date: Thu, 9 Apr 2026 23:21:17 -0400 Subject: [PATCH 2/4] QT: Add entries from .m3u files to change disc menu --- pcsx2-qt/MainWindow.cpp | 29 ++++++++++++++++- pcsx2-qt/MainWindow.h | 2 ++ pcsx2/VMManager.cpp | 70 +++++++++++++++++++++++++++++++++++++++-- pcsx2/VMManager.h | 6 ++++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 85769c848cf6f..669a666ee0e43 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -49,6 +49,7 @@ #include #include +#include #include #include #include @@ -1687,11 +1688,37 @@ void MainWindow::onRemoveDiscActionTriggered() void MainWindow::onChangeDiscMenuAboutToShow() { - // TODO: This is where we would populate the playlist if there is one. + const std::vector& playlist = VMManager::GetM3UPlaylistEntries(); + if (playlist.empty()) + return; + + m_change_disc_playlist_actions.clear(); + m_change_disc_playlist_actions.append(m_ui.menuChangeDisc->addSeparator()); + + const int active_index = VMManager::GetM3UPlaylistCurrentIndex(); + for (int i = 0; i < static_cast(playlist.size()); ++i) + { + const QString label = tr("%1: %2").arg(i + 1).arg(QFileInfo(QString::fromStdString(playlist[i])).fileName()); + QAction* action = m_ui.menuChangeDisc->addAction(label); + action->setCheckable(true); + action->setChecked(i == active_index); + action->setToolTip(QString::fromStdString(playlist[i])); + const std::string target_path = playlist[i]; + connect(action, &QAction::triggered, this, [target_path]() { + g_emu_thread->changeDisc(CDVD_SourceType::Iso, QString::fromStdString(target_path)); + }); + m_change_disc_playlist_actions.append(action); + } } void MainWindow::onChangeDiscMenuAboutToHide() { + for (QAction* action : m_change_disc_playlist_actions) + { + m_ui.menuChangeDisc->removeAction(action); + action->deleteLater(); + } + m_change_disc_playlist_actions.clear(); } void MainWindow::onLoadStateMenuAboutToShow() diff --git a/pcsx2-qt/MainWindow.h b/pcsx2-qt/MainWindow.h index 348df8637290c..ee0ec5e2740eb 100644 --- a/pcsx2-qt/MainWindow.h +++ b/pcsx2-qt/MainWindow.h @@ -306,6 +306,8 @@ private Q_SLOTS: InputRecordingViewer* m_input_recording_viewer = nullptr; AutoUpdaterDialog* m_auto_updater_dialog = nullptr; + QList m_change_disc_playlist_actions; + QProgressBar* m_status_progress_widget = nullptr; QLabel* m_status_verbose_widget = nullptr; QLabel* m_status_renderer_widget = nullptr; diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index f362eb7a16bb5..ddd4e1034c895 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -1236,6 +1236,37 @@ bool VMManager::HasBootedELF() return s_current_crc != 0 && s_elf_executed; } +static std::string s_current_m3u_playlist_source; +static std::vector s_current_m3u_playlist_entries; +static int s_current_m3u_playlist_index = -1; + +static void ClearM3UPlaylist() +{ + s_current_m3u_playlist_source.clear(); + s_current_m3u_playlist_entries.clear(); + s_current_m3u_playlist_index = -1; +} + +static void SetM3UPlaylist(const std::string& m3u_path, std::vector entries, int current_index) +{ + s_current_m3u_playlist_source = m3u_path; + s_current_m3u_playlist_entries = std::move(entries); + s_current_m3u_playlist_index = current_index; +} + +static void UpdateM3UPlaylistCurrentIndex(const std::string& current_disc_path) +{ + s_current_m3u_playlist_index = -1; + for (size_t i = 0; i < s_current_m3u_playlist_entries.size(); ++i) + { + if (s_current_m3u_playlist_entries[i] == current_disc_path) + { + s_current_m3u_playlist_index = static_cast(i); + return; + } + } +} + static std::vector ParseM3UPlaylist(const std::string& m3u_path) { std::vector disc_paths; @@ -1273,6 +1304,7 @@ static std::vector ParseM3UPlaylist(const std::string& m3u_path) bool VMManager::AutoDetectSource(const std::string& filename, Error* error) { + ClearM3UPlaylist(); if (!filename.empty()) { if (!FileSystem::FileExists(filename.c_str())) @@ -1280,7 +1312,7 @@ bool VMManager::AutoDetectSource(const std::string& filename, Error* error) Error::SetStringFmt(error, TRANSLATE_FS("VMManager", "Requested filename '{}' does not exist."), filename); return false; } - + if (IsGSDumpFileName(filename)) { CDVDsys_ChangeSource(CDVD_SourceType::NoDisc); @@ -1313,8 +1345,8 @@ bool VMManager::AutoDetectSource(const std::string& filename, Error* error) return false; } - // For now, just load the first disc - CDVDsys_SetFile(CDVD_SourceType::Iso, disc_paths[0]); + SetM3UPlaylist(filename, disc_paths, 0); + CDVDsys_SetFile(CDVD_SourceType::Iso, s_current_m3u_playlist_entries[0]); CDVDsys_ChangeSource(CDVD_SourceType::Iso); return true; } @@ -2367,6 +2399,28 @@ bool VMManager::ChangeDisc(CDVD_SourceType source, std::string path) const CDVD_SourceType old_type = CDVDsys_GetSourceType(); const std::string old_path(CDVDsys_GetFile(old_type)); + if (source == CDVD_SourceType::Iso && StringUtil::EndsWithNoCase(path, ".m3u")) + { + const std::vector disc_paths = ParseM3UPlaylist(path); + if (disc_paths.empty()) + { + return false; + } + + SetM3UPlaylist(path, disc_paths, 0); + path = s_current_m3u_playlist_entries[0]; + } + else if (source != CDVD_SourceType::Iso) + { + ClearM3UPlaylist(); + } + else if (!path.empty()) + { + UpdateM3UPlaylistCurrentIndex(path); + if (s_current_m3u_playlist_index < 0) + ClearM3UPlaylist(); + } + CDVDsys_ChangeSource(source); if (!path.empty()) CDVDsys_SetFile(source, path); @@ -2421,6 +2475,16 @@ bool VMManager::ChangeDisc(CDVD_SourceType source, std::string path) return result; } +const std::vector& VMManager::GetM3UPlaylistEntries() +{ + return s_current_m3u_playlist_entries; +} + +int VMManager::GetM3UPlaylistCurrentIndex() +{ + return s_current_m3u_playlist_index; +} + bool VMManager::SetELFOverride(std::string path) { if (!HasValidVM() || (!path.empty() && !FileSystem::FileExists(path.c_str()))) diff --git a/pcsx2/VMManager.h b/pcsx2/VMManager.h index dd32d45a0eea8..881a55d7d319d 100644 --- a/pcsx2/VMManager.h +++ b/pcsx2/VMManager.h @@ -89,6 +89,12 @@ namespace VMManager /// Returns the path of the disc currently running. std::string GetDiscPath(); + /// Returns the list of disc paths from the currently loaded M3U playlist. + const std::vector& GetM3UPlaylistEntries(); + + /// Returns the current selected disc index for the loaded M3U playlist, or -1 if none. + int GetM3UPlaylistCurrentIndex(); + /// Returns the serial of the disc currently running. std::string GetDiscSerial(); From 42e2b65c73603efc059e1e0dc86452f7f5fea67f Mon Sep 17 00:00:00 2001 From: Nathan Friedly Date: Fri, 10 Apr 2026 23:02:31 -0400 Subject: [PATCH 3/4] FSUI: Add Change Disc submenu with .m3u entries when present If a .m3u file was opened, Change Disc now opens a sub menu with the entries from the playlist and an option to open the file browser. If there wasn't a .m3u playlist, it just opens the file browser directly as before. --- pcsx2/ImGui/FullscreenUI.cpp | 46 +++++++++++++++++++++++++++-- pcsx2/ImGui/FullscreenUI_Internal.h | 1 + 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/pcsx2/ImGui/FullscreenUI.cpp b/pcsx2/ImGui/FullscreenUI.cpp index a1f1aeedec6ea..ebe00ecd487d2 100644 --- a/pcsx2/ImGui/FullscreenUI.cpp +++ b/pcsx2/ImGui/FullscreenUI.cpp @@ -964,9 +964,16 @@ void FullscreenUI::RequestChangeDisc() { ConfirmShutdownIfMemcardBusy([](bool result) { if (result) - DoChangeDiscFromFile(); + { + if (!VMManager::GetM3UPlaylistEntries().empty()) + OpenPauseSubMenu(PauseSubMenu::ChangeDisc); + else + DoChangeDiscFromFile(); + } else + { ClosePauseMenu(); + } }); } @@ -1634,10 +1641,14 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) 11, // None 4, // Exit 3, // Achievements + 0, // ChangeDisc placeholder }; + const std::vector& change_disc_playlist = VMManager::GetM3UPlaylistEntries(); + const u32 change_disc_item_count = static_cast(change_disc_playlist.size()) + 2; // +2 for back and from file const bool just_focused = ResetFocusHere(); - BeginMenuButtons(submenu_item_count[static_cast(s_current_pause_submenu)], 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, + BeginMenuButtons((s_current_pause_submenu == PauseSubMenu::ChangeDisc) ? change_disc_item_count : submenu_item_count[static_cast(s_current_pause_submenu)], + 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_Y_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); if (!ImGui::IsPopupOpen(0u, ImGuiPopupFlags_AnyPopup)) @@ -1668,6 +1679,10 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) first_id = ImGui::GetID(FSUI_ICONSTR(ICON_PF_BACKWARD, "Back To Pause Menu")); last_id = ImGui::GetID(FSUI_ICONSTR(ICON_FA_STOPWATCH, "Leaderboards")); break; + case PauseSubMenu::ChangeDisc: + first_id = ImGui::GetID(FSUI_ICONSTR(ICON_PF_BACKWARD, "Back To Pause Menu")); + last_id = ImGui::GetID(FSUI_ICONSTR(ICON_FA_FOLDER_OPEN, "From File...")); + break; } if (first_id != 0 && last_id != 0) @@ -1798,6 +1813,33 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) OpenLeaderboardsWindow(); } break; + + + case PauseSubMenu::ChangeDisc: + { + if (ActiveButton(FSUI_ICONSTR(ICON_PF_BACKWARD, "Back To Pause Menu"), false) || WantsToCloseMenu()) + OpenPauseSubMenu(PauseSubMenu::None); + + const std::vector& playlist = VMManager::GetM3UPlaylistEntries(); + const int active_index = VMManager::GetM3UPlaylistCurrentIndex(); + + for (int i = 0; i < static_cast(playlist.size()); ++i) + { + const std::string label = fmt::format("{}: {}", i + 1, Path::GetFileName(playlist[i])); + const bool is_active = (i == active_index); + if (ActiveButton(FSUI_ICONSTR(ICON_FA_COMPACT_DISC, label.c_str()), is_active)) + { + Host::RunOnCPUThread([path = playlist[i]]() { VMManager::ChangeDisc(CDVD_SourceType::Iso, path); }); + ClosePauseMenu(); + } + } + + if (ActiveButton(FSUI_ICONSTR(ICON_FA_FOLDER_OPEN, "From File..."), false)) + { + DoChangeDiscFromFile(); + } + } + break; } EndMenuButtons(); diff --git a/pcsx2/ImGui/FullscreenUI_Internal.h b/pcsx2/ImGui/FullscreenUI_Internal.h index e51db5ff468bc..16bd9c98aade1 100644 --- a/pcsx2/ImGui/FullscreenUI_Internal.h +++ b/pcsx2/ImGui/FullscreenUI_Internal.h @@ -155,6 +155,7 @@ namespace FullscreenUI None, Exit, Achievements, + ChangeDisc, }; enum class SettingsPage From 46ddb3e10831d1413c10c64645862dcd9eb265b5 Mon Sep 17 00:00:00 2001 From: Nathan Friedly Date: Sun, 12 Apr 2026 23:20:05 -0400 Subject: [PATCH 4/4] FSUI: make change disc sub menu take up the entire screen width to avoid unnecesarily truncating file names Also center change disc menu to avoid writing on top of the text at the bottom right --- pcsx2/ImGui/FullscreenUI.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pcsx2/ImGui/FullscreenUI.cpp b/pcsx2/ImGui/FullscreenUI.cpp index ebe00ecd487d2..66811906eceba 100644 --- a/pcsx2/ImGui/FullscreenUI.cpp +++ b/pcsx2/ImGui/FullscreenUI.cpp @@ -1631,7 +1631,8 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) } } - const ImVec2 window_size(LayoutScale(500.0f, LAYOUT_SCREEN_HEIGHT)); + const float window_width = (s_current_pause_submenu == PauseSubMenu::ChangeDisc) ? display_size.x : 500.0f; + const ImVec2 window_size(LayoutScale(window_width, LAYOUT_SCREEN_HEIGHT)); const ImVec2 window_pos(0.0f, display_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT) - window_size.y); if (BeginFullscreenWindow(window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f, @@ -1647,8 +1648,9 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) const std::vector& change_disc_playlist = VMManager::GetM3UPlaylistEntries(); const u32 change_disc_item_count = static_cast(change_disc_playlist.size()) + 2; // +2 for back and from file const bool just_focused = ResetFocusHere(); + const float y_align = (s_current_pause_submenu == PauseSubMenu::ChangeDisc) ? 0.5f : 1.0f; BeginMenuButtons((s_current_pause_submenu == PauseSubMenu::ChangeDisc) ? change_disc_item_count : submenu_item_count[static_cast(s_current_pause_submenu)], - 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, + y_align, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_Y_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); if (!ImGui::IsPopupOpen(0u, ImGuiPopupFlags_AnyPopup))