mirror of
https://github.com/dolphin-emu/dolphin.git
synced 2026-08-20 09:24:59 -05:00
Merge pull request #14535 from jordan-woyak/gbplayer
HW: Implement Game Boy Player.
This commit is contained in:
@@ -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()
|
||||
|
||||
8
Data/Sys/GameSettings/UGP.ini
Normal file
8
Data/Sys/GameSettings/UGP.ini
Normal file
@@ -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
|
||||
1
Externals/mGBA/CMakeLists.txt
vendored
1
Externals/mGBA/CMakeLists.txt
vendored
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@ enum class HSPDeviceType : int
|
||||
{
|
||||
None,
|
||||
ARAMExpansion,
|
||||
GBPlayer,
|
||||
};
|
||||
|
||||
class IHSPDevice
|
||||
|
||||
578
Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp
Normal file
578
Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.cpp
Normal file
@@ -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
|
||||
82
Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h
Normal file
82
Source/Core/Core/HW/HSP/HSP_DeviceGBPlayer.h
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user