diff --git a/Source/Core/Core/Analytics.cpp b/Source/Core/Core/Analytics.cpp index e46dcd2832..a0b4fc2175 100644 --- a/Source/Core/Core/Analytics.cpp +++ b/Source/Core/Core/Analytics.cpp @@ -133,7 +133,7 @@ void DolphinAnalytics::ReportGameStart() } // Keep in sync with enum class GameQuirk definition. -constexpr std::array GAME_QUIRKS_NAMES{"icache-matters", +constexpr std::array GAME_QUIRKS_NAMES{"icache-matters", "directly-reads-wiimote-input", "uses-DVDLowStopLaser", "uses-DVDLowOffset", @@ -144,7 +144,9 @@ constexpr std::array GAME_QUIRKS_NAMES{"icache-matters", "uses-different-partition-command", "uses-di-interrupt-command", "mismatched-gpu-texgens-between-xf-and-bp", - "mismatched-gpu-colors-between-xf-and-bp"}; + "mismatched-gpu-colors-between-xf-and-bp", + "uses-uncommon-wd-mode", + "uses-wd-unimplemented-ioctl"}; static_assert(GAME_QUIRKS_NAMES.size() == static_cast(GameQuirk::COUNT), "Game quirks names and enum definition are out of sync."); diff --git a/Source/Core/Core/Analytics.h b/Source/Core/Core/Analytics.h index 94f7eb5a78..6416257b93 100644 --- a/Source/Core/Core/Analytics.h +++ b/Source/Core/Core/Analytics.h @@ -54,6 +54,13 @@ enum class GameQuirk MISMATCHED_GPU_TEXGENS_BETWEEN_XF_AND_BP, MISMATCHED_GPU_COLORS_BETWEEN_XF_AND_BP, + // The WD module can be configured to operate in six different modes. + // In practice, only mode 1 (DS communications) and mode 3 (AOSS access point scanning) + // are used by games and the system menu respectively. + USES_UNCOMMON_WD_MODE, + + USES_WD_UNIMPLEMENTED_IOCTL, + COUNT, }; diff --git a/Source/Core/Core/IOS/Device.cpp b/Source/Core/Core/IOS/Device.cpp index b3324503f0..9d04dca6ba 100644 --- a/Source/Core/Core/IOS/Device.cpp +++ b/Source/Core/Core/IOS/Device.cpp @@ -78,7 +78,8 @@ IOCtlVRequest::IOCtlVRequest(const u32 address_) : Request(address_) const IOCtlVRequest::IOVector* IOCtlVRequest::GetVector(size_t index) const { - ASSERT(index < (in_vectors.size() + io_vectors.size())); + if (index >= in_vectors.size() + io_vectors.size()) + return nullptr; if (index < in_vectors.size()) return &in_vectors[index]; return &io_vectors[index - in_vectors.size()]; diff --git a/Source/Core/Core/IOS/Device.h b/Source/Core/Core/IOS/Device.h index bffe1cdc1c..b3944eb16e 100644 --- a/Source/Core/Core/IOS/Device.h +++ b/Source/Core/Core/IOS/Device.h @@ -154,7 +154,10 @@ struct IOCtlVRequest final : Request // merging them into a single std::vector would make using the first out vector more complicated. std::vector in_vectors; std::vector io_vectors; + + /// Returns the specified vector or nullptr if the index is out of bounds. const IOVector* GetVector(size_t index) const; + explicit IOCtlVRequest(u32 address); bool HasNumberOfValidVectors(size_t in_count, size_t io_count) const; void Dump(std::string_view description, Common::Log::LOG_TYPE type = Common::Log::IOS, diff --git a/Source/Core/Core/IOS/Network/NCD/Manage.cpp b/Source/Core/Core/IOS/Network/NCD/Manage.cpp index 2fcfb91beb..602a721374 100644 --- a/Source/Core/Core/IOS/Network/NCD/Manage.cpp +++ b/Source/Core/Core/IOS/Network/NCD/Manage.cpp @@ -20,6 +20,12 @@ NetNCDManage::NetNCDManage(Kernel& ios, const std::string& device_name) : Device config.ReadConfig(ios.GetFS().get()); } +void NetNCDManage::DoState(PointerWrap& p) +{ + Device::DoState(p); + p.Do(m_ipc_fd); +} + IPCCommandResult NetNCDManage::IOCtlV(const IOCtlVRequest& request) { s32 return_value = IPC_SUCCESS; @@ -29,11 +35,51 @@ IPCCommandResult NetNCDManage::IOCtlV(const IOCtlVRequest& request) switch (request.request) { case IOCTLV_NCD_LOCKWIRELESSDRIVER: + if (!request.HasNumberOfValidVectors(0, 1)) + return GetDefaultReply(IPC_EINVAL); + + if (request.io_vectors[0].size < 2 * sizeof(u32)) + return GetDefaultReply(IPC_EINVAL); + + if (m_ipc_fd != 0) + { + // It is an error to lock the driver again when it is already locked. + common_result = IPC_EINVAL; + } + else + { + // NCD writes the internal address of the request's file descriptor. + // We will just write the value of the file descriptor. + // The value will be positive so this will work fine. + m_ipc_fd = request.fd; + Memory::Write_U32(request.fd, request.io_vectors[0].address + 4); + } break; case IOCTLV_NCD_UNLOCKWIRELESSDRIVER: - // Memory::Read_U32(request.in_vectors.at(0).address); + { + if (!request.HasNumberOfValidVectors(1, 1)) + return GetDefaultReply(IPC_EINVAL); + + if (request.in_vectors[0].size < sizeof(u32)) + return GetDefaultReply(IPC_EINVAL); + + if (request.io_vectors[0].size < sizeof(u32)) + return GetDefaultReply(IPC_EINVAL); + + const u32 request_handle = Memory::Read_U32(request.in_vectors[0].address); + if (m_ipc_fd == request_handle) + { + m_ipc_fd = 0; + common_result = 0; + } + else + { + common_result = -3; + } + break; + } case IOCTLV_NCD_GETCONFIG: INFO_LOG_FMT(IOS_NET, "NET_NCD_MANAGE: IOCTLV_NCD_GETCONFIG"); diff --git a/Source/Core/Core/IOS/Network/NCD/Manage.h b/Source/Core/Core/IOS/Network/NCD/Manage.h index 4105bf7408..8da300e719 100644 --- a/Source/Core/Core/IOS/Network/NCD/Manage.h +++ b/Source/Core/Core/IOS/Network/NCD/Manage.h @@ -20,6 +20,8 @@ public: IPCCommandResult IOCtlV(const IOCtlVRequest& request) override; + void DoState(PointerWrap& p) override; + private: enum { @@ -34,5 +36,6 @@ private: }; Net::WiiNetConfig config; + u32 m_ipc_fd = 0; }; } // namespace IOS::HLE::Device diff --git a/Source/Core/Core/IOS/Network/WD/Command.cpp b/Source/Core/Core/IOS/Network/WD/Command.cpp index 363e49b091..0f6eb0b49d 100644 --- a/Source/Core/Core/IOS/Network/WD/Command.cpp +++ b/Source/Core/Core/IOS/Network/WD/Command.cpp @@ -4,33 +4,330 @@ #include "Core/IOS/Network/WD/Command.h" -#include #include #include +#include "Common/BitSet.h" #include "Common/CommonTypes.h" #include "Common/Logging/Log.h" #include "Common/Network.h" #include "Common/Swap.h" - +#include "Core/Analytics.h" #include "Core/HW/Memmap.h" #include "Core/IOS/Network/MACUtils.h" namespace IOS::HLE::Device { -NetWDCommand::NetWDCommand(Kernel& ios, const std::string& device_name) : Device(ios, device_name) +namespace { +// clang-format off +// Channel: FEDC BA98 7654 3210 +constexpr u16 LegalChannelMask = 0b0111'1111'1111'1110u; +constexpr u16 LegalNitroChannelMask = 0b0011'1111'1111'1110u; +// clang-format on + +u16 SelectWifiChannel(u16 enabled_channels_mask, u16 current_channel) +{ + const Common::BitSet enabled_channels{enabled_channels_mask & LegalChannelMask}; + u16 next_channel = current_channel; + for (int i = 0; i < 16; ++i) + { + next_channel = (next_channel + 3) % 16; + if (enabled_channels[next_channel]) + return next_channel; + } + // This does not make a lot of sense, but it is what WD does. + return u16(enabled_channels[next_channel]); +} + +u16 MakeNitroAllowedChannelMask(u16 enabled_channels_mask, u16 nitro_mask) +{ + nitro_mask &= LegalNitroChannelMask; + // TODO: WD's version of this function has some complicated logic to determine the actual mask. + return enabled_channels_mask & nitro_mask; +} +} // namespace + +NetWDCommand::Status NetWDCommand::GetTargetStatusForMode(WD::Mode mode) +{ + switch (mode) + { + case WD::Mode::DSCommunications: + return Status::ScanningForDS; + case WD::Mode::AOSSAccessPointScan: + return Status::ScanningForAOSSAccessPoint; + default: + return Status::Idle; + } +} + +NetWDCommand::NetWDCommand(Kernel& ios, const std::string& device_name) : Device(ios, device_name) +{ + // TODO: use the MPCH setting in setting.txt to determine this value. + m_nitro_enabled_channels = LegalNitroChannelMask; + + // TODO: Set the version string here. This is exposed to the PPC. + m_info.mac = IOS::Net::GetMACAddress(); + m_info.enabled_channels = 0xfffe; + m_info.channel = SelectWifiChannel(m_info.enabled_channels, 0); + // The country code is supposed to be null terminated as it is logged with printf in WD. + std::strncpy(m_info.country_code.data(), "US", m_info.country_code.size()); + m_info.nitro_allowed_channels = + MakeNitroAllowedChannelMask(m_info.enabled_channels, m_nitro_enabled_channels); + m_info.initialised = true; +} + +void NetWDCommand::Update() +{ + Device::Update(); + ProcessRecvRequests(); + HandleStateChange(); +} + +void NetWDCommand::ProcessRecvRequests() +{ + // Because we currently do not actually emulate the wireless driver, we have no frames + // and no notification data that could be used to reply to requests. + // Therefore, requests are left pending to simulate the situation where there is nothing to send. + + // All pending requests must still be processed when the handle to the resource manager is closed. + const bool force_process = m_clear_all_requests.TestAndClear(); + + const auto process_queue = [&](std::deque& queue) { + if (!force_process) + return; + + while (!queue.empty()) + { + const auto request = queue.front(); + s32 result; + + // If the resource manager handle is closed while processing a request, + // InvalidFd is returned. + if (m_ipc_owner_fd < 0) + { + result = s32(ResultCode::InvalidFd); + } + else + { + // TODO: Frame/notification data would be copied here. + // And result would be set to the data length or to an error code. + result = 0; + } + + INFO_LOG_FMT(IOS_NET, "Processed request {:08x} (result {:08x})", request, result); + m_ios.EnqueueIPCReply(Request{request}, result); + queue.pop_front(); + } + }; + + process_queue(m_recv_notification_requests); + process_queue(m_recv_frame_requests); +} + +void NetWDCommand::HandleStateChange() +{ + const auto status = m_status; + const auto target_status = m_target_status; + + if (status == target_status) + return; + + INFO_LOG_FMT(IOS_NET, "{}: Handling status change ({} -> {})", __func__, status, target_status); + + switch (status) + { + case Status::Idle: + switch (target_status) + { + case Status::ScanningForAOSSAccessPoint: + // This is supposed to reset the driver first by going into another state. + // However, we can worry about that once we actually emulate WL. + m_status = Status::ScanningForAOSSAccessPoint; + break; + case Status::ScanningForDS: + // This is supposed to set a bunch of Wi-Fi driver parameters and initiate a scan. + m_status = Status::ScanningForDS; + break; + case Status::Idle: + break; + } + break; + + case Status::ScanningForDS: + m_status = Status::Idle; + break; + + case Status::ScanningForAOSSAccessPoint: + // We are supposed to reset the driver by going into a reset state. + // However, we can worry about that once we actually emulate WL. + break; + } + + INFO_LOG_FMT(IOS_NET, "{}: done (status: {} -> {}, target was {})", __func__, status, m_status, + target_status); +} + +void NetWDCommand::DoState(PointerWrap& p) +{ + Device::DoState(p); + p.Do(m_ipc_owner_fd); + p.Do(m_mode); + p.Do(m_buffer_flags); + p.Do(m_status); + p.Do(m_target_status); + p.Do(m_nitro_enabled_channels); + p.Do(m_info); + p.Do(m_recv_frame_requests); + p.Do(m_recv_notification_requests); +} + +IPCCommandResult NetWDCommand::Open(const OpenRequest& request) +{ + if (m_ipc_owner_fd < 0) + { + const auto flags = u32(request.flags); + const auto mode = WD::Mode(flags & 0xFFFF); + const auto buffer_flags = flags & 0x7FFF0000; + INFO_LOG_FMT(IOS_NET, "Opening with mode={} buffer_flags={:08x}", mode, buffer_flags); + + // We don't support anything other than mode 1 and mode 3 at the moment. + if (mode != WD::Mode::DSCommunications && mode != WD::Mode::AOSSAccessPointScan) + { + ERROR_LOG_FMT(IOS_NET, "Unsupported WD operating mode: {}", mode); + DolphinAnalytics::Instance().ReportGameQuirk(GameQuirk::USES_UNCOMMON_WD_MODE); + return GetDefaultReply(s32(ResultCode::UnavailableCommand)); + } + + if (m_target_status == Status::Idle && mode <= WD::Mode::Unknown6) + { + m_mode = mode; + m_ipc_owner_fd = request.fd; + m_buffer_flags = buffer_flags; + } + } + + INFO_LOG_FMT(IOS_NET, "Opened"); + return Device::Open(request); +} + +IPCCommandResult NetWDCommand::Close(u32 fd) +{ + if (m_ipc_owner_fd < 0 || fd != u32(m_ipc_owner_fd)) + { + ERROR_LOG_FMT(IOS_NET, "Invalid close attempt."); + return GetDefaultReply(u32(ResultCode::InvalidFd)); + } + + INFO_LOG_FMT(IOS_NET, "Closing and resetting status to Idle"); + m_target_status = m_status = Status::Idle; + + m_ipc_owner_fd = -1; + m_clear_all_requests.Set(); + return Device::Close(fd); +} + +IPCCommandResult NetWDCommand::SetLinkState(const IOCtlVRequest& request) +{ + const auto* vector = request.GetVector(0); + if (!vector || vector->address == 0) + return GetDefaultReply(u32(ResultCode::IllegalParameter)); + + const u32 state = Memory::Read_U32(vector->address); + INFO_LOG_FMT(IOS_NET, "WD_SetLinkState called (state={}, mode={})", state, m_mode); + + if (state == 0) + { + if (!WD::IsValidMode(m_mode)) + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + + INFO_LOG_FMT(IOS_NET, "WD_SetLinkState: setting target status to 1 (Idle)"); + m_target_status = Status::Idle; + } + else + { + if (state != 1) + return GetDefaultReply(u32(ResultCode::IllegalParameter)); + + if (!WD::IsValidMode(m_mode)) + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + + const auto target_status = GetTargetStatusForMode(m_mode); + if (m_status != target_status && m_info.enabled_channels == 0) + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + + INFO_LOG_FMT(IOS_NET, "WD_SetLinkState: setting target status to {}", target_status); + m_target_status = target_status; + } + + return GetDefaultReply(IPC_SUCCESS); +} + +IPCCommandResult NetWDCommand::GetLinkState(const IOCtlVRequest& request) const +{ + INFO_LOG_FMT(IOS_NET, "WD_GetLinkState called (status={}, mode={})", m_status, m_mode); + if (!WD::IsValidMode(m_mode)) + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + + // Contrary to what the name of the ioctl suggests, this returns a boolean, not the current state. + return GetDefaultReply(u32(m_status == GetTargetStatusForMode(m_mode))); +} + +IPCCommandResult NetWDCommand::Disassociate(const IOCtlVRequest& request) +{ + const auto* vector = request.GetVector(0); + if (!vector || vector->address == 0) + return GetDefaultReply(u32(ResultCode::IllegalParameter)); + + Common::MACAddress mac; + Memory::CopyFromEmu(mac.data(), vector->address, mac.size()); + + INFO_LOG_FMT(IOS_NET, "WD_Disassociate: MAC {}", Common::MacAddressToString(mac)); + + if (m_mode != WD::Mode::DSCommunications && m_mode != WD::Mode::Unknown5 && + m_mode != WD::Mode::Unknown6) + { + ERROR_LOG_FMT(IOS_NET, "WD_Disassociate: cannot disassociate in mode {}", m_mode); + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + } + + const auto target_status = GetTargetStatusForMode(m_mode); + if (m_status != target_status) + { + ERROR_LOG_FMT(IOS_NET, "WD_Disassociate: cannot disassociate in status {} (target {})", + m_status, target_status); + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + } + + // TODO: Check the input MAC address and only return 0x80008001 if it is unknown. + return GetDefaultReply(u32(ResultCode::IllegalParameter)); +} + +IPCCommandResult NetWDCommand::GetInfo(const IOCtlVRequest& request) const +{ + const auto* vector = request.GetVector(0); + if (!vector || vector->address == 0) + return GetDefaultReply(u32(ResultCode::IllegalParameter)); + + Memory::CopyToEmu(vector->address, &m_info, sizeof(m_info)); + return GetDefaultReply(IPC_SUCCESS); } -// This is just for debugging / playing around. -// There really is no reason to implement wd unless we can bend it such that -// we can talk to the DS. IPCCommandResult NetWDCommand::IOCtlV(const IOCtlVRequest& request) { - s32 return_value = IPC_SUCCESS; - switch (request.request) { + case IOCTLV_WD_INVALID: + return GetDefaultReply(u32(ResultCode::UnavailableCommand)); + case IOCTLV_WD_GET_MODE: + return GetDefaultReply(s32(m_mode)); + case IOCTLV_WD_SET_LINKSTATE: + return SetLinkState(request); + case IOCTLV_WD_GET_LINKSTATE: + return GetLinkState(request); + case IOCTLV_WD_DISASSOC: + return Disassociate(request); + case IOCTLV_WD_SCAN: { // Gives parameters detailing type of scan and what to match @@ -59,25 +356,19 @@ IPCCommandResult NetWDCommand::IOCtlV(const IOCtlVRequest& request) break; case IOCTLV_WD_GET_INFO: - { - Info* info = (Info*)Memory::GetPointer(request.io_vectors.at(0).address); - memset(info, 0, sizeof(Info)); - // Probably used to disallow certain channels? - memcpy(info->country, "US", 2); - info->ntr_allowed_channels = Common::swap16(0xfffe); + return GetInfo(request); - const Common::MACAddress address = IOS::Net::GetMACAddress(); - std::copy(address.begin(), address.end(), info->mac); - } - break; + case IOCTLV_WD_RECV_FRAME: + m_recv_frame_requests.emplace_back(request.address); + return GetNoReply(); + + case IOCTLV_WD_RECV_NOTIFICATION: + m_recv_notification_requests.emplace_back(request.address); + return GetNoReply(); - case IOCTLV_WD_GET_MODE: - case IOCTLV_WD_SET_LINKSTATE: - case IOCTLV_WD_GET_LINKSTATE: case IOCTLV_WD_SET_CONFIG: case IOCTLV_WD_GET_CONFIG: case IOCTLV_WD_CHANGE_BEACON: - case IOCTLV_WD_DISASSOC: case IOCTLV_WD_MP_SEND_FRAME: case IOCTLV_WD_SEND_FRAME: case IOCTLV_WD_CALL_WL: @@ -85,12 +376,11 @@ IPCCommandResult NetWDCommand::IOCtlV(const IOCtlVRequest& request) case IOCTLV_WD_GET_LASTERROR: case IOCTLV_WD_CHANGE_GAMEINFO: case IOCTLV_WD_CHANGE_VTSF: - case IOCTLV_WD_RECV_FRAME: - case IOCTLV_WD_RECV_NOTIFICATION: default: - request.Dump(GetDeviceName(), Common::Log::IOS_NET, Common::Log::LINFO); + DolphinAnalytics::Instance().ReportGameQuirk(GameQuirk::USES_WD_UNIMPLEMENTED_IOCTL); + request.Dump(GetDeviceName(), Common::Log::IOS_NET, Common::Log::LWARNING); } - return GetDefaultReply(return_value); + return GetDefaultReply(IPC_SUCCESS); } } // namespace IOS::HLE::Device diff --git a/Source/Core/Core/IOS/Network/WD/Command.h b/Source/Core/Core/IOS/Network/WD/Command.h index c23c42f21a..78d294036e 100644 --- a/Source/Core/Core/IOS/Network/WD/Command.h +++ b/Source/Core/Core/IOS/Network/WD/Command.h @@ -4,23 +4,65 @@ #pragma once +#include #include #include "Common/CommonTypes.h" +#include "Common/Flag.h" +#include "Common/Network.h" +#include "Common/Swap.h" #include "Core/IOS/Device.h" +namespace IOS::HLE::WD +{ +// Values 2, 4, 5, 6 exist as well but are not known to be used by games, the Mii Channel +// or the system menu. +enum class Mode +{ + NotInitialized = 0, + // Used by games to broadcast DS programs or to communicate with a DS more generally. + DSCommunications = 1, + Unknown2 = 2, + // AOSS (https://en.wikipedia.org/wiki/AOSS) is a WPS-like feature. + // This is only known to be used by the system menu. + AOSSAccessPointScan = 3, + Unknown4 = 4, + Unknown5 = 5, + Unknown6 = 6, +}; + +constexpr bool IsValidMode(Mode mode) +{ + return mode >= Mode::DSCommunications && mode <= Mode::Unknown6; +} +} // namespace IOS::HLE::WD + namespace IOS::HLE::Device { class NetWDCommand : public Device { public: + enum class ResultCode : u32 + { + InvalidFd = 0x80008000, + IllegalParameter = 0x80008001, + UnavailableCommand = 0x80008002, + DriverError = 0x80008003, + }; + NetWDCommand(Kernel& ios, const std::string& device_name); + IPCCommandResult Open(const OpenRequest& request) override; + IPCCommandResult Close(u32 fd) override; IPCCommandResult IOCtlV(const IOCtlVRequest& request) override; + void Update() override; + bool IsOpened() const override { return true; } + void DoState(PointerWrap& p) override; private: enum { + IOCTLV_WD_INVALID = 0x1000, IOCTLV_WD_GET_MODE = 0x1001, // WD_GetMode IOCTLV_WD_SET_LINKSTATE = 0x1002, // WD_SetLinkState IOCTLV_WD_GET_LINKSTATE = 0x1003, // WD_GetLinkState @@ -89,14 +131,45 @@ private: struct Info { - u8 mac[6]; - u16 ntr_allowed_channels; - u16 unk8; - char country[2]; - u32 unkc; - char wlversion[0x50]; - u8 unk[0x30]; + Common::MACAddress mac{}; + Common::BigEndianValue enabled_channels{}; + Common::BigEndianValue nitro_allowed_channels{}; + std::array country_code{}; + u8 channel{}; + bool initialised{}; + std::array wl_version{}; }; + static_assert(sizeof(Info) == 0x90); #pragma pack(pop) + + enum class Status + { + Idle, + ScanningForAOSSAccessPoint, + ScanningForDS, + }; + + void ProcessRecvRequests(); + void HandleStateChange(); + static Status GetTargetStatusForMode(WD::Mode mode); + + IPCCommandResult SetLinkState(const IOCtlVRequest& request); + IPCCommandResult GetLinkState(const IOCtlVRequest& request) const; + IPCCommandResult Disassociate(const IOCtlVRequest& request); + IPCCommandResult GetInfo(const IOCtlVRequest& request) const; + + s32 m_ipc_owner_fd = -1; + WD::Mode m_mode = WD::Mode::NotInitialized; + u32 m_buffer_flags{}; + + Status m_status = Status::Idle; + Status m_target_status = Status::Idle; + + u16 m_nitro_enabled_channels{}; + Info m_info; + + Common::Flag m_clear_all_requests; + std::deque m_recv_frame_requests; + std::deque m_recv_notification_requests; }; } // namespace IOS::HLE::Device diff --git a/Source/Core/Core/State.cpp b/Source/Core/Core/State.cpp index 8326ae8815..4f15aea52b 100644 --- a/Source/Core/Core/State.cpp +++ b/Source/Core/Core/State.cpp @@ -74,7 +74,7 @@ static Common::Event g_compressAndDumpStateSyncEvent; static std::thread g_save_thread; // Don't forget to increase this after doing changes on the savestate system -constexpr u32 STATE_VERSION = 126; // Last changed in PR 9348 +constexpr u32 STATE_VERSION = 127; // Last changed in PR 9300 // Maps savestate versions to Dolphin versions. // Versions after 42 don't need to be added to this list,