From 47001ad23349fae130d6faae9409117899eb11cc Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Wed, 30 Oct 2024 22:10:32 +0000 Subject: [PATCH 01/50] Make `MEMPTR` a little more `T*`-like (#1385) --- src/Common/MemPtr.h | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Common/MemPtr.h b/src/Common/MemPtr.h index 7825e4d5..2dc92040 100644 --- a/src/Common/MemPtr.h +++ b/src/Common/MemPtr.h @@ -98,35 +98,36 @@ class MEMPTR : MEMPTRBase return MEMPTR(this->m_value); } - MEMPTR operator+(const MEMPTR& ptr) noexcept + sint32 operator-(const MEMPTR& ptr) noexcept + requires(!std::is_void_v) { - return MEMPTR(this->GetMPTR() + ptr.GetMPTR()); - } - MEMPTR operator-(const MEMPTR& ptr) noexcept - { - return MEMPTR(this->GetMPTR() - ptr.GetMPTR()); + return static_cast(this->GetMPTR() - ptr.GetMPTR()); } MEMPTR operator+(sint32 v) noexcept + requires(!std::is_void_v) { // pointer arithmetic - return MEMPTR(this->GetMPTR() + v * 4); + return MEMPTR(this->GetMPTR() + v * sizeof(T)); } MEMPTR operator-(sint32 v) noexcept + requires(!std::is_void_v) { // pointer arithmetic - return MEMPTR(this->GetMPTR() - v * 4); + return MEMPTR(this->GetMPTR() - v * sizeof(T)); } MEMPTR& operator+=(sint32 v) noexcept + requires(!std::is_void_v) { m_value += v * sizeof(T); return *this; } template - std::enable_if_t, Q>& operator*() const noexcept + requires(!std::is_void_v) + Q& operator*() const noexcept { return *GetPtr(); } @@ -137,7 +138,8 @@ class MEMPTR : MEMPTRBase } template - std::enable_if_t, Q>& operator[](int index) noexcept + requires(!std::is_void_v) + Q& operator[](int index) noexcept { return GetPtr()[index]; } From 1c49a8a1ba8fea6e656c1d535f35e316bc29da76 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Fri, 1 Nov 2024 22:47:19 +0100 Subject: [PATCH 02/50] nn_nfp: Implement GetNfpReadOnlyInfo and fix deactivate event Fixes Amiibos not being detected in MK8 --- src/Cafe/OS/libs/nn_nfp/nn_nfp.cpp | 102 +++++++++++++++++------------ 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/src/Cafe/OS/libs/nn_nfp/nn_nfp.cpp b/src/Cafe/OS/libs/nn_nfp/nn_nfp.cpp index 10d9e7cb..38e0c4fe 100644 --- a/src/Cafe/OS/libs/nn_nfp/nn_nfp.cpp +++ b/src/Cafe/OS/libs/nn_nfp/nn_nfp.cpp @@ -334,45 +334,63 @@ void nnNfpExport_MountRom(PPCInterpreter_t* hCPU) osLib_returnFromFunction(hCPU, BUILD_NN_RESULT(NN_RESULT_LEVEL_SUCCESS, NN_RESULT_MODULE_NN_NFP, 0)); } -typedef struct +namespace nn::nfp { - /* +0x00 */ uint8 characterId[3]; - /* +0x03 */ uint8 amiiboSeries; - /* +0x04 */ uint16be number; - /* +0x06 */ uint8 nfpType; - /* +0x07 */ uint8 unused[0x2F]; -}nfpRomInfo_t; - -static_assert(offsetof(nfpRomInfo_t, amiiboSeries) == 0x3, "nfpRomInfo.seriesId has invalid offset"); -static_assert(offsetof(nfpRomInfo_t, number) == 0x4, "nfpRomInfo.number has invalid offset"); -static_assert(offsetof(nfpRomInfo_t, nfpType) == 0x6, "nfpRomInfo.nfpType has invalid offset"); -static_assert(sizeof(nfpRomInfo_t) == 0x36, "nfpRomInfo_t has invalid size"); - -void nnNfpExport_GetNfpRomInfo(PPCInterpreter_t* hCPU) -{ - cemuLog_log(LogType::NN_NFP, "GetNfpRomInfo(0x{:08x})", hCPU->gpr[3]); - ppcDefineParamStructPtr(romInfo, nfpRomInfo_t, 0); - - nnNfpLock(); - if (nfp_data.hasActiveAmiibo == false) + struct RomInfo { - nnNfpUnlock(); - osLib_returnFromFunction(hCPU, BUILD_NN_RESULT(NN_RESULT_LEVEL_STATUS, NN_RESULT_MODULE_NN_NFP, 0)); // todo: Return correct error code - return; + /* +0x00 */ uint8 characterId[3]; + /* +0x03 */ uint8 amiiboSeries; + /* +0x04 */ uint16be number; + /* +0x06 */ uint8 nfpType; + /* +0x07 */ uint8 unused[0x2F]; + }; + + static_assert(offsetof(RomInfo, amiiboSeries) == 0x3); + static_assert(offsetof(RomInfo, number) == 0x4); + static_assert(offsetof(RomInfo, nfpType) == 0x6); + static_assert(sizeof(RomInfo) == 0x36); + + using ReadOnlyInfo = RomInfo; // same layout + + void GetRomInfo(RomInfo* romInfo) + { + cemu_assert_debug(nfp_data.hasActiveAmiibo); + memset(romInfo, 0x00, sizeof(RomInfo)); + romInfo->characterId[0] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.gameAndCharacterId[0]; + romInfo->characterId[1] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.gameAndCharacterId[1]; + romInfo->characterId[2] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.characterVariation; // guessed + romInfo->amiiboSeries = nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboSeries; // guessed + romInfo->number = *(uint16be*)nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboModelNumber; // guessed + romInfo->nfpType = nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboFigureType; // guessed + memset(romInfo->unused, 0x00, sizeof(romInfo->unused)); } - memset(romInfo, 0x00, sizeof(nfpRomInfo_t)); - romInfo->characterId[0] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.gameAndCharacterId[0]; - romInfo->characterId[1] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.gameAndCharacterId[1]; - romInfo->characterId[2] = nfp_data.amiiboNFCData.amiiboIdentificationBlock.characterVariation; // guessed + nnResult GetNfpRomInfo(RomInfo* romInfo) + { + nnNfpLock(); + if (nfp_data.hasActiveAmiibo == false) + { + nnNfpUnlock(); + return BUILD_NN_RESULT(NN_RESULT_LEVEL_STATUS, NN_RESULT_MODULE_NN_NFP, 0); // todo: Return correct error code + } + GetRomInfo(romInfo); + nnNfpUnlock(); + return BUILD_NN_RESULT(NN_RESULT_LEVEL_SUCCESS, NN_RESULT_MODULE_NN_NFP, 0); + } - romInfo->amiiboSeries = nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboSeries; // guessed - romInfo->number = *(uint16be*)nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboModelNumber; // guessed - romInfo->nfpType = nfp_data.amiiboNFCData.amiiboIdentificationBlock.amiiboFigureType; // guessed - - nnNfpUnlock(); - osLib_returnFromFunction(hCPU, BUILD_NN_RESULT(NN_RESULT_LEVEL_SUCCESS, NN_RESULT_MODULE_NN_NFP, 0)); -} + nnResult GetNfpReadOnlyInfo(ReadOnlyInfo* readOnlyInfo) + { + nnNfpLock(); + if (nfp_data.hasActiveAmiibo == false) + { + nnNfpUnlock(); + return BUILD_NN_RESULT(NN_RESULT_LEVEL_STATUS, NN_RESULT_MODULE_NN_NFP, 0); // todo: Return correct error code + } + GetRomInfo(readOnlyInfo); + nnNfpUnlock(); + return BUILD_NN_RESULT(NN_RESULT_LEVEL_SUCCESS, NN_RESULT_MODULE_NN_NFP, 0); + } +}; typedef struct { @@ -880,13 +898,13 @@ void nnNfp_update() if (amiiboElapsedTouchTime >= 1500) { nnNfp_unloadAmiibo(); + if (nfp_data.deactivateEvent) + { + coreinit::OSEvent* osEvent = (coreinit::OSEvent*)memory_getPointerFromVirtualOffset(nfp_data.deactivateEvent); + coreinit::OSSignalEvent(osEvent); + } } nnNfpUnlock(); - if (nfp_data.deactivateEvent) - { - coreinit::OSEvent* osEvent = (coreinit::OSEvent*)memory_getPointerFromVirtualOffset(nfp_data.deactivateEvent); - coreinit::OSSignalEvent(osEvent); - } } void nnNfpExport_GetNfpState(PPCInterpreter_t* hCPU) @@ -1001,8 +1019,6 @@ namespace nn::nfp osLib_addFunction("nn_nfp", "Mount__Q2_2nn3nfpFv", nnNfpExport_Mount); osLib_addFunction("nn_nfp", "MountRom__Q2_2nn3nfpFv", nnNfpExport_MountRom); osLib_addFunction("nn_nfp", "Unmount__Q2_2nn3nfpFv", nnNfpExport_Unmount); - - osLib_addFunction("nn_nfp", "GetNfpRomInfo__Q2_2nn3nfpFPQ3_2nn3nfp7RomInfo", nnNfpExport_GetNfpRomInfo); osLib_addFunction("nn_nfp", "GetNfpCommonInfo__Q2_2nn3nfpFPQ3_2nn3nfp10CommonInfo", nnNfpExport_GetNfpCommonInfo); osLib_addFunction("nn_nfp", "GetNfpRegisterInfo__Q2_2nn3nfpFPQ3_2nn3nfp12RegisterInfo", nnNfpExport_GetNfpRegisterInfo); @@ -1028,7 +1044,9 @@ namespace nn::nfp { nnNfp_load(); // legacy interface, update these to use cafeExportRegister / cafeExportRegisterFunc - cafeExportRegisterFunc(nn::nfp::GetErrorCode, "nn_nfp", "GetErrorCode__Q2_2nn3nfpFRCQ2_2nn6Result", LogType::Placeholder); + cafeExportRegisterFunc(nn::nfp::GetErrorCode, "nn_nfp", "GetErrorCode__Q2_2nn3nfpFRCQ2_2nn6Result", LogType::NN_NFP); + cafeExportRegisterFunc(nn::nfp::GetNfpRomInfo, "nn_nfp", "GetNfpRomInfo__Q2_2nn3nfpFPQ3_2nn3nfp7RomInfo", LogType::NN_NFP); + cafeExportRegisterFunc(nn::nfp::GetNfpReadOnlyInfo, "nn_nfp", "GetNfpReadOnlyInfo__Q2_2nn3nfpFPQ3_2nn3nfp12ReadOnlyInfo", LogType::NN_NFP); } } From 9941e00b545a9c99a8e62c8c33ebe790d38de26e Mon Sep 17 00:00:00 2001 From: SamoZ256 <96914946+SamoZ256@users.noreply.github.com> Date: Tue, 5 Nov 2024 22:22:00 +0100 Subject: [PATCH 03/50] macOS: Fix libusb path for bundle (#1403) --- src/CMakeLists.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7d64d91b..84d4fcad 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -101,12 +101,18 @@ if (MACOS_BUNDLE) COMMAND ${CMAKE_COMMAND} ARGS -E copy_directory "${CMAKE_SOURCE_DIR}/bin/${folder}" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/SharedSupport/${folder}") endforeach(folder) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(LIBUSB_PATH "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-osx/debug/lib/libusb-1.0.0.dylib") + else() + set(LIBUSB_PATH "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-osx/lib/libusb-1.0.0.dylib") + endif() + add_custom_command (TARGET CemuBin POST_BUILD COMMAND ${CMAKE_COMMAND} ARGS -E copy "/usr/local/lib/libMoltenVK.dylib" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libMoltenVK.dylib" - COMMAND ${CMAKE_COMMAND} ARGS -E copy "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-osx/lib/libusb-1.0.0.dylib" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libusb-1.0.0.dylib" + COMMAND ${CMAKE_COMMAND} ARGS -E copy "${LIBUSB_PATH}" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libusb-1.0.0.dylib" COMMAND ${CMAKE_COMMAND} ARGS -E copy "${CMAKE_SOURCE_DIR}/src/resource/update.sh" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/update.sh" COMMAND bash -c "install_name_tool -add_rpath @executable_path/../Frameworks ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}" - COMMAND bash -c "install_name_tool -change /Users/runner/work/Cemu/Cemu/build/vcpkg_installed/x64-osx/lib/libusb-1.0.0.dylib @executable_path/../Frameworks/libusb-1.0.0.dylib ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}") + COMMAND bash -c "install_name_tool -change ${LIBUSB_PATH} ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libusb-1.0.0.dylib ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}") endif() set_target_properties(CemuBin PROPERTIES From 813f9148b1afe75053102877e3db5733f701660e Mon Sep 17 00:00:00 2001 From: SamoZ256 <96914946+SamoZ256@users.noreply.github.com> Date: Thu, 7 Nov 2024 07:09:35 +0100 Subject: [PATCH 04/50] macOS: Fix absolute path to libusb dylib (#1405) --- src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 84d4fcad..3792ab85 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -112,7 +112,7 @@ if (MACOS_BUNDLE) COMMAND ${CMAKE_COMMAND} ARGS -E copy "${LIBUSB_PATH}" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libusb-1.0.0.dylib" COMMAND ${CMAKE_COMMAND} ARGS -E copy "${CMAKE_SOURCE_DIR}/src/resource/update.sh" "${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/update.sh" COMMAND bash -c "install_name_tool -add_rpath @executable_path/../Frameworks ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}" - COMMAND bash -c "install_name_tool -change ${LIBUSB_PATH} ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/Frameworks/libusb-1.0.0.dylib ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}") + COMMAND bash -c "install_name_tool -change ${LIBUSB_PATH} @executable_path/../Frameworks/libusb-1.0.0.dylib ${CMAKE_SOURCE_DIR}/bin/${OUTPUT_NAME}.app/Contents/MacOS/${OUTPUT_NAME}") endif() set_target_properties(CemuBin PROPERTIES From 4ac1ab162a2a2734a4b1839e57ff367233e16853 Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Sat, 9 Nov 2024 05:21:06 +0000 Subject: [PATCH 05/50] procui: swap `tickDelay` and `priority` args in callbacks (#1408) --- src/Cafe/OS/libs/proc_ui/proc_ui.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cafe/OS/libs/proc_ui/proc_ui.cpp b/src/Cafe/OS/libs/proc_ui/proc_ui.cpp index dd9a460f..ff38abbb 100644 --- a/src/Cafe/OS/libs/proc_ui/proc_ui.cpp +++ b/src/Cafe/OS/libs/proc_ui/proc_ui.cpp @@ -427,7 +427,7 @@ namespace proc_ui } if(callbackType != ProcUICallbackId::AcquireForeground) priority = -priority; - AddCallbackInternal(funcPtr, userParam, priority, 0, s_CallbackTables[stdx::to_underlying(callbackType)][coreIndex]); + AddCallbackInternal(funcPtr, userParam, 0, priority, s_CallbackTables[stdx::to_underlying(callbackType)][coreIndex]); } void ProcUIRegisterCallback(ProcUICallbackId callbackType, void* funcPtr, void* userParam, sint32 priority) @@ -437,7 +437,7 @@ namespace proc_ui void ProcUIRegisterBackgroundCallback(void* funcPtr, void* userParam, uint64 tickDelay) { - AddCallbackInternal(funcPtr, userParam, 0, tickDelay, s_backgroundCallbackList); + AddCallbackInternal(funcPtr, userParam, tickDelay, 0, s_backgroundCallbackList); } void FreeCallbackChain(ProcUICallbackList& callbackList) From 2e829479d9f63dcfbd8ef67d456793a70f684b18 Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Sat, 9 Nov 2024 05:22:13 +0000 Subject: [PATCH 06/50] nsyshid/libusb: correct error message formatting and print error string on open fail (#1407) --- src/Cafe/OS/libs/nsyshid/BackendLibusb.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Cafe/OS/libs/nsyshid/BackendLibusb.cpp b/src/Cafe/OS/libs/nsyshid/BackendLibusb.cpp index 7548c998..ab355136 100644 --- a/src/Cafe/OS/libs/nsyshid/BackendLibusb.cpp +++ b/src/Cafe/OS/libs/nsyshid/BackendLibusb.cpp @@ -15,7 +15,7 @@ namespace nsyshid::backend::libusb if (m_initReturnCode < 0) { m_ctx = nullptr; - cemuLog_logDebug(LogType::Force, "nsyshid::BackendLibusb: failed to initialize libusb with return code %i", + cemuLog_logDebug(LogType::Force, "nsyshid::BackendLibusb: failed to initialize libusb, return code: {}", m_initReturnCode); return; } @@ -35,7 +35,7 @@ namespace nsyshid::backend::libusb if (ret != LIBUSB_SUCCESS) { cemuLog_logDebug(LogType::Force, - "nsyshid::BackendLibusb: failed to register hotplug callback with return code %i", + "nsyshid::BackendLibusb: failed to register hotplug callback with return code {}", ret); } else @@ -415,7 +415,7 @@ namespace nsyshid::backend::libusb if (ret < 0) { cemuLog_log(LogType::Force, - "nsyshid::DeviceLibusb::open(): failed to get device descriptor; return code: %i", + "nsyshid::DeviceLibusb::open(): failed to get device descriptor, return code: {}", ret); libusb_free_device_list(devices, 1); return false; @@ -439,8 +439,8 @@ namespace nsyshid::backend::libusb { this->m_libusbHandle = nullptr; cemuLog_log(LogType::Force, - "nsyshid::DeviceLibusb::open(): failed to open device; return code: %i", - ret); + "nsyshid::DeviceLibusb::open(): failed to open device: {}", + libusb_strerror(ret)); libusb_free_device_list(devices, 1); return false; } From ca2e0a7c31dcaeac110dc4aa703a992b55c8155f Mon Sep 17 00:00:00 2001 From: Joshua de Reeper Date: Mon, 11 Nov 2024 07:58:01 +0000 Subject: [PATCH 07/50] nsyshid: Add support for emulated Dimensions Toypad (#1371) --- src/Cafe/CMakeLists.txt | 2 + src/Cafe/OS/libs/nsyshid/BackendEmulated.cpp | 9 + src/Cafe/OS/libs/nsyshid/Dimensions.cpp | 1162 +++++++++++++++++ src/Cafe/OS/libs/nsyshid/Dimensions.h | 108 ++ src/config/CemuConfig.cpp | 2 + src/config/CemuConfig.h | 1 + .../EmulatedUSBDeviceFrame.cpp | 410 +++++- .../EmulatedUSBDeviceFrame.h | 48 +- 8 files changed, 1690 insertions(+), 52 deletions(-) create mode 100644 src/Cafe/OS/libs/nsyshid/Dimensions.cpp create mode 100644 src/Cafe/OS/libs/nsyshid/Dimensions.h diff --git a/src/Cafe/CMakeLists.txt b/src/Cafe/CMakeLists.txt index 91d257b2..0901fece 100644 --- a/src/Cafe/CMakeLists.txt +++ b/src/Cafe/CMakeLists.txt @@ -465,6 +465,8 @@ add_library(CemuCafe OS/libs/nsyshid/BackendLibusb.h OS/libs/nsyshid/BackendWindowsHID.cpp OS/libs/nsyshid/BackendWindowsHID.h + OS/libs/nsyshid/Dimensions.cpp + OS/libs/nsyshid/Dimensions.h OS/libs/nsyshid/Infinity.cpp OS/libs/nsyshid/Infinity.h OS/libs/nsyshid/Skylander.cpp diff --git a/src/Cafe/OS/libs/nsyshid/BackendEmulated.cpp b/src/Cafe/OS/libs/nsyshid/BackendEmulated.cpp index 95eaf06a..533d349e 100644 --- a/src/Cafe/OS/libs/nsyshid/BackendEmulated.cpp +++ b/src/Cafe/OS/libs/nsyshid/BackendEmulated.cpp @@ -1,4 +1,6 @@ #include "BackendEmulated.h" + +#include "Dimensions.h" #include "Infinity.h" #include "Skylander.h" #include "config/CemuConfig.h" @@ -33,5 +35,12 @@ namespace nsyshid::backend::emulated auto device = std::make_shared(); AttachDevice(device); } + if (GetConfig().emulated_usb_devices.emulate_dimensions_toypad && !FindDeviceById(0x0E6F, 0x0241)) + { + cemuLog_logDebug(LogType::Force, "Attaching Emulated Toypad"); + // Add Dimensions Toypad + auto device = std::make_shared(); + AttachDevice(device); + } } } // namespace nsyshid::backend::emulated \ No newline at end of file diff --git a/src/Cafe/OS/libs/nsyshid/Dimensions.cpp b/src/Cafe/OS/libs/nsyshid/Dimensions.cpp new file mode 100644 index 00000000..f328dde7 --- /dev/null +++ b/src/Cafe/OS/libs/nsyshid/Dimensions.cpp @@ -0,0 +1,1162 @@ +#include "Dimensions.h" + +#include "nsyshid.h" +#include "Backend.h" + +#include "Common/FileStream.h" + +#include +#include + +namespace nsyshid +{ + static constexpr std::array COMMAND_KEY = {0x55, 0xFE, 0xF6, 0xB0, 0x62, 0xBF, 0x0B, 0x41, + 0xC9, 0xB3, 0x7C, 0xB4, 0x97, 0x3E, 0x29, 0x7B}; + + static constexpr std::array CHAR_CONSTANT = {0xB7, 0xD5, 0xD7, 0xE6, 0xE7, 0xBA, 0x3C, 0xA8, + 0xD8, 0x75, 0x47, 0x68, 0xCF, 0x23, 0xE9, 0xFE, 0xAA}; + + static constexpr std::array PWD_CONSTANT = {0x28, 0x63, 0x29, 0x20, 0x43, 0x6F, 0x70, 0x79, + 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x4C, 0x45, + 0x47, 0x4F, 0x20, 0x32, 0x30, 0x31, 0x34, 0xAA, 0xAA}; + + DimensionsUSB g_dimensionstoypad; + + const std::map s_listMinis = { + {0, "Blank Tag"}, + {1, "Batman"}, + {2, "Gandalf"}, + {3, "Wyldstyle"}, + {4, "Aquaman"}, + {5, "Bad Cop"}, + {6, "Bane"}, + {7, "Bart Simpson"}, + {8, "Benny"}, + {9, "Chell"}, + {10, "Cole"}, + {11, "Cragger"}, + {12, "Cyborg"}, + {13, "Cyberman"}, + {14, "Doc Brown"}, + {15, "The Doctor"}, + {16, "Emmet"}, + {17, "Eris"}, + {18, "Gimli"}, + {19, "Gollum"}, + {20, "Harley Quinn"}, + {21, "Homer Simpson"}, + {22, "Jay"}, + {23, "Joker"}, + {24, "Kai"}, + {25, "ACU Trooper"}, + {26, "Gamer Kid"}, + {27, "Krusty the Clown"}, + {28, "Laval"}, + {29, "Legolas"}, + {30, "Lloyd"}, + {31, "Marty McFly"}, + {32, "Nya"}, + {33, "Owen Grady"}, + {34, "Peter Venkman"}, + {35, "Slimer"}, + {36, "Scooby-Doo"}, + {37, "Sensei Wu"}, + {38, "Shaggy"}, + {39, "Stay Puft"}, + {40, "Superman"}, + {41, "Unikitty"}, + {42, "Wicked Witch of the West"}, + {43, "Wonder Woman"}, + {44, "Zane"}, + {45, "Green Arrow"}, + {46, "Supergirl"}, + {47, "Abby Yates"}, + {48, "Finn the Human"}, + {49, "Ethan Hunt"}, + {50, "Lumpy Space Princess"}, + {51, "Jake the Dog"}, + {52, "Harry Potter"}, + {53, "Lord Voldemort"}, + {54, "Michael Knight"}, + {55, "B.A. Baracus"}, + {56, "Newt Scamander"}, + {57, "Sonic the Hedgehog"}, + {58, "Future Update (unreleased)"}, + {59, "Gizmo"}, + {60, "Stripe"}, + {61, "E.T."}, + {62, "Tina Goldstein"}, + {63, "Marceline the Vampire Queen"}, + {64, "Batgirl"}, + {65, "Robin"}, + {66, "Sloth"}, + {67, "Hermione Granger"}, + {68, "Chase McCain"}, + {69, "Excalibur Batman"}, + {70, "Raven"}, + {71, "Beast Boy"}, + {72, "Betelgeuse"}, + {73, "Lord Vortech (unreleased)"}, + {74, "Blossom"}, + {75, "Bubbles"}, + {76, "Buttercup"}, + {77, "Starfire"}, + {78, "World 15 (unreleased)"}, + {79, "World 16 (unreleased)"}, + {80, "World 17 (unreleased)"}, + {81, "World 18 (unreleased)"}, + {82, "World 19 (unreleased)"}, + {83, "World 20 (unreleased)"}, + {768, "Unknown 768"}, + {769, "Supergirl Red Lantern"}, + {770, "Unknown 770"}}; + + const std::map s_listTokens = { + {1000, "Police Car"}, + {1001, "Aerial Squad Car"}, + {1002, "Missile Striker"}, + {1003, "Gravity Sprinter"}, + {1004, "Street Shredder"}, + {1005, "Sky Clobberer"}, + {1006, "Batmobile"}, + {1007, "Batblaster"}, + {1008, "Sonic Batray"}, + {1009, "Benny's Spaceship"}, + {1010, "Lasercraft"}, + {1011, "The Annihilator"}, + {1012, "DeLorean Time Machine"}, + {1013, "Electric Time Machine"}, + {1014, "Ultra Time Machine"}, + {1015, "Hoverboard"}, + {1016, "Cyclone Board"}, + {1017, "Ultimate Hoverjet"}, + {1018, "Eagle Interceptor"}, + {1019, "Eagle Sky Blazer"}, + {1020, "Eagle Swoop Diver"}, + {1021, "Swamp Skimmer"}, + {1022, "Cragger's Fireship"}, + {1023, "Croc Command Sub"}, + {1024, "Cyber-Guard"}, + {1025, "Cyber-Wrecker"}, + {1026, "Laser Robot Walker"}, + {1027, "K-9"}, + {1028, "K-9 Ruff Rover"}, + {1029, "K-9 Laser Cutter"}, + {1030, "TARDIS"}, + {1031, "Laser-Pulse TARDIS"}, + {1032, "Energy-Burst TARDIS"}, + {1033, "Emmet's Excavator"}, + {1034, "Destroy Dozer"}, + {1035, "Destruct-o-Mech"}, + {1036, "Winged Monkey"}, + {1037, "Battle Monkey"}, + {1038, "Commander Monkey"}, + {1039, "Axe Chariot"}, + {1040, "Axe Hurler"}, + {1041, "Soaring Chariot"}, + {1042, "Shelob the Great"}, + {1043, "8-Legged Stalker"}, + {1044, "Poison Slinger"}, + {1045, "Homer's Car"}, + {1047, "The SubmaHomer"}, + {1046, "The Homercraft"}, + {1048, "Taunt-o-Vision"}, + {1050, "The MechaHomer"}, + {1049, "Blast Cam"}, + {1051, "Velociraptor"}, + {1053, "Venom Raptor"}, + {1052, "Spike Attack Raptor"}, + {1054, "Gyrosphere"}, + {1055, "Sonic Beam Gyrosphere"}, + {1056, " Gyrosphere"}, + {1057, "Clown Bike"}, + {1058, "Cannon Bike"}, + {1059, "Anti-Gravity Rocket Bike"}, + {1060, "Mighty Lion Rider"}, + {1061, "Lion Blazer"}, + {1062, "Fire Lion"}, + {1063, "Arrow Launcher"}, + {1064, "Seeking Shooter"}, + {1065, "Triple Ballista"}, + {1066, "Mystery Machine"}, + {1067, "Mystery Tow & Go"}, + {1068, "Mystery Monster"}, + {1069, "Boulder Bomber"}, + {1070, "Boulder Blaster"}, + {1071, "Cyclone Jet"}, + {1072, "Storm Fighter"}, + {1073, "Lightning Jet"}, + {1074, "Electro-Shooter"}, + {1075, "Blade Bike"}, + {1076, "Flight Fire Bike"}, + {1077, "Blades of Fire"}, + {1078, "Samurai Mech"}, + {1079, "Samurai Shooter"}, + {1080, "Soaring Samurai Mech"}, + {1081, "Companion Cube"}, + {1082, "Laser Deflector"}, + {1083, "Gold Heart Emitter"}, + {1084, "Sentry Turret"}, + {1085, "Turret Striker"}, + {1086, "Flight Turret Carrier"}, + {1087, "Scooby Snack"}, + {1088, "Scooby Fire Snack"}, + {1089, "Scooby Ghost Snack"}, + {1090, "Cloud Cuckoo Car"}, + {1091, "X-Stream Soaker"}, + {1092, "Rainbow Cannon"}, + {1093, "Invisible Jet"}, + {1094, "Laser Shooter"}, + {1095, "Torpedo Bomber"}, + {1096, "NinjaCopter"}, + {1097, "Glaciator"}, + {1098, "Freeze Fighter"}, + {1099, "Travelling Time Train"}, + {1100, "Flight Time Train"}, + {1101, "Missile Blast Time Train"}, + {1102, "Aqua Watercraft"}, + {1103, "Seven Seas Speeder"}, + {1104, "Trident of Fire"}, + {1105, "Drill Driver"}, + {1106, "Bane Dig 'n' Drill"}, + {1107, "Bane Drill 'n' Blast"}, + {1108, "Quinn Mobile"}, + {1109, "Quinn Ultra Racer"}, + {1110, "Missile Launcher"}, + {1111, "The Joker's Chopper"}, + {1112, "Mischievous Missile Blaster"}, + {1113, "Lock 'n' Laser Jet"}, + {1114, "Hover Pod"}, + {1115, "Krypton Striker"}, + {1116, "Super Stealth Pod"}, + {1117, "Dalek"}, + {1118, "Fire 'n' Ride Dalek"}, + {1119, "Silver Shooter Dalek"}, + {1120, "Ecto-1"}, + {1121, "Ecto-1 Blaster"}, + {1122, "Ecto-1 Water Diver"}, + {1123, "Ghost Trap"}, + {1124, "Ghost Stun 'n' Trap"}, + {1125, "Proton Zapper"}, + {1126, "Unknown"}, + {1127, "Unknown"}, + {1128, "Unknown"}, + {1129, "Unknown"}, + {1130, "Unknown"}, + {1131, "Unknown"}, + {1132, "Lloyd's Golden Dragon"}, + {1133, "Sword Projector Dragon"}, + {1134, "Unknown"}, + {1135, "Unknown"}, + {1136, "Unknown"}, + {1137, "Unknown"}, + {1138, "Unknown"}, + {1139, "Unknown"}, + {1140, "Unknown"}, + {1141, "Unknown"}, + {1142, "Unknown"}, + {1143, "Unknown"}, + {1144, "Mega Flight Dragon"}, + {1145, "Unknown"}, + {1146, "Unknown"}, + {1147, "Unknown"}, + {1148, "Unknown"}, + {1149, "Unknown"}, + {1150, "Unknown"}, + {1151, "Unknown"}, + {1152, "Unknown"}, + {1153, "Unknown"}, + {1154, "Unknown"}, + {1155, "Flying White Dragon"}, + {1156, "Golden Fire Dragon"}, + {1157, "Ultra Destruction Dragon"}, + {1158, "Arcade Machine"}, + {1159, "8-Bit Shooter"}, + {1160, "The Pixelator"}, + {1161, "G-6155 Spy Hunter"}, + {1162, "Interdiver"}, + {1163, "Aerial Spyhunter"}, + {1164, "Slime Shooter"}, + {1165, "Slime Exploder"}, + {1166, "Slime Streamer"}, + {1167, "Terror Dog"}, + {1168, "Terror Dog Destroyer"}, + {1169, "Soaring Terror Dog"}, + {1170, "Ancient Psychic Tandem War Elephant"}, + {1171, "Cosmic Squid"}, + {1172, "Psychic Submarine"}, + {1173, "BMO"}, + {1174, "DOGMO"}, + {1175, "SNAKEMO"}, + {1176, "Jakemobile"}, + {1177, "Snail Dude Jake"}, + {1178, "Hover Jake"}, + {1179, "Lumpy Car"}, + {1181, "Lumpy Land Whale"}, + {1180, "Lumpy Truck"}, + {1182, "Lunatic Amp"}, + {1183, "Shadow Scorpion"}, + {1184, "Heavy Metal Monster"}, + {1185, "B.A.'s Van"}, + {1186, "Fool Smasher"}, + {1187, "Pain Plane"}, + {1188, "Phone Home"}, + {1189, "Mobile Uplink"}, + {1190, "Super-Charged Satellite"}, + {1191, "Niffler"}, + {1192, "Sinister Scorpion"}, + {1193, "Vicious Vulture"}, + {1194, "Swooping Evil"}, + {1195, "Brutal Bloom"}, + {1196, "Crawling Creeper"}, + {1197, "Ecto-1 (2016)"}, + {1198, "Ectozer"}, + {1199, "PerfEcto"}, + {1200, "Flash 'n' Finish"}, + {1201, "Rampage Record Player"}, + {1202, "Stripe's Throne"}, + {1203, "R.C. Racer"}, + {1204, "Gadget-O-Matic"}, + {1205, "Scarlet Scorpion"}, + {1206, "Hogwarts Express"}, + {1208, "Steam Warrior"}, + {1207, "Soaring Steam Plane"}, + {1209, "Enchanted Car"}, + {1210, "Shark Sub"}, + {1211, "Monstrous Mouth"}, + {1212, "IMF Scrambler"}, + {1213, "Shock Cycle"}, + {1214, "IMF Covert Jet"}, + {1215, "IMF Sports Car"}, + {1216, "IMF Tank"}, + {1217, "IMF Splorer"}, + {1218, "Sonic Speedster"}, + {1219, "Blue Typhoon"}, + {1220, "Moto Bug"}, + {1221, "The Tornado"}, + {1222, "Crabmeat"}, + {1223, "Eggcatcher"}, + {1224, "K.I.T.T."}, + {1225, "Goliath Armored Semi"}, + {1226, "K.I.T.T. Jet"}, + {1227, "Police Helicopter"}, + {1228, "Police Hovercraft"}, + {1229, "Police Plane"}, + {1230, "Bionic Steed"}, + {1231, "Bat-Raptor"}, + {1232, "Ultrabat"}, + {1233, "Batwing"}, + {1234, "The Black Thunder"}, + {1235, "Bat-Tank"}, + {1236, "Skeleton Organ"}, + {1237, "Skeleton Jukebox"}, + {1238, "Skele-Turkey"}, + {1239, "One-Eyed Willy's Pirate Ship"}, + {1240, "Fanged Fortune"}, + {1241, "Inferno Cannon"}, + {1242, "Buckbeak"}, + {1243, "Giant Owl"}, + {1244, "Fierce Falcon"}, + {1245, "Saturn's Sandworm"}, + {1247, "Haunted Vacuum"}, + {1246, "Spooky Spider"}, + {1248, "PPG Smartphone"}, + {1249, "PPG Hotline"}, + {1250, "Powerpuff Mag-Net"}, + {1253, "Mega Blast Bot"}, + {1251, "Ka-Pow Cannon"}, + {1252, "Slammin' Guitar"}, + {1254, "Octi"}, + {1255, "Super Skunk"}, + {1256, "Sonic Squid"}, + {1257, "T-Car"}, + {1258, "T-Forklift"}, + {1259, "T-Plane"}, + {1260, "Spellbook of Azarath"}, + {1261, "Raven Wings"}, + {1262, "Giant Hand"}, + {1263, "Titan Robot"}, + {1264, "T-Rocket"}, + {1265, "Robot Retriever"}}; + + DimensionsToypadDevice::DimensionsToypadDevice() + : Device(0x0E6F, 0x0241, 1, 2, 0) + { + m_IsOpened = false; + } + + bool DimensionsToypadDevice::Open() + { + if (!IsOpened()) + { + m_IsOpened = true; + } + return true; + } + + void DimensionsToypadDevice::Close() + { + if (IsOpened()) + { + m_IsOpened = false; + } + } + + bool DimensionsToypadDevice::IsOpened() + { + return m_IsOpened; + } + + Device::ReadResult DimensionsToypadDevice::Read(ReadMessage* message) + { + memcpy(message->data, g_dimensionstoypad.GetStatus().data(), message->length); + message->bytesRead = message->length; + return Device::ReadResult::Success; + } + + Device::WriteResult DimensionsToypadDevice::Write(WriteMessage* message) + { + if (message->length != 32) + return Device::WriteResult::Error; + + g_dimensionstoypad.SendCommand(std::span{message->data, 32}); + message->bytesWritten = message->length; + return Device::WriteResult::Success; + } + + bool DimensionsToypadDevice::GetDescriptor(uint8 descType, + uint8 descIndex, + uint8 lang, + uint8* output, + uint32 outputMaxLength) + { + uint8 configurationDescriptor[0x29]; + + uint8* currentWritePtr; + + // configuration descriptor + currentWritePtr = configurationDescriptor + 0; + *(uint8*)(currentWritePtr + 0) = 9; // bLength + *(uint8*)(currentWritePtr + 1) = 2; // bDescriptorType + *(uint16be*)(currentWritePtr + 2) = 0x0029; // wTotalLength + *(uint8*)(currentWritePtr + 4) = 1; // bNumInterfaces + *(uint8*)(currentWritePtr + 5) = 1; // bConfigurationValue + *(uint8*)(currentWritePtr + 6) = 0; // iConfiguration + *(uint8*)(currentWritePtr + 7) = 0x80; // bmAttributes + *(uint8*)(currentWritePtr + 8) = 0xFA; // MaxPower + currentWritePtr = currentWritePtr + 9; + // configuration descriptor + *(uint8*)(currentWritePtr + 0) = 9; // bLength + *(uint8*)(currentWritePtr + 1) = 0x04; // bDescriptorType + *(uint8*)(currentWritePtr + 2) = 0; // bInterfaceNumber + *(uint8*)(currentWritePtr + 3) = 0; // bAlternateSetting + *(uint8*)(currentWritePtr + 4) = 2; // bNumEndpoints + *(uint8*)(currentWritePtr + 5) = 3; // bInterfaceClass + *(uint8*)(currentWritePtr + 6) = 0; // bInterfaceSubClass + *(uint8*)(currentWritePtr + 7) = 0; // bInterfaceProtocol + *(uint8*)(currentWritePtr + 8) = 0; // iInterface + currentWritePtr = currentWritePtr + 9; + // configuration descriptor + *(uint8*)(currentWritePtr + 0) = 9; // bLength + *(uint8*)(currentWritePtr + 1) = 0x21; // bDescriptorType + *(uint16be*)(currentWritePtr + 2) = 0x0111; // bcdHID + *(uint8*)(currentWritePtr + 4) = 0x00; // bCountryCode + *(uint8*)(currentWritePtr + 5) = 0x01; // bNumDescriptors + *(uint8*)(currentWritePtr + 6) = 0x22; // bDescriptorType + *(uint16be*)(currentWritePtr + 7) = 0x001D; // wDescriptorLength + currentWritePtr = currentWritePtr + 9; + // endpoint descriptor 1 + *(uint8*)(currentWritePtr + 0) = 7; // bLength + *(uint8*)(currentWritePtr + 1) = 0x05; // bDescriptorType + *(uint8*)(currentWritePtr + 2) = 0x81; // bEndpointAddress + *(uint8*)(currentWritePtr + 3) = 0x03; // bmAttributes + *(uint16be*)(currentWritePtr + 4) = 0x40; // wMaxPacketSize + *(uint8*)(currentWritePtr + 6) = 0x01; // bInterval + currentWritePtr = currentWritePtr + 7; + // endpoint descriptor 2 + *(uint8*)(currentWritePtr + 0) = 7; // bLength + *(uint8*)(currentWritePtr + 1) = 0x05; // bDescriptorType + *(uint8*)(currentWritePtr + 1) = 0x02; // bEndpointAddress + *(uint8*)(currentWritePtr + 2) = 0x03; // bmAttributes + *(uint16be*)(currentWritePtr + 3) = 0x40; // wMaxPacketSize + *(uint8*)(currentWritePtr + 5) = 0x01; // bInterval + currentWritePtr = currentWritePtr + 7; + + cemu_assert_debug((currentWritePtr - configurationDescriptor) == 0x29); + + memcpy(output, configurationDescriptor, + std::min(outputMaxLength, sizeof(configurationDescriptor))); + return true; + } + + bool DimensionsToypadDevice::SetProtocol(uint8 ifIndex, uint8 protocol) + { + cemuLog_log(LogType::Force, "Toypad Protocol"); + return true; + } + + bool DimensionsToypadDevice::SetReport(ReportMessage* message) + { + cemuLog_log(LogType::Force, "Toypad Report"); + return true; + } + + std::array DimensionsUSB::GetStatus() + { + std::array response = {}; + + bool responded = false; + do + { + if (!m_queries.empty()) + { + response = m_queries.front(); + m_queries.pop(); + responded = true; + } + else if (!m_figureAddedRemovedResponses.empty() && m_isAwake) + { + std::lock_guard lock(m_dimensionsMutex); + response = m_figureAddedRemovedResponses.front(); + m_figureAddedRemovedResponses.pop(); + responded = true; + } + else + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + while (responded == false); + return response; + } + + void DimensionsUSB::SendCommand(std::span buf) + { + const uint8 command = buf[2]; + const uint8 sequence = buf[3]; + + std::array q_result{}; + + switch (command) + { + case 0xB0: // Wake + { + // Consistent device response to the wake command + q_result = {0x55, 0x0e, 0x01, 0x28, 0x63, 0x29, + 0x20, 0x4c, 0x45, 0x47, 0x4f, 0x20, + 0x32, 0x30, 0x31, 0x34, 0x46}; + break; + } + case 0xB1: // Seed + { + // Initialise a random number generator using the seed provided + g_dimensionstoypad.GenerateRandomNumber(std::span{buf.begin() + 4, 8}, sequence, q_result); + break; + } + case 0xB3: // Challenge + { + // Get the next number in the sequence based on the RNG from 0xB1 command + g_dimensionstoypad.GetChallengeResponse(std::span{buf.begin() + 4, 8}, sequence, q_result); + break; + } + case 0xC0: // Color + case 0xC1: // Get Pad Color + case 0xC2: // Fade + case 0xC3: // Flash + case 0xC4: // Fade Random + case 0xC6: // Fade All + case 0xC7: // Flash All + case 0xC8: // Color All + { + // Send a blank response to acknowledge color has been sent to toypad + q_result = {0x55, 0x01, sequence}; + q_result[3] = GenerateChecksum(q_result, 3); + break; + } + case 0xD2: // Read + { + // Read 4 pages from the figure at index (buf[4]), starting with page buf[5] + g_dimensionstoypad.QueryBlock(buf[4], buf[5], q_result, sequence); + break; + } + case 0xD3: // Write + { + // Write 4 bytes to page buf[5] to the figure at index buf[4] + g_dimensionstoypad.WriteBlock(buf[4], buf[5], std::span{buf.begin() + 6, 4}, q_result, sequence); + break; + } + case 0xD4: // Model + { + // Get the model id of the figure at index buf[4] + g_dimensionstoypad.GetModel(std::span{buf.begin() + 4, 8}, sequence, q_result); + break; + } + case 0xD0: // Tag List + case 0xE1: // PWD + case 0xE5: // Active + case 0xFF: // LEDS Query + { + // Further investigation required + cemuLog_log(LogType::Force, "Unimplemented LD Function: {:x}", command); + break; + } + default: + { + cemuLog_log(LogType::Force, "Unknown LD Function: {:x}", command); + break; + } + } + + m_queries.push(q_result); + } + + uint32 DimensionsUSB::LoadFigure(const std::array& buf, std::unique_ptr file, uint8 pad, uint8 index) + { + std::lock_guard lock(m_dimensionsMutex); + + const uint32 id = GetFigureId(buf); + + DimensionsMini& figure = GetFigureByIndex(index); + figure.dimFile = std::move(file); + figure.id = id; + figure.pad = pad; + figure.index = index + 1; + figure.data = buf; + // When a figure is added to the toypad, respond to the game with the pad they were added to, their index, + // the direction (0x00 in byte 6 for added) and their UID + std::array figureChangeResponse = {0x56, 0x0b, figure.pad, 0x00, figure.index, 0x00, buf[0], buf[1], buf[2], buf[4], buf[5], buf[6], buf[7]}; + figureChangeResponse[13] = GenerateChecksum(figureChangeResponse, 13); + m_figureAddedRemovedResponses.push(figureChangeResponse); + + return id; + } + + bool DimensionsUSB::RemoveFigure(uint8 pad, uint8 index, bool fullRemove) + { + std::lock_guard lock(m_dimensionsMutex); + + DimensionsMini& figure = GetFigureByIndex(index); + if (figure.index == 255) + return false; + + // When a figure is removed from the toypad, respond to the game with the pad they were removed from, their index, + // the direction (0x01 in byte 6 for removed) and their UID + if (fullRemove) + { + std::array figureChangeResponse = {0x56, 0x0b, figure.pad, 0x00, figure.index, 0x01, + figure.data[0], figure.data[1], figure.data[2], + figure.data[4], figure.data[5], figure.data[6], figure.data[7]}; + figureChangeResponse[13] = GenerateChecksum(figureChangeResponse, 13); + m_figureAddedRemovedResponses.push(figureChangeResponse); + figure.Save(); + figure.dimFile.reset(); + } + + figure.index = 255; + figure.pad = 255; + figure.id = 0; + + return true; + } + + bool DimensionsUSB::TempRemove(uint8 index) + { + std::lock_guard lock(m_dimensionsMutex); + + DimensionsMini& figure = GetFigureByIndex(index); + if (figure.index == 255) + return false; + + // Send a response to the game that the figure has been "Picked up" from existing slot, + // until either the movement is cancelled, or user chooses a space to move to + std::array figureChangeResponse = {0x56, 0x0b, figure.pad, 0x00, figure.index, 0x01, + figure.data[0], figure.data[1], figure.data[2], + figure.data[4], figure.data[5], figure.data[6], figure.data[7]}; + + figureChangeResponse[13] = GenerateChecksum(figureChangeResponse, 13); + m_figureAddedRemovedResponses.push(figureChangeResponse); + } + + bool DimensionsUSB::CancelRemove(uint8 index) + { + std::lock_guard lock(m_dimensionsMutex); + + DimensionsMini& figure = GetFigureByIndex(index); + if (figure.index == 255) + return false; + + // Cancel the previous movement of the figure + std::array figureChangeResponse = {0x56, 0x0b, figure.pad, 0x00, figure.index, 0x00, + figure.data[0], figure.data[1], figure.data[2], + figure.data[4], figure.data[5], figure.data[6], figure.data[7]}; + + figureChangeResponse[13] = GenerateChecksum(figureChangeResponse, 13); + m_figureAddedRemovedResponses.push(figureChangeResponse); + + return true; + } + + bool DimensionsUSB::CreateFigure(fs::path pathName, uint32 id) + { + FileStream* dimFile(FileStream::createFile2(pathName)); + if (!dimFile) + return false; + + std::array fileData{}; + RandomUID(fileData); + fileData[3] = id & 0xFF; + + std::array uid = {fileData[0], fileData[1], fileData[2], fileData[4], fileData[5], fileData[6], fileData[7]}; + + // Only characters are created with their ID encrypted and stored in pages 36 and 37, + // as well as a password stored in page 43. Blank tags have their information populated + // by the game when it calls the write_block command. + if (id != 0) + { + const std::array figureKey = GenerateFigureKey(fileData); + + std::array valueToEncrypt = {uint8(id & 0xFF), uint8((id >> 8) & 0xFF), uint8((id >> 16) & 0xFF), uint8((id >> 24) & 0xFF), + uint8(id & 0xFF), uint8((id >> 8) & 0xFF), uint8((id >> 16) & 0xFF), uint8((id >> 24) & 0xFF)}; + + std::array encrypted = Encrypt(valueToEncrypt, figureKey); + + std::memcpy(&fileData[36 * 4], &encrypted[0], 4); + std::memcpy(&fileData[37 * 4], &encrypted[4], 4); + + std::memcpy(&fileData[43 * 4], PWDGenerate(fileData).data(), 4); + } + else + { + // Page 38 is used as verification for blank tags + fileData[(38 * 4) + 1] = 1; + } + + if (fileData.size() != dimFile->writeData(fileData.data(), fileData.size())) + { + delete dimFile; + return false; + } + delete dimFile; + return true; + } + + bool DimensionsUSB::MoveFigure(uint8 pad, uint8 index, uint8 oldPad, uint8 oldIndex) + { + if (oldIndex == index) + { + // Don't bother removing and loading again, just send response to the game + CancelRemove(index); + return true; + } + + // When moving figures between spaces on the toypad, remove any figure from the space they are moving to, + // then remove them from their current space, then load them to the space they are moving to + RemoveFigure(pad, index, true); + + DimensionsMini& figure = GetFigureByIndex(oldIndex); + const std::array data = figure.data; + std::unique_ptr inFile = std::move(figure.dimFile); + + RemoveFigure(oldPad, oldIndex, false); + + LoadFigure(data, std::move(inFile), pad, index); + + return true; + } + + void DimensionsUSB::GenerateRandomNumber(std::span buf, uint8 sequence, + std::array& replyBuf) + { + // Decrypt payload into an 8 byte array + std::array value = Decrypt(buf, std::nullopt); + // Seed is the first 4 bytes (little endian) of the decrypted payload + uint32 seed = (uint32&)value[0]; + // Confirmation is the second 4 bytes (big endian) of the decrypted payload + uint32 conf = (uint32be&)value[4]; + // Initialize rng using the seed from decrypted payload + InitializeRNG(seed); + // Encrypt 8 bytes, first 4 bytes is the decrypted confirmation from payload, 2nd 4 bytes are blank + std::array valueToEncrypt = {value[4], value[5], value[6], value[7], 0, 0, 0, 0}; + std::array encrypted = Encrypt(valueToEncrypt, std::nullopt); + replyBuf[0] = 0x55; + replyBuf[1] = 0x09; + replyBuf[2] = sequence; + // Copy encrypted value to response data + memcpy(&replyBuf[3], encrypted.data(), encrypted.size()); + replyBuf[11] = GenerateChecksum(replyBuf, 11); + } + + void DimensionsUSB::GetChallengeResponse(std::span buf, uint8 sequence, + std::array& replyBuf) + { + // Decrypt payload into an 8 byte array + std::array value = Decrypt(buf, std::nullopt); + // Confirmation is the first 4 bytes of the decrypted payload + uint32 conf = (uint32be&)value[0]; + // Generate next random number based on RNG + uint32 nextRandom = GetNext(); + // Encrypt an 8 byte array, first 4 bytes are the next random number (little endian) + // followed by the confirmation from the decrypted payload + std::array valueToEncrypt = {uint8(nextRandom & 0xFF), uint8((nextRandom >> 8) & 0xFF), + uint8((nextRandom >> 16) & 0xFF), uint8((nextRandom >> 24) & 0xFF), + value[0], value[1], value[2], value[3]}; + std::array encrypted = Encrypt(valueToEncrypt, std::nullopt); + replyBuf[0] = 0x55; + replyBuf[1] = 0x09; + replyBuf[2] = sequence; + // Copy encrypted value to response data + memcpy(&replyBuf[3], encrypted.data(), encrypted.size()); + replyBuf[11] = GenerateChecksum(replyBuf, 11); + + if (!m_isAwake) + m_isAwake = true; + } + + void DimensionsUSB::InitializeRNG(uint32 seed) + { + m_randomA = 0xF1EA5EED; + m_randomB = seed; + m_randomC = seed; + m_randomD = seed; + + for (int i = 0; i < 42; i++) + { + GetNext(); + } + } + + uint32 DimensionsUSB::GetNext() + { + uint32 e = m_randomA - std::rotl(m_randomB, 21); + m_randomA = m_randomB ^ std::rotl(m_randomC, 19); + m_randomB = m_randomC + std::rotl(m_randomD, 6); + m_randomC = m_randomD + e; + m_randomD = e + m_randomA; + return m_randomD; + } + + std::array DimensionsUSB::Decrypt(std::span buf, std::optional> key) + { + // Value to decrypt is separated in to two little endian 32 bit unsigned integers + uint32 dataOne = (uint32&)buf[0]; + uint32 dataTwo = (uint32&)buf[4]; + + // Use the key as 4 32 bit little endian unsigned integers + uint32 keyOne; + uint32 keyTwo; + uint32 keyThree; + uint32 keyFour; + + if (key) + { + keyOne = (uint32&)key.value()[0]; + keyTwo = (uint32&)key.value()[4]; + keyThree = (uint32&)key.value()[8]; + keyFour = (uint32&)key.value()[12]; + } + else + { + keyOne = (uint32&)COMMAND_KEY[0]; + keyTwo = (uint32&)COMMAND_KEY[4]; + keyThree = (uint32&)COMMAND_KEY[8]; + keyFour = (uint32&)COMMAND_KEY[12]; + } + + uint32 sum = 0xC6EF3720; + uint32 delta = 0x9E3779B9; + + for (int i = 0; i < 32; i++) + { + dataTwo -= (((dataOne << 4) + keyThree) ^ (dataOne + sum) ^ ((dataOne >> 5) + keyFour)); + dataOne -= (((dataTwo << 4) + keyOne) ^ (dataTwo + sum) ^ ((dataTwo >> 5) + keyTwo)); + sum -= delta; + } + + cemu_assert(sum == 0); + + std::array decrypted = {uint8(dataOne & 0xFF), uint8((dataOne >> 8) & 0xFF), + uint8((dataOne >> 16) & 0xFF), uint8((dataOne >> 24) & 0xFF), + uint8(dataTwo & 0xFF), uint8((dataTwo >> 8) & 0xFF), + uint8((dataTwo >> 16) & 0xFF), uint8((dataTwo >> 24) & 0xFF)}; + return decrypted; + } + std::array DimensionsUSB::Encrypt(std::span buf, std::optional> key) + { + // Value to encrypt is separated in to two little endian 32 bit unsigned integers + uint32 dataOne = (uint32&)buf[0]; + uint32 dataTwo = (uint32&)buf[4]; + + // Use the key as 4 32 bit little endian unsigned integers + uint32 keyOne; + uint32 keyTwo; + uint32 keyThree; + uint32 keyFour; + + if (key) + { + keyOne = (uint32&)key.value()[0]; + keyTwo = (uint32&)key.value()[4]; + keyThree = (uint32&)key.value()[8]; + keyFour = (uint32&)key.value()[12]; + } + else + { + keyOne = (uint32&)COMMAND_KEY[0]; + keyTwo = (uint32&)COMMAND_KEY[4]; + keyThree = (uint32&)COMMAND_KEY[8]; + keyFour = (uint32&)COMMAND_KEY[12]; + } + + uint32 sum = 0; + uint32 delta = 0x9E3779B9; + + for (int i = 0; i < 32; i++) + { + sum += delta; + dataOne += (((dataTwo << 4) + keyOne) ^ (dataTwo + sum) ^ ((dataTwo >> 5) + keyTwo)); + dataTwo += (((dataOne << 4) + keyThree) ^ (dataOne + sum) ^ ((dataOne >> 5) + keyFour)); + } + + cemu_assert(sum == 0xC6EF3720); + + std::array encrypted = {uint8(dataOne & 0xFF), uint8((dataOne >> 8) & 0xFF), + uint8((dataOne >> 16) & 0xFF), uint8((dataOne >> 24) & 0xFF), + uint8(dataTwo & 0xFF), uint8((dataTwo >> 8) & 0xFF), + uint8((dataTwo >> 16) & 0xFF), uint8((dataTwo >> 24) & 0xFF)}; + return encrypted; + } + + std::array DimensionsUSB::GenerateFigureKey(const std::array& buf) + { + std::array uid = {buf[0], buf[1], buf[2], buf[4], buf[5], buf[6], buf[7]}; + + uint32 scrambleA = Scramble(uid, 3); + uint32 scrambleB = Scramble(uid, 4); + uint32 scrambleC = Scramble(uid, 5); + uint32 scrambleD = Scramble(uid, 6); + + return {uint8((scrambleA >> 24) & 0xFF), uint8((scrambleA >> 16) & 0xFF), + uint8((scrambleA >> 8) & 0xFF), uint8(scrambleA & 0xFF), + uint8((scrambleB >> 24) & 0xFF), uint8((scrambleB >> 16) & 0xFF), + uint8((scrambleB >> 8) & 0xFF), uint8(scrambleB & 0xFF), + uint8((scrambleC >> 24) & 0xFF), uint8((scrambleC >> 16) & 0xFF), + uint8((scrambleC >> 8) & 0xFF), uint8(scrambleC & 0xFF), + uint8((scrambleD >> 24) & 0xFF), uint8((scrambleD >> 16) & 0xFF), + uint8((scrambleD >> 8) & 0xFF), uint8(scrambleD & 0xFF)}; + } + + uint32 DimensionsUSB::Scramble(const std::array& uid, uint8 count) + { + std::vector toScramble; + toScramble.reserve(uid.size() + CHAR_CONSTANT.size()); + for (uint8 x : uid) + { + toScramble.push_back(x); + } + for (uint8 c : CHAR_CONSTANT) + { + toScramble.push_back(c); + } + toScramble[(count * 4) - 1] = 0xaa; + + std::array randomized = DimensionsRandomize(toScramble, count); + + return (uint32be&)randomized[0]; + } + + std::array DimensionsUSB::PWDGenerate(const std::array& buf) + { + std::array uid = {buf[0], buf[1], buf[2], buf[4], buf[5], buf[6], buf[7]}; + + std::vector pwdCalc = {PWD_CONSTANT.begin(), PWD_CONSTANT.end() - 1}; + for (uint8 i = 0; i < uid.size(); i++) + { + pwdCalc.insert(pwdCalc.begin() + i, uid[i]); + } + + return DimensionsRandomize(pwdCalc, 8); + } + + std::array DimensionsUSB::DimensionsRandomize(const std::vector key, uint8 count) + { + uint32 scrambled = 0; + for (uint8 i = 0; i < count; i++) + { + const uint32 v4 = std::rotr(scrambled, 25); + const uint32 v5 = std::rotr(scrambled, 10); + const uint32 b = (uint32&)key[i * 4]; + scrambled = b + v4 + v5 - scrambled; + } + return {uint8(scrambled & 0xFF), uint8(scrambled >> 8 & 0xFF), uint8(scrambled >> 16 & 0xFF), uint8(scrambled >> 24 & 0xFF)}; + } + + uint32 DimensionsUSB::GetFigureId(const std::array& buf) + { + const std::array figureKey = GenerateFigureKey(buf); + + const std::span modelNumber = std::span{buf.begin() + (36 * 4), 8}; + + const std::array decrypted = Decrypt(modelNumber, figureKey); + + const uint32 figNum = (uint32&)decrypted[0]; + // Characters have their model number encrypted in page 36 + if (figNum < 1000) + { + return figNum; + } + // Vehicles/Gadgets have their model number written as little endian in page 36 + return (uint32&)modelNumber[0]; + } + + DimensionsUSB::DimensionsMini& + DimensionsUSB::GetFigureByIndex(uint8 index) + { + return m_figures[index]; + } + + void DimensionsUSB::QueryBlock(uint8 index, uint8 page, + std::array& replyBuf, + uint8 sequence) + { + std::lock_guard lock(m_dimensionsMutex); + + replyBuf[0] = 0x55; + replyBuf[1] = 0x12; + replyBuf[2] = sequence; + replyBuf[3] = 0x00; + + // Index from game begins at 1 rather than 0, so minus 1 here + if (const uint8 figureIndex = index - 1; figureIndex < 7) + { + const DimensionsMini& figure = GetFigureByIndex(figureIndex); + + // Query 4 pages of 4 bytes from the figure, copy this to the response + if (figure.index != 255 && (4 * page) < ((0x2D * 4) - 16)) + { + std::memcpy(&replyBuf[4], figure.data.data() + (4 * page), 16); + } + } + replyBuf[20] = GenerateChecksum(replyBuf, 20); + } + + void DimensionsUSB::WriteBlock(uint8 index, uint8 page, std::span toWriteBuf, + std::array& replyBuf, uint8 sequence) + { + std::lock_guard lock(m_dimensionsMutex); + + replyBuf[0] = 0x55; + replyBuf[1] = 0x02; + replyBuf[2] = sequence; + replyBuf[3] = 0x00; + + // Index from game begins at 1 rather than 0, so minus 1 here + if (const uint8 figureIndex = index - 1; figureIndex < 7) + { + DimensionsMini& figure = GetFigureByIndex(figureIndex); + + // Copy 4 bytes to the page on the figure requested by the game + if (figure.index != 255 && page < 0x2D) + { + // Id is written to page 36 + if (page == 36) + { + figure.id = (uint32&)toWriteBuf[0]; + } + std::memcpy(figure.data.data() + (page * 4), toWriteBuf.data(), 4); + figure.Save(); + } + } + replyBuf[4] = GenerateChecksum(replyBuf, 4); + } + + void DimensionsUSB::GetModel(std::span buf, uint8 sequence, + std::array& replyBuf) + { + // Decrypt payload to 8 byte array, byte 1 is the index, 4-7 are the confirmation + std::array value = Decrypt(buf, std::nullopt); + uint8 index = value[0]; + uint32 conf = (uint32be&)value[4]; + // Response is the figure's id (little endian) followed by the confirmation from payload + // Index from game begins at 1 rather than 0, so minus 1 here + std::array valueToEncrypt = {}; + if (const uint8 figureIndex = index - 1; figureIndex < 7) + { + const DimensionsMini& figure = GetFigureByIndex(figureIndex); + valueToEncrypt = {uint8(figure.id & 0xFF), uint8((figure.id >> 8) & 0xFF), + uint8((figure.id >> 16) & 0xFF), uint8((figure.id >> 24) & 0xFF), + value[4], value[5], value[6], value[7]}; + } + std::array encrypted = Encrypt(valueToEncrypt, std::nullopt); + replyBuf[0] = 0x55; + replyBuf[1] = 0x0a; + replyBuf[2] = sequence; + replyBuf[3] = 0x00; + memcpy(&replyBuf[4], encrypted.data(), encrypted.size()); + replyBuf[12] = GenerateChecksum(replyBuf, 12); + } + + void DimensionsUSB::RandomUID(std::array& uid_buffer) + { + uid_buffer[0] = 0x04; + uid_buffer[7] = 0x80; + + std::random_device rd; + std::mt19937 mt(rd()); + std::uniform_int_distribution dist(0, 255); + + uid_buffer[1] = dist(mt); + uid_buffer[2] = dist(mt); + uid_buffer[4] = dist(mt); + uid_buffer[5] = dist(mt); + uid_buffer[6] = dist(mt); + } + + uint8 DimensionsUSB::GenerateChecksum(const std::array& data, + int num_of_bytes) const + { + int checksum = 0; + for (int i = 0; i < num_of_bytes; i++) + { + checksum += data[i]; + } + return (checksum & 0xFF); + } + + void DimensionsUSB::DimensionsMini::Save() + { + if (!dimFile) + return; + + dimFile->SetPosition(0); + dimFile->writeData(data.data(), data.size()); + } + + std::map DimensionsUSB::GetListMinifigs() + { + return s_listMinis; + } + + std::map DimensionsUSB::GetListTokens() + { + return s_listTokens; + } + + std::string DimensionsUSB::FindFigure(uint32 figNum) + { + for (const auto& it : GetListMinifigs()) + { + if (it.first == figNum) + { + return it.second; + } + } + for (const auto& it : GetListTokens()) + { + if (it.first == figNum) + { + return it.second; + } + } + return fmt::format("Unknown ({})", figNum); + } +} // namespace nsyshid \ No newline at end of file diff --git a/src/Cafe/OS/libs/nsyshid/Dimensions.h b/src/Cafe/OS/libs/nsyshid/Dimensions.h new file mode 100644 index 00000000..d5a2a529 --- /dev/null +++ b/src/Cafe/OS/libs/nsyshid/Dimensions.h @@ -0,0 +1,108 @@ +#include + +#include "nsyshid.h" +#include "Backend.h" + +#include "Common/FileStream.h" + +namespace nsyshid +{ + class DimensionsToypadDevice final : public Device + { + public: + DimensionsToypadDevice(); + ~DimensionsToypadDevice() = default; + + bool Open() override; + + void Close() override; + + bool IsOpened() override; + + ReadResult Read(ReadMessage* message) override; + + WriteResult Write(WriteMessage* message) override; + + bool GetDescriptor(uint8 descType, + uint8 descIndex, + uint8 lang, + uint8* output, + uint32 outputMaxLength) override; + + bool SetProtocol(uint8 ifIndex, uint8 protocol) override; + + bool SetReport(ReportMessage* message) override; + + private: + bool m_IsOpened; + }; + + class DimensionsUSB + { + public: + struct DimensionsMini final + { + std::unique_ptr dimFile; + std::array data{}; + uint8 index = 255; + uint8 pad = 255; + uint32 id = 0; + void Save(); + }; + + void SendCommand(std::span buf); + std::array GetStatus(); + + void GenerateRandomNumber(std::span buf, uint8 sequence, + std::array& replyBuf); + void InitializeRNG(uint32 seed); + void GetChallengeResponse(std::span buf, uint8 sequence, + std::array& replyBuf); + void QueryBlock(uint8 index, uint8 page, std::array& replyBuf, + uint8 sequence); + void WriteBlock(uint8 index, uint8 page, std::span toWriteBuf, std::array& replyBuf, + uint8 sequence); + void GetModel(std::span buf, uint8 sequence, + std::array& replyBuf); + + bool RemoveFigure(uint8 pad, uint8 index, bool fullRemove); + bool TempRemove(uint8 index); + bool CancelRemove(uint8 index); + uint32 LoadFigure(const std::array& buf, std::unique_ptr file, uint8 pad, uint8 index); + bool CreateFigure(fs::path pathName, uint32 id); + bool MoveFigure(uint8 pad, uint8 index, uint8 oldPad, uint8 oldIndex); + static std::map GetListMinifigs(); + static std::map GetListTokens(); + std::string FindFigure(uint32 figNum); + + protected: + std::mutex m_dimensionsMutex; + std::array m_figures{}; + + private: + void RandomUID(std::array& uidBuffer); + uint8 GenerateChecksum(const std::array& data, + int numOfBytes) const; + std::array Decrypt(std::span buf, std::optional> key); + std::array Encrypt(std::span buf, std::optional> key); + std::array GenerateFigureKey(const std::array& uid); + std::array PWDGenerate(const std::array& uid); + std::array DimensionsRandomize(const std::vector key, uint8 count); + uint32 GetFigureId(const std::array& buf); + uint32 Scramble(const std::array& uid, uint8 count); + uint32 GetNext(); + DimensionsMini& GetFigureByIndex(uint8 index); + + uint32 m_randomA; + uint32 m_randomB; + uint32 m_randomC; + uint32 m_randomD; + + bool m_isAwake = false; + + std::queue> m_figureAddedRemovedResponses; + std::queue> m_queries; + }; + extern DimensionsUSB g_dimensionstoypad; + +} // namespace nsyshid \ No newline at end of file diff --git a/src/config/CemuConfig.cpp b/src/config/CemuConfig.cpp index e7920e84..f5ee7ab4 100644 --- a/src/config/CemuConfig.cpp +++ b/src/config/CemuConfig.cpp @@ -346,6 +346,7 @@ void CemuConfig::Load(XMLConfigParser& parser) auto usbdevices = parser.get("EmulatedUsbDevices"); emulated_usb_devices.emulate_skylander_portal = usbdevices.get("EmulateSkylanderPortal", emulated_usb_devices.emulate_skylander_portal); emulated_usb_devices.emulate_infinity_base = usbdevices.get("EmulateInfinityBase", emulated_usb_devices.emulate_infinity_base); + emulated_usb_devices.emulate_dimensions_toypad = usbdevices.get("EmulateDimensionsToypad", emulated_usb_devices.emulate_dimensions_toypad); } void CemuConfig::Save(XMLConfigParser& parser) @@ -545,6 +546,7 @@ void CemuConfig::Save(XMLConfigParser& parser) auto usbdevices = config.set("EmulatedUsbDevices"); usbdevices.set("EmulateSkylanderPortal", emulated_usb_devices.emulate_skylander_portal.GetValue()); usbdevices.set("EmulateInfinityBase", emulated_usb_devices.emulate_infinity_base.GetValue()); + usbdevices.set("EmulateDimensionsToypad", emulated_usb_devices.emulate_dimensions_toypad.GetValue()); } GameEntry* CemuConfig::GetGameEntryByTitleId(uint64 titleId) diff --git a/src/config/CemuConfig.h b/src/config/CemuConfig.h index 2f22cd76..be131266 100644 --- a/src/config/CemuConfig.h +++ b/src/config/CemuConfig.h @@ -521,6 +521,7 @@ struct CemuConfig { ConfigValue emulate_skylander_portal{false}; ConfigValue emulate_infinity_base{false}; + ConfigValue emulate_dimensions_toypad{false}; }emulated_usb_devices{}; private: diff --git a/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.cpp b/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.cpp index 3a0f534a..c77ae081 100644 --- a/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.cpp +++ b/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.cpp @@ -1,4 +1,4 @@ -#include "gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.h" +#include "EmulatedUSBDeviceFrame.h" #include @@ -8,14 +8,17 @@ #include "util/helpers/helpers.h" #include "Cafe/OS/libs/nsyshid/nsyshid.h" +#include "Cafe/OS/libs/nsyshid/Dimensions.h" #include "Common/FileStream.h" #include #include +#include #include #include #include +#include #include #include #include @@ -29,7 +32,6 @@ #include #include "resource/embedded/resources.h" -#include "EmulatedUSBDeviceFrame.h" EmulatedUSBDeviceFrame::EmulatedUSBDeviceFrame(wxWindow* parent) : wxFrame(parent, wxID_ANY, _("Emulated USB Devices"), wxDefaultPosition, @@ -44,6 +46,7 @@ EmulatedUSBDeviceFrame::EmulatedUSBDeviceFrame(wxWindow* parent) notebook->AddPage(AddSkylanderPage(notebook), _("Skylanders Portal")); notebook->AddPage(AddInfinityPage(notebook), _("Infinity Base")); + notebook->AddPage(AddDimensionsPage(notebook), _("Dimensions Toypad")); sizer->Add(notebook, 1, wxEXPAND | wxALL, 2); @@ -120,8 +123,52 @@ wxPanel* EmulatedUSBDeviceFrame::AddInfinityPage(wxNotebook* notebook) return panel; } -wxBoxSizer* EmulatedUSBDeviceFrame::AddSkylanderRow(uint8 rowNumber, - wxStaticBox* box) +wxPanel* EmulatedUSBDeviceFrame::AddDimensionsPage(wxNotebook* notebook) +{ + auto* panel = new wxPanel(notebook); + auto* panel_sizer = new wxBoxSizer(wxVERTICAL); + auto* box = new wxStaticBox(panel, wxID_ANY, _("Dimensions Manager")); + auto* box_sizer = new wxStaticBoxSizer(box, wxVERTICAL); + + auto* row = new wxBoxSizer(wxHORIZONTAL); + + m_emulateToypad = + new wxCheckBox(box, wxID_ANY, _("Emulate Dimensions Toypad")); + m_emulateToypad->SetValue( + GetConfig().emulated_usb_devices.emulate_dimensions_toypad); + m_emulateToypad->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { + GetConfig().emulated_usb_devices.emulate_dimensions_toypad = + m_emulateToypad->IsChecked(); + g_config.Save(); + }); + row->Add(m_emulateToypad, 1, wxEXPAND | wxALL, 2); + box_sizer->Add(row, 1, wxEXPAND | wxALL, 2); + auto* top_row = new wxBoxSizer(wxHORIZONTAL); + auto* bottom_row = new wxBoxSizer(wxHORIZONTAL); + + auto* dummy = new wxStaticText(box, wxID_ANY, ""); + + top_row->Add(AddDimensionPanel(2, 0, box), 1, wxEXPAND | wxALL, 2); + top_row->Add(dummy, 1, wxEXPAND | wxLEFT | wxRIGHT, 2); + top_row->Add(AddDimensionPanel(1, 1, box), 1, wxEXPAND | wxALL, 2); + top_row->Add(dummy, 1, wxEXPAND | wxLEFT | wxRIGHT, 2); + top_row->Add(AddDimensionPanel(3, 2, box), 1, wxEXPAND | wxALL, 2); + + bottom_row->Add(AddDimensionPanel(2, 3, box), 1, wxEXPAND | wxALL, 2); + bottom_row->Add(AddDimensionPanel(2, 4, box), 1, wxEXPAND | wxALL, 2); + bottom_row->Add(dummy, 1, wxEXPAND | wxLEFT | wxRIGHT, 0); + bottom_row->Add(AddDimensionPanel(3, 5, box), 1, wxEXPAND | wxALL, 2); + bottom_row->Add(AddDimensionPanel(3, 6, box), 1, wxEXPAND | wxALL, 2); + + box_sizer->Add(top_row, 1, wxEXPAND | wxALL, 2); + box_sizer->Add(bottom_row, 1, wxEXPAND | wxALL, 2); + panel_sizer->Add(box_sizer, 1, wxEXPAND | wxALL, 2); + panel->SetSizerAndFit(panel_sizer); + + return panel; +} + +wxBoxSizer* EmulatedUSBDeviceFrame::AddSkylanderRow(uint8 rowNumber, wxStaticBox* box) { auto* row = new wxBoxSizer(wxHORIZONTAL); @@ -184,6 +231,44 @@ wxBoxSizer* EmulatedUSBDeviceFrame::AddInfinityRow(wxString name, uint8 rowNumbe return row; } +wxBoxSizer* EmulatedUSBDeviceFrame::AddDimensionPanel(uint8 pad, uint8 index, wxStaticBox* box) +{ + auto* panel = new wxBoxSizer(wxVERTICAL); + + auto* combo_row = new wxBoxSizer(wxHORIZONTAL); + m_dimensionSlots[index] = new wxTextCtrl(box, wxID_ANY, _("None"), wxDefaultPosition, wxDefaultSize, + wxTE_READONLY); + combo_row->Add(m_dimensionSlots[index], 1, wxEXPAND | wxALL, 2); + auto* move_button = new wxButton(box, wxID_ANY, _("Move")); + move_button->Bind(wxEVT_BUTTON, [pad, index, this](wxCommandEvent&) { + MoveMinifig(pad, index); + }); + + combo_row->Add(move_button, 1, wxEXPAND | wxALL, 2); + + auto* button_row = new wxBoxSizer(wxHORIZONTAL); + auto* load_button = new wxButton(box, wxID_ANY, _("Load")); + load_button->Bind(wxEVT_BUTTON, [pad, index, this](wxCommandEvent&) { + LoadMinifig(pad, index); + }); + auto* clear_button = new wxButton(box, wxID_ANY, _("Clear")); + clear_button->Bind(wxEVT_BUTTON, [pad, index, this](wxCommandEvent&) { + ClearMinifig(pad, index); + }); + auto* create_button = new wxButton(box, wxID_ANY, _("Create")); + create_button->Bind(wxEVT_BUTTON, [pad, index, this](wxCommandEvent&) { + CreateMinifig(pad, index); + }); + button_row->Add(clear_button, 1, wxEXPAND | wxALL, 2); + button_row->Add(create_button, 1, wxEXPAND | wxALL, 2); + button_row->Add(load_button, 1, wxEXPAND | wxALL, 2); + + panel->Add(combo_row, 1, wxEXPAND | wxALL, 2); + panel->Add(button_row, 1, wxEXPAND | wxALL, 2); + + return panel; +} + void EmulatedUSBDeviceFrame::LoadSkylander(uint8 slot) { wxFileDialog openFileDialog(this, _("Open Skylander dump"), "", "", @@ -307,8 +392,8 @@ CreateSkylanderDialog::CreateSkylanderDialog(wxWindow* parent, uint8 slot) return; m_filePath = saveFileDialog.GetPath(); - - if(!nsyshid::g_skyportal.CreateSkylander(_utf8ToPath(m_filePath.utf8_string()), skyId, skyVar)) + + if (!nsyshid::g_skyportal.CreateSkylander(_utf8ToPath(m_filePath.utf8_string()), skyId, skyVar)) { wxMessageDialog errorMessage(this, "Failed to create file"); errorMessage.ShowModal(); @@ -351,6 +436,80 @@ wxString CreateSkylanderDialog::GetFilePath() const return m_filePath; } +void EmulatedUSBDeviceFrame::UpdateSkylanderEdits() +{ + for (auto i = 0; i < nsyshid::MAX_SKYLANDERS; i++) + { + std::string displayString; + if (auto sd = m_skySlots[i]) + { + auto [portalSlot, skyId, skyVar] = sd.value(); + displayString = nsyshid::g_skyportal.FindSkylander(skyId, skyVar); + } + else + { + displayString = "None"; + } + + m_skylanderSlots[i]->ChangeValue(displayString); + } +} + +void EmulatedUSBDeviceFrame::LoadFigure(uint8 slot) +{ + wxFileDialog openFileDialog(this, _("Open Infinity Figure dump"), "", "", + "BIN files (*.bin)|*.bin", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (openFileDialog.ShowModal() != wxID_OK || openFileDialog.GetPath().empty()) + { + wxMessageDialog errorMessage(this, "File Okay Error"); + errorMessage.ShowModal(); + return; + } + + LoadFigurePath(slot, openFileDialog.GetPath()); +} + +void EmulatedUSBDeviceFrame::LoadFigurePath(uint8 slot, wxString path) +{ + std::unique_ptr infFile(FileStream::openFile2(_utf8ToPath(path.utf8_string()), true)); + if (!infFile) + { + wxMessageDialog errorMessage(this, "File Open Error"); + errorMessage.ShowModal(); + return; + } + + std::array fileData; + if (infFile->readData(fileData.data(), fileData.size()) != fileData.size()) + { + wxMessageDialog open_error(this, "Failed to read file! File was too small"); + open_error.ShowModal(); + return; + } + ClearFigure(slot); + + uint32 number = nsyshid::g_infinitybase.LoadFigure(fileData, std::move(infFile), slot); + m_infinitySlots[slot]->ChangeValue(nsyshid::g_infinitybase.FindFigure(number).second); +} + +void EmulatedUSBDeviceFrame::CreateFigure(uint8 slot) +{ + cemuLog_log(LogType::Force, "Create Figure: {}", slot); + CreateInfinityFigureDialog create_dlg(this, slot); + create_dlg.ShowModal(); + if (create_dlg.GetReturnCode() == 1) + { + LoadFigurePath(slot, create_dlg.GetFilePath()); + } +} + +void EmulatedUSBDeviceFrame::ClearFigure(uint8 slot) +{ + m_infinitySlots[slot]->ChangeValue("None"); + nsyshid::g_infinitybase.RemoveFigure(slot); +} + CreateInfinityFigureDialog::CreateInfinityFigureDialog(wxWindow* parent, uint8 slot) : wxDialog(parent, wxID_ANY, _("Infinity Figure Creator"), wxDefaultPosition, wxSize(500, 150)) { @@ -447,76 +606,231 @@ wxString CreateInfinityFigureDialog::GetFilePath() const return m_filePath; } -void EmulatedUSBDeviceFrame::LoadFigure(uint8 slot) +void EmulatedUSBDeviceFrame::LoadMinifig(uint8 pad, uint8 index) { - wxFileDialog openFileDialog(this, _("Open Infinity Figure dump"), "", "", - "BIN files (*.bin)|*.bin", + wxFileDialog openFileDialog(this, _("Load Dimensions Figure"), "", "", + "Dimensions files (*.bin)|*.bin", wxFD_OPEN | wxFD_FILE_MUST_EXIST); if (openFileDialog.ShowModal() != wxID_OK || openFileDialog.GetPath().empty()) + return; + + LoadMinifigPath(openFileDialog.GetPath(), pad, index); +} + +void EmulatedUSBDeviceFrame::LoadMinifigPath(wxString path_name, uint8 pad, uint8 index) +{ + std::unique_ptr dim_file(FileStream::openFile2(_utf8ToPath(path_name.utf8_string()), true)); + if (!dim_file) { - wxMessageDialog errorMessage(this, "File Okay Error"); + wxMessageDialog errorMessage(this, "Failed to open minifig file"); errorMessage.ShowModal(); return; } - LoadFigurePath(slot, openFileDialog.GetPath()); -} + std::array file_data; -void EmulatedUSBDeviceFrame::LoadFigurePath(uint8 slot, wxString path) -{ - std::unique_ptr infFile(FileStream::openFile2(_utf8ToPath(path.utf8_string()), true)); - if (!infFile) + if (dim_file->readData(file_data.data(), file_data.size()) != file_data.size()) { - wxMessageDialog errorMessage(this, "File Open Error"); + wxMessageDialog errorMessage(this, "Failed to read minifig file data"); errorMessage.ShowModal(); return; } - std::array fileData; - if (infFile->readData(fileData.data(), fileData.size()) != fileData.size()) - { - wxMessageDialog open_error(this, "Failed to read file! File was too small"); - open_error.ShowModal(); - return; - } - ClearFigure(slot); + ClearMinifig(pad, index); - uint32 number = nsyshid::g_infinitybase.LoadFigure(fileData, std::move(infFile), slot); - m_infinitySlots[slot]->ChangeValue(nsyshid::g_infinitybase.FindFigure(number).second); + uint32 id = nsyshid::g_dimensionstoypad.LoadFigure(file_data, std::move(dim_file), pad, index); + m_dimensionSlots[index]->ChangeValue(nsyshid::g_dimensionstoypad.FindFigure(id)); + m_dimSlots[index] = id; } -void EmulatedUSBDeviceFrame::CreateFigure(uint8 slot) +void EmulatedUSBDeviceFrame::ClearMinifig(uint8 pad, uint8 index) { - cemuLog_log(LogType::Force, "Create Figure: {}", slot); - CreateInfinityFigureDialog create_dlg(this, slot); + nsyshid::g_dimensionstoypad.RemoveFigure(pad, index, true); + m_dimensionSlots[index]->ChangeValue("None"); + m_dimSlots[index] = std::nullopt; +} + +void EmulatedUSBDeviceFrame::CreateMinifig(uint8 pad, uint8 index) +{ + CreateDimensionFigureDialog create_dlg(this); create_dlg.ShowModal(); if (create_dlg.GetReturnCode() == 1) { - LoadFigurePath(slot, create_dlg.GetFilePath()); + LoadMinifigPath(create_dlg.GetFilePath(), pad, index); } } -void EmulatedUSBDeviceFrame::ClearFigure(uint8 slot) +void EmulatedUSBDeviceFrame::MoveMinifig(uint8 pad, uint8 index) { - m_infinitySlots[slot]->ChangeValue("None"); - nsyshid::g_infinitybase.RemoveFigure(slot); -} + if (!m_dimSlots[index]) + return; -void EmulatedUSBDeviceFrame::UpdateSkylanderEdits() -{ - for (auto i = 0; i < nsyshid::MAX_SKYLANDERS; i++) + MoveDimensionFigureDialog move_dlg(this, index); + nsyshid::g_dimensionstoypad.TempRemove(index); + move_dlg.ShowModal(); + if (move_dlg.GetReturnCode() == 1) { - std::string displayString; - if (auto sd = m_skySlots[i]) + nsyshid::g_dimensionstoypad.MoveFigure(move_dlg.GetNewPad(), move_dlg.GetNewIndex(), pad, index); + if (index != move_dlg.GetNewIndex()) { - auto [portalSlot, skyId, skyVar] = sd.value(); - displayString = nsyshid::g_skyportal.FindSkylander(skyId, skyVar); + m_dimSlots[move_dlg.GetNewIndex()] = m_dimSlots[index]; + m_dimensionSlots[move_dlg.GetNewIndex()]->ChangeValue(m_dimensionSlots[index]->GetValue()); + m_dimSlots[index] = std::nullopt; + m_dimensionSlots[index]->ChangeValue("None"); } - else - { - displayString = "None"; - } - - m_skylanderSlots[i]->ChangeValue(displayString); } + else + { + nsyshid::g_dimensionstoypad.CancelRemove(index); + } +} + +CreateDimensionFigureDialog::CreateDimensionFigureDialog(wxWindow* parent) + : wxDialog(parent, wxID_ANY, _("Dimensions Figure Creator"), wxDefaultPosition, wxSize(500, 200)) +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + auto* comboRow = new wxBoxSizer(wxHORIZONTAL); + + auto* comboBox = new wxComboBox(this, wxID_ANY); + comboBox->Append("---Select---", reinterpret_cast(0xFFFFFFFF)); + wxArrayString filterlist; + for (const auto& it : nsyshid::g_dimensionstoypad.GetListMinifigs()) + { + const uint32 figure = it.first; + comboBox->Append(it.second, reinterpret_cast(figure)); + filterlist.Add(it.second); + } + comboBox->SetSelection(0); + bool enabled = comboBox->AutoComplete(filterlist); + comboRow->Add(comboBox, 1, wxEXPAND | wxALL, 2); + + auto* figNumRow = new wxBoxSizer(wxHORIZONTAL); + + wxIntegerValidator validator; + + auto* labelFigNum = new wxStaticText(this, wxID_ANY, "Figure Number:"); + auto* editFigNum = new wxTextCtrl(this, wxID_ANY, _("0"), wxDefaultPosition, wxDefaultSize, 0, validator); + + figNumRow->Add(labelFigNum, 1, wxALL, 5); + figNumRow->Add(editFigNum, 1, wxALL, 5); + + auto* buttonRow = new wxBoxSizer(wxHORIZONTAL); + + auto* createButton = new wxButton(this, wxID_ANY, _("Create")); + createButton->Bind(wxEVT_BUTTON, [editFigNum, this](wxCommandEvent&) { + long longFigNum; + if (!editFigNum->GetValue().ToLong(&longFigNum) || longFigNum > 0xFFFF) + { + wxMessageDialog idError(this, "Error Converting Figure Number!", "Number Entered is Invalid"); + idError.ShowModal(); + this->EndModal(0); + } + uint16 figNum = longFigNum & 0xFFFF; + auto figure = nsyshid::g_dimensionstoypad.FindFigure(figNum); + wxString predefName = figure + ".bin"; + wxFileDialog + saveFileDialog(this, _("Create Dimensions Figure file"), "", predefName, + "BIN files (*.bin)|*.bin", wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + + if (saveFileDialog.ShowModal() == wxID_CANCEL) + this->EndModal(0); + + m_filePath = saveFileDialog.GetPath(); + + nsyshid::g_dimensionstoypad.CreateFigure(_utf8ToPath(m_filePath.utf8_string()), figNum); + + this->EndModal(1); + }); + auto* cancelButton = new wxButton(this, wxID_ANY, _("Cancel")); + cancelButton->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + this->EndModal(0); + }); + + comboBox->Bind(wxEVT_COMBOBOX, [comboBox, editFigNum, this](wxCommandEvent&) { + const uint64 fig_info = reinterpret_cast(comboBox->GetClientData(comboBox->GetSelection())); + if (fig_info != 0xFFFF) + { + const uint16 figNum = fig_info & 0xFFFF; + + editFigNum->SetValue(wxString::Format(wxT("%i"), figNum)); + } + }); + + buttonRow->Add(createButton, 1, wxALL, 5); + buttonRow->Add(cancelButton, 1, wxALL, 5); + + sizer->Add(comboRow, 1, wxEXPAND | wxALL, 2); + sizer->Add(figNumRow, 1, wxEXPAND | wxALL, 2); + sizer->Add(buttonRow, 1, wxEXPAND | wxALL, 2); + + this->SetSizer(sizer); + this->Centre(wxBOTH); +} + +wxString CreateDimensionFigureDialog::GetFilePath() const +{ + return m_filePath; +} + +MoveDimensionFigureDialog::MoveDimensionFigureDialog(EmulatedUSBDeviceFrame* parent, uint8 currentIndex) + : wxDialog(parent, wxID_ANY, _("Dimensions Figure Mover"), wxDefaultPosition, wxSize(700, 300)) +{ + auto* sizer = new wxGridSizer(2, 5, 10, 10); + + std::array, 7> ids = parent->GetCurrentMinifigs(); + + sizer->Add(AddMinifigSlot(2, 0, currentIndex, ids[0]), 1, wxALL, 5); + sizer->Add(new wxStaticText(this, wxID_ANY, ""), 1, wxALL, 5); + sizer->Add(AddMinifigSlot(1, 1, currentIndex, ids[1]), 1, wxALL, 5); + sizer->Add(new wxStaticText(this, wxID_ANY, ""), 1, wxALL, 5); + sizer->Add(AddMinifigSlot(3, 2, currentIndex, ids[2]), 1, wxALL, 5); + + sizer->Add(AddMinifigSlot(2, 3, currentIndex, ids[3]), 1, wxALL, 5); + sizer->Add(AddMinifigSlot(2, 4, currentIndex, ids[4]), 1, wxALL, 5); + sizer->Add(new wxStaticText(this, wxID_ANY, ""), 1, wxALL, 5); + sizer->Add(AddMinifigSlot(3, 5, currentIndex, ids[5]), 1, wxALL, 5); + sizer->Add(AddMinifigSlot(3, 6, currentIndex, ids[6]), 1, wxALL, 5); + + this->SetSizer(sizer); + this->Centre(wxBOTH); +} + +wxBoxSizer* MoveDimensionFigureDialog::AddMinifigSlot(uint8 pad, uint8 index, uint8 currentIndex, std::optional currentId) +{ + auto* panel = new wxBoxSizer(wxVERTICAL); + + auto* label = new wxStaticText(this, wxID_ANY, "None"); + if (currentId) + label->SetLabel(nsyshid::g_dimensionstoypad.FindFigure(currentId.value())); + + auto* moveButton = new wxButton(this, wxID_ANY, _("Move Here")); + if (index == currentIndex) + moveButton->SetLabelText("Pick up and Place"); + + moveButton->Bind(wxEVT_BUTTON, [pad, index, this](wxCommandEvent&) { + m_newPad = pad; + m_newIndex = index; + this->EndModal(1); + }); + + panel->Add(label, 1, wxALL, 5); + panel->Add(moveButton, 1, wxALL, 5); + + return panel; +} + +uint8 MoveDimensionFigureDialog::GetNewPad() const +{ + return m_newPad; +} + +uint8 MoveDimensionFigureDialog::GetNewIndex() const +{ + return m_newIndex; +} + +std::array, 7> EmulatedUSBDeviceFrame::GetCurrentMinifigs() +{ + return m_dimSlots; } \ No newline at end of file diff --git a/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.h b/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.h index ae29a036..78c70a4a 100644 --- a/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.h +++ b/src/gui/EmulatedUSBDevices/EmulatedUSBDeviceFrame.h @@ -17,33 +17,47 @@ class wxStaticBox; class wxString; class wxTextCtrl; -class EmulatedUSBDeviceFrame : public wxFrame { +class EmulatedUSBDeviceFrame : public wxFrame +{ public: EmulatedUSBDeviceFrame(wxWindow* parent); ~EmulatedUSBDeviceFrame(); + std::array, 7> GetCurrentMinifigs(); private: wxCheckBox* m_emulatePortal; wxCheckBox* m_emulateBase; + wxCheckBox* m_emulateToypad; std::array m_skylanderSlots; std::array m_infinitySlots; + std::array m_dimensionSlots; std::array>, nsyshid::MAX_SKYLANDERS> m_skySlots; + std::array, 7> m_dimSlots; wxPanel* AddSkylanderPage(wxNotebook* notebook); wxPanel* AddInfinityPage(wxNotebook* notebook); + wxPanel* AddDimensionsPage(wxNotebook* notebook); wxBoxSizer* AddSkylanderRow(uint8 row_number, wxStaticBox* box); wxBoxSizer* AddInfinityRow(wxString name, uint8 row_number, wxStaticBox* box); + wxBoxSizer* AddDimensionPanel(uint8 pad, uint8 index, wxStaticBox* box); void LoadSkylander(uint8 slot); void LoadSkylanderPath(uint8 slot, wxString path); void CreateSkylander(uint8 slot); void ClearSkylander(uint8 slot); + void UpdateSkylanderEdits(); void LoadFigure(uint8 slot); void LoadFigurePath(uint8 slot, wxString path); void CreateFigure(uint8 slot); void ClearFigure(uint8 slot); - void UpdateSkylanderEdits(); + void LoadMinifig(uint8 pad, uint8 index); + void LoadMinifigPath(wxString path_name, uint8 pad, uint8 index); + void CreateMinifig(uint8 pad, uint8 index); + void ClearMinifig(uint8 pad, uint8 index); + void MoveMinifig(uint8 pad, uint8 index); }; -class CreateSkylanderDialog : public wxDialog { + +class CreateSkylanderDialog : public wxDialog +{ public: explicit CreateSkylanderDialog(wxWindow* parent, uint8 slot); wxString GetFilePath() const; @@ -52,11 +66,37 @@ class CreateSkylanderDialog : public wxDialog { wxString m_filePath; }; -class CreateInfinityFigureDialog : public wxDialog { +class CreateInfinityFigureDialog : public wxDialog +{ public: explicit CreateInfinityFigureDialog(wxWindow* parent, uint8 slot); wxString GetFilePath() const; protected: wxString m_filePath; +}; + +class CreateDimensionFigureDialog : public wxDialog +{ + public: + explicit CreateDimensionFigureDialog(wxWindow* parent); + wxString GetFilePath() const; + + protected: + wxString m_filePath; +}; + +class MoveDimensionFigureDialog : public wxDialog +{ + public: + explicit MoveDimensionFigureDialog(EmulatedUSBDeviceFrame* parent, uint8 currentIndex); + uint8 GetNewPad() const; + uint8 GetNewIndex() const; + + protected: + uint8 m_newIndex = 0; + uint8 m_newPad = 0; + + private: + wxBoxSizer* AddMinifigSlot(uint8 pad, uint8 index, uint8 oldIndex, std::optional currentId); }; \ No newline at end of file From a5717e1b11fe2a6c2c1be0afbd4908d765a777af Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Fri, 8 Nov 2024 01:07:53 +0100 Subject: [PATCH 08/50] FST: Refactoring to fix a read bug + verify all reads - Fixes a bug where corrupted data would be returned when reading files from unhashed sections with non-block aligned offset or size - Added hash checks for all reads where possible. This means that FST now can automatically catch corruptions when they are encountered while reading from the volume --- src/Cafe/Filesystem/FST/FST.cpp | 379 ++++++++++++++++++++++---------- src/Cafe/Filesystem/FST/FST.h | 38 +++- 2 files changed, 301 insertions(+), 116 deletions(-) diff --git a/src/Cafe/Filesystem/FST/FST.cpp b/src/Cafe/Filesystem/FST/FST.cpp index 570671d4..f1255778 100644 --- a/src/Cafe/Filesystem/FST/FST.cpp +++ b/src/Cafe/Filesystem/FST/FST.cpp @@ -3,8 +3,7 @@ #include "Cemu/ncrypto/ncrypto.h" #include "Cafe/Filesystem/WUD/wud.h" #include "util/crypto/aes128.h" -#include "openssl/evp.h" /* EVP_Digest */ -#include "openssl/sha.h" /* SHA1 / SHA256_DIGEST_LENGTH */ +#include "openssl/sha.h" /* SHA1 / SHA256 */ #include "fstUtil.h" #include "FST.h" @@ -141,7 +140,7 @@ struct DiscPartitionTableHeader static constexpr uint32 MAGIC_VALUE = 0xCCA6E67B; /* +0x00 */ uint32be magic; - /* +0x04 */ uint32be sectorSize; // must be 0x8000? + /* +0x04 */ uint32be blockSize; // must be 0x8000? /* +0x08 */ uint8 partitionTableHash[20]; // hash of the data range at +0x800 to end of sector (0x8000) /* +0x1C */ uint32be numPartitions; }; @@ -164,10 +163,10 @@ struct DiscPartitionHeader static constexpr uint32 MAGIC_VALUE = 0xCC93A4F5; /* +0x00 */ uint32be magic; - /* +0x04 */ uint32be sectorSize; // must match DISC_SECTOR_SIZE + /* +0x04 */ uint32be sectorSize; // must match DISC_SECTOR_SIZE for hashed blocks /* +0x08 */ uint32be ukn008; - /* +0x0C */ uint32be ukn00C; + /* +0x0C */ uint32be ukn00C; // h3 array size? /* +0x10 */ uint32be h3HashNum; /* +0x14 */ uint32be fstSize; // in bytes /* +0x18 */ uint32be fstSector; // relative to partition start @@ -178,13 +177,15 @@ struct DiscPartitionHeader /* +0x24 */ uint8 fstHashType; /* +0x25 */ uint8 fstEncryptionType; // purpose of this isn't really understood. Maybe it controls which key is being used? (1 -> disc key, 2 -> partition key) - /* +0x26 */ uint8 versionA; - /* +0x27 */ uint8 ukn027; // also a version field? + /* +0x26 */ uint8be versionA; + /* +0x27 */ uint8be ukn027; // also a version field? // there is an array at +0x40 ? Related to H3 list. Also related to value at +0x0C and h3HashNum + /* +0x28 */ uint8be _uknOrPadding028[0x18]; + /* +0x40 */ uint8be h3HashArray[32]; // dynamic size. Only present if fstHashType != 0 }; -static_assert(sizeof(DiscPartitionHeader) == 0x28); +static_assert(sizeof(DiscPartitionHeader) == 0x40+0x20); bool FSTVolume::FindDiscKey(const fs::path& path, NCrypto::AesKey& discTitleKey) { @@ -269,7 +270,7 @@ FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, NCrypto::AesKey& d cemuLog_log(LogType::Force, "Disc image rejected because decryption failed"); return nullptr; } - if (partitionHeader->sectorSize != DISC_SECTOR_SIZE) + if (partitionHeader->blockSize != DISC_SECTOR_SIZE) { cemuLog_log(LogType::Force, "Disc image rejected because partition sector size is invalid"); return nullptr; @@ -336,6 +337,9 @@ FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, NCrypto::AesKey& d cemu_assert_debug(partitionHeaderSI.fstEncryptionType == 1); // todo - check other fields? + if(partitionHeaderSI.fstHashType == 0 && partitionHeaderSI.h3HashNum != 0) + cemuLog_log(LogType::Force, "FST: Partition uses unhashed blocks but stores a non-zero amount of H3 hashes"); + // GM partition DiscPartitionHeader partitionHeaderGM{}; if (!readPartitionHeader(partitionHeaderGM, gmPartitionIndex)) @@ -349,9 +353,10 @@ FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, NCrypto::AesKey& d // if decryption is necessary // load SI FST dataSource->SetBaseOffset((uint64)partitionArray[siPartitionIndex].partitionAddress * DISC_SECTOR_SIZE); - auto siFST = OpenFST(dataSource.get(), (uint64)partitionHeaderSI.fstSector * DISC_SECTOR_SIZE, partitionHeaderSI.fstSize, &discTitleKey, static_cast(partitionHeaderSI.fstHashType)); + auto siFST = OpenFST(dataSource.get(), (uint64)partitionHeaderSI.fstSector * DISC_SECTOR_SIZE, partitionHeaderSI.fstSize, &discTitleKey, static_cast(partitionHeaderSI.fstHashType), nullptr); if (!siFST) return nullptr; + cemu_assert_debug(!(siFST->HashIsDisabled() && partitionHeaderSI.h3HashNum != 0)); // if hash is disabled, no H3 data may be present // load ticket file for partition that we want to decrypt NCrypto::ETicketParser ticketParser; std::vector ticketData = siFST->ExtractFile(fmt::format("{:02x}/title.tik", gmPartitionIndex)); @@ -360,16 +365,32 @@ FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, NCrypto::AesKey& d cemuLog_log(LogType::Force, "Disc image ticket file is invalid"); return nullptr; } +#if 0 + // each SI partition seems to contain a title.tmd that we could parse and which should have information about the associated GM partition + // but the console seems to ignore this file for disc images, at least when mounting, so we shouldn't rely on it either + std::vector tmdData = siFST->ExtractFile(fmt::format("{:02x}/title.tmd", gmPartitionIndex)); + if (tmdData.empty()) + { + cemuLog_log(LogType::Force, "Disc image TMD file is missing"); + return nullptr; + } + // parse TMD + NCrypto::TMDParser tmdParser; + if (!tmdParser.parse(tmdData.data(), tmdData.size())) + { + cemuLog_log(LogType::Force, "Disc image TMD file is invalid"); + return nullptr; + } +#endif delete siFST; - NCrypto::AesKey gmTitleKey; ticketParser.GetTitleKey(gmTitleKey); - // load GM partition dataSource->SetBaseOffset((uint64)partitionArray[gmPartitionIndex].partitionAddress * DISC_SECTOR_SIZE); - FSTVolume* r = OpenFST(std::move(dataSource), (uint64)partitionHeaderGM.fstSector * DISC_SECTOR_SIZE, partitionHeaderGM.fstSize, &gmTitleKey, static_cast(partitionHeaderGM.fstHashType)); + FSTVolume* r = OpenFST(std::move(dataSource), (uint64)partitionHeaderGM.fstSector * DISC_SECTOR_SIZE, partitionHeaderGM.fstSize, &gmTitleKey, static_cast(partitionHeaderGM.fstHashType), nullptr); if (r) SET_FST_ERROR(OK); + cemu_assert_debug(!(r->HashIsDisabled() && partitionHeaderGM.h3HashNum != 0)); // if hash is disabled, no H3 data may be present return r; } @@ -426,15 +447,15 @@ FSTVolume* FSTVolume::OpenFromContentFolder(fs::path folderPath, ErrorCode* erro } // load FST // fstSize = size of first cluster? - FSTVolume* fstVolume = FSTVolume::OpenFST(std::move(dataSource), 0, fstSize, &titleKey, fstHashMode); + FSTVolume* fstVolume = FSTVolume::OpenFST(std::move(dataSource), 0, fstSize, &titleKey, fstHashMode, &tmdParser); if (fstVolume) SET_FST_ERROR(OK); return fstVolume; } -FSTVolume* FSTVolume::OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode) +FSTVolume* FSTVolume::OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode, NCrypto::TMDParser* optionalTMD) { - cemu_assert_debug(fstHashMode != ClusterHashMode::RAW || fstHashMode != ClusterHashMode::RAW2); + cemu_assert_debug(fstHashMode != ClusterHashMode::RAW || fstHashMode != ClusterHashMode::RAW_STREAM); if (fstSize < sizeof(FSTHeader)) return nullptr; constexpr uint64 FST_CLUSTER_OFFSET = 0; @@ -465,6 +486,34 @@ FSTVolume* FSTVolume::OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint3 clusterTable[i].offset = clusterDataTable[i].offset; clusterTable[i].size = clusterDataTable[i].size; clusterTable[i].hashMode = static_cast((uint8)clusterDataTable[i].hashMode); + clusterTable[i].hasContentHash = false; // from the TMD file (H4?) + } + // if the TMD is available (when opening .app files) we can use the extra info from it to validate unhashed clusters + // each content entry in the TMD corresponds to one cluster used by the FST + if(optionalTMD) + { + if(numCluster != optionalTMD->GetContentList().size()) + { + cemuLog_log(LogType::Force, "FST: Number of clusters does not match TMD content list"); + return nullptr; + } + auto& contentList = optionalTMD->GetContentList(); + for(size_t i=0; im_offsetFactor = fstHeader->offsetFactor; fstVolume->m_sectorSize = DISC_SECTOR_SIZE; fstVolume->m_partitionTitlekey = *partitionTitleKey; - std::swap(fstVolume->m_cluster, clusterTable); - std::swap(fstVolume->m_entries, fstEntries); - std::swap(fstVolume->m_nameStringTable, nameStringTable); + fstVolume->m_hashIsDisabled = fstHeader->hashIsDisabled != 0; + fstVolume->m_cluster = std::move(clusterTable); + fstVolume->m_entries = std::move(fstEntries); + fstVolume->m_nameStringTable = std::move(nameStringTable); return fstVolume; } -FSTVolume* FSTVolume::OpenFST(std::unique_ptr dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode) +FSTVolume* FSTVolume::OpenFST(std::unique_ptr dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode, NCrypto::TMDParser* optionalTMD) { FSTDataSource* ds = dataSource.release(); - FSTVolume* fstVolume = OpenFST(ds, fstOffset, fstSize, partitionTitleKey, fstHashMode); + FSTVolume* fstVolume = OpenFST(ds, fstOffset, fstSize, partitionTitleKey, fstHashMode, optionalTMD); if (!fstVolume) { delete ds; @@ -757,7 +807,7 @@ uint32 FSTVolume::ReadFile(FSTFileHandle& fileHandle, uint32 offset, uint32 size return 0; cemu_assert_debug(!HAS_FLAG(entry.GetFlags(), FSTEntry::FLAGS::FLAG_LINK)); FSTCluster& cluster = m_cluster[entry.fileInfo.clusterIndex]; - if (cluster.hashMode == ClusterHashMode::RAW || cluster.hashMode == ClusterHashMode::RAW2) + if (cluster.hashMode == ClusterHashMode::RAW || cluster.hashMode == ClusterHashMode::RAW_STREAM) return ReadFile_HashModeRaw(entry.fileInfo.clusterIndex, entry, offset, size, dataOut); else if (cluster.hashMode == ClusterHashMode::HASH_INTERLEAVED) return ReadFile_HashModeHashed(entry.fileInfo.clusterIndex, entry, offset, size, dataOut); @@ -765,87 +815,15 @@ uint32 FSTVolume::ReadFile(FSTFileHandle& fileHandle, uint32 offset, uint32 size return 0; } -uint32 FSTVolume::ReadFile_HashModeRaw(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut) -{ - const uint32 readSizeInput = readSize; - uint8* dataOutU8 = (uint8*)dataOut; - if (readOffset >= entry.fileInfo.fileSize) - return 0; - else if ((readOffset + readSize) >= entry.fileInfo.fileSize) - readSize = (entry.fileInfo.fileSize - readOffset); - - const FSTCluster& cluster = m_cluster[clusterIndex]; - uint64 clusterOffset = (uint64)cluster.offset * m_sectorSize; - uint64 absFileOffset = entry.fileInfo.fileOffset * m_offsetFactor + readOffset; - - // make sure the raw range we read is aligned to AES block size (16) - uint64 readAddrStart = absFileOffset & ~0xF; - uint64 readAddrEnd = (absFileOffset + readSize + 0xF) & ~0xF; - - bool usesInitialIV = readOffset < 16; - if (!usesInitialIV) - readAddrStart -= 16; // read previous AES block since we require it for the IV - uint32 prePadding = (uint32)(absFileOffset - readAddrStart); // number of extra bytes we read before readOffset (for AES alignment and IV calculation) - uint32 postPadding = (uint32)(readAddrEnd - (absFileOffset + readSize)); - - uint8 readBuffer[64 * 1024]; - // read first chunk - // if file read offset (readOffset) is within the first AES-block then use initial IV calculated from cluster index - // otherwise read previous AES-block is the IV (AES-CBC) - uint64 readAddrCurrent = readAddrStart; - uint32 rawBytesToRead = (uint32)std::min((readAddrEnd - readAddrStart), (uint64)sizeof(readBuffer)); - if (m_dataSource->readData(clusterIndex, clusterOffset, readAddrCurrent, readBuffer, rawBytesToRead) != rawBytesToRead) - { - cemuLog_log(LogType::Force, "FST read error in raw content"); - return 0; - } - readAddrCurrent += rawBytesToRead; - - uint8 iv[16]{}; - if (usesInitialIV) - { - // for the first AES block, the IV is initialized from cluster index - iv[0] = (uint8)(clusterIndex >> 8); - iv[1] = (uint8)(clusterIndex >> 0); - AES128_CBC_decrypt_updateIV(readBuffer, readBuffer, rawBytesToRead, m_partitionTitlekey.b, iv); - std::memcpy(dataOutU8, readBuffer + prePadding, rawBytesToRead - prePadding - postPadding); - dataOutU8 += (rawBytesToRead - prePadding - postPadding); - readSize -= (rawBytesToRead - prePadding - postPadding); - } - else - { - // IV is initialized from previous AES block (AES-CBC) - std::memcpy(iv, readBuffer, 16); - AES128_CBC_decrypt_updateIV(readBuffer + 16, readBuffer + 16, rawBytesToRead - 16, m_partitionTitlekey.b, iv); - std::memcpy(dataOutU8, readBuffer + prePadding, rawBytesToRead - prePadding - postPadding); - dataOutU8 += (rawBytesToRead - prePadding - postPadding); - readSize -= (rawBytesToRead - prePadding - postPadding); - } - - // read remaining chunks - while (readSize > 0) - { - uint32 bytesToRead = (uint32)std::min((uint32)sizeof(readBuffer), readSize); - uint32 alignedBytesToRead = (bytesToRead + 15) & ~0xF; - if (m_dataSource->readData(clusterIndex, clusterOffset, readAddrCurrent, readBuffer, alignedBytesToRead) != alignedBytesToRead) - { - cemuLog_log(LogType::Force, "FST read error in raw content"); - return 0; - } - AES128_CBC_decrypt_updateIV(readBuffer, readBuffer, alignedBytesToRead, m_partitionTitlekey.b, iv); - std::memcpy(dataOutU8, readBuffer, bytesToRead); - dataOutU8 += bytesToRead; - readSize -= bytesToRead; - readAddrCurrent += alignedBytesToRead; - } - - return readSizeInput - readSize; -} - constexpr size_t BLOCK_SIZE = 0x10000; constexpr size_t BLOCK_HASH_SIZE = 0x0400; constexpr size_t BLOCK_FILE_SIZE = 0xFC00; +struct FSTRawBlock +{ + std::vector rawData; // unhashed block size depends on sector size field in partition header +}; + struct FSTHashedBlock { uint8 rawData[BLOCK_SIZE]; @@ -887,12 +865,160 @@ struct FSTHashedBlock static_assert(sizeof(FSTHashedBlock) == BLOCK_SIZE); +struct FSTCachedRawBlock +{ + FSTRawBlock blockData; + uint8 ivForNextBlock[16]; + uint64 lastAccess; +}; + struct FSTCachedHashedBlock { FSTHashedBlock blockData; uint64 lastAccess; }; +// Checks cache fill state and if necessary drops least recently accessed block from the cache. Optionally allows to recycle the released cache entry to cut down cost of memory allocation and clearing +void FSTVolume::TrimCacheIfRequired(FSTCachedRawBlock** droppedRawBlock, FSTCachedHashedBlock** droppedHashedBlock) +{ + // calculate size used by cache + size_t cacheSize = 0; + for (auto& itr : m_cacheDecryptedRawBlocks) + cacheSize += itr.second->blockData.rawData.size(); + for (auto& itr : m_cacheDecryptedHashedBlocks) + cacheSize += sizeof(FSTCachedHashedBlock) + sizeof(FSTHashedBlock); + // only trim if cache is full (larger than 2MB) + if (cacheSize < 2*1024*1024) // 2MB + return; + // scan both cache lists to find least recently accessed block to drop + auto dropRawItr = std::min_element(m_cacheDecryptedRawBlocks.begin(), m_cacheDecryptedRawBlocks.end(), [](const auto& a, const auto& b) -> bool + { return a.second->lastAccess < b.second->lastAccess; }); + auto dropHashedItr = std::min_element(m_cacheDecryptedHashedBlocks.begin(), m_cacheDecryptedHashedBlocks.end(), [](const auto& a, const auto& b) -> bool + { return a.second->lastAccess < b.second->lastAccess; }); + uint64 lastAccess = std::numeric_limits::max(); + if(dropRawItr != m_cacheDecryptedRawBlocks.end()) + lastAccess = dropRawItr->second->lastAccess; + if(dropHashedItr != m_cacheDecryptedHashedBlocks.end()) + lastAccess = std::min(lastAccess, dropHashedItr->second->lastAccess); + if(dropRawItr != m_cacheDecryptedRawBlocks.end() && dropRawItr->second->lastAccess == lastAccess) + { + if (droppedRawBlock) + *droppedRawBlock = dropRawItr->second; + else + delete dropRawItr->second; + m_cacheDecryptedRawBlocks.erase(dropRawItr); + return; + } + else if(dropHashedItr != m_cacheDecryptedHashedBlocks.end() && dropHashedItr->second->lastAccess == lastAccess) + { + if (droppedHashedBlock) + *droppedHashedBlock = dropHashedItr->second; + else + delete dropHashedItr->second; + m_cacheDecryptedHashedBlocks.erase(dropHashedItr); + } +} + +void FSTVolume::DetermineUnhashedBlockIV(uint32 clusterIndex, uint32 blockIndex, uint8 ivOut[16]) +{ + memset(ivOut, 0, sizeof(ivOut)); + if(blockIndex == 0) + { + ivOut[0] = (uint8)(clusterIndex >> 8); + ivOut[1] = (uint8)(clusterIndex >> 0); + } + else + { + // the last 16 encrypted bytes of the previous block are the IV (AES CBC) + // if the previous block is cached we can grab the IV from there. Otherwise we have to read the 16 bytes from the data source + uint32 prevBlockIndex = blockIndex - 1; + uint64 cacheBlockId = ((uint64)clusterIndex << (64 - 16)) | (uint64)prevBlockIndex; + auto itr = m_cacheDecryptedRawBlocks.find(cacheBlockId); + if (itr != m_cacheDecryptedRawBlocks.end()) + { + memcpy(ivOut, itr->second->ivForNextBlock, 16); + } + else + { + cemu_assert(m_sectorSize >= 16); + uint64 clusterOffset = (uint64)m_cluster[clusterIndex].offset * m_sectorSize; + uint8 prevIV[16]; + if (m_dataSource->readData(clusterIndex, clusterOffset, blockIndex * m_sectorSize - 16, prevIV, 16) != 16) + { + cemuLog_log(LogType::Force, "Failed to read IV for raw FST block"); + m_detectedCorruption = true; + return; + } + memcpy(ivOut, prevIV, 16); + } + } +} + +FSTCachedRawBlock* FSTVolume::GetDecryptedRawBlock(uint32 clusterIndex, uint32 blockIndex) +{ + FSTCluster& cluster = m_cluster[clusterIndex]; + uint64 clusterOffset = (uint64)cluster.offset * m_sectorSize; + // generate id for cache + uint64 cacheBlockId = ((uint64)clusterIndex << (64 - 16)) | (uint64)blockIndex; + // lookup block in cache + FSTCachedRawBlock* block = nullptr; + auto itr = m_cacheDecryptedRawBlocks.find(cacheBlockId); + if (itr != m_cacheDecryptedRawBlocks.end()) + { + block = itr->second; + block->lastAccess = ++m_cacheAccessCounter; + return block; + } + // if cache already full, drop least recently accessed block and recycle FSTCachedRawBlock object if possible + TrimCacheIfRequired(&block, nullptr); + if (!block) + block = new FSTCachedRawBlock(); + block->blockData.rawData.resize(m_sectorSize); + // block not cached, read new + block->lastAccess = ++m_cacheAccessCounter; + if (m_dataSource->readData(clusterIndex, clusterOffset, blockIndex * m_sectorSize, block->blockData.rawData.data(), m_sectorSize) != m_sectorSize) + { + cemuLog_log(LogType::Force, "Failed to read raw FST block"); + delete block; + m_detectedCorruption = true; + return nullptr; + } + // decrypt hash data + uint8 iv[16]{}; + DetermineUnhashedBlockIV(clusterIndex, blockIndex, iv); + memcpy(block->ivForNextBlock, block->blockData.rawData.data() + m_sectorSize - 16, 16); + AES128_CBC_decrypt(block->blockData.rawData.data(), block->blockData.rawData.data(), m_sectorSize, m_partitionTitlekey.b, iv); + // if this is the next block, then hash it + if(cluster.hasContentHash) + { + if(cluster.singleHashNumBlocksHashed == blockIndex) + { + cemu_assert_debug(!(cluster.contentSize % m_sectorSize)); // size should be multiple of sector size? Regardless, the hashing code below can handle non-aligned sizes + bool isLastBlock = blockIndex == (std::max(cluster.contentSize / m_sectorSize, 1) - 1); + uint32 hashSize = m_sectorSize; + if(isLastBlock) + hashSize = cluster.contentSize - (uint64)blockIndex*m_sectorSize; + EVP_DigestUpdate(cluster.singleHashCtx.get(), block->blockData.rawData.data(), hashSize); + cluster.singleHashNumBlocksHashed++; + if(isLastBlock) + { + uint8 hash[32]; + EVP_DigestFinal_ex(cluster.singleHashCtx.get(), hash, nullptr); + if(memcmp(hash, cluster.contentHash32, cluster.contentHashIsSHA1 ? 20 : 32) != 0) + { + cemuLog_log(LogType::Force, "FST: Raw section hash mismatch"); + delete block; + m_detectedCorruption = true; + return nullptr; + } + } + } + } + // register in cache + m_cacheDecryptedRawBlocks.emplace(cacheBlockId, block); + return block; +} + FSTCachedHashedBlock* FSTVolume::GetDecryptedHashedBlock(uint32 clusterIndex, uint32 blockIndex) { const FSTCluster& cluster = m_cluster[clusterIndex]; @@ -908,22 +1034,17 @@ FSTCachedHashedBlock* FSTVolume::GetDecryptedHashedBlock(uint32 clusterIndex, ui block->lastAccess = ++m_cacheAccessCounter; return block; } - // if cache already full, drop least recently accessed block (but recycle the FSTHashedBlock* object) - if (m_cacheDecryptedHashedBlocks.size() >= 16) - { - auto dropItr = std::min_element(m_cacheDecryptedHashedBlocks.begin(), m_cacheDecryptedHashedBlocks.end(), [](const auto& a, const auto& b) -> bool - { return a.second->lastAccess < b.second->lastAccess; }); - block = dropItr->second; - m_cacheDecryptedHashedBlocks.erase(dropItr); - } - else + // if cache already full, drop least recently accessed block and recycle FSTCachedHashedBlock object if possible + TrimCacheIfRequired(nullptr, &block); + if (!block) block = new FSTCachedHashedBlock(); // block not cached, read new block->lastAccess = ++m_cacheAccessCounter; if (m_dataSource->readData(clusterIndex, clusterOffset, blockIndex * BLOCK_SIZE, block->blockData.rawData, BLOCK_SIZE) != BLOCK_SIZE) { - cemuLog_log(LogType::Force, "Failed to read FST block"); + cemuLog_log(LogType::Force, "Failed to read hashed FST block"); delete block; + m_detectedCorruption = true; return nullptr; } // decrypt hash data @@ -931,11 +1052,46 @@ FSTCachedHashedBlock* FSTVolume::GetDecryptedHashedBlock(uint32 clusterIndex, ui AES128_CBC_decrypt(block->blockData.getHashData(), block->blockData.getHashData(), BLOCK_HASH_SIZE, m_partitionTitlekey.b, iv); // decrypt file data AES128_CBC_decrypt(block->blockData.getFileData(), block->blockData.getFileData(), BLOCK_FILE_SIZE, m_partitionTitlekey.b, block->blockData.getH0Hash(blockIndex%16)); + // compare with H0 to verify data integrity + NCrypto::CHash160 h0; + SHA1(block->blockData.getFileData(), BLOCK_FILE_SIZE, h0.b); + uint32 h0Index = (blockIndex % 4096); + if (memcmp(h0.b, block->blockData.getH0Hash(h0Index & 0xF), sizeof(h0.b)) != 0) + { + cemuLog_log(LogType::Force, "FST: Hash H0 mismatch in hashed block (section {} index {})", clusterIndex, blockIndex); + delete block; + m_detectedCorruption = true; + return nullptr; + } // register in cache m_cacheDecryptedHashedBlocks.emplace(cacheBlockId, block); return block; } +uint32 FSTVolume::ReadFile_HashModeRaw(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut) +{ + uint8* dataOutU8 = (uint8*)dataOut; + if (readOffset >= entry.fileInfo.fileSize) + return 0; + else if ((readOffset + readSize) >= entry.fileInfo.fileSize) + readSize = (entry.fileInfo.fileSize - readOffset); + uint64 absFileOffset = entry.fileInfo.fileOffset * m_offsetFactor + readOffset; + uint32 remainingReadSize = readSize; + while (remainingReadSize > 0) + { + const FSTCachedRawBlock* rawBlock = this->GetDecryptedRawBlock(clusterIndex, absFileOffset/m_sectorSize); + if (!rawBlock) + break; + uint32 blockOffset = (uint32)(absFileOffset % m_sectorSize); + uint32 bytesToRead = std::min(remainingReadSize, m_sectorSize - blockOffset); + std::memcpy(dataOutU8, rawBlock->blockData.rawData.data() + blockOffset, bytesToRead); + dataOutU8 += bytesToRead; + remainingReadSize -= bytesToRead; + absFileOffset += bytesToRead; + } + return readSize - remainingReadSize; +} + uint32 FSTVolume::ReadFile_HashModeHashed(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut) { /* @@ -966,7 +1122,6 @@ uint32 FSTVolume::ReadFile_HashModeHashed(uint32 clusterIndex, FSTEntry& entry, */ const FSTCluster& cluster = m_cluster[clusterIndex]; - uint64 clusterBaseOffset = (uint64)cluster.offset * m_sectorSize; uint64 fileReadOffset = entry.fileInfo.fileOffset * m_offsetFactor + readOffset; uint32 blockIndex = (uint32)(fileReadOffset / BLOCK_FILE_SIZE); uint32 bytesRemaining = readSize; @@ -1019,6 +1174,8 @@ bool FSTVolume::Next(FSTDirectoryIterator& directoryIterator, FSTFileHandle& fil FSTVolume::~FSTVolume() { + for (auto& itr : m_cacheDecryptedRawBlocks) + delete itr.second; for (auto& itr : m_cacheDecryptedHashedBlocks) delete itr.second; if (m_sourceIsOwned) @@ -1115,4 +1272,4 @@ bool FSTVerifier::VerifyHashedContentFile(FileStream* fileContent, const NCrypto void FSTVolumeTest() { FSTPathUnitTest(); -} \ No newline at end of file +} diff --git a/src/Cafe/Filesystem/FST/FST.h b/src/Cafe/Filesystem/FST/FST.h index 24fc39ea..601799ce 100644 --- a/src/Cafe/Filesystem/FST/FST.h +++ b/src/Cafe/Filesystem/FST/FST.h @@ -1,5 +1,6 @@ #pragma once #include "Cemu/ncrypto/ncrypto.h" +#include "openssl/evp.h" struct FSTFileHandle { @@ -45,6 +46,7 @@ public: ~FSTVolume(); uint32 GetFileCount() const; + bool HasCorruption() const { return m_detectedCorruption; } bool OpenFile(std::string_view path, FSTFileHandle& fileHandleOut, bool openOnlyFiles = false); @@ -86,15 +88,25 @@ private: enum class ClusterHashMode : uint8 { RAW = 0, // raw data + encryption, no hashing? - RAW2 = 1, // raw data + encryption, with hash stored in tmd? + RAW_STREAM = 1, // raw data + encryption, with hash stored in tmd? HASH_INTERLEAVED = 2, // hashes + raw interleaved in 0x10000 blocks (0x400 bytes of hashes at the beginning, followed by 0xFC00 bytes of data) }; struct FSTCluster { + FSTCluster() : singleHashCtx(nullptr, &EVP_MD_CTX_free) {} + uint32 offset; uint32 size; ClusterHashMode hashMode; + // extra data if TMD is available + bool hasContentHash; + uint8 contentHash32[32]; + bool contentHashIsSHA1; // if true then it's SHA1 (with extra bytes zeroed out), otherwise it's SHA256 + uint64 contentSize; // size of the content (in blocks) + // hash context for single hash mode (content hash must be available) + std::unique_ptr singleHashCtx; // unique_ptr to make this move-only + uint32 singleHashNumBlocksHashed{0}; }; struct FSTEntry @@ -164,17 +176,30 @@ private: bool m_sourceIsOwned{}; uint32 m_sectorSize{}; // for cluster offsets uint32 m_offsetFactor{}; // for file offsets + bool m_hashIsDisabled{}; // disables hash verification (for all clusters of this volume?) std::vector m_cluster; std::vector m_entries; std::vector m_nameStringTable; NCrypto::AesKey m_partitionTitlekey; + bool m_detectedCorruption{false}; - /* Cache for decrypted hashed blocks */ + bool HashIsDisabled() const + { + return m_hashIsDisabled; + } + + /* Cache for decrypted raw and hashed blocks */ + std::unordered_map m_cacheDecryptedRawBlocks; std::unordered_map m_cacheDecryptedHashedBlocks; uint64 m_cacheAccessCounter{}; + void DetermineUnhashedBlockIV(uint32 clusterIndex, uint32 blockIndex, uint8 ivOut[16]); + + struct FSTCachedRawBlock* GetDecryptedRawBlock(uint32 clusterIndex, uint32 blockIndex); struct FSTCachedHashedBlock* GetDecryptedHashedBlock(uint32 clusterIndex, uint32 blockIndex); + void TrimCacheIfRequired(struct FSTCachedRawBlock** droppedRawBlock, struct FSTCachedHashedBlock** droppedHashedBlock); + /* File reading */ uint32 ReadFile_HashModeRaw(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut); uint32 ReadFile_HashModeHashed(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut); @@ -185,7 +210,10 @@ private: /* +0x00 */ uint32be magic; /* +0x04 */ uint32be offsetFactor; /* +0x08 */ uint32be numCluster; - /* +0x0C */ uint32be ukn0C; + /* +0x0C */ uint8be hashIsDisabled; + /* +0x0D */ uint8be ukn0D; + /* +0x0E */ uint8be ukn0E; + /* +0x0F */ uint8be ukn0F; /* +0x10 */ uint32be ukn10; /* +0x14 */ uint32be ukn14; /* +0x18 */ uint32be ukn18; @@ -262,8 +290,8 @@ private: static_assert(sizeof(FSTHeader_FileEntry) == 0x10); - static FSTVolume* OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode); - static FSTVolume* OpenFST(std::unique_ptr dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode); + static FSTVolume* OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode, NCrypto::TMDParser* optionalTMD); + static FSTVolume* OpenFST(std::unique_ptr dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode, NCrypto::TMDParser* optionalTMD); static bool ProcessFST(FSTHeader_FileEntry* fileTable, uint32 numFileEntries, uint32 numCluster, std::vector& nameStringTable, std::vector& fstEntries); bool MatchFSTEntryName(FSTEntry& entry, std::string_view comparedName) From 66658351c1e274265ddc18643b761f7df1e21e11 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sun, 10 Nov 2024 10:10:46 +0100 Subject: [PATCH 09/50] erreula: Rework implementation and fix bugs - ErrEula doesn't disappear on its own anymore. The expected behavior is for the game to call Disappear once a button has been selected. This fixes issues where the dialog would softlock in some games - Modernized code a bit - Added a subtle fade in/out effect --- src/Cafe/CafeSystem.cpp | 42 +-- src/Cafe/CafeSystem.h | 7 +- src/Cafe/OS/libs/coreinit/coreinit_Time.h | 7 +- src/Cafe/OS/libs/erreula/erreula.cpp | 423 ++++++++++++++-------- src/gui/MainWindow.cpp | 12 +- 5 files changed, 311 insertions(+), 180 deletions(-) diff --git a/src/Cafe/CafeSystem.cpp b/src/Cafe/CafeSystem.cpp index 51de3550..88e0ed3d 100644 --- a/src/Cafe/CafeSystem.cpp +++ b/src/Cafe/CafeSystem.cpp @@ -637,40 +637,40 @@ namespace CafeSystem fsc_unmount("/cemuBossStorage/", FSC_PRIORITY_BASE); } - STATUS_CODE LoadAndMountForegroundTitle(TitleId titleId) + PREPARE_STATUS_CODE LoadAndMountForegroundTitle(TitleId titleId) { cemuLog_log(LogType::Force, "Mounting title {:016x}", (uint64)titleId); sGameInfo_ForegroundTitle = CafeTitleList::GetGameInfo(titleId); if (!sGameInfo_ForegroundTitle.IsValid()) { cemuLog_log(LogType::Force, "Mounting failed: Game meta information is either missing, inaccessible or not valid (missing or invalid .xml files in code and meta folder)"); - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; } // check base TitleInfo& titleBase = sGameInfo_ForegroundTitle.GetBase(); if (!titleBase.IsValid()) - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; if(!titleBase.ParseXmlInfo()) - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; cemuLog_log(LogType::Force, "Base: {}", titleBase.GetPrintPath()); // mount base if (!titleBase.Mount("/vol/content", "content", FSC_PRIORITY_BASE) || !titleBase.Mount(GetInternalVirtualCodeFolder(), "code", FSC_PRIORITY_BASE)) { cemuLog_log(LogType::Force, "Mounting failed"); - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; } // check update TitleInfo& titleUpdate = sGameInfo_ForegroundTitle.GetUpdate(); if (titleUpdate.IsValid()) { if (!titleUpdate.ParseXmlInfo()) - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; cemuLog_log(LogType::Force, "Update: {}", titleUpdate.GetPrintPath()); // mount update if (!titleUpdate.Mount("/vol/content", "content", FSC_PRIORITY_PATCH) || !titleUpdate.Mount(GetInternalVirtualCodeFolder(), "code", FSC_PRIORITY_PATCH)) { cemuLog_log(LogType::Force, "Mounting failed"); - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; } } else @@ -682,20 +682,20 @@ namespace CafeSystem // todo - support for multi-title AOC TitleInfo& titleAOC = aocList[0]; if (!titleAOC.ParseXmlInfo()) - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; cemu_assert_debug(titleAOC.IsValid()); cemuLog_log(LogType::Force, "DLC: {}", titleAOC.GetPrintPath()); // mount AOC if (!titleAOC.Mount(fmt::format("/vol/aoc{:016x}", titleAOC.GetAppTitleId()), "content", FSC_PRIORITY_PATCH)) { cemuLog_log(LogType::Force, "Mounting failed"); - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; } } else cemuLog_log(LogType::Force, "DLC: Not present"); sForegroundTitleId = titleId; - return STATUS_CODE::SUCCESS; + return PREPARE_STATUS_CODE::SUCCESS; } void UnmountForegroundTitle() @@ -723,7 +723,7 @@ namespace CafeSystem } } - STATUS_CODE SetupExecutable() + PREPARE_STATUS_CODE SetupExecutable() { // set rpx path from cos.xml if available _pathToBaseExecutable = _pathToExecutable; @@ -755,7 +755,7 @@ namespace CafeSystem } } LoadMainExecutable(); - return STATUS_CODE::SUCCESS; + return PREPARE_STATUS_CODE::SUCCESS; } void SetupMemorySpace() @@ -769,7 +769,7 @@ namespace CafeSystem memory_unmapForCurrentTitle(); } - STATUS_CODE PrepareForegroundTitle(TitleId titleId) + PREPARE_STATUS_CODE PrepareForegroundTitle(TitleId titleId) { CafeTitleList::WaitForMandatoryScan(); sLaunchModeIsStandalone = false; @@ -780,21 +780,21 @@ namespace CafeSystem // mount mlc storage MountBaseDirectories(); // mount title folders - STATUS_CODE r = LoadAndMountForegroundTitle(titleId); - if (r != STATUS_CODE::SUCCESS) + PREPARE_STATUS_CODE r = LoadAndMountForegroundTitle(titleId); + if (r != PREPARE_STATUS_CODE::SUCCESS) return r; gameProfile_load(); // setup memory space and PPC recompiler SetupMemorySpace(); PPCRecompiler_init(); r = SetupExecutable(); // load RPX - if (r != STATUS_CODE::SUCCESS) + if (r != PREPARE_STATUS_CODE::SUCCESS) return r; InitVirtualMlcStorage(); - return STATUS_CODE::SUCCESS; + return PREPARE_STATUS_CODE::SUCCESS; } - STATUS_CODE PrepareForegroundTitleFromStandaloneRPX(const fs::path& path) + PREPARE_STATUS_CODE PrepareForegroundTitleFromStandaloneRPX(const fs::path& path) { sLaunchModeIsStandalone = true; cemuLog_log(LogType::Force, "Launching executable in standalone mode due to incorrect layout or missing meta files"); @@ -812,7 +812,7 @@ namespace CafeSystem if (!r) { cemuLog_log(LogType::Force, "Failed to mount {}", _pathToUtf8(contentPath)); - return STATUS_CODE::UNABLE_TO_MOUNT; + return PREPARE_STATUS_CODE::UNABLE_TO_MOUNT; } } } @@ -824,7 +824,7 @@ namespace CafeSystem // since a lot of systems (including save folder location) rely on a TitleId, we derive a placeholder id from the executable hash auto execData = fsc_extractFile(_pathToExecutable.c_str()); if (!execData) - return STATUS_CODE::INVALID_RPX; + return PREPARE_STATUS_CODE::INVALID_RPX; uint32 h = generateHashFromRawRPXData(execData->data(), execData->size()); sForegroundTitleId = 0xFFFFFFFF00000000ULL | (uint64)h; cemuLog_log(LogType::Force, "Generated placeholder TitleId: {:016x}", sForegroundTitleId); @@ -834,7 +834,7 @@ namespace CafeSystem // load executable SetupExecutable(); InitVirtualMlcStorage(); - return STATUS_CODE::SUCCESS; + return PREPARE_STATUS_CODE::SUCCESS; } void _LaunchTitleThread() diff --git a/src/Cafe/CafeSystem.h b/src/Cafe/CafeSystem.h index c4043a59..e9de8d7d 100644 --- a/src/Cafe/CafeSystem.h +++ b/src/Cafe/CafeSystem.h @@ -15,20 +15,19 @@ namespace CafeSystem virtual void CafeRecreateCanvas() = 0; }; - enum class STATUS_CODE + enum class PREPARE_STATUS_CODE { SUCCESS, INVALID_RPX, UNABLE_TO_MOUNT, // failed to mount through TitleInfo (most likely caused by an invalid or outdated path) - //BAD_META_DATA, - the title list only stores titles with valid meta, so this error code is impossible }; void Initialize(); void SetImplementation(SystemImplementation* impl); void Shutdown(); - STATUS_CODE PrepareForegroundTitle(TitleId titleId); - STATUS_CODE PrepareForegroundTitleFromStandaloneRPX(const fs::path& path); + PREPARE_STATUS_CODE PrepareForegroundTitle(TitleId titleId); + PREPARE_STATUS_CODE PrepareForegroundTitleFromStandaloneRPX(const fs::path& path); void LaunchForegroundTitle(); bool IsTitleRunning(); diff --git a/src/Cafe/OS/libs/coreinit/coreinit_Time.h b/src/Cafe/OS/libs/coreinit/coreinit_Time.h index 018e8eb7..3aa92b99 100644 --- a/src/Cafe/OS/libs/coreinit/coreinit_Time.h +++ b/src/Cafe/OS/libs/coreinit/coreinit_Time.h @@ -40,7 +40,12 @@ namespace coreinit inline TimerTicks ConvertNsToTimerTicks(uint64 ns) { - return ((GetTimerClock() / 31250LL) * ((ns)) / 32000LL); + return ((GetTimerClock() / 31250LL) * ((TimerTicks)ns) / 32000LL); + } + + inline TimerTicks ConvertMsToTimerTicks(uint64 ms) + { + return (TimerTicks)ms * GetTimerClock() / 1000LL; } }; diff --git a/src/Cafe/OS/libs/erreula/erreula.cpp b/src/Cafe/OS/libs/erreula/erreula.cpp index a7f2f35c..342e8b64 100644 --- a/src/Cafe/OS/libs/erreula/erreula.cpp +++ b/src/Cafe/OS/libs/erreula/erreula.cpp @@ -9,32 +9,45 @@ #include #include "Cafe/OS/libs/coreinit/coreinit_FS.h" +#include "Cafe/OS/libs/coreinit/coreinit_Time.h" #include "Cafe/OS/libs/vpad/vpad.h" namespace nn { namespace erreula { -#define RESULTTYPE_NONE 0 -#define RESULTTYPE_FINISH 1 -#define RESULTTYPE_NEXT 2 -#define RESULTTYPE_JUMP 3 -#define RESULTTYPE_PASSWORD 4 -#define ERRORTYPE_CODE 0 -#define ERRORTYPE_TEXT 1 -#define ERRORTYPE_TEXT_ONE_BUTTON 2 -#define ERRORTYPE_TEXT_TWO_BUTTON 3 - -#define ERREULA_STATE_HIDDEN 0 -#define ERREULA_STATE_APPEARING 1 -#define ERREULA_STATE_VISIBLE 2 -#define ERREULA_STATE_DISAPPEARING 3 - - struct AppearArg_t + enum class ErrorDialogType : uint32 { - AppearArg_t() = default; - AppearArg_t(const AppearArg_t& o) + Code = 0, + Text = 1, + TextOneButton = 2, + TextTwoButton = 3 + }; + + static const sint32 FADE_TIME = 80; + + enum class ErrEulaState : uint32 + { + Hidden = 0, + Appearing = 1, + Visible = 2, + Disappearing = 3 + }; + + enum class ResultType : uint32 + { + None = 0, + Finish = 1, + Next = 2, + Jump = 3, + Password = 4 + }; + + struct AppearError + { + AppearError() = default; + AppearError(const AppearError& o) { errorType = o.errorType; screenType = o.screenType; @@ -49,7 +62,7 @@ namespace erreula drawCursor = o.drawCursor; } - uint32be errorType; + betype errorType; uint32be screenType; uint32be controllerType; uint32be holdType; @@ -63,7 +76,9 @@ namespace erreula bool drawCursor{}; }; - static_assert(sizeof(AppearArg_t) == 0x2C); // maybe larger + using AppearArg = AppearError; + + static_assert(sizeof(AppearError) == 0x2C); // maybe larger struct HomeNixSignArg_t { @@ -80,6 +95,132 @@ namespace erreula static_assert(sizeof(ControllerInfo_t) == 0x14); // maybe larger + class ErrEulaInstance + { + public: + enum class BUTTON_SELECTION : uint32 + { + NONE = 0xFFFFFFFF, + LEFT = 0, + RIGHT = 1, + }; + + void Init() + { + m_buttonSelection = BUTTON_SELECTION::NONE; + m_resultCode = -1; + m_resultCodeForLeftButton = 0; + m_resultCodeForRightButton = 0; + SetState(ErrEulaState::Hidden); + } + + void DoAppearError(AppearArg* arg) + { + m_buttonSelection = BUTTON_SELECTION::NONE; + m_resultCode = -1; + m_resultCodeForLeftButton = -1; + m_resultCodeForRightButton = -1; + // for standard dialog its 0 and 1? + m_resultCodeForLeftButton = 0; + m_resultCodeForRightButton = 1; + SetState(ErrEulaState::Appearing); + } + + void DoDisappearError() + { + if(m_state != ErrEulaState::Visible) + return; + SetState(ErrEulaState::Disappearing); + } + + void DoCalc() + { + // appearing and disappearing state will automatically advance after some time + if (m_state == ErrEulaState::Appearing || m_state == ErrEulaState::Disappearing) + { + uint32 elapsedTick = coreinit::OSGetTime() - m_lastStateChange; + if (elapsedTick > coreinit::EspressoTime::ConvertMsToTimerTicks(FADE_TIME)) + { + SetState(m_state == ErrEulaState::Appearing ? ErrEulaState::Visible : ErrEulaState::Hidden); + } + } + } + + bool IsDecideSelectButtonError() const + { + return m_buttonSelection != BUTTON_SELECTION::NONE; + } + + bool IsDecideSelectLeftButtonError() const + { + return m_buttonSelection != BUTTON_SELECTION::LEFT; + } + + bool IsDecideSelectRightButtonError() const + { + return m_buttonSelection != BUTTON_SELECTION::RIGHT; + } + + void SetButtonSelection(BUTTON_SELECTION selection) + { + cemu_assert_debug(m_buttonSelection == BUTTON_SELECTION::NONE); + m_buttonSelection = selection; + cemu_assert_debug(selection == BUTTON_SELECTION::LEFT || selection == BUTTON_SELECTION::RIGHT); + m_resultCode = selection == BUTTON_SELECTION::LEFT ? m_resultCodeForLeftButton : m_resultCodeForRightButton; + } + + ErrEulaState GetState() const + { + return m_state; + } + + sint32 GetResultCode() const + { + return m_resultCode; + } + + ResultType GetResultType() const + { + if(m_resultCode == -1) + return ResultType::None; + if(m_resultCode < 10) + return ResultType::Finish; + if(m_resultCode >= 9999) + return ResultType::Next; + if(m_resultCode == 40) + return ResultType::Password; + return ResultType::Jump; + } + + float GetFadeTransparency() const + { + if(m_state == ErrEulaState::Appearing || m_state == ErrEulaState::Disappearing) + { + uint32 elapsedTick = coreinit::OSGetTime() - m_lastStateChange; + if(m_state == ErrEulaState::Appearing) + return std::min(1.0f, (float)elapsedTick / (float)coreinit::EspressoTime::ConvertMsToTimerTicks(FADE_TIME)); + else + return std::max(0.0f, 1.0f - (float)elapsedTick / (float)coreinit::EspressoTime::ConvertMsToTimerTicks(FADE_TIME)); + } + return 1.0f; + } + + private: + void SetState(ErrEulaState state) + { + m_state = state; + m_lastStateChange = coreinit::OSGetTime(); + } + + ErrEulaState m_state; + uint32 m_lastStateChange; + + /* +0x30 */ betype m_resultCode; + /* +0x239C */ betype m_buttonSelection; + /* +0x23A0 */ betype m_resultCodeForLeftButton; + /* +0x23A4 */ betype m_resultCodeForRightButton; + }; + struct ErrEula_t { SysAllocator mutex; @@ -87,17 +228,11 @@ namespace erreula uint32 langType; MEMPTR fsClient; - AppearArg_t currentDialog; - uint32 state; - bool buttonPressed; - bool rightButtonPressed; + std::unique_ptr errEulaInstance; + AppearError currentDialog; bool homeNixSignVisible; - - std::chrono::steady_clock::time_point stateTimer{}; } g_errEula = {}; - - std::wstring GetText(uint16be* text) { @@ -113,22 +248,61 @@ namespace erreula } - void export_ErrEulaCreate(PPCInterpreter_t* hCPU) + void ErrEulaCreate(void* workmem, uint32 regionType, uint32 langType, coreinit::FSClient_t* fsClient) { - ppcDefineParamMEMPTR(thisptr, uint8, 0); - ppcDefineParamU32(regionType, 1); - ppcDefineParamU32(langType, 2); - ppcDefineParamMEMPTR(fsClient, coreinit::FSClient_t, 3); - coreinit::OSLockMutex(&g_errEula.mutex); g_errEula.regionType = regionType; g_errEula.langType = langType; g_errEula.fsClient = fsClient; + cemu_assert_debug(!g_errEula.errEulaInstance); + g_errEula.errEulaInstance = std::make_unique(); + g_errEula.errEulaInstance->Init(); coreinit::OSUnlockMutex(&g_errEula.mutex); + } - osLib_returnFromFunction(hCPU, 0); + void ErrEulaDestroy() + { + g_errEula.errEulaInstance.reset(); + } + + // check if any dialog button was selected + bool IsDecideSelectButtonError() + { + if(!g_errEula.errEulaInstance) + return false; + return g_errEula.errEulaInstance->IsDecideSelectButtonError(); + } + + // check if left dialog button was selected + bool IsDecideSelectLeftButtonError() + { + if(!g_errEula.errEulaInstance) + return false; + return g_errEula.errEulaInstance->IsDecideSelectLeftButtonError(); + } + + // check if right dialog button was selected + bool IsDecideSelectRightButtonError() + { + if(!g_errEula.errEulaInstance) + return false; + return g_errEula.errEulaInstance->IsDecideSelectRightButtonError(); + } + + sint32 GetResultCode() + { + if(!g_errEula.errEulaInstance) + return -1; + return g_errEula.errEulaInstance->GetResultCode(); + } + + ResultType GetResultType() + { + if(!g_errEula.errEulaInstance) + return ResultType::None; + return g_errEula.errEulaInstance->GetResultType(); } void export_AppearHomeNixSign(PPCInterpreter_t* hCPU) @@ -137,28 +311,24 @@ namespace erreula osLib_returnFromFunction(hCPU, 0); } - void export_AppearError(PPCInterpreter_t* hCPU) + void ErrEulaAppearError(AppearArg* arg) { - ppcDefineParamMEMPTR(arg, AppearArg_t, 0); - - g_errEula.currentDialog = *arg.GetPtr(); - g_errEula.state = ERREULA_STATE_APPEARING; - g_errEula.buttonPressed = false; - g_errEula.rightButtonPressed = false; - - g_errEula.stateTimer = tick_cached(); - - osLib_returnFromFunction(hCPU, 0); + g_errEula.currentDialog = *arg; + if(g_errEula.errEulaInstance) + g_errEula.errEulaInstance->DoAppearError(arg); } - void export_GetStateErrorViewer(PPCInterpreter_t* hCPU) + void ErrEulaDisappearError() { - osLib_returnFromFunction(hCPU, g_errEula.state); + if(g_errEula.errEulaInstance) + g_errEula.errEulaInstance->DoDisappearError(); } - void export_DisappearError(PPCInterpreter_t* hCPU) + + ErrEulaState ErrEulaGetStateErrorViewer() { - g_errEula.state = ERREULA_STATE_HIDDEN; - osLib_returnFromFunction(hCPU, 0); + if(!g_errEula.errEulaInstance) + return ErrEulaState::Hidden; + return g_errEula.errEulaInstance->GetState(); } void export_ChangeLang(PPCInterpreter_t* hCPU) @@ -168,27 +338,6 @@ namespace erreula osLib_returnFromFunction(hCPU, 0); } - void export_IsDecideSelectButtonError(PPCInterpreter_t* hCPU) - { - if (g_errEula.buttonPressed) - cemuLog_logDebug(LogType::Force, "IsDecideSelectButtonError: TRUE"); - osLib_returnFromFunction(hCPU, g_errEula.buttonPressed); - } - - void export_IsDecideSelectLeftButtonError(PPCInterpreter_t* hCPU) - { - if (g_errEula.buttonPressed) - cemuLog_logDebug(LogType::Force, "IsDecideSelectLeftButtonError: TRUE"); - osLib_returnFromFunction(hCPU, g_errEula.buttonPressed); - } - - void export_IsDecideSelectRightButtonError(PPCInterpreter_t* hCPU) - { - if (g_errEula.rightButtonPressed) - cemuLog_logDebug(LogType::Force, "IsDecideSelectRightButtonError: TRUE"); - osLib_returnFromFunction(hCPU, g_errEula.rightButtonPressed); - } - void export_IsAppearHomeNixSign(PPCInterpreter_t* hCPU) { osLib_returnFromFunction(hCPU, g_errEula.homeNixSignVisible); @@ -200,61 +349,19 @@ namespace erreula osLib_returnFromFunction(hCPU, 0); } - void export_GetResultType(PPCInterpreter_t* hCPU) + void ErrEulaCalc(ControllerInfo_t* controllerInfo) { - uint32 result = RESULTTYPE_NONE; - if (g_errEula.buttonPressed || g_errEula.rightButtonPressed) - { - cemuLog_logDebug(LogType::Force, "GetResultType: FINISH"); - result = RESULTTYPE_FINISH; - } - - osLib_returnFromFunction(hCPU, result); - } - - void export_Calc(PPCInterpreter_t* hCPU) - { - ppcDefineParamMEMPTR(controllerInfo, ControllerInfo_t, 0); - // TODO: check controller buttons bla to accept dialog? - osLib_returnFromFunction(hCPU, 0); + if(g_errEula.errEulaInstance) + g_errEula.errEulaInstance->DoCalc(); } void render(bool mainWindow) { - if(g_errEula.state == ERREULA_STATE_HIDDEN) + if(!g_errEula.errEulaInstance) return; - - if(g_errEula.state == ERREULA_STATE_APPEARING) - { - if(std::chrono::duration_cast(tick_cached() - g_errEula.stateTimer).count() <= 1000) - { - return; - } - - g_errEula.state = ERREULA_STATE_VISIBLE; - g_errEula.stateTimer = tick_cached(); - } - /*else if(g_errEula.state == STATE_VISIBLE) - { - if (std::chrono::duration_cast(tick_cached() - g_errEula.stateTimer).count() >= 1000) - { - g_errEula.state = STATE_DISAPPEARING; - g_errEula.stateTimer = tick_cached(); - return; - } - }*/ - else if(g_errEula.state == ERREULA_STATE_DISAPPEARING) - { - if (std::chrono::duration_cast(tick_cached() - g_errEula.stateTimer).count() >= 2000) - { - g_errEula.state = ERREULA_STATE_HIDDEN; - g_errEula.stateTimer = tick_cached(); - } - + if(g_errEula.errEulaInstance->GetState() != ErrEulaState::Visible && g_errEula.errEulaInstance->GetState() != ErrEulaState::Appearing && g_errEula.errEulaInstance->GetState() != ErrEulaState::Disappearing) return; - } - - const AppearArg_t& appearArg = g_errEula.currentDialog; + const AppearError& appearArg = g_errEula.currentDialog; std::string text; const uint32 errorCode = (uint32)appearArg.errorCode; if (errorCode != 0) @@ -276,17 +383,28 @@ namespace erreula ImGui::SetNextWindowPos(position, ImGuiCond_Always, pivot); ImGui::SetNextWindowBgAlpha(0.9f); ImGui::PushFont(font); - + std::string title; if (appearArg.title) title = boost::nowide::narrow(GetText(appearArg.title.GetPtr())); - if(title.empty()) // ImGui doesn't allow empty titles, so set one if appearArg.title is not set or empty + if (title.empty()) // ImGui doesn't allow empty titles, so set one if appearArg.title is not set or empty title = "ErrEula"; + + float fadeTransparency = 1.0f; + if (g_errEula.errEulaInstance->GetState() == ErrEulaState::Appearing || g_errEula.errEulaInstance->GetState() == ErrEulaState::Disappearing) + { + fadeTransparency = g_errEula.errEulaInstance->GetFadeTransparency(); + } + + float originalAlpha = ImGui::GetStyle().Alpha; + ImGui::GetStyle().Alpha = fadeTransparency; + ImGui::SetNextWindowBgAlpha(0.9f * fadeTransparency); if (ImGui::Begin(title.c_str(), nullptr, kPopupFlags)) { const float startx = ImGui::GetWindowSize().x / 2.0f; + bool hasLeftButtonPressed = false, hasRightButtonPressed = false; - switch ((uint32)appearArg.errorType) + switch (appearArg.errorType) { default: { @@ -294,11 +412,10 @@ namespace erreula ImGui::TextUnformatted(text.c_str(), text.c_str() + text.size()); ImGui::Spacing(); ImGui::SetCursorPosX(startx - 50); - g_errEula.buttonPressed |= ImGui::Button("OK", {100, 0}); - + hasLeftButtonPressed = ImGui::Button("OK", {100, 0}); break; } - case ERRORTYPE_TEXT: + case ErrorDialogType::Text: { std::string txtTmp = "Unknown Error"; if (appearArg.text) @@ -309,10 +426,10 @@ namespace erreula ImGui::Spacing(); ImGui::SetCursorPosX(startx - 50); - g_errEula.buttonPressed |= ImGui::Button("OK", { 100, 0 }); + hasLeftButtonPressed = ImGui::Button("OK", { 100, 0 }); break; } - case ERRORTYPE_TEXT_ONE_BUTTON: + case ErrorDialogType::TextOneButton: { std::string txtTmp = "Unknown Error"; if (appearArg.text) @@ -328,10 +445,10 @@ namespace erreula float width = std::max(100.0f, ImGui::CalcTextSize(button1.c_str()).x + 10.0f); ImGui::SetCursorPosX(startx - (width / 2.0f)); - g_errEula.buttonPressed |= ImGui::Button(button1.c_str(), { width, 0 }); + hasLeftButtonPressed = ImGui::Button(button1.c_str(), { width, 0 }); break; } - case ERRORTYPE_TEXT_TWO_BUTTON: + case ErrorDialogType::TextTwoButton: { std::string txtTmp = "Unknown Error"; if (appearArg.text) @@ -352,42 +469,52 @@ namespace erreula float width2 = std::max(100.0f, ImGui::CalcTextSize(button2.c_str()).x + 10.0f); ImGui::SetCursorPosX(startx - (width1 / 2.0f) - (width2 / 2.0f) - 10); - g_errEula.buttonPressed |= ImGui::Button(button1.c_str(), { width1, 0 }); + hasLeftButtonPressed = ImGui::Button(button1.c_str(), { width1, 0 }); ImGui::SameLine(); - g_errEula.rightButtonPressed |= ImGui::Button(button2.c_str(), { width2, 0 }); + hasRightButtonPressed = ImGui::Button(button2.c_str(), { width2, 0 }); break; } } + if (!g_errEula.errEulaInstance->IsDecideSelectButtonError()) + { + if (hasLeftButtonPressed) + g_errEula.errEulaInstance->SetButtonSelection(ErrEulaInstance::BUTTON_SELECTION::LEFT); + if (hasRightButtonPressed) + g_errEula.errEulaInstance->SetButtonSelection(ErrEulaInstance::BUTTON_SELECTION::RIGHT); + } } ImGui::End(); ImGui::PopFont(); - - if(g_errEula.buttonPressed || g_errEula.rightButtonPressed) - { - g_errEula.state = ERREULA_STATE_DISAPPEARING; - g_errEula.stateTimer = tick_cached(); - } + ImGui::GetStyle().Alpha = originalAlpha; } void load() { + g_errEula.errEulaInstance.reset(); + OSInitMutexEx(&g_errEula.mutex, nullptr); - //osLib_addFunction("erreula", "ErrEulaCreate__3RplFPUcQ3_2nn7erreula10", export_ErrEulaCreate); // copy ctor? - osLib_addFunction("erreula", "ErrEulaCreate__3RplFPUcQ3_2nn7erreula10RegionTypeQ3_2nn7erreula8LangTypeP8FSClient", export_ErrEulaCreate); + cafeExportRegisterFunc(ErrEulaCreate, "erreula", "ErrEulaCreate__3RplFPUcQ3_2nn7erreula10RegionTypeQ3_2nn7erreula8LangTypeP8FSClient", LogType::Placeholder); + cafeExportRegisterFunc(ErrEulaDestroy, "erreula", "ErrEulaDestroy__3RplFv", LogType::Placeholder); + + cafeExportRegisterFunc(IsDecideSelectButtonError, "erreula", "ErrEulaIsDecideSelectButtonError__3RplFv", LogType::Placeholder); + cafeExportRegisterFunc(IsDecideSelectLeftButtonError, "erreula", "ErrEulaIsDecideSelectLeftButtonError__3RplFv", LogType::Placeholder); + cafeExportRegisterFunc(IsDecideSelectRightButtonError, "erreula", "ErrEulaIsDecideSelectRightButtonError__3RplFv", LogType::Placeholder); + + cafeExportRegisterFunc(GetResultCode, "erreula", "ErrEulaGetResultCode__3RplFv", LogType::Placeholder); + cafeExportRegisterFunc(GetResultType, "erreula", "ErrEulaGetResultType__3RplFv", LogType::Placeholder); + + cafeExportRegisterFunc(ErrEulaAppearError, "erreula", "ErrEulaAppearError__3RplFRCQ3_2nn7erreula9AppearArg", LogType::Placeholder); + cafeExportRegisterFunc(ErrEulaDisappearError, "erreula", "ErrEulaDisappearError__3RplFv", LogType::Placeholder); + cafeExportRegisterFunc(ErrEulaGetStateErrorViewer, "erreula", "ErrEulaGetStateErrorViewer__3RplFv", LogType::Placeholder); + + cafeExportRegisterFunc(ErrEulaCalc, "erreula", "ErrEulaCalc__3RplFRCQ3_2nn7erreula14ControllerInfo", LogType::Placeholder); + osLib_addFunction("erreula", "ErrEulaAppearHomeNixSign__3RplFRCQ3_2nn7erreula14HomeNixSignArg", export_AppearHomeNixSign); - osLib_addFunction("erreula", "ErrEulaAppearError__3RplFRCQ3_2nn7erreula9AppearArg", export_AppearError); - osLib_addFunction("erreula", "ErrEulaGetStateErrorViewer__3RplFv", export_GetStateErrorViewer); osLib_addFunction("erreula", "ErrEulaChangeLang__3RplFQ3_2nn7erreula8LangType", export_ChangeLang); - osLib_addFunction("erreula", "ErrEulaIsDecideSelectButtonError__3RplFv", export_IsDecideSelectButtonError); - osLib_addFunction("erreula", "ErrEulaCalc__3RplFRCQ3_2nn7erreula14ControllerInfo", export_Calc); - osLib_addFunction("erreula", "ErrEulaIsDecideSelectLeftButtonError__3RplFv", export_IsDecideSelectLeftButtonError); - osLib_addFunction("erreula", "ErrEulaIsDecideSelectRightButtonError__3RplFv", export_IsDecideSelectRightButtonError); osLib_addFunction("erreula", "ErrEulaIsAppearHomeNixSign__3RplFv", export_IsAppearHomeNixSign); osLib_addFunction("erreula", "ErrEulaDisappearHomeNixSign__3RplFv", export_DisappearHomeNixSign); - osLib_addFunction("erreula", "ErrEulaGetResultType__3RplFv", export_GetResultType); - osLib_addFunction("erreula", "ErrEulaDisappearError__3RplFv", export_DisappearError); } } } diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index c83ab16b..69ff4e99 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -483,20 +483,20 @@ bool MainWindow::FileLoad(const fs::path launchPath, wxLaunchGameEvent::INITIATE wxMessageBox(t, _("Error"), wxOK | wxCENTRE | wxICON_ERROR); return false; } - CafeSystem::STATUS_CODE r = CafeSystem::PrepareForegroundTitle(baseTitleId); - if (r == CafeSystem::STATUS_CODE::INVALID_RPX) + CafeSystem::PREPARE_STATUS_CODE r = CafeSystem::PrepareForegroundTitle(baseTitleId); + if (r == CafeSystem::PREPARE_STATUS_CODE::INVALID_RPX) { cemu_assert_debug(false); return false; } - else if (r == CafeSystem::STATUS_CODE::UNABLE_TO_MOUNT) + else if (r == CafeSystem::PREPARE_STATUS_CODE::UNABLE_TO_MOUNT) { wxString t = _("Unable to mount title.\nMake sure the configured game paths are still valid and refresh the game list.\n\nFile which failed to load:\n"); t.append(_pathToUtf8(launchPath)); wxMessageBox(t, _("Error"), wxOK | wxCENTRE | wxICON_ERROR); return false; } - else if (r != CafeSystem::STATUS_CODE::SUCCESS) + else if (r != CafeSystem::PREPARE_STATUS_CODE::SUCCESS) { wxString t = _("Failed to launch game."); t.append(_pathToUtf8(launchPath)); @@ -511,8 +511,8 @@ bool MainWindow::FileLoad(const fs::path launchPath, wxLaunchGameEvent::INITIATE CafeTitleFileType fileType = DetermineCafeSystemFileType(launchPath); if (fileType == CafeTitleFileType::RPX || fileType == CafeTitleFileType::ELF) { - CafeSystem::STATUS_CODE r = CafeSystem::PrepareForegroundTitleFromStandaloneRPX(launchPath); - if (r != CafeSystem::STATUS_CODE::SUCCESS) + CafeSystem::PREPARE_STATUS_CODE r = CafeSystem::PrepareForegroundTitleFromStandaloneRPX(launchPath); + if (r != CafeSystem::PREPARE_STATUS_CODE::SUCCESS) { cemu_assert_debug(false); // todo wxString t = _("Failed to launch executable. Path: "); From 719c631f13c451e045903e79768e98e264a0e9b2 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Wed, 13 Nov 2024 06:28:13 +0100 Subject: [PATCH 10/50] config: Fix receive_untested_updates using the wrong default --- src/config/CemuConfig.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/CemuConfig.cpp b/src/config/CemuConfig.cpp index f5ee7ab4..26f420a5 100644 --- a/src/config/CemuConfig.cpp +++ b/src/config/CemuConfig.cpp @@ -38,7 +38,7 @@ void CemuConfig::Load(XMLConfigParser& parser) fullscreen_menubar = parser.get("fullscreen_menubar", false); feral_gamemode = parser.get("feral_gamemode", false); check_update = parser.get("check_update", check_update); - receive_untested_updates = parser.get("receive_untested_updates", check_update); + receive_untested_updates = parser.get("receive_untested_updates", receive_untested_updates); save_screenshot = parser.get("save_screenshot", save_screenshot); did_show_vulkan_warning = parser.get("vk_warning", did_show_vulkan_warning); did_show_graphic_pack_download = parser.get("gp_download", did_show_graphic_pack_download); From 6f9f3d52ea12d3951b2d9d71806f18fea46140e3 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Wed, 13 Nov 2024 06:38:13 +0100 Subject: [PATCH 11/50] CI: Remove outdated workflow --- ...imental_release.yml => deploy_release.yml} | 4 +- .github/workflows/deploy_stable_release.yml | 85 ------------------- 2 files changed, 2 insertions(+), 87 deletions(-) rename .github/workflows/{deploy_experimental_release.yml => deploy_release.yml} (98%) delete mode 100644 .github/workflows/deploy_stable_release.yml diff --git a/.github/workflows/deploy_experimental_release.yml b/.github/workflows/deploy_release.yml similarity index 98% rename from .github/workflows/deploy_experimental_release.yml rename to .github/workflows/deploy_release.yml index 97e0c69e..2b9ee491 100644 --- a/.github/workflows/deploy_experimental_release.yml +++ b/.github/workflows/deploy_release.yml @@ -1,4 +1,4 @@ -name: Deploy experimental release +name: Deploy release on: workflow_dispatch: inputs: @@ -54,7 +54,7 @@ jobs: next_version_major: ${{ needs.calculate-version.outputs.next_version_major }} next_version_minor: ${{ needs.calculate-version.outputs.next_version_minor }} deploy: - name: Deploy experimental release + name: Deploy release runs-on: ubuntu-22.04 needs: [call-release-build, calculate-version] steps: diff --git a/.github/workflows/deploy_stable_release.yml b/.github/workflows/deploy_stable_release.yml deleted file mode 100644 index fd339e7d..00000000 --- a/.github/workflows/deploy_stable_release.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Create new release -on: - workflow_dispatch: - inputs: - PlaceholderInput: - description: PlaceholderInput - required: false -jobs: - call-release-build: - uses: ./.github/workflows/build.yml - with: - deploymode: release - deploy: - name: Deploy release - runs-on: ubuntu-20.04 - needs: call-release-build - steps: - - uses: actions/checkout@v3 - - - uses: actions/download-artifact@v4 - with: - name: cemu-bin-linux-x64 - path: cemu-bin-linux-x64 - - - uses: actions/download-artifact@v4 - with: - name: cemu-appimage-x64 - path: cemu-appimage-x64 - - - uses: actions/download-artifact@v4 - with: - name: cemu-bin-windows-x64 - path: cemu-bin-windows-x64 - - - uses: actions/download-artifact@v4 - with: - name: cemu-bin-macos-x64 - path: cemu-bin-macos-x64 - - - name: Initialize - run: | - mkdir upload - sudo apt update -qq - sudo apt install -y zip - - - name: Get Cemu release version - run: | - gcc -o getversion .github/getversion.cpp - echo "Cemu CI version: $(./getversion)" - echo "CEMU_FOLDER_NAME=Cemu_$(./getversion)" >> $GITHUB_ENV - echo "CEMU_VERSION=$(./getversion)" >> $GITHUB_ENV - - - name: Create release from windows-bin - run: | - ls ./ - ls ./bin/ - cp -R ./bin ./${{ env.CEMU_FOLDER_NAME }} - mv cemu-bin-windows-x64/Cemu.exe ./${{ env.CEMU_FOLDER_NAME }}/Cemu.exe - zip -9 -r upload/cemu-${{ env.CEMU_VERSION }}-windows-x64.zip ${{ env.CEMU_FOLDER_NAME }} - rm -r ./${{ env.CEMU_FOLDER_NAME }} - - - name: Create appimage - run: | - VERSION=${{ env.CEMU_VERSION }} - echo "Cemu Version is $VERSION" - ls cemu-appimage-x64 - mv cemu-appimage-x64/Cemu-*-x86_64.AppImage upload/Cemu-$VERSION-x86_64.AppImage - - - name: Create release from ubuntu-bin - run: | - ls ./ - ls ./bin/ - cp -R ./bin ./${{ env.CEMU_FOLDER_NAME }} - mv cemu-bin-linux-x64/Cemu ./${{ env.CEMU_FOLDER_NAME }}/Cemu - zip -9 -r upload/cemu-${{ env.CEMU_VERSION }}-ubuntu-20.04-x64.zip ${{ env.CEMU_FOLDER_NAME }} - rm -r ./${{ env.CEMU_FOLDER_NAME }} - - - name: Create release from macos-bin - run: cp cemu-bin-macos-x64/Cemu.dmg upload/cemu-${{ env.CEMU_VERSION }}-macos-12-x64.dmg - - - name: Create release - run: | - wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.15.0/ghr_v0.15.0_linux_amd64.tar.gz - tar xvzf ghr.tar.gz; rm ghr.tar.gz - ghr_v0.15.0_linux_amd64/ghr -t ${{ secrets.GITHUB_TOKEN }} -n "Cemu ${{ env.CEMU_VERSION }}" -b "Changelog:" v${{ env.CEMU_VERSION }} ./upload From 269d5b9aabc2a346f441cae5e662fb32fbb7da41 Mon Sep 17 00:00:00 2001 From: goeiecool9999 <7033575+goeiecool9999@users.noreply.github.com> Date: Sat, 16 Nov 2024 10:02:43 +0100 Subject: [PATCH 12/50] Vulkan: Make scaling shaders compatible + fixes (#1392) --- src/Cafe/GraphicPack/GraphicPack2.cpp | 2 +- src/Cafe/HW/Latte/Core/LatteRenderTarget.cpp | 9 +- .../Latte/Renderer/OpenGL/OpenGLRenderer.cpp | 11 +- .../HW/Latte/Renderer/RendererOuputShader.cpp | 211 +++++++----------- .../HW/Latte/Renderer/RendererOuputShader.h | 14 +- .../Renderer/Vulkan/LatteTextureViewVk.cpp | 10 + .../Latte/Renderer/Vulkan/VulkanRenderer.cpp | 27 +++ 7 files changed, 135 insertions(+), 149 deletions(-) diff --git a/src/Cafe/GraphicPack/GraphicPack2.cpp b/src/Cafe/GraphicPack/GraphicPack2.cpp index c54c31cb..4b6cb095 100644 --- a/src/Cafe/GraphicPack/GraphicPack2.cpp +++ b/src/Cafe/GraphicPack/GraphicPack2.cpp @@ -960,7 +960,7 @@ bool GraphicPack2::Activate() auto option_upscale = rules.FindOption("upscaleMagFilter"); if(option_upscale && boost::iequals(*option_upscale, "NearestNeighbor")) m_output_settings.upscale_filter = LatteTextureView::MagFilter::kNearestNeighbor; - auto option_downscale = rules.FindOption("NearestNeighbor"); + auto option_downscale = rules.FindOption("downscaleMinFilter"); if (option_downscale && boost::iequals(*option_downscale, "NearestNeighbor")) m_output_settings.downscale_filter = LatteTextureView::MagFilter::kNearestNeighbor; } diff --git a/src/Cafe/HW/Latte/Core/LatteRenderTarget.cpp b/src/Cafe/HW/Latte/Core/LatteRenderTarget.cpp index ca6a2a4d..3bb6c7e3 100644 --- a/src/Cafe/HW/Latte/Core/LatteRenderTarget.cpp +++ b/src/Cafe/HW/Latte/Core/LatteRenderTarget.cpp @@ -933,13 +933,6 @@ void LatteRenderTarget_copyToBackbuffer(LatteTextureView* textureView, bool isPa if (shader == nullptr) { sint32 scaling_filter = downscaling ? GetConfig().downscale_filter : GetConfig().upscale_filter; - - if (g_renderer->GetType() == RendererAPI::Vulkan) - { - // force linear or nearest neighbor filter - if(scaling_filter != kLinearFilter && scaling_filter != kNearestNeighborFilter) - scaling_filter = kLinearFilter; - } if (scaling_filter == kLinearFilter) { @@ -957,7 +950,7 @@ void LatteRenderTarget_copyToBackbuffer(LatteTextureView* textureView, bool isPa else shader = RendererOutputShader::s_bicubic_shader; - filter = LatteTextureView::MagFilter::kNearestNeighbor; + filter = LatteTextureView::MagFilter::kLinear; } else if (scaling_filter == kBicubicHermiteFilter) { diff --git a/src/Cafe/HW/Latte/Renderer/OpenGL/OpenGLRenderer.cpp b/src/Cafe/HW/Latte/Renderer/OpenGL/OpenGLRenderer.cpp index cf134a5d..bbf988bc 100644 --- a/src/Cafe/HW/Latte/Renderer/OpenGL/OpenGLRenderer.cpp +++ b/src/Cafe/HW/Latte/Renderer/OpenGL/OpenGLRenderer.cpp @@ -570,13 +570,10 @@ void OpenGLRenderer::DrawBackbufferQuad(LatteTextureView* texView, RendererOutpu g_renderer->ClearColorbuffer(padView); } - sint32 effectiveWidth, effectiveHeight; - texView->baseTexture->GetEffectiveSize(effectiveWidth, effectiveHeight, 0); - shader_unbind(RendererShader::ShaderType::kGeometry); shader_bind(shader->GetVertexShader()); shader_bind(shader->GetFragmentShader()); - shader->SetUniformParameters(*texView, { effectiveWidth, effectiveHeight }, { imageWidth, imageHeight }); + shader->SetUniformParameters(*texView, {imageWidth, imageHeight}); // set viewport glViewportIndexedf(0, imageX, imageY, imageWidth, imageHeight); @@ -584,6 +581,12 @@ void OpenGLRenderer::DrawBackbufferQuad(LatteTextureView* texView, RendererOutpu LatteTextureViewGL* texViewGL = (LatteTextureViewGL*)texView; texture_bindAndActivate(texView, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + texViewGL->samplerState.clampS = texViewGL->samplerState.clampT = 0xFF; + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, useLinearTexFilter ? GL_LINEAR : GL_NEAREST); + texViewGL->samplerState.filterMin = 0xFFFFFFFF; glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, useLinearTexFilter ? GL_LINEAR : GL_NEAREST); texViewGL->samplerState.filterMag = 0xFFFFFFFF; diff --git a/src/Cafe/HW/Latte/Renderer/RendererOuputShader.cpp b/src/Cafe/HW/Latte/Renderer/RendererOuputShader.cpp index cdbeb3f3..409dc24f 100644 --- a/src/Cafe/HW/Latte/Renderer/RendererOuputShader.cpp +++ b/src/Cafe/HW/Latte/Renderer/RendererOuputShader.cpp @@ -2,18 +2,7 @@ #include "Cafe/HW/Latte/Renderer/OpenGL/OpenGLRenderer.h" const std::string RendererOutputShader::s_copy_shader_source = -R"(#version 420 - -#ifdef VULKAN -layout(location = 0) in vec2 passUV; -layout(binding = 0) uniform sampler2D textureSrc; -layout(location = 0) out vec4 colorOut0; -#else -in vec2 passUV; -layout(binding=0) uniform sampler2D textureSrc; -layout(location = 0) out vec4 colorOut0; -#endif - +R"( void main() { colorOut0 = vec4(texture(textureSrc, passUV).rgb,1.0); @@ -22,20 +11,6 @@ void main() const std::string RendererOutputShader::s_bicubic_shader_source = R"( -#version 420 - -#ifdef VULKAN -layout(location = 0) in vec2 passUV; -layout(binding = 0) uniform sampler2D textureSrc; -layout(binding = 1) uniform vec2 textureSrcResolution; -layout(location = 0) out vec4 colorOut0; -#else -in vec2 passUV; -layout(binding=0) uniform sampler2D textureSrc; -uniform vec2 textureSrcResolution; -layout(location = 0) out vec4 colorOut0; -#endif - vec4 cubic(float x) { float x2 = x * x; @@ -48,24 +23,23 @@ vec4 cubic(float x) return w / 6.0; } -vec4 bcFilter(vec2 texcoord, vec2 texscale) +vec4 bcFilter(vec2 uv, vec4 texelSize) { - float fx = fract(texcoord.x); - float fy = fract(texcoord.y); - texcoord.x -= fx; - texcoord.y -= fy; + vec2 pixel = uv*texelSize.zw - 0.5; + vec2 pixelFrac = fract(pixel); + vec2 pixelInt = pixel - pixelFrac; - vec4 xcubic = cubic(fx); - vec4 ycubic = cubic(fy); + vec4 xcubic = cubic(pixelFrac.x); + vec4 ycubic = cubic(pixelFrac.y); - vec4 c = vec4(texcoord.x - 0.5, texcoord.x + 1.5, texcoord.y - 0.5, texcoord.y + 1.5); + vec4 c = vec4(pixelInt.x - 0.5, pixelInt.x + 1.5, pixelInt.y - 0.5, pixelInt.y + 1.5); vec4 s = vec4(xcubic.x + xcubic.y, xcubic.z + xcubic.w, ycubic.x + ycubic.y, ycubic.z + ycubic.w); vec4 offset = c + vec4(xcubic.y, xcubic.w, ycubic.y, ycubic.w) / s; - vec4 sample0 = texture(textureSrc, vec2(offset.x, offset.z) * texscale); - vec4 sample1 = texture(textureSrc, vec2(offset.y, offset.z) * texscale); - vec4 sample2 = texture(textureSrc, vec2(offset.x, offset.w) * texscale); - vec4 sample3 = texture(textureSrc, vec2(offset.y, offset.w) * texscale); + vec4 sample0 = texture(textureSrc, vec2(offset.x, offset.z) * texelSize.xy); + vec4 sample1 = texture(textureSrc, vec2(offset.y, offset.z) * texelSize.xy); + vec4 sample2 = texture(textureSrc, vec2(offset.x, offset.w) * texelSize.xy); + vec4 sample3 = texture(textureSrc, vec2(offset.y, offset.w) * texelSize.xy); float sx = s.x / (s.x + s.y); float sy = s.z / (s.z + s.w); @@ -76,20 +50,13 @@ vec4 bcFilter(vec2 texcoord, vec2 texscale) } void main(){ - colorOut0 = vec4(bcFilter(passUV*textureSrcResolution, vec2(1.0,1.0)/textureSrcResolution).rgb,1.0); + vec4 texelSize = vec4( 1.0 / textureSrcResolution.xy, textureSrcResolution.xy); + colorOut0 = vec4(bcFilter(passUV, texelSize).rgb,1.0); } )"; const std::string RendererOutputShader::s_hermite_shader_source = -R"(#version 420 - -in vec4 gl_FragCoord; -in vec2 passUV; -layout(binding=0) uniform sampler2D textureSrc; -uniform vec2 textureSrcResolution; -uniform vec2 outputResolution; -layout(location = 0) out vec4 colorOut0; - +R"( // https://www.shadertoy.com/view/MllSzX vec3 CubicHermite (vec3 A, vec3 B, vec3 C, vec3 D, float t) @@ -111,7 +78,7 @@ vec3 BicubicHermiteTexture(vec2 uv, vec4 texelSize) vec2 frac = fract(pixel); pixel = floor(pixel) / texelSize.zw - vec2(texelSize.xy/2.0); - vec4 doubleSize = texelSize*texelSize; + vec4 doubleSize = texelSize*2.0; vec3 C00 = texture(textureSrc, pixel + vec2(-texelSize.x ,-texelSize.y)).rgb; vec3 C10 = texture(textureSrc, pixel + vec2( 0.0 ,-texelSize.y)).rgb; @@ -142,15 +109,17 @@ vec3 BicubicHermiteTexture(vec2 uv, vec4 texelSize) } void main(){ - vec4 texelSize = vec4( 1.0 / outputResolution.xy, outputResolution.xy); + vec4 texelSize = vec4( 1.0 / textureSrcResolution.xy, textureSrcResolution.xy); colorOut0 = vec4(BicubicHermiteTexture(passUV, texelSize), 1.0); } )"; RendererOutputShader::RendererOutputShader(const std::string& vertex_source, const std::string& fragment_source) { + auto finalFragmentSrc = PrependFragmentPreamble(fragment_source); + m_vertex_shader = g_renderer->shader_create(RendererShader::ShaderType::kVertex, 0, 0, vertex_source, false, false); - m_fragment_shader = g_renderer->shader_create(RendererShader::ShaderType::kFragment, 0, 0, fragment_source, false, false); + m_fragment_shader = g_renderer->shader_create(RendererShader::ShaderType::kFragment, 0, 0, finalFragmentSrc, false, false); m_vertex_shader->PreponeCompilation(true); m_fragment_shader->PreponeCompilation(true); @@ -163,74 +132,45 @@ RendererOutputShader::RendererOutputShader(const std::string& vertex_source, con if (g_renderer->GetType() == RendererAPI::OpenGL) { - m_attributes[0].m_loc_texture_src_resolution = m_vertex_shader->GetUniformLocation("textureSrcResolution"); - m_attributes[0].m_loc_input_resolution = m_vertex_shader->GetUniformLocation("inputResolution"); - m_attributes[0].m_loc_output_resolution = m_vertex_shader->GetUniformLocation("outputResolution"); + m_uniformLocations[0].m_loc_textureSrcResolution = m_vertex_shader->GetUniformLocation("textureSrcResolution"); + m_uniformLocations[0].m_loc_nativeResolution = m_vertex_shader->GetUniformLocation("nativeResolution"); + m_uniformLocations[0].m_loc_outputResolution = m_vertex_shader->GetUniformLocation("outputResolution"); - m_attributes[1].m_loc_texture_src_resolution = m_fragment_shader->GetUniformLocation("textureSrcResolution"); - m_attributes[1].m_loc_input_resolution = m_fragment_shader->GetUniformLocation("inputResolution"); - m_attributes[1].m_loc_output_resolution = m_fragment_shader->GetUniformLocation("outputResolution"); + m_uniformLocations[1].m_loc_textureSrcResolution = m_fragment_shader->GetUniformLocation("textureSrcResolution"); + m_uniformLocations[1].m_loc_nativeResolution = m_fragment_shader->GetUniformLocation("nativeResolution"); + m_uniformLocations[1].m_loc_outputResolution = m_fragment_shader->GetUniformLocation("outputResolution"); } - else - { - cemuLog_logDebug(LogType::Force, "RendererOutputShader() - todo for Vulkan"); - m_attributes[0].m_loc_texture_src_resolution = -1; - m_attributes[0].m_loc_input_resolution = -1; - m_attributes[0].m_loc_output_resolution = -1; - - m_attributes[1].m_loc_texture_src_resolution = -1; - m_attributes[1].m_loc_input_resolution = -1; - m_attributes[1].m_loc_output_resolution = -1; - } - } -void RendererOutputShader::SetUniformParameters(const LatteTextureView& texture_view, const Vector2i& input_res, const Vector2i& output_res) const +void RendererOutputShader::SetUniformParameters(const LatteTextureView& texture_view, const Vector2i& output_res) const { - float res[2]; - // vertex shader - if (m_attributes[0].m_loc_texture_src_resolution != -1) - { - res[0] = (float)texture_view.baseTexture->width; - res[1] = (float)texture_view.baseTexture->height; - m_vertex_shader->SetUniform2fv(m_attributes[0].m_loc_texture_src_resolution, res, 1); - } + sint32 effectiveWidth, effectiveHeight; + texture_view.baseTexture->GetEffectiveSize(effectiveWidth, effectiveHeight, 0); + auto setUniforms = [&](RendererShader* shader, const UniformLocations& locations){ + float res[2]; + if (locations.m_loc_textureSrcResolution != -1) + { + res[0] = (float)effectiveWidth; + res[1] = (float)effectiveHeight; + shader->SetUniform2fv(locations.m_loc_textureSrcResolution, res, 1); + } - if (m_attributes[0].m_loc_input_resolution != -1) - { - res[0] = (float)input_res.x; - res[1] = (float)input_res.y; - m_vertex_shader->SetUniform2fv(m_attributes[0].m_loc_input_resolution, res, 1); - } + if (locations.m_loc_nativeResolution != -1) + { + res[0] = (float)texture_view.baseTexture->width; + res[1] = (float)texture_view.baseTexture->height; + shader->SetUniform2fv(locations.m_loc_nativeResolution, res, 1); + } - if (m_attributes[0].m_loc_output_resolution != -1) - { - res[0] = (float)output_res.x; - res[1] = (float)output_res.y; - m_vertex_shader->SetUniform2fv(m_attributes[0].m_loc_output_resolution, res, 1); - } - - // fragment shader - if (m_attributes[1].m_loc_texture_src_resolution != -1) - { - res[0] = (float)texture_view.baseTexture->width; - res[1] = (float)texture_view.baseTexture->height; - m_fragment_shader->SetUniform2fv(m_attributes[1].m_loc_texture_src_resolution, res, 1); - } - - if (m_attributes[1].m_loc_input_resolution != -1) - { - res[0] = (float)input_res.x; - res[1] = (float)input_res.y; - m_fragment_shader->SetUniform2fv(m_attributes[1].m_loc_input_resolution, res, 1); - } - - if (m_attributes[1].m_loc_output_resolution != -1) - { - res[0] = (float)output_res.x; - res[1] = (float)output_res.y; - m_fragment_shader->SetUniform2fv(m_attributes[1].m_loc_output_resolution, res, 1); - } + if (locations.m_loc_outputResolution != -1) + { + res[0] = (float)output_res.x; + res[1] = (float)output_res.y; + shader->SetUniform2fv(locations.m_loc_outputResolution, res, 1); + } + }; + setUniforms(m_vertex_shader, m_uniformLocations[0]); + setUniforms(m_fragment_shader, m_uniformLocations[1]); } RendererOutputShader* RendererOutputShader::s_copy_shader; @@ -341,6 +281,27 @@ void main(){ )"; return vertex_source.str(); } + +std::string RendererOutputShader::PrependFragmentPreamble(const std::string& shaderSrc) +{ + return R"(#version 430 +#ifdef VULKAN +layout(push_constant) uniform pc { + vec2 textureSrcResolution; + vec2 nativeResolution; + vec2 outputResolution; +}; +#else +uniform vec2 textureSrcResolution; +uniform vec2 nativeResolution; +uniform vec2 outputResolution; +#endif + +layout(location = 0) in vec2 passUV; +layout(binding = 0) uniform sampler2D textureSrc; +layout(location = 0) out vec4 colorOut0; +)" + shaderSrc; +} void RendererOutputShader::InitializeStatic() { std::string vertex_source, vertex_source_ud; @@ -349,28 +310,18 @@ void RendererOutputShader::InitializeStatic() { vertex_source = GetOpenGlVertexSource(false); vertex_source_ud = GetOpenGlVertexSource(true); - - s_copy_shader = new RendererOutputShader(vertex_source, s_copy_shader_source); - s_copy_shader_ud = new RendererOutputShader(vertex_source_ud, s_copy_shader_source); - - s_bicubic_shader = new RendererOutputShader(vertex_source, s_bicubic_shader_source); - s_bicubic_shader_ud = new RendererOutputShader(vertex_source_ud, s_bicubic_shader_source); - - s_hermit_shader = new RendererOutputShader(vertex_source, s_hermite_shader_source); - s_hermit_shader_ud = new RendererOutputShader(vertex_source_ud, s_hermite_shader_source); } else { vertex_source = GetVulkanVertexSource(false); vertex_source_ud = GetVulkanVertexSource(true); - - s_copy_shader = new RendererOutputShader(vertex_source, s_copy_shader_source); - s_copy_shader_ud = new RendererOutputShader(vertex_source_ud, s_copy_shader_source); - - /* s_bicubic_shader = new RendererOutputShader(vertex_source, s_bicubic_shader_source); TODO - s_bicubic_shader_ud = new RendererOutputShader(vertex_source_ud, s_bicubic_shader_source); - - s_hermit_shader = new RendererOutputShader(vertex_source, s_hermite_shader_source); - s_hermit_shader_ud = new RendererOutputShader(vertex_source_ud, s_hermite_shader_source);*/ } + s_copy_shader = new RendererOutputShader(vertex_source, s_copy_shader_source); + s_copy_shader_ud = new RendererOutputShader(vertex_source_ud, s_copy_shader_source); + + s_bicubic_shader = new RendererOutputShader(vertex_source, s_bicubic_shader_source); + s_bicubic_shader_ud = new RendererOutputShader(vertex_source_ud, s_bicubic_shader_source); + + s_hermit_shader = new RendererOutputShader(vertex_source, s_hermite_shader_source); + s_hermit_shader_ud = new RendererOutputShader(vertex_source_ud, s_hermite_shader_source); } diff --git a/src/Cafe/HW/Latte/Renderer/RendererOuputShader.h b/src/Cafe/HW/Latte/Renderer/RendererOuputShader.h index 398ac663..61b24c20 100644 --- a/src/Cafe/HW/Latte/Renderer/RendererOuputShader.h +++ b/src/Cafe/HW/Latte/Renderer/RendererOuputShader.h @@ -17,7 +17,7 @@ public: RendererOutputShader(const std::string& vertex_source, const std::string& fragment_source); virtual ~RendererOutputShader() = default; - void SetUniformParameters(const LatteTextureView& texture_view, const Vector2i& input_res, const Vector2i& output_res) const; + void SetUniformParameters(const LatteTextureView& texture_view, const Vector2i& output_res) const; RendererShader* GetVertexShader() const { @@ -43,16 +43,18 @@ public: static std::string GetVulkanVertexSource(bool render_upside_down); static std::string GetOpenGlVertexSource(bool render_upside_down); + static std::string PrependFragmentPreamble(const std::string& shaderSrc); + protected: RendererShader* m_vertex_shader; RendererShader* m_fragment_shader; - struct + struct UniformLocations { - sint32 m_loc_texture_src_resolution = -1; - sint32 m_loc_input_resolution = -1; - sint32 m_loc_output_resolution = -1; - } m_attributes[2]{}; + sint32 m_loc_textureSrcResolution = -1; + sint32 m_loc_nativeResolution = -1; + sint32 m_loc_outputResolution = -1; + } m_uniformLocations[2]{}; private: static const std::string s_copy_shader_source; diff --git a/src/Cafe/HW/Latte/Renderer/Vulkan/LatteTextureViewVk.cpp b/src/Cafe/HW/Latte/Renderer/Vulkan/LatteTextureViewVk.cpp index f0e2295e..de76f76d 100644 --- a/src/Cafe/HW/Latte/Renderer/Vulkan/LatteTextureViewVk.cpp +++ b/src/Cafe/HW/Latte/Renderer/Vulkan/LatteTextureViewVk.cpp @@ -202,6 +202,13 @@ VkSampler LatteTextureViewVk::GetDefaultTextureSampler(bool useLinearTexFilter) VkSamplerCreateInfo samplerInfo{}; samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + // emulate OpenGL minFilters + // see note under: https://docs.vulkan.org/spec/latest/chapters/samplers.html#VkSamplerCreateInfo + // if maxLod = 0 then magnification is always performed + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + samplerInfo.minLod = 0.0f; + samplerInfo.maxLod = 0.25f; + if (useLinearTexFilter) { samplerInfo.magFilter = VK_FILTER_LINEAR; @@ -212,6 +219,9 @@ VkSampler LatteTextureViewVk::GetDefaultTextureSampler(bool useLinearTexFilter) samplerInfo.magFilter = VK_FILTER_NEAREST; samplerInfo.minFilter = VK_FILTER_NEAREST; } + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; if (vkCreateSampler(m_device, &samplerInfo, nullptr, &sampler) != VK_SUCCESS) { diff --git a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp index 9ad2c5ca..37432eeb 100644 --- a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp +++ b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp @@ -2581,10 +2581,18 @@ VkPipeline VulkanRenderer::backbufferBlit_createGraphicsPipeline(VkDescriptorSet colorBlending.blendConstants[2] = 0.0f; colorBlending.blendConstants[3] = 0.0f; + VkPushConstantRange pushConstantRange{ + .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT, + .offset = 0, + .size = 3 * sizeof(float) * 2 // 3 vec2's + }; + VkPipelineLayoutCreateInfo pipelineLayoutInfo{}; pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; pipelineLayoutInfo.setLayoutCount = 1; pipelineLayoutInfo.pSetLayouts = &descriptorLayout; + pipelineLayoutInfo.pushConstantRangeCount = 1; + pipelineLayoutInfo.pPushConstantRanges = &pushConstantRange; VkResult result = vkCreatePipelineLayout(m_logicalDevice, &pipelineLayoutInfo, nullptr, &m_pipelineLayout); if (result != VK_SUCCESS) @@ -2956,6 +2964,25 @@ void VulkanRenderer::DrawBackbufferQuad(LatteTextureView* texView, RendererOutpu vkCmdBindDescriptorSets(m_state.currentCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, m_pipelineLayout, 0, 1, &descriptSet, 0, nullptr); + // update push constants + Vector2f pushData[3]; + + // textureSrcResolution + sint32 effectiveWidth, effectiveHeight; + texView->baseTexture->GetEffectiveSize(effectiveWidth, effectiveHeight, 0); + pushData[0] = {(float)effectiveWidth, (float)effectiveHeight}; + + // nativeResolution + pushData[1] = { + (float)texViewVk->baseTexture->width, + (float)texViewVk->baseTexture->height, + }; + + // outputResolution + pushData[2] = {(float)imageWidth,(float)imageHeight}; + + vkCmdPushConstants(m_state.currentCommandBuffer, m_pipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(float) * 2 * 3, &pushData); + vkCmdDraw(m_state.currentCommandBuffer, 6, 1, 0, 0); vkCmdEndRenderPass(m_state.currentCommandBuffer); From 2065ac5f635e48a6bf01ac480236783f46bcf0de Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sat, 16 Nov 2024 12:51:58 +0100 Subject: [PATCH 13/50] GfxPack: Better logging messages for diagnosing problems in rules.txt --- src/Cafe/GraphicPack/GraphicPack2.cpp | 42 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/Cafe/GraphicPack/GraphicPack2.cpp b/src/Cafe/GraphicPack/GraphicPack2.cpp index 4b6cb095..f21bb89d 100644 --- a/src/Cafe/GraphicPack/GraphicPack2.cpp +++ b/src/Cafe/GraphicPack/GraphicPack2.cpp @@ -345,7 +345,7 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) const auto preset_name = rules.FindOption("name"); if (!preset_name) { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": Preset in line {} skipped because it has no name option defined", m_name, rules.GetCurrentSectionLineNumber()); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": Preset in line {} skipped because it has no name option defined", GetNormalizedPathString(), rules.GetCurrentSectionLineNumber()); continue; } @@ -369,7 +369,7 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) } catch (const std::exception & ex) { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": Can't parse preset \"{}\": {}", m_name, *preset_name, ex.what()); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": Can't parse preset \"{}\": {}", GetNormalizedPathString(), *preset_name, ex.what()); } } else if (boost::iequals(currentSectionName, "RAM")) @@ -383,7 +383,7 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) { if (m_version <= 5) { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": [RAM] options are only available for graphic pack version 6 or higher", m_name, optionNameBuf); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": [RAM] options are only available for graphic pack version 6 or higher", GetNormalizedPathString(), optionNameBuf); throw std::exception(); } @@ -393,12 +393,12 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) { if (addrEnd <= addrStart) { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": start address (0x{:08x}) must be greater than end address (0x{:08x}) for {}", m_name, addrStart, addrEnd, optionNameBuf); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": start address (0x{:08x}) must be greater than end address (0x{:08x}) for {}", GetNormalizedPathString(), addrStart, addrEnd, optionNameBuf); throw std::exception(); } else if ((addrStart & 0xFFF) != 0 || (addrEnd & 0xFFF) != 0) { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": addresses for %s are not aligned to 0x1000", m_name, optionNameBuf); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": addresses for %s are not aligned to 0x1000", GetNormalizedPathString(), optionNameBuf); throw std::exception(); } else @@ -408,7 +408,7 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) } else { - cemuLog_log(LogType::Force, "Graphic pack \"{}\": has invalid syntax for option {}", m_name, optionNameBuf); + cemuLog_log(LogType::Force, "Graphic pack \"{}\": has invalid syntax for option {}", GetNormalizedPathString(), optionNameBuf); throw std::exception(); } } @@ -422,24 +422,32 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) std::unordered_map> tmp_map; // all vars must be defined in the default preset vars before - for (const auto& entry : m_presets) + std::vector> mismatchingPresetVars; + for (const auto& presetEntry : m_presets) { - tmp_map[entry->category].emplace_back(entry); + tmp_map[presetEntry->category].emplace_back(presetEntry); - for (auto& kv : entry->variables) + for (auto& presetVar : presetEntry->variables) { - const auto it = m_preset_vars.find(kv.first); + const auto it = m_preset_vars.find(presetVar.first); if (it == m_preset_vars.cend()) { - cemuLog_log(LogType::Force, "Graphic pack: \"{}\" contains preset variables which are not defined in the default section", m_name); - throw std::exception(); + mismatchingPresetVars.emplace_back(presetEntry->name, presetVar.first); + continue; } - // overwrite var type with default var type - kv.second.first = it->second.first; + presetVar.second.first = it->second.first; } } + if(!mismatchingPresetVars.empty()) + { + cemuLog_log(LogType::Force, "Graphic pack \"{}\" contains preset variables which are not defined in the [Default] section:", GetNormalizedPathString()); + for (const auto& [presetName, varName] : mismatchingPresetVars) + cemuLog_log(LogType::Force, "Preset: {} Variable: {}", presetName, varName); + throw std::exception(); + } + // have first entry be default active for every category if no default= is set for(auto entry : get_values(tmp_map)) { @@ -469,7 +477,7 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) auto& p2 = kv.second[i + 1]; if (p1->variables.size() != p2->variables.size()) { - cemuLog_log(LogType::Force, "Graphic pack: \"{}\" contains inconsistent preset variables", m_name); + cemuLog_log(LogType::Force, "Graphic pack: \"{}\" contains inconsistent preset variables", GetNormalizedPathString()); throw std::exception(); } @@ -477,14 +485,14 @@ GraphicPack2::GraphicPack2(fs::path rulesPath, IniParser& rules) std::set keys2(get_keys(p2->variables).begin(), get_keys(p2->variables).end()); if (keys1 != keys2) { - cemuLog_log(LogType::Force, "Graphic pack: \"{}\" contains inconsistent preset variables", m_name); + cemuLog_log(LogType::Force, "Graphic pack: \"{}\" contains inconsistent preset variables", GetNormalizedPathString()); throw std::exception(); } if(p1->is_default) { if(has_default) - cemuLog_log(LogType::Force, "Graphic pack: \"{}\" has more than one preset with the default key set for the same category \"{}\"", m_name, p1->name); + cemuLog_log(LogType::Force, "Graphic pack: \"{}\" has more than one preset with the default key set for the same category \"{}\"", GetNormalizedPathString(), p1->name); p1->active = true; has_default = true; } From c3e29fb6199cfd297a86dd0f6bc0d92fe7dab3de Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sat, 16 Nov 2024 13:45:33 +0100 Subject: [PATCH 14/50] Latte: Add support for shader instructions MIN_UINT and MAX_UINT Seen in the eShop version of Fatal Frame Also made some warnings less spammy since this game seems to trigger it a lot --- .../LatteDecompiler.cpp | 2 ++ .../LatteDecompilerAnalyzer.cpp | 2 ++ .../LatteDecompilerEmitGLSL.cpp | 20 +++++++++++-------- .../LatteDecompilerInstructions.h | 2 ++ src/Cafe/OS/libs/gx2/GX2_Shader.cpp | 2 +- src/input/emulated/VPADController.cpp | 2 +- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompiler.cpp b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompiler.cpp index c3f7c19e..5972aacc 100644 --- a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompiler.cpp +++ b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompiler.cpp @@ -370,6 +370,8 @@ bool LatteDecompiler_IsALUTransInstruction(bool isOP3, uint32 opcode) opcode == ALU_OP2_INST_LSHR_INT || opcode == ALU_OP2_INST_MAX_INT || opcode == ALU_OP2_INST_MIN_INT || + opcode == ALU_OP2_INST_MAX_UINT || + opcode == ALU_OP2_INST_MIN_UINT || opcode == ALU_OP2_INST_MOVA_FLOOR || opcode == ALU_OP2_INST_MOVA_INT || opcode == ALU_OP2_INST_SETE_DX10 || diff --git a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerAnalyzer.cpp b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerAnalyzer.cpp index 19604e0c..ff64988c 100644 --- a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerAnalyzer.cpp +++ b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerAnalyzer.cpp @@ -140,6 +140,8 @@ bool _isIntegerInstruction(const LatteDecompilerALUInstruction& aluInstruction) case ALU_OP2_INST_SUB_INT: case ALU_OP2_INST_MAX_INT: case ALU_OP2_INST_MIN_INT: + case ALU_OP2_INST_MAX_UINT: + case ALU_OP2_INST_MIN_UINT: case ALU_OP2_INST_SETE_INT: case ALU_OP2_INST_SETGT_INT: case ALU_OP2_INST_SETGE_INT: diff --git a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerEmitGLSL.cpp b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerEmitGLSL.cpp index 7a6605f8..e7ebcf3a 100644 --- a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerEmitGLSL.cpp +++ b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerEmitGLSL.cpp @@ -1415,19 +1415,23 @@ void _emitALUOP2InstructionCode(LatteDecompilerShaderContext* shaderContext, Lat } else if( aluInstruction->opcode == ALU_OP2_INST_ADD_INT ) _emitALUOperationBinary(shaderContext, aluInstruction, " + "); - else if( aluInstruction->opcode == ALU_OP2_INST_MAX_INT || aluInstruction->opcode == ALU_OP2_INST_MIN_INT ) + else if( aluInstruction->opcode == ALU_OP2_INST_MAX_INT || aluInstruction->opcode == ALU_OP2_INST_MIN_INT || + aluInstruction->opcode == ALU_OP2_INST_MAX_UINT || aluInstruction->opcode == ALU_OP2_INST_MIN_UINT) { // not verified + bool isUnsigned = aluInstruction->opcode == ALU_OP2_INST_MAX_UINT || aluInstruction->opcode == ALU_OP2_INST_MIN_UINT; + auto opType = isUnsigned ? LATTE_DECOMPILER_DTYPE_UNSIGNED_INT : LATTE_DECOMPILER_DTYPE_SIGNED_INT; _emitInstructionOutputVariableName(shaderContext, aluInstruction); - if( aluInstruction->opcode == ALU_OP2_INST_MAX_INT ) - src->add(" = max("); + src->add(" = "); + _emitTypeConversionPrefix(shaderContext, opType, outputType); + if( aluInstruction->opcode == ALU_OP2_INST_MAX_INT || aluInstruction->opcode == ALU_OP2_INST_MAX_UINT ) + src->add("max("); else - src->add(" = min("); - _emitTypeConversionPrefix(shaderContext, LATTE_DECOMPILER_DTYPE_SIGNED_INT, outputType); - _emitOperandInputCode(shaderContext, aluInstruction, 0, LATTE_DECOMPILER_DTYPE_SIGNED_INT); + src->add("min("); + _emitOperandInputCode(shaderContext, aluInstruction, 0, opType); src->add(", "); - _emitOperandInputCode(shaderContext, aluInstruction, 1, LATTE_DECOMPILER_DTYPE_SIGNED_INT); - _emitTypeConversionSuffix(shaderContext, LATTE_DECOMPILER_DTYPE_SIGNED_INT, outputType); + _emitOperandInputCode(shaderContext, aluInstruction, 1, opType); + _emitTypeConversionSuffix(shaderContext, opType, outputType); src->add(");" _CRLF); } else if( aluInstruction->opcode == ALU_OP2_INST_SUB_INT ) diff --git a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerInstructions.h b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerInstructions.h index 4cb1982e..6c029b46 100644 --- a/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerInstructions.h +++ b/src/Cafe/HW/Latte/LegacyShaderDecompiler/LatteDecompilerInstructions.h @@ -60,6 +60,8 @@ #define ALU_OP2_INST_SUB_INT (0x035) // integer instruction #define ALU_OP2_INST_MAX_INT (0x036) // integer instruction #define ALU_OP2_INST_MIN_INT (0x037) // integer instruction +#define ALU_OP2_INST_MAX_UINT (0x038) // integer instruction +#define ALU_OP2_INST_MIN_UINT (0x039) // integer instruction #define ALU_OP2_INST_SETE_INT (0x03A) // integer instruction #define ALU_OP2_INST_SETGT_INT (0x03B) // integer instruction #define ALU_OP2_INST_SETGE_INT (0x03C) // integer instruction diff --git a/src/Cafe/OS/libs/gx2/GX2_Shader.cpp b/src/Cafe/OS/libs/gx2/GX2_Shader.cpp index dfbbfcff..7a153737 100644 --- a/src/Cafe/OS/libs/gx2/GX2_Shader.cpp +++ b/src/Cafe/OS/libs/gx2/GX2_Shader.cpp @@ -421,7 +421,7 @@ namespace GX2 { if(aluRegisterOffset&0x8000) { - cemuLog_logDebug(LogType::Force, "_GX2SubmitUniformReg(): Unhandled loop const special case or invalid offset"); + cemuLog_logDebugOnce(LogType::Force, "_GX2SubmitUniformReg(): Unhandled loop const special case or invalid offset"); return; } if((aluRegisterOffset+sizeInU32s) > 0x400) diff --git a/src/input/emulated/VPADController.cpp b/src/input/emulated/VPADController.cpp index f1ab1bc4..81615c9b 100644 --- a/src/input/emulated/VPADController.cpp +++ b/src/input/emulated/VPADController.cpp @@ -408,7 +408,7 @@ bool VPADController::push_rumble(uint8* pattern, uint8 length) std::scoped_lock lock(m_rumble_mutex); if (m_rumble_queue.size() >= 5) { - cemuLog_logDebug(LogType::Force, "too many cmds"); + cemuLog_logDebugOnce(LogType::Force, "VPADControlMotor(): Pattern too long"); return false; } From 7b513f1744a7952abf2fb927c27b2c09a2c992f4 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sun, 17 Nov 2024 09:52:44 +0100 Subject: [PATCH 15/50] Latte: Add workaround for infinite loop in Fatal Frame shaders --- src/config/ActiveSettings.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config/ActiveSettings.cpp b/src/config/ActiveSettings.cpp index 560f2986..f81f8336 100644 --- a/src/config/ActiveSettings.cpp +++ b/src/config/ActiveSettings.cpp @@ -198,14 +198,20 @@ bool ActiveSettings::ShaderPreventInfiniteLoopsEnabled() { const uint64 titleId = CafeSystem::GetForegroundTitleId(); // workaround for NSMBU (and variants) having a bug where shaders can get stuck in infinite loops - // update: As of Cemu 1.20.0 this should no longer be required + // Fatal Frame has an actual infinite loop in shader 0xb6a67c19f6472e00 encountered during a cutscene for the second drop (eShop version only?) + // update: As of Cemu 1.20.0 this should no longer be required for NSMBU/NSLU due to fixes with uniform handling. But we leave it here for good measure + // todo - Once we add support for loop config registers this workaround should become unnecessary return /* NSMBU JP */ titleId == 0x0005000010101C00 || /* NSMBU US */ titleId == 0x0005000010101D00 || /* NSMBU EU */ titleId == 0x0005000010101E00 || /* NSMBU+L US */ titleId == 0x000500001014B700 || /* NSMBU+L EU */ titleId == 0x000500001014B800 || /* NSLU US */ titleId == 0x0005000010142300 || - /* NSLU EU */ titleId == 0x0005000010142400; + /* NSLU EU */ titleId == 0x0005000010142400 || + /* Project Zero: Maiden of Black Water (EU) */ titleId == 0x00050000101D0300 || + /* Fatal Frame: Maiden of Black Water (US) */ titleId == 0x00050000101D0600 || + /* Project Zero: Maiden of Black Water (JP) */ titleId == 0x000500001014D200 || + /* Project Zero: Maiden of Black Water (Trial, EU) */ titleId == 0x00050000101D3F00; } bool ActiveSettings::FlushGPUCacheOnSwap() From 409f12b13a2d67f18465c2920ab04a4d5ab27ae6 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sun, 17 Nov 2024 10:38:39 +0100 Subject: [PATCH 16/50] coreinit: Fix calculation of thread total awake time --- src/Cafe/OS/libs/coreinit/coreinit_Thread.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cafe/OS/libs/coreinit/coreinit_Thread.cpp b/src/Cafe/OS/libs/coreinit/coreinit_Thread.cpp index 2f3808b7..db457047 100644 --- a/src/Cafe/OS/libs/coreinit/coreinit_Thread.cpp +++ b/src/Cafe/OS/libs/coreinit/coreinit_Thread.cpp @@ -1114,13 +1114,13 @@ namespace coreinit thread->requestFlags = (OSThread_t::REQUEST_FLAG_BIT)(thread->requestFlags & OSThread_t::REQUEST_FLAG_CANCEL); // remove all flags except cancel flag // update total cycles - uint64 remainingCycles = std::min((uint64)hCPU->remainingCycles, (uint64)thread->quantumTicks); - uint64 executedCycles = thread->quantumTicks - remainingCycles; - if (executedCycles < hCPU->skippedCycles) + sint64 executedCycles = (sint64)thread->quantumTicks - (sint64)hCPU->remainingCycles; + executedCycles = std::max(executedCycles, 0); + if (executedCycles < (sint64)hCPU->skippedCycles) executedCycles = 0; else executedCycles -= hCPU->skippedCycles; - thread->totalCycles += executedCycles; + thread->totalCycles += (uint64)executedCycles; // store context and set current thread to null __OSThreadStoreContext(hCPU, thread); OSSetCurrentThread(OSGetCoreId(), nullptr); From 90eb2e01f405e502ed33fea330352e167c4a14db Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Fri, 22 Nov 2024 12:43:12 +0000 Subject: [PATCH 17/50] nsyshid/dimensions: add missing return (#1425) --- src/Cafe/OS/libs/nsyshid/Dimensions.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cafe/OS/libs/nsyshid/Dimensions.cpp b/src/Cafe/OS/libs/nsyshid/Dimensions.cpp index f328dde7..8a2acc76 100644 --- a/src/Cafe/OS/libs/nsyshid/Dimensions.cpp +++ b/src/Cafe/OS/libs/nsyshid/Dimensions.cpp @@ -675,6 +675,7 @@ namespace nsyshid figureChangeResponse[13] = GenerateChecksum(figureChangeResponse, 13); m_figureAddedRemovedResponses.push(figureChangeResponse); + return true; } bool DimensionsUSB::CancelRemove(uint8 index) From 073523768692c1adb994ce2f07d1c29530de41e6 Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Sat, 30 Nov 2024 22:05:50 +0000 Subject: [PATCH 18/50] Input: Move pairing dialog button and source (#1424) --- src/gui/CMakeLists.txt | 4 ++-- src/gui/input/InputSettings2.cpp | 9 +++++++++ src/gui/{ => input}/PairingDialog.cpp | 2 +- src/gui/{ => input}/PairingDialog.h | 0 src/gui/input/panels/WiimoteInputPanel.cpp | 12 ------------ 5 files changed, 12 insertions(+), 15 deletions(-) rename src/gui/{ => input}/PairingDialog.cpp (99%) rename src/gui/{ => input}/PairingDialog.h (100%) diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 02f96a9c..e1a04ec0 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -75,6 +75,8 @@ add_library(CemuGui input/InputAPIAddWindow.h input/InputSettings2.cpp input/InputSettings2.h + input/PairingDialog.cpp + input/PairingDialog.h input/panels/ClassicControllerInputPanel.cpp input/panels/ClassicControllerInputPanel.h input/panels/InputPanel.cpp @@ -97,8 +99,6 @@ add_library(CemuGui MemorySearcherTool.h PadViewFrame.cpp PadViewFrame.h - PairingDialog.cpp - PairingDialog.h TitleManager.cpp TitleManager.h EmulatedUSBDevices/EmulatedUSBDeviceFrame.cpp diff --git a/src/gui/input/InputSettings2.cpp b/src/gui/input/InputSettings2.cpp index 72bf4f7d..2ae8a74b 100644 --- a/src/gui/input/InputSettings2.cpp +++ b/src/gui/input/InputSettings2.cpp @@ -20,6 +20,8 @@ #include "gui/input/InputAPIAddWindow.h" #include "input/ControllerFactory.h" +#include "gui/input/PairingDialog.h" + #include "gui/input/panels/VPADInputPanel.h" #include "gui/input/panels/ProControllerInputPanel.h" @@ -252,6 +254,13 @@ wxWindow* InputSettings2::initialize_page(size_t index) page_data.m_controller_api_remove = remove_api; } + auto* pairingDialog = new wxButton(page, wxID_ANY, _("Pair Wii/Wii U Controller")); + pairingDialog->Bind(wxEVT_BUTTON, [this](wxEvent&) { + PairingDialog pairing_dialog(this); + pairing_dialog.ShowModal(); + }); + sizer->Add(pairingDialog, wxGBPosition(5, 0), wxDefaultSpan, wxALIGN_CENTER_VERTICAL | wxALL, 5); + // controller auto* controller_bttns = new wxBoxSizer(wxHORIZONTAL); auto* settings = new wxButton(page, wxID_ANY, _("Settings"), wxDefaultPosition, wxDefaultSize, 0); diff --git a/src/gui/PairingDialog.cpp b/src/gui/input/PairingDialog.cpp similarity index 99% rename from src/gui/PairingDialog.cpp rename to src/gui/input/PairingDialog.cpp index f90e6d13..03d6315b 100644 --- a/src/gui/PairingDialog.cpp +++ b/src/gui/input/PairingDialog.cpp @@ -1,5 +1,5 @@ #include "gui/wxgui.h" -#include "gui/PairingDialog.h" +#include "PairingDialog.h" #if BOOST_OS_WINDOWS #include diff --git a/src/gui/PairingDialog.h b/src/gui/input/PairingDialog.h similarity index 100% rename from src/gui/PairingDialog.h rename to src/gui/input/PairingDialog.h diff --git a/src/gui/input/panels/WiimoteInputPanel.cpp b/src/gui/input/panels/WiimoteInputPanel.cpp index 050baad1..44a7c001 100644 --- a/src/gui/input/panels/WiimoteInputPanel.cpp +++ b/src/gui/input/panels/WiimoteInputPanel.cpp @@ -12,7 +12,6 @@ #include "input/emulated/WiimoteController.h" #include "gui/helpers/wxHelpers.h" #include "gui/components/wxInputDraw.h" -#include "gui/PairingDialog.h" constexpr WiimoteController::ButtonId g_kFirstColumnItems[] = { @@ -40,11 +39,6 @@ WiimoteInputPanel::WiimoteInputPanel(wxWindow* parent) auto* main_sizer = new wxBoxSizer(wxVERTICAL); auto* horiz_main_sizer = new wxBoxSizer(wxHORIZONTAL); - auto* pair_button = new wxButton(this, wxID_ANY, _("Pair a Wii or Wii U controller")); - pair_button->Bind(wxEVT_BUTTON, &WiimoteInputPanel::on_pair_button, this); - horiz_main_sizer->Add(pair_button); - horiz_main_sizer->AddSpacer(10); - auto* extensions_sizer = new wxBoxSizer(wxHORIZONTAL); horiz_main_sizer->Add(extensions_sizer, wxSizerFlags(0).Align(wxALIGN_CENTER_VERTICAL)); @@ -264,9 +258,3 @@ void WiimoteInputPanel::load_controller(const EmulatedControllerPtr& emulated_co set_active_device_type(wiimote->get_device_type()); } } - -void WiimoteInputPanel::on_pair_button(wxCommandEvent& event) -{ - PairingDialog pairing_dialog(this); - pairing_dialog.ShowModal(); -} From 80a6057512240b14f6aa6ab45ba955e25a1c7acf Mon Sep 17 00:00:00 2001 From: Jeremy Kescher Date: Mon, 2 Dec 2024 01:01:22 +0100 Subject: [PATCH 19/50] build: Fix linker failure with glslang 15.0.0 (#1436) --- src/Cafe/CMakeLists.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Cafe/CMakeLists.txt b/src/Cafe/CMakeLists.txt index 0901fece..76dba007 100644 --- a/src/Cafe/CMakeLists.txt +++ b/src/Cafe/CMakeLists.txt @@ -532,6 +532,12 @@ set_property(TARGET CemuCafe PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$ Date: Sun, 1 Dec 2024 20:19:15 -0800 Subject: [PATCH 20/50] Set version for macOS bundle (#1431) --- src/CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3792ab85..79471321 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -82,8 +82,8 @@ if (MACOS_BUNDLE) set(MACOSX_BUNDLE_ICON_FILE "cemu.icns") set(MACOSX_BUNDLE_GUI_IDENTIFIER "info.cemu.Cemu") set(MACOSX_BUNDLE_BUNDLE_NAME "Cemu") - set(MACOSX_BUNDLE_SHORT_VERSION_STRING ${CMAKE_PROJECT_VERSION}) - set(MACOSX_BUNDLE_BUNDLE_VERSION ${CMAKE_PROJECT_VERSION}) + set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${EMULATOR_VERSION_MAJOR}.${EMULATOR_VERSION_MINOR}.${EMULATOR_VERSION_PATCH}") + set(MACOSX_BUNDLE_BUNDLE_VERSION "${EMULATOR_VERSION_MAJOR}.${EMULATOR_VERSION_MINOR}.${EMULATOR_VERSION_PATCH}") set(MACOSX_BUNDLE_COPYRIGHT "Copyright © 2024 Cemu Project") set(MACOSX_BUNDLE_CATEGORY "public.app-category.games") From 40d9664d1c23a7a1ff6e0197a77a4358032b870a Mon Sep 17 00:00:00 2001 From: Cemu-Language CI Date: Sat, 7 Dec 2024 07:14:20 +0000 Subject: [PATCH 21/50] Update translation files --- bin/resources/ar/â€â€cemu.mo | Bin 0 -> 81849 bytes bin/resources/de/cemu.mo | Bin 65571 -> 69412 bytes bin/resources/ru/cemu.mo | Bin 90752 -> 91888 bytes bin/resources/sv/cemu.mo | Bin 15821 -> 67980 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 bin/resources/ar/â€â€cemu.mo diff --git a/bin/resources/ar/â€â€cemu.mo b/bin/resources/ar/â€â€cemu.mo new file mode 100644 index 0000000000000000000000000000000000000000..4062628be1486a0ffe6a0d2731c6853e4b133880 GIT binary patch literal 81849 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

hKxv~Cc*NycI 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 Lj-3P<*Tet-jKmS1 literal 0 HcmV?d00001 diff --git a/bin/resources/de/cemu.mo b/bin/resources/de/cemu.mo index 8dc4e8cc9ec032d5dcd38af5359b3246398a7e0a..cd9edd3cb981068219006dd4b84921e7b6169b00 100644 GIT binary patch delta 25224 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

EE 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

KaLpFuV= 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 xMq_*2iWs6mnOngrGbg1uIU}qWDEcR diff --git a/bin/resources/ru/cemu.mo b/bin/resources/ru/cemu.mo index 4ff04e2bf7ba65f396cd0c2f640e8bc7df3c35cf..eb8f372f691d87c2d6059a630432007c5ee44eaa 100644 GIT binary patch delta 19385 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 delta 18537 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 diff --git a/bin/resources/sv/cemu.mo b/bin/resources/sv/cemu.mo index 3e850b365f93c9190904a00dc65da40718ab6c6e..c8fd68ee6a01d9e46e65852673262b289852d362 100644 GIT binary patch literal 67980 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 z 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 literal 15821 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 From e2d0871ca34ab9c20bd9939e661c299067b04124 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sat, 7 Dec 2024 07:48:30 +0100 Subject: [PATCH 22/50] Camera: Set error code in CAMInit Fixes Hunter's Trophy 2 crashing on boot --- src/Cafe/OS/libs/camera/camera.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cafe/OS/libs/camera/camera.cpp b/src/Cafe/OS/libs/camera/camera.cpp index 4debb37f..03e01bfc 100644 --- a/src/Cafe/OS/libs/camera/camera.cpp +++ b/src/Cafe/OS/libs/camera/camera.cpp @@ -181,7 +181,7 @@ namespace camera sint32 CAMInit(uint32 cameraId, CAMInitInfo_t* camInitInfo, uint32be* error) { CameraInstance* camInstance = new CameraInstance(camInitInfo->width, camInitInfo->height, camInitInfo->handlerFuncPtr); - + *error = 0; // Hunter's Trophy 2 will fail to boot if we don't set this std::unique_lock _lock(g_mutex_camera); if (g_cameraCounter == 0) { From 356cf0e5e00a17a632ee6c845072a877c96c8eaf Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:23:11 +0100 Subject: [PATCH 23/50] Multiple smaller HLE improvements --- src/Cafe/OS/RPL/rpl_structs.h | 2 +- src/Cafe/OS/libs/coreinit/coreinit_GHS.cpp | 14 +++++++++++++- src/Cafe/OS/libs/coreinit/coreinit_GHS.h | 4 ++++ src/Cafe/OS/libs/coreinit/coreinit_Thread.h | 2 +- src/Cafe/OS/libs/gx2/GX2_Resource.cpp | 6 ++++++ src/Cafe/OS/libs/nsysnet/nsysnet.cpp | 11 +++-------- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Cafe/OS/RPL/rpl_structs.h b/src/Cafe/OS/RPL/rpl_structs.h index 998ec8d7..c66f6136 100644 --- a/src/Cafe/OS/RPL/rpl_structs.h +++ b/src/Cafe/OS/RPL/rpl_structs.h @@ -116,7 +116,7 @@ typedef struct /* +0x34 */ uint32be ukn34; /* +0x38 */ uint32be ukn38; /* +0x3C */ uint32be ukn3C; - /* +0x40 */ uint32be toolkitVersion; + /* +0x40 */ uint32be minimumToolkitVersion; /* +0x44 */ uint32be ukn44; /* +0x48 */ uint32be ukn48; /* +0x4C */ uint32be ukn4C; diff --git a/src/Cafe/OS/libs/coreinit/coreinit_GHS.cpp b/src/Cafe/OS/libs/coreinit/coreinit_GHS.cpp index e2864fb9..33c8eedc 100644 --- a/src/Cafe/OS/libs/coreinit/coreinit_GHS.cpp +++ b/src/Cafe/OS/libs/coreinit/coreinit_GHS.cpp @@ -156,12 +156,22 @@ namespace coreinit return ¤tThread->crt.eh_mem_manage; } - void* __gh_errno_ptr() + sint32be* __gh_errno_ptr() { OSThread_t* currentThread = coreinit::OSGetCurrentThread(); return ¤tThread->context.ghs_errno; } + void __gh_set_errno(sint32 errNo) + { + *__gh_errno_ptr() = errNo; + } + + sint32 __gh_get_errno() + { + return *__gh_errno_ptr(); + } + void* __get_eh_store_globals() { OSThread_t* currentThread = coreinit::OSGetCurrentThread(); @@ -272,6 +282,8 @@ namespace coreinit cafeExportRegister("coreinit", __get_eh_globals, LogType::Placeholder); cafeExportRegister("coreinit", __get_eh_mem_manage, LogType::Placeholder); cafeExportRegister("coreinit", __gh_errno_ptr, LogType::Placeholder); + cafeExportRegister("coreinit", __gh_set_errno, LogType::Placeholder); + cafeExportRegister("coreinit", __gh_get_errno, LogType::Placeholder); cafeExportRegister("coreinit", __get_eh_store_globals, LogType::Placeholder); cafeExportRegister("coreinit", __get_eh_store_globals_tdeh, LogType::Placeholder); diff --git a/src/Cafe/OS/libs/coreinit/coreinit_GHS.h b/src/Cafe/OS/libs/coreinit/coreinit_GHS.h index 0ac09e94..5f000732 100644 --- a/src/Cafe/OS/libs/coreinit/coreinit_GHS.h +++ b/src/Cafe/OS/libs/coreinit/coreinit_GHS.h @@ -4,5 +4,9 @@ namespace coreinit { void PrepareGHSRuntime(); + sint32be* __gh_errno_ptr(); + void __gh_set_errno(sint32 errNo); + sint32 __gh_get_errno(); + void InitializeGHS(); }; \ No newline at end of file diff --git a/src/Cafe/OS/libs/coreinit/coreinit_Thread.h b/src/Cafe/OS/libs/coreinit/coreinit_Thread.h index df787bf0..1a93022b 100644 --- a/src/Cafe/OS/libs/coreinit/coreinit_Thread.h +++ b/src/Cafe/OS/libs/coreinit/coreinit_Thread.h @@ -38,7 +38,7 @@ struct OSContext_t /* +0x1E0 */ uint64be fp_ps1[32]; /* +0x2E0 */ uint64be coretime[3]; /* +0x2F8 */ uint64be starttime; - /* +0x300 */ uint32be ghs_errno; // returned by __gh_errno_ptr() (used by socketlasterr) + /* +0x300 */ sint32be ghs_errno; // returned by __gh_errno_ptr() (used by socketlasterr) /* +0x304 */ uint32be affinity; /* +0x308 */ uint32be upmc1; /* +0x30C */ uint32be upmc2; diff --git a/src/Cafe/OS/libs/gx2/GX2_Resource.cpp b/src/Cafe/OS/libs/gx2/GX2_Resource.cpp index 97f51a0d..a6029de9 100644 --- a/src/Cafe/OS/libs/gx2/GX2_Resource.cpp +++ b/src/Cafe/OS/libs/gx2/GX2_Resource.cpp @@ -87,6 +87,11 @@ namespace GX2 return true; } + void GX2RSetBufferName(GX2RBuffer* buffer, const char* name) + { + // no-op in production builds + } + void* GX2RLockBufferEx(GX2RBuffer* buffer, uint32 resFlags) { return buffer->GetPtr(); @@ -226,6 +231,7 @@ namespace GX2 cafeExportRegister("gx2", GX2RCreateBufferUserMemory, LogType::GX2); cafeExportRegister("gx2", GX2RDestroyBufferEx, LogType::GX2); cafeExportRegister("gx2", GX2RBufferExists, LogType::GX2); + cafeExportRegister("gx2", GX2RSetBufferName, LogType::GX2); cafeExportRegister("gx2", GX2RLockBufferEx, LogType::GX2); cafeExportRegister("gx2", GX2RUnlockBufferEx, LogType::GX2); cafeExportRegister("gx2", GX2RInvalidateBuffer, LogType::GX2); diff --git a/src/Cafe/OS/libs/nsysnet/nsysnet.cpp b/src/Cafe/OS/libs/nsysnet/nsysnet.cpp index 5a0ddc59..c83915db 100644 --- a/src/Cafe/OS/libs/nsysnet/nsysnet.cpp +++ b/src/Cafe/OS/libs/nsysnet/nsysnet.cpp @@ -3,6 +3,7 @@ #include "Cafe/OS/libs/coreinit/coreinit_Thread.h" #include "Cafe/IOSU/legacy/iosu_crypto.h" #include "Cafe/OS/libs/coreinit/coreinit_Time.h" +#include "Cafe/OS/libs/coreinit/coreinit_GHS.h" #include "Common/socket.h" @@ -117,20 +118,14 @@ void nsysnetExport_socket_lib_finish(PPCInterpreter_t* hCPU) osLib_returnFromFunction(hCPU, 0); // 0 -> Success } -static uint32be* __gh_errno_ptr() -{ - OSThread_t* osThread = coreinit::OSGetCurrentThread(); - return &osThread->context.ghs_errno; -} - void _setSockError(sint32 errCode) { - *(uint32be*)__gh_errno_ptr() = (uint32)errCode; + coreinit::__gh_set_errno(errCode); } sint32 _getSockError() { - return (sint32)*(uint32be*)__gh_errno_ptr(); + return coreinit::__gh_get_errno(); } // error translation modes for _translateError From 934cb5460577dd4f672ae3d9c918826e50073934 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:24:59 +0100 Subject: [PATCH 24/50] Properly check if MLC is writeable --- src/gui/CemuApp.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/gui/CemuApp.cpp b/src/gui/CemuApp.cpp index 50ff3b89..c4b1f4e4 100644 --- a/src/gui/CemuApp.cpp +++ b/src/gui/CemuApp.cpp @@ -234,6 +234,12 @@ void CemuApp::InitializeExistingMLCOrFail(fs::path mlc) g_config.Save(); } } + else + { + // default path is not writeable. Just let the user know and quit. Unsure if it would be a good idea to ask the user to choose an alternative path instead + wxMessageBox(formatWxString(_("Cemu failed to write to the default mlc directory.\nThe path is:\n{}"), wxHelper::FromPath(mlc)), _("Error"), wxOK | wxCENTRE | wxICON_ERROR); + exit(0); + } } bool CemuApp::OnInit() @@ -507,6 +513,13 @@ bool CemuApp::CreateDefaultMLCFiles(const fs::path& mlc) file.flush(); file.close(); } + // create a dummy file in the mlc folder to check if it's writable + const auto dummyFile = fs::path(mlc).append("writetestdummy"); + std::ofstream file(dummyFile); + if (!file.is_open()) + return false; + file.close(); + fs::remove(dummyFile); } catch (const std::exception& ex) { From dd0af0a56fa3c6b8a82f60c19e67bbe06d673d0e Mon Sep 17 00:00:00 2001 From: capitalistspz Date: Sat, 7 Dec 2024 11:02:40 +0000 Subject: [PATCH 25/50] Linux: Allow connecting Wiimotes via L2CAP (#1353) --- .github/workflows/build.yml | 4 +- BUILD.md | 6 +- CMakeLists.txt | 7 + cmake/Findbluez.cmake | 20 + src/gui/input/PairingDialog.cpp | 394 ++++++++++-------- src/gui/input/PairingDialog.h | 2 +- src/input/CMakeLists.txt | 10 + .../api/Wiimote/WiimoteControllerProvider.cpp | 120 ++++-- .../api/Wiimote/WiimoteControllerProvider.h | 6 +- src/input/api/Wiimote/WiimoteDevice.h | 3 +- .../api/Wiimote/hidapi/HidapiWiimote.cpp | 7 +- src/input/api/Wiimote/hidapi/HidapiWiimote.h | 4 +- src/input/api/Wiimote/l2cap/L2CapWiimote.cpp | 148 +++++++ src/input/api/Wiimote/l2cap/L2CapWiimote.h | 22 + 14 files changed, 532 insertions(+), 221 deletions(-) create mode 100644 cmake/Findbluez.cmake create mode 100644 src/input/api/Wiimote/l2cap/L2CapWiimote.cpp create mode 100644 src/input/api/Wiimote/l2cap/L2CapWiimote.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72bbcf52..6ae4b892 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: - name: "Install system dependencies" run: | sudo apt update -qq - sudo apt install -y clang-15 cmake freeglut3-dev libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev libudev-dev nasm ninja-build + sudo apt install -y clang-15 cmake freeglut3-dev libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev libudev-dev nasm ninja-build libbluetooth-dev - name: "Setup cmake" uses: jwlawson/actions-setup-cmake@v2 @@ -96,7 +96,7 @@ jobs: - name: "Install system dependencies" run: | sudo apt update -qq - sudo apt install -y clang-15 cmake freeglut3-dev libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev nasm ninja-build appstream + sudo apt install -y clang-15 cmake freeglut3-dev libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev nasm ninja-build appstream libbluetooth-dev - name: "Build AppImage" run: | diff --git a/BUILD.md b/BUILD.md index 44d69c6c..41de928e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -46,10 +46,10 @@ To compile Cemu, a recent enough compiler and STL with C++20 support is required ### Dependencies #### For Arch and derivatives: -`sudo pacman -S --needed base-devel clang cmake freeglut git glm gtk3 libgcrypt libpulse libsecret linux-headers llvm nasm ninja systemd unzip zip` +`sudo pacman -S --needed base-devel bluez-libs clang cmake freeglut git glm gtk3 libgcrypt libpulse libsecret linux-headers llvm nasm ninja systemd unzip zip` #### For Debian, Ubuntu and derivatives: -`sudo apt install -y cmake curl clang-15 freeglut3-dev git libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev libtool nasm ninja-build` +`sudo apt install -y cmake curl clang-15 freeglut3-dev git libbluetooth-dev libgcrypt20-dev libglm-dev libgtk-3-dev libpulse-dev libsecret-1-dev libsystemd-dev libtool nasm ninja-build` You may also need to install `libusb-1.0-0-dev` as a workaround for an issue with the vcpkg hidapi package. @@ -57,7 +57,7 @@ At Step 3 in [Build Cemu using cmake and clang](#build-cemu-using-cmake-and-clan `cmake -S . -B build -DCMAKE_BUILD_TYPE=release -DCMAKE_C_COMPILER=/usr/bin/clang-15 -DCMAKE_CXX_COMPILER=/usr/bin/clang++-15 -G Ninja -DCMAKE_MAKE_PROGRAM=/usr/bin/ninja` #### For Fedora and derivatives: -`sudo dnf install clang cmake cubeb-devel freeglut-devel git glm-devel gtk3-devel kernel-headers libgcrypt-devel libsecret-devel libtool libusb1-devel llvm nasm ninja-build perl-core systemd-devel zlib-devel zlib-static` +`sudo dnf install bluez-libs clang cmake cubeb-devel freeglut-devel git glm-devel gtk3-devel kernel-headers libgcrypt-devel libsecret-devel libtool libusb1-devel llvm nasm ninja-build perl-core systemd-devel zlib-devel zlib-static` ### Build Cemu diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b5cff6c..cf04b235 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,7 @@ endif() if (UNIX AND NOT APPLE) option(ENABLE_WAYLAND "Build with Wayland support" ON) option(ENABLE_FERAL_GAMEMODE "Enables Feral Interactive GameMode Support" ON) + option(ENABLE_BLUEZ "Build with Bluez support" ON) endif() option(ENABLE_OPENGL "Enables the OpenGL backend" ON) @@ -179,6 +180,12 @@ if (UNIX AND NOT APPLE) endif() find_package(GTK3 REQUIRED) + if(ENABLE_BLUEZ) + find_package(bluez REQUIRED) + set(ENABLE_WIIMOTE ON) + add_compile_definitions(HAS_BLUEZ) + endif() + endif() if (ENABLE_VULKAN) diff --git a/cmake/Findbluez.cmake b/cmake/Findbluez.cmake new file mode 100644 index 00000000..007cdac9 --- /dev/null +++ b/cmake/Findbluez.cmake @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2022 Andrea Pappacoda +# SPDX-License-Identifier: ISC + +find_package(bluez CONFIG) +if (NOT bluez_FOUND) + find_package(PkgConfig) + if (PKG_CONFIG_FOUND) + pkg_search_module(bluez IMPORTED_TARGET GLOBAL bluez-1.0 bluez) + if (bluez_FOUND) + add_library(bluez::bluez ALIAS PkgConfig::bluez) + endif () + endif () +endif () + +find_package_handle_standard_args(bluez + REQUIRED_VARS + bluez_LINK_LIBRARIES + bluez_FOUND + VERSION_VAR bluez_VERSION +) diff --git a/src/gui/input/PairingDialog.cpp b/src/gui/input/PairingDialog.cpp index 03d6315b..350fce81 100644 --- a/src/gui/input/PairingDialog.cpp +++ b/src/gui/input/PairingDialog.cpp @@ -4,233 +4,297 @@ #if BOOST_OS_WINDOWS #include #endif +#if BOOST_OS_LINUX +#include +#include +#include +#include +#endif wxDECLARE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent); wxDEFINE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent); PairingDialog::PairingDialog(wxWindow* parent) - : wxDialog(parent, wxID_ANY, _("Pairing..."), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxMINIMIZE_BOX | wxSYSTEM_MENU | wxTAB_TRAVERSAL | wxCLOSE_BOX) + : wxDialog(parent, wxID_ANY, _("Pairing..."), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxMINIMIZE_BOX | wxSYSTEM_MENU | wxTAB_TRAVERSAL | wxCLOSE_BOX) { - auto* sizer = new wxBoxSizer(wxVERTICAL); - m_gauge = new wxGauge(this, wxID_ANY, 100, wxDefaultPosition, wxSize(350, 20), wxGA_HORIZONTAL); - m_gauge->SetValue(0); - sizer->Add(m_gauge, 0, wxALL | wxEXPAND, 5); + auto* sizer = new wxBoxSizer(wxVERTICAL); + m_gauge = new wxGauge(this, wxID_ANY, 100, wxDefaultPosition, wxSize(350, 20), wxGA_HORIZONTAL); + m_gauge->SetValue(0); + sizer->Add(m_gauge, 0, wxALL | wxEXPAND, 5); - auto* rows = new wxFlexGridSizer(0, 2, 0, 0); - rows->AddGrowableCol(1); + auto* rows = new wxFlexGridSizer(0, 2, 0, 0); + rows->AddGrowableCol(1); - m_text = new wxStaticText(this, wxID_ANY, _("Searching for controllers...")); - rows->Add(m_text, 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); + m_text = new wxStaticText(this, wxID_ANY, _("Searching for controllers...")); + rows->Add(m_text, 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); - { - auto* right_side = new wxBoxSizer(wxHORIZONTAL); + { + auto* right_side = new wxBoxSizer(wxHORIZONTAL); - m_cancelButton = new wxButton(this, wxID_ANY, _("Cancel")); - m_cancelButton->Bind(wxEVT_BUTTON, &PairingDialog::OnCancelButton, this); - right_side->Add(m_cancelButton, 0, wxALL, 5); + m_cancelButton = new wxButton(this, wxID_ANY, _("Cancel")); + m_cancelButton->Bind(wxEVT_BUTTON, &PairingDialog::OnCancelButton, this); + right_side->Add(m_cancelButton, 0, wxALL, 5); - rows->Add(right_side, 1, wxALIGN_RIGHT, 5); - } + rows->Add(right_side, 1, wxALIGN_RIGHT, 5); + } - sizer->Add(rows, 0, wxALL | wxEXPAND, 5); + sizer->Add(rows, 0, wxALL | wxEXPAND, 5); - SetSizerAndFit(sizer); - Centre(wxBOTH); + SetSizerAndFit(sizer); + Centre(wxBOTH); - Bind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this); - Bind(wxEVT_PROGRESS_PAIR, &PairingDialog::OnGaugeUpdate, this); + Bind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this); + Bind(wxEVT_PROGRESS_PAIR, &PairingDialog::OnGaugeUpdate, this); - m_thread = std::thread(&PairingDialog::WorkerThread, this); + m_thread = std::thread(&PairingDialog::WorkerThread, this); } PairingDialog::~PairingDialog() { - Unbind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this); + Unbind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this); } void PairingDialog::OnClose(wxCloseEvent& event) { - event.Skip(); + event.Skip(); - m_threadShouldQuit = true; - if (m_thread.joinable()) - m_thread.join(); + m_threadShouldQuit = true; + if (m_thread.joinable()) + m_thread.join(); } void PairingDialog::OnCancelButton(const wxCommandEvent& event) { - Close(); + Close(); } void PairingDialog::OnGaugeUpdate(wxCommandEvent& event) { - PairingState state = (PairingState)event.GetInt(); + PairingState state = (PairingState)event.GetInt(); - switch (state) - { - case PairingState::Pairing: - { - m_text->SetLabel(_("Found controller. Pairing...")); - m_gauge->SetValue(50); - break; - } + switch (state) + { + case PairingState::Pairing: + { + m_text->SetLabel(_("Found controller. Pairing...")); + m_gauge->SetValue(50); + break; + } - case PairingState::Finished: - { - m_text->SetLabel(_("Successfully paired the controller.")); - m_gauge->SetValue(100); - m_cancelButton->SetLabel(_("Close")); - break; - } + case PairingState::Finished: + { + m_text->SetLabel(_("Successfully paired the controller.")); + m_gauge->SetValue(100); + m_cancelButton->SetLabel(_("Close")); + break; + } - case PairingState::NoBluetoothAvailable: - { - m_text->SetLabel(_("Failed to find a suitable Bluetooth radio.")); - m_gauge->SetValue(0); - m_cancelButton->SetLabel(_("Close")); - break; - } + case PairingState::NoBluetoothAvailable: + { + m_text->SetLabel(_("Failed to find a suitable Bluetooth radio.")); + m_gauge->SetValue(0); + m_cancelButton->SetLabel(_("Close")); + break; + } - case PairingState::BluetoothFailed: - { - m_text->SetLabel(_("Failed to search for controllers.")); - m_gauge->SetValue(0); - m_cancelButton->SetLabel(_("Close")); - break; - } + case PairingState::SearchFailed: + { + m_text->SetLabel(_("Failed to find controllers.")); + m_gauge->SetValue(0); + m_cancelButton->SetLabel(_("Close")); + break; + } - case PairingState::PairingFailed: - { - m_text->SetLabel(_("Failed to pair with the found controller.")); - m_gauge->SetValue(0); - m_cancelButton->SetLabel(_("Close")); - break; - } + case PairingState::PairingFailed: + { + m_text->SetLabel(_("Failed to pair with the found controller.")); + m_gauge->SetValue(0); + m_cancelButton->SetLabel(_("Close")); + break; + } - case PairingState::BluetoothUnusable: - { - m_text->SetLabel(_("Please use your system's Bluetooth manager instead.")); - m_gauge->SetValue(0); - m_cancelButton->SetLabel(_("Close")); - break; - } + case PairingState::BluetoothUnusable: + { + m_text->SetLabel(_("Please use your system's Bluetooth manager instead.")); + m_gauge->SetValue(0); + m_cancelButton->SetLabel(_("Close")); + break; + } - - default: - { - break; - } - } + default: + { + break; + } + } } -void PairingDialog::WorkerThread() -{ - const std::wstring wiimoteName = L"Nintendo RVL-CNT-01"; - const std::wstring wiiUProControllerName = L"Nintendo RVL-CNT-01-UC"; - #if BOOST_OS_WINDOWS - const GUID bthHidGuid = {0x00001124,0x0000,0x1000,{0x80,0x00,0x00,0x80,0x5F,0x9B,0x34,0xFB}}; +void PairingDialog::WorkerThread() +{ + const std::wstring wiimoteName = L"Nintendo RVL-CNT-01"; + const std::wstring wiiUProControllerName = L"Nintendo RVL-CNT-01-UC"; - const BLUETOOTH_FIND_RADIO_PARAMS radioFindParams = - { - .dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS) - }; + const GUID bthHidGuid = {0x00001124, 0x0000, 0x1000, {0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB}}; - HANDLE radio = INVALID_HANDLE_VALUE; - HBLUETOOTH_RADIO_FIND radioFind = BluetoothFindFirstRadio(&radioFindParams, &radio); - if (radioFind == nullptr) - { - UpdateCallback(PairingState::NoBluetoothAvailable); - return; - } + const BLUETOOTH_FIND_RADIO_PARAMS radioFindParams = + { + .dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS)}; - BluetoothFindRadioClose(radioFind); + HANDLE radio = INVALID_HANDLE_VALUE; + HBLUETOOTH_RADIO_FIND radioFind = BluetoothFindFirstRadio(&radioFindParams, &radio); + if (radioFind == nullptr) + { + UpdateCallback(PairingState::NoBluetoothAvailable); + return; + } - BLUETOOTH_RADIO_INFO radioInfo = - { - .dwSize = sizeof(BLUETOOTH_RADIO_INFO) - }; + BluetoothFindRadioClose(radioFind); - DWORD result = BluetoothGetRadioInfo(radio, &radioInfo); - if (result != ERROR_SUCCESS) - { - UpdateCallback(PairingState::NoBluetoothAvailable); - return; - } + BLUETOOTH_RADIO_INFO radioInfo = + { + .dwSize = sizeof(BLUETOOTH_RADIO_INFO)}; - const BLUETOOTH_DEVICE_SEARCH_PARAMS searchParams = - { - .dwSize = sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS), + DWORD result = BluetoothGetRadioInfo(radio, &radioInfo); + if (result != ERROR_SUCCESS) + { + UpdateCallback(PairingState::NoBluetoothAvailable); + return; + } - .fReturnAuthenticated = FALSE, - .fReturnRemembered = FALSE, - .fReturnUnknown = TRUE, - .fReturnConnected = FALSE, + const BLUETOOTH_DEVICE_SEARCH_PARAMS searchParams = + { + .dwSize = sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS), - .fIssueInquiry = TRUE, - .cTimeoutMultiplier = 5, + .fReturnAuthenticated = FALSE, + .fReturnRemembered = FALSE, + .fReturnUnknown = TRUE, + .fReturnConnected = FALSE, - .hRadio = radio - }; + .fIssueInquiry = TRUE, + .cTimeoutMultiplier = 5, - BLUETOOTH_DEVICE_INFO info = - { - .dwSize = sizeof(BLUETOOTH_DEVICE_INFO) - }; + .hRadio = radio}; - while (!m_threadShouldQuit) - { - HBLUETOOTH_DEVICE_FIND deviceFind = BluetoothFindFirstDevice(&searchParams, &info); - if (deviceFind == nullptr) - { - UpdateCallback(PairingState::BluetoothFailed); - return; - } + BLUETOOTH_DEVICE_INFO info = + { + .dwSize = sizeof(BLUETOOTH_DEVICE_INFO)}; - while (!m_threadShouldQuit) - { - if (info.szName == wiimoteName || info.szName == wiiUProControllerName) - { - BluetoothFindDeviceClose(deviceFind); + while (!m_threadShouldQuit) + { + HBLUETOOTH_DEVICE_FIND deviceFind = BluetoothFindFirstDevice(&searchParams, &info); + if (deviceFind == nullptr) + { + UpdateCallback(PairingState::SearchFailed); + return; + } - UpdateCallback(PairingState::Pairing); + while (!m_threadShouldQuit) + { + if (info.szName == wiimoteName || info.szName == wiiUProControllerName) + { + BluetoothFindDeviceClose(deviceFind); - wchar_t passwd[6] = { radioInfo.address.rgBytes[0], radioInfo.address.rgBytes[1], radioInfo.address.rgBytes[2], radioInfo.address.rgBytes[3], radioInfo.address.rgBytes[4], radioInfo.address.rgBytes[5] }; - DWORD bthResult = BluetoothAuthenticateDevice(nullptr, radio, &info, passwd, 6); - if (bthResult != ERROR_SUCCESS) - { - UpdateCallback(PairingState::PairingFailed); - return; - } + UpdateCallback(PairingState::Pairing); - bthResult = BluetoothSetServiceState(radio, &info, &bthHidGuid, BLUETOOTH_SERVICE_ENABLE); - if (bthResult != ERROR_SUCCESS) - { - UpdateCallback(PairingState::PairingFailed); - return; - } + wchar_t passwd[6] = {radioInfo.address.rgBytes[0], radioInfo.address.rgBytes[1], radioInfo.address.rgBytes[2], radioInfo.address.rgBytes[3], radioInfo.address.rgBytes[4], radioInfo.address.rgBytes[5]}; + DWORD bthResult = BluetoothAuthenticateDevice(nullptr, radio, &info, passwd, 6); + if (bthResult != ERROR_SUCCESS) + { + UpdateCallback(PairingState::PairingFailed); + return; + } - UpdateCallback(PairingState::Finished); - return; - } + bthResult = BluetoothSetServiceState(radio, &info, &bthHidGuid, BLUETOOTH_SERVICE_ENABLE); + if (bthResult != ERROR_SUCCESS) + { + UpdateCallback(PairingState::PairingFailed); + return; + } - BOOL nextDevResult = BluetoothFindNextDevice(deviceFind, &info); - if (nextDevResult == FALSE) - { - break; - } - } + UpdateCallback(PairingState::Finished); + return; + } - BluetoothFindDeviceClose(deviceFind); - } -#else - UpdateCallback(PairingState::BluetoothUnusable); -#endif + BOOL nextDevResult = BluetoothFindNextDevice(deviceFind, &info); + if (nextDevResult == FALSE) + { + break; + } + } + + BluetoothFindDeviceClose(deviceFind); + } } +#elif BOOST_OS_LINUX +void PairingDialog::WorkerThread() +{ + constexpr static uint8_t LIAC_LAP[] = {0x00, 0x8b, 0x9e}; + constexpr static auto isWiimoteName = [](std::string_view name) { + return name == "Nintendo RVL-CNT-01" || name == "Nintendo RVL-CNT-01-TR"; + }; + + // Get default BT device + const auto hostId = hci_get_route(nullptr); + if (hostId < 0) + { + UpdateCallback(PairingState::NoBluetoothAvailable); + return; + } + + // Search for device + inquiry_info* infos = nullptr; + m_cancelButton->Disable(); + const auto respCount = hci_inquiry(hostId, 7, 4, LIAC_LAP, &infos, IREQ_CACHE_FLUSH); + m_cancelButton->Enable(); + if (respCount <= 0) + { + UpdateCallback(PairingState::SearchFailed); + return; + } + stdx::scope_exit infoFree([&]() { bt_free(infos);}); + + if (m_threadShouldQuit) + return; + + // Open dev to read name + const auto hostDev = hci_open_dev(hostId); + stdx::scope_exit devClose([&]() { hci_close_dev(hostDev);}); + + char nameBuffer[HCI_MAX_NAME_LENGTH] = {}; + + bool foundADevice = false; + // Get device name and compare. Would use product and vendor id from SDP, but many third-party Wiimotes don't store them + for (const auto& devInfo : std::span(infos, respCount)) + { + const auto& addr = devInfo.bdaddr; + const auto err = hci_read_remote_name(hostDev, &addr, HCI_MAX_NAME_LENGTH, nameBuffer, + 2000); + if (m_threadShouldQuit) + return; + if (err || !isWiimoteName(nameBuffer)) + continue; + + L2CapWiimote::AddCandidateAddress(addr); + foundADevice = true; + const auto& b = addr.b; + cemuLog_log(LogType::Force, "Pairing Dialog: Found '{}' with address '{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}'", + nameBuffer, b[5], b[4], b[3], b[2], b[1], b[0]); + } + if (foundADevice) + UpdateCallback(PairingState::Finished); + else + UpdateCallback(PairingState::SearchFailed); +} +#else +void PairingDialog::WorkerThread() +{ + UpdateCallback(PairingState::BluetoothUnusable); +} +#endif void PairingDialog::UpdateCallback(PairingState state) { - auto* event = new wxCommandEvent(wxEVT_PROGRESS_PAIR); - event->SetInt((int)state); - wxQueueEvent(this, event); + auto* event = new wxCommandEvent(wxEVT_PROGRESS_PAIR); + event->SetInt((int)state); + wxQueueEvent(this, event); } \ No newline at end of file diff --git a/src/gui/input/PairingDialog.h b/src/gui/input/PairingDialog.h index 6c7612d1..02cab4fc 100644 --- a/src/gui/input/PairingDialog.h +++ b/src/gui/input/PairingDialog.h @@ -17,7 +17,7 @@ private: Pairing, Finished, NoBluetoothAvailable, - BluetoothFailed, + SearchFailed, PairingFailed, BluetoothUnusable }; diff --git a/src/input/CMakeLists.txt b/src/input/CMakeLists.txt index 9f7873a1..004dc2ba 100644 --- a/src/input/CMakeLists.txt +++ b/src/input/CMakeLists.txt @@ -73,6 +73,11 @@ if (ENABLE_WIIMOTE) api/Wiimote/hidapi/HidapiWiimote.cpp api/Wiimote/hidapi/HidapiWiimote.h ) + if (UNIX AND NOT APPLE) + target_sources(CemuInput PRIVATE + api/Wiimote/l2cap/L2CapWiimote.cpp + api/Wiimote/l2cap/L2CapWiimote.h) + endif() endif () @@ -97,3 +102,8 @@ endif() if (ENABLE_WXWIDGETS) target_link_libraries(CemuInput PRIVATE wx::base wx::core) endif() + + +if (UNIX AND NOT APPLE) + target_link_libraries(CemuInput PRIVATE bluez::bluez) +endif () \ No newline at end of file diff --git a/src/input/api/Wiimote/WiimoteControllerProvider.cpp b/src/input/api/Wiimote/WiimoteControllerProvider.cpp index c80f3fbe..221d75a7 100644 --- a/src/input/api/Wiimote/WiimoteControllerProvider.cpp +++ b/src/input/api/Wiimote/WiimoteControllerProvider.cpp @@ -2,7 +2,12 @@ #include "input/api/Wiimote/NativeWiimoteController.h" #include "input/api/Wiimote/WiimoteMessages.h" +#ifdef HAS_HIDAPI #include "input/api/Wiimote/hidapi/HidapiWiimote.h" +#endif +#ifdef HAS_BLUEZ +#include "input/api/Wiimote/l2cap/L2CapWiimote.h" +#endif #include #include @@ -12,6 +17,7 @@ WiimoteControllerProvider::WiimoteControllerProvider() { m_reader_thread = std::thread(&WiimoteControllerProvider::reader_thread, this); m_writer_thread = std::thread(&WiimoteControllerProvider::writer_thread, this); + m_connectionThread = std::thread(&WiimoteControllerProvider::connectionThread, this); } WiimoteControllerProvider::~WiimoteControllerProvider() @@ -21,48 +27,51 @@ WiimoteControllerProvider::~WiimoteControllerProvider() m_running = false; m_writer_thread.join(); m_reader_thread.join(); + m_connectionThread.join(); } } std::vector> WiimoteControllerProvider::get_controllers() { + m_connectedDeviceMutex.lock(); + auto devices = m_connectedDevices; + m_connectedDeviceMutex.unlock(); + std::scoped_lock lock(m_device_mutex); - std::queue disconnected_wiimote_indices; - for (auto i{0u}; i < m_wiimotes.size(); ++i){ - if (!(m_wiimotes[i].connected = m_wiimotes[i].device->write_data({kStatusRequest, 0x00}))){ - disconnected_wiimote_indices.push(i); - } - } - - const auto valid_new_device = [&](std::shared_ptr & device) { - const auto writeable = device->write_data({kStatusRequest, 0x00}); - const auto not_already_connected = - std::none_of(m_wiimotes.cbegin(), m_wiimotes.cend(), - [device](const auto& it) { - return (*it.device == *device) && it.connected; - }); - return writeable && not_already_connected; - }; - - for (auto& device : WiimoteDevice_t::get_devices()) + for (auto& device : devices) { - if (!valid_new_device(device)) + const auto writeable = device->write_data({kStatusRequest, 0x00}); + if (!writeable) continue; - // Replace disconnected wiimotes - if (!disconnected_wiimote_indices.empty()){ - const auto idx = disconnected_wiimote_indices.front(); - disconnected_wiimote_indices.pop(); - m_wiimotes.replace(idx, std::make_unique(device)); - } - // Otherwise add them - else { - m_wiimotes.push_back(std::make_unique(device)); - } + bool isDuplicate = false; + ssize_t lowestReplaceableIndex = -1; + for (ssize_t i = m_wiimotes.size() - 1; i >= 0; --i) + { + const auto& wiimoteDevice = m_wiimotes[i].device; + if (wiimoteDevice) + { + if (*wiimoteDevice == *device) + { + isDuplicate = true; + break; + } + continue; + } + + lowestReplaceableIndex = i; + } + if (isDuplicate) + continue; + if (lowestReplaceableIndex != -1) + m_wiimotes.replace(lowestReplaceableIndex, std::make_unique(device)); + else + m_wiimotes.push_back(std::make_unique(device)); } std::vector> result; + result.reserve(m_wiimotes.size()); for (size_t i = 0; i < m_wiimotes.size(); ++i) { result.emplace_back(std::make_shared(i)); @@ -74,7 +83,7 @@ std::vector> WiimoteControllerProvider::get_cont bool WiimoteControllerProvider::is_connected(size_t index) { std::shared_lock lock(m_device_mutex); - return index < m_wiimotes.size() && m_wiimotes[index].connected; + return index < m_wiimotes.size() && m_wiimotes[index].device; } bool WiimoteControllerProvider::is_registered_device(size_t index) @@ -141,6 +150,30 @@ WiimoteControllerProvider::WiimoteState WiimoteControllerProvider::get_state(siz return {}; } +void WiimoteControllerProvider::connectionThread() +{ + SetThreadName("Wiimote-connect"); + while (m_running.load(std::memory_order_relaxed)) + { + std::vector devices; +#ifdef HAS_HIDAPI + const auto& hidDevices = HidapiWiimote::get_devices(); + std::ranges::move(hidDevices, std::back_inserter(devices)); +#endif +#ifdef HAS_BLUEZ + const auto& l2capDevices = L2CapWiimote::get_devices(); + std::ranges::move(l2capDevices, std::back_inserter(devices)); +#endif + { + std::scoped_lock lock(m_connectedDeviceMutex); + m_connectedDevices.clear(); + std::ranges::move(devices, std::back_inserter(m_connectedDevices)); + } + std::this_thread::sleep_for(std::chrono::seconds(2)); + } +} + + void WiimoteControllerProvider::reader_thread() { SetThreadName("Wiimote-reader"); @@ -148,7 +181,7 @@ void WiimoteControllerProvider::reader_thread() while (m_running.load(std::memory_order_relaxed)) { const auto now = std::chrono::steady_clock::now(); - if (std::chrono::duration_cast(now - lastCheck) > std::chrono::seconds(2)) + if (std::chrono::duration_cast(now - lastCheck) > std::chrono::milliseconds(500)) { // check for new connected wiimotes get_controllers(); @@ -160,11 +193,16 @@ void WiimoteControllerProvider::reader_thread() for (size_t index = 0; index < m_wiimotes.size(); ++index) { auto& wiimote = m_wiimotes[index]; - if (!wiimote.connected) + if (!wiimote.device) continue; const auto read_data = wiimote.device->read_data(); - if (!read_data || read_data->empty()) + if (!read_data) + { + wiimote.device.reset(); + continue; + } + if (read_data->empty()) continue; receivedAnyPacket = true; @@ -921,18 +959,18 @@ void WiimoteControllerProvider::writer_thread() if (index != (size_t)-1 && !data.empty()) { - if (m_wiimotes[index].rumble) + auto& wiimote = m_wiimotes[index]; + if (!wiimote.device) + continue; + if (wiimote.rumble) data[1] |= 1; - - m_wiimotes[index].connected = m_wiimotes[index].device->write_data(data); - if (m_wiimotes[index].connected) + if (!wiimote.device->write_data(data)) { - m_wiimotes[index].data_ts = std::chrono::high_resolution_clock::now(); + wiimote.device.reset(); + wiimote.rumble = false; } else - { - m_wiimotes[index].rumble = false; - } + wiimote.data_ts = std::chrono::high_resolution_clock::now(); } device_lock.unlock(); diff --git a/src/input/api/Wiimote/WiimoteControllerProvider.h b/src/input/api/Wiimote/WiimoteControllerProvider.h index 7629b641..90f28d5c 100644 --- a/src/input/api/Wiimote/WiimoteControllerProvider.h +++ b/src/input/api/Wiimote/WiimoteControllerProvider.h @@ -77,16 +77,17 @@ public: private: std::atomic_bool m_running = false; std::thread m_reader_thread, m_writer_thread; - std::shared_mutex m_device_mutex; + std::thread m_connectionThread; + std::vector m_connectedDevices; + std::mutex m_connectedDeviceMutex; struct Wiimote { Wiimote(WiimoteDevicePtr device) : device(std::move(device)) {} WiimoteDevicePtr device; - std::atomic_bool connected = true; std::atomic_bool rumble = false; std::shared_mutex mutex; @@ -103,6 +104,7 @@ private: void reader_thread(); void writer_thread(); + void connectionThread(); void calibrate(size_t index); IRMode set_ir_camera(size_t index, bool state); diff --git a/src/input/api/Wiimote/WiimoteDevice.h b/src/input/api/Wiimote/WiimoteDevice.h index 7938bbdf..8ea5b321 100644 --- a/src/input/api/Wiimote/WiimoteDevice.h +++ b/src/input/api/Wiimote/WiimoteDevice.h @@ -9,8 +9,7 @@ public: virtual bool write_data(const std::vector& data) = 0; virtual std::optional> read_data() = 0; - virtual bool operator==(WiimoteDevice& o) const = 0; - bool operator!=(WiimoteDevice& o) const { return *this == o; } + virtual bool operator==(const WiimoteDevice& o) const = 0; }; using WiimoteDevicePtr = std::shared_ptr; diff --git a/src/input/api/Wiimote/hidapi/HidapiWiimote.cpp b/src/input/api/Wiimote/hidapi/HidapiWiimote.cpp index db185675..5780909f 100644 --- a/src/input/api/Wiimote/hidapi/HidapiWiimote.cpp +++ b/src/input/api/Wiimote/hidapi/HidapiWiimote.cpp @@ -47,8 +47,11 @@ std::vector HidapiWiimote::get_devices() { return wiimote_devices; } -bool HidapiWiimote::operator==(WiimoteDevice& o) const { - return static_cast(o).m_path == m_path; +bool HidapiWiimote::operator==(const WiimoteDevice& rhs) const { + auto other = dynamic_cast(&rhs); + if (!other) + return false; + return m_path == other->m_path; } HidapiWiimote::~HidapiWiimote() { diff --git a/src/input/api/Wiimote/hidapi/HidapiWiimote.h b/src/input/api/Wiimote/hidapi/HidapiWiimote.h index 858cb1f3..952a36f0 100644 --- a/src/input/api/Wiimote/hidapi/HidapiWiimote.h +++ b/src/input/api/Wiimote/hidapi/HidapiWiimote.h @@ -10,7 +10,7 @@ public: bool write_data(const std::vector &data) override; std::optional> read_data() override; - bool operator==(WiimoteDevice& o) const override; + bool operator==(const WiimoteDevice& o) const override; static std::vector get_devices(); @@ -19,5 +19,3 @@ private: const std::string m_path; }; - -using WiimoteDevice_t = HidapiWiimote; \ No newline at end of file diff --git a/src/input/api/Wiimote/l2cap/L2CapWiimote.cpp b/src/input/api/Wiimote/l2cap/L2CapWiimote.cpp new file mode 100644 index 00000000..28a123f3 --- /dev/null +++ b/src/input/api/Wiimote/l2cap/L2CapWiimote.cpp @@ -0,0 +1,148 @@ +#include "L2CapWiimote.h" +#include + +constexpr auto comparator = [](const bdaddr_t& a, const bdaddr_t& b) { + return bacmp(&a, &b); +}; + +static auto s_addresses = std::map(comparator); +static std::mutex s_addressMutex; + +static bool AttemptConnect(int sockFd, const sockaddr_l2& addr) +{ + auto res = connect(sockFd, reinterpret_cast(&addr), + sizeof(sockaddr_l2)); + if (res == 0) + return true; + return connect(sockFd, reinterpret_cast(&addr), + sizeof(sockaddr_l2)) == 0; +} + +static bool AttemptSetNonBlock(int sockFd) +{ + return fcntl(sockFd, F_SETFL, fcntl(sockFd, F_GETFL) | O_NONBLOCK) == 0; +} + +L2CapWiimote::L2CapWiimote(int recvFd, int sendFd, bdaddr_t addr) + : m_recvFd(recvFd), m_sendFd(sendFd), m_addr(addr) +{ +} + +L2CapWiimote::~L2CapWiimote() +{ + close(m_recvFd); + close(m_sendFd); + const auto& b = m_addr.b; + cemuLog_logDebug(LogType::Force, "Wiimote at {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x} disconnected", b[5], b[4], b[3], b[2], b[1], b[0]); + + // Re-add to candidate vec + s_addressMutex.lock(); + s_addresses[m_addr] = false; + s_addressMutex.unlock(); +} + +void L2CapWiimote::AddCandidateAddress(bdaddr_t addr) +{ + std::scoped_lock lock(s_addressMutex); + s_addresses.try_emplace(addr, false); +} + +std::vector L2CapWiimote::get_devices() +{ + s_addressMutex.lock(); + std::vector unconnected; + for (const auto& [addr, connected] : s_addresses) + { + if (!connected) + unconnected.push_back(addr); + } + s_addressMutex.unlock(); + + std::vector outDevices; + for (const auto& addr : unconnected) + { + // Socket for sending data to controller, PSM 0x11 + auto sendFd = socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_L2CAP); + if (sendFd < 0) + { + cemuLog_logDebug(LogType::Force, "Failed to open send socket: {}", strerror(errno)); + continue; + } + + sockaddr_l2 sendAddr{}; + sendAddr.l2_family = AF_BLUETOOTH; + sendAddr.l2_psm = htobs(0x11); + sendAddr.l2_bdaddr = addr; + + if (!AttemptConnect(sendFd, sendAddr) || !AttemptSetNonBlock(sendFd)) + { + const auto& b = addr.b; + cemuLog_logDebug(LogType::Force, "Failed to connect send socket to '{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}': {}", + b[5], b[4], b[3], b[2], b[1], b[0], strerror(errno)); + close(sendFd); + continue; + } + + // Socket for receiving data from controller, PSM 0x13 + auto recvFd = socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_L2CAP); + if (recvFd < 0) + { + cemuLog_logDebug(LogType::Force, "Failed to open recv socket: {}", strerror(errno)); + close(sendFd); + continue; + } + sockaddr_l2 recvAddr{}; + recvAddr.l2_family = AF_BLUETOOTH; + recvAddr.l2_psm = htobs(0x13); + recvAddr.l2_bdaddr = addr; + + if (!AttemptConnect(recvFd, recvAddr) || !AttemptSetNonBlock(recvFd)) + { + const auto& b = addr.b; + cemuLog_logDebug(LogType::Force, "Failed to connect recv socket to '{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}': {}", + b[5], b[4], b[3], b[2], b[1], b[0], strerror(errno)); + close(sendFd); + close(recvFd); + continue; + } + outDevices.emplace_back(std::make_shared(sendFd, recvFd, addr)); + + s_addressMutex.lock(); + s_addresses[addr] = true; + s_addressMutex.unlock(); + } + return outDevices; +} + +bool L2CapWiimote::write_data(const std::vector& data) +{ + const auto size = data.size(); + cemu_assert_debug(size < 23); + uint8 buffer[23]; + // All outgoing messages must be prefixed with 0xA2 + buffer[0] = 0xA2; + std::memcpy(buffer + 1, data.data(), size); + const auto outSize = size + 1; + return send(m_sendFd, buffer, outSize, 0) == outSize; +} + +std::optional> L2CapWiimote::read_data() +{ + uint8 buffer[23]; + const auto nBytes = recv(m_sendFd, buffer, 23, 0); + + if (nBytes < 0 && errno == EWOULDBLOCK) + return std::vector{}; + // All incoming messages must be prefixed with 0xA1 + if (nBytes < 2 || buffer[0] != 0xA1) + return std::nullopt; + return std::vector(buffer + 1, buffer + 1 + nBytes - 1); +} + +bool L2CapWiimote::operator==(const WiimoteDevice& rhs) const +{ + auto mote = dynamic_cast(&rhs); + if (!mote) + return false; + return bacmp(&m_addr, &mote->m_addr) == 0; +} \ No newline at end of file diff --git a/src/input/api/Wiimote/l2cap/L2CapWiimote.h b/src/input/api/Wiimote/l2cap/L2CapWiimote.h new file mode 100644 index 00000000..cc8d071b --- /dev/null +++ b/src/input/api/Wiimote/l2cap/L2CapWiimote.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +class L2CapWiimote : public WiimoteDevice +{ + public: + L2CapWiimote(int recvFd, int sendFd, bdaddr_t addr); + ~L2CapWiimote() override; + + bool write_data(const std::vector& data) override; + std::optional> read_data() override; + bool operator==(const WiimoteDevice& o) const override; + + static void AddCandidateAddress(bdaddr_t addr); + static std::vector get_devices(); + private: + int m_recvFd; + int m_sendFd; + bdaddr_t m_addr; +}; + From adab729f43ca27bc81280be764b32f11a350308e Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sun, 8 Dec 2024 13:33:34 +0100 Subject: [PATCH 26/50] UI: Correctly handle unicode paths during save export --- src/gui/TitleManager.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/TitleManager.cpp b/src/gui/TitleManager.cpp index 00e7992f..4a4f7f56 100644 --- a/src/gui/TitleManager.cpp +++ b/src/gui/TitleManager.cpp @@ -632,7 +632,7 @@ void TitleManager::OnSaveExport(wxCommandEvent& event) const auto persistent_id = (uint32)(uintptr_t)m_save_account_list->GetClientData(selection_index); - wxFileDialog path_dialog(this, _("Select a target file to export the save entry"), entry->path.string(), wxEmptyString, + wxFileDialog path_dialog(this, _("Select a target file to export the save entry"), wxHelper::FromPath(entry->path), wxEmptyString, fmt::format("{}|*.zip", _("Exported save entry (*.zip)")), wxFD_SAVE | wxFD_OVERWRITE_PROMPT); if (path_dialog.ShowModal() != wxID_OK || path_dialog.GetPath().IsEmpty()) return; From 6aaad1eb83819bcfb9109da2e5e09b1f8d776722 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Sun, 15 Dec 2024 21:47:05 +0100 Subject: [PATCH 27/50] Debugger: Added right click context menu to disasm view + small fixes --- src/Cafe/HW/Espresso/Debugger/Debugger.cpp | 28 +++++++ src/Cafe/HW/Espresso/Debugger/Debugger.h | 1 + src/gui/debugger/DebuggerWindow2.cpp | 11 ++- src/gui/debugger/DebuggerWindow2.h | 2 + src/gui/debugger/DisasmCtrl.cpp | 86 +++++++++++++++++----- src/gui/debugger/DisasmCtrl.h | 13 ++++ 6 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/Cafe/HW/Espresso/Debugger/Debugger.cpp b/src/Cafe/HW/Espresso/Debugger/Debugger.cpp index e7369af6..1fed07cd 100644 --- a/src/Cafe/HW/Espresso/Debugger/Debugger.cpp +++ b/src/Cafe/HW/Espresso/Debugger/Debugger.cpp @@ -447,6 +447,34 @@ bool debugger_hasPatch(uint32 address) return false; } +void debugger_removePatch(uint32 address) +{ + for (sint32 i = 0; i < debuggerState.patches.size(); i++) + { + auto& patch = debuggerState.patches[i]; + if (address < patch->address || address >= (patch->address + patch->length)) + continue; + MPTR startAddress = patch->address; + MPTR endAddress = patch->address + patch->length; + // remove any breakpoints overlapping with the patch + for (auto& bp : debuggerState.breakpoints) + { + if (bp->address + 4 > startAddress && bp->address < endAddress) + { + bp->enabled = false; + debugger_updateExecutionBreakpoint(bp->address); + } + } + // restore original data + memcpy(MEMPTR(startAddress).GetPtr(), patch->origData.data(), patch->length); + PPCRecompiler_invalidateRange(startAddress, endAddress); + // remove patch + delete patch; + debuggerState.patches.erase(debuggerState.patches.begin() + i); + return; + } +} + void debugger_stepInto(PPCInterpreter_t* hCPU, bool updateDebuggerWindow = true) { bool isRecEnabled = ppcRecompilerEnabled; diff --git a/src/Cafe/HW/Espresso/Debugger/Debugger.h b/src/Cafe/HW/Espresso/Debugger/Debugger.h index 717df28a..249c47b8 100644 --- a/src/Cafe/HW/Espresso/Debugger/Debugger.h +++ b/src/Cafe/HW/Espresso/Debugger/Debugger.h @@ -114,6 +114,7 @@ void debugger_updateExecutionBreakpoint(uint32 address, bool forceRestore = fals void debugger_createPatch(uint32 address, std::span patchData); bool debugger_hasPatch(uint32 address); +void debugger_removePatch(uint32 address); void debugger_forceBreak(); // force breakpoint at the next possible instruction bool debugger_isTrapped(); diff --git a/src/gui/debugger/DebuggerWindow2.cpp b/src/gui/debugger/DebuggerWindow2.cpp index 969e40bd..9f25cf96 100644 --- a/src/gui/debugger/DebuggerWindow2.cpp +++ b/src/gui/debugger/DebuggerWindow2.cpp @@ -64,6 +64,7 @@ wxBEGIN_EVENT_TABLE(DebuggerWindow2, wxFrame) EVT_COMMAND(wxID_ANY, wxEVT_RUN, DebuggerWindow2::OnRunProgram) EVT_COMMAND(wxID_ANY, wxEVT_NOTIFY_MODULE_LOADED, DebuggerWindow2::OnNotifyModuleLoaded) EVT_COMMAND(wxID_ANY, wxEVT_NOTIFY_MODULE_UNLOADED, DebuggerWindow2::OnNotifyModuleUnloaded) + EVT_COMMAND(wxID_ANY, wxEVT_DISASMCTRL_NOTIFY_GOTO_ADDRESS, DebuggerWindow2::OnDisasmCtrlGotoAddress) // file menu EVT_MENU(MENU_ID_FILE_EXIT, DebuggerWindow2::OnExit) // window @@ -383,6 +384,12 @@ void DebuggerWindow2::OnMoveIP(wxCommandEvent& event) m_disasm_ctrl->CenterOffset(ip); } +void DebuggerWindow2::OnDisasmCtrlGotoAddress(wxCommandEvent& event) +{ + uint32 address = static_cast(event.GetExtraLong()); + UpdateModuleLabel(address); +} + void DebuggerWindow2::OnParentMove(const wxPoint& main_position, const wxSize& main_size) { m_main_position = main_position; @@ -416,7 +423,7 @@ void DebuggerWindow2::OnNotifyModuleLoaded(wxCommandEvent& event) void DebuggerWindow2::OnNotifyModuleUnloaded(wxCommandEvent& event) { - RPLModule* module = (RPLModule*)event.GetClientData(); + RPLModule* module = (RPLModule*)event.GetClientData(); // todo - the RPL module is already unloaded at this point. Find a better way to handle this SaveModuleStorage(module, true); m_module_window->OnGameLoaded(); m_symbol_window->OnGameLoaded(); @@ -659,7 +666,7 @@ void DebuggerWindow2::CreateMenuBar() void DebuggerWindow2::UpdateModuleLabel(uint32 address) { - if(address == 0) + if (address == 0) address = m_disasm_ctrl->GetViewBaseAddress(); RPLModule* module = RPLLoader_FindModuleByCodeAddr(address); diff --git a/src/gui/debugger/DebuggerWindow2.h b/src/gui/debugger/DebuggerWindow2.h index 0ca44c44..145b5e1d 100644 --- a/src/gui/debugger/DebuggerWindow2.h +++ b/src/gui/debugger/DebuggerWindow2.h @@ -86,6 +86,8 @@ private: void OnMoveIP(wxCommandEvent& event); void OnNotifyModuleLoaded(wxCommandEvent& event); void OnNotifyModuleUnloaded(wxCommandEvent& event); + // events from DisasmCtrl + void OnDisasmCtrlGotoAddress(wxCommandEvent& event); void CreateMenuBar(); void UpdateModuleLabel(uint32 address = 0); diff --git a/src/gui/debugger/DisasmCtrl.cpp b/src/gui/debugger/DisasmCtrl.cpp index c2cd5722..2f38d55e 100644 --- a/src/gui/debugger/DisasmCtrl.cpp +++ b/src/gui/debugger/DisasmCtrl.cpp @@ -15,6 +15,8 @@ #include "Cafe/HW/Espresso/Debugger/DebugSymbolStorage.h" #include // for wxMemoryInputStream +wxDEFINE_EVENT(wxEVT_DISASMCTRL_NOTIFY_GOTO_ADDRESS, wxCommandEvent); + #define MAX_SYMBOL_LEN (120) #define COLOR_DEBUG_ACTIVE_BP 0xFFFFA0FF @@ -74,6 +76,8 @@ DisasmCtrl::DisasmCtrl(wxWindow* parent, const wxWindowID& id, const wxPoint& po auto tooltip_sizer = new wxBoxSizer(wxVERTICAL); tooltip_sizer->Add(new wxStaticText(m_tooltip_window, wxID_ANY, wxEmptyString), 0, wxALL, 5); m_tooltip_window->SetSizer(tooltip_sizer); + + Bind(wxEVT_MENU, &DisasmCtrl::OnContextMenuEntryClicked, this, IDContextMenu_ToggleBreakpoint, IDContextMenu_Last); } void DisasmCtrl::Init() @@ -662,29 +666,67 @@ void DisasmCtrl::CopyToClipboard(std::string text) { #endif } +static uint32 GetUnrelocatedAddress(MPTR address) +{ + RPLModule* rplModule = RPLLoader_FindModuleByCodeAddr(address); + if (!rplModule) + return 0; + if (address >= rplModule->regionMappingBase_text.GetMPTR() && address < (rplModule->regionMappingBase_text.GetMPTR() + rplModule->regionSize_text)) + return 0x02000000 + (address - rplModule->regionMappingBase_text.GetMPTR()); + return 0; +} + void DisasmCtrl::OnContextMenu(const wxPoint& position, uint32 line) { - wxPoint pos = position; auto optVirtualAddress = LinePixelPosToAddress(position.y - GetViewStart().y * m_line_height); if (!optVirtualAddress) return; MPTR virtualAddress = *optVirtualAddress; + m_contextMenuAddress = virtualAddress; + // show dialog + wxMenu menu; + menu.Append(IDContextMenu_ToggleBreakpoint, _("Toggle breakpoint")); + if(debugger_hasPatch(virtualAddress)) + menu.Append(IDContextMenu_RestoreOriginalInstructions, _("Restore original instructions")); + menu.AppendSeparator(); + menu.Append(IDContextMenu_CopyAddress, _("Copy address")); + uint32 unrelocatedAddress = GetUnrelocatedAddress(virtualAddress); + if (unrelocatedAddress && unrelocatedAddress != virtualAddress) + menu.Append(IDContextMenu_CopyUnrelocatedAddress, _("Copy virtual address (for IDA/Ghidra)")); + PopupMenu(&menu); +} - // address - if (pos.x <= OFFSET_ADDRESS + OFFSET_ADDRESS_RELATIVE) +void DisasmCtrl::OnContextMenuEntryClicked(wxCommandEvent& event) +{ + switch(event.GetId()) { - CopyToClipboard(fmt::format("{:#10x}", virtualAddress)); - return; - } - else if (pos.x <= OFFSET_ADDRESS + OFFSET_ADDRESS_RELATIVE + OFFSET_DISASSEMBLY) - { - // double-clicked on disassembly (operation and operand data) - return; - } - else - { - // comment - return; + case IDContextMenu_ToggleBreakpoint: + { + debugger_toggleExecuteBreakpoint(m_contextMenuAddress); + wxCommandEvent evt(wxEVT_BREAKPOINT_CHANGE); + wxPostEvent(this->m_parent, evt); + break; + } + case IDContextMenu_RestoreOriginalInstructions: + { + debugger_removePatch(m_contextMenuAddress); + wxCommandEvent evt(wxEVT_BREAKPOINT_CHANGE); // This also refreshes the disassembly view + wxPostEvent(this->m_parent, evt); + break; + } + case IDContextMenu_CopyAddress: + { + CopyToClipboard(fmt::format("{:#10x}", m_contextMenuAddress)); + break; + } + case IDContextMenu_CopyUnrelocatedAddress: + { + uint32 unrelocatedAddress = GetUnrelocatedAddress(m_contextMenuAddress); + CopyToClipboard(fmt::format("{:#10x}", unrelocatedAddress)); + break; + } + default: + UNREACHABLE; } } @@ -722,7 +764,6 @@ std::optional DisasmCtrl::LinePixelPosToAddress(sint32 posY) if (posY < 0) return std::nullopt; - sint32 lineIndex = posY / m_line_height; if (lineIndex >= m_lineToAddress.size()) return std::nullopt; @@ -751,8 +792,6 @@ void DisasmCtrl::CenterOffset(uint32 offset) m_active_line = line; RefreshLine(m_active_line); - - debug_printf("scroll to %x\n", debuggerState.debugSession.instructionPointer); } void DisasmCtrl::GoToAddressDialog() @@ -765,6 +804,10 @@ void DisasmCtrl::GoToAddressDialog() auto value = goto_dialog.GetValue().ToStdString(); std::transform(value.begin(), value.end(), value.begin(), tolower); + // trim any leading spaces + while(!value.empty() && value[0] == ' ') + value.erase(value.begin()); + debugger_addParserSymbols(parser); // try to parse expression as hex value first (it should interpret 1234 as 0x1234, not 1234) @@ -773,17 +816,24 @@ void DisasmCtrl::GoToAddressDialog() const auto result = (uint32)parser.Evaluate("0x"+value); m_lastGotoTarget = result; CenterOffset(result); + wxCommandEvent evt(wxEVT_DISASMCTRL_NOTIFY_GOTO_ADDRESS); + evt.SetExtraLong(static_cast(result)); + wxPostEvent(GetParent(), evt); } else if (parser.IsConstantExpression(value)) { const auto result = (uint32)parser.Evaluate(value); m_lastGotoTarget = result; CenterOffset(result); + wxCommandEvent evt(wxEVT_DISASMCTRL_NOTIFY_GOTO_ADDRESS); + evt.SetExtraLong(static_cast(result)); + wxPostEvent(GetParent(), evt); } else { try { + // if not a constant expression (i.e. relying on unknown variables), then evaluating will throw an exception with a detailed error message const auto _ = (uint32)parser.Evaluate(value); } catch (const std::exception& ex) diff --git a/src/gui/debugger/DisasmCtrl.h b/src/gui/debugger/DisasmCtrl.h index 993d5697..5a67e49a 100644 --- a/src/gui/debugger/DisasmCtrl.h +++ b/src/gui/debugger/DisasmCtrl.h @@ -1,9 +1,20 @@ #pragma once #include "gui/components/TextList.h" +wxDECLARE_EVENT(wxEVT_DISASMCTRL_NOTIFY_GOTO_ADDRESS, wxCommandEvent); // Notify parent that goto address operation completed. Event contains the address that was jumped to. + class DisasmCtrl : public TextList { + enum + { + IDContextMenu_ToggleBreakpoint = wxID_HIGHEST + 1, + IDContextMenu_RestoreOriginalInstructions, + IDContextMenu_CopyAddress, + IDContextMenu_CopyUnrelocatedAddress, + IDContextMenu_Last + }; public: + DisasmCtrl(wxWindow* parent, const wxWindowID& id, const wxPoint& pos, const wxSize& size, long style); void Init(); @@ -26,6 +37,7 @@ protected: void OnKeyPressed(sint32 key_code, const wxPoint& position) override; void OnMouseDClick(const wxPoint& position, uint32 line) override; void OnContextMenu(const wxPoint& position, uint32 line) override; + void OnContextMenuEntryClicked(wxCommandEvent& event); bool OnShowTooltip(const wxPoint& position, uint32 line) override; void ScrollWindow(int dx, int dy, const wxRect* prect) override; @@ -40,6 +52,7 @@ private: sint32 m_mouse_line, m_mouse_line_drawn; sint32 m_active_line; uint32 m_lastGotoTarget{}; + uint32 m_contextMenuAddress{}; // code region info uint32 currentCodeRegionStart; uint32 currentCodeRegionEnd; From b53b223ba9b45974cd80674d8de9c6e736e34ae9 Mon Sep 17 00:00:00 2001 From: Exzap <13877693+Exzap@users.noreply.github.com> Date: Mon, 16 Dec 2024 02:38:43 +0100 Subject: [PATCH 28/50] Vulkan: Use cache for sampler objects --- .../HW/Latte/Core/LattePerformanceMonitor.h | 1 + src/Cafe/HW/Latte/Renderer/Vulkan/VKRBase.h | 48 ++++++-- .../Latte/Renderer/Vulkan/VulkanRenderer.cpp | 110 +++++++++++++++++- .../Renderer/Vulkan/VulkanRendererCore.cpp | 8 +- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/src/Cafe/HW/Latte/Core/LattePerformanceMonitor.h b/src/Cafe/HW/Latte/Core/LattePerformanceMonitor.h index 713e094e..ac75bb1b 100644 --- a/src/Cafe/HW/Latte/Core/LattePerformanceMonitor.h +++ b/src/Cafe/HW/Latte/Core/LattePerformanceMonitor.h @@ -124,6 +124,7 @@ typedef struct LattePerfStatCounter numGraphicPipelines; LattePerfStatCounter numImages; LattePerfStatCounter numImageViews; + LattePerfStatCounter numSamplers; LattePerfStatCounter numRenderPass; LattePerfStatCounter numFramebuffer; diff --git a/src/Cafe/HW/Latte/Renderer/Vulkan/VKRBase.h b/src/Cafe/HW/Latte/Renderer/Vulkan/VKRBase.h index f79bd2dc..9c7e03f3 100644 --- a/src/Cafe/HW/Latte/Renderer/Vulkan/VKRBase.h +++ b/src/Cafe/HW/Latte/Renderer/Vulkan/VKRBase.h @@ -19,7 +19,7 @@ public: virtual ~VKRMoveableRefCounter() { - cemu_assert_debug(refCount == 0); + cemu_assert_debug(m_refCount == 0); // remove references #ifdef CEMU_DEBUG_ASSERT @@ -30,7 +30,11 @@ public: } #endif for (auto itr : refs) - itr->ref->refCount--; + { + itr->ref->m_refCount--; + if (itr->ref->m_refCount == 0) + itr->ref->RefCountReachedZero(); + } refs.clear(); delete selfRef; selfRef = nullptr; @@ -41,8 +45,8 @@ public: VKRMoveableRefCounter(VKRMoveableRefCounter&& rhs) noexcept { this->refs = std::move(rhs.refs); - this->refCount = rhs.refCount; - rhs.refCount = 0; + this->m_refCount = rhs.m_refCount; + rhs.m_refCount = 0; this->selfRef = rhs.selfRef; rhs.selfRef = nullptr; this->selfRef->ref = this; @@ -57,7 +61,7 @@ public: void addRef(VKRMoveableRefCounter* refTarget) { this->refs.emplace_back(refTarget->selfRef); - refTarget->refCount++; + refTarget->m_refCount++; #ifdef CEMU_DEBUG_ASSERT // add reverse ref @@ -68,16 +72,23 @@ public: // methods to directly increment/decrement ref counter (for situations where no external object is available) void incRef() { - this->refCount++; + m_refCount++; } void decRef() { - this->refCount--; + m_refCount--; + if (m_refCount == 0) + RefCountReachedZero(); } protected: - int refCount{}; + virtual void RefCountReachedZero() + { + // does nothing by default + } + + int m_refCount{}; private: VKRMoveableRefCounterRef* selfRef; std::vector refs; @@ -88,7 +99,7 @@ private: void moveObj(VKRMoveableRefCounter&& rhs) { this->refs = std::move(rhs.refs); - this->refCount = rhs.refCount; + this->m_refCount = rhs.m_refCount; this->selfRef = rhs.selfRef; this->selfRef->ref = this; } @@ -131,6 +142,25 @@ public: VkSampler m_textureDefaultSampler[2] = { VK_NULL_HANDLE, VK_NULL_HANDLE }; // relict from LatteTextureViewVk, get rid of it eventually }; + +class VKRObjectSampler : public VKRDestructibleObject +{ + public: + VKRObjectSampler(VkSamplerCreateInfo* samplerInfo); + ~VKRObjectSampler() override; + + static VKRObjectSampler* GetOrCreateSampler(VkSamplerCreateInfo* samplerInfo); + static void DestroyCache(); + + void RefCountReachedZero() override; // sampler objects are destroyed when not referenced anymore + + VkSampler GetSampler() const { return m_sampler; } + private: + static std::unordered_map s_samplerCache; + VkSampler m_sampler{ VK_NULL_HANDLE }; + uint64 m_hash; +}; + class VKRObjectRenderPass : public VKRDestructibleObject { public: diff --git a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp index 37432eeb..eae6daf2 100644 --- a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp +++ b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.cpp @@ -672,6 +672,8 @@ VulkanRenderer::~VulkanRenderer() if (m_commandPool != VK_NULL_HANDLE) vkDestroyCommandPool(m_logicalDevice, m_commandPool, nullptr); + VKRObjectSampler::DestroyCache(); + // destroy debug callback if (m_debugCallback) { @@ -3707,6 +3709,7 @@ void VulkanRenderer::AppendOverlayDebugInfo() ImGui::Text("DS StorageBuf %u", performanceMonitor.vk.numDescriptorStorageBuffers.get()); ImGui::Text("Images %u", performanceMonitor.vk.numImages.get()); ImGui::Text("ImageView %u", performanceMonitor.vk.numImageViews.get()); + ImGui::Text("ImageSampler %u", performanceMonitor.vk.numSamplers.get()); ImGui::Text("RenderPass %u", performanceMonitor.vk.numRenderPass.get()); ImGui::Text("Framebuffer %u", performanceMonitor.vk.numFramebuffer.get()); m_spinlockDestructionQueue.lock(); @@ -3752,7 +3755,7 @@ void VKRDestructibleObject::flagForCurrentCommandBuffer() bool VKRDestructibleObject::canDestroy() { - if (refCount > 0) + if (m_refCount > 0) return false; return VulkanRenderer::GetInstance()->HasCommandBufferFinished(m_lastCmdBufferId); } @@ -3793,6 +3796,111 @@ VKRObjectTextureView::~VKRObjectTextureView() performanceMonitor.vk.numImageViews.decrement(); } +static uint64 CalcHashSamplerCreateInfo(const VkSamplerCreateInfo& info) +{ + uint64 h = 0xcbf29ce484222325ULL; + auto fnvHashCombine = [](uint64_t &h, auto val) { + using T = decltype(val); + static_assert(sizeof(T) <= 8); + uint64_t val64 = 0; + std::memcpy(&val64, &val, sizeof(val)); + h ^= val64; + h *= 0x100000001b3ULL; + }; + cemu_assert_debug(info.sType == VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO); + fnvHashCombine(h, info.flags); + fnvHashCombine(h, info.magFilter); + fnvHashCombine(h, info.minFilter); + fnvHashCombine(h, info.mipmapMode); + fnvHashCombine(h, info.addressModeU); + fnvHashCombine(h, info.addressModeV); + fnvHashCombine(h, info.addressModeW); + fnvHashCombine(h, info.mipLodBias); + fnvHashCombine(h, info.anisotropyEnable); + if(info.anisotropyEnable == VK_TRUE) + fnvHashCombine(h, info.maxAnisotropy); + fnvHashCombine(h, info.compareEnable); + if(info.compareEnable == VK_TRUE) + fnvHashCombine(h, info.compareOp); + fnvHashCombine(h, info.minLod); + fnvHashCombine(h, info.maxLod); + fnvHashCombine(h, info.borderColor); + fnvHashCombine(h, info.unnormalizedCoordinates); + // handle custom border color + VkBaseOutStructure* ext = (VkBaseOutStructure*)info.pNext; + while(ext) + { + if(ext->sType == VK_STRUCTURE_TYPE_SAMPLER_CUSTOM_BORDER_COLOR_CREATE_INFO_EXT) + { + auto* extInfo = (VkSamplerCustomBorderColorCreateInfoEXT*)ext; + fnvHashCombine(h, extInfo->customBorderColor.uint32[0]); + fnvHashCombine(h, extInfo->customBorderColor.uint32[1]); + fnvHashCombine(h, extInfo->customBorderColor.uint32[2]); + fnvHashCombine(h, extInfo->customBorderColor.uint32[3]); + } + else + { + cemu_assert_unimplemented(); + } + ext = ext->pNext; + } + return h; +} + +std::unordered_map VKRObjectSampler::s_samplerCache; + +VKRObjectSampler::VKRObjectSampler(VkSamplerCreateInfo* samplerInfo) +{ + auto* vulkanRenderer = VulkanRenderer::GetInstance(); + if (vkCreateSampler(vulkanRenderer->GetLogicalDevice(), samplerInfo, nullptr, &m_sampler) != VK_SUCCESS) + vulkanRenderer->UnrecoverableError("Failed to create texture sampler"); + performanceMonitor.vk.numSamplers.increment(); + m_hash = CalcHashSamplerCreateInfo(*samplerInfo); +} + +VKRObjectSampler::~VKRObjectSampler() +{ + vkDestroySampler(VulkanRenderer::GetInstance()->GetLogicalDevice(), m_sampler, nullptr); + performanceMonitor.vk.numSamplers.decrement(); + // remove from cache + auto it = s_samplerCache.find(m_hash); + if(it != s_samplerCache.end()) + s_samplerCache.erase(it); +} + +void VKRObjectSampler::RefCountReachedZero() +{ + VulkanRenderer::GetInstance()->ReleaseDestructibleObject(this); +} + +VKRObjectSampler* VKRObjectSampler::GetOrCreateSampler(VkSamplerCreateInfo* samplerInfo) +{ + auto* vulkanRenderer = VulkanRenderer::GetInstance(); + uint64 hash = CalcHashSamplerCreateInfo(*samplerInfo); + auto it = s_samplerCache.find(hash); + if (it != s_samplerCache.end()) + { + auto* sampler = it->second; + return sampler; + } + auto* sampler = new VKRObjectSampler(samplerInfo); + s_samplerCache[hash] = sampler; + return sampler; +} + +void VKRObjectSampler::DestroyCache() +{ + // assuming all other objects which depend on vkSampler are destroyed, this cache should also have been emptied already + // but just to be sure lets still clear the cache + cemu_assert_debug(s_samplerCache.empty()); + for(auto& sampler : s_samplerCache) + { + cemu_assert_debug(sampler.second->m_refCount == 0); + delete sampler.second; + } + s_samplerCache.clear(); +} + VKRObjectRenderPass::VKRObjectRenderPass(AttachmentInfo_t& attachmentInfo, sint32 colorAttachmentCount) { // generate helper hash for pipeline state diff --git a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRendererCore.cpp b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRendererCore.cpp index 3a684072..dd39bd88 100644 --- a/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRendererCore.cpp +++ b/src/Cafe/HW/Latte/Renderer/Vulkan/VulkanRendererCore.cpp @@ -727,7 +727,6 @@ VkDescriptorSetInfo* VulkanRenderer::draw_getOrCreateDescriptorSet(PipelineInfo* VkSamplerCustomBorderColorCreateInfoEXT samplerCustomBorderColor{}; - VkSampler sampler; VkSamplerCreateInfo samplerInfo{}; samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; @@ -899,10 +898,9 @@ VkDescriptorSetInfo* VulkanRenderer::draw_getOrCreateDescriptorSet(PipelineInfo* } } } - - if (vkCreateSampler(m_logicalDevice, &samplerInfo, nullptr, &sampler) != VK_SUCCESS) - UnrecoverableError("Failed to create texture sampler"); - info.sampler = sampler; + VKRObjectSampler* samplerObj = VKRObjectSampler::GetOrCreateSampler(&samplerInfo); + vkObjDS->addRef(samplerObj); + info.sampler = samplerObj->GetSampler(); textureArray.emplace_back(info); } From 3738ccd2e676aa2c2cf2cdd5f08d5ac8dd221f57 Mon Sep 17 00:00:00 2001 From: goeiecool9999 <7033575+goeiecool9999@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:55:23 +0100 Subject: [PATCH 29/50] Play bootSound.btsnd while shaders/pipelines are compiling (#1047) --- src/Cafe/HW/Latte/Core/LatteShaderCache.cpp | 122 +++++++++++++++++++- src/Cafe/OS/libs/snd_core/ax_out.cpp | 79 ++----------- src/audio/CubebAPI.cpp | 2 +- src/audio/DirectSoundAPI.cpp | 2 +- src/audio/IAudioAPI.cpp | 42 +++++++ src/audio/IAudioAPI.h | 9 +- src/audio/XAudio27API.cpp | 17 ++- src/audio/XAudio27API.h | 2 + src/audio/XAudio2API.cpp | 13 ++- src/audio/XAudio2API.h | 2 + src/config/CemuConfig.cpp | 2 + src/config/CemuConfig.h | 16 ++- src/gui/GeneralSettings2.cpp | 56 ++------- src/gui/GeneralSettings2.h | 1 + src/util/CMakeLists.txt | 2 + src/util/bootSound/BootSoundReader.cpp | 51 ++++++++ src/util/bootSound/BootSoundReader.h | 20 ++++ 17 files changed, 310 insertions(+), 128 deletions(-) create mode 100644 src/util/bootSound/BootSoundReader.cpp create mode 100644 src/util/bootSound/BootSoundReader.h diff --git a/src/Cafe/HW/Latte/Core/LatteShaderCache.cpp b/src/Cafe/HW/Latte/Core/LatteShaderCache.cpp index 9576eb2e..9b24de45 100644 --- a/src/Cafe/HW/Latte/Core/LatteShaderCache.cpp +++ b/src/Cafe/HW/Latte/Core/LatteShaderCache.cpp @@ -25,6 +25,9 @@ #include "util/helpers/Serializer.h" #include +#include