summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt3
-rw-r--r--Data/Sys/GameSettings/UGP.ini8
-rw-r--r--Externals/mGBA/CMakeLists.txt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt3
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityResultLaunchers.kt20
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt40
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java3
-rw-r--r--Source/Android/app/src/main/res/values/strings.xml5
-rw-r--r--Source/Core/Common/ChunkFile.h7
-rw-r--r--Source/Core/Core/CMakeLists.txt2
-rw-r--r--Source/Core/Core/Config/MainSettings.cpp6
-rw-r--r--Source/Core/Core/Config/MainSettings.h4
-rw-r--r--Source/Core/Core/HW/GBACore.cpp51
-rw-r--r--Source/Core/Core/HW/HSP/HSP_Device.cpp6
-rw-r--r--Source/Core/Core/HW/HSP/HSP_Device.h1
-rw-r--r--Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp578
-rw-r--r--Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h82
-rw-r--r--Source/Core/Core/State.cpp2
-rw-r--r--Source/Core/DolphinLib.props2
-rw-r--r--Source/Core/DolphinQt/Settings/GameCubePane.cpp5
-rw-r--r--Source/Core/DolphinQt/Settings/GameCubePane.h4
23 files changed, 817 insertions, 18 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2711a222a6..f07566c044 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -733,9 +733,6 @@ if(USE_DISCORD_PRESENCE)
include_directories(Externals/discord-rpc/include)
endif()
-if(NOT ENABLE_QT)
- set(USE_MGBA 0)
-endif()
if(USE_MGBA)
dolphin_find_optional_system_library(LIBMGBA Externals/mGBA 0.11)
endif()
diff --git a/Data/Sys/GameSettings/UGP.ini b/Data/Sys/GameSettings/UGP.ini
new file mode 100644
index 0000000000..39d067edd3
--- /dev/null
+++ b/Data/Sys/GameSettings/UGP.ini
@@ -0,0 +1,8 @@
+# UGPP01, UGPE01, UGPJ01 - Game Boy Player
+
+[Core]
+# Attach Game Boy Player hardware.
+HSPDevice = 2
+# Overclock to work around sporadic audio/video stream failures.
+Overclock = 1.25
+OverclockEnable = True
diff --git a/Externals/mGBA/CMakeLists.txt b/Externals/mGBA/CMakeLists.txt
index 122e484b2d..61736a02f1 100644
--- a/Externals/mGBA/CMakeLists.txt
+++ b/Externals/mGBA/CMakeLists.txt
@@ -11,6 +11,7 @@ if(NOT MSVC)
endif()
if(ANDROID)
+ target_compile_definitions(mgba PUBLIC ENABLE_VFS_FD)
target_compile_definitions(mgba PRIVATE -Dfutimes=futimens)
endif()
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
index b6a03e6b61..eab79765c6 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
@@ -145,6 +145,7 @@ enum class BooleanSetting(
"EnablePlayTimeTracking",
true
),
+ MAIN_GBA_THREADS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_GBA, "Threads", true),
MAIN_EXPAND_TO_CUTOUT_AREA(
Settings.FILE_DOLPHIN,
Settings.SECTION_INI_INTERFACE,
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
index 9f91a4244c..3a6a097642 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
@@ -112,6 +112,7 @@ class Settings : Closeable {
const val SECTION_INI_CORE = "Core"
const val SECTION_INI_INTERFACE = "Interface"
const val SECTION_INI_DSP = "DSP"
+ const val SECTION_INI_GBA = "GBA"
const val SECTION_LOGGER_LOGS = "Logs"
const val SECTION_LOGGER_OPTIONS = "Options"
const val SECTION_GFX_HARDWARE = "Hardware"
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
index c4f1107bcd..512a33ddbb 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
@@ -69,6 +69,9 @@ enum class StringSetting(
""
),
MAIN_WFS_PATH(Settings.FILE_DOLPHIN, Settings.SECTION_INI_GENERAL, "WFSPath", ""),
+ MAIN_GBA_BIOS_PATH(Settings.FILE_DOLPHIN, Settings.SECTION_INI_GBA, "BIOS", ""),
+ MAIN_GB_PLAYER_ROM(Settings.FILE_DOLPHIN, Settings.SECTION_INI_GBA, "GBPlayerRom", ""),
+ MAIN_GBA_SAVES_PATH(Settings.FILE_DOLPHIN, Settings.SECTION_INI_GBA, "SavesPath", ""),
MAIN_TRIFORCE_IP_REDIRECTIONS(
Settings.FILE_DOLPHIN,
Settings.SECTION_INI_CORE,
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityResultLaunchers.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityResultLaunchers.kt
index 9c13be1b19..2750bef687 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityResultLaunchers.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityResultLaunchers.kt
@@ -34,6 +34,26 @@ class SettingsActivityResultLaunchers(
)
}
+ val requestGbaRomFile = fragment.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ onFileResult(
+ result,
+ FileBrowserHelper.GBA_ROM_EXTENSIONS,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ }
+
+ val requestBinFile = fragment.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ onFileResult(
+ result,
+ FileBrowserHelper.BIN_EXTENSION,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ }
+
val requestRawFile = fragment.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
index 1f68bbbd1a..b11a0323a0 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -702,6 +702,46 @@ class SettingsFragmentPresenter(
MenuTag.CONFIG_SERIALPORT1
)
)
+
+ sl.add(HeaderSetting(context, R.string.gba_settings, 0))
+ sl.add(
+ SwitchSetting(
+ context,
+ BooleanSetting.MAIN_GBA_THREADS,
+ R.string.gba_threads,
+ 0,
+ )
+ )
+ sl.add(
+ FilePicker(
+ context,
+ StringSetting.MAIN_GBA_BIOS_PATH,
+ R.string.gba_bios_path,
+ 0,
+ fragmentView.activityResultLaunchers.requestBinFile,
+ "/GBA/gba_bios.bin"
+ )
+ )
+ sl.add(
+ FilePicker(
+ context,
+ StringSetting.MAIN_GB_PLAYER_ROM,
+ R.string.gb_player_rom,
+ 0,
+ fragmentView.activityResultLaunchers.requestGbaRomFile,
+ null
+ )
+ )
+ sl.add(
+ DirectoryPicker(
+ context,
+ StringSetting.MAIN_GBA_SAVES_PATH,
+ R.string.gba_saves_path,
+ 0,
+ fragmentView.activityResultLaunchers.requestDirectory,
+ "/GBA/Saves/"
+ )
+ )
}
private fun addWiiSettings(sl: ArrayList<SettingsItem>) {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java
index 061d102be8..f52a70216f 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java
@@ -39,6 +39,9 @@ public final class FileBrowserHelper
GAME_LIKE_EXTENSIONS.add("dff");
}
+ public static final HashSet<String> GBA_ROM_EXTENSIONS = new HashSet<>(Arrays.asList(
+ "gba", "gbc", "gb", "agb", "mb", "rom", "bin"));
+
public static final HashSet<String> BIN_EXTENSION = new HashSet<>(Collections.singletonList(
"bin"));
diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml
index 855cb56ab8..c1c9ec4b09 100644
--- a/Source/Android/app/src/main/res/values/strings.xml
+++ b/Source/Android/app/src/main/res/values/strings.xml
@@ -85,6 +85,11 @@
<string name="slot_a_device">GameCube Slot A Device</string>
<string name="slot_b_device">GameCube Slot B Device</string>
<string name="serial_port_1_device">GameCube Serial Port 1 Device</string>
+ <string name="gba_settings">GBA Settings</string>
+ <string name="gba_threads">Run GBA Cores in Dedicated Threads</string>
+ <string name="gba_bios_path">BIOS</string>
+ <string name="gb_player_rom">Game Boy Player ROM</string>
+ <string name="gba_saves_path">Saves</string>
<string name="wii_submenu">Wii</string>
<string name="wii_misc_settings">Misc Settings</string>
<string name="wii_sd_card_settings">SD Card Settings</string>
diff --git a/Source/Core/Common/ChunkFile.h b/Source/Core/Common/ChunkFile.h
index f44f08d279..a9737c1e0e 100644
--- a/Source/Core/Common/ChunkFile.h
+++ b/Source/Core/Common/ChunkFile.h
@@ -300,6 +300,13 @@ public:
}
}
+ void DoPointer(void*& x, void* const base)
+ {
+ auto* ptr = static_cast<u8*>(x);
+ DoPointer(ptr, static_cast<u8*>(base));
+ x = ptr;
+ }
+
void DoMarker(const std::string& prevName, u32 arbitraryNumber = 0x42)
{
u32 cookie = arbitraryNumber;
diff --git a/Source/Core/Core/CMakeLists.txt b/Source/Core/Core/CMakeLists.txt
index 61ae899f00..ba72d80a84 100644
--- a/Source/Core/Core/CMakeLists.txt
+++ b/Source/Core/Core/CMakeLists.txt
@@ -250,6 +250,8 @@ add_library(core
HW/HSP/HSP_Device.h
HW/HSP/HSP_DeviceARAMExpansion.cpp
HW/HSP/HSP_DeviceARAMExpansion.h
+ HW/HSP/HSP_DeviceGBPlayer.cpp
+ HW/HSP/HSP_DeviceGBPlayer.h
HW/HSP/HSP_DeviceNull.cpp
HW/HSP/HSP_DeviceNull.h
HW/HW.cpp
diff --git a/Source/Core/Core/Config/MainSettings.cpp b/Source/Core/Core/Config/MainSettings.cpp
index 147aeedf50..2e1d41a151 100644
--- a/Source/Core/Core/Config/MainSettings.cpp
+++ b/Source/Core/Core/Config/MainSettings.cpp
@@ -398,11 +398,13 @@ void SetIsoPaths(const std::vector<std::string>& paths)
#ifdef HAS_LIBMGBA
const Info<std::string> MAIN_GBA_BIOS_PATH{{System::Main, "GBA", "BIOS"}, ""};
-const std::array<Info<std::string>, 4> MAIN_GBA_ROM_PATHS{
+const std::array<Info<std::string>, 5> MAIN_GBA_ROM_PATHS{
Info<std::string>{{System::Main, "GBA", "Rom1"}, ""},
Info<std::string>{{System::Main, "GBA", "Rom2"}, ""},
Info<std::string>{{System::Main, "GBA", "Rom3"}, ""},
- Info<std::string>{{System::Main, "GBA", "Rom4"}, ""}};
+ Info<std::string>{{System::Main, "GBA", "Rom4"}, ""},
+ Info<std::string>{{System::Main, "GBA", "GBPlayerRom"}, ""},
+};
const Info<std::string> MAIN_GBA_SAVES_PATH{{System::Main, "GBA", "SavesPath"}, ""};
const Info<bool> MAIN_GBA_SAVES_IN_ROM_PATH{{System::Main, "GBA", "SavesInRomPath"}, false};
const Info<bool> MAIN_GBA_THREADS{{System::Main, "GBA", "Threads"}, true};
diff --git a/Source/Core/Core/Config/MainSettings.h b/Source/Core/Core/Config/MainSettings.h
index a1baf26242..45ca6e972b 100644
--- a/Source/Core/Core/Config/MainSettings.h
+++ b/Source/Core/Core/Config/MainSettings.h
@@ -224,10 +224,12 @@ void SetIsoPaths(const std::vector<std::string>& paths);
#ifdef HAS_LIBMGBA
extern const Info<std::string> MAIN_GBA_BIOS_PATH;
-extern const std::array<Info<std::string>, 4> MAIN_GBA_ROM_PATHS;
+extern const std::array<Info<std::string>, 5> MAIN_GBA_ROM_PATHS;
extern const Info<std::string> MAIN_GBA_SAVES_PATH;
extern const Info<bool> MAIN_GBA_SAVES_IN_ROM_PATH;
extern const Info<bool> MAIN_GBA_THREADS;
+
+static constexpr std::size_t GBPLAYER_GBA_INDEX = 4;
#endif
// Main.Network
diff --git a/Source/Core/Core/HW/GBACore.cpp b/Source/Core/Core/HW/GBACore.cpp
index 09f56fed5b..2226610d35 100644
--- a/Source/Core/Core/HW/GBACore.cpp
+++ b/Source/Core/Core/HW/GBACore.cpp
@@ -39,6 +39,10 @@
#include "Core/NetPlayProto.h"
#include "Core/System.h"
+#ifdef ANDROID
+#include "jni/AndroidCommon/AndroidCommon.h"
+#endif
+
namespace HW::GBA
{
namespace
@@ -126,6 +130,15 @@ static VFile* OpenROM_Zip(const char* path)
return vf;
}
+static VFile* OpenReadOnlyFile(const char* path)
+{
+#ifdef ANDROID
+ if (IsPathAndroidContent(path))
+ return VFileFromFD(OpenAndroidContent(path, OpenModeToAndroid("r")));
+#endif
+ return VFileOpen(path, O_RDONLY);
+}
+
static VFile* OpenROM(const char* rom_path)
{
VFile* vf{};
@@ -134,7 +147,7 @@ static VFile* OpenROM(const char* rom_path)
if (!vf)
vf = OpenROM_Zip(rom_path);
if (!vf)
- vf = VFileOpen(rom_path, O_RDONLY);
+ vf = OpenReadOnlyFile(rom_path);
if (!vf)
return nullptr;
@@ -238,11 +251,15 @@ bool Core::Start(u64 gc_ticks)
m_gc_ticks_remainder = 0;
m_keys = 0;
- SetSIODriver();
+ if (m_device_number != Config::GBPLAYER_GBA_INDEX)
+ {
+ SetSIODriver();
+ SetAVStream();
+ }
+
SetVideoBuffer();
SetAudioBufferSize();
AddCallbacks();
- SetAVStream();
SetupEvent();
m_core->reset(m_core);
@@ -334,7 +351,7 @@ void Core::EReaderQueueCard(std::string_view card_path)
bool Core::LoadBIOS(const char* bios_path)
{
- VFile* vf = VFileOpen(bios_path, O_RDONLY);
+ VFile* vf = OpenReadOnlyFile(bios_path);
if (!vf)
{
ERROR_LOG_FMT(CORE, "GBA{0} failed to open the BIOS in {1}", m_device_number + 1, bios_path);
@@ -394,10 +411,28 @@ void Core::SetSIODriver()
void Core::SetVideoBuffer()
{
- u32 width, height;
- m_core->currentVideoSize(m_core, &width, &height);
- m_video_buffer.resize(width * height);
- m_core->setVideoBuffer(m_core, m_video_buffer.data(), width);
+ if (m_device_number == Config::GBPLAYER_GBA_INDEX)
+ {
+ // GBPlayer expects a GBA-sized video buffer even in GB mode.
+ // Clear it first to avoid stuck colors from the previous game on switch from GBA->GB.
+ m_video_buffer.clear();
+ m_video_buffer.resize(std::size_t{GBA_VIDEO_HORIZONTAL_PIXELS} * GBA_VIDEO_VERTICAL_PIXELS);
+
+ constexpr size_t GB_VIDEO_OFFSET = 1960;
+
+ m_core->setVideoBuffer(
+ m_core, m_video_buffer.data() + ((GetPlatform() == mPLATFORM_GBA) ? 0 : GB_VIDEO_OFFSET),
+ GBA_VIDEO_HORIZONTAL_PIXELS);
+ }
+ else
+ {
+ u32 width;
+ u32 height;
+ m_core->currentVideoSize(m_core, &width, &height);
+ m_video_buffer.resize(std::size_t{width} * height);
+ m_core->setVideoBuffer(m_core, m_video_buffer.data(), width);
+ }
+
if (auto host = m_host.lock())
host->GameChanged();
}
diff --git a/Source/Core/Core/HW/HSP/HSP_Device.cpp b/Source/Core/Core/HW/HSP/HSP_Device.cpp
index 069fca3753..7a75afc79c 100644
--- a/Source/Core/Core/HW/HSP/HSP_Device.cpp
+++ b/Source/Core/Core/HW/HSP/HSP_Device.cpp
@@ -6,7 +6,9 @@
#include <memory>
#include "Core/HW/HSP/HSP_DeviceARAMExpansion.h"
+#include "Core/HW/HSP/HSP_DeviceGBPlayer.h"
#include "Core/HW/HSP/HSP_DeviceNull.h"
+#include "Core/System.h"
namespace HSP
{
@@ -26,10 +28,14 @@ void IHSPDevice::DoState(PointerWrap& p)
// F A C T O R Y
std::unique_ptr<IHSPDevice> HSPDevice_Create(const HSPDeviceType device)
{
+ auto& system = Core::System::GetInstance();
+
switch (device)
{
case HSPDeviceType::ARAMExpansion:
return std::make_unique<CHSPDevice_ARAMExpansion>(device);
+ case HSPDeviceType::GBPlayer:
+ return std::make_unique<CHSPDevice_GBPlayer>(system, device);
case HSPDeviceType::None:
default:
return std::make_unique<CHSPDevice_Null>(device);
diff --git a/Source/Core/Core/HW/HSP/HSP_Device.h b/Source/Core/Core/HW/HSP/HSP_Device.h
index b9caa21df8..baf8fc025e 100644
--- a/Source/Core/Core/HW/HSP/HSP_Device.h
+++ b/Source/Core/Core/HW/HSP/HSP_Device.h
@@ -15,6 +15,7 @@ enum class HSPDeviceType : int
{
None,
ARAMExpansion,
+ GBPlayer,
};
class IHSPDevice
diff --git a/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp b/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp
new file mode 100644
index 0000000000..470ba38a23
--- /dev/null
+++ b/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp
@@ -0,0 +1,578 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/HW/HSP/HSP_DeviceGBPlayer.h"
+
+#include <cstring>
+#include <ratio>
+
+#if defined(HAS_LIBMGBA)
+#include <mgba-util/audio-buffer.h>
+#include <mgba-util/audio-resampler.h>
+#include <mgba/internal/gba/gba.h>
+#include <mgba/internal/gba/video.h>
+
+#include "Core/HW/GBACore.h"
+#endif
+
+#include "Common/ChunkFile.h"
+#include "Common/Logging/Log.h"
+
+#include "Core/Config/MainSettings.h"
+#include "Core/CoreTiming.h"
+#include "Core/HW/ProcessorInterface.h"
+#include "Core/HW/SystemTimers.h"
+#include "Core/System.h"
+
+namespace
+{
+
+enum class GBPRegister : u8
+{
+ Test = 0x10,
+ Video = 0x11,
+ Control = 0x14,
+ SIOControl = 0x15,
+ Audio = 0x18,
+ SIO = 0x19,
+ Keypad = 0x1c,
+ IRQ = 0x1d,
+};
+
+constexpr u16 IRQ_ASSERTED = 0x8000;
+
+constexpr u8 CONTROL_CART_DETECTED = 0x01;
+constexpr u8 CONTROL_CART_INSERTED = 0x02;
+constexpr u8 CONTROL_3V = 0x04;
+constexpr u8 CONTROL_5V = 0x08;
+constexpr u8 CONTROL_MASK_IRQ = 0x10;
+// constexpr u8 CONTROL_SLEEP = 0x20;
+// constexpr u8 CONTROL_LINK_CABLE = 0x40;
+// constexpr u8 CONTROL_LINK_ENABLE = 0x80;
+
+constexpr u32 COMMAND_ADDRESS_MASK = 0x1f;
+constexpr u32 AV_ADDRESS_MASK = 0xff8;
+
+class CGBPlayer_Dummy : public HSP::IGBPlayer
+{
+public:
+ using IGBPlayer::IGBPlayer;
+
+ void Reset() override {}
+ void Stop() override {}
+
+ bool IsLoaded() const override { return false; }
+ bool IsGBA() const override { return false; }
+
+ void ReadScanlines(std::span<u32, AV_REGION_SIZE> scanlines) override {}
+ void ReadAudio(std::span<u8, AV_REGION_SIZE> audio) override {}
+
+ void SetKeys(u16 keys) override {}
+
+ void DoState(PointerWrap& p) override {}
+};
+
+} // namespace
+
+namespace HSP
+{
+
+enum class CHSPDevice_GBPlayer::IRQ : int
+{
+ Link = 0,
+ GamePak = 1,
+ Sleep = 2,
+ Serial = 3,
+ Video = 4,
+ Audio = 5,
+};
+
+#if defined(HAS_LIBMGBA)
+
+class CGBPlayer_mGBA : public IGBPlayer
+{
+public:
+ CGBPlayer_mGBA(Core::System&, CHSPDevice_GBPlayer*);
+
+ CGBPlayer_mGBA(const CGBPlayer_mGBA&) = delete;
+ CGBPlayer_mGBA(CGBPlayer_mGBA&&) = delete;
+ CGBPlayer_mGBA& operator=(const CGBPlayer_mGBA&) = delete;
+ CGBPlayer_mGBA& operator=(CGBPlayer_mGBA&&) = delete;
+
+ void Reset() override;
+ void Stop() override;
+
+ bool IsLoaded() const override;
+ bool IsGBA() const override;
+
+ void ReadScanlines(std::span<u32, AV_REGION_SIZE> scanlines) override;
+ void ReadAudio(std::span<u8, AV_REGION_SIZE> audio) override;
+
+ void SetKeys(u16 keys) override;
+
+ void DoState(PointerWrap& p) override;
+
+private:
+ static constexpr std::size_t AUDIO_BUFFER_SIZE = 512;
+
+ void UpdateAudio(s64 cycles_late);
+ void UpdateVideo(u32 next_scanlines, s64 cycles_late);
+
+ HW::GBA::Core m_gba_core;
+
+ mAudioBuffer m_audio_buffer;
+ mAudioResampler m_audio_resampler;
+
+ CoreTiming::EventType* m_update_audio_event = nullptr;
+ CoreTiming::EventType* m_update_video_event = nullptr;
+
+ // These are used to calculate timings that don't drift.
+ u32 m_video_irq_phase = 0;
+ u16 m_audio_irq_phase = 0;
+
+ u16 m_current_scanline_index = 0;
+ u16 m_keys = 0;
+
+ // Used to trigger a video IRQ during the next audio IRQ in UpdateAudio.
+ // Separately timed video IRQs cause the AV stream to enter a bad state.
+ // It's very fragile for some reason. This might be a CPU timing issue ?
+ bool m_generate_video_irq = false;
+};
+
+CGBPlayer_mGBA::CGBPlayer_mGBA(Core::System& system, CHSPDevice_GBPlayer* player)
+ : IGBPlayer(system, player), m_gba_core{m_system, Config::GBPLAYER_GBA_INDEX}
+{
+ auto& core_timing = m_system.GetCoreTiming();
+
+ m_update_audio_event =
+ core_timing.RegisterEvent("GBPmGBAUpdateAudio", [this](Core::System&, u64, s64 cycles_late) {
+ UpdateAudio(cycles_late);
+ });
+ m_update_video_event = core_timing.RegisterEvent(
+ "GBPmGBAUpdateVideo", [this](Core::System&, u64 user_data, s64 cycles_late) {
+ UpdateVideo(u32(user_data), cycles_late);
+ });
+
+ mAudioBufferInit(&m_audio_buffer, AUDIO_BUFFER_SIZE, AUDIO_CHANNEL_COUNT);
+ mAudioResamplerInit(&m_audio_resampler, mINTERPOLATOR_SINC);
+ mAudioResamplerSetDestination(&m_audio_resampler, &m_audio_buffer, AUDIO_SAMPLE_RATE);
+
+ // TODO: Does the real hardware boot on power up or wait for the GBPRegister::Control command ?
+ Reset();
+}
+
+void CGBPlayer_mGBA::Reset()
+{
+ Stop();
+
+ m_gba_core.Start(m_system.GetCoreTiming().GetTicks());
+
+ // Potentially BIOS or ROM could not be loaded.
+ if (!m_gba_core.IsStarted())
+ return;
+
+ auto& core_timing = m_system.GetCoreTiming();
+
+ // Start audio/video updates after a delay. This timing isn't critical.
+ const auto ticks_per_second = m_system.GetSystemTimers().GetTicksPerSecond();
+ core_timing.ScheduleEvent(ticks_per_second / 50, m_update_audio_event);
+ core_timing.ScheduleEvent(ticks_per_second / 50, m_update_video_event, 0);
+}
+
+void CGBPlayer_mGBA::Stop()
+{
+ if (!m_gba_core.IsStarted())
+ return;
+
+ auto& core_timing = m_system.GetCoreTiming();
+
+ core_timing.RemoveEvent(m_update_audio_event);
+ core_timing.RemoveEvent(m_update_video_event);
+
+ m_video_irq_phase = 0;
+ m_audio_irq_phase = 0;
+
+ m_current_scanline_index = 0;
+ m_keys = 0;
+ m_generate_video_irq = false;
+
+ m_gba_core.Stop();
+
+ mAudioBufferClear(&m_audio_buffer);
+}
+
+bool CGBPlayer_mGBA::IsLoaded() const
+{
+ return true;
+}
+
+bool CGBPlayer_mGBA::IsGBA() const
+{
+ return m_gba_core.IsStarted() && m_gba_core.GetPlatform() == mPLATFORM_GBA;
+}
+
+void CGBPlayer_mGBA::ReadScanlines(std::span<u32, AV_REGION_SIZE> scanlines)
+{
+ DEBUG_LOG_FMT(HSP, "GBPlayer: ReadScanlines: {}", m_current_scanline_index);
+
+ if (m_current_scanline_index >= GBA_VIDEO_VERTICAL_PIXELS)
+ return;
+
+ if (!m_gba_core.IsStarted())
+ return;
+
+ // Since the GBA core is idle, this is a convenient time to read and resample some audio.
+ mAudioResamplerSetSource(&m_audio_resampler, m_gba_core.GetAudioBuffer(),
+ m_gba_core.GetAudioSampleRate(), true);
+ mAudioResamplerProcess(&m_audio_resampler);
+
+ const std::span video_buffer = m_gba_core.GetVideoBuffer();
+
+ for (u32 i = 0; i != GBA_VIDEO_HORIZONTAL_PIXELS * 4; ++i)
+ {
+ const u32 color =
+ M_RGB8_TO_RGB5(video_buffer[(m_current_scanline_index * GBA_VIDEO_HORIZONTAL_PIXELS) + i]);
+ u32 c = color & 0xFF;
+ c |= (color & 0xFF00u) << 8;
+ scanlines[i] = c | (c << 8); // Parity
+ }
+
+ if (m_current_scanline_index == 0)
+ scanlines[0] |= 0x80800000;
+
+ m_current_scanline_index += 4;
+
+ if (m_current_scanline_index < GBA_VIDEO_VERTICAL_PIXELS)
+ m_generate_video_irq = true;
+}
+
+// TODO: Explain the meaning of this audio sample processing.
+static constexpr u8 SampleToBits(u16* value)
+{
+ const auto x = std::min<u32>(*value, 8);
+ *value -= x;
+ return u8(0xff00u >> x);
+}
+
+void CGBPlayer_mGBA::ReadAudio(std::span<u8, AV_REGION_SIZE> audio)
+{
+ std::array<std::array<s16, AUDIO_CHANNEL_COUNT>, AUDIO_READ_SIZE> buffer;
+ const auto read_count = mAudioBufferRead(&m_audio_buffer, buffer.data()->data(), buffer.size());
+
+ // Each s16 sample is converted to a u9 which is then converted to 64 u8 values.
+ // This must have something to do with interpretation by the DSP ?
+ // TODO: Explain things better.
+
+ constexpr u32 out_bytes_per_sample = AV_REGION_SIZE / AUDIO_READ_SIZE / AUDIO_CHANNEL_COUNT;
+ static_assert(out_bytes_per_sample == 64);
+
+ auto out_it = audio.begin();
+ for (auto [l, r] : std::span{buffer}.first(read_count))
+ {
+ auto l_9bit = u16(u32(l + 0x8000) >> 7u);
+ auto r_9bit = u16(u32(r + 0x8000) >> 7u);
+
+ for (u32 i = 0; i != out_bytes_per_sample; ++i)
+ {
+ *(out_it++) = SampleToBits(&l_9bit);
+ *(out_it++) = SampleToBits(&r_9bit);
+ }
+ }
+}
+
+void CGBPlayer_mGBA::SetKeys(u16 keys)
+{
+ m_keys = keys;
+}
+
+void CGBPlayer_mGBA::DoState(PointerWrap& p)
+{
+ m_gba_core.DoState(p);
+
+ p.Do(m_video_irq_phase);
+ p.Do(m_audio_irq_phase);
+
+ p.Do(m_current_scanline_index);
+ p.Do(m_keys);
+ p.Do(m_generate_video_irq);
+
+ // Resampled audio buffer.
+ p.DoArray(static_cast<u8*>(m_audio_buffer.data.data), u32(m_audio_buffer.data.capacity));
+ p.Do(m_audio_buffer.data.size);
+ p.DoPointer(m_audio_buffer.data.readPtr, m_audio_buffer.data.data);
+ p.DoPointer(m_audio_buffer.data.writePtr, m_audio_buffer.data.data);
+}
+
+void CGBPlayer_mGBA::UpdateAudio(s64 cycles_late)
+{
+ m_player->AssertIRQ(CHSPDevice_GBPlayer::IRQ::Audio);
+
+ if (m_generate_video_irq)
+ {
+ m_player->AssertIRQ(CHSPDevice_GBPlayer::IRQ::Video);
+ m_generate_video_irq = false;
+ }
+
+ constexpr u32 audio_irq_freq = AUDIO_SAMPLE_RATE / AUDIO_READ_SIZE;
+
+ const s64 ticks_per_second = m_system.GetSystemTimers().GetTicksPerSecond();
+
+ // Calculate a non-drifting time for the next IRQ.
+ const s64 this_irq_ticks = ticks_per_second * m_audio_irq_phase / audio_irq_freq;
+
+ ++m_audio_irq_phase;
+ const s64 next_irq_ticks = ticks_per_second * m_audio_irq_phase / audio_irq_freq;
+
+ m_audio_irq_phase %= audio_irq_freq;
+
+ m_system.GetCoreTiming().ScheduleEvent(next_irq_ticks - this_irq_ticks - cycles_late,
+ m_update_audio_event);
+}
+
+void CGBPlayer_mGBA::UpdateVideo(u32 scanline_index, s64 cycles_late)
+{
+ // The ~59.7 GBA framerate.
+ constexpr std::ratio<GBA_ARM7TDMI_FREQUENCY, VIDEO_TOTAL_LENGTH> gba_framerate;
+
+ if (scanline_index == 0)
+ {
+ DEBUG_LOG_FMT(HSP, "GBPlayer: Frame start");
+
+ m_current_scanline_index = 0;
+
+ m_generate_video_irq = true;
+
+ auto& core_timing = m_system.GetCoreTiming();
+
+ const s64 ticks_per_second = m_system.GetSystemTimers().GetTicksPerSecond();
+
+ // Schedule an event to execute the GBA on vblank.
+ const s64 visible_scanlines_ticks = ticks_per_second * GBA_VIDEO_VERTICAL_PIXELS *
+ VIDEO_HORIZONTAL_LENGTH / GBA_ARM7TDMI_FREQUENCY;
+ core_timing.ScheduleEvent(visible_scanlines_ticks - cycles_late, m_update_video_event,
+ GBA_VIDEO_VERTICAL_PIXELS);
+
+ // Calculate a non-drifting time for the start of the next frame.
+ const s64 this_frame_ticks =
+ ticks_per_second * m_video_irq_phase * gba_framerate.den / gba_framerate.num;
+
+ ++m_video_irq_phase;
+ const s64 next_frame_ticks =
+ ticks_per_second * m_video_irq_phase * gba_framerate.den / gba_framerate.num;
+
+ m_video_irq_phase %= gba_framerate.num;
+
+ core_timing.ScheduleEvent(next_frame_ticks - this_frame_ticks - cycles_late,
+ m_update_video_event, 0);
+
+ // Ensure the GBA frame emulation is complete.
+ m_gba_core.Flush();
+ }
+ else if (scanline_index == GBA_VIDEO_VERTICAL_PIXELS)
+ {
+ DEBUG_LOG_FMT(HSP, "GBPlayer: Frame end");
+
+ if (m_current_scanline_index != GBA_VIDEO_VERTICAL_PIXELS)
+ {
+ // This happens when the game doesn't read the entire frame in time.
+ // Lowering the CPU clock override will cause this to happen repeatedly.
+ WARN_LOG_FMT(HSP, "GBPlayer: Frame end while reading scanlines: {}",
+ m_current_scanline_index);
+ }
+
+ // Emulate a single frame during vblank.
+ m_gba_core.RunFrame(m_keys);
+
+ m_current_scanline_index = scanline_index;
+ }
+}
+
+#endif
+
+CHSPDevice_GBPlayer::CHSPDevice_GBPlayer(Core::System& system, HSPDeviceType device)
+ : IHSPDevice(device), m_system{system}
+{
+#if defined(HAS_LIBMGBA)
+ m_gbp = std::make_unique<CGBPlayer_mGBA>(m_system, this);
+#else
+ m_gbp = std::make_unique<CGBPlayer_Dummy>(m_system, this);
+#endif
+}
+
+u64 CHSPDevice_GBPlayer::Read(u32 address)
+{
+ u64 value = 0;
+ switch (GBPRegister(address >> 20))
+ {
+ case GBPRegister::Test:
+ {
+ std::memcpy(&value, &m_test[address & COMMAND_ADDRESS_MASK], sizeof(value));
+ break;
+ }
+ case GBPRegister::Control:
+ {
+ if (m_gbp->IsLoaded())
+ {
+ m_control |= CONTROL_CART_INSERTED;
+ if ((m_control & CONTROL_3V) && (m_gbp->IsGBA() || (m_control & CONTROL_5V)))
+ m_control |= CONTROL_CART_DETECTED;
+ else
+ m_control &= ~CONTROL_CART_DETECTED;
+ }
+ else
+ {
+ m_control &= ~(CONTROL_CART_DETECTED | CONTROL_CART_INSERTED);
+ }
+
+ value = m_control * 0x0101010101010101ULL;
+ break;
+ }
+ case GBPRegister::IRQ:
+ {
+ value = m_irq * 0x0000000100000001ULL;
+ value |= (m_irq & 0xFF00) * 0x0001010000010100ULL;
+ value &= 0xFFFF7FFFFFFF7FFFULL;
+ break;
+ }
+ case GBPRegister::Video:
+ {
+ const u32 offset = address & AV_ADDRESS_MASK;
+ if (offset == 0)
+ m_gbp->ReadScanlines(m_scanlines);
+
+ const u32 scanlines_pos = offset / 4;
+
+ value = u64(m_scanlines[scanlines_pos]) << 32;
+ value |= m_scanlines[scanlines_pos + 1];
+ break;
+ }
+ case GBPRegister::Audio:
+ {
+ const u32 offset = address & AV_ADDRESS_MASK;
+ if (offset == 0)
+ m_gbp->ReadAudio(m_audio);
+
+ const u32 audio_pos = offset / 4;
+
+ value = 0x0101010100000000ULL * m_audio[audio_pos];
+ value |= 0x01010101ULL * m_audio[audio_pos + 1];
+ break;
+ }
+ default:
+ {
+ WARN_LOG_FMT(HSP, "GBPlayer: Unknown read from 0x{:08x}", address);
+ break;
+ }
+ }
+ return value;
+}
+
+void CHSPDevice_GBPlayer::Write(u32 address, u64 value)
+{
+ // This seems to compensate for DSP.cpp turning 32byte writes into 4 x 8byte writes.
+ // TODO: Rectify that and adjust this.
+ constexpr u32 last_chunk = 0x18;
+
+ switch (GBPRegister(address >> 20))
+ {
+ case GBPRegister::Test:
+ {
+ value = ~value;
+ std::memcpy(&m_test[address & COMMAND_ADDRESS_MASK], &value, sizeof(value));
+ break;
+ }
+ case GBPRegister::Control:
+ {
+ if ((address & COMMAND_ADDRESS_MASK) != last_chunk)
+ break;
+
+ if (!(m_control & (CONTROL_3V | CONTROL_5V)) && (value & (CONTROL_3V | CONTROL_5V)))
+ {
+ INFO_LOG_FMT(HSP, "GBPlayer: Reset");
+ m_gbp->Reset();
+ }
+
+ if ((m_control & (CONTROL_3V | CONTROL_5V)) && !(value & (CONTROL_3V | CONTROL_5V)))
+ {
+ INFO_LOG_FMT(HSP, "GBPlayer: Stop");
+ m_gbp->Stop();
+
+ m_scanlines.fill(0);
+ m_audio.fill(0);
+ }
+
+ m_control = value & 0xFC;
+ UpdateInterrupts();
+ break;
+ }
+ case GBPRegister::IRQ:
+ {
+ if ((address & COMMAND_ADDRESS_MASK) != last_chunk)
+ break;
+
+ m_irq &= ~value;
+ UpdateInterrupts();
+ break;
+ }
+ case GBPRegister::Keypad:
+ {
+ if ((address & COMMAND_ADDRESS_MASK) != last_chunk)
+ break;
+
+ m_gbp->SetKeys(value & 0x3FF);
+ break;
+ }
+ default:
+ {
+ WARN_LOG_FMT(HSP, "GBPlayer: Unknown write to 0x{:08x}: 0x{:016x}", address, value);
+ break;
+ }
+ }
+}
+
+void CHSPDevice_GBPlayer::DoState(PointerWrap& p)
+{
+ // Fail when trying to load a state with a different HAS_LIBMGBA value.
+#if defined(HAS_LIBMGBA)
+ p.DoMarker("Have mGBA", 1);
+#else
+ p.DoMarker("Have mGBA", 0);
+#endif
+
+ p.Do(m_test);
+ p.Do(m_control);
+ p.Do(m_irq);
+
+ p.Do(m_scanlines);
+ p.Do(m_audio);
+
+ m_gbp->DoState(p);
+}
+
+void CHSPDevice_GBPlayer::AssertIRQ(IRQ irq)
+{
+ // The 6 IRQ types set the even bits: 0x555.
+ // FYI: GBPlayer software appears to use the odd bits to mask the even bits.
+ const u32 irq_bit_value = 1u << (2u * u32(irq));
+
+ if ((m_irq & irq_bit_value) != 0)
+ WARN_LOG_FMT(HSP, "GBPlayer: IRQ {} is already set.", u32(irq));
+
+ m_irq |= irq_bit_value | IRQ_ASSERTED;
+ UpdateInterrupts();
+}
+
+void CHSPDevice_GBPlayer::UpdateInterrupts()
+{
+ const bool set_interrupt = ((m_control & CONTROL_MASK_IRQ) == 0) && ((m_irq & IRQ_ASSERTED) != 0);
+ m_system.GetProcessorInterface().SetInterrupt(ProcessorInterface::INT_CAUSE_HSP, set_interrupt);
+}
+
+IGBPlayer::IGBPlayer(Core::System& system, CHSPDevice_GBPlayer* player)
+ : m_system{system}, m_player{player}
+{
+}
+
+IGBPlayer::~IGBPlayer() = default;
+
+} // namespace HSP
diff --git a/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h b/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h
new file mode 100644
index 0000000000..ba93412de0
--- /dev/null
+++ b/Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h
@@ -0,0 +1,82 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <array>
+#include <memory>
+#include <span>
+
+#include "Common/CommonTypes.h"
+#include "Core/HW/HSP/HSP_Device.h"
+
+namespace Core
+{
+class System;
+}
+
+namespace HSP
+{
+class CHSPDevice_GBPlayer;
+
+class IGBPlayer
+{
+public:
+ // Audio/Video data is read in chunks.
+ static constexpr std::size_t AV_REGION_SIZE = 0x400;
+
+ static constexpr std::size_t AUDIO_SAMPLE_RATE = 0x8000;
+ static constexpr std::size_t AUDIO_READ_SIZE = 8;
+ static constexpr std::size_t AUDIO_CHANNEL_COUNT = 2;
+
+ IGBPlayer(Core::System&, CHSPDevice_GBPlayer*);
+ virtual ~IGBPlayer();
+
+ virtual void Reset() = 0;
+ virtual void Stop() = 0;
+
+ virtual bool IsLoaded() const = 0;
+ virtual bool IsGBA() const = 0;
+
+ virtual void ReadScanlines(std::span<u32, AV_REGION_SIZE>) = 0;
+ virtual void ReadAudio(std::span<u8, AV_REGION_SIZE>) = 0;
+
+ virtual void SetKeys(u16) = 0;
+
+ virtual void DoState(PointerWrap& p) = 0;
+
+protected:
+ Core::System& m_system;
+ CHSPDevice_GBPlayer* const m_player;
+};
+
+class CHSPDevice_GBPlayer : public IHSPDevice
+{
+public:
+ enum class IRQ : int;
+
+ CHSPDevice_GBPlayer(Core::System& system, HSPDeviceType device);
+
+ void Write(u32 address, u64 value) override;
+ u64 Read(u32 address) override;
+
+ void DoState(PointerWrap& p) override;
+
+ void AssertIRQ(IRQ);
+
+private:
+ void UpdateInterrupts();
+
+ Core::System& m_system;
+
+ std::array<u8, 0x20> m_test{};
+ u8 m_control{0xCC};
+ u16 m_irq{0};
+
+ std::array<u32, IGBPlayer::AV_REGION_SIZE> m_scanlines{};
+ std::array<u8, IGBPlayer::AV_REGION_SIZE> m_audio{};
+
+ std::unique_ptr<IGBPlayer> m_gbp;
+};
+
+} // namespace HSP
diff --git a/Source/Core/Core/State.cpp b/Source/Core/Core/State.cpp
index e121dcfb7b..a234b4f919 100644
--- a/Source/Core/Core/State.cpp
+++ b/Source/Core/Core/State.cpp
@@ -95,7 +95,7 @@ struct CompressAndDumpStateArgs
static Common::WorkQueueThreadSP<CompressAndDumpStateArgs> s_compress_and_dump_thread;
// Don't forget to increase this after doing changes on the savestate system
-constexpr u32 STATE_VERSION = 185; // Last changed in PR 14526
+constexpr u32 STATE_VERSION = 186; // Last changed in PR 14535
// Increase this if the StateExtendedHeader definition changes
constexpr u32 EXTENDED_HEADER_VERSION = 1; // Last changed in PR 12217
diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props
index ea1679a743..d50fbcdc4d 100644
--- a/Source/Core/DolphinLib.props
+++ b/Source/Core/DolphinLib.props
@@ -317,6 +317,7 @@
<ClInclude Include="Core\HW\HSP\HSP.h" />
<ClInclude Include="Core\HW\HSP\HSP_Device.h" />
<ClInclude Include="Core\HW\HSP\HSP_DeviceARAMExpansion.h" />
+ <ClInclude Include="Core\HW\HSP\HSP_DeviceGBPlayer.h" />
<ClInclude Include="Core\HW\HSP\HSP_DeviceNull.h" />
<ClInclude Include="Core\HW\HW.h" />
<ClInclude Include="Core\HW\MagCard\C1231BR.h" />
@@ -1026,6 +1027,7 @@
<ClCompile Include="Core\HW\HSP\HSP.cpp" />
<ClCompile Include="Core\HW\HSP\HSP_Device.cpp" />
<ClCompile Include="Core\HW\HSP\HSP_DeviceARAMExpansion.cpp" />
+ <ClCompile Include="Core\HW\HSP\HSP_DeviceGBPlayer.cpp" />
<ClCompile Include="Core\HW\HSP\HSP_DeviceNull.cpp" />
<ClCompile Include="Core\HW\HW.cpp" />
<ClCompile Include="Core\HW\MagCard\C1231BR.cpp" />
diff --git a/Source/Core/DolphinQt/Settings/GameCubePane.cpp b/Source/Core/DolphinQt/Settings/GameCubePane.cpp
index b4ee1f25ad..178a26d64b 100644
--- a/Source/Core/DolphinQt/Settings/GameCubePane.cpp
+++ b/Source/Core/DolphinQt/Settings/GameCubePane.cpp
@@ -220,7 +220,10 @@ void GameCubePane::CreateWidgets()
{
m_gba_rom_edits[i] = new ConfigText(Config::MAIN_GBA_ROM_PATHS[i]);
m_gba_browse_roms[i] = new NonDefaultQPushButton(QStringLiteral("..."));
- gba_layout->addWidget(new QLabel(tr("Port %1 ROM:").arg(i + 1)), gba_row, 0);
+ auto* const label =
+ new QLabel((i == Config::GBPLAYER_GBA_INDEX) ? tr("Game Boy Player ROM:") :
+ tr("Port %1 ROM:").arg(i + 1));
+ gba_layout->addWidget(label, gba_row, 0);
gba_layout->addWidget(m_gba_rom_edits[i], gba_row, 1);
gba_layout->addWidget(m_gba_browse_roms[i], gba_row, 2);
gba_row++;
diff --git a/Source/Core/DolphinQt/Settings/GameCubePane.h b/Source/Core/DolphinQt/Settings/GameCubePane.h
index a97e82af3f..3a339496a6 100644
--- a/Source/Core/DolphinQt/Settings/GameCubePane.h
+++ b/Source/Core/DolphinQt/Settings/GameCubePane.h
@@ -85,8 +85,8 @@ private:
ConfigBool* m_gba_save_rom_path;
QPushButton* m_gba_browse_bios;
ConfigUserPath* m_gba_bios_edit;
- std::array<QPushButton*, 4> m_gba_browse_roms;
- std::array<ConfigText*, 4> m_gba_rom_edits;
+ std::array<QPushButton*, 5> m_gba_browse_roms;
+ std::array<ConfigText*, 5> m_gba_rom_edits;
QPushButton* m_gba_browse_saves;
ConfigUserPath* m_gba_saves_edit;
};